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?
What a Merkle proof is
Section titled “What a Merkle proof is”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 pathThe 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:
- the stateRoot, copied from a block header it trusts, and
- 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.”
The light-client model
Section titled “The light-client model”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.
The architect’s lens
Section titled “The architect’s lens”- 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
stateRootverify 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.
Check your understanding
Section titled “Check your understanding”- In one sentence, what is a Merkle-Patricia proof for a key, and what two trusted inputs does a verifier need to check it?
- 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? - 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.”
eth_getProofreturns anaccountProofand, per slot, astorageProof. Why are there two levels, and what value from the first proof do you need before you can check the second?- 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
- 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). - You hash the first node and check it equals the trusted
stateRoot; that node names its next child by a hashH; you hash the next supplied node and check it equalsH; 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. - (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. - 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’sstorageRoot. So you first prove the account against thestateRoot, read thestorageRootout of the proven account leaf, then prove each slot against thatstorageRoot. Without the provenstorageRootyou have no root to check the storage proof against. - Verification has two halves: “is this proof consistent with root
R?” (the hashing on this page, essentially unbreakable) and “should I trust rootR?” (consensus). The proof guaranteed only the first — that the value is consistent withR. 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.