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

Opcodemsg.senderStorageState modsValue transfer
CALLCaller's addressCallee'sAllowedYes
DELEGATECALLOriginal callerCaller'sAllowedNo
STATICCALLCaller's addressCallee'sForbiddenNo
CALLCODECaller's addressCaller'sAllowedYes (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

Pitfall: DELEGATECALL Storage Collisions

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, LOG0LOG4, 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

  1. Push frame: save current state, create new frame with fresh stack/memory
  2. Execute: run the callee's bytecode in the new frame
  3. Pop frame: restore caller's state, copy return data
  4. 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:

AddressFunctionGas
0x1ecrecover (signature recovery)3000
0x2SHA-25660 + 12/word
0x3RIPEMD-160600 + 120/word
0x4identity (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