Skip to content

Merkle Proofs and Light Clients

The last five pages built a machine: RLP and nibbles as the encoding, leaf, extension, and branch nodes as the shapes, and the four tries as the places Ethereum keeps its state. Every one of those tries collapses to a single 32-byte root, and the state root sits in the block header for every node to compare. So far we have used that root for one job: letting two full nodes check, in one cheap comparison, that they reached the same world.

This page cashes in the other property a Merkle-Patricia trie was chosen for — not a plain Merkle tree, not a flat sort-and-hash — the one a flat hash cannot give you: a proof. Holding only a trusted root, you can be convinced that “account 0xAbc… has balance B and nonce N” — or that some account does not exist at all — by receiving a handful of nodes and hashing them yourself. That is the mechanism behind every light client, every bridge, and every phone wallet that trusts state it never downloaded. It is the answer to the book’s throughline for anyone who cannot run a full node: how do untrusting strangers agree on the state of a shared world computer — without each holding the whole thing?

Recall that in a Merkle-Patricia trie, a node names its children by their hashes. A branch node does not embed its children; it holds the 32-byte keccak256 hash of each child’s encoding. The root itself is just the hash of the topmost node. This is what makes the structure authenticated: you cannot change a leaf deep in the trie without changing its hash, which changes its parent’s contents, which changes the parent’s hash, all the way up to the root.

A Merkle proof for a key is exactly the set of nodes you would walk through to look that key up, handed to you in order:

the trie (only the path to our key is drawn):
┌──────────┐
stateRoot ──► │ branch │ ← root node (hashed = stateRoot)
└────┬─────┘
│ nibble 'a'
┌────▼─────┐
│ extension│ ← shared path "bc"
└────┬─────┘
┌────▼─────┐
│ branch │
└────┬─────┘
│ nibble '3'
┌────▼─────┐
│ leaf │ value = RLP(nonce, balance, storageRoot, codeHash)
└──────────┘
proof = [ branch, extension, branch, leaf ] ← the ordered nodes on this path

The proof is not the whole trie. It is one root-to-leaf path — plus, at each branch on that path, the hashes of the siblings the path did not take (those hashes already live inside the branch node, so you get them for free). Everything off the path is omitted. For a trie of n entries the path is O(log n) nodes deep, so the proof is O(log n) nodes — a few kilobytes to prove one account out of hundreds of millions.

Verifying a proof: re-hash bottom-up, check the root

Section titled “Verifying a proof: re-hash bottom-up, check the root”

The verifier starts with exactly two trusted things:

  1. the stateRoot, copied from a block header it trusts, and
  2. the key it is asking about (for an account, keccak256(address) — the trie is keyed by the hash of the address, as the four-tries page explained).

Verification never trusts the prover. It re-derives everything:

1. Hash the FIRST node in the proof. Does it equal the trusted stateRoot?
no → REJECT (the prover handed you a fake or wrong root node)
yes → this node is authentic. Read it.
2. This node tells you which child to follow for the next nibble of the key,
and names that child by its hash H.
3. Hash the NEXT node in the proof. Does it equal H?
no → REJECT (broken chain — this node isn't the child it claims to be)
yes → authentic. Recurse to step 2 with this node.
4. When you reach the LEAF for your key: read its value. That value is proven.

Every node in the chain is pinned by a hash the previous node committed to, and the first node is pinned by the root you already trust. So the moment all the hashes line up, the leaf’s value is as trustworthy as the block header — no more, no less. A single flipped byte anywhere in the proof breaks a hash equality and the whole thing is rejected. There is nothing to trust about the party who sent the proof; a malicious full node cannot make you accept a false balance, only refuse to answer.

Under the hood — why keccak256 and RLP appear here

Section titled “Under the hood — why keccak256 and RLP appear here”

Two details make the equalities work. First, the hash is keccak256 — the same 32-byte hash the trie uses everywhere — so “hash this node and compare” is a well-defined, universal operation. Second, each node is RLP-encoded (RLP and nibbles) before it is hashed, and RLP is a canonical encoding: one and only one byte string represents a given node. Canonicality is load-bearing. If two different byte strings could encode the same node, a prover could ship the encoding whose hash happened to match while its contents said something else — and the proof system would leak. Because RLP is canonical, hash(node) == H means the node’s contents are exactly what the parent committed to. The leaf’s value is itself RLP: for an account it is RLP([nonce, balance, storageRoot, codeHash]), so once the leaf is proven you can read all four fields directly.

Inclusion vs. exclusion: proving what isn’t there

Section titled “Inclusion vs. exclusion: proving what isn’t there”

An inclusion proof shows a key is present with value V: the path ends in a leaf whose remaining key nibbles match, and the leaf carries V. That is the case above.

The subtler power is the exclusion proof — proving a key is absent, which a flat list of hashes cannot do at all. The trie’s structure makes it natural, because “absent” leaves a visible shape:

Ask for key a-b-c-9-9… but the trie only ever stored a-b-c-3…
┌──────────┐
│ branch │ at nibble '9' this branch's slot is EMPTY
└────┬─────┘
nibble '9' → nil
  • Absent at a branch. You walk the key nibble by nibble and reach a branch whose slot for the next nibble is empty (nil). The proof is the path down to that branch. You verify every node up to it against the root, see the empty slot, and conclude: no key with this prefix exists. It cannot be there, because if it were, that slot would name a child.
  • Diverges at a leaf or extension. You reach a leaf (or extension) whose stored key-fragment does not match the nibbles you still need. Since a trie stores each key along exactly one path, a mismatch here means your key was never inserted — the path that would hold it forks off elsewhere.

Either way the verifier re-hashes the supplied path to the root exactly as before; the conclusion is “absent” instead of a value. Exclusion proofs are why a light client can trust a 0 balance for an address it has never seen: not “I couldn’t find it” (which you’d have to take on faith) but “the trusted root cryptographically commits that it isn’t here.”

Put inclusion and exclusion together and you have a light client: a program that verifies state without storing or executing it.

A full node downloads every block, replays every transaction, and holds the entire multi-hundred-gigabyte state trie. A light client does almost none of that. It follows only the chain of block headers — each header is a few hundred bytes and contains the stateRoot — and it obtains those headers through consensus (post-Merge, by verifying the beacon chain’s signatures, so it knows a header is canonical without trusting any single peer). That gives it one precious thing: a trusted stateRoot for a recent block.

From that root alone, it can answer real questions by asking any full node for a proof and checking it locally:

"What is the balance of 0xAbc… ?"
light client ── eth_getProof(0xAbc…) ──► full node
full node ── [path of trie nodes] ──► light client
light client: re-hash path, check == trusted stateRoot,
read balance from the leaf. ✓ trustless.

The JSON-RPC method eth_getProof (standardised as EIP-1186) returns exactly this: the accountProof (nodes from the state root down to the account leaf) and, for each storage slot you asked about, a storageProof (nodes from that account’s storageRoot down to the slot leaf). Note the two-level structure — it mirrors the two-level trie from the four-tries page: first prove the account against the state root, read its storageRoot from the proven leaf, then prove the slot against that. A phone can verify a Uniswap pool’s reserves this way while holding nothing but a header.

Crucially, the full node serving the proof is untrusted. It can refuse to answer, but it cannot lie: a forged proof fails a hash check. This is the whole point — the light client offloads storage and computation to a powerful stranger while keeping verification for itself. The stranger did the work of running the world computer; the phone checks the receipt.

  • Why does it exist? Because most participants cannot run a full node — a phone, a browser tab, or a contract on another chain has neither the storage for hundreds of gigabytes nor the bandwidth to replay every block — yet they still need to know state, not merely be told it.
  • What problem does it solve? It lets a party holding only a trusted stateRoot verify any account or storage slot for itself, converting “trust this full node’s answer” into “check this proof against a root I already believe.” Verification moves to the weak device; storage and computation stay on the strong one.
  • What are the trade-offs? Proofs are O(log n) nodes — a few KB, not free — and every query is a round-trip to some full node that can withhold an answer (liveness) even though it cannot forge one (safety). And a proof is only as trustworthy as the root it is checked against, which pushes the hard problem onto consensus.
  • When should I avoid it? When you need to execute new transactions, scan for all accounts matching a predicate, or answer historical queries at scale — proofs verify point lookups against a known root, not general computation or bulk scans. For that you still want a full/archive node.
  • What breaks if I remove it? Light clients, trust-minimised bridges, and stateless verification all collapse; the only way to know state becomes downloading and replaying the entire chain, and the world computer becomes verifiable only by those rich enough to hold all of it — exactly the centralisation the design exists to prevent.
  1. In one sentence, what is a Merkle-Patricia proof for a key, and what two trusted inputs does a verifier need to check it?
  2. Walk the verification algorithm: starting from the trusted stateRoot, how does re-hashing the supplied nodes bottom-up (or top-down) convince you the leaf’s value is authentic, and what single thing causes a reject?
  3. The trie can prove a key is absent, which a flat sort-and-hash root cannot. Describe the two shapes an exclusion proof can take, and why each one means “this key was never inserted.”
  4. eth_getProof returns an accountProof and, per slot, a storageProof. Why are there two levels, and what value from the first proof do you need before you can check the second?
  5. A bridge verifies a valid Merkle proof and mints tokens, yet still gets drained. Using the two halves of “verification,” explain what the attacker broke and what the proof itself did and did not guarantee.
Show answers
  1. It is the ordered set of trie nodes along that key’s path, from the root node down to its leaf (including, at each branch, the sibling hashes already stored inside the branch). The verifier needs only the trusted stateRoot (from a block header) and the key it is asking about (keccak256(address) for an account).
  2. You hash the first node and check it equals the trusted stateRoot; that node names its next child by a hash H; you hash the next supplied node and check it equals H; you recurse down to the leaf. Each node is pinned by a hash the previous node committed to, and the top is pinned by the root you already trust, so once every equality holds the leaf’s value is as trustworthy as the header. Any single flipped byte breaks a hash equality and the proof is rejected — the sender is never trusted.
  3. (a) Empty branch slot: you walk the key’s nibbles to a branch whose slot for the next nibble is nil; if the key existed, that slot would name a child, so it cannot. (b) Divergent leaf/extension: you reach a leaf or extension whose stored key-fragment does not match the nibbles you still need; since each key lives on exactly one path, the mismatch means the key was never inserted. Both are verified by re-hashing the supplied path to the root exactly as an inclusion proof is.
  4. Because Ethereum uses a two-level trie: the state trie maps keccak256(address) → account, and each contract has its own storage trie rooted at the account’s storageRoot. So you first prove the account against the stateRoot, read the storageRoot out of the proven account leaf, then prove each slot against that storageRoot. Without the proven storageRoot you have no root to check the storage proof against.
  5. Verification has two halves: “is this proof consistent with root R?” (the hashing on this page, essentially unbreakable) and “should I trust root R?” (consensus). The proof guaranteed only the first — that the value is consistent with R. The attacker broke the second: they made the bridge accept a root (or a set of validator signatures over one) that consensus never really endorsed. The proof did its job; the bridge trusted the wrong root.