Ethereum's entire state fits in 32 bytes

COMMITe2e7213HEAD → main
PUBLISHEDMar 15, 20224y ago
UPDATEDJul 25, 20265d ago
READING14 min2,095 words
#ethereum#cryptography·by santiago toscanini
Ethereum·Part 2 of 6

Part five left an account as four RLP fields — nonce, balance, storageRoot, codeHash — and a promise I didn't keep: I told you the world state is a map from 160-bit address to that account, that only a single 32-byte stateRoot lives in the block header, and that the map itself lives off-chain. That leaves one question doing all the work. How does one hash commit to every account, so that any node can prove your balance and no node can lie about it?

You already met the answer in a different costume. The git series ended on Merkle trees: a tree's hash is the hash of its children's hashes, recursively, so the root commits to everything and any edit bubbles up as new hashes. Bitcoin flattened that into a merkle_root over a block's transactions. Ethereum takes the same idea and gives it a keyboard: a tree you can look things up in by key, not just verify. It's called the Merkle Patricia Trie, it's still the structure Ethereum keys its whole state on in 2026, and this post builds one in front of you.

Nothing here is a mockup. The trie engine is a few hundred lines of TypeScript over the same hand-rolled keccak256 the last two posts carried. Every root on this page is computed in your browser, and the marquee example — the keys doe, dog, dogglesworth — reproduces 8aad789d…, the exact root that go-ethereum's own test suite asserts. If a digit were wrong, you'd catch me.

01 · A trie made of nibbles

Start with the plain idea, no hashing yet. A trie (from retrieval; my course notes even spelled out PATRICIA as "Practical Algorithm To Retrieve Information Coded In Alphanumeric") is a tree keyed by the characters of the key rather than by comparisons. To find dog you walk d, then o, then g. Keys that share a prefix share a path. Nothing is stored twice.

Ethereum walks the key one nibble at a time — a nibble is half a byte, four bits, one hex digit. My Spanish notes call it a mordisco, a little bite, and that's exactly right: the byte 0x64 (the letter d) is two bites, 6 then 4. Sixteen possible values per step, so every fork in the tree is 16-way.

That 16-way fork forces three kinds of node, and they're the whole vocabulary:

  • A leaf is the end of the road: the leftover nibbles of a key, plus its value.
  • A branch is the 16-way fork: sixteen child slots, one per nibble 0f, plus a seventeenth slot for a value that ends exactly here. (go-ethereum literally types this as [17]node, not the fifteen my notes misremembered.)
  • An extension is a shortcut: when a long run of nibbles has no forks, you don't spend a branch on each one, you store the shared run once and point at what comes after.

There's one more piece of bookkeeping, and it's the kind of detail that decides whether your from-scratch trie ever reproduces a real root: hex-prefix encoding. A path of nibbles has to be packed back into whole bytes to be stored, and the decoder needs two facts the raw nibbles don't carry — is this a leaf or an extension, and was the nibble count odd or even? Both get stuffed into a single leading flag nibble: +2 for a leaf, +1 for odd length. So 2 means leaf-even, 3 leaf-odd, 0 extension-even, 1 extension-odd. Watch that tag appear on the nodes below.

02 · Build one, watch it split

Here is the trie, live. It starts loaded with go-ethereum's test keys, so the root reads 8aad789d… — check it against any copy of geth you like. Then make it yours: insert a key, and watch a leaf crack open into a branch the moment two keys disagree on a nibble. Try the do / dog / doge / horse set to see extensions form over the shared do:

Things worth watching for:

  • A leaf is not forever. Insert dog into a trie that only holds doe and the leaf doe/reindeer shatters: the shared do becomes an extension, and a branch forks on the next nibble (e for reindeer, g for puppy). One insert, one structural change, and the two values now hang off distinct paths.
  • Small nodes vanish into their parents. Every node is referenced by keccak256(rlp(node))unless its RLP is under 32 bytes, in which case the raw node is embedded inline in its parent instead of being hashed. This one rule is why hand-computed roots so often disagree with real ones; the widget marks every inlined node so you can see where the hashing stops.
  • The root is the only thing that always gets hashed. Everything the header stores about your entire account set is that one 32-byte number at the bottom. Reorder your inserts and it never changes — a trie is a set, not a log.

03 · Change one key, rewrite one path

Now the property the whole edifice exists for. In the widget above, every insert lights a few nodes red — those are the nodes that had to be re-hashed. Everything else keeps its exact hash and gets reused untouched. Add a tenth key to a trie of nine and you don't rebuild the trie; you rewrite the single path from the new leaf up to the root, and splice it onto the eight-and-a-bit branches that didn't move.

If that cascade feels familiar, it should. It's the git cascade from part one — change a blob, and only the trees and the commit on its path get new hashes — and it's the Bitcoin one — flip a transaction, and only its branch of the Merkle tree climbs to a new root. Same machine, third time: content-addressed structural sharing. A new block changes a handful of accounts out of hundreds of millions, so it rewrites a handful of paths and shares the entire rest of the tree with the previous state. That is how a full node can keep every historical state root without storing a fresh copy of the world at every block.

And it's why the storageRoot field back in part five was a hash and not a number. Each contract account's third field is the root of its own trie — its storage. So the state trie's leaves contain roots of other tries. The state root is a hash of hashes of hashes: a summary of every account, and through each contract's storageRoot, a summary of every storage trie too. The state is a tree of trees.

