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.
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.
