πΎ Storage: What Lives Where
This doc explains how ethlambda saves data. Especially,
the split between the fork choice Store and the StorageBackend trait,
what each of the eight tables holds, and which data is in-memory only.
Overview
All chain data flows through a single high-level type, the Store
(crates/storage/src/store.rs), which persists it through a small pluggable
key-value abstraction, the StorageBackend trait
(crates/storage/src/api/traits.rs). Two backends implement the trait:
RocksDB for production and an in-memory backend for tests.
Everything persisted is SSZ-encoded bytes.
LAYERED ARCHITECTURE
ββββββββββββββββββββ
ββββββββββββββββββββ ββββββββββββββββββββ
β BlockChain actor β β P2P actor β
ββββββββββ¬ββββββββββ ββββββββββ¬ββββββββββ
β (cloned Store: shared β
β backend + buffers) β
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Store β
β crates/storage/src/store.rs β
β β
β β’ table selection, key encoding, SSZ codec β
β β’ snapshot-vs-diff decisions, pruning β
β β’ in-memory attestation buffers + state cache β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β begin_read() / begin_write()
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β StorageBackend trait β
β crates/storage/src/api/traits.rs β
β β
β raw bytes in, raw bytes out β
βββββββββββββ¬ββββββββββββββββββββββββββ¬ββββββββββββ
β (production) β (test)
βΌ βΌ
βββββββββββββββββββββββββ βββββββββββββββββββββββββ
β RocksDBBackend β β InMemoryBackend β
β (production, one β β (tests, HashMap per β
β column family per β β table, lost on β
β table) β β drop) β
βββββββββββββββββββββββββ βββββββββββββββββββββββββ
The Store / StorageBackend Split
StorageBackend: dumb bytes
The StorageBackend trait knows nothing about consensus types. It moves raw
bytes in and out of named tables:
begin_read()returns aStorageReadViewwithget(table, key)andprefix_iterator(table, prefix).begin_write()returns aStorageWriteBatchwithput_batch,delete_batch, andcommit(). A batch stages puts and deletes across multiple tables and applies them atomically on commit.
The two implementations live in crates/storage/src/backend/:
| Backend | Details |
|---|---|
RocksDBBackend | One column family per table. Writes go through a native WriteBatch with sync=false (no fsync per commit). |
InMemoryBackend | A HashMap per table behind an RwLock. Its prefix_iterator sorts keys lexicographically to match RocksDBβs iteration order, because pruning relies on slot-ordered early-stop scans (see Key encoding). |
Store: all the semantics
The Store owns everything the backend doesnβt: which table each datum goes
to, how keys are built, SSZ encoding/decoding, when to write a full state
snapshot versus a diff, and when to prune. It is the only writer to the
backend.
A naming subtlety: the Store struct lives in the storage crate
(crates/storage/src/store.rs), while the fork choice logic that drives it
(on_block, on_tick, update_head, β¦) lives in
crates/blockchain/src/store.rs as free functions taking &mut Store.
Store is Clone, and every field is an Arc, so clones are cheap and all
clones share the same backend and the same in-memory pools. At startup
(bin/ethlambda/src/main.rs) one Arc<RocksDBBackend> is opened, one Store
is built from it, and clones are handed to the BlockChain and P2P actors.
INSIDE THE STORE
ββββββββββββββββ
βββββββββββββββββββββββββββββ Store βββββββββββββββββββββββββββββ
β β
β PERSISTED (via backend) IN-MEMORY ONLY β
β ββββββββββββββββββββββ ββββββββββββββββββββββ β
β βββββββββββββββββββββββ ββββββββββββββββββββββββ β
β β BlockHeaders β β new_payloads β β
β β BlockBodies β β (pending aggregated β β
β β BlockProof β β attestations) β β
β β BlockRoots β β known_payloads β β
β β States β β (fork-choice-active β β
β β StateDiffs β β attestations) β β
β β Metadata β β gossip_signatures β β
β β LiveChain β β (raw XMSS sigs β β
β βββββββββββββββββββββββ β awaiting β β
β β aggregation) β β
β Survives restarts. β state_cache (LRU) β β
β ββββββββββββββββββββββββ β
β β
β Lost on restart. β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The Tables
The eight variants of the Table enum (crates/storage/src/api/tables.rs):
| Table | Key | Value | Pruned? |
|---|---|---|---|
BlockHeaders | root | BlockHeader | never |
BlockBodies | root | BlockBody | never |
BlockProof | slot β root | aggregate proof (MultiMessageAggregate) | yes: finalized older than ~1 day |
BlockRoots | slot | block root (H256) | never |
States | root | full State snapshot | never |
StateDiffs | root | StateDiff | never |
Metadata | string | SSZ scalars | never |
LiveChain | slot β root | parent_root | yes: below finalized |
Key encoding
Three key layouts are used:
- Root-keyed tables use the 32-byte SSZ encoding of the block root
(
root.to_ssz()). - Slot-prefixed tables (
BlockProof,LiveChain) useencode_slot_root_key: an 8-byte big-endian slot followed by the 32-byte root. Big-endian means lexicographic key order equals numeric slot order, so pruning can iterate from the start of the table and stop at the first key past its cutoff instead of scanning everything. - Slot-only (
BlockRoots) usesencode_block_root_key: just the 8-byte big-endian slot, since the value already holds the root. This table is never pruned, so the ordering buys nothing here; it is kept only for consistency with the other slot-prefixed keys.
BlockHeaders
root β BlockHeader. Written for every block, including the genesis/anchor
block, and never pruned: headers are the permanent record of the chain.
Headers are also read back during state reconstruction (see
State Storage).
BlockBodies
root β BlockBody. Written for every block except those with an empty
body: if header.body_root == EMPTY_BODY_ROOT (the hash tree root of
BlockBody::default()), nothing is stored and reads synthesize
BlockBody::default(). This covers the genesis block and checkpoint sync
anchors, whose bodies are either empty or unavailable. Never pruned.
BlockProof
slot β root β MultiMessageAggregate. This table stores the blockβs merged
aggregate proof blob. It is keyed by slot β root so that pruning can scan in
slot order and stop early.
Stored separately from headers/bodies because the genesis block has no proof.
get_signed_block synthesizes an empty proof for the slot-0 anchor only; for
any other block a missing entry (a pruned finalized block) surfaces as None
rather than a fabricated block.
This is the one block table that is pruned; see Pruning.
BlockRoots
slot β H256, the canonical block root at each slot. Rewritten on every head
update inside update_checkpoints: block_root_index_changes walks the old
and new headβs branches back to their common ancestor, deleting the slots
that leave the canonical chain and writing the ones that join it. A reorg
therefore touches only the affected slot range, not the whole table. Never
pruned.
Backs get_signed_blocks_by_slot_range, which serves BlocksByRange requests
over req/resp (crates/net/p2p/src/req_resp/handlers.rs). It does not
back the RPC GET /lean/v0/blocks/:slot endpoint: that handler resolves a
slot through the head stateβs historical_block_hashes instead
(resolve_slot in crates/net/rpc/src/blocks.rs), so a block on a side fork
is reachable there only by root, never by slot.
States
root β State (full SSZ snapshot). Holds full-state snapshots only: the
bootstrap anchor written at initialization, plus one anchor whenever a block
crosses a SNAPSHOT_ANCHOR_INTERVAL-slot boundary. Never pruned β these
anchors are the base every diff chain resolves against, so reconstruction
always terminates.
The genesis validator registry is constant for the life of the chain
(validators is fixed at genesis; the lean STF never mutates it), but it has
no table of its own: it rides inside every States snapshot alongside
config, which StateDiff reconstruction relies on (see
State Storage).
StateDiffs
root β StateDiff. A parent-linked diff written for every non-genesis
state. Never pruned, so together with the snapshots this preserves the full
state history. See State Storage for what a
diff contains and how states are rebuilt.
Metadata
String keys mapping to SSZ-encoded scalars β the Storeβs own persistent
fields:
| Key | Type | Meaning |
|---|---|---|
time | u64 | Intervals elapsed since genesis (the store clock) |
config | ChainConfig | Chain configuration (currently just genesis_time) |
head | H256 | Current fork choice head |
safe_target | H256 | Current safe target (see lmd_ghost.md) |
latest_justified | Checkpoint | Latest justified checkpoint |
latest_finalized | Checkpoint | Latest finalized checkpoint |
config is the odd one out: init_store writes it once at bootstrap and
nothing ever rewrites it afterward (it has a getter, Store::config, but no
setter). Because it never changes, the Store keeps a copy in memory and
reads of it never reach the backend. It is also part of the DBβs fingerprint:
from_db_state refuses to resume a data directory belonging to another
network (see Startup and Restore). Every other
Metadata key is mutated in place as the chain progresses.
LiveChain
slot β root β parent_root. A pure index for fork choice: it lets
get_live_chain() build the root β (slot, parent_root) block tree without
deserializing a single block. It contains the finalized anchor plus all
non-finalized blocks, and is pruned as finalization advances (the finalized
block itself is kept).
Presence in LiveChain is what makes a block visible to fork choice:
insert_pending_block deliberately writes a blockβs header/body/proof
without a LiveChain entry, persisting the heavy proof data (~3 KB+)
while the block waits for its parent. When the block is later processed,
insert_signed_block overwrites the same keys (idempotent) and adds the
LiveChain entry.
State Storage: Snapshots + Diffs
Storing a full State per block would be wasteful: most fields never change
or change predictably. Instead, insert_state writes:
- Always a
StateDiffkeyed by the block root, linked to its parent viabase_root(the blockβsparent_root). - Only at anchors a full snapshot into
States. A block is an anchor when it crosses aSNAPSHOT_ANCHOR_INTERVALslot boundary relative to its parent (~68 minutes at 4-second slots). This bounds any reconstruction walk to at mostSNAPSHOT_ANCHOR_INTERVALdiff applications.
A StateDiff stores only what cannot be recovered elsewhere: the target slot,
justified/finalized checkpoints, and the justification fields
(justified_slots, justifications_roots, justifications_validators,
stored in full β they are bounded by the non-finalized window, so they stay
small under healthy finality). The rest is deliberately omitted:
| Omitted field | Recovered from |
|---|---|
config, validators | The snapshot (they never change) |
latest_block_header | The BlockHeaders table |
historical_block_hashes | Regenerated from base_root + the slot gap |
The historical_block_hashes append is checked rather
than trusted blindly: validate_history_append
(crates/storage/src/state_diff.rs) rejects a diff whose appended hashes
donβt match the expected slot gap or arenβt zero-filled for skipped slots,
so a broken append surfaces at diff-creation time instead of corrupting a
later reconstruction.
Reads go through get_state, which tries three levels:
- An in-memory LRU cache (
STATE_CACHE_CAPACITY = 32states, keyed by block root). States are content-addressed and immutable, so the cache never needs invalidation. The common case β reading the parent state right after importing its block β is a cache hit. - A full snapshot in
States. - Reconstruction: walk
base_rootpointers back throughStateDiffsuntil a snapshot is found, then replay the diffs forward.
STATE RECONSTRUCTION
ββββββββββββββββββββ
get_state(D): not in the cache and no snapshot β rebuild in two passes.
Pass 1: walk backward from D, following each diff's base_root pointer
and collecting diffs, until a block with a snapshot is found:
ββββββββββ base=C ββββββββββ base=B ββββββββββ base=A ββββββββββββ
β diff D β ββββββββΆ β diff C β ββββββββΆ β diff B β ββββββββΆ β snapshot β
ββββββββββ ββββββββββ ββββββββββ β at A β
(target) (StateDiffs table) ββββββββββββ
(States table)
Pass 2: starting from the snapshot, apply the diffs oldest-first:
state A ββapply BβββΆ state B ββapply CβββΆ state C ββapply DβββΆ state D β
The rebuilt state D gets its latest_block_header from the BlockHeaders
table and is memoized in the LRU cache before being returned.
If the diff chain is broken or the targetβs header is missing, get_state
returns None rather than a partial state.
Write Paths: What a Block Import Persists
Block import (on_block in crates/blockchain/src/store.rs) commits a
sequence of independent write batches:
BLOCK IMPORT WRITE SEQUENCE
βββββββββββββββββββββββββββ
on_block(signed_block)
β
ββ 1. update_checkpoints() Metadata: head,
β (only if the post-state latest_justified
β justified a higher slot) (+ triggers pruning)
β
ββ 2. insert_signed_block() β BlockHeaders[root]
β β BlockBodies[root] (if non-empty)
β ββone batchβ BlockProof[slotβroot]
β β LiveChain[slotβroot]
β β
β
ββ 3. insert_state() β StateDiffs[root]
β ββone batchβ States[root] (anchors only)
β β (+ LRU cache insert)
β
ββ 4. update_head() Metadata: head
(re-runs fork choice) (+ justified/finalized if advanced,
+ BlockRoots diff (canonical index),
+ pruning on finalization)
Each numbered step is atomic on its own, but the import as a whole is not
one transaction. The commit order keeps the on-disk store consistent after any
prefix of these steps: the justified checkpoint written in step 1 always names
an already-persisted ancestor of the imported block (the state transition
only counts attestations whose roots match the stateβs own
historical_block_hashes, and every ancestor was fully persisted when it was
imported), and the head only advances in step 4, after the block and state are
durable. A crash mid-import can therefore lose the tail of the import β e.g. a
persisted block and state the head does not point to yet β but never leave
metadata referencing missing data. Re-importing the block is idempotent (a
duplicate is skipped via has_state).
Pruning
Pruning is driven by finalization and splits into a cheap immediate phase and a deferred heavy phase.
Immediately, when finalization advances (inside update_checkpoints):
prune_live_chain: deletesLiveChainentries below the finalized slot, keeping the finalized block itself. This keeps the fork choice working set bounded to the non-finalized chain.prune_gossip_signatures: drops buffered in-memory gossip signatures at or below the finalized slot.prune_stale_aggregated_payloads: drops in-memory aggregated payloads (both pending and known) whose target slot is at or below the finalized slot.
Deferred (prune_old_data, called after a batch of blocks has been
processed):
prune_old_block_proofs: deletesBlockProofentries belowcutoff = tip_slot β BLOCK_PROOF_PRUNING_RANGE(21,600 slots, ~1 day at 4-second slots) β but only whencutoff β€ finalized_slot, i.e. the entire pruned range lies within finalized history. Non-finalized proofs are never touched. Finalized blocks can never revert, so their proofs are not needed for fork choice, reorg safety, or re-aggregation once outside the window.
Never pruned: BlockHeaders, BlockBodies, BlockRoots, States,
StateDiffs, and Metadata. Headers, bodies, the canonical slot index, and
the snapshot+diff chain are the full historical record; only the proof blobs
and the (non-finalized) fork choice index are disposable.
In-Memory Only (Lost on Restart)
Four Store fields never touch the backend. All are bounded buffers shared
across Store clones:
| Buffer | Capacity | Contents |
|---|---|---|
new_payloads | 64 messages | Pending aggregated attestation proofs, not yet active for fork choice |
known_payloads | 512 messages | Fork-choice-active aggregated proofs |
gossip_signatures | 2048 signatures | Raw per-validator XMSS signatures awaiting aggregation (each ~3 KB, so ~6 MB worst case) |
state_cache | 32 states | LRU memoization of reconstructed/imported states |
The payload buffers evict FIFO when full, and redundant proofs (whose participants are a subset of an existing proof for the same attestation data) are skipped on insert.
Note that the per-validator βlatest attestationβ maps used by fork choice are
not stored anywhere β they are derived on demand from these buffers via
extract_latest_known_attestations and friends. See the attestation pipeline
section of lmd_ghost.md for how attestations move between the
pools.
After a restart these buffers start empty: pending attestations and un-aggregated gossip signatures are lost and must be re-collected from the network. Everything persisted in the eight tables survives.
Startup and Restore
A Store is created through one of three constructors in
crates/storage/src/store.rs:
| Constructor | When | What it does |
|---|---|---|
from_anchor_state | Genesis boot | Initializes from the genesis state (no anchor block body) |
get_forkchoice_store | Checkpoint sync | Initializes from a downloaded finalized state + anchor block, after validating they are consistent |
from_db_state | Resume from an existing data directory | Re-opens the persisted store as-is |
The first two funnel into init_store, which writes the anchor in one
atomic batch: all six Metadata keys (time = 0, config, head = safe_target
= anchor root, justified = finalized = anchor checkpoint), the anchor header,
its BlockRoots entry, the body if non-empty, a full snapshot into States
(the base of every future diff chain), and the anchorβs LiveChain entry.
from_db_state is the restore path: it reads config and latest_finalized
from Metadata, returning None for an empty DB. A populated DB from another
network is fatal instead: the finalized stateβs genesis time and validator
registry are compared against the genesis config, and a mismatch fails with
Error::GenesisMismatch rather than being treated as empty, since writing a
fresh anchor would leave the foreign chainβs rows in place to be served to
peers. At startup the node prefers this path but only
accepts the on-disk store if its head is at most MAX_RESUMABLE_DB_STATE_AGE = 450 slots (~30 minutes) behind the current slot; a staler DB falls through
to checkpoint sync, which writes a fresh anchor on top of the existing data.
Key Files
| File | Component |
|---|---|
crates/storage/src/store.rs | Store: persistence logic, in-memory buffers, pruning, constructors |
crates/storage/src/api/traits.rs | StorageBackend, StorageReadView, StorageWriteBatch |
crates/storage/src/api/tables.rs | The Table enum |
crates/storage/src/state_diff.rs | StateDiff: diff creation and state reconstruction |
crates/storage/src/backend/rocksdb.rs | Production RocksDB backend |
crates/storage/src/backend/in_memory.rs | Test backend |
crates/blockchain/src/store.rs | Fork choice logic driving the Store (on_block, on_tick, β¦) |