Skip to content

WritingEthereum

Ethereum Storage Proofs

How Ethereum stores state and how another chain can verify it.

Revised

In this article

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_getProof returns 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 stores each character as a separate node.

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 combines nodes along single-child paths.

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:

A Merkle tree commits every leaf to one root hash.

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:

Path compression and hash commitments combined.

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:

The block state root commits to accounts; each contract account commits to its storage trie.

This structure has two main components:

  1. 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
  2. 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:

  1. An authenticated block header containing the state root for the target block
  2. State proofs linking the target value to that root

To verify a USDC balance, we need:

  1. Account Proof: Confirms the existence of USDC's contract and its storage root in the state trie.
  2. 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.

A storage value is only as trustworthy as the block header anchoring its state root.

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.

A mapping key is the hash of the padded key followed by the padded mapping slot.

Requesting and Verifying Proofs

To verify a balance, we need to:

  1. Find the Balance Location
# Pseudocode: both values are left-padded to 32 bytes.
storage_slot = keccak256(pad32(user_address) + pad32(9))
  1. 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:

  1. Hash the supplied block header and match it against a trusted block hash
  2. Decode the authenticated header and extract its state root
  3. Verify the account proof against that state root
  4. Extract the contract's storage root from the proven account
  5. 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:

  1. Historical Data Access

    • Token balances at any block
    • Contract states at any point
    • Contract configuration at a specific block
  2. Cross-chain Systems

    • Block header verification for bridges
    • L2 systems using L1 data
    • Cross-chain oracles
  3. 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.

References