An RPC can tell you an Ethereum account balance. A storage proof lets you verify the answer.
Ethereum commits its entire execution state to a single root in every block header. From that root, a verifier can check a specific account and storage slot without replaying the chain or trusting the RPC that supplied the data.
This article builds that verification path from the bottom up:
- How Merkle Patricia tries commit to Ethereum state
- How RLP encodes the nodes in those tries
- How
eth_getProofreturns an account proof and storage proof - How another chain can anchor the result to a trusted block
Ethereum's State Data
Before verifying a storage slot, we need to understand what the state root commits to and how a proof reaches it.
Consensus and Execution
Ethereum splits responsibility between two layers. The consensus layer determines which blocks are canonical and finalized. The execution layer runs transactions and computes the resulting state. Storage proofs concern that execution state.
The Execution Layer
Two pieces matter for this article:
- Merkle Patricia tries organize accounts and contract storage into hash-linked structures.
- RLP (Recursive Length Prefix) gives trie nodes a deterministic byte encoding.
Together, they let a verifier reconstruct a path from one value to the state root in a block header.
State Tries
We can derive Ethereum's state structure in four steps: a tree, a compressed trie, a Merkle tree, and finally a Merkle Patricia trie.
1. Trees and Tries
A tree is a hierarchy of nodes connected by edges. Every node except the root has one parent.
Tries (pronounced "try," from retrieval) store keys as paths. Entries with the same prefix share the same path until they diverge.
Consider a trie containing three words: "slow," "slower," and "water":
An uncompressed trie can use one node for each character along a single-child path. In this example, "slower" adds one node per character after "slow." Across millions of accounts, those nodes add storage and traversal overhead.
2. Radix Tries
Radix tries reduce this overhead by compressing each single-path chain.
Here's how our previous example looks in a radix trie:
Radix compression changes the example in three ways:
- Fewer nodes
- Shared prefixes (like "slow")
- The same lookup path with less stored structure
3. Merkle Trees
A Merkle tree hashes each value and combines those hashes up the tree. The root commits to every value beneath it.
In a Merkle tree, each piece of data is hashed, and these hashes are combined up the tree:
The structure has four relevant properties:
- Each level represents a layer of hashing
- Every parent node contains the combined hash of its children
- Any modification to leaf data propagates up to the root hash
- Verification requires only the branch path to the root
4. Merkle Patricia Tries
Ethereum's Merkle Patricia trie combines radix-trie path compression with Merkle commitments.
Applying this to our previous example, we get:
The resulting structure combines two mechanisms:
- Path compression
- Common prefixes share paths, reducing redundancy (e.g., "slow")
- Single paths are combined (e.g., "water")
- Fewer nodes are stored
- Hash commitments
- Each node is cryptographically hashed
- Hashes are combined upwards to form the root hash
- Any changes in the data affect the root hash
- A branch can be checked against the root
Ethereum's State Organization
Ethereum uses the same structure at two levels: one global state trie and a separate storage trie for each contract account.
The entire state is organized as:
This structure has two main components:
-
State Trie: The top level
- Root hash stored in each block
- Maps addresses to account data
- Any change to any account affects this root
- A proof can link an account to this root
-
Storage Tries: For each contract
- Individual trie for contract data
- Maps storage slots to values
- Changes in contract data only affect its trie
- A proof can link a storage slot to this root
The global state root in the block header commits to both levels.
RLP Encoding
The trie defines the structure. RLP defines the bytes that get hashed.
RLP encodes nested byte arrays deterministically. Three properties matter here:
- It encodes structure without assigning application-level data types
- The same input produces the same output
- Lists can contain other lists
For example, a basic account state in Ethereum contains:
# Account state: [nonce, balance, storageRoot, codeHash]
account = [nonce, balance, storage_root, code_hash]
encoded = rlp.encode(account)
Ethereum encodes the account with RLP before placing it in the state trie. Every client therefore derives the same bytes and the same hash from the same account data.
Example: Verifying a USDC Balance
An ERC-20 balance is a useful example because it lives in contract storage rather than directly in an Ethereum account.
Required Inputs
The verification requires two inputs:
- An authenticated block header containing the state root for the target block
- State proofs linking the target value to that root
To verify a USDC balance, we need:
- Account Proof: Confirms the existence of USDC's contract and its storage root in the state trie.
- Storage Proof: Validates that the balance exists at the specified slot in USDC's storage trie.
A valid proof against an attacker-chosen root proves nothing. The verifier must authenticate the block header independently.
USDC Storage Layout
Every smart contract account has persistent key-value storage:
- Each slot is 32 bytes long
- The contract's implementation determines which value occupies each slot
- Values persist across transactions
ERC-20 standardizes the external interface, not the storage layout. The mapping slot therefore depends on the token implementation.
For USDC's implementation, the balances mapping sits at slot 9. A user's balance key is the Keccak-256 hash of the padded user address followed by the padded mapping slot.
Requesting and Verifying Proofs
To verify a balance, we need to:
- Find the Balance Location
# Pseudocode: both values are left-padded to 32 bytes.
storage_slot = keccak256(pad32(user_address) + pad32(9))
- Extract the Complete Proof Chain
# Get both account and storage proofs
proof = web3.eth.get_proof(
USDC_ADDRESS, # Contract address
[storage_slot], # Storage slot we want
block_number # Block we're interested in
)
The eth_getProof call returns:
- Account proof (path from state root to USDC contract)
- Storage proof (path from contract storage root to balance)
- RLP-encoded trie nodes for both paths
The same verification can run off-chain or in a smart contract. In either environment, the invariants are identical:
- Decode each piece of data via RLP
- Verify every node against the hash referenced by its parent
- Reject the proof if any link fails to match
Verification on L2
A state proof is only as trustworthy as its state root. On another chain, the verifier therefore needs an authenticated Ethereum block before it can trust the proof.
On OP Stack chains, the L1Block predeploy exposes the current L1 origin hash. It does not expose a complete block header or its state root. A caller can provide the RLP-encoded header; the contract hashes it, compares that hash with the trusted L1 origin hash, and only then extracts the state root.
The complete verification chain is:
- Hash the supplied block header and match it against a trusted block hash
- Decode the authenticated header and extract its state root
- Verify the account proof against that state root
- Extract the contract's storage root from the proven account
- Verify the storage proof and read the balance
Each step authenticates the input to the next. A mismatch anywhere invalidates the result.
Other Uses
The same pattern supports more than an ERC-20 balance:
-
Historical Data Access
- Token balances at any block
- Contract states at any point
- Contract configuration at a specific block
-
Cross-chain Systems
- Block header verification for bridges
- L2 systems using L1 data
- Cross-chain oracles
-
Data Systems
- Snapshot systems
- State synchronization
- Light client support
The proof does not make the block trustworthy. It makes a state value verifiable once the verifier has a trusted block commitment. Keeping those two responsibilities separate is the core design constraint.
Future State Commitments
Merkle Patricia tries make state verifiable, but they were not designed for compact proofs or validity-proof systems. Ethereum is exploring replacements, including Verkle trees and the unified binary tree proposed in EIP-7864.
The node encoding, hash function, and proof size may change. The verification shape remains: authenticate a block commitment, prove an account beneath it, then prove a storage value beneath the account.
Votemarket V2
Votemarket V2 uses this pattern to extract and verify voting state across chains. The public proof toolkit contains the supporting infrastructure.