The State of the Machine - Part I: The Alphabet (Nibbles)
Welcome to Part 1 of the "State of the Machine" series - We begin with the physics of the disk, explore why Binary Trees fail at scale, why Ethereum chose the Hexary Radix, and the bitwise surgery required to implement a virtual
u4type system in a world that only speaksu8.
Series Navigation:
- Introduction
- Part I: The Alphabet (Nibbles) <- You are here
- Part II: The Four Horsemen (Nodes)
- Part III: The Serialization Trap (RLP)
- Part IV: The Surgical Strike (Insertion)
- Part V: The Witness (Merkle Proofs)
PART I: THE ALPHABET
Chapter 1: The Hexary Constraint (The Physics of the Disk)
When you decide to build a blockchain, or really any distributed state machine, the first question you have to answer isn't "Which consensus algorithm do I use?" or "Rust vs. Go?".
The first question, the one that will determine if your system even scales is: "How deep is my tree?"
This sounds like an academic question. It sounds like something you'd hear in a Data Structures class, answered by a professor who hasn't shipped production code in twenty years. But in the context of the Ethereum Virtual Machine (EVM), this question essentially dictates the performance ceiling of the entire global financial computer.
To understand why I built merkle-trie-rs the way I did, and why the Ethereum Yellow Paper makes the specific (and quite often frustrating) choices it does, we have to start with the fundamental failure of the Binary Tree in the context of cryptography.
1. The Scale of the Problem
Let's look at the numbers.
As of 2026, there are roughly 280 Million unique addresses on the Ethereum Mainnet.
Every single one of these addresses has a State:
- Nonce: How many transactions has it sent?
- Balance: How much Wei does it own?
- Storage Root: If it's a contract, what is the hash of its internal database?
- Code Hash: If it's a contract, what is the hash of its bytecode?
This is the "World State."
We need to store 280 million of these objects. And we need to do it in a way that allows us to:
- Read: Get the balance of Address X quickly.
- Write: Update the balance of Address X efficiently.
- Verify: Produce a single 32-byte hash (The State Root) that fingerprint-locks the entire dataset.
If we just wanted Read/Write, we would use a Hash Map or a B-Tree (like SQL). But we need Verification. We need that Root Hash. This forces us to use a Merkle Structure.
2. The Binary Failure
The most obvious choice for a Merkle Structure is the Binary Merkle Tree. This is what Bitcoin uses for its transactions.
In a Binary Tree, every node has exactly two children: Left (0) and Right (1).
This is efficient for in-memory structures. CPU branch prediction loves binary choices. It fits perfectly into L1 cache lines.
But Ethereum state doesn't live in memory. It lives on Disk (NVMe/SSD). And disks hate depth.
Let's do the math that kills the Binary Tree.
An Ethereum address is 160 bits (20 bytes). To prevent grinding attacks, we typically hash the address with Keccak-256 before storing it.
So our "Key" is 256 bits long (32 bytes).
In a Binary Radix Tree, we traverse the key one bit at a time.
- If bit 0 is
0, go Left. - If bit 0 is
1, go Right.
How deep is the tree to store a 256-bit key?
It is 256 levels deep.
To look up one balance, you have to traverse 256 pointers.
In a worst-case scenario (a "Cold Read"), where none of these nodes are cached in RAM, that is 256 separate disk seeks.
Let's look at the physics of an NVMe drive:
- Random Read Latency: ~30 microseconds (0.03ms).
- 256 Seeks: 256 times 0.03ms = 7.68ms.
7.68 milliseconds to read a single account.
That sounds fast, right?
Hahaha. Wrong.
A single Ethereum block has a "Gas Limit" of 30 Million. A simple transaction uses 21,000 gas. A complex DeFi transaction might check the balances of 10 different tokens, read 5 oracle prices, and update 3 liquidity pool states. A single transaction might touch 20-50 state objects.
50 reads times 7.68ms = 384ms.
A third of a second just to read the data for one transaction.
If you want to process 100 transactions per block, you need 38 seconds. But Ethereum block time is 12 seconds.
The Binary Tree is mathematically impossible for Ethereum. It is simply too deep. The physics of disk I/O latency makes it non-viable.
3. Enter The Radix (The Branch Factor)
The solution to depth is Width.
If we make the tree wider, we make it shallower. This is the core principle of B-Trees, which power almost every database you use (Postgres, MySQL).
If we increase the "Branching Factor" (the number of children per node), we decrease the height of the tree by a logarithmic factor.
Ethereum takes a middle ground. It doesn't use a B-Tree (which is mutable and hard to verify cryptographically). It uses a Hexary Trie.
"Hexary" means Base-16.
Instead of 0 or 1, every step in the path allows for 16 choices:
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F.
Let's re-run the math with this new architecture.
If we have a 256-bit key, and we consume 4 bits (one "nibble") per step:
256 bits / 4 bits per level = 64 levels
We have cut the depth of the tree by a factor of 4.
Instead of 256 disk seeks, we have 64.
And with Extension Nodes (which we will cover in Part II), we can collapse that depth even further. In practice, an Ethereum account is rarely more than 6-8 levels deep in the mainnet state trie.
This brings our read time down from ~8ms to ~0.5ms. That is the difference between a working blockchain and a stalled one.
4. The Trade-off: Sparse vs. Dense
You might ask (and this is a question I asked myself when reading the spec), "If width is so good, why stop at 16? Why not Base-256? Why not a Base-1024 trie? We could make the tree 3 levels deep."
This is where the engineering reality hits you. The memory overhead.
If we made a Base-256 Trie (where every step consumes a full byte), every single "Branch Node" would need to store 256 pointers (Hashes) to its children.
A Keccak Hash is 32 bytes.
256 children times 32 bytes = 8,192 bytes (8 KB)
Every branch node would be 8 KB.
Most nodes in a Trie are Sparse. They don't have children in every slot. If you have a path 0xABCD..., and no other keys share that prefix, your branch node at 0xA will have one child (at index B) and 255 NULL pointers.
You are storing massive arrays filled with 99% empty space. You are bloating the database size with zeros. This destroys your disk throughput, because now you are reading 8KB pages just to get 32 bytes of data.
Base-16 (Hexary) is the Goldilocks zone.
A Branch node has 16 children.
16 children times 32 bytes = 512 bytes
512 bytes is magic.
- It fits comfortably inside a standard Ethernet MTU (1500 bytes).
- It fits inside the smallest standard disk sector.
- It is small enough to be efficient to hash (Keccak-256 cost is proportional to data size).
- It is wide enough to keep the tree shallow.
5. The Implementation Reality (The Mismatch)
So, I sat down to write merkle-trie-rs with this knowledge. I understood the "Why."
I opened my IDE, typed cargo new merkle-trie-rs, and realized my first major implementation hurdle.
My computer speaks Bytes (u8).
Rust speaks Bytes.
The Operating System speaks Bytes.
The File System speaks Bytes.
But the Ethereum Trie speaks Nibbles (u4).
There is no u4 primitive in Rust. You cannot allocate 4 bits of memory. The smallest addressable unit of memory in modern computing is the Byte (8 bits).
If I want to traverse the path 0xABC, I cannot just index into a byte array.
0xABCis 3 nibbles.- It is 1.5 bytes.
- You cannot have 1.5 bytes in a
Vec<u8>.
This means that before I could write a single line of logic for the Trie, before I could define a Node or hash a value, I had to build a Translation Layer.
I had to build a virtual type system that allowed me to address data at the 4-bit level, while the hardware underneath forced me to work in 8-bit alignment.
This mismatch, i.e., the friction between the "Virtual" Hexary world and the "Physical" Binary world, is where the complexity begins.
And it is exactly where we must start coding.
In the next chapter, we are going to perform the "Bitwise Surgery". We are going to implement the Nibbles struct, and we are going to learn how to manipulate bits at the sub-byte level without losing our sanity.
Chapter 2: The Bitwise Surgery (Nibbles)
In the previous chapter, we established that the Ethereum State Trie is a Hexary structure. It thinks in Base-16.
But your computer's RAM, your SSD, and the Rust standard library all think in Base-256 (Bytes).
This creates a fundamental impedance mismatch.
When we want to traverse the path 0xABCD, we are conceptually taking 4 steps:
- Go to child
0xA(10) - Go to child
0xB(11) - Go to child
0xC(12) - Go to child
0xD(13)
However, on the disk, this key is stored as 2 bytes: [0xAB, 0xCD].
If we try to iterate over this Vec<u8>, we only get 2 steps. The Trie logic breaks.
To bridge this gap, we must implement a Translation Layer. We need a data structure that allows us to ingest raw Bytes from the outside world, surgically split them into 4-bit chunks, and expose an API that pretends we are working with u4 integers.
We call this structure: Nibbles.
1. The Anatomy of a Nibble
A "Nibble" is half a byte. It is 4 bits.
The maximum value of a nibble is 1111 in binary, which is 15 in decimal, or F in Hex.
This maps perfectly to our 16-way branch nodes.
Since Rust does not have a u4 type, we have two implementation choices:
Option A: Bit-Packing
Store the data as Vec<u8>. Keep track of an offset (in bits).
- Pros: Memory efficient (uses 50% less RAM).
- Cons: Implementation hell. To read the 3rd nibble, you have to read the 2nd byte, mask the upper 4 bits, and shift. To slice it, you need complex bitwise math.
Option B: Byte-Expansion (The "Fat Nibble")
Store each nibble in its own u8.
- Input:
0xAB(1 byte) - Storage:
[0x0A, 0x0B](2 bytes) - Pros: O(1) access. Slicing is just
vec[i]. Debugging is trivial. - Cons: Uses 2x memory.
For merkle-trie-rs, I chose Option B.
Why? Because the EVM State Trie is IO-bound, not Memory-bound. Wasting a few megabytes of RAM to save CPU cycles on bitwise shifting (and to save myself from debugging hell) is a worthy trade.
2. The Implementation
Let's define our struct in src/nibbles.rs.
/// A wrapper around a vector of bytes, where each byte represents a single nibble (0-15).
/// Invariant: Every u8 in `data` must be < 16.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Nibbles {
data: Vec<u8>,
}
impl Nibbles {
/// Creates a Nibbles instance from a raw byte slice.
/// Example: raw `[0xAB, 0xCD]` -> data `[0xA, 0xB, 0xC, 0xD]`
pub fn from_raw(data: &[u8], _is_leaf: bool) -> Self {
let mut nibbles = Vec::with_capacity(data.len() * 2);
for &byte in data {
// The Bitwise Surgery happens here.
// We need to extract the High Nibble and the Low Nibble.
// 1. Extract High Nibble (The left 4 bits)
// Operation: Shift right by 4
// 0xAB = 1010 1011
// Shift: >> 4
// Result:0000 1010 (0x0A)
nibbles.push(byte >> 4);
// 2. Extract Low Nibble (The right 4 bits)
// Operation: Mask only
// 0xAB = 1010 1011
// Mask: 0000 1111 (0x0F)
// Result:0000 1011 (0x0B)
nibbles.push(byte & 0x0F);
}
Self { data: nibbles }
}
}
This from_raw function is the gatekeeper. It takes the "Physical" representation (Bytes) and converts it into our "Virtual" representation (Nibbles).
Note that the second parameter _is_leaf is present for API consistency but unused in the actual implementation. The leaf/extension distinction is handled during encoding, not during nibble creation.
3. The Bitwise Logic Deep Dive
Let's pause and look at that loop again. This is the first time we touch the metal.
nibbles.push(byte >> 4);
The right shift >> automatically handles the extraction. When we shift right by 4 bits, the high nibble moves to the low position, and the upper bits become zero.
1010 1011 >> 4 becomes 0000 1010.
For the low nibble:
nibbles.push(byte & 0x0F);
Here, the mask is mandatory.
1010 1011 (0xAB).
If we don't mask, we still have 1010 1011 (171). That is not a valid nibble. We must zero out the top bits.
1010 1011 & 0000 1111 -> 0000 1011 (11, or 0xB).
Now we have a clean integer between 0 and 15.
4. The Matching Problem (Common Prefixes)
The most critical operation in a Merkle Patricia Trie is not insertion; it is Path Splitting.
When we insert a key, we often hit an Extension Node.
- Extension Path:
0xA, 0xB, 0xC - New Key Path:
0xA, 0xB, 0xD
We need to efficiently find the Common Prefix to know where to split the tree.
Since we stored our nibbles as a Vec<u8>, this becomes a trivial iteration loop. If we had chosen "Bit-Packing," this function would be a nightmare of bit-offsets.
impl Nibbles {
/// Returns the number of nibbles shared at the beginning of two paths.
/// Example: `[A, B, C]` and `[A, B, D]` -> returns 2.
pub fn get_common_prefix_length(a: &Nibbles, b: &Nibbles) -> usize {
let mut count = 0;
let min_len = a.len().min(b.len());
for i in 0..min_len {
if a.data[i] == b.data[i] {
count += 1;
} else {
break;
}
}
count
}
/// Provides access to the underlying slice.
pub fn as_slice(&self) -> &[u8] {
&self.data
}
}
5. Reversing the Surgery (Nibbles back to Bytes)
Finally, we need a way to go back.
When we perform RLP Serialization (which we will cover in Part III), the encoder expects Bytes, not Nibbles.
We cannot feed [0x0A, 0x0B] to the RLP encoder. It will encode them as two separate bytes. We need to stitch them back together into 0xAB.
This raises a dangerous edge case: Odd Lengths.
If data = [0xA, 0xB, 0xC], we have 3 nibbles.
We can combine A and B into 0xAB.
But C is alone. It is an orphan.
Standard byte conversion is impossible for odd-length nibble vectors.
This is why the Nibbles struct is strictly an Internal Intermediate Representation. It exists only in memory while we traverse the Trie. When we want to persist to disk, we must use Hex-Prefix Encoding (Chapter 3) to handle the odd leftovers.
But for debugging purposes, we often want to convert even-length paths back to bytes:
pub fn to_bytes(&self) -> Vec<u8> {
// Assertion: We can only convert byte-aligned paths
if self.data.len() % 2 != 0 {
panic!("Cannot convert odd-length nibbles to raw bytes directly!");
}
let mut out = Vec::with_capacity(self.data.len() / 2);
// Iterate in chunks of 2
for chunk in self.data.chunks(2) {
let high = chunk[0];
let low = chunk[1];
// The Reversal Operation: Shift & Or
// High: 0000 1010 (0x0A) << 4 -> 1010 0000 (0xA0)
// Low: 0000 1011 (0x0B)
// OR: 1010 0000 | 0000 1011 -> 1010 1011 (0xAB)
let byte = (high << 4) | low;
out.push(byte);
}
out
}
6. The Abstract Cost
We have now successfully created a virtual type system.
We can talk about Nibbles. We can split them. We can find common prefixes.
But we have paid a price.
Every time we read a key from the database, we perform a heap allocation (Vec::with_capacity).
Every time we write a key, we perform bitwise shifting.
In a system aiming for 100% determinism, this overhead is acceptable. But it is important to remember that Nibbles are a "leaky abstraction." Under the hood, the machine is still struggling to align these 4-bit integers into 8-bit slots.
In the next chapter, we will tackle the Hex-Prefix Encoding. This is the official Ethereum standard for solving the "Orphan Nibble" problem we just identified. It involves a clever (and confusing) system of "Flags" packed into the first byte of every node.
If Nibbles was basic surgery, Hex-Prefix is microsurgery.
Chapter 3: The Compact Encoding (Hex-Prefix)
We have a problem.
In Chapter 2, we built the Nibbles struct. We created a virtual world where we can split bytes into 4-bit chunks.
We can represent the path [0xA, 0xB, 0xC].
But this structure only exists in RAM.
When we want to write this node to the database, or hash it to generate a State Root, we must serialize it back into Bytes.
If we try to naively convert [0xA, 0xB, 0xC] back to bytes, we hit a wall.
0xA+0xB=0xAB(1 Byte).0xC=...(Half a Byte).
You cannot write half a byte to a disk. The Operating System forbids it.
We need a standard way to pad this data.
Furthermore, we have a second problem: Ambiguity.
In the Merkle Patricia Trie, there are two types of nodes that hold paths:
- Leaf Nodes: These contain the end of a key and the final Value.
- Extension Nodes: These contain a shared prefix of a key and point to another node.
If I give you the path 0xABC, does it lead to a value (Leaf)? Or is it just a segment of a longer path (Extension)?
A raw byte array [0xAB, 0xC0] doesn't tell you.
To solve both the "Odd Nibble" problem and the "Node Type" ambiguity, Ethereum uses Hex-Prefix (HP) Encoding (also called Compact Encoding).
1. The Flag Byte
The solution is to prepend a single Header Byte (or Flag Byte) to every path.
This byte encodes two pieces of boolean metadata:
- Parity Flag ($P$): Is the path length Odd or Even?
- Terminator Flag ($T$): Is this a Leaf (1) or an Extension (0)?
Since we have 2 booleans, we have 4 possible states ($2^2$).
We store these flags in the High Nibble of the first byte.
| $T$ (Terminator) | $P$ (Parity) | Binary Prefix | Hex Prefix | Meaning |
|---|---|---|---|---|
| 0 | 0 | 0000 | 0x0 | Extension, Even Length |
| 0 | 1 | 0001 | 0x1 | Extension, Odd Length |
| 1 | 0 | 0010 | 0x2 | Leaf, Even Length |
| 1 | 1 | 0011 | 0x3 | Leaf, Odd Length |
2. The Logic Matrix
The implementation logic depends entirely on the Parity Flag.
Case A: Even Path Length (e.g., [0xA, 0xB])
We have 2 nibbles. They fit perfectly into 1 byte (0xAB).
However, we still need to attach the flags.
Since the path is aligned, we cannot "sneak" the flags into the first byte of data. We must add a Padding Byte.
- Action: Prepend a full byte containing just the flags.
- Formula: For leaf:
0x20, for extension:0x00 - Example (Leaf):
Flags=0x20. Output:[0x20, 0xAB].
Case B: Odd Path Length (e.g., [0xA, 0xB, 0xC])
We have 3 nibbles.
A is the orphan. B and C can form a byte (0xBC).
We can use the Flag Byte to house the orphan A.
- Action: Pack the first nibble of the path into the lower half of the Flag Byte.
- Formula:
[Flag << 4 | A, B, C] - Example (Leaf):
Flags=0x3. Output:[0x3A, 0xBC].
This is efficient. For odd paths, the overhead is zero (we use the half-byte of space we would have wasted anyway). For even paths, the overhead is 1 byte.
3. The Rust Implementation
This is one of the most bug-prone functions in the entire codebase. A single bitwise slip here will generate a different hash for the node, which ripples up to a different State Root.
We implement this as a static helper in src/nibbles.rs.
/// Encodes a slice of nibbles into Ethereum's Compact Hex-Prefix format.
///
/// # Arguments
/// * `nibbles` - A slice of u8, where each u8 < 16.
/// * `is_leaf` - boolean indicating if this path belongs to a Leaf Node.
pub fn encode_compact(nibbles: &[u8], is_leaf: bool) -> Vec<u8> {
let len = nibbles.len();
let is_odd = len % 2 == 1;
let mut result = Vec::new();
if is_odd {
// CASE: Odd Length
// Flag: 0x3 for leaf, 0x1 for extension
let flag = if is_leaf { 0x3 } else { 0x1 };
// The first nibble is packed into the Flag Byte.
// Formula: (Flag << 4) | First Nibble
// Example: Flag 3 (0011), Nibble A (1010)
// Shift: 0011 0000 (0x30)
// OR: 0011 1010 (0x3A)
let first_byte = (flag << 4) | nibbles[0];
result.push(first_byte);
// Encode the rest of the nibbles (skipping the first one)
for i in (1..len).step_by(2) {
result.push((nibbles[i] << 4) | nibbles[i + 1]);
}
} else {
// CASE: Even Length
// Flag: 0x20 for leaf, 0x00 for extension
let flag = if is_leaf { 0x20 } else { 0x00 };
result.push(flag);
// Encode all nibbles
for i in (0..len).step_by(2) {
result.push((nibbles[i] << 4) | nibbles[i + 1]);
}
}
result
}
4. Decoding (The Reverse Engineer)
Writing the encoder is only half the battle. When we read from the database, we get raw bytes. We need to look at that first byte, decode the flags, and reconstruct the Nibbles vector.
This logic is essentially a match statement on the first byte.
// Note: In the actual implementation, decode_compact is a private helper in node.rs
// and only returns the nibbles (not the is_leaf flag which is determined by context)
fn decode_compact(compact: &[u8]) -> Vec<u8> {
if compact.is_empty() {
return Vec::new();
}
let first_byte = compact[0];
let prefix = first_byte >> 4;
let mut nibbles = Vec::new();
// Check if odd length (prefix & 0x1 != 0)
if (prefix & 0x1) != 0 {
// CASE: Odd
// The first nibble is hidden in the bottom 4 bits of the header
nibbles.push(first_byte & 0x0F);
}
// Decode the remaining bytes
for &byte in &compact[1..] {
nibbles.push(byte >> 4);
nibbles.push(byte & 0x0F);
}
nibbles
}
5. Why This Matters (The Eureka Moment)
At first glance, this seems like over-engineering. Why not just use JSON? Why not just store the boolean is_leaf in a separate struct field?
You have to remember the constraint: The Hash.
We are going to hash this node. The hash must be identical across every computer on earth.
If we used a struct like:
struct Node {
is_leaf: bool,
path: Vec<u8>
}
The serialization of that boolean would depend on the compiler, the architecture, or the serialization library.
By baking the is_leaf flag physically into the byte stream of the path itself, Ethereum guarantees that the distinction between a Leaf and an Extension is cryptographically bound to the data.
You cannot confuse a Leaf for an Extension without changing the Hash.
This is the principle of Cryptographic Determinism. The Type is not metadata; the Type is Data.
6. Summary of Part I
We have laid the foundation.
- We understood the physics of the Hexary Trie (Chapter 1).
- We built the
Nibblesvirtual type (Chapter 2). - We implemented the
Hex-Prefixstorage format (Chapter 3).
We now have an alphabet. We can speak the language of the EVM.
But we haven't built a single node yet. We have letters, but no words.
In Part II, we will construct the Four Horsemen: The Node types themselves. We will tackle the recursive Enum, the Box pointer, and the specific architecture of the Branch, Extension, and Leaf.
Next: Part II: The Four Horsemen (Nodes) - Begin architecting the recursive node enum and handling the Merkle property.
Repository: github.com/bit2swaz/merkle-trie-rs
