How to Implement a Toy EVM from Scratch

How to Implement a Toy EVM from Scratch

A blockchain engine engineer's guide to the Ethereum Virtual Machine — building a stack-based virtual machine, arithmetic opcodes, memory layouts, storage writes, and gas accounting systems.

Smart contracts are the backbone of decentralized finance, Web3, and digital consensus. When developers deploy code to Ethereum, it runs inside the Ethereum Virtual Machine (EVM): a sandboxed, stack-based runtime environment that executes bytecodes deterministically across thousands of independent nodes.

Understanding how the EVM executes opcodes, manages stack limits, handles memory expansion penalties, and writes persistent storage is crucial for writing gas-optimized code. Instead of analyzing high-level Solidity abstractions, this guide traces the EVM at the byte level. We will derive the quadratic memory expansion gas equation, walk through EVM opcode decoders, build a working stack-machine executor in JavaScript, and step through bytecode execution in our interactive emulator.


1. Understanding the Ethereum Virtual Machine (EVM)

1.1 Sandboxed Determinism

To achieve consensus, a decentralized network requires all nodes to output the exact same state after executing a transaction. Traditional virtual machines (like the JVM or Docker containers) permit non-deterministic operations: they read system clocks, access network connections, or allocate virtual memory addresses dynamically. If a smart contract read the local node clock, different nodes would calculate different outcomes, fracturing the consensus.

The EVM is designed for absolute determinism. It has no access to the outside world, no network socket capabilities, and zero system clock calls. Every operation—from adding integers to writing persistent memory—is driven by deterministic bytecodes. If the input state is identical, the output state will be identical on every machine in the world.

1.2 EVM Deterministic Architecture vs Traditional VMs

Traditional VMs interact with host system resources and file systems. The EVM is confined to three isolated data structures: Stack, Memory, and Storage, with all execution governed by gas meters.

Common Misconception — Solidity is executed directly by nodes: A common developer misconception is that Ethereum nodes compile and run Solidity directly. In reality, Solidity is merely a high-level language. The compiler compiles it down to raw EVM bytecode (a sequence of hexadecimal bytes like 6002600301). The node's EVM interpreter decodes these bytes sequentially, executing math operations on a virtual stack.


2. EVM Architecture: Stack, Memory, and Storage

2.1 The Three Pillars of EVM Storage

To execute code, the EVM organizes variables into three isolated memory segments, each with unique access costs, lifetimes, and sizes:

  • **Stack**: An ephemeral LIFO (Last-In, First-Out) stack containing 256-bit words. It holds active variables, math operands, and jump targets. The stack has a strict limit of 1024 elements; exceeding this triggers a Stack Overflow error.
  • **Memory**: An ephemeral, byte-addressable linear memory array. It is cleared between transaction calls. Accessing memory is cheap, but allocating larger memory spaces triggers an expansion penalty.
  • **Storage**: A persistent, key-value mapping linking 256-bit keys to 256-bit values. This is the contract state stored on the blockchain database. Writing to storage is extremely expensive, costing up to 20,000 gas per slot.
graph TD B["EVM Executor"] St["Stack: 1024 words, 256-bit"] Me["Memory: Volatile, byte-addressable"] Sg["Storage: Persistent, key-value map"] B --> St B --> Me B --> Sg

Mermaid Diagram: The three data structures of the EVM memory model and their isolation boundaries.


3. Opcode Design and Execution Loops

3.1 Bytecode Parsing and Program Counters

An EVM bytecode stream is a sequence of bytes. The interpreter maintains a **Program Counter (PC)** index. The interpreter reads the byte at the current PC, resolves it to a matching opcode (e.g. 0x01 maps to ADD, 0x60 maps to PUSH1), and executes it. If the opcode is a push instruction, the interpreter reads the next $N$ bytes as parameters and advances the PC accordingly.

This program loop continues until the stream ends, or a halt instruction (like STOP, RETURN, or REVERT) is reached, or the gas meter runs dry.


4. The Mathematics of Gas Calculation and Memory Expansion

4.1 Memory Expansion Penalties

To prevent users from exhausting node memory resources, the EVM charges a gas fee for memory usage. Reading or writing beyond the current allocated memory range triggers a **Memory Expansion**. The gas cost of memory expansion is computed using a quadratic formula based on the active memory word count $a$ (where each word is 32 bytes):

$$ C_{\text{mem}}(a) = 3 a + \left\lfloor \frac{a^2}{512} \right\rfloor $$

Let $a_{\text{new}}$ represent the target memory word count, and let $a_{\text{old}}$ represent the previous active memory word count. The incremental expansion gas cost is calculated as:

