The Call Stack
When one contract calls another, the EVM creates a new call frame — a fresh execution environment with its own stack, memory, and program counter. The original frame is suspended until the sub-call returns. Working through this was where things started clicking for me.
sequenceDiagram
participant A as Contract A
participant EVM as EVM
participant B as Contract B
A->>EVM: CALL(B, gas, value, input)
EVM->>EVM: Create new frame<br/>(fresh stack, memory)
EVM->>B: Execute B's bytecode
B-->>EVM: RETURN(output)
EVM-->>A: Resume A<br/>(success=1, returndata=output)
Call types
| Opcode | msg.sender | Storage | State mods | Value transfer |
|---|---|---|---|---|
CALL | Caller's address | Callee's | Allowed | Yes |
DELEGATECALL | Original caller | Caller's | Allowed | No |
STATICCALL | Caller's address | Callee's | Forbidden | No |
CALLCODE | Caller's address | Caller's | Allowed | Yes (deprecated) |
CALL — standard call
This creates a new execution context. msg.sender is the calling contract, storage access is scoped to the callee, and ETH can be transferred.
Before: Stack [ ..., retSize, retOff, argSize, argOff, value, addr, gas ]
CALL: pop all 7 args → create new frame → execute callee bytecode
After: Stack [ ..., success ] ← 1 if callee returned, 0 if it reverted
DELEGATECALL — call with caller's context
This is the foundation of the proxy pattern. The callee's code runs in the caller's storage context. msg.sender and msg.value are preserved from the original call. I found this is how upgradeable contracts work — the proxy's storage is modified by the implementation's logic.
Before: Stack [ ..., retSize, retOff, argSize, argOff, addr, gas ]
DELEGATECALL: pop 6 args → new frame with CALLER's storage and msg.sender
After: Stack [ ..., success ] ← 1 or 0
When Contract A DELEGATECALLs Contract B, B's code writes to A's storage slots. If A and B have different storage layouts, B will overwrite A's variables at the wrong slots. This is the most common proxy bug.
STATICCALL — read-only call
A read-only call. Any opcode that modifies state (SSTORE, LOG0–LOG4, CREATE, CREATE2, CALL with non-zero value, and SELFDESTRUCT) will revert the sub-call. I use this for view functions and safe external reads.
Before: Stack [ ..., retSize, retOff, argSize, argOff, addr, gas ]
STATICCALL: pop 6 args → new frame with is_static=true
After: Stack [ ..., success ] ← reverts if callee tries to write state
The 63/64 Rule (EIP-150)
Before EIP-150, a contract could forward all its remaining gas to a sub-call. This enabled gas-exhaustion attacks: a malicious contract could create deeply nested calls, each consuming almost all gas, leaving the caller with nothing. Once I understood this, the fix made immediate sense.
EIP-150 limits forwarded gas to 63/64 of remaining gas:
#![allow(unused)] fn main() { let max_forward = gas_remaining - gas_remaining / 64; let actual = gas_requested.min(max_forward); }
This ensures the caller always retains at least 1/64 of its gas after a sub-call, which makes gas accounting predictable.
Depth limit
The call depth is limited to 1024. The 1025th nested call fails immediately. Combined with the 63/64 rule, I noticed this makes deep call tree attacks doubly impractical.
Call frame lifecycle
- Push frame: save current state, create new frame with fresh stack/memory
- Execute: run the callee's bytecode in the new frame
- Pop frame: restore caller's state, copy return data
- Result: caller reads success/failure and return data
If the sub-call reverts, the caller's state is untouched — only the sub-call's changes are discarded. Implementing this correctly took me a couple of iterations.
Precompiles
Addresses 0x1 through 0x9 are precompiled contracts — built-in functions with fixed gas costs:
| Address | Function | Gas |
|---|---|---|
| 0x1 | ecrecover (signature recovery) | 3000 |
| 0x2 | SHA-256 | 60 + 12/word |
| 0x3 | RIPEMD-160 | 600 + 120/word |
| 0x4 | identity (memcpy) | 15 + 3/word |
Precompiles look like regular CALLs but are handled specially by the interpreter — no bytecode is executed. I wired them in as a dispatch table in the host layer.
Exercises
- 5.1 — Construct CallFrame for each call type, verify fields
- 5.2 — Verify MAX_CALL_DEPTH constant
- 5.4 — Gas forwarding via the 63/64 rule
Run: cargo test call