FABRKNT
Reading the Stack — Bridge to Intermediate
EVM at the bytes level
Lesson 3 of 10·CONTENT12 min25 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Reading the Stack — Bridge to Intermediate
Lesson role
CONTENT
Sequence
3 / 10

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:

OpcodeWhy dynamic
SLOADCold (2100) vs warm (100) — depends on whether the slot was touched in this tx
SSTORECold-write, warm-write, write-from-zero, write-to-zero, all priced differently
CALL / CALLCODE / DELEGATECALL / STATICCALLDepends on cold/warm of the called account, value transferred, account creation
EXPMore gas for larger exponents
KECCAK256More gas for larger inputs
CALLDATACOPY / CODECOPY / MCOPYMore gas for larger copies
Memory-touching opcodesPay 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-GasREVERT
Gas remainingAll consumedReturned to caller
State changesAll revertedAll reverted
ReturndataEmptyData 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:

Opcodeaddress(this) insidemsg.sender insideStorage you touchCode you run
CALLthe called contractthe callerthe called contract's storagethe called contract's code
STATICCALLsame as CALLsame as CALLsame — but writes will revertsame as CALL
DELEGATECALLthe callerthe caller's callerthe caller's storagethe 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

  1. 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.
  2. In Foundry, deploy a tiny proxy + implementation pair. Use forge test -vvvv and watch the call trace — every CALL and DELEGATECALL is shown, with frame nesting visible.
  3. 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).