Module 8 · Ethereum, the EVM & on-chain zero-knowledge
The EVM execution model
The EVM is the deterministic machine that every Ethereum node runs to execute contract bytecode. It is a 256-bit stack machine, quasi-Turing-complete because gas bounds every computation. Everything an auditor reasons about — reentrancy, authorization bugs, storage corruption — ultimately reduces to how this machine moves data between its stack, memory, storage and calldata, and how execution context is set when one contract calls another.
Learning objective
Understand the Ethereum Virtual Machine as a deterministic 256-bit stack machine bounded by gas: the stack and its 1024-depth limit, the three data regions (volatile memory with quadratic expansion cost, persistent storage, read-only calldata), the execution context (msg.sender vs tx.origin, block.timestamp and why it is manipulable), and the four external-call opcodes (CALL, DELEGATECALL, STATICCALL, CALLCODE) at a conceptual level.
- Track
- Engineering
- Module
- 8 · Ethereum, the EVM & on-chain zero-knowledge
- Prerequisites
- Hash functions
Conceptual introduction
Knowing Solidity is not the same as understanding what actually runs on-chain. The compiler turns your source into EVM bytecode, and it is that bytecode — interpreted opcode by opcode on every validating node — that defines the true behaviour of a contract. Many security-critical facts (why one operation costs far more gas than another, why a storage slot got overwritten, why an authorization check can be bypassed) are invisible at the Solidity level and only make sense once you can see the machine underneath.
The Ethereum Virtual Machine (EVM) is a stack-based virtual machine that executes bytecode deterministically across every node in the network. Deterministic means the same input produces the same output on every node; this is what lets thousands of independent machines agree on the result of a transaction. Each contract executes in its own isolated context, and every single operation is metered: it has a gas price that the caller must pay for.
The EVM is called quasi-Turing-complete: it could express arbitrary computation, but gas places a hard upper bound on how much work any transaction can do. Without that bound an infinite loop would be a denial-of-service weapon; with it, the loop simply runs out of gas and the transaction reverts. The limit is an economic safety mechanism, not a mathematical one.
At a high level the machine has a few moving parts: a program counter that points at the current opcode, a stack that holds operands, a gas counter that is decremented as work is done, and three regions where data lives — memory, storage and calldata. The rest of this lesson walks through each of these and then through the context that is established whenever one contract calls another.
The stack: the heart of the machine
Every arithmetic, logical and control-flow operation in the EVM works on the stack. There are no named registers: an opcode pops its operands from the top of the stack and pushes its result back. This is why EVM assembly reads like reverse-Polish notation — you arrange values on the stack and then apply an operator to them.
- Word size
- Every stack element is 256 bits (32 bytes) wide. This matches the output size of keccak256 and the size of an Ethereum address padded to a word, which makes hashing and addressing natural but makes 8-bit or 16-bit arithmetic a Solidity abstraction layered on top of 256-bit words.
- Maximum depth
- The stack can hold at most 1024 elements. Exceeding this causes a stack overflow and the current execution frame fails.
- Reachable region
- Only the top 16 elements are addressable through the DUP1..DUP16 (duplicate) and SWAP1..SWAP16 (swap) opcodes. Anything buried deeper than 16 slots cannot be read without first consuming what is above it.
illustrative — an ADD on the stack (top of stack on the left)initial stack: [5, 3, ...] opcode: ADD step 1 POP two top items -> 5 and 3 leave the stack step 2 compute 5 + 3 -> 8 step 3 PUSH the result -> 8 goes back on top final stack: [8, ...]
The 1024-element depth limit is not just trivia. In early Ethereum an attacker could deliberately push the call stack close to its limit and then invoke a victim contract, so that a nested external call failed purely from stack exhaustion — while the surrounding code kept running as if it had succeeded. That class of "stack depth attack" was mitigated by changing how gas is forwarded to sub-calls (covered in the gas lesson), but understanding the limit remains essential when auditing older contracts.
Memory: volatile and quadratically priced
Memory is a byte-addressable array that exists only for the duration of a single execution. It starts completely zeroed, is thrown away when the call returns, and is used for scratch work: building the arguments to a call, assembling return data, hashing a buffer. You read and write it in 32-byte words with MLOAD and MSTORE, or a single byte with MSTORE8.
The subtlety is cost. Memory is cheap at small sizes but its price grows with the square of how far you expand it. Each time you touch a higher offset than before, the machine charges for the new words, and a component of that charge is quadratic. This is deliberate: it keeps ordinary contracts cheap while making it ruinously expensive to force the machine to allocate megabytes, which would otherwise be a denial-of-service vector.
illustrative — memory expansion cost (schematic formula, not a benchmark)let words = ceil(byte_size / 32) // size measured in 32-byte words memory_cost(words) = 3 * words + (words * words) / 512 |________| |________________| linear term quadratic term tiny buffers: the linear term dominates -> cheap huge buffers: the quadratic term dominates -> grows fast # the exact constants are defined by the protocol; the SHAPE is the point
A classic trap: a function that allocates a buffer whose size comes from untrusted input. If an attacker can pass a very large size, the quadratic term turns a single call into an enormous gas burn. The same principle lets a malicious callee return an oversized blob to inflate the caller memory — a "return bomb". Never size memory from unvalidated external input.
Solidity reserves the lowest memory offsets for its own bookkeeping: a scratch area used for hashing, and the free memory pointer that tracks where the next allocation may start. Low-level assembly that overwrites the free memory pointer corrupts every subsequent allocation — a reminder that memory, though volatile, is not free of consequences.
Storage and calldata
Storage is where a contract state survives between transactions. Conceptually it is a key-value store with 256-bit keys and 256-bit values — 2^256 possible slots, every one of them zero until written. It is the most expensive region to touch and, as the dedicated storage lesson shows, the one where the costliest bugs live. For now the key facts are: it is persistent, it is sparse (you only pay for slots you actually write), and reading it is far dearer than reading memory.
Calldata is the read-only input region that carries the data of a transaction or a call. It cannot be modified during execution, which makes it both safe to read directly and cheaper than copying the same bytes into memory. Its first four bytes are the function selector — the first four bytes of the keccak256 hash of the function signature — which the contract dispatcher uses to decide which function to run. The remaining bytes are the ABI-encoded arguments.
- Memory
- Volatile scratch space, zeroed per call, 32-byte word access, cheap small but quadratic to expand.
- Storage
- Persistent 256-bit key/value state, survives across transactions, by far the most expensive region.
- Calldata
- Immutable, read-only input to a call; cheapest to read; holds the 4-byte selector followed by encoded arguments.
Because two different function signatures can hash to the same leading four bytes, selector collisions are possible. In a naively built proxy this can route a call to the wrong implementation function — a real and exploited bug class. Selectors are an addressing convenience, not an authentication mechanism.
Execution context
Every running frame has an execution context: a set of environment values describing who called, how much value was sent, and facts about the current block. Reading them is how a contract makes decisions, and misreading them is how authorization bugs are born. The two most important identity values are msg.sender and tx.origin.
- msg.sender
- The address that made the CURRENT call. If an externally owned account (EOA) calls contract A and A calls B, then inside B msg.sender is A, not the EOA. This is the correct value for authorization.
- tx.origin
- The EOA that originally signed and started the whole transaction. It stays the same through the entire call chain, no matter how many contracts deep you are.
- msg.value
- The amount of ETH (in wei) sent with the current call.
- block.timestamp
- The timestamp proposed for the current block by its proposer. It is an input to execution, not an objective clock.
Using tx.origin for authorization is one of the most dangerous common mistakes. Suppose a wallet checks "require(tx.origin == owner)". If the owner can be lured into calling any function of a malicious contract, that contract can in turn call the wallet: tx.origin is still the owner, so the check passes, and the attacker contract drains the wallet. The fix is to always authorize on msg.sender, which identifies the immediate caller and cannot be spoofed by an intermediary.
illustrative — the tx.origin authorization trap (synthetic)// VULNERABLE contract Wallet { address owner; function transfer(address to, uint amount) external { require(tx.origin == owner); // wrong: true for ANY call the owner started pay(to, amount); } } // A malicious contract the owner is tricked into calling can now call // Wallet.transfer(attacker, balance): tx.origin is still the owner. // FIX: require(msg.sender == owner); // identifies the immediate caller
block.timestamp can be nudged by the block proposer within a small tolerance. Never use it as a source of randomness, nor for financial decisions that hinge on second-level precision. Coarse time windows (hours, days) are fine; "winner = timestamp % players" is exploitable.
The four external-call types
When one contract invokes another, the EVM offers four call opcodes that differ in one crucial respect: whose storage is used, and how the context is set. Almost every severe smart-contract vulnerability lives at these boundaries, so the distinctions below are worth internalizing precisely.
- CALL
- The standard call. Code runs in the CALLEE context: it reads and writes the callee storage, msg.sender becomes the caller, and ETH value may be forwarded. This is an ordinary call to another contract.
- DELEGATECALL
- Runs the callee code but in the CALLER storage context. The borrowed code can read and write YOUR storage, and msg.sender and msg.value are preserved from the original call. This is the foundation of upgradeable proxies — and of many catastrophic hacks when storage layouts disagree.
- STATICCALL
- Like CALL but guarantees no state change: it reverts if the called code attempts SSTORE, LOG, CREATE or SELFDESTRUCT. Introduced to make read-only calls (such as to an oracle view function) safe at the EVM level.
- CALLCODE
- A deprecated predecessor of DELEGATECALL. It runs borrowed code in your storage but, unlike DELEGATECALL, changes msg.sender. It was deprecated for being confusing and dangerous; you will only meet it in legacy bytecode.
The mental model for DELEGATECALL is "run someone else code as if it were mine". A proxy contract holds the data; it DELEGATECALLs into an implementation contract that holds the logic. Because the implementation writes to the proxy storage, the two contracts must agree exactly on which variable lives in which slot. When they do not, writing one variable silently overwrites another — the storage-collision bug class that the third lesson in this module is devoted to.
illustrative — CALL vs DELEGATECALL context (synthetic)A --CALL--> B code runs in B storage written: B msg.sender inside B: A A --DELEGATECALL--> B code of B runs storage written: A msg.sender preserved: A's caller # B's logic mutates A's slots -> layout mismatch = corruption
- 1Decide what you actually need: to invoke another contract normally (CALL), to borrow logic into your own state (DELEGATECALL), to read without any side effect (STATICCALL).
- 2If forwarding ETH, remember only CALL and CALLCODE can carry value.
- 3If using DELEGATECALL, prove to yourself that the storage layouts of caller and callee are compatible slot-for-slot.
- 4Always check the boolean success value a low-level call returns; a failed call does not automatically revert the caller.
- 5Bound the return data you are willing to copy, so a malicious callee cannot inflate your memory with a return bomb.
Application to security and agents
For a security reviewer, the execution model is the ground truth. Reentrancy is a CALL that re-enters before state is updated; a confused-deputy authorization bug is often a tx.origin check or a mis-set msg.sender across a call boundary; a proxy that bricks itself is a DELEGATECALL into a layout-incompatible implementation. You cannot reliably spot these from Solidity alone, because the vulnerability lives in how the machine sets context, not in any single line of source.
For autonomous-agent systems that act on-chain, the same model governs what an agent can and cannot be trusted to do. An agent that signs a transaction is the tx.origin for its entire call chain, so any contract the agent is induced to call inherits that origin — exactly the trap that makes tx.origin unsafe for authorization. An agent reasoning about whether an on-chain action is safe must reason in terms of which contract holds the authority (msg.sender) and whose storage a delegated call will mutate, not in terms of surface-level function names.
Risks and limitations
- ▸The 256-bit word size means small integer types are abstractions; pre-0.8 Solidity silently wrapped on overflow, and low-level assembly still can.
- ▸Memory is cheap to start but quadratic to expand; sizing it from untrusted input is a denial-of-service vector.
- ▸tx.origin is never a safe authorization check; always authorize on msg.sender.
- ▸block.timestamp is proposer-influenced within a tolerance and must not be used for randomness or exact-second financial logic.
- ▸DELEGATECALL runs foreign code against your own storage; a layout mismatch between caller and callee corrupts state, and this is among the most expensive bug classes in the ecosystem.
- ▸Low-level calls return a success boolean that the EVM does not act on for you — ignoring it means treating a failed call as a successful one.
Exercise
A lottery contract picks a winner with "winner = players[uint(block.timestamp) % players.length]" and pays out immediately. Separately, a wallet authorizes withdrawals with "require(tx.origin == owner)". Explain the flaw in each design in terms of the EVM execution model, and give the corrected approach for each.
Expected direction: The lottery relies on block.timestamp, which the block proposer can shift within a tolerance; a proposer who is also a participant can nudge the timestamp so the modulus selects their own index, making the draw manipulable. Timestamp is an execution input, not an unbiased random source — a safe design draws randomness from a commit-reveal scheme or a verifiable randomness source, not from block fields. The wallet check uses tx.origin, which stays equal to the owner EOA through the entire call chain; if the owner is tricked into calling a malicious contract, that contract can call the wallet and the check still passes because tx.origin is the owner. The fix is require(msg.sender == owner), which identifies the immediate caller and cannot be spoofed by an intermediary contract.
Check your understanding
- Why is the EVM described as quasi-Turing-complete rather than Turing-complete?
- Because gas bounds every computation: it could express arbitrary programs, but any transaction that tries to run forever simply exhausts its gas and reverts, so unbounded computation is impossible.
- Inside contract B, when an EOA calls contract A and A calls B, what are msg.sender and tx.origin?
- msg.sender is A (the immediate caller of the current frame); tx.origin is the EOA that signed and started the whole transaction. Authorization must use msg.sender.
- What is the defining difference between CALL and DELEGATECALL?
- CALL runs the callee code in the callee storage context. DELEGATECALL runs the callee code in the caller storage context, so the borrowed code reads and writes the caller slots and msg.sender/msg.value are preserved — the basis of proxies and of storage-collision bugs.
- Why is expanding EVM memory from an untrusted size dangerous?
- Memory expansion cost has a quadratic term, so a large attacker-controlled size can force a huge gas burn (or a return bomb from a callee), enabling denial of service.
References
- [1]Ethereum Yellow Paper (Wood) — formal specification of the EVM
- [2]Mastering Ethereum (Antonopoulos & Wood), the EVM chapter
- [3]Solidity documentation — "Layout in Memory" and "Contracts / Function Calls"
- [4]EIP-214 — STATICCALL opcode
- [5]evm.codes — interactive opcode and gas reference
References point to public, well-known technical sources for further study.
Related articles
