Skip to main content
In Leafage, “state” means EVM account state: the balance, nonce, and code of every address, plus the storage slots of every contract. eth_call and every state-read API depend on these four things only, so a query node stores these four things only.

Why account state is enough

Account state is a small part of a full node’s database. The rest is transaction bodies, receipts, logs, and the Merkle Patricia Trie maintained for validation and block production. Dropping the MPT lets a query node use a flat key-value store (RocksDB or MDBX): accounts are looked up by address, storage slots by address || slot.

State diff (StateDiff)

A state diff is the set of account state changes between before and after executing one block. The write node converts it directly from the StateDB commit set it receives in the OnCommit hook; no transaction replay is involved.
Two design decisions shape how it lives on S3:
  • Keyed by state root, not block hash: {chainID}[/{version}]/{stateRoot}/stateDiff. Adjacent blocks with the same state root (typically empty blocks) share one object; when a query node sees that the parent and current block have the same state root, it treats the diff as empty and skips the fetch.
  • Encoded with RLP, not JSON: it is consumed only by Leafage’s internal components, so readability does not matter and size does.
The block header is stored separately as {chainID}[/{version}]/{blockHash}/block. A query node fetches the header by hash and the diff by state root; together they are all the input needed to apply one block.

Diff layers and the state tree

A query node does not write each block’s diff straight into the database. It first keeps it in memory for a while as a diff layer (DiffLayer). All diff layers form a linked list called the state tree (StateTree):
Reads start at the layer of the target block and walk downwards, returning on the first hit; if no layer has the key, the read falls through to disk. Most requests target latest, so reads are usually pure in-memory operations. Window: at most --diff-depth-limit (default 64) layers stay in memory. The window has two meanings at once: it is the historical depth a State node can answer directly, and it is the deepest reorg that can be handled in memory. Deeper reorgs go through the S3 backfill path. Forks: a fork block’s diff layer enters hash_diff_map but not num_diff_map. Queries by hash can still reach fork state; queries by height always follow the canonical chain.

Finalization

When a block’s depth exceeds the window, the oldest layer is flushed to the database and removed from memory. This step is called finalization, and it is where State and Archive nodes part ways:
Overwrites in place, keeping only the latest value.No historical versions exist on disk. Once a requested height leaves the in-memory window, the node returns -39006 BlockNotFound.

Where state comes from

A query node’s state has three sources, matching three scenarios: The usual way to bring up a brand-new node is to download a RocksDB snapshot and catch up from the snapshot height to the chain head, which typically takes minutes rather than replaying from genesis.
Catch-up near the chain head can land on the wrong branch. --catchup-safe-depth makes those blocks near the head backfill along the parent-hash chain from the notifications instead; set it above the chain’s maximum reorg depth.

Summary

  • State = an account’s balance, nonce, code, storage; query nodes store only these, no MPT and no transactions.
  • A StateDiff is one block’s set of state changes, stored in the S3 internal bucket keyed by state root; empty blocks produce no new object.
  • Recent blocks stay in memory as diff layers; the window defaults to 64 layers and is both a State node’s queryable depth and the reorg handling limit.
  • Layers leaving the window are finalized to disk: a State node overwrites the latest value, an Archive node keeps history by height.
Continue reading: