Skip to main content

Gas optimization

Measure before optimising. forge test --gas-report or hardhat-gas-reporter tell you which functions actually cost money; intuition about EVM cost is frequently wrong.

Cache storage in memory

Storage access dominates. Current costs, roughly:

OperationGas
SLOAD — cold slot2100
SLOAD — warm slot100
SSTORE — zero to non-zero20000
SSTORE — non-zero to non-zero2900
MLOAD / MSTORE3

Reading a struct into memory once, rather than touching storage repeatedly, is the single highest-value change in most contracts:

Slot0 memory _slot0 = slot0; // one SLOAD sequence, then memory reads

EIP-2929 introduced the cold/warm distinction: the first access to a slot in a transaction costs 2100, later accesses 100. So the saving is largest for slots read many times, and a loop that reads the same storage variable each iteration is the classic case.

The same applies to array.length in a loop condition — cache it in a local before the loop.

Custom errors over revert strings

// Costs bytecode at deploy time and encodes the string on revert
require(balance >= amount, "Insufficient balance");

// 4-byte selector plus arguments
error InsufficientBalance(uint256 available, uint256 required);
if (balance < amount) revert InsufficientBalance(balance, amount);

Predictable contract addresses

With CREATE, a contract's address is derived from the creator's address and its nonce, so the address depends on deployment order.

CREATE2 — reached by supplying a salt — derives it from the creator, the salt, the creation bytecode and the constructor arguments instead. The address is then computable before deployment, and independent of ordering.

pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());

Counterparties can compute that address themselves rather than reading it from storage, which removes an SLOAD and, more importantly, a registry lookup:

function computeAddress(address factory, PoolKey memory key)
internal
pure
returns (address pool)
{
require(key.token0 < key.token1);
pool = address(
uint160(
uint256(
keccak256(
abi.encodePacked(
hex"ff",
factory,
keccak256(abi.encode(key.token0, key.token1, key.fee)),
POOL_INIT_CODE_HASH
)
)
)
)
);
}

Two things about that cast. Solidity 0.8 forbids converting uint256 straight to address, because the widths differ — you must narrow through uint160 first. Uniswap V3's original is written for 0.7, where the direct cast compiled, so copying it verbatim into a 0.8 project fails with a type error.

POOL_INIT_CODE_HASH is keccak256 of the creation bytecode. It changes with the compiler version and settings, so a hard-coded constant silently produces wrong addresses after a compiler bump. Derive it in a test and assert it matches.

Also note the ordering requirement — token0 < token1 — which canonicalises the pair so that (A, B) and (B, A) resolve to the same pool.

Handling inconsistent ERC-20 returns

The ERC-20 specification says transfer returns a bool, but the tokens in circulation do two different things: some return false on failure (as OpenZeppelin does), and others return nothing at all and revert instead. Same function, same arguments, different return data.

abi.encodeWithSelector with a low-level call accommodates both, because the caller decodes the response itself rather than relying on the compiler's expectation:

function safeTransfer(address token, address to, uint256 value) internal {
(bool success, bytes memory data) =
token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), "TF");
}

The condition reads as: the call did not revert, and either it returned nothing (a non-compliant token that reverts on failure) or it returned true.

One gap worth knowing: a call to an address with no code succeeds and returns empty data, so this passes against a non-existent token. Check token.code.length > 0 first where the address is not already trusted. OpenZeppelin's SafeERC20 does this and is the version to use in production.

Free computation via revert

revert(string reason) aborts the current call and returns data to the caller. Note the spelling — revert, not "reverse".

Reverting refunds only the unused gas; whatever was consumed before the revert is still charged. So aborting a transaction does not make the computation free.

It becomes free when the whole call is an eth_call rather than a transaction. Nothing is mined, so nothing is charged, and the revert data still comes back. Uniswap's quoter uses exactly this: it performs a real swap, then reverts, encoding the result in the revert data.

// Rather than executing the state change of a transaction, a node can be asked
// to pretend the call is not state-changing and return the result.
//
// Nothing is persisted, and over eth_call nothing is charged. Useful for
// determining whether a transaction would succeed, and what it would return.
function quoteExactInputSingle(
address tokenIn,
address tokenOut,
uint24 fee,
uint256 amountIn,
uint160 sqrtPriceLimitX96
) public override returns (uint256 amountOut) {
// ...
try
getPool(tokenIn, tokenOut, fee).swap(
// ...
)
{} catch (bytes memory reason) {
return parseRevertReason(reason);
}
}

function parseRevertReason(bytes memory reason) private pure returns (uint256) {
// ...
return abi.decode(reason, (uint256));
}

Clients invoke it through the static-call path so no gas is spent:

// ethers v6
const amountOut = await quoter.quoteExactInputSingle.staticCall(
tokenIn, tokenOut, fee, amountIn, 0
);

The function is not marked view, because it genuinely does modify state during execution — the revert is what discards the changes. That is why it must be called with staticCall explicitly rather than being resolved as a read.

Other reliable wins

  • immutable and constant. Values fixed at construction are written into the bytecode instead of storage, turning an SLOAD into a PUSH.
  • calldata over memory for external function arguments that are only read — it skips the copy entirely.
  • Pack storage variables so related values share a 32-byte slot. But only where they are written together; a partial write to a packed slot costs a read plus a write.
  • unchecked for arithmetic that provably cannot overflow, such as a loop counter bounded by an array length. Solidity 0.8 inserts overflow checks on every operation, and these are not free.
  • Short-circuit ordering. Put the cheapest condition first in a && chain; a memory comparison before a storage read avoids the SLOAD entirely when it fails.

Do not optimise by removing safety checks. A reentrancy guard or an input validation that costs a few hundred gas is not where savings should come from.