$$ \Delta C_{\text{mem}} = C_{\text{mem}}(a_{\text{new}}) - C_{\text{mem}}(a_{\text{old}}) $$

Because of the quadratic term $\lfloor a^2 / 512 \rfloor$, allocating massive memory offsets (like writing to index $100,000$) costs millions of gas, forcing transactions to revert with Out-Of-Gas exceptions. This ensures that memory consumption remains bounded.

4.2 Transaction Gas Limits and Opcode Cost Accumulation Heuristics

Before executing a transaction, the sender specifies a **Gas Limit ($G_{\text{limit}}$)** and pays a upfront fee equal to $G_{\text{limit}} \times \text{GasPrice}$. As the EVM processes opcodes, it tracks the remaining gas $G_{\text{remaining}}$ dynamically. For each opcode execution step $i$ consuming gas cost $g_i$, the following inequality must hold:

$$ G_{\text{remaining}} - g_i \ge 0 $$

If the remaining gas drops below the cost of the current opcode (i.e. $G_{\text{remaining}} < g_i$), the virtual machine halts execution immediately. This triggers an **Out-Of-Gas Exception**, rolling back all state changes while burning the entire gas limit budget as a penalty to prevent spam attacks.

Opcode costs are split into static base costs (e.g. 3 gas for ADD) and dynamic costs. Dynamic costs depend on stack inputs. For example, the cost of copying memory (like the CALLCOPY or CODECOPY opcodes) scales linearly with the number of words copied:

$$ G_{\text{copy}}(w) = G_{\text{base}} + 3 \cdot w \quad (\text{where } w = \lceil \text{bytes} / 32 \rceil) $$

This dynamic charging system prevents resource exploitation. Below is a comparison table of gas parameters:

Gas Parameter Mathematical Definition Primary Cryptographic Role Resource Guard Constraint
Gas Limit ($G_{\text{limit}}$) User-specified max budget allocation Prevents infinite recursion loops (Halting Problem guard) Clamped by block gas limit boundaries
Memory Expansion Gas $3 a + \lfloor a^2 / 512 \rfloor$ Protects node RAM allocations from bloat Quadratic penalty bounds large pointer offsets
Base Fee EIP-1559 network congestion fee Burned automatically to stabilize gas prices Scales dynamically based on block utilization metrics

Pitfall — Unbounded loops in storage arrays: Iterating over an array of storage keys whose length is set dynamically (e.g. looping over all users) consumes gas linearly. As the user database grows, the gas cost of calling this function will eventually exceed the block gas limit, rendering the function permanently uncallable.


5. Designing the Stack: The 1024-Word Limit and Stack Overflow/Underflow

5.1 Stack Constraints and Local Variable Limits

The EVM stack can hold up to 1024 words. While sufficient for arithmetic, it creates limits for local variables. Opcodes like DUP (duplicate) and SWAP can only access the top 16 elements of the stack (from DUP1 to DUP16).

If a Solidity function declares more than 16 local variables, the compiler cannot generate valid swap bytecodes to access deep variables. This triggers the infamous **"CompilerError: Stack too deep"** message, forcing developers to group local variables into structs or memory arrays.


6. State Transitions and Deterministic Execution

6.1 State Modification Rules

To ensure consensus, the EVM handles state changes as atomic state transitions. If a transaction encounters an error (like an out-of-gas exception, stack overflow, or explicit revert), the entire state modification is discarded, restoring the contract's storage to its pre-transaction state.

Only the transaction fee (gas consumed up to the revert point) is subtracted from the sender's account. This prevents nodes from committing partial updates, keeping the database consistent.

6.2 State Transition Formulas and Revert Cascades

Formally, as defined in the Ethereum Yellow Paper, the state transition function of the Ethereum network is denoted as $\Upsilon$. Let $\sigma_t$ represent the global state of the network at block $t$, and let $T$ represent a transaction. The transition function maps the current state to an updated state $\sigma_{t+1}$:

$$ \Upsilon(\sigma_t, T) = \sigma_{t+1} \quad \text{or} \quad \sigma_{\text{error}} $$

During transaction execution, the EVM operates on a temporary scratchpad sub-state $\sigma'$. When a sub-call (message call via `CALL` or `DELEGATECALL`) is executed, the EVM pushes a new execution frame onto the call stack. Let $\sigma_k'$ be the sub-state of the calling frame, and let $S_{\text{call}}$ be the sub-call transaction context. If the sub-call executes successfully, its changes are committed to the parent frame:

$$ \sigma_{k+1}' = \sigma_{k}' + \Delta \sigma_{\text{sub}} $$

If the sub-call reverts (e.g. failing a `require` check or running out of gas), the local changes $\Delta \sigma_{\text{sub}}$ are discarded, and the parent frame state is restored to $\sigma_k'$. Crucially, the sub-call returns a success boolean status value ($0$ for failure, $1$ for success) to the stack of the parent caller:

$$ \text{Stack}_{\text{parent}} \leftarrow \text{Stack}_{\text{parent}} \cup \{0\} \quad (\text{on Revert}) $$

If the parent context fails to handle this zero status value (e.g. if the Solidity compiler inserts automatic revert checks on external calls), the revert cascades up the call stack, restoring all states back to the transaction root. Below is a comparison table of state transition boundaries:

Execution Phase State Modification Status Gas Accounting Action Database Committal Scope
Active Run Frame Pending (written to local memory cache) Deducted incrementally per opcode None (isolated from disk DB)
Explicit Revert Discarded (coordinates restored to pre-call state) Charged up to the execution revert point None (local changes erased)
Successful Commit Approved (changes merged into parent state) Charged in full (minus accumulated refunds) Written to disk trie database at block end

Pitfall — Silent failures of low-level Solidity calls: Low-level calls (like address.call(...)) do not automatically throw exceptions on target revert; they return false. If your code ignores this boolean return value, parent execution continues using outdated state assumptions, leading to logic flaws.


7. Advanced: Simulating Memory Allocation and Storage Tries

7.1 Storage Tries

EVM storage is not a flat array; it is structured as a **Modified Merkle Patricia Trie**. Each contract has its own storage trie linking 256-bit keys to 256-bit values. The root hash of this trie is stored in the blockchain state, securing the integrity of the database.

Because storage is persistent and secured by tries, writing to storage is expensive. Modifying a storage slot from zero to non-zero (SSTORE) costs 20,000 gas, while clearing a slot (setting it back to zero) offers a gas refund, encouraging state cleaning.

7.2 Merkle Patricia Trie Node Hashing and Storage Gas Refund Rules

To organize key-value pairs efficiently without storing empty spaces, the EVM uses a **Modified Merkle Patricia Trie (MPT)**. The MPT consists of three distinct node types to compress paths:

  • **Leaf Node**: A two-item node representing a path end. It links a path slice (nibbles) directly to the storage value: [path, value].
  • **Extension Node**: A two-item node acting as a shared path shortcut, linking a path slice to another node hash: [path, next_node_hash].
  • **Branch Node**: A 17-item array node representing path splitting. It contains 16 slots matching hexadecimal characters (nibbles 0-F) and 1 slot for value: [v0, v1, ..., v15, value].

Let $K$ be a 256-bit storage key, hashed using Keccak-256 to yield a 32-byte path slice. The trie resolves keys by traversing nibbles (4-bit characters). When value $V$ at key $K$ is updated, the hash changes cascade recursively up to the trie root. Let $H$ be the parent hashing function. The new parent hash is derived from the child node hashes:

$$ \text{ParentHash} = \text{Keccak-256}\left(\text{encode}\left([\text{child}_0, \text{child}_1, \dots]\right)\right) $$

Because updating any slot changes the final root state hash of Ethereum, the EVM charges massive gas for storage modifications. Under current network rules, modifying a storage slot from zero to non-zero (SSTORE) costs 20,000 gas. Changing an existing slot costs 2,900 gas (cold slot) or 100 gas (warm slot).

To keep the chain state small, the EVM offers a gas refund when a storage slot is cleared (set from non-zero to zero). This refund acts as a negative fee, subtracted from the transaction's consumed gas at execution end. Let $G_{\text{consumed}}$ be the execution gas, and let $R$ be the collected refunds. Under EIP-3529 rules, the maximum refund is capped at 20% of the total transaction gas cost to prevent users from exploiting refunds to execute large transactions for free:

$$ G_{\text{charged}} = G_{\text{consumed}} - \min\left(R, \, \left\lfloor \frac{G_{\text{consumed}}}{5} \right\rfloor\right) $$

This ensures that state cleanup is rewarded while keeping execution times bounded. Below is a comparison table of state data structures:

EVM Storage Type Cryptographic Proof Scheme Underlying Database Type State Persistence Scope
Account State Global State MPT Root LevelDB / RocksDB (on disk) Permanent (until account is self-destructed)
Contract Storage Contract Storage MPT Root LevelDB / RocksDB (on disk) Permanent (persisted across blocks)
Execution Memory None Volatile RAM (ephemeral buffer) Temporary (discarded when call frame returns)

Pitfall — Reverting after gas refunds: If a transaction executes storage cleanups (accumulating refunds) but later reverts (e.g. failing a final require check), all storage changes are rolled back. Consequently, the accumulated gas refunds are discarded, and you are charged for the gas consumed up to the revert point.


8. Advanced: EVM Opcode Reference and Gas Cost Matrix

8.1 Opcode Matrix

The table below compares the gas costs and stack parameters of key EVM opcodes:

Opcode Hex Value Base Gas Cost Stack Operation (Pop -> Push)
ADD 0x01 3 gas Pop 2, Push 1 (adds top two stack elements)
MLOAD 0x51 3 gas + memory expansion Pop 1, Push 1 (reads 32 bytes from memory offset)
SLOAD 0x54 2100 gas (cold) / 100 gas (warm) Pop 1, Push 1 (reads 32 bytes from storage key)
SSTORE 0x55 Up to 20,000 gas Pop 2, Push 0 (writes 32 bytes to storage key)

Pitfall — Reentrancy vulnerability during external calls: The CALL opcode transfers execution flow to another contract. If the target contract calls back into the caller before state variables are updated, it can drain funds. Always use the checks-effects-interactions pattern or reentrancy guards.


9. Complete Toy EVM Stack Machine in JavaScript

9.1 The EVM Interpreter Class

The following JavaScript class implements a toy EVM stack machine, interpreting PUSH and ADD opcodes recursively:

class ToyEVM {
    constructor() {
        this.stack = [];
        this.pc = 0;
    }
 
    run(bytecode) {
        while (this.pc < bytecode.length) {
            const op = bytecode[this.pc];
            if (op === 0x60) { // PUSH1
                const val = bytecode[this.pc + 1];
                this.stack.push(val);
                this.pc += 2;
            } else if (op === 0x01) { // ADD
                const a = this.stack.pop();
                const b = this.stack.pop();
                this.stack.push(a + b);
                this.pc += 1;
            } else {
                this.pc += 1;
            }
        }
        return this.stack;
    }
}

10. Interactive: EVM Bytecode Execution Simulator

Click "Step Code" to run the next opcode byte. Watch how the stack and program counter change sequentially:

PC: 0x00 | Stack Depth: 0
Bytecode: 6002600301 (PUSH1 02, PUSH1 03, ADD)
Log output displays here...
Empty Stack

Gas Cost vs Active Memory Allocation

The chart below displays the memory expansion gas cost curve as the active memory allocation scales up, illustrating the quadratic penalty at high sizes:


12. Frequently Asked Questions

Q1: Why is Ethereum Virtual Machine a stack-based machine instead of register-based?

Because stack-based machines are simpler to implement and lead to smaller bytecode sizes, minimizing blockchain bandwidth overhead. Register-based machines require explicit operands, increasing packet size.

Q2: What is the maximum size of a single EVM stack word?

Each stack slot is exactly 256 bits (32 bytes). This is designed to support 256-bit cryptographic operations (like Keccak-256 hashes and ECDSA signatures) natively without splitting integers.

Q3: How does the EVM handle integer overflow?

In early EVM versions, integer overflow wrapped around (e.g. $2^{256} - 1 + 1 = 0$) without throwing errors. Since Solidity 0.8.0, the compiler automatically inserts check opcodes that revert transactions on overflow.

Q4: Why is SLOAD so much more expensive than MLOAD?

Because SLOAD reads from the persistent database stored on disk (trie lookup), requiring physical read operations. MLOAD reads from volatile RAM, which is fast and temporary.

Q5: What happens to unused gas when a transaction finishes successfully?

Any remaining gas is returned to the transaction sender's account. Only the actual gas consumed by executed opcodes is charged.

Q6: What is a gas refund?

Setting a storage slot from non-zero to zero (deleting data) or executing the SELFDESTRUCT opcode cleans state. The EVM rewards this by refunding up to 50% of the transaction's consumed gas at execution end.

Q7: Can a contract read the storage of another contract directly?

No. The EVM limits storage access to the current execution context. A contract must expose a public view function (calling SLOAD internally) to allow external contracts to query its state.

Q8: How do I verify my contract gas estimations are correct?

Verify: (1) run tests using Hardhat or Foundry gas reports, (2) analyze storage slot alignment (packing variables), (3) verify that loop bounds are fixed, and (4) verify that memory allocations remain bounded to avoid expansion penalties.

Post a Comment

Previous Post Next Post