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:
| Operation | Gas |
|---|---|
SLOAD — cold slot | 2100 |
SLOAD — warm slot | 100 |
SSTORE — zero to non-zero | 20000 |
SSTORE — non-zero to non-zero | 2900 |
MLOAD / MSTORE | 3 |
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
immutableandconstant. Values fixed at construction are written into the bytecode instead of storage, turning anSLOADinto aPUSH.calldataovermemoryfor 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.
uncheckedfor 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 theSLOADentirely 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.