04 · The storage trie, or 2²⁵⁶ slots you don't store

Open one of those contract accounts and you find another trie with the same three node types — but its keys are storage slots. A contract's storage is, formally, an array of 2²⁵⁶ slots, each holding 32 bytes. That's about 10⁷⁷ slots, where 10⁸⁰ is the number of particles in the observable universe. Obviously nobody allocates that. The trie only ever holds the slots you actually wrote; every slot you never touched is an implied zero, stored nowhere.

Which raises the question every Solidity programmer eventually asks: if I write mapping(address => uint256) balances, where does balances[you] actually live? Not somewhere sequential — that would let one mapping's growth collide with the next variable. It lives at a slot derived by hashing. For a mapping declared at slot p, the value for key k sits at keccak256(pad32(k) ‖ pad32(p)) — key and slot each left-padded to 32 bytes, concatenated, hashed. Compute it live:

Things worth watching for:

  • The preimage is 64 bytes and nothing more. abi.encode(key, slot) is just the two 32-byte words back to back; the slot is one keccak256 of them. The widget shows the exact bytes going in. Load the Solidity documentation's own example — key 2 in a mapping at slot 1 — and you'll land on d9d16d34…, the number their docs print.
  • A dynamic array scatters differently. Its length sits in slot p itself, but its elements start at keccak256(pad32(p)) and run consecutively from there — so the data lives far off in hash-space where it can't collide with anything declared next to it.
  • An ERC-20 balance is one storage slot. Load the balances[holder] preset and you've computed the single 32-byte slot that holds someone's token balance. That's the whole trick behind the token post: a "balance" is a mapping entry, and a mapping entry is a hashed slot in a trie whose root hangs off an account whose root hangs off the state. Turtles, but you can name every one.

05 · The logsBloom, a filter that only ever says "maybe"

One more field in every block header earns its own trie-adjacent trick. When a transaction emits an event — a Transfer, say — the node records a log, and each log's address and indexed topics get folded into the block's 2048-bit logsBloom. (My course notes open the entry by calling this "the block's mining metadata," then explain the bloom filter correctly one paragraph later. The wrong line is the one that stuck, which is how these things go: it has nothing to do with mining. It's a summary of the event logs the block emitted, and it exists so a light client can ask "was there any Transfer to me in this block?" without downloading every log.)

A Bloom filter is a beautiful little lie, and it gets its own post — including the arithmetic that shows this particular one saturating on a busy block. Each topic sets exactly 3 of the 2048 bits, chosen by hashing. To ask whether a topic is present, you check its 3 bits. If any is unset, it is definitely absent — no false negatives, ever. But if all 3 happen to be set by other topics, the filter says "maybe" for something that was never there. Manufacture that false positive yourself:

Things worth watching for:

  • Three bits per topic, and that's the whole algorithm. The Yellow Paper calls it M3:2048: take keccak256(topic), read three 16-bit words off the front, mask each to 11 bits (mod 2048). The widget lights exactly those three.
  • "No" is certain, "yes" is a rumor. Probe a topic you never added and it fails on the first unset bit. Then saturate the filter with more logs and probe again — or hit "find a false positive" and watch it surface a topic the filter swears might be present and never was.
  • This is a real block header field. A busy block's bloom is dense enough that false positives are routine, which is exactly why a node treats a bloom hit as "worth reading the actual logs," never as proof.

06 · What's next

The compressed model: Ethereum's entire world is one 32-byte stateRoot, and under it a Merkle Patricia Trie of accounts, each contract account carrying the root of its own storage trie, every node named by keccak256(rlp(node)), every write rewriting one path and sharing the rest. It's the git Merkle tree with lookup, the Bitcoin cascade with a keyboard, and the same tamper-evidence for free. A balance is a hashed slot; a block's events are three bits each in a 2048-bit filter that only ever promises "maybe."

Honesty about where the toy stops:

  • The structure is current; a replacement is drafted. MPT is still Ethereum's state structure in 2026 — Verkle trees, long promised, did not ship. A binary-tree successor (EIP-7864, evolved out of the Verkle work) is planned to shrink proof sizes, but as of this writing it is not deployed. When you read that Ethereum "uses a Merkle Patricia Trie," that is still true.
  • My trie is legible, not adversarial. It's in-memory, models no deletion, and — the big one — keys real keys. Ethereum runs a secure trie: it stores every key as keccak256(key), so the state trie is really keyed by keccak256(address) and storage tries by keccak256(slot). That hashing keeps the tree balanced against an attacker who'd otherwise craft addresses sharing a long prefix. I skip it so the nibble paths spell words you can read.
  • The bloom's "maybe" is load-bearing. Never treat a bloom hit as a fact. It's a hint that saves a disk read, nothing more.

The series turns from where the state lives to what changes it:

  • The EVM: the world computer is a 256-bit stack machine, halts because it runs out of gas, and is single-threaded like JavaScript
  • Tokens: a contract that's just a mapping, and why a balance you now know is one storage slot became the thing everyone trades
  • The audit: reentrancy, front-running, and my own attacker contract from a university exam

Same promise as always: it's bytes all the way down, keccak256 all the way up, and you can reproduce every hash on this page.

$git log --oneline public/posts/ethereum-trie/
e2e7213Merkle trees and Bloom filters: the two structures the blog kept borrowingtoday
536605dNeural networks from scratch: a third post on pixels and vectorisationtoday
© 2026 · v2.0 · santiago toscanini