The State of the Machine - Part V: The Witness (Merkle Proofs)
"A Merkle Proof is a cryptographic Witness. It is a minimal subset of the database that allows a client to reconstruct the path to a specific value and verify that it mathematically connects to the trusted State Root."
Series Navigation:
- Introduction
- Part I: The Alphabet (Nibbles)
- Part II: The Four Horsemen (Nodes)
- Part III: The Serialization Trap (RLP)
- Part IV: The Surgical Strike (Insertion)
- Part V: The Witness (Merkle Proofs) <- You are here
PART V: THE WITNESS
Chapter 12: The Theory of Light Clients (Merkle Proofs)
We have built a fully functioning Ethereum State Trie. We can insert data, handle collisions, and calculate the global State Root hash.
If you are running an Archive Node (storing the entire history of Ethereum), you are done. You have the data.
But most of the world does not run Archive Nodes.
- Mobile Wallets (Metamask/Rainbow): They run on phones with limited storage.
- Smart Contracts (L2 Bridges): They run inside the EVM, where reading external state is impossible.
- Light Clients: They run on laptops with weak internet.
These clients have a problem. They know the Block Header (and therefore the State Root hash 0xABC...), because headers are small and easy to sync.
But they don't know the Account Balance of 0x123....
They have to ask a Full Node: "Hey, what is the balance of 0x123?"
The Full Node replies: "It is 50 ETH."
The Trust Problem:
How does the Light Client know the Full Node isn't lying?
Maybe the balance is actually 0, but the Full Node wants to trick the wallet into accepting a fake transaction.
This is where the Merkle Proof comes in.
A Merkle Proof is a cryptographic Witness. It is a minimal subset of the database that allows a client to reconstruct the path to a specific value and verify that it mathematically connects to the trusted State Root.
1. The Anatomy of a Proof
In a standard Binary Merkle Tree (like Bitcoin), a proof consists of the Sibling Hashes. To verify a leaf, you need the hash of its neighbor, and its neighbor's neighbor, all the way up.
In the Merkle Patricia Trie, the proof is simpler, yet heavier.
Because MPT nodes embed the hashes of their children directly inside them, we don't need "siblings." We just need the Nodes themselves.
A Proof is simply a list of RLP-encoded nodes: Vec<Vec<u8>>.
[RootNode, MiddleNode, ..., LeafNode]
The Verification Logic:
- Trust: The Client trusts the State Root Hash (from the Block Header).
- Step 1: The Client takes Item 0 from the proof (The Root Node).
- Hashes it:
Keccak(Item0). - Checks: Does
Hash == TrustedStateRoot? - If yes, Item 0 is valid.
- Hashes it:
- Step 2: The Client decodes Item 0. It looks at the path it wants to verify (e.g.,
0xA...).- It looks at Index
Ainside the decoded node. - It finds a hash pointer:
0xDEF....
- It looks at Index
- Step 3: The Client takes Item 1 from the proof.
- Hashes it:
Keccak(Item1). - Checks: Does
Hash == 0xDEF...(the pointer from the previous step)? - If yes, Item 1 is valid.
- Hashes it:
This chain continues until we hit the Leaf. If every link holds, the Value in the Leaf must be true. There is no mathematical way to fake it without breaking the hash function.
2. Implementing get_proof (The Generator)
Generating a proof is surprisingly easy. It is almost identical to the get function we wrote in Part II.
The only difference is that instead of returning just the value, we record every node we visit.
We add this method to src/trie.rs.
impl EthTrie {
/// Generates a Merkle Proof for a given key.
/// Returns a list of RLP-encoded nodes from Root to Leaf.
pub fn get_proof(&self, key: &[u8]) -> Vec<Vec<u8>> {
let nibbles = Nibbles::from_raw(key, false);
let nibbles_vec = nibbles.as_slice().to_vec();
let mut proof = Vec::new();
Self::get_proof_at(&self.root, &nibbles_vec, &mut proof);
proof
}
fn get_proof_at(node: &Node, nibbles: &[u8], proof: &mut Vec<Vec<u8>>) {
let encoded = rlp::encode(node);
proof.push(encoded.to_vec());
match node {
Node::Null => {
// Proof ends here - key doesn't exist
}
Node::Leaf { key: _, value: _ } => {
// Proof ends at leaf
}
Node::Extension { prefix, next } => {
if nibbles.len() >= prefix.len() && &nibbles[..prefix.len()] == prefix.as_slice() {
Self::get_proof_at(next, &nibbles[prefix.len()..], proof);
}
}
Node::Branch { children, value: _ } => {
if !nibbles.is_empty() {
let idx = nibbles[0] as usize;
Self::get_proof_at(&children[idx], &nibbles[1..], proof);
}
}
}
}
}
3. The "Proof of Non-Existence"
Notice something interesting in the code above?
We handle cases where the key is not found.
If I ask for a proof of 0x123 and that key doesn't exist, the Trie doesn't just say "Error."
It returns a proof that ends early.
- It shows me the Root.
- It shows me the Branch at
0x1.... - It shows me that the slot at
0x2isNull.
By proving that the slot is empty, the Full Node cryptographically proves that the key cannot exist.
This is critical for applications like "AirDrops" (proving you weren't on the list) or checking transaction nonces (proving a transaction hasn't happened yet).
4. Implementing Verification (The Client Side)
Now, let's write the code that runs on the "Phone" (the Client).
This function does not need access to EthTrie. It is a static helper.
It takes a Root Hash and a Proof, and returns the Value.
impl EthTrie {
pub fn verify_proof(
root_hash: &[u8; 32],
key: &[u8],
proof: &[Vec<u8>],
) -> Option<Vec<u8>> {
if proof.is_empty() {
return None;
}
let nibbles = Nibbles::from_raw(key, false);
let nibbles_vec = nibbles.as_slice().to_vec();
let first_item = &proof[0];
let computed_hash = Self::compute_hash(first_item);
if computed_hash != *root_hash {
return None;
}
Self::verify_proof_recursive(&nibbles_vec, proof, 0)
}
fn verify_proof_recursive(
nibbles: &[u8],
proof: &[Vec<u8>],
proof_index: usize,
) -> Option<Vec<u8>> {
if proof_index >= proof.len() {
return None;
}
let current_item = &proof[proof_index];
let node: Node = match rlp::decode(current_item) {
Ok(n) => n,
Err(_) => return None,
};
match node {
Node::Null => None,
Node::Leaf { key, value } => {
if key == nibbles {
Some(value)
} else {
None
}
}
Node::Extension { prefix, next: _ } => {
if nibbles.len() < prefix.len() || &nibbles[..prefix.len()] != prefix.as_slice() {
return None;
}
if proof_index + 1 >= proof.len() {
return None;
}
Self::verify_proof_recursive(&nibbles[prefix.len()..], proof, proof_index + 1)
}
Node::Branch { children: _, value } => {
if nibbles.is_empty() {
value
} else {
if proof_index + 1 >= proof.len() {
return None;
}
Self::verify_proof_recursive(&nibbles[1..], proof, proof_index + 1)
}
}
}
}
fn compute_hash(data: &[u8]) -> [u8; 32] {
let mut hasher = Keccak::v256();
let mut output = [0u8; 32];
hasher.update(data);
hasher.finalize(&mut output);
output
}
}
5. Why This Matters
The get_proof and verify_proof functions are foundational to modern Ethereum infrastructure:
Layer 2 Scaling:
Optimistic Rollups (Optimism/Arbitrum) and ZK Rollups rely on the ability to prove state transitions without executing them on the main chain. When you bridge ETH from L1 to L2, you're essentially verifying a Merkle Proof that says "I deposited 10 ETH into the bridge contract."
Light Clients:
Mobile wallets and browser extensions can verify account balances and transaction inclusion without downloading the entire blockchain. They only need:
- Block headers (~500 bytes each)
- Merkle proofs for specific queries (~1-5 KB each)
This makes Ethereum accessible to devices that couldn't possibly store the full ~1TB chain.
Cross-Chain Bridges:
Bridges between different blockchains use Merkle proofs to prove that an event occurred on the source chain without requiring the destination chain to validate every block.
By implementing this, we haven't just built a database. We've built the verification primitive that powers trustless computation.
In the next chapter, we will look at visualization tools that help us understand what's happening inside the trie.
Chapter 13: The Visualizer (Seeing the Invisible)
Throughout this journey, we have been working in the dark.
We insert a key, and we get back a State Root: 0x5f2a....
We insert another key, and the root changes: 0x9c3e....
But what is actually happening inside?
Did the Extension node split correctly?
Did the Branch node adopt the new child?
Did we accidentally leave a dangling pointer?
In C++ or Java, you might use a debugger to inspect memory. But our structure is recursive Box pointers. Inspecting this in a debugger just shows you memory addresses like 0x7f8830.... It doesn't tell you the structure.
To survive the complexity of the Merkle Patricia Trie, we need a tool to make the invisible visible.
We need an ASCII Visualizer.
1. The Challenge of Recursion
We want to output something that looks like a directory tree:
Root
├── Branch
│ ├── [0] -> Leaf "dog"
│ └── [1] -> Extension "12" -> Leaf "cat"
└── Leaf "zebra"
To do this, we need a recursive function that tracks:
- The Current Node.
- The Current Depth (indentation).
- The Connector Style (Is this the last child? Do we print
└──or├──?).
2. The Implementation
We implement a print_tree method that recursively prints the trie structure with proper indentation.
impl EthTrie {
pub fn print_tree(&self) {
println!("trie structure:");
println!("root hash: {}", hex::encode(self.root_hash()));
Self::print_node(&self.root, 0, "");
}
fn print_node(node: &Node, depth: usize, prefix: &str) {
let indent = " ".repeat(depth);
match node {
Node::Null => {
println!("{}{}[null]", indent, prefix);
}
Node::Leaf { key, value } => {
println!(
"{}{}[leaf] path: {:?}, value: {:?}",
indent,
prefix,
Self::format_nibbles(key),
String::from_utf8_lossy(value)
);
}
Node::Extension { prefix: ext_prefix, next } => {
println!(
"{}{}[extension] prefix: {:?}",
indent,
prefix,
Self::format_nibbles(ext_prefix)
);
Self::print_node(next, depth + 1, "└─ ");
}
Node::Branch { children, value } => {
if let Some(v) = value {
println!(
"{}{}[branch] value: {:?}",
indent,
prefix,
String::from_utf8_lossy(v)
);
} else {
println!("{}{}[branch]", indent, prefix);
}
for (i, child) in children.iter().enumerate() {
if !matches!(**child, Node::Null) {
let child_prefix = format!("[{:x}] ", i);
Self::print_node(child, depth + 1, &child_prefix);
}
}
}
}
}
fn format_nibbles(nibbles: &[u8]) -> String {
nibbles
.iter()
.map(|n| format!("{:x}", n))
.collect::<Vec<_>>()
.join("")
}
}
3. The Debugging Epiphany
Why is this visualizer so important?
Because of the complexity of node splitting operations.
When implementing the insertion logic, especially the Extension and Leaf splitting cases, it's easy to make off-by-one errors in nibble slicing. You might keep one nibble too many in a prefix, or drop one when creating a child node.
Without visualization, these bugs manifest as hash mismatches - the most unhelpful error message imaginable. The hashes are completely different due to the avalanche effect, giving you no clue about where the logic went wrong.
With the visualizer, you can run:
trie.insert("dog", "puppy");
trie.insert("do", "verb");
trie.print_tree();
And immediately see the structure. If an Extension node has the wrong prefix length, or a Branch appears where it shouldn't, it's visually obvious. This transforms debugging from "staring at hex dumps for hours" to "spot the structural error in seconds."
4. Interactive Demo
The visualizer enables a powerful demo command in the CLI that shows the trie evolving in real-time.
// main.rs
if let Some(("demo", _)) = matches.subcommand() {
let mut trie = EthTrie::new();
println!("1. Empty Trie");
trie.print_tree();
println!("\n2. Insert 'dog' -> 'puppy'");
trie.insert(b"dog", b"puppy");
trie.print_tree();
println!("\n3. Insert 'do' -> 'verb' (SPLIT!)");
trie.insert(b"do", b"verb");
trie.print_tree();
}
This transforms the project from a library into an interactive learning tool. Users (and recruiters) can see the algorithm working, watch nodes split, and understand why Extension nodes are created.
Chapter 14: The Cost of Truth (Performance Analysis)
We have successfully implemented the Ethereum State Trie.
- It is Cryptographically Secure (Merkle Property).
- It is Storage Efficient (Patricia Compression).
- It is IO Optimized (Hexary Radix).
But there is no such thing as a free lunch.
Every design choice we made (the 16-way branching, the recursive RLP encoding, the infinite Box pointers), comes with a computational price tag.
In this chapter, we are going to profile merkle-trie-rs. We are going to look at the memory overhead of our Rust implementation and verify if the "Hexary" architecture actually delivers the performance promises made in the Yellow Paper.
1. The Cloning Trade-off (Rust Specific)
One of the key design decisions in this implementation is the use of Cloning during insertion.
// Our Insert Logic
let old_root = self.root.take();
let new_root = self.insert_at(*old_root, ...);
In a traditional mutable data structure (like a HashMap), inserting an item is typically O(1) or O(log N). You find the slot and write to it in place.
In our Persistent Data Structure, we copy nodes along the path being modified. If we insert a key that is 6 levels deep:
- We clone the Root Branch.
- We clone the Child Branch.
- We clone the Grandchild Branch.
- ...
- We create a new Leaf.
This means we're allocating new memory for each node on the path.
The Trade-off:
While this sounds expensive, it brings significant benefits:
- Immutability: Old tree versions remain valid
- Concurrent Access: Multiple readers can access different versions without locks
- Cryptographic Integrity: Each version has its own immutable hash
For Ethereum's use case, where verifying state transitions is more important than raw insertion speed, this trade-off makes sense.
2. The Hexary Benchmark (Base-2 vs Base-16)
The theoretical advantage of the Hexary architecture is clear: fewer levels means fewer disk seeks.
Theoretical Analysis:
| Metric | Binary Tree | Hexary Trie (MPT) | Improvement |
|---|---|---|---|
| Tree Depth (256-bit key) | 256 levels | ~64 levels | 4x Shallower |
| Branching Factor | 2 children | 16 children | 8x Wider |
In practice, the Patricia compression (Extension nodes) further reduces the depth to 6-8 levels for most real-world Ethereum state trees, making lookups extremely efficient even with hundreds of millions of accounts.
This confirms the thesis of Part I: the overhead of managing 16-element arrays is negligible compared to the savings from traversing a shallower tree.
3. The Serialization Bottleneck
However, profiling revealed a villain.
It wasn't the Tree Traversal. It wasn't the Nibble Slicing.
It was RLP Encoding.
When we calculate the Root Hash, we have to serialize the entire tree.
keccak256(rlp::encode(node))
Because of the Inline Node Optimization, this process is recursive and chatty.
We are allocating thousands of tiny Vec<u8> buffers just to throw them away immediately after hashing.
Optimization Opportunity:
In a production version (v2.0), we would implement a Lazy Hash Cache.
Instead of recalculating the hash every time, we would add a field to the Node struct:
struct Branch {
children: [Box<Node>; 16],
dirty: bool, // Has this changed?
cached_hash: [u8; 32] // Memoized result
}
If we touch a node, we mark it dirty. If we ask for the hash of a clean node, we return the cached bytes instantly.
This would turn our O(N) root calculation into O(k) (where k is the number of modified nodes).
4. Memory Usage (The Sparse Array Cost)
There is one downside to Base-16.
Every Branch Node has an array of 16 pointers: [Box<Node>; 16].
On a 64-bit system, a pointer is 8 bytes.
128 bytes per branch node, just for pointers.
In a sparse tree (where most branches only have 2 or 3 children), this is wasted space. We are storing Node::Null pointers 80% of the time.
Ethereum accepts this trade-off. RAM is cheap; Disk seeks are expensive. Wasting 100 bytes of RAM to save 10 milliseconds of Disk I/O is a winning trade every single time.
5. Summary
Our performance analysis confirms:
- Hexary Architecture works: The tree stays remarkably shallow (6-8 levels) even with millions of keys.
- Rust Ownership works: The cost of cloning nodes is acceptable for the safety and concurrency benefits it provides.
- Optimization Path: The next bottleneck to attack is RLP allocation, likely via caching.
We have built a system that is not just correct, but performant enough to be the engine of a real blockchain.
Chapter 15: The Epilogue (The State of the Machine)
We started this journey with a question: "How does the machine know what is true?"
Rather than accepting high-level answers ("Consensus" or "Blockchain"), we wanted to understand the mechanical reality. We wanted to see the actual data structures and algorithms.
Over the last 14 chapters, we built those mechanisms from scratch:
- We built a Translation Layer (
Nibbles) to bridge the gap between our binary hardware and the EVM's hexary logic. - We architected the Recursive Types (
Node) to represent the structure of the state. - We implemented the RLP Serialization logic, navigating the minefield of the "Inline Node Optimization."
- We performed Surgical Insertions, splitting immutable nodes and rewriting history without breaking the cryptographic chain.
- We generated Merkle Proofs, empowering light clients to verify the truth without possessing the database.
And finally, we visualized it. We saw the tree grow, split, and hash itself into existence.
1. The Rust Advantage
When I started this project, I asked: "Why is every new blockchain client written in Rust?"
(Reth, Polkadot, Solana, Near, Aptos).
Now I understand.
Implementing a Merkle Trie requires handling a massive explosion of edge cases.
- "What if the key ends at a Branch?"
- "What if the Extension prefix matches only halfway?"
- "What if the Node is Null?"
In C++, these edge cases lead to undefined behavior and crashes.
In Go, these are nil pointer panics at runtime.
In Rust, the match statement forces us to handle every single case explicitly. The compiler will not let the program compile until we answer the question: "What happens if this Option is None?"
Rust's Enum system is the unsung hero of blockchain engineering. It allows us to model the mathematical states of the Yellow Paper directly in the type system.
2. The Lesson of Compatibility
The hardest part of this project was not the algorithms; it was the Compatibility.
When you build your own database, you set the rules. If you want to serialize an integer as 4 bytes, you do it.
When you implement Ethereum, you are handcuffed to decisions made by Vitalik Buterin and Gavin Wood in 2014.
- You must use Keccak-256 (not SHA-3).
- You must use RLP (not JSON).
- You must inline nodes smaller than 32 bytes.
There is no room for creativity in the implementation details. There is only Compliance.
This taught me a profound respect for the client developers (Geth, Nethermind, Reth) who maintain this compatibility across hard forks and upgrades. They are the custodians of the protocol's truth.
3. What We Didn't Build (The Roadmap)
merkle-trie-rs is a fully functional, in-memory implementation. But it is not yet a production database.
If I were to take this to v2.0, here is what I would build:
- Disk Persistence: Right now, the trie lives in RAM. We need to implement a backend (like RocksDB or MDBX) to map
Hash -> RLP(Node)and store it on an SSD. - Lazy Loading: We currently load the whole tree into memory. A real client only loads the path it needs. We would need to change
Box<Node>toHashOr<Node>. - Secure Tries: We didn't implement the key-hashing mechanism used in Mainnet to prevent "DoS Grinding Attacks" (where an attacker crafts keys to create deep branches).
4. Final Thoughts
We often treat the State Root 0x... as an opaque identifier.
But it's the cryptographic fingerprint of millions of nibbles, thousands of node splits, and billions of hash operations, all working in deterministic unison.
This project was built to understand Ethereum's state management from first principles.
The design reflects an optimization philosophy that prioritizes disk I/O efficiency and verification speed over implementation simplicity. It's a system from an era where 500GB SSDs were expensive and bandwidth was scarce - yet it continues to scale effectively with hundreds of millions of accounts.
The code is open source. The tests are passing. The implementation is verified.
I hope this deep dive has helped demystify the black box.
The Repository:
github.com/bit2swaz/merkle-trie-rs
"Don't Trust. Verify."
~ @bit2swaz
Series Complete! Return to Introduction or explore other parts.
THE END
