Lesson 2 — Gas accounting in depth, and call frames
Question
The EVM measures and charges every operation in gas. A tx has a gas limit; exceed it and execution halts. When a contract calls another contract (CALL / DELEGATECALL / STATICCALL), a call frame is pushed. Understand the cost model and the parent-child frame relationship.
Principle (minimum model)
- Intrinsic gas. The flat tx cost (21K base + 4/16 gas per calldata byte) + access-list pre-purchase. Charged before bytecode runs.
- Per-opcode gas. Each opcode has a fixed or dynamic cost. ADD = 3, SLOAD cold = 2100, SSTORE new = 20K. EIP-2929 introduced the cold/warm distinction.
- EIP-2929 cold/warm. First access to a slot = cold (2100 gas); subsequent = warm (100 gas). Reflects the actual cost of state access.
- Gas refunds. Resetting an SSTORE slot to 0 earns a refund. Capped at 1/5 of gas_used at tx end.
- Call frames. CALL / DELEGATECALL / STATICCALL push a new frame. Parent passes gas to child (63/64 rule). Child's return value lands on parent's stack.
- CALL vs DELEGATECALL vs STATICCALL. CALL = new context (msg.sender changes); DELEGATECALL = same context (library pattern, used by proxies); STATICCALL = read-only (state changes forbidden).
- Out-of-gas in a child frame. Child REVERTs; parent continues (its CALL returns 0 = failure).
- msg.sender vs tx.origin. msg.sender = the immediate caller (per frame); tx.origin = the EOA that started the tx (constant throughout). Often the basis of security bugs.
Worked example + steps
Gas accounting in depth, and call frames
You know "gas costs money." This lesson goes one level deeper — into where gas actually goes, and how a single transaction can trigger a tree of nested call frames with separate context.
Both topics are assumed knowledge in Intermediate lessons on custom opcodes, precompiles, and ExEx.
Gas — three categories
Every transaction's gas budget is consumed in three ways:
1. Intrinsic gas — paid before any opcode runs
Just for being a transaction:
- 21,000 gas flat fee per tx
- Plus 4 gas per zero byte of calldata
- Plus 16 gas per non-zero byte of calldata
- Plus 32,000 gas if it's a contract-creation tx
This is paid before the first opcode executes. If your tx doesn't have enough gas to even cover intrinsic, it doesn't make it into the block.
2. Per-opcode gas — fixed and dynamic
Most opcodes have a fixed cost: ADD = 3, MUL = 5, JUMP = 8, MLOAD = 3.
A few have dynamic costs that depend on context:
| Opcode | Why dynamic |
|---|---|
SLOAD | Cold (2100) vs warm (100) — depends on whether the slot was touched in this tx |
SSTORE | Cold-write, warm-write, write-from-zero, write-to-zero, all priced differently |
CALL / CALLCODE / DELEGATECALL / STATICCALL | Depends on cold/warm of the called account, value transferred, account creation |
EXP | More gas for larger exponents |
KECCAK256 | More gas for larger inputs |
CALLDATACOPY / CODECOPY / MCOPY | More gas for larger copies |
| Memory-touching opcodes | Pay memory expansion gas if they grow memory |
3. Refunds — gas you get back
Some operations refund gas:
- SSTORE that clears a slot (writes zero to a non-zero slot): refunds 4800 gas
- SELFDESTRUCT (legacy, mostly removed in EIP-6780): refunds 24,000 gas
Refunds are capped at gas_used / 5 (EIP-3529). You can't game the system by SSTORE-clearing a thousand slots in a tiny tx.
OOG vs revert — they look similar, they're not
Both halt execution. The difference matters:
| Out-Of-Gas | REVERT | |
|---|---|---|
| Gas remaining | All consumed | Returned to caller |
| State changes | All reverted | All reverted |
| Returndata | Empty | Data from REVERT operand |
| Caller sees | "call failed, no data" | "call failed, with data" |
In a Solidity require(x, "msg"), the EVM emits REVERT with the encoded "msg" as returndata. In an arithmetic overflow under Solidity ≥ 0.8, the same — REVERT with a Panic(uint256) error code.
OOG is different. It happens when a frame runs out of gas mid-execution. The frame loses everything (state + remaining gas), and the caller sees a generic failure.
When Intermediate lesson 1 shows you Revm's PrecompileHalt::OutOfGas vs other halts, this distinction is what's being modeled.
Call frames — the EVM's call stack
A transaction starts with one frame — the top-level call from the EOA to a contract (or contract creation).
When that frame executes CALL (or DELEGATECALL / STATICCALL / CREATE), it spawns a new frame. The new frame has its own:
- Stack (fresh)
- Memory (fresh, empty)
- Calldata (the input bytes from the call opcode)
- PC (starts at 0 of the called contract's code)
- Gas budget (subset of the caller's remaining gas)
When the inner frame halts, control returns to the outer frame, which sees:
- The success/failure flag
- The returndata buffer
- The remaining gas (added back to the outer frame's budget)
This nesting can go up to 1024 levels deep before the call stack overflows.
The four call-style opcodes — who owns what
This is the table that confuses Solidity developers most:
| Opcode | address(this) inside | msg.sender inside | Storage you touch | Code you run |
|---|---|---|---|---|
CALL | the called contract | the caller | the called contract's storage | the called contract's code |
STATICCALL | same as CALL | same as CALL | same — but writes will revert | same as CALL |
DELEGATECALL | the caller | the caller's caller | the caller's storage | the called contract's code |
CALLCODE | (deprecated) | (deprecated) | (deprecated) | (deprecated) |
Three of these are alive in modern Ethereum. Read the table twice. The ones that matter for understanding bugs:
CALL
Most common. A.foo() from contract X: a new frame runs A's code, sees A's storage, has msg.sender = X. Anything A writes goes to A's storage. Storage and code align.
STATICCALL
Same shape as CALL but the inner frame is forbidden from writing state (SSTORE, LOG, CREATE, SELFDESTRUCT, CALL with value all halt with revert). Used for "view" calls — Solidity emits STATICCALL when you call a view function.
DELEGATECALL
The dangerous one. A's code runs in X's context. That means: address(this) is X. msg.sender is X's caller. Storage reads/writes go to X's storage, not A's. Code is read from A.
This is how proxy patterns work (UUPS, Transparent Proxy, Diamond): the proxy contract (X) DELEGATECALLs into an implementation contract (A), so X's storage gets modified by A's logic.
It's also the source of many high-profile hacks — Wormhole, Parity multisig, etc. — because if A's storage layout doesn't match X's, writes corrupt X's state in unexpected slots.
When Intermediate lesson 6 (ExEx) shows you "this transaction touched these storage slots across these accounts," knowing CALL vs DELEGATECALL is what lets you make sense of it.
A real call graph
EOA sends tx to contract X
│
├── X's code runs (frame 1)
│ │
│ ├── X.transfer() → CALL into contract Y
│ │ │
│ │ └── Y's code runs (frame 2, fresh memory/stack)
│ │ │
│ │ └── Y reads from Y.storage (using SLOAD)
│ │
│ ├── X uses STATICCALL into contract Z (a view function)
│ │ │
│ │ └── Z's code runs (frame 3, fresh, write-locked)
│ │
│ └── X DELEGATECALLs into contract W (an implementation)
│ │
│ └── W's code runs (frame 4) — but writes go to X's storage!
│
└── tx completes; receipts emitted with logs from all frames
Every frame has its own memory/stack. Returndata flows back up at each return. Gas accounting tracks per-frame consumption.
Reading list
- Open evm.codes for CALL, DELEGATECALL, STATICCALL. Read the parameters (gas, address, value, argsOffset, argsSize, retOffset, retSize) — they're identical except DELEGATECALL/STATICCALL drop
value. - In Foundry, deploy a tiny proxy + implementation pair. Use
forge test -vvvvand watch the call trace — every CALL and DELEGATECALL is shown, with frame nesting visible. - Look up Wormhole hack post-mortem — the bug was DELEGATECALL semantics misunderstood. Reading the post-mortem with this lesson fresh makes the exploit obvious.
What you should walk away with
- Gas comes in three shapes: intrinsic (per-tx), per-opcode (fixed and dynamic), and refunds (capped).
- OOG and REVERT look similar but differ on returndata and gas remaining.
- A tx is a tree of call frames, each with its own stack/memory/PC/gas.
- DELEGATECALL is the call style that runs the callee's code in the caller's context — the foundation of proxy patterns and the source of many bugs.
When Intermediate lesson 5 (custom precompiles) talks about the gas pricing model, or lesson 6 (ExEx) talks about reorganizing across multiple committed transactions, you'll have the call-frame and gas model in your head — not as abstract concepts, but as concrete machinery.
Summary (3 lines)
- Gas = intrinsic (21K + calldata) + per-opcode (cold/warm) + refunds (capped at gas_used/5). Charged before and during execution.
- Call frames: CALL / DELEGATECALL / STATICCALL push frames; 63/64 rule for gas to child; CALL = new context, DELEGATECALL = same context (library), STATICCALL = read-only.
- msg.sender = per-frame; tx.origin = per-tx EOA. Out-of-gas in child reverts only the child. Next module: block-level Ethereum (blocks, receipts, reorgs).