Skip to content
Learn · Advanced

Assembly and gas golfing

Practical techniques to reduce gas on hot paths — storage packing, custom errors, calldata optimization, and when inline assembly is justified.

Advanced
17 min read

Measure before optimizing

Profile with gas reports (Hardhat gas-reporter, Foundry gas snapshots). Optimize functions called thousands of times per block — AMM swaps, claims, transfers. Admin-only setters rarely matter. OpenZeppelin defaults trade gas for safety; fork and override only with tests.

  • Compare before/after with same test vectors.
  • L2 gas profiles differ — L1 data cost dominates some rollup txs.
  • Premature assembly increases audit cost and bug risk.

Pack storage

Reorder struct fields to minimize slots. A bool + uint248 fits one slot. Cold SSTORE (zero to nonzero) costs ~20k; warm ~5k. Keeping related fields in one slot amortizes writes when updating together.

  • ERC721Enumerable is gas-heavy — avoid on-chain enumeration at scale.
  • Mappings cannot pack with adjacent scalars — plan layout early.
  • Upgradeable contracts: packing changes slot indices — document before shipping v1.

Custom errors

Solidity 0.8.4+ custom errors revert with a 4-byte selector plus ABI-encoded args — far cheaper than long revert strings. OpenZeppelin increasingly uses errors in core contracts. Users and indexers decode selectors from ABI.

  • error InsufficientBalance(uint256 available, uint256 required);
  • replace require(x, "long message") with if (!x) revert MyError();
  • NatSpec on errors documents them for integrators.
Errors.solsolidity
error Unauthorized(address caller);
error DeadlineExpired(uint256 deadline, uint256 current);

function claim(uint256 deadline) external {
    if (block.timestamp > deadline) {
        revert DeadlineExpired(deadline, block.timestamp);
    }
}

Calldata and immutables

External function array/string args live in calldata — cheaper than memory copies. Mark deployment-time constants immutable instead of storage when values never change. CLONE patterns (EIP-1167 minimal proxies) reduce deploy cost for many identical instances.

  • internal/private helpers can use memory — external user entry uses calldata.
  • immutable is set in constructor — no SLOAD at runtime.
  • constant keccak literals are computed at compile time.

Merkle proofs on the hot path

Airdrops verify Merkle proofs in claim(). OpenZeppelin MerkleProof library is readable; assembly implementations squeeze verify for large drops. Ensure leaf encoding matches off-chain tree generation — Ethereum Toolset Merkle tools align with common OZ patterns.

  • Verify calldata proof length — malformed proofs should revert cheaply.
  • Bitmap claimed flags per index — O(1) storage vs unbounded arrays.
  • Batch claims amortize base tx cost across users off-chain (relayer) not always on-chain.

Inline assembly — when and cautions

Yul assembly for tight loops (ECDSA recover, efficient hashing, optimized transfers) can halve gas. Costs: readability, audit surface, portability across compiler versions. Follow OpenZeppelin patterns — e.g. SafeERC20 low-level calls — instead of inventing raw call glue.

  • memory-safe annotation and scratch space rules matter in 0.8.20+.
  • Incorrect mload/mstore offsets corrupt memory — subtle bugs.
  • Prefer Solidity 0.8 checked math unless you prove unchecked safe.

Protocol-level patterns

Batch operations, permit instead of approve txs, ERC1155 batch transfers, and minimal proxy clones are product-level gas wins users feel. Document gas ranges in UI. For NFT drops, consecutive mint batching (OZ) beats individual safeMints for large supplies.

  • Meta-transactions shift gas to relayer — user experience win, not always lower total gas.
  • L2 deployment is often better UX optimization than micro-optimizing L1 opcodes.
  • Do not golf away ReentrancyGuard on withdrawals to save hundreds of gas.