Deep EVM #3: Understanding Gas — Why Your Contract Costs What It Costs
Engineering Team
Gas Is Not an Abstraction
Developers often treat gas as a vague “cost” metric. In reality, gas is a precisely defined resource accounting system with exact formulas. Every opcode, every byte of calldata, every storage access has a deterministic gas cost defined in the Yellow Paper and subsequent EIPs.
Understanding these costs transforms gas optimization from guesswork into engineering.
Intrinsic Gas: The Baseline
Before your contract code executes a single opcode, the transaction has already consumed gas. This is the intrinsic gas:
intrinsic_gas = 21000 // base transaction cost
+ 16 * nonzero_calldata_bytes // per nonzero byte
+ 4 * zero_calldata_bytes // per zero byte
+ access_list_cost // if EIP-2930 access list
+ 32000 (if contract creation) // CREATE transactions only
For a simple ERC-20 transfer(address,uint256) call:
- Base: 21000 gas
- Function selector (4 bytes, likely nonzero): 4 * 16 = 64 gas
- Address parameter (32 bytes, ~12 nonzero): 12 * 16 + 20 * 4 = 272 gas
- Amount parameter (32 bytes, ~8 nonzero): 8 * 16 + 24 * 4 = 224 gas
- Total intrinsic: ~21,560 gas — before any opcode executes.
This is why “zero-calldata” patterns matter for MEV bots. If your bot can encode information in zero bytes instead of nonzero bytes, you save 12 gas per byte. For a backrun transaction with 200 bytes of calldata, optimizing zero bytes can save 2,400 gas.
EIP-2929: The Access List Revolution
Before EIP-2929 (Berlin, April 2021), SLOAD always cost 800 gas and address access cost 700 gas. EIP-2929 changed everything by introducing per-transaction access sets.
How It Works
- At transaction start, only the sender and recipient addresses are in the “warm” set.
- The first time you touch an address (BALANCE, EXTCODESIZE, CALL, etc.) or storage slot (SLOAD, SSTORE), it is “cold” — you pay the cold surcharge.
- All subsequent accesses to the same address/slot within the transaction are “warm” — you pay the warm price.
Cold vs Warm Gas Costs
| Opcode | Cold | Warm | Savings |
|---|---|---|---|
| SLOAD | 2100 | 100 | 2000 |
| SSTORE | +2100 cold surcharge | base cost only | 2100 |
| BALANCE | 2600 | 100 | 2500 |
| EXTCODESIZE | 2600 | 100 | 2500 |
| EXTCODECOPY | 2600 | 100 | 2500 |
| EXTCODEHASH | 2600 | 100 | 2500 |
| CALL | 2600 | 100 | 2500 |
| STATICCALL | 2600 | 100 | 2500 |
| DELEGATECALL | 2600 | 100 | 2500 |
EIP-2930 Access Lists
You can pre-declare which addresses and storage slots your transaction will access by including an access list. This “pre-warms” them, paying the cold cost upfront but at a discount:
access_list_cost = 2400 per address + 1900 per storage key
Compare: cold SLOAD costs 2100 gas, but pre-warming via access list costs 1900. You save 200 gas per slot. For transactions that access many storage slots (like DEX swaps reading multiple pool variables), access lists provide meaningful savings.
// Transaction with access list
{
"to": "0xUniswapV3Pool",
"accessList": [
{
"address": "0xUniswapV3Pool",
"storageKeys": [
"0x0000...0000", // slot0 (sqrtPriceX96, tick, etc.)
"0x0000...0004", // liquidity
"0x0000...0003" // feeGrowthGlobal
]
}
]
}
SSTORE: The Most Complex Opcode
SSTORE gas cost is governed by EIP-2200 (Istanbul) and modified by EIP-2929 and EIP-3529. The cost depends on three factors: the original value (at transaction start), the current value, and the new value.
SSTORE Gas Table
| Original | Current | New | Gas Cost | Refund |
|---|---|---|---|---|
| 0 | 0 | nonzero | 22100 (cold) / 20000 (warm) | 0 |
| nonzero | same | different nonzero | 5000 (cold) / 2900 (warm) | 0 |
| nonzero | same | 0 | 5000 (cold) / 2900 (warm) | 4800 |
| nonzero | different | original | 200 | restore refund* |
| 0 | nonzero | 0 | 200 | 19900 |
*Restore refunds vary based on whether the slot was originally zero or nonzero.
The key insight: setting a zero slot to nonzero costs 20000 gas (writing a new state trie leaf), while modifying an existing nonzero slot costs 2900 gas (warm). This 7x difference is why:
- Initializing mappings for new users is expensive (first transfer to a new address)
- Using
1instead of0as the “unset” value for reentrancy locks saves 17100 gas - Deleting storage (setting to 0) gives a refund, but not enough to make “create then delete” patterns free
Gas Refunds After EIP-3529
EIP-3529 (London, August 2021) dramatically reduced gas refunds:
- Maximum refund capped at 20% of total gas used (was 50%)
- SELFDESTRUCT refund removed entirely
- Only SSTORE nonzero-to-zero refund remains: 4800 gas
This killed gas tokens (CHI, GST2) which exploited the old 50% refund by creating and destroying contracts/storage to “bank” gas refunds. With a 20% cap, the math no longer works.
Optimization Patterns That Actually Matter
1. Cache Storage Reads in Memory
Every SLOAD costs 100-2100 gas. If you read the same slot multiple times, cache it:
// BAD: 3 SLOADs (300+ gas)
function withdraw() external {
require(balances[msg.sender] > 0);
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
}
// GOOD: 1 SLOAD + 1 SSTORE (but Solidity often optimizes this)
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0;
}
2. Pack Storage Variables
Multiple variables in one slot = one SLOAD instead of many:
// 3 slots, 3 SLOADs = 6300 gas (cold)
uint256 price; // slot 0
uint256 amount; // slot 1
uint256 timestamp; // slot 2
// 1 slot, 1 SLOAD = 2100 gas (cold)
uint128 price; // slot 0, lower 128 bits
uint64 amount; // slot 0, next 64 bits
uint64 timestamp; // slot 0, upper 64 bits
3. Use Calldata Instead of Memory for External Parameters
// BAD: copies array to memory
function processOrders(Order[] memory orders) external { ... }
// GOOD: reads directly from calldata
function processOrders(Order[] calldata orders) external { ... }
4. Short-Circuit Expensive Operations
// BAD: always reads storage
function isAllowed(address user) public view returns (bool) {
return allowlist[user] || owner() == user;
}
// GOOD: check cheap comparison first
function isAllowed(address user) public view returns (bool) {
if (owner() == user) return true; // CALLER is 2 gas
return allowlist[user]; // SLOAD only if needed
}
5. Use Unchecked Arithmetic When Safe
// Solidity 0.8+ adds overflow checks: ~40 gas overhead per operation
for (uint256 i = 0; i < length; i++) { ... }
// Unchecked: saves ~40 gas per iteration
for (uint256 i = 0; i < length;) {
// ... loop body ...
unchecked { ++i; }
}
6. Use Custom Errors Instead of Require Strings
// BAD: stores string in bytecode, expensive revert data
require(amount > 0, "Amount must be positive");
// GOOD: 4-byte selector, no string storage
error InvalidAmount();
if (amount == 0) revert InvalidAmount();
Custom errors save both deployment gas (less bytecode) and runtime gas (4 bytes vs 64+ bytes of revert data).
7. Minimize Calldata Zeros vs Nonzeros
For MEV bots, this is critical. Structure your calldata to maximize zero bytes:
// 16 gas per nonzero byte vs 4 gas per zero byte
// If you can choose how to encode your data, prefer representations
// that contain more zero bytes.
// Example: encode amounts in wei rather than custom units when
// the wei representation has trailing zeros.
Real-World Gas Breakdown: Uniswap V3 Swap
Let us trace the gas consumption of a typical Uniswap V3 exact-input swap:
| Component | Gas |
|---|---|
| Intrinsic (21000 + calldata) | ~21,500 |
| Function dispatch | ~100 |
| Input validation | ~200 |
| Pool state reads (slot0, liquidity) | ~4,200 (cold) |
| Price computation (tick math) | ~5,000 |
| Balance updates (2 SSTOREs) | ~10,000 |
| Token transfers (2 external calls) | ~15,000 |
| Event emission | ~1,500 |
| Total | ~57,500 |
Nearly 60% of the gas goes to storage operations and external calls. The actual math (price computation, tick crossing) is a small fraction.
Conclusion
Gas optimization is not about micro-optimizing arithmetic opcodes — it is about minimizing storage access and external calls. The patterns that save the most gas are architectural: storage packing, caching, calldata usage, and access list optimization. In the next article, we turn to security: how the EVM’s execution model creates — and prevents — vulnerabilities like reentrancy.