Execution Context and the CALL Family
The last four pages built a single machine: a stack of 256-bit words working over four kinds of memory, with every opcode priced in gas. But so far that machine has run exactly one program in isolation. Real Ethereum is not one program — it is thousands of contracts calling each other, a token contract asking a price oracle, a wallet delegating to a shared library, a router hopping through five pools in one transaction.
This page is about that composition. When contract A invokes contract B, what travels with the call? Whose code runs, whose storage does it touch, and who does B think called it? The answer is a small bundle of state called the execution context, and the whole security model of smart contracts comes down to which parts of that context each kind of call keeps and which it swaps. Get the context wrong and you do not get a bug — you get a drained treasury.
Every execution runs in a context
Section titled “Every execution runs in a context”An EVM execution is never “just code.” Each running frame carries a context: a handful of values that answer “where am I, on whose behalf, and how much can I spend?”
Execution context (one frame)┌───────────────────────────────────────────────────────────┐│ code the bytecode currently executing ││ storage the account whose slots SLOAD/SSTORE hit ││ address "self" — the account this code runs as ││ msg.sender who invoked this frame (the immediate caller) ││ msg.value native ETH (wei) sent along with the call ││ calldata the read-only input bytes for this frame ││ gas the budget forwarded into this frame │└───────────────────────────────────────────────────────────┘Three of these — code, storage, and address — answer what runs and against what. Two more — msg.sender and msg.value — answer on whose behalf and with what money. The last two, calldata and gas, are the input and the fuel. When a top-level transaction first enters the EVM, msg.sender is the externally-owned account that signed it and address is the contract it targeted. The interesting question is what happens to these fields when that contract calls another contract.
The three call opcodes differ in exactly one way: which context fields they carry over and which they replace. Learn that table and you have learned the CALL family.
CALL — invoke another contract in its own context
Section titled “CALL — invoke another contract in its own context”CALL is the ordinary case, the one your mental model probably already assumes. Contract A calls contract B. B runs its own code against its own storage, and from B’s point of view msg.sender is A. Value can ride along; a fresh slice of gas is forwarded.
A executes: CALL(gas, B, value, argsOffset, argsLen, retOffset, retLen)
A's frame B's frame (new) ┌──────────────────┐ ┌──────────────────────┐ │ code = A │ CALL → │ code = B │ │ storage = A │─────────────▶│ storage = B │ │ address = A │ │ address = B │ │ msg.sender = ... │ │ msg.sender = A │ ← A is the caller │ │◀─────────────│ msg.value = value │ └──────────────────┘ returns └──────────────────────┘Everything about B’s world is B’s own. If B does SSTORE, it writes B’s storage. If B calls msg.sender, it gets A. This is the boundary you want almost always: contracts are separate accounts with separate state, and calling one does not let it reach into another’s slots.
Note the nesting. B, while running, can itself CALL a contract C, which becomes yet another frame with msg.sender = B. msg.sender is always the immediate caller — never the original transaction signer several frames up. Confusing “the immediate caller” with “the origin of the transaction” is one of the oldest classes of authorization bug in Solidity.
Grounding it in ethmini
Section titled “Grounding it in ethmini”Our companion crate models a call at its simplest. A Call transaction names a target, a value, and a gas limit:
pub enum TxKind { Transfer { to: Address, value: u128 }, Deploy { code: Vec<u8>, value: u128 }, Call { to: Address, value: u128, gas_limit: u64 },}When WorldState::apply executes a Call, it does the two things a CALL does. First it moves the value. Then it looks at the target: if the target has no code, the call is just the value transfer and it is done. If the target is a contract, it runs that contract’s bytecode against a clone of the contract’s own storage, and commits the result only if the run halts cleanly:
TxKind::Call { to, value, gas_limit } => { if *value > 0 { self.transfer_value(tx.from, *to, *value); }
// A call to a codeless account is just a value transfer. let target = self.account_mut(*to); if !target.is_contract() { return Ok(Receipt { status: ExecStatus::Success, /* .. */ }); }
// Run the callee's code against a COPY of the callee's storage. let code = target.code.clone(); let storage = target.storage.clone(); match vm::run(&code, &storage, *gas_limit) { Ok(exec) => { self.account_mut(*to).storage = exec.storage; // commit on success Ok(Receipt { status: ExecStatus::Success, /* .. */ }) } Err(reason) => Err(Reverted { gas_used: *gas_limit, reason }), }}Two lines carry the whole lesson. let storage = target.storage.clone(); is who-owns-the-storage made literal: the callee runs against its own slots. And the Ok/Err split is the revert rule — a clean halt commits the new storage, a thrown call discards the clone and the caller’s world is untouched. That is the same all-or-nothing structure the gas page relied on, now one level up: not “one opcode reverts” but “one call frame reverts.”
DELEGATECALL — run the callee’s code, keep the caller’s context
Section titled “DELEGATECALL — run the callee’s code, keep the caller’s context”Now the sharp one. DELEGATECALL runs contract B’s code, but does not switch storage, address, msg.sender, or msg.value. B’s bytecode executes as if it were A’s own code, glued in at runtime. Every SSTORE it does writes A’s storage. address is still A. msg.sender is whoever called A — it passes straight through, unchanged.
A executes: DELEGATECALL(gas, B, argsOffset, argsLen, retOffset, retLen)
A's frame "B's code, A's context" ┌──────────────────┐ ┌──────────────────────────┐ │ code = A │ DELEG → │ code = B (borrowed) │ │ storage = A │─────────────▶│ storage = A (still A's!) │ │ address = A │ │ address = A (still A) │ │ msg.sender = X │ │ msg.sender = X (unchanged) │ │ msg.value = v │ │ msg.value = v (unchanged) │ └──────────────────┘ └──────────────────────────┘Why would anyone want this? Two reasons, and they are the backbone of production Ethereum.
- Libraries. A shared math or utility contract holds logic that many contracts want, deployed once. Calling it with
DELEGATECALLruns that logic as your own, mutating your storage. You reuse code without giving up custody of state. - Upgradeable proxies. This is the big one. A tiny, permanent proxy contract holds all the storage and the ETH; it does almost nothing but
DELEGATECALLinto a separate implementation (logic) contract whose address it stores in a slot. To upgrade the contract, you deploy a new implementation and point the proxy at it. The address users interact with never changes, the storage and balances never move, but the code behind them can be replaced. Nearly every large protocol is deployed this way.
And here is the footgun, stated plainly: DELEGATECALL lets someone else’s code write your storage. If the implementation and the proxy disagree about which variable lives in which storage slot, the implementation happily writes the wrong slot — this is a storage-layout collision, and it can silently overwrite an owner address or a balance. Worse, if a proxy can ever be tricked into DELEGATECALL-ing into an attacker-controlled contract, the attacker runs arbitrary code against your storage and can take everything. There is no boundary left to save you: you invited the code in and handed it your slots.
Our ethmini crate deliberately does not implement DELEGATECALL — its Call always runs the callee against the callee’s own storage, the safe default. That omission is honest: DELEGATECALL is precisely the primitive whose power comes from breaking the clean boundary the mini-chain enforces, and you should meet it knowing it is the exception, not the rule.
STATICCALL — a read-only call that forbids all state change
Section titled “STATICCALL — a read-only call that forbids all state change”The third opcode solves the opposite problem: sometimes you want to call a contract only to ask it a question, and you want a hard guarantee it cannot change anything while it answers. STATICCALL runs the callee’s code in its own context exactly like CALL, but with one flag flipped: any state-modifying opcode inside that frame reverts. No SSTORE, no value transfer, no CREATE, no SELFDESTRUCT, no event logs. If the callee tries, the frame throws.
STATICCALL(gas, B, argsOffset, argsLen, retOffset, retLen)
B runs in its own context (like CALL) but with a read-only lock: SLOAD ✓ allowed SSTORE ✗ revert CALL ✓ (also static) value>0 ✗ revert RETURN ✓ allowed LOG ✗ revertThe point is safe queries. When a contract calls a price oracle or reads another contract’s balance mid-transaction, STATICCALL guarantees the query is a pure read: it cannot re-enter and mutate your state behind your back. This makes it a first-line defence against reentrancy — the attack where a callee calls back into the caller before the caller finished updating its own state. A STATICCALLed contract simply cannot perform the write that reentrancy needs. Solidity emits STATICCALL automatically for functions marked view or pure when you call across contracts, so you often use it without naming it.
The gas budget travels too
Section titled “The gas budget travels too”A call is not just about context — it is about fuel. When A calls B, B does not get A’s whole remaining gas. A carves out a budget and forwards it; B spends from that budget, and whatever B does not use returns to A when the frame ends. Each nested frame has its own meter, all drawn from the same original transaction limit.
There is a subtlety worth knowing: the EVM forwards at most 63/64ths of the caller’s remaining gas to a call (the “1/64th rule,” EIP-150, 2016). A little is always held back so that even if the callee runs completely dry, the caller has enough left to notice the failure, handle it, and finish cleanly. It is a structural guard so that a hostile or buggy callee cannot consume literally all the gas and leave the caller unable to react.
Reverts unwind one frame at a time
Section titled “Reverts unwind one frame at a time”Composability changes what “revert” means. On the single-program pages, a revert threw away one execution’s storage writes. With nested calls, each frame is its own unit of atomicity:
- If B reverts, B’s state changes are unwound and B’s unused gas returns to A. But A is not forced to die — A receives the failure (as a
0success flag onCALL) and can choose to handle it, retry, or revert itself. - If A does not handle B’s failure and reverts too, A’s changes unwind, and so on up the stack.
- If the top-level frame reverts, the entire transaction’s state changes vanish — but the sender’s nonce is still consumed and gas is still paid, exactly as ethmini models: a reverted transaction is still included.
tx ─► A ─CALL─► B ─CALL─► C C reverts ─┐ B sees CALL return 0 ◄┘ (B may recover, or revert) A sees B's result ... (A may recover, or revert) top-level revert ⇒ whole tx's state undone, but nonce+gas still chargedThis is the same clone-and-commit-on-success discipline from ethmini’s vm::run, applied recursively: each call runs against a snapshot, and only a clean return commits it into the parent’s world.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Ethereum is a composable world computer: contracts are meant to call contracts. The CALL family is the mechanism by which one program invokes another and decides exactly what context — code, storage, identity, money, gas — crosses that boundary.
- What problem does it solve? It lets code be reused (libraries, proxies) and lets contracts query and coordinate with each other, while still giving each account a clear, defensible boundary around its own storage and identity.
- What are the trade-offs? Power versus safety.
CALLkeeps a clean boundary;DELEGATECALLerases it for the sake of upgradeability and reuse, and in doing so makes another contract’s code, and its whole lifecycle, part of your security model.STATICCALLtrades capability for a hard read-only guarantee. - When should I avoid it? Avoid
DELEGATECALLunless you fully control both storage layouts and the implementation’s lifecycle. Avoid trustingmsg.senderas if it were the transaction origin. UseSTATICCALL(orview) whenever a call should only read, so a callee can never re-enter and mutate you. - What breaks if I remove it? Without CALL there are no interacting contracts — every program is an island, no tokens calling routers, no proxies, no on-chain composition. Ethereum collapses back into a set of isolated scripts, losing the “world computer” property entirely.
Check your understanding
Section titled “Check your understanding”- Name the seven values that make up an execution context, and group them into “what runs / on whose behalf / input and fuel.”
- Contract A does a plain
CALLto contract B, and B doesSSTORE. Whose storage is written, and what ismsg.senderinside B? - Explain, in terms of storage ownership, why an upgradeable proxy uses
DELEGATECALLrather thanCALL— and name the single field that makes it dangerous. - Why is
STATICCALLa defence against reentrancy? What specifically can a contract not do inside aSTATICCALLed frame? - In ethmini’s
Callhandling, two facts do all the work: it clones the callee’s storage before running, and it commits only on a clean halt. Restate each as a rule about calls in real Ethereum.
Show answers
code,storage,address,msg.sender,msg.value,calldata,gas. What runs and against what:code,storage,address. On whose behalf and with what money:msg.sender,msg.value. Input and fuel:calldata,gas.- B’s storage is written — a
CALLruns the callee in its own context, against its own slots. Inside B,msg.senderis A, the immediate caller (not the original transaction signer, if A was itself called). - A proxy wants the logic to live in a swappable implementation contract, but the state and ETH to stay put in the permanent proxy.
DELEGATECALLruns the implementation’s code against the proxy’s storage, so upgrading the logic never moves the data. The dangerous field isstorage: because the borrowed code writes your slots, a storage-layout mismatch (or delegating into attacker code) corrupts or steals your state. - Because the read-only lock makes any state-modifying opcode revert inside that frame. The callee cannot
SSTORE, transfer value,CREATE,SELFDESTRUCT, or emit logs — so it cannot re-enter and mutate the caller’s state, which is exactly what a reentrancy attack needs to do. - Clone before running ⇒ a call runs the callee’s code against a working copy of state, so a callee that throws leaves the world untouched — state changes are all-or-nothing per frame. Commit only on clean halt ⇒ a reverted inner call unwinds its own changes and returns its unused gas; only a successful return commits the frame’s writes into the parent’s world.