FABRKNT
Reading the Stack — Bridge to Intermediate
EVM at the bytes level
Lesson 2 of 10·CONTENT12 min25 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Reading the Stack — Bridge to Intermediate
Lesson role
CONTENT
Sequence
2 / 10

Lesson 1 — Memory, storage, and the world state

Question

The EVM bytecode touches five memory regions. Stack / Memory / Calldata / Storage / Code. Each has different lifetime, cost, and capacity. Which is persisted, which is volatile, which is read-only, and what is the world state?

Principle (minimum model)

  • Stack. 1024 slots, volatile (one transaction), cheapest (3 gas / push). The current computation.
  • Memory. Linear array, volatile, read/write via MSTORE / MLOAD. Expansion costs gas (quadratic for large allocations).
  • Calldata. Read-only transaction input, set only on external calls. CALLDATALOAD / CALLDATASIZE. Cheapest.
  • Storage. The only persisted region. Per-contract, SLOAD 2100 gas (cold) / 100 gas (warm), SSTORE 20K gas (new slot) / 5K (change). Most expensive.
  • Code. The contract's deployed bytecode, read-only. EXTCODECOPY / CODECOPY.
  • World state. The combined storage of every contract plus every EOA's balance. Maintained in a Merkle Patricia Trie; the state root sits in the block header.
  • MPT (Merkle Patricia Trie). Ethereum's state persistence structure. Three node types (Branch / Extension / Leaf) + keccak256 root hash. The state root is what L1 sync builds.

Worked example + steps

Memory, storage, and the world state

The dispatch loop showed you what an opcode is. Most opcodes touch one of four stores. This lesson walks through them — and through the world-state model that Solidity hides from you but Intermediate lessons assume you know.

The four stores

StoreLifetimeCost shapeSolidity surface
StackOne call frameCheap (3 gas / op)implicit
MemoryOne call frameCheap, grows quadraticallythe memory keyword
CalldataOne call frame, read-onlyCheap readsfunction arguments
StoragePermanent (per contract)Expensive (cold = 2100, warm = 100)state variables

Each store has its own opcodes. Mixing them up is one of the most common Solidity bugs.

Stack — the EVM's primary scratch space

You've already met this. 1024 items max, each 32 bytes (one EVM word). Every arithmetic / comparison / logic opcode reads from the top of the stack and writes back to it.

Stack overflow (depth > 1024) and stack underflow (popping an empty stack) both halt the frame.

Memory — linear, expandable, frame-local

Memory is a flat array of bytes, addressed from offset 0, growing as needed. Two opcodes do the work:

  • MLOAD offset → load 32 bytes from memory[offset..offset+32], push to stack
  • MSTORE offset value → write a 32-byte value from stack to memory[offset..offset+32]

(MSTORE8 writes one byte. MCOPY does memory-to-memory copies.)

Two things that matter:

1. Memory grows on demand — and you pay for it

If you write to memory at offset 1000 and the current memory size is 64 bytes, the EVM expands memory to cover offset 1000 before the write. The expansion costs gas, and it's quadratic past 32 KB:

gas_cost(size_in_words) = 3 × words + words² / 512

That's why long byte-array operations get expensive fast. A 1 MB memory expansion costs roughly 2 million gas just for the space, before you write anything.

2. Memory dies at the end of the frame

When CALL returns or STOP halts, memory is gone. The next call gets fresh, empty memory at offset 0.

Calldata — the immutable input buffer

When you call a contract, the calldata is the input bytes — the function selector (4 bytes) plus ABI-encoded arguments. It's read-only and addressed from offset 0.

CALLDATALOAD offset → load 32 bytes from calldata
CALLDATASIZE        → push the size of calldata
CALLDATACOPY        → copy calldata to memory

Calldata reads are cheap and there's no expansion cost — it was already paid when the call was created.

Storage — the permanent map

This is the most important store for understanding world-state.

Each contract has its own storage, modeled as a map from U256 keys to U256 values:

storage[address]: HashMap<U256, U256>

The keys are 32-byte words. The values are 32-byte words. There are no fixed slots — every key in the entire U256 space is virtually there, defaulting to zero.

Two opcodes:

  • SLOAD key → read storage[key], push to stack
  • SSTORE key value → write value to storage[key]

Cold vs warm — the gas trap

The first SLOAD of a given slot in a transaction is cold — 2100 gas. Subsequent SLOADs of the same slot in the same tx are warm — 100 gas.

Why? Because the actual implementation has to check whether the slot has been touched (a Merkle Patricia Trie lookup) on the first access; subsequent accesses are cached.

This is EIP-2929, retrofitted into Ethereum after attackers found that a contract calling SLOAD repeatedly on cold slots could DoS the network for cheap. The cold/warm distinction is the fix.

How Solidity uses storage

Solidity assigns storage slots at compile time. uint256 private balance lives at slot 0, mapping(address => uint256) balances lives at keccak256(address . slot_index), etc. Solidity is doing slot allocation on top of the raw U256 → U256 map.

When Intermediate lesson 3 (Database trait) shows you:

fn storage(&mut self, address: Address, index: StorageKey)
    -> Result<StorageValue, Self::Error>;

— that signature is exactly the model above. The trait says: "given a contract address and a slot key, give me the U256 value." That's the storage map.

The world state — accounts everywhere

So far we've described one contract. The full Ethereum world state is a map from address to account:

world_state: HashMap<Address, Account>

struct Account {
    nonce: u64,
    balance: U256,
    code_hash: B256,        // keccak256 of this account's bytecode (empty for EOAs)
    storage_root: B256,     // root of this contract's storage trie
}

Every Ethereum account — yours, every contract, every wallet — is a row in this map. The interesting fields:

  • code_hash: empty for externally-owned accounts (EOAs); points to a contract's bytecode otherwise
  • storage_root: the Merkle root of this contract's storage map (the trie covered in Expert lessons)

When you make a transaction, you're updating this map: incrementing nonces, transferring balances, modifying contract storage.

In Revm's Database trait, fn basic(&mut self, address: Address) returns the Option<AccountInfo> for an address. That's a row lookup in this map.

Putting it together

A single SSTORE you write in Solidity becomes:

  1. Solidity computes the slot key (e.g., keccak256(msg.sender . 5))
  2. The compiler emits PUSH32 <key> then SSTORE
  3. The EVM runs SSTORE: cold → 22100 gas (write + first-touch), warm → 5000 gas
  4. The interpreter calls the Database's storage-write path
  5. The MPT for this contract gets updated, eventually changing storage_root in the account, eventually changing the global stateRoot

Five layers between your line of Solidity and the chain's state root. All five are in the source you'll read in Intermediate and Expert.

Reading list

  1. Open evm.codes and find: MLOAD, MSTORE, SLOAD, SSTORE, CALLDATALOAD. Read their gas notes.
  2. Find a real Solidity contract on Etherscan, look at its bytecode, search for SLOAD (0x54) and SSTORE (0x55) bytes. They're everywhere.
  3. In Foundry, write a contract with one uint256 state var. Read it twice in one function. Measure gas with forge test --gas-report. The second read is roughly 2000 gas cheaper — that's cold-vs-warm in action.

What you should walk away with

  • Stack, memory, calldata, storage are four different stores with different lifetimes, costs, and APIs.
  • Storage is a per-contract U256 → U256 map — Solidity slot allocation is just packing on top of it.
  • The world state is a Address → Account map; each Account points to its own storage trie.
  • The Revm Database trait's three core methods (basic, code_by_hash, storage) directly mirror the world-state model above.

When Intermediate lesson 3 shows you the Database trait, you'll recognize it as exactly this picture, expressed as Rust traits.

Summary (3 lines)

  • Five memory regions: Stack 1024 slots volatile / Memory volatile quadratic / Calldata read-only input / Storage persisted most-expensive / Code read-only bytecode.
  • Only Storage is persisted. The world state = every contract's Storage + every EOA's balance, maintained in a Merkle Patricia Trie; the state root sits in the block header.
  • Next lesson: gas mechanics + call frames — the cost model and contract-to-contract calling conventions.