Skip to content

feat(snap): add EIP-8189 state bootstrap - #11

Draft
lean-apple wants to merge 117 commits into
mainfrom
poc/eip-8189-snap-sync
Draft

feat(snap): add EIP-8189 state bootstrap#11
lean-apple wants to merge 117 commits into
mainfrom
poc/eip-8189-snap-sync

Conversation

@lean-apple

@lean-apple lean-apple commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Overview

Adds an opt-in EIP-8189 snap/2 state bootstrap for fresh or interrupted hashed-state databases. Once the state is verified and accepted, Reth resumes its existing pipeline.

  • Introduces a snap sync session that selects a hash-anchored pivot, downloads account, storage, and bytecode ranges, applies verified BALs, follows the canonical head, and verifies the final state root (session, advancement and healing).

  • Extends snap/2 networking with BAL messages, slim account encoding, request routing, response limits, and account/storage/code/BAL serving (wire types, request serving).

  • Reuses Reth infrastructure for header synchronization and normal backfill through PipelineSync, together with existing provider, hashed-state, trie, stage checkpoint, and BAL abstractions (bootstrap wrapper).

  • Adds crash-safe persistence through a generation marker, atomic state batches, a chunked trie rebuild, final root verification, and a RocksDB-backed BAL store (state writer, finalization, BAL store).

  • Modifies shared components only where reusable, including BAL post-state extraction and the committed trie-root rebuild helper (BAL state merge, trie rebuild).

  • Tests the complete bootstrap handoff, including adversarial range proofs, stale-pivot advancement, snap-to-pipeline continuation, and persisted BAL serving (proof tests, E2E bootstrap).

Snap remains default-off and is activated with --snap.

Towards Reth

The implementation can be upstreamed through several parallel tracks after landing the shared BAL semantics and codecs:

Shared BAL semantics + codecs
├── Wire protocol → client/server routing → downloaders
├── BAL persistence → orphan retention
├── Trie rebuild helper → generation writer
└── Canonical chain/pivot tracking + proof verifier
                            │
                            ▼
                    Session integration
                            │
              ┌─────────────┴─────────────┐
              ▼                           ▼
      Node/CLI integration        Full reorg recovery
              └─────────────┬─────────────┘
                            ▼
                      Production E2E

The corresponding upstream changes are:

  1. Share BAL post-state interpretation through BalAccountState and keep the snap slim-account codec in eth-wire-types.

  2. Add snap/2 message-ID validation, including removal of trie-node messages, and the BAL request/response RLP types (message IDs, BAL messages).

  3. Extend the existing snap client and network request handler with account, storage, bytecode, and BAL routing, retaining Reth’s provider abstractions and response limits (client API, server routing).

  4. Add the BlockAccessLists RocksDB table and the buffered persistent BAL store, then register it with the existing RocksDB provider.

  5. Expose the committed, chunked state-root rebuild from reth-trie-db so snap finalization and existing database initialization share the same implementation.

  6. Introduce the snap-sync crate around a provider-backed canonical chain source, hash-based ancestor walking, and pivot tracking (crate boundary, canonical chain source).

  7. Add the authenticated range-proof verifier and account-range downloader, including omitted-leaf and ordering tests.

  8. Add the storage-range downloader and bytecode downloader, with peer retry, request matching, and adversarial response validation.

  9. Add the snap state generation lifecycle: clean generation start, atomic account/storage/code writes, persisted interruption marker, pipeline handoff, and final root verification (writer and acceptance, finalization).

  10. Add the session state machine that sequences pivot selection, authenticated download, rolling target advancement, BAL catch-up, final verification, and acceptance (session runner, advancement and healing).

  11. Integrate the bootstrap as a decorator around Reth’s existing PipelineSync, gate it behind --snap, and verify snap-to-pipeline handoff through E2E tests (bootstrap integration, launch gating, E2E coverage).

Remaining work

The snap/2 bootstrap path is implemented, but the following remains before considering EIP-8189 production-ready:

  • Complete reorg recovery. The session detects when the applied BAL segment leaves the canonical chain, but currently discards the generation and restarts. It does not yet roll back the old fork with retained BAL state and apply the new fork (current restart behavior).

  • Support mixed RLPx capabilities. Snap is currently recognized only on the dedicated ETH+snap connection variant. A connection negotiating ETH, snap, and another satellite capability is not treated as snap-capable (current capability check).

  • Persist orphaned BALs needed for recovery. Canonical BALs are flushed to RocksDB, while BALs retained for executed forks can remain memory-only. Durable fork BAL retention is needed by the complete rollback/reapplication path (canonical persistence handoff, persistent BAL store).

  • Decide interrupted-generation resumption. A restart safely detects partial state, but begins again from a clean generation rather than resuming the verified downloaded prefix (generation marker, clean generation start).

  • Define historical-data behavior. Snap acceptance advances pipeline and static-file frontiers without downloading pre-snap bodies, receipts, or change sets. The production design must explicitly retain checkpoint-style semantics or schedule historical backfill afterward (current acceptance behavior).

  • Complete production E2E coverage. Add tests for reorgs through and before the pivot, restart during state generation, persisted BAL serving after restart, malicious-peer failover, mixed capabilities, and historical RPC behavior. Existing coverage proves bootstrap, state-root equality, stale-pivot advancement, BAL serving, and normal pipeline continuation (current E2E coverage).

Enabling snap by default should follow completion of the recovery path and mainnet-scale performance testing.

cc @mattsse for review

lean-apple and others added 18 commits August 1, 2026 13:21
Adds `reth-engine-snap`, the client half of snap/2 (EIP-8189): a pivot tracker
that follows the chain head reported by the engine, and a streaming downloader
that pulls accounts, storage and bytecodes at the pivot root.

Every response is verified before it is written. Range proofs are checked by
reconstructing the trie root from the returned leaves plus the proof subtrees
outside the range, so a peer cannot omit a leaf in the middle of a range that
proves both endpoints. Storage ranges are additionally checked against each
account's storage root, and bytecodes against their code hashes.

When a peer can no longer serve the pivot root the download reports where it
stopped, the pivot advances to a fresher block, and the download resumes from
that account rather than restarting.

BAL catch-up between the final pivot and the head, the final state-root check,
and reorg recovery are not implemented yet.
The four write helpers each repeated the same three provider bounds. Moving
them onto a struct that holds the factory states those bounds once on the impl
and lets the downloader thread one value instead of a factory reference.
Adds BAL replay, EIP-8189's replacement for snap/1 trie healing: once the state
at the pivot is downloaded, the blocks between the pivot and the head are
brought forward by writing the post-block values their access lists commit to,
with no transaction execution and no trie-node round trips.

A block's access list only carries the fields that block changed, so entries are
merged onto the stored account rather than overwriting it. The post-block value
of a field is the change with the highest block access index, read from the
index itself rather than from list order.

`catch_up_with_bals` works against a head snapshot taken at entry so it always
terminates; the caller re-invokes to follow a head that moved meanwhile.
Replaces hand-rolled equivalents of things reth and alloy already provide:
hashed writes go through `HashedPostState` instead of ad-hoc tuples, storage
ranges are checked with `reth_trie::root::storage_root` instead of a proof-free
range verification, `Account::into_trie_account` and `From<TrieAccount>` do the
account conversions, and the range cursor uses `U256` arithmetic.

Moves snap/2's slim account encoding onto `AccountData` in eth-wire-types so the
server and the downloader share one codec rather than each keeping a copy, and
gives `StorageData` the matching value accessors.

Groups the loose download helpers onto `StateDownloader` and `StorageRoots`, and
the proof-free response checks onto the latter, so the provider bounds are
stated once per impl instead of on every function.
Closes the sync with the check the per-range proofs cannot give: each response
was only proved against the root it was served at, so nothing until now ruled
out gaps between ranges served at different pivots, or a block access list
applied wrongly. `SnapStateWriter::finalize_sync` rebuilds the state trie over
the assembled hashed state and compares its root to the block header.

The same pass yields the intermediate trie nodes, which are written on success
because a node cannot serve proofs or extend the chain from hashed state alone.
A mismatch returns both roots and commits nothing, so a retry starts from the
hashed state rather than a half-built trie.

Also propagates the `alloy-trie` features added with the shared slim account
codec, which zepter flagged as missing on no_std builds.
…rand

Applying a block access list writes post-block values without recording what
they replaced, so an orphaned block cannot be rewound the way an executed one
can, and re-applying the new chain only corrects the keys that chain happens to
touch.

`AppliedChain` remembers which keys each applied block wrote. Catch-up compares
each block's parent against what was applied below it and stops on a mismatch;
the orphaned blocks' keys are marked stale, and re-applying the new chain clears
whatever it rewrites. What remains is state from a chain that no longer exists.

Reading those keys back from peers is the follow-up. Until then a reorg leaves
`stale_keys` non-empty, which the final state-root check turns into a failure
rather than silent corruption.
The downloader dropped the peer id with `into_data()`, so a peer answering with
the wrong message type, a bad range proof, non-monotonic keys or unrequested
bytecode was never penalized and kept being selected. Every other reth
downloader reports these through `DownloadClient::report_bad_message`; snap now
does the same at each validation point.

A block access list that does not match the header commitment reports the peer
too, but only when the list came from one: lists delivered by the engine with
the payload have no peer to hold to account.
Reporting a peer and returning an error still ended the whole sync on the first
bad response, so a single peer serving a wrong message type or an unusable proof
could stop snap sync outright. Penalizing without retrying does not keep the
download going; `bodies/request.rs` reports the peer and resubmits.

Each request kind now retries up to `MAX_REQUEST_ATTEMPTS` times, reporting the
peer between attempts so the network layer routes the retry elsewhere, and only
fails once the attempts are used up.

Response validation moves into the request methods, since a response is only
worth returning once it has been checked. `StorageRoots::verify_response` checks
a whole storage-ranges reply, which drops the per-call-site penalize helpers.
Completes reorg recovery. Detection already identified the keys an orphaned
chain wrote that the surviving chain never rewrites; those are now read back
from peers so a reorg costs a handful of lookups instead of failing the sync at
the state-root check.

Snap has no request for a specific key, so each is fetched as a range whose
origin and limit are that key. Those replies are checked with a single-key proof
rather than the range verifier: a range proof asserts completeness through to
the end of the trie, which a one-key reply does not claim and cannot support.

An absence proof is a real answer, not a gap. An account that no longer exists
is deleted and its storage wiped, and an absent slot reads back as zero, so a
reorg that removed state removes it here too.
Adds an opt-in `--snap` flag that advertises the `snap/2` capability. The
capability was reachable only through `NetworkConfigBuilder::with_snap`, which
the node launch path never called, so no reth node advertised snap/2. It stays
off by default: snap is not reth's sync path, and advertising commits the node
to serving those requests.

This covers the serving half of activation. Driving a snap sync still needs the
engine to feed `SnapSyncEvent`s, so the flag makes a node a snap/2 server rather
than a snap/2 client.

Also drops a doc link from public `orphan_from` to the crate-private `record`,
which fails the `docs` CI job under `-D warnings`.
Snap sync consumes forkchoice information but is not Engine API processing.
Living under crates/engine invited the coupling it had: payload buffering,
canonicality, downloading and persistence all reaching into each other.

Renames reth-engine-snap to reth-snap-sync and moves it to crates/snap-sync.
`SnapSyncSession` is now the single serialized owner, with pivot advancement and
reorgs as transitions of its state machine rather than separate subsystems, so
pivot.rs and reorg.rs are gone. `CanonicalChainSource` gives the crate the
narrow view of the chain it needs without depending on engine-tree internals.

Two defects stop being expressible. Blocks are identified by hash everywhere,
so a target, header or access list can no longer be resolved to whichever block
happens to sit at a height; and the head comes from forkchoice, so it may move
sideways or backwards instead of only forward. `store::reset` gives a session a
clean generation rather than inheriting a genesis allocation or a failed run.

Drops the key-only reorg recovery. Re-reading a stranded key at the target root
restores the value from before the fork, which is wrong whenever that key also
changed between the target and the common ancestor; a session now restarts.

Protocol layers stay where they are: eth-wire-types owns the messages, p2p the
request traits, network the peer selection. This crate owns synchronization
policy and database assembly only.
Addresses the review of 585e46c.

Stale targets can now advance. `advance_target` picks a fresher target and
applies the access lists between the old and new one to the already-downloaded
prefix, which is EIP-8189's rolling transition. Without it the only way past a
stale root was `start`, which discards all progress, and resuming at a new root
would have left a prefix from one state beside a suffix from another.

Finalization re-anchors against forkchoice. The head can move while access lists
are being applied, and a root matching an orphaned block matches nothing the
node will build on.

Bytecodes are re-requested until none are outstanding. A short reply is
legitimate because servers cut responses at a size limit, so dropping the hashes
it omitted left accounts pointing at code the database does not have — which the
state root check cannot catch, since code lives outside the trie.

Complete zero-origin storage tries now replace rather than merge, so slots
absent from the downloaded trie cannot survive a target advance or a repeated
account write.

Also: an empty state root is a valid answer rather than an unavailable one; an
empty reply from one peer spends an attempt instead of ending the request; BAL
requests retry against another peer; and an account left empty by an access list
is deleted per EIP-161 instead of being written as an empty leaf, using the
existing `Account::is_empty` rather than a local predicate.
…the trie walk

Addresses the review of c97b1b7.

A served account range is now committed in micro-batches, and nothing becomes
durable until that batch's accounts, their complete storage and every bytecode
they reference are written in one transaction. Writing accounts before their
storage let a stale root strand them above the resume point: the rolling target
transition applies access lists only below the covered prefix, and a range at a
fresher root does not mention accounts that were deleted, so the old leaf
survived.

Finalization reads a forkchoice token before the state trie is rebuilt and
compares it afterwards. Rebuilding walks the whole state, which is long enough
for the head to move and leave the earlier canonicality check stale, committing
trie updates for an orphaned block and marking the session complete.

Deleting an account under EIP-161 now emits a wiping `HashedStorage`. Reth
clears an account's storage rows only when the entry is marked wiped, so a
deleted account that changed no slots kept its old ones and a later recreation
at the same address would inherit them.
The wasm job builds every workspace crate that is not excluded. Snap sync
depends on reth-provider and reth-network-p2p, which pull in tokio features and
C libraries that do not build for wasm32-wasip1, so the new crate has to sit
with the other native-only ones.
`finalize_sync` built the whole state trie with a single `root_with_updates`
call, holding every trie node for the entire state in memory before writing
any of them. Drive `root_with_progress` in a loop instead, the way
`MerkleStage` walks a full rebuild: each chunk's nodes are written and
dropped, so peak memory no longer scales with total state size.

All chunks share one transaction, so a root mismatch still discards every
node written along the way.
The network layer rejects a snap request outright while no connected peer
advertises `snap/2`, so a session starting before peers connect burned its
whole retry budget in microseconds and reported a failed sync.

Treat that rejection as its own outcome: the step records how far it got and
returns `WaitingForPeers`, leaving the session resumable once a peer shows up.
A session starts by wiping the hashed state, so a crash part-way left tables
that look like a healthy node's while holding a partial download, with nothing
on disk to say so.

Record the generation in the same transaction as the wipe and clear it in the
same transaction as the root check, so the marker is present exactly while the
state is untrustworthy. `interrupted_generation` reports it on startup.
The e2e test carried a hand-written decode counterpart of the slim account
body to assert the wire shape. Drop it, along with the open-coded slot value
decoding, and test the codec next to the wire type instead.
@lean-apple
lean-apple force-pushed the poc/eip-8189-snap-sync branch from 907abaa to 6afa30d Compare August 1, 2026 11:24
Applying a block access list wrote hashed state and deployed bytecodes in
separate transactions, so a crash between them left an account's code hash
pointing at bytecode the database does not have. The final root check cannot
catch that, because code lives outside the trie.
Snap responses are keyed by hashed address with no preimage, so only the v2
layout — hashed tables as the canonical state representation — can be
assembled from them. On v1 the state providers read plain tables, so a
snap-synced node would verify a correct root and then execute against empty
state.

Checked before the wipe, so a v1 node's existing state is left untouched.
Engine-tree prewarming and snap-sync healing each carried their own reading
of an EIP-7928 account entry — last write per field, merge onto the parent
account, slot hashing, deployed-code collection — and the copies had already
drifted: one took the last list element where the other took the highest
block access index.

Move the shared reading to `reth_trie_common::bal` behind an `eip7928`
feature and consume it from both. The index decides everywhere now; how the
result is used stays with each caller.
The chain source carried its own `cached_bal` hook, duplicating what the
node's `BalStoreHandle` already provides. Hand the session the store
instead, so payload-received lists and snap sync read the same cache and
the chain source shrinks to canonicality alone.
A height alone does not identify a block across a reorg, so a marker
carrying only the target number could not say which block the partial
state on disk belongs to. Persist the target hash and state root next to
the checkpoint row, in the same transactions that create and clear it.
Every access list request went out with request id 0. The ids exist to
correlate requests with responses, which stops working the moment more
than one is ever outstanding.
The marker was cleared inside `finalize_sync`, in the same commit as the
trie tables — before the session re-checks that the block survived the trie
walk. A reorg during the walk therefore left the database unmarked while
holding the complete state of an orphaned block.

A matching root proves whose state this is, not that the node accepted it.
Clear the marker in an explicit completion step after the canonicality
re-check, and rewrite it when the rolling transition moves the target, so
it always names the block the state on disk is converging on.
A list fetched from a peer and verified against the header commitment is as
trustworthy as one a payload carried, but it was dropped after use — a
session that walks the same segment again re-fetched it. Insert it into the
node's BAL store, the same place the session already reads from.
Adds `HeaderChain`, the first `CanonicalChainSource` implementation. A snap
syncing node answers SYNCING to the consensus layer, so forkchoice reaches
it as a bare head hash; numbers, parent links, state roots and access list
commitments all come from peers' headers instead.

Every header is verified against the hash it was requested by — the first
against the request, each next against its predecessor's parent — so a peer
cannot answer with a block other than the one asked for, and a segment walk
either lands on its anchor or proves the two are on different chains.
Documents what EIP-8189 support exists today: serving behind --snap over a
128-block window, the experimental unwired sync side, the v2 storage layout
requirement, and the SnapSync marker's meaning.
…apple/reth into feat/snap-account-range-downloader
# Conflicts:
#	crates/engine/tree/src/tree/payload_processor/prewarm.rs
…nap-sync

# Conflicts:
#	crates/net/downloaders/src/snap/mod.rs
`RawBal::ensure_hash` and `DecodedBal::from_raw_bal` replace the manual
hash comparison and `Vec<AccountChanges>` decode. Carrying one `RawBal`
through the path also hashes the list once instead of twice, and the
alloy decoder rejects trailing bytes.
`reth-trie-common` already re-exports alloy's `ProofNodes`, which maps
trie paths to nodes; this one maps commitments to blobs.
`ancestor` and `segment` opened a read transaction per header, so
selecting a pivot cost 17 of them and a catch-up segment one per block.
Driving a step from the wrong state was reported as a network failure,
which reads as a peer problem and is retried like one.
Provider, database, chain and RLP failures were flattened into strings,
which dropped the source chain and made every call site carry a
`map_err`. Chain lookups were also reported as network failures.
The check needs neither a client nor a database, and being an associated
function forced the test to invent a `StateDownloader<'_, (), ()>` alias.
Reth's block-keyed APIs take `NumHash`, which the session was building
by hand from the two fields.
A peer that answered badly was only reported before the retry went back
through normal peer selection, which weighs request state, response quality
and latency rather than that report, so the same fast peer could take every
attempt and stale a pivot another connected peer could serve.

Snap requests now carry the peers already tried for the same logical request
and the fetcher skips them. Once every capable peer has been tried the request
fails with `NoEligiblePeers` instead of waiting for peer churn.
Trie reconstruction ran synchronously between the two canonical-token reads,
but the token only changes when the outer select processes the head channel,
which cannot happen while the session future blocks. A forkchoice update
queued during that window left the token unchanged, so the session accepted
state for the old head.

Rebuilding now runs on the blocking pool so head updates keep landing, and
acceptance re-checks canonicality immediately before clearing the generation
marker. A head that only moved forward reopens healing instead of failing the
sync, so the assembled state is replayed rather than downloaded again.
The dedicated snap connection was selected only for exactly eth + snap/2, so
negotiating any third capability routed the session through the satellite
multiplexer, which reported no snap support and rejected snap sends. snap/2
was silently unusable against such peers.

Snap is now installed as a satellite protocol alongside the other handlers and
bridged to the session, so the multiplexed connection reports and serves snap
like the dedicated one.
@lean-apple
lean-apple force-pushed the poc/eip-8189-snap-sync branch from d67cc77 to 08aa4ec Compare August 17, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant