Skip to content

feat(archiver): verify the stored tip is canonical on start and reconnect - #1347

Open
DylanVerstraete wants to merge 1 commit into
fix/archiver-liveness-cfrom
fix/archiver-liveness-d
Open

DylanVerstraete wants to merge 1 commit into
fix/archiver-liveness-cfrom
fix/archiver-liveness-d

Conversation

@DylanVerstraete

Copy link
Copy Markdown
Contributor

Fourth PR of the archiver liveness audit (finding 4). Stacked on #1346#1345#1344; retarget to usc-dev as those merge.

Problem

RootStore::put_roots only detects a reorg when a height that is already stored is written again with different content. Two paths never touch stored heights: resuming after a restart and reconnecting after a stream death both continue from stored tip + 1. If the source reorged past the finalization lag while the archiver was away (or the lag is set too small), the tail sits on an abandoned fork and canonical roots get spliced on top of fork roots. Every proof built across that seam is wrong and nothing notices.

Changes

  • archiver/src/anchor.rs (new): reconcile(store, max_depth, canonical_hash) re-fetches the block at the stored tip and compares hashes.
    • match → Verified, resume from tip + 1
    • legacy entry without a stored hash → Unverifiable, warn and resume
    • mismatch → fail closed with StoreError::AnchorMismatch (store untouched) and an operator hint
  • --reanchor-max-depth N (default 0): opt-in bounded self-healing. Walk back at most N stored blocks to the last canonical one, truncate_above it and resume. No canonical block within N still fails closed, so an RPC serving the wrong chain cannot wipe the archive. A legacy hash-less entry below a mismatching tip also stops the walk (fork point cannot be located).
  • RootStore::truncate_above(height): deletes the tail, corrects and persists the entry counter.
  • Runs at startup (after the chain-id pin, before backfill) and on every reconnect against the freshly dialled client, selected against SIGTERM. On reconnect an RPC error is just a failed attempt (retried with backoff); a mismatch exits the process.
  • The --end-height early-exit check moved after the anchor step so a truncation cannot be skipped by it.
  • README: new "Canonical anchor" section, flag row. Also spells out the difference from the chain-id pin: the pin catches a different chain, the anchor catches the same chain having moved under us.

Verification

  • cargo test -p archiver: 35 tests (8 new anchor tests: empty, verified-without-extra-fetches, fail-closed default, reanchor-within-depth truncates exactly the fork tail, deeper-than-depth fails without truncating, legacy tip, legacy-below-mismatch stops the walk, RPC error propagates without truncating; 1 new store test for truncate_above incl. count persistence across reopen). clippy -D warnings, fmt.
  • Empirical, against reth --dev: archived 49 blocks, then swapped in a fresh dev chain (same chain id 1337, different hashes) and let it pass height 49.
    • default: exit 1, refusing to resume on top of a fork; re-run with --reanchor-max-depth N … / stored tip 49 is not canonical: stored block hash 0x44fe…, canonical block hash 0x8e10…
    • --reanchor-max-depth 3 and 100: exit 1, no canonical block found within --reanchor-max-depth …; the fork is deeper than allowed, store still holds 49 entries
    • same chain restarted: stored tip is canonical tip=49, resumes normally
    • a stored tip that the source has not reached yet fails with the fetch error (Failed to get block 49), which is the conservative outcome for a lagging replica.

@cursor

cursor Bot commented Sep 11, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes archive integrity on startup/reconnect: bounded truncation can delete stored roots, and misconfiguration or RPC issues can halt the process or leave proofs wrong if operators bypass checks.

Overview
Adds canonical anchor reconciliation so the archiver does not resume on an abandoned fork after restart or stream reconnect—something the existing reorg guard cannot catch when it only appends from tip + 1.

Before (re)starting the stream it re-fetches the stored tip block and compares RPC hash to the persisted block hash. Match resumes normally; legacy rows without a hash warn and continue; mismatch fails closed with StoreError::AnchorMismatch unless --reanchor-max-depth N (REANCHOR_MAX_DEPTH, default 0) allows walking back at most N stored heights, truncate_above the fork tail, and resuming from the last canonical block.

Wiring: new anchor::reconcile, RootStore::truncate_above, startup reconcile after chain-id pin (with --end-height check moved after anchor), and the same check on each successful WS reconnect (RPC errors retry; anchor mismatch exits). README documents the flag and how this differs from chain-id pinning.

Reviewed by Cursor Bugbot for commit b5748c0. Bugbot is set up for automated code reviews on this repo. Configure here.

…nect

Audit finding 4. The store's reorg guard only fires when an already-stored
height is rewritten. Resume-after-restart and reconnect-after-stream-death
both continue from stored tip + 1, so a reorg past the finalization lag (or a
lag set too small) while the archiver was away left the tail on an abandoned
fork with canonical roots spliced on top, unnoticed.

- New anchor module: re-fetch the block at the stored tip and compare its
  hash with the one persisted next to the root. Match → resume. Legacy entry
  without a hash → warn, resume. Mismatch → fail closed with
  StoreError::AnchorMismatch and the store untouched.
- --reanchor-max-depth N (default 0) opts into bounded self-healing: walk
  back at most N stored blocks to the last canonical one, drop everything
  above it (new RootStore::truncate_above, count corrected and persisted)
  and resume from there. No canonical block within N still fails closed, so
  an RPC serving the wrong chain cannot wipe the archive.
- Runs at startup (after the chain-id pin, before backfill) and on every
  reconnect against the fresh client, selected against SIGTERM. On
  reconnect an RPC error is a failed attempt and retried; a mismatch exits.

Verified against reth --dev: archived 49 blocks, swapped in a fresh dev chain
(same chain id, new hashes) past height 49. Default: exit 1 with the
AnchorMismatch hint. Depth 3 and 100: 'fork is deeper than allowed', 49
entries still in the store. Same chain restarted: 'stored tip is canonical'.
@DylanVerstraete
DylanVerstraete requested review from a team, BradleyOlson64, beqaabu, creditcoinprotoclaw, didac-gluwa, jakerumbles and mdbig1 and removed request for a team September 11, 2026 10:46
DylanVerstraete added a commit that referenced this pull request Sep 16, 2026
…ad; end a silent subscription

Review (beqaabu):

1. Boundary::Attested released heights straight from the published bound
   with no regard for the head this subscription had delivered. Against a
   source node behind the attestors, a height above its head fails its fetch,
   which StreamRoots treats as a connection error: heap cleared, every
   in-flight root aborted, reconnect, and the same height fails again next
   round. Zero progress, including on heights the node could serve.
   Maturity::mature_height already keeps the invariant for block tags; the
   attested arm now does too. The bound is folded with the newest head and
   clamped, and nothing is released until both are known.

2. Under an attested bound a silent subscription was indistinguishable from
   "nothing new to fetch", so a dead socket hid behind the archiver's
   "no new attestation yet" log for 13 minutes in the Sepolia run. Both the
   roots and tip streams now end the head stream when it stays silent for
   head_silence_timeout (default 120 s) AND the node, asked directly, reports
   the chain moved on or cannot be reached. A chain that genuinely produced
   no block (dev chains mining on demand) is left alone, so this does not
   churn CI's anvil setups. Ending the stream is what makes the outer layer
   reconnect, so the failure surfaces as a reconnect instead of silence.

3. A comment cited a canonical-anchor check that does not exist on this
   branch; it arrives with the archiver liveness stack (#1347). Reworded to
   say so and to make clear that reconciling reverted roots is not the
   bound poller's job.
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