From 6df96b8f6f1e7d0f7c78032460d67e750c48ed54 Mon Sep 17 00:00:00 2001 From: roman Date: Mon, 6 Jul 2026 00:25:58 -0600 Subject: [PATCH] feat(network): rework below-sync-start backfill as a forward cursor and rename anchor terminology Replace the disabled backward checkpoint backfill with a second forward cursor sweeping genesis -> trusted sync start: checkpoint-bounded finalized ranges folding a dedicated backfill history tree (seeded empty at genesis), verified with the same verify_supplied_roots_from_parts gate as the forward frontier, persisting header-authenticated confirmed root prefixes into the serving index. A final non-finalized stitch range confirms the sync-start height's own root. Backfill progress is tracked by its own frontier (exempt from forward coverage, with a frontier-keyed staleness rule), never moves best_header_tip or the sync-exchange frontier, and stays dormant behind BELOW_SYNC_START_BACKFILL_ENABLED. Rename the overloaded "anchor" terminology across the header-sync layer: trusted_sync_start (sync start), link_hash (per-range link target), StaleLinkFailures, rebase_*/HeaderRebased, and peer status sync_start_height. Config keys and the zebra-state API keep their legacy names at the boundary. Design doc: docs/design/below-sync-start-backfill.md --- docs/design/below-sync-start-backfill.md | 214 +++++ docs/design/verified-commitment-trees.md | 73 +- zebra-network/src/zakura/block_sync/bbr.rs | 4 +- .../src/zakura/block_sync/peer_routine.rs | 2 +- .../src/zakura/block_sync/reactor.rs | 4 +- .../src/zakura/block_sync/request.rs | 2 +- .../src/zakura/block_sync/sequencer_task.rs | 8 +- zebra-network/src/zakura/block_sync/tests.rs | 26 +- zebra-network/src/zakura/discovery/service.rs | 14 +- zebra-network/src/zakura/exchange.rs | 40 +- zebra-network/src/zakura/handler.rs | 33 +- .../src/zakura/header_sync/config.rs | 22 +- zebra-network/src/zakura/header_sync/error.rs | 18 +- .../src/zakura/header_sync/events.rs | 44 +- zebra-network/src/zakura/header_sync/mod.rs | 2 +- .../src/zakura/header_sync/reactor.rs | 348 ++++++-- .../src/zakura/header_sync/scheduler.rs | 53 +- .../src/zakura/header_sync/service.rs | 2 +- zebra-network/src/zakura/header_sync/state.rs | 192 +++-- zebra-network/src/zakura/header_sync/tests.rs | 800 +++++++++++++++--- .../src/zakura/header_sync/validation.rs | 18 +- zebra-network/src/zakura/header_sync/wire.rs | 2 +- .../src/zakura/testkit/blocksync_fuzz/mod.rs | 6 +- .../zakura/testkit/blocksync_fuzz/scenario.rs | 10 +- .../zakura/testkit/blocksync_fuzz/tests.rs | 8 +- zebra-network/src/zakura/testkit/cluster.rs | 103 +-- .../src/zakura/testkit/mock_blocksync.rs | 4 +- zebra-network/src/zakura/testkit/node.rs | 14 +- zebra-network/src/zakura/trace.rs | 10 +- .../service/finalized_state/zebra_db/block.rs | 23 +- .../zebra_db/block/tests/vectors.rs | 80 +- zebrad/src/commands/start.rs | 131 +++ .../start/zakura/header_sync_driver.rs | 81 +- 33 files changed, 1855 insertions(+), 536 deletions(-) create mode 100644 docs/design/below-sync-start-backfill.md diff --git a/docs/design/below-sync-start-backfill.md b/docs/design/below-sync-start-backfill.md new file mode 100644 index 00000000000..19d4a232051 --- /dev/null +++ b/docs/design/below-sync-start-backfill.md @@ -0,0 +1,214 @@ +# Below-sync-start forward backfill (Zakura header sync) + +## Overview + +A node whose Zakura header sync starts at a **trusted sync start** — a configured checkpoint +above genesis — has no headers or commitment roots for the region below it. This design +replaces the old, disabled *backward* checkpoint backfill with a **forward backfill**: a second +forward cursor that sweeps genesis → sync start using the exact same code path as the main +forward frontier, verifying peer-supplied commitment roots along the way and filling the +`commitment_roots_by_height` serving index. + +The mechanism ships **dormant** behind a compile-time default +(`BELOW_SYNC_START_BACKFILL_ENABLED = false`, plumbed per-startup as +`HeaderSyncStartup::backfill_enabled`) until the node wiring consumes backfilled data. Its +targeted consumer is the roots-index serving backfill described in +`verified-commitment-trees.md` §10: with it armed, even a snapshot-started node becomes a +full-range roots server without fetching bodies. + +Alongside the rework, the overloaded term **"anchor"** is renamed across the header-sync layer +(see [Terminology rename](#terminology-rename)). + +## 1. Why the backward design was disabled + +The original v1 backfill scheduled one *backward* checkpoint bracket below the sync start, +linked on the previous checkpoint's hash. Under verified commitment trees (VCT, +`verified-commitment-trees.md` §6.4), every root-carrying range must be folded into a history +tree positioned at the range's parent to authenticate its roots against header commitments. +A backward range folds onto the *previous checkpoint's* tree — which the reactor never tracks +and cannot cheaply obtain (there is no per-height finalized history tree, and checkpoints carry +hashes, not tree states). So backward ranges could only ever be checkpoint-hash-authenticated, +committed with no verified roots, and nothing consumed them. The scheduling was disabled +outright rather than left emitting unverifiable requests. + +The insight behind the rework: **the only tree seed the reactor can always know is genesis** +(the empty tree — the natural pre-Heartwood value). A backfill that runs *forward from genesis* +needs no checkpoint-bracket tree tracking at all: it builds its own tree exactly the way the +main forward frontier does. + +## 2. Design + +### 2.1 A second forward cursor + +`HeaderSyncCore` gains a backfill frontier mirroring the forward one: + +| Field | Meaning | +| --- | --- | +| `backfill_tip` / `backfill_hash` / `backfill_parent_hash` | highest backfilled header below the sync start (its own root may still be unconfirmed) | +| `backfill_history_tree` | ZIP-221 history tree positioned at the parent of the next backfill range; seeded empty at genesis | +| `backfill_sync_start_root_confirmed` | true once the stitch (§2.3) has confirmed and persisted the sync-start height's own root | + +`refresh_backfill_range` (state.rs) schedules one range at a time while +`backfill_tip < sync_start`: + +- **Checkpoint-bounded brackets.** Each range ends at the next checkpoint at/above the frontier, + capped at the sync start (itself a checkpoint, so the cap never produces a non-checkpoint + end). Ranges are `finalized: true` — whole delivery is required and the delivered end must + hash-match the checkpoint list — and `want_tree_aux_roots: true` (the whole region is below + the VCT handoff boundary). +- **One-block overlap.** Root-carrying ranges redeliver the frontier header, linked on + `backfill_parent_hash`, so the previous range's tip root is confirmed by its successor — + identical to the forward overlap rule. +- **Priority.** `RangePriority::Backfill` ranges are assigned strictly after forward work + (the scheduler drains the forward queue first), so backfill never competes with tip sync. + +### 2.2 Verification: the same gate as forward + +Delivery validation reuses the forward pipeline end to end. The verified-roots gate keys on +`want_tree_aux_roots` alone; `validate_header_aux_commitments_for_range` selects the tree by +priority (forward frontier tree vs backfill tree) and both feed the same +`verify_supplied_roots_from_parts` fold (`zebra-chain/src/parallel/commitment_aux_verify.rs`) — +ZIP-221 one-block-lag commitment checks plus the direct below-Heartwood/NU5/`Nu6_3` pins, no +new crypto. Commits carry `verified_roots: Some(..)` and persist only the header-authenticated +confirmed prefix, exactly like forward ranges. The trust boundary of +`verified-commitment-trees.md` §11 is unchanged: nothing unauthenticated is ever written. + +`HeaderSyncAction::CommitHeaderRange` gains a `backfill: bool` flag. On the committed event the +reactor resolves the originating cursor from its pending commit and advances **only** the +backfill frontier: backfill commits never move `best_header_tip`, never publish the shared +sync-exchange frontier (the zebrad driver skips `publish_header_frontier`), and never produce +body gaps. + +### 2.3 The sync-start stitch + +A root at height `H` is only confirmed by the header at `H + 1`. Inside the backfill region that +is handled by the bracket overlap, but the **sync-start height's own root** has no confirming +successor there — and the forward path never persists it either (its first range starts at +`sync_start + 1`, and `CommitHeaderRange` persists confirmed prefixes, never the height below a +range start). Left alone, the roots serving index would have a permanent one-height hole exactly +at the sync start, truncating every all-or-nothing `BlockRoots` serve that crosses it. + +So once the backfill frontier reaches the sync start, a final **stitch range** +`[sync_start ..= sync_start + 1]` re-fetches the sync-start header plus its successor: + +- `finalized: false` (its end is not a checkpoint), so instead the reactor authenticates any + delivered backfill header at the sync-start height **in-span against the trusted hash**; + state re-checks every checkpoint height at commit as defense in depth. +- The fold confirms exactly the sync-start root (confirmed prefix of a two-header range). +- The redelivered `sync_start + 1` header re-commits idempotently (state accepts identical + re-commits and never lets a header range overwrite committed bodies or finalized headers). +- The commit sets `backfill_sync_start_root_confirmed`; the frontier itself stays at the sync + start, and the backfill is complete. + +### 2.4 Coverage exemption and the backfill staleness rule + +The scheduler's covered-interval machinery tracks the **forward** frontier's committed spans. +The stitch deliberately overlaps forward-covered heights (the forward path starts at the sync +start), so backfill ranges are **exempt from covered checks everywhere** — `ensure`, `retry`, +`prune_covered`, and `cancel_covered_outstanding` all skip `RangePriority::Backfill`. + +That exemption needs a replacement staleness rule. Without one, a fanout duplicate of an +already-committed bracket would loop forever: delivered against the advanced backfill tree it +can only mismatch, be retried, be reassigned, and mismatch again. Backfill's rule is keyed to +its own frontier: when a bracket commit advances `backfill_tip`, queued, assigned, and in-flight +backfill ranges whose end is at or below the new frontier are retired +(`RangeScheduler::retire_stale_backfill`, `cancel_stale_backfill_outstanding`), and late +deliveries for the cancelled requests are tolerated through the existing late-covered-response +credit. A delivery that still reaches the tree-mismatch arm with a stale range is dropped rather +than retried. + +### 2.5 Tree mismatch is a bug, not a race + +For the forward frontier, a tree behind its range parent means a non-Zakura path committed +ahead, and a guarded `QueryBestHeaderHistoryTree` rebuild recovers. Nothing analogous moves the +backfill region: the rebuild path is keyed to `best_header_tip`, and full-block commits below +the sync start do not occur in the intended deployment. A backfill tree mismatch therefore +indicates an internal bug. The reactor logs at `warn`, increments +`sync.header.backfill.tree_mismatch`, clears the assignment, and retries (or drops a stale +range) — it deliberately does **not** dispatch the forward rebuild, which would install the +wrong tree. The v1 recovery for a persistently wedged backfill is a restart, which reseeds the +cursor at genesis. + +### 2.6 Startup and restarts + +v1 keeps no durable backfill cursor: every startup reseeds at genesis. This is safe (identical +re-commits are idempotent; roots re-verify from the empty tree) and acceptable for a dormant +feature, but wasteful once armed. The follow-up is a startup read analogous to +`ReadRequest::BestHeaderHistoryTree` but genesis-based: fold the durable below-sync-start +confirmed roots from the empty tree up to the first gap (capped below the sync start) and return +the frontier `(height, hash)` plus tree. `HeaderSyncCore::new` isolates backfill seeding at a +single insertion point for exactly this. + +## 3. Terminology rename + +"Anchor" previously meant three things in this layer: the trusted sync start, the per-range link +target, and — worst — it collided with Zcash note-commitment *anchors*, the very anchor sets VCT +folds roots into. Renamed across `zebra-network/src/zakura/` and the zebrad Zakura drivers: + +| Old | New | +| --- | --- | +| `anchor` (sync-start sense: core/startup/config accessor) | `trusted_sync_start` | +| `RangeRequest.anchor_hash`, block-sync request `anchor_hash` | `link_hash` | +| `validate_anchor` | `validate_trusted_sync_start` | +| `StaleAnchorFailures` / `stale_anchor` / `HEADER_SYNC_STALE_ANCHOR_*` | `StaleLinkFailures` / `stale_link` / `HEADER_SYNC_STALE_LINK_*` | +| `reanchor_*`, `HeaderReanchored`, `HeaderFrontierReanchor` | `rebase_*`, `HeaderRebased`, `HeaderFrontierRebase` | +| metric `sync.header.history_tree.reanchor` | `sync.header.history_tree.rebase` | +| peer status `anchor_height` (advertised serving floor) | `sync_start_height` (wire encoding unchanged) | + +Deliberately kept (compatibility; flagged as follow-up): the `ZakuraHeaderSyncConfig` +`anchor_height` / `anchor_hash` config keys, and the zebra-state API names +(`Request::CommitHeaderRange { anchor }`, `CommitHeaderRangeError::{UnknownAnchor, +MissingGenesisAnchor}`). The driver maps `link_hash` into the state request's `anchor` field at +the crate boundary with a comment. + +## 4. Pre-arming checklist (known gaps, acceptable while dormant) + +- **Genesis link context.** The first backfill commit is linked on the genesis hash and requires + the genesis header in state (`MissingGenesisAnchor` otherwise — a silent local retry loop). A + sync-start-configured node may hold neither; seed the genesis header at driver startup when + arming. +- **Peers without below-sync-start history.** Assignment filters only by advertised tip, so + peers that themselves started at a sync start return empty responses and cycle through the + retry backoff. Enhancement: prefer peers whose advertised `sync_start_height` is at/below the + range parent for backfill ranges. +- **Archival nodes with bodies below the sync start.** Backfill would re-insert zakura header + rows at body-holding heights. Either document "don't arm backfill on archival nodes" or add a + state-side skip of identical header-row inserts at body-holding heights. +- **Bracket size vs the root-carrying byte budget.** Finalized ranges are never narrowed, so + every checkpoint bracket must fit one root-carrying response + (`clamp_header_sync_request_count(.., want_tree_aux_roots)`). Verify mainnet checkpoint + spacing (≤ 400) fits before arming; otherwise brackets need a multi-range interior. +- **Startup resume read** (§2.6) to avoid re-fetching from genesis on every restart. + +## 5. Testing + +Reactor (`zebra-network/src/zakura/header_sync/tests.rs`, fixtures opt in via +`backfill_enabled`): + +- `below_sync_start_backfill_is_disabled_by_default` — default-off regression. +- `backfill_schedules_checkpoint_bounded_range_from_genesis` — bracket shape + truncated + finalized delivery rejected. +- `backfill_checkpoint_end_hash_mismatch_is_misbehavior` — divergent checkpoint fixture. +- `backfill_verifies_roots_against_backfill_tree` — commit carries `backfill: true` with the + confirmed prefix; a wrong root is `InvalidRange` with no commit. +- `backfill_commit_advances_backfill_frontier_and_stitch_completes` — full lifecycle: bracket + commit → stitch request → stitch commit confirms exactly the sync-start root → no further + backfill requests; asserts no `HeaderAdvanced`/`BodyGaps` on backfill commits. +- `forward_ranges_are_assigned_before_backfill` — the forward range's full fanout is assigned + before the backfill bracket gets its first peer. +- `late_backfill_fanout_delivery_is_dropped_without_rebuild` — no `QueryBestHeaderHistoryTree`, + no misbehavior, no duplicate commit. + +State (`zebra-state`): `header_range_commit_accepts_intermediate_anchor_and_identical_recommit` +— mid-backfill bracket anchors resolve through the header index, identical re-commits (the +stitch shape) are accepted, and the header tip never regresses. Existing tests already pin the +confirmed-prefix/empty/full-length root-vector shapes. + +Driver (`zebrad`): `header_sync_driver_backfill_commit_does_not_publish_header_frontier` — a +`backfill: true` commit leaves the exchange frontier untouched; the identical forward commit +publishes it. + +## 6. Related documents + +- `verified-commitment-trees.md` — the VCT design this builds on; §6.4 describes the shared + verification gate, §10 the serving-availability consumer, §12 increment 6f. diff --git a/docs/design/verified-commitment-trees.md b/docs/design/verified-commitment-trees.md index 75b1333bdd5..97cc5346f54 100644 --- a/docs/design/verified-commitment-trees.md +++ b/docs/design/verified-commitment-trees.md @@ -457,9 +457,9 @@ other peers are already header-authenticated, and so a restart never trusts an u otherwise the base read returns `None` and the rebuild degrades to a network-paced no-progress loop. And the reload carries the confirmed frontier `(height, hash)` the fold actually reached, not just the tree: when durable roots have a gap the fold stops _below_ `best_header_tip - 1`, so the rebuilt tree - never reaches the current tip's parent. The reactor then reanchors its header tip down onto that - frontier — resuming one block above it, anchored on the frontier hash, exactly as the startup - reconstruction does (`sync.header.history_tree.reanchor`) — instead of keeping the stale higher tip, + never reaches the current tip's parent. The reactor then rebases its header tip down onto that + frontier — resuming one block above it, linked on the frontier hash, exactly as the startup + reconstruction does (`sync.header.history_tree.rebase`) — instead of keeping the stale higher tip, where every forward range would re-detect the behind tree and re-trigger the identical deterministic rebuild forever. - **Verify before persisting.** When a below-checkpoint header range arrives, the reactor folds its @@ -475,19 +475,33 @@ other peers are already header-authenticated, and so a restart never trusts an u real-tree roots there; only header-only heights fall back to provisional roots. - **Persist only the confirmed prefix.** A block's commitment binds the history tree as of its parent, so a range `[start..=end]` authenticates the roots for `[start..=end-1]`; the tip's own - root is only confirmed once the next range delivers `end+1`. Forward ranges therefore overlap by - one block — the next request re-anchors at the tip's parent — and `CommitHeaderRange` persists - only the header-authenticated confirmed prefix; the range tip's root is never written. The state - writes exactly the roots it is handed (`prepare_header_range_batch_with_roots` accepts a prefix one - shorter than the headers, or none — see the checkpoint-backfill note below), so the "one root per - header" wire invariant (§5.4) and the persisted set are deliberately distinct. -- **Checkpoint backfill skips this gate.** Only _forward_ ranges carry a frontier tree that can be - folded and checked. _Backward_ checkpoint-backfill ranges (headers below the sync anchor) are - authenticated by the checkpoint hash and fold onto the previous checkpoint's tree, not the forward - frontier the reactor caches, so they are committed without header-commitment validation and - persist no provisional roots. `prepare_header_range_batch_with_roots` accepts an empty roots vector - for exactly this shape; a full-length (tip-included) vector is still rejected, so the trust - boundary is unchanged — nothing unauthenticated is ever written. + root is only confirmed once the next range delivers `end+1`. Root-carrying ranges therefore + overlap by one block — the next request links at the tip's parent — and `CommitHeaderRange` + persists only the header-authenticated confirmed prefix; the range tip's root is never written. + The state writes exactly the roots it is handed (`prepare_header_range_batch_with_roots` accepts + a prefix one shorter than the headers, or none for plain above-checkpoint ranges), so the "one + root per header" wire invariant (§5.4) and the persisted set are deliberately distinct. +- **Below-sync-start backfill uses the same gate.** A node whose trusted sync start is a + checkpoint above genesis can backfill the headers and roots below it with a second forward + cursor: checkpoint-bounded finalized ranges starting at genesis, folding a dedicated backfill + history tree seeded empty (the natural pre-Heartwood value) and advancing bracket by bracket up + to the sync start, with the same one-block overlap and the same + `verify_supplied_roots_from_parts` verification the forward frontier uses — so backfilled roots + are header-authenticated and fill the `commitment_roots_by_height` serving index (the §10 + serving-availability backfill). The sync-start height's own root has no confirming successor + inside the backfill region (the forward path starts above it and never persists it), so a final + non-finalized stitch range `[sync_start ..= sync_start + 1]` re-fetches the sync-start header — + authenticated in-span against the trusted hash — plus its successor to confirm and persist that + last root; the redelivered successor re-commits idempotently. Backfill progress is tracked by + its own frontier rather than the forward covered set (the stitch deliberately overlaps + forward-covered heights), backfill commits never move `best_header_tip` or the shared + sync-exchange frontier, and a backfill tree that mismatches its range parent is retried without + dispatching the forward tree rebuild (`sync.header.backfill.tree_mismatch`). The whole mechanism + is dormant behind `BELOW_SYNC_START_BACKFILL_ENABLED` (per-startup `backfill_enabled`, opted + into by tests) until the node wiring consumes backfilled data; v1 restarts reseed the cursor at + genesis (identical re-commits are idempotent), and a follow-up startup read — a + genesis-based analogue of `BestHeaderHistoryTree` folding the durable below-sync-start roots up + to the first gap — can resume it from the highest contiguous backfilled frontier instead. - **Reconstruct at startup.** The durable roots CF therefore holds a contiguous run of confirmed roots above the verified body tip, but never the header tip's own root. On startup `ReadRequest::BestHeaderHistoryTree` folds the durable confirmed roots onto the verified-tip @@ -663,7 +677,8 @@ corrupting state. Two mechanisms address it, in order of cost: cost. A background task can backfill missing lower ranges by fetching _roots_ (not bodies), so even a snapshot-started node becomes a full-range roots server cheaply. This is the targeted fix for - the §10 serving-availability gap. + the §10 serving-availability gap; its transport already exists as the dormant below-sync-start + forward backfill (§6.4, increment 6f). - **Indexing-follower resync (heavyweight, opt-in).** Rebuild the per-height trees off the consensus critical path (re-downloading bodies if pruned), turning a fast node into a full archive node. This pays back the cost fast-sync avoided, so it is the archive/RPC path @@ -735,9 +750,16 @@ commitment before it influences the anchor set or the history MMR.** Consequence gossip-free, the normal Zakura path advances the tree by fold-on-commit. The `QueryBestHeaderHistoryTree` / `BestHeaderHistoryTreeLoaded` reload path remains as a guarded fallback when checkpoint, legacy, or gossip-driven commits move the durable body frontier ahead of - the header-frontier tree below the checkpoint; it rebuilds from durable roots, reanchors if the + the header-frontier tree below the checkpoint; it rebuilds from durable roots, rebases if the rebuilt frontier stops at a gap, and stays idle during ordinary header-leading sync. The - reanchor/follow-verified-tip scheduling is kept but gated to fire only at/above the checkpoint. + rebase/follow-verified-tip scheduling is kept but gated to fire only at/above the checkpoint. +- **Increment 6f — below-sync-start forward backfill (dormant).** The old backward + checkpoint-backfill (which could not verify roots — backward ranges would fold onto the + previous checkpoint's tree, which the reactor never tracks) is reworked into a second forward + cursor from genesis with its own backfill history tree, reusing the forward verification path + end to end and persisting header-authenticated roots below the trusted sync start (§6.4). It + ships dormant behind `BELOW_SYNC_START_BACKFILL_ENABLED` until the node wiring consumes + backfilled data — the targeted consumer is the §10 roots-index serving backfill. - **Increment 7 — indexing follower lane (archive only).** Relocate `tx_by_loc` + address indexes and the per-height trees + subtree CFs onto an async follower, so archive mode regains historical RPC without re-adding the frontier recompute to the consensus path. @@ -772,7 +794,8 @@ Live commit-path counters distinguish the fast and legacy paths and the failure | `state.vct.fast_path.miss` | a finalized commit did not take the fast path | | `state.vct.root.stalled.height` (gauge) | a height stuck on a retryable stall past the warn threshold | | `sync.header.history_tree.rebuild` | header-frontier history tree lazily rebuilt (§6.4) because a non-Zakura commit ran ahead of it below the checkpoint; **stays 0 in the normal header-leading path**, so a nonzero value flags fallback/catch-up | -| `sync.header.history_tree.reanchor` | a lazy rebuild (§6.4) folded to a frontier _below_ `best_header_tip - 1` (durable roots had a gap), so the reactor reanchored its header tip down onto the rebuilt tree; a subset of `rebuild`, and likewise 0 in the normal path | +| `sync.header.history_tree.rebase` | a lazy rebuild (§6.4) folded to a frontier _below_ `best_header_tip - 1` (durable roots had a gap), so the reactor rebased its header tip down onto the rebuilt tree; a subset of `rebuild`, and likewise 0 in the normal path | +| `sync.header.backfill.tree_mismatch` | a below-sync-start backfill delivery found the backfill tree mismatched its range parent (§6.4); nothing else moves the backfill region, so a nonzero value flags an internal bug (recovery: restart reseeds the cursor at genesis) | The header-sync `headers_received` / `headers_served` / commit-state trace rows also carry `want_tree_aux_roots` and `tree_aux_roots_len`, so root delivery is visible per range. The @@ -805,6 +828,16 @@ asserts to prove roots actually came over the wire rather than a silent legacy s exercise serving and committing finalized ranges with roots end-to-end, including the all-or-nothing serving helper (roots attached only on complete coverage, otherwise rootless headers) and routing received roots into `CommitHeaderRange`. +- **Below-sync-start backfill (reactor):** default-off regression + (`below_sync_start_backfill_is_disabled_by_default`); checkpoint-bounded bracket scheduling and + whole-delivery enforcement; checkpoint-end hash mismatch rejection; root verification against + the backfill tree (commit carries the confirmed prefix with `backfill: true`; a wrong root is + misbehavior); frontier advance and stitch completion without moving the forward frontier or + emitting body gaps; forward-before-backfill assignment ordering; a late fanout duplicate of a + committed bracket dropped without the forward tree rebuild. Driver side: a `backfill: true` + commit does not publish the exchange header frontier. State side: intermediate-anchor and + identical re-commit acceptance + (`header_range_commit_accepts_intermediate_anchor_and_identical_recommit`). - **State persistence:** `CommitHeaderRange` persists provisional roots into `commitment_roots_by_height`, rejects count/height mismatches, refuses to overwrite the verified row of an already-committed height diff --git a/zebra-network/src/zakura/block_sync/bbr.rs b/zebra-network/src/zakura/block_sync/bbr.rs index 9a0a2ec5890..13b48990e75 100644 --- a/zebra-network/src/zakura/block_sync/bbr.rs +++ b/zebra-network/src/zakura/block_sync/bbr.rs @@ -1033,7 +1033,7 @@ mod bbr_tests { request: BlockRangeRequest { start_height: block::Height(0), count: 1, - anchor_hash: block::Hash([0; 32]), + link_hash: block::Hash([0; 32]), estimated_bytes: 0, expected_blocks: Vec::new(), }, @@ -1137,7 +1137,7 @@ mod bbr_tests { request: BlockRangeRequest { start_height: height, count: 1, - anchor_hash: block::Hash([0; 32]), + link_hash: block::Hash([0; 32]), estimated_bytes: bytes_each, expected_blocks: vec![ExpectedBlock { height, diff --git a/zebra-network/src/zakura/block_sync/peer_routine.rs b/zebra-network/src/zakura/block_sync/peer_routine.rs index 3625d439246..ab4edba8e08 100644 --- a/zebra-network/src/zakura/block_sync/peer_routine.rs +++ b/zebra-network/src/zakura/block_sync/peer_routine.rs @@ -871,7 +871,7 @@ impl PeerRoutine { let request = BlockRangeRequest { start_height: items[0].0, count, - anchor_hash: items[0].1.hash, + link_hash: items[0].1.hash, // The summed size-estimate reservation for this request (released // on a send failure below); equals the sum of the per-height // `expected_blocks` estimates. diff --git a/zebra-network/src/zakura/block_sync/reactor.rs b/zebra-network/src/zakura/block_sync/reactor.rs index a2baa954fb9..2a80a14e252 100644 --- a/zebra-network/src/zakura/block_sync/reactor.rs +++ b/zebra-network/src/zakura/block_sync/reactor.rs @@ -628,7 +628,7 @@ impl BlockSyncReactor { self.handle_state_frontiers_changed(state_frontiers).await; } } - FrontierChange::HeaderReanchored => { + FrontierChange::HeaderRebased => { self.state.best_header_tip = frontier.best_header.height; self.state.best_header_hash = frontier.best_header.hash; self.handle_chain_tip_reset(state_frontiers, false).await; @@ -861,7 +861,7 @@ impl BlockSyncReactor { // outstanding-but-unreceived. Without this clause the producer would // re-queue that height and issue a duplicate concurrent fetch (the original // filter excluded it the same way; the stale outstanding clears on its own - // timeout). The hash is checked so a reanchor (different hash) still + // timeout). The hash is checked so a rebase (different hash) still // re-queues. The registry's outstanding is routine-owned, so this clause is // now backed by per-peer state independent of `work.in_flight`. let blocks: Vec<_> = blocks diff --git a/zebra-network/src/zakura/block_sync/request.rs b/zebra-network/src/zakura/block_sync/request.rs index 3ab52a4c7b2..e3a13bc88b0 100644 --- a/zebra-network/src/zakura/block_sync/request.rs +++ b/zebra-network/src/zakura/block_sync/request.rs @@ -27,7 +27,7 @@ pub enum BlockSizeEstimate { pub(super) struct BlockRangeRequest { pub(super) start_height: block::Height, pub(super) count: u32, - pub(super) anchor_hash: block::Hash, + pub(super) link_hash: block::Hash, /// The reserved byte total for this request (released on /// timeout/disconnect/send-failure). Equal to the sum of the per-height size /// estimates in `expected_blocks`. diff --git a/zebra-network/src/zakura/block_sync/sequencer_task.rs b/zebra-network/src/zakura/block_sync/sequencer_task.rs index a3f81232315..dde9360071a 100644 --- a/zebra-network/src/zakura/block_sync/sequencer_task.rs +++ b/zebra-network/src/zakura/block_sync/sequencer_task.rs @@ -463,7 +463,7 @@ impl SequencerTask { && reset_tip_matches_local_work && self .has_active_successor_after(frontiers.verified_block_tip, peer_has_successor_after) - && self.active_successor_links_to_anchor( + && self.active_successor_links_to_hash( frontiers.verified_block_tip, frontiers.verified_block_hash, ) @@ -701,10 +701,10 @@ impl SequencerTask { self.sequencer.has_buffered_at_or_above(next) || peer_has_successor_after } - fn active_successor_links_to_anchor( + fn active_successor_links_to_hash( &self, height: block::Height, - anchor_hash: block::Hash, + link_hash: block::Hash, ) -> bool { let Some(next) = next_height(height) else { return true; @@ -712,7 +712,7 @@ impl SequencerTask { self.sequencer .applying_previous_block_hash(next) - .map(|previous_block_hash| previous_block_hash == anchor_hash) + .map(|previous_block_hash| previous_block_hash == link_hash) .unwrap_or(true) } diff --git a/zebra-network/src/zakura/block_sync/tests.rs b/zebra-network/src/zakura/block_sync/tests.rs index 8267d8e16f5..dfa0df4d420 100644 --- a/zebra-network/src/zakura/block_sync/tests.rs +++ b/zebra-network/src/zakura/block_sync/tests.rs @@ -454,7 +454,7 @@ fn window_request(height: u32) -> OutstandingBlockRange { request: BlockRangeRequest { start_height: block::Height(height), count: 1, - anchor_hash: block::Hash([byte; 32]), + link_hash: block::Hash([byte; 32]), estimated_bytes: 1, expected_blocks: vec![ExpectedBlock { height: block::Height(height), @@ -477,7 +477,7 @@ fn window_request_range(start: u32, count: u32) -> OutstandingBlockRange { request: BlockRangeRequest { start_height: block::Height(start), count, - anchor_hash: block::Hash([byte; 32]), + link_hash: block::Hash([byte; 32]), estimated_bytes: u64::from(count), expected_blocks: (start..start + count) .map(|height| ExpectedBlock { @@ -4168,7 +4168,7 @@ fn outstanding_three_block_range(budget: &mut ByteBudget) -> OutstandingBlockRan let request = BlockRangeRequest { start_height: block::Height(1), count: 3, - anchor_hash: block::Hash([1; 32]), + link_hash: block::Hash([1; 32]), // Size-estimate reservation: each block reserves its size hint, so the // request reserves the sum of the per-height estimates below. estimated_bytes: THREE_BLOCK_ESTIMATE * 3, @@ -4554,7 +4554,7 @@ fn underestimated_body_is_buffered_and_charges_budget_delta() { let request = BlockRangeRequest { start_height: block::Height(1), count: 1, - anchor_hash: block::Hash([1; 32]), + link_hash: block::Hash([1; 32]), // Size-estimate reservation: the per-height hint, not worst case. estimated_bytes: hint, expected_blocks: vec![ExpectedBlock { @@ -8002,7 +8002,7 @@ async fn checkpoint_hole_disconnect_retries_first_missing_height_with_fresh_peer } #[tokio::test] -async fn reactor_reset_mid_download_drops_stale_anchors_and_releases_budget() { +async fn reactor_reset_mid_download_drops_stale_links_and_releases_budget() { let mut config = ZakuraBlockSyncConfig { max_inflight_block_bytes: BS_PER_BLOCK_WORST_CASE_BYTES * 2, ..immediate_body_download_config() @@ -8617,7 +8617,7 @@ async fn reactor_destructive_forward_reset_does_not_rerequest_same_hash_in_fligh } // A genuine fork to a different hash at height 2 reaches block sync as a reset - // (reanchor), which `reset_above`s the WorkQueue and clears any stale + // (rebase), which `reset_above`s the WorkQueue and clears any stale // `in_flight` claim for height 2 before the producer re-fills — the path the // reset path relies on to install a new per-height hash (a bare // `NeededBlocks` never hash-corrects an in-flight height). After that reset the @@ -8629,7 +8629,7 @@ async fn reactor_destructive_forward_reset_does_not_rerequest_same_hash_in_fligh verified_block_hash: block::Hash([99; 32]), })) .await - .expect("fork reanchor reset queues"); + .expect("fork rebase reset queues"); while !matches!( next_action(&mut actions).await, BlockSyncAction::QueryNeededBlocks { @@ -10835,7 +10835,7 @@ async fn reactor_preserves_successor_work_across_stale_finalized_reset() { } #[tokio::test] -async fn reactor_exchange_reanchor_lowers_only_best_header_target() { +async fn reactor_exchange_rebase_lowers_only_best_header_target() { let initial = test_frontier_update(0, 5, 10, FrontierChange::Snapshot); let (exchange, startup) = exchange_block_sync_startup(initial, immediate_body_download_config()); @@ -10844,7 +10844,7 @@ async fn reactor_exchange_reanchor_lowers_only_best_header_target() { wait_for_query_needed_blocks(&mut actions, block::Height(5), block::Height(10)).await; exchange.publish_frontier( - test_frontier_update(0, 1, 7, FrontierChange::HeaderReanchored), + test_frontier_update(0, 1, 7, FrontierChange::HeaderRebased), "test", ); wait_for_query_needed_blocks(&mut actions, block::Height(5), block::Height(7)).await; @@ -10854,7 +10854,7 @@ async fn reactor_exchange_reanchor_lowers_only_best_header_target() { } #[tokio::test] -async fn reactor_exchange_reanchor_releases_stale_submitted_bodies() { +async fn reactor_exchange_rebase_releases_stale_submitted_bodies() { let blocks = mainnet_blocks_1_to_3(); let mut config = immediate_body_download_config(); // Worst-case reservation: budget for exactly the three in-flight bodies. @@ -10917,7 +10917,7 @@ async fn reactor_exchange_reanchor_releases_stale_submitted_bodies() { ); exchange.publish_frontier( - test_frontier_update(0, 0, 1, FrontierChange::HeaderReanchored), + test_frontier_update(0, 0, 1, FrontierChange::HeaderRebased), "test", ); wait_for_query_needed_blocks(&mut actions, block::Height(0), block::Height(1)).await; @@ -10933,12 +10933,12 @@ async fn reactor_exchange_reanchor_releases_stale_submitted_bodies() { blocks.iter().map(block_meta).collect(), )) .await - .expect("needed metadata after reanchor queues"); + .expect("needed metadata after rebase queues"); let (got_start, got_count) = wait_for_outbound_getblocks(&mut outbound_rx).await; assert_eq!(got_start, block::Height(1)); assert_eq!( got_count, 3, - "reanchored headers must release old submitted bodies and request them again", + "rebased headers must release old submitted bodies and request them again", ); reactor_task.abort(); diff --git a/zebra-network/src/zakura/discovery/service.rs b/zebra-network/src/zakura/discovery/service.rs index 549a51fcd98..a8f883eaf01 100644 --- a/zebra-network/src/zakura/discovery/service.rs +++ b/zebra-network/src/zakura/discovery/service.rs @@ -822,16 +822,16 @@ mod tests { crate::BoxError, > { let network = Network::new_regtest(Default::default()); - let anchor = (block::Height(0), network.genesis_hash()); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, - Some(anchor), + Some(trusted_sync_start), ZakuraHeaderSyncConfig::default(), LOCAL_MAX_MESSAGE_BYTES, ); @@ -1039,7 +1039,7 @@ mod tests { msg: HeaderSyncMessage::Status(HeaderSyncStatus { tip_height: block::Height(1), tip_hash: block::Hash([9; 32]), - anchor_height: block::Height(0), + sync_start_height: block::Height(0), max_headers_per_response: 1, max_inflight_requests: 1, }), diff --git a/zebra-network/src/zakura/exchange.rs b/zebra-network/src/zakura/exchange.rs index 517ed765c9e..1571aa6a48c 100644 --- a/zebra-network/src/zakura/exchange.rs +++ b/zebra-network/src/zakura/exchange.rs @@ -53,8 +53,8 @@ pub enum FrontierChange { VerifiedReset, /// Best header target advanced. HeaderAdvanced, - /// Best header target was reanchored, possibly lower. - HeaderReanchored, + /// Best header target was rebased, possibly lower. + HeaderRebased, } /// Latest shared frontier plus the transition cause. @@ -208,7 +208,7 @@ pub trait HeaderSyncStatePortImpl: Send + Sync + 'static { fn commit_header_range( &self, peer: ZakuraPeerId, - anchor: block::Hash, + link_hash: block::Hash, start_height: block::Height, headers: Vec>, body_sizes: Vec, @@ -218,8 +218,8 @@ pub trait HeaderSyncStatePortImpl: Send + Sync + 'static { /// Publish a locally accepted best-header advance. fn publish_best_header(&self, tip: Frontier) -> BoxFuture<'static, ()>; - /// Publish a best-header reanchor. - fn publish_header_reanchor(&self, old: Frontier, new: Frontier) -> BoxFuture<'static, ()>; + /// Publish a best-header rebase. + fn publish_header_rebase(&self, old: Frontier, new: Frontier) -> BoxFuture<'static, ()>; } /// Block-sync view of shared Zakura state. @@ -284,14 +284,20 @@ impl HeaderSyncStatePort { pub fn commit_header_range( &self, peer: ZakuraPeerId, - anchor: block::Hash, + link_hash: block::Hash, start_height: block::Height, headers: Vec>, body_sizes: Vec, finalized: bool, ) -> BoxFuture<'static, Result> { - self.inner - .commit_header_range(peer, anchor, start_height, headers, body_sizes, finalized) + self.inner.commit_header_range( + peer, + link_hash, + start_height, + headers, + body_sizes, + finalized, + ) } /// Publish a locally accepted best-header advance. @@ -299,9 +305,9 @@ impl HeaderSyncStatePort { self.inner.publish_best_header(tip) } - /// Publish a best-header reanchor. - pub fn publish_header_reanchor(&self, old: Frontier, new: Frontier) -> BoxFuture<'static, ()> { - self.inner.publish_header_reanchor(old, new) + /// Publish a best-header rebase. + pub fn publish_header_rebase(&self, old: Frontier, new: Frontier) -> BoxFuture<'static, ()> { + self.inner.publish_header_rebase(old, new) } } @@ -436,7 +442,7 @@ pub fn apply_frontier_update( } frontier.best_header = requested.frontier.best_header; } - FrontierChange::HeaderReanchored => { + FrontierChange::HeaderRebased => { if requested.frontier.best_header == current.frontier.best_header { return None; } @@ -464,7 +470,7 @@ fn frontier_change_label(change: FrontierChange) -> &'static str { FrontierChange::VerifiedGrow => "verified_grow", FrontierChange::VerifiedReset => "verified_reset", FrontierChange::HeaderAdvanced => "header_advanced", - FrontierChange::HeaderReanchored => "header_reanchored", + FrontierChange::HeaderRebased => "header_rebased", } } @@ -687,7 +693,7 @@ mod tests { frontier(3, 3), frontier(3, 3), frontier(13, 13), - FrontierChange::HeaderReanchored, + FrontierChange::HeaderRebased, ), ] { if let Some(updated) = apply_frontier_update(current, requested) { @@ -719,7 +725,7 @@ mod tests { } #[test] - fn header_reanchor_lowers_only_best_header() { + fn header_rebase_lowers_only_best_header() { let current = update( frontier(5, 5), frontier(8, 8), @@ -730,10 +736,10 @@ mod tests { frontier(1, 1), frontier(2, 2), frontier(9, 9), - FrontierChange::HeaderReanchored, + FrontierChange::HeaderRebased, ); - let updated = apply_frontier_update(current, requested).expect("reanchor is accepted"); + let updated = apply_frontier_update(current, requested).expect("rebase is accepted"); assert_eq!(updated.frontier.finalized, frontier(5, 5)); assert_eq!(updated.frontier.verified_body, frontier(8, 8)); diff --git a/zebra-network/src/zakura/handler.rs b/zebra-network/src/zakura/handler.rs index 03bb4729498..995e979c136 100644 --- a/zebra-network/src/zakura/handler.rs +++ b/zebra-network/src/zakura/handler.rs @@ -2799,18 +2799,21 @@ pub async fn spawn_zakura_endpoint_with_header_sync_driver( config.zakura.bootstrap_peers.len(), supervisor.subscribe(), )?; - let anchor = config.zakura.header_sync.anchor(&config.network)?; + let trusted_sync_start = config + .zakura + .header_sync + .trusted_sync_start(&config.network)?; let frontiers = header_sync_driver_startup.as_ref().map_or( HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, |startup| startup.frontiers, ); let best_header_tip = header_sync_driver_startup .as_ref() - .map_or(Some(anchor), |startup| startup.best_header_tip); + .map_or(Some(trusted_sync_start), |startup| startup.best_header_tip); let best_header_parent_hash = header_sync_driver_startup .as_ref() .and_then(|startup| startup.best_header_parent_hash); @@ -2819,7 +2822,7 @@ pub async fn spawn_zakura_endpoint_with_header_sync_driver( .map(|startup| startup.best_header_history_tree.clone()) .unwrap_or_else(|| Arc::new(HistoryTree::default())); let sync_frontier = header_sync_driver_startup.as_ref().map(|driver_startup| { - let best_header_tip = driver_startup.best_header_tip.unwrap_or(anchor); + let best_header_tip = driver_startup.best_header_tip.unwrap_or(trusted_sync_start); let initial = FrontierUpdate { frontier: crate::zakura::chain_frontier_from_parts( driver_startup.frontiers.finalized_height, @@ -2835,7 +2838,7 @@ pub async fn spawn_zakura_endpoint_with_header_sync_driver( }); let mut startup = HeaderSyncStartup::new( config.network.clone(), - anchor, + trusted_sync_start, frontiers, best_header_tip, config.zakura.header_sync.clone(), @@ -2858,7 +2861,7 @@ pub async fn spawn_zakura_endpoint_with_header_sync_driver( let block_sync_driver_enabled = header_sync_driver_startup.is_some(); let (block_sync, block_sync_actions, block_sync_task) = if let Some(driver_startup) = header_sync_driver_startup.as_ref() { - let best_header_tip = driver_startup.best_header_tip.unwrap_or(anchor); + let best_header_tip = driver_startup.best_header_tip.unwrap_or(trusted_sync_start); let frontier_updates = sync_frontier .as_ref() .expect("sync frontier is initialized when block sync driver is enabled") @@ -5428,16 +5431,16 @@ mod tests { fn header_sync_startup(shutdown: CancellationToken) -> HeaderSyncStartup { let network = Network::Mainnet; - let anchor = (block::Height(0), network.genesis_hash()); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, - Some(anchor), + Some(trusted_sync_start), ZakuraHeaderSyncConfig::default(), LOCAL_MAX_MESSAGE_BYTES, ); @@ -5458,7 +5461,7 @@ mod tests { HeaderSyncStatus { tip_height: block::Height(0), tip_hash: network.genesis_hash(), - anchor_height: block::Height(0), + sync_start_height: block::Height(0), ..HeaderSyncStatus::default() } } diff --git a/zebra-network/src/zakura/header_sync/config.rs b/zebra-network/src/zakura/header_sync/config.rs index dd42577587c..36cc2402986 100644 --- a/zebra-network/src/zakura/header_sync/config.rs +++ b/zebra-network/src/zakura/header_sync/config.rs @@ -9,7 +9,7 @@ pub struct HeaderSyncStatus { /// Sender's best known tip hash. pub tip_hash: block::Hash, /// Sender's lowest contiguous header height. - pub anchor_height: block::Height, + pub sync_start_height: block::Height, /// Maximum headers the sender will serve per response. pub max_headers_per_response: u32, /// Maximum concurrent `GetHeaders` requests the sender will service. @@ -20,7 +20,7 @@ impl HeaderSyncStatus { pub(super) fn encode_to(&self, writer: &mut W) -> Result<(), HeaderSyncWireError> { write_height(writer, self.tip_height)?; self.tip_hash.zcash_serialize(&mut *writer)?; - write_height(writer, self.anchor_height)?; + write_height(writer, self.sync_start_height)?; writer.write_u32::(clamp_advertised_range(self.max_headers_per_response))?; writer.write_u16::(self.max_inflight_requests)?; Ok(()) @@ -30,7 +30,7 @@ impl HeaderSyncStatus { Ok(Self { tip_height: read_height(reader)?, tip_hash: block::Hash::zcash_deserialize(&mut *reader)?, - anchor_height: read_height(reader)?, + sync_start_height: read_height(reader)?, max_headers_per_response: clamp_advertised_range(reader.read_u32::()?), max_inflight_requests: reader.read_u16::()?, }) @@ -42,7 +42,7 @@ impl Default for HeaderSyncStatus { Self { tip_height: block::Height::MIN, tip_hash: block::Hash([0; 32]), - anchor_height: block::Height::MIN, + sync_start_height: block::Height::MIN, max_headers_per_response: DEFAULT_HS_RANGE, max_inflight_requests: DEFAULT_HS_MAX_INFLIGHT, } @@ -67,15 +67,19 @@ pub struct ZakuraHeaderSyncConfig { /// Disabling this keeps range-based header sync and legacy request/response /// active while forcing block bodies to arrive through the block-sync stream. pub accept_new_blocks: bool, - /// Optional trusted header-sync anchor height. + /// Optional trusted sync-start height. /// /// When unset, header sync starts from genesis. When set, [`anchor_hash`](Self::anchor_hash) /// must also be set and must match genesis or a configured checkpoint. + /// + /// The config key keeps the legacy `anchor` name for compatibility. pub anchor_height: Option, - /// Optional trusted header-sync anchor hash. + /// Optional trusted sync-start hash. /// /// When unset, header sync starts from genesis. When set, [`anchor_height`](Self::anchor_height) /// must also be set and must match genesis or a configured checkpoint. + /// + /// The config key keeps the legacy `anchor` name for compatibility. pub anchor_hash: Option, } @@ -105,15 +109,15 @@ impl ZakuraHeaderSyncConfig { .clamp(1, LOCAL_MAX_HS_INFLIGHT_PER_PEER) } - /// Return the configured trusted anchor, or genesis when no override is configured. - pub fn anchor( + /// Return the configured trusted sync start, or genesis when no override is configured. + pub fn trusted_sync_start( &self, network: &Network, ) -> Result<(block::Height, block::Hash), HeaderSyncStartError> { match (self.anchor_height, self.anchor_hash) { (Some(height), Some(hash)) => Ok((height, hash)), (None, None) => Ok((block::Height(0), network.genesis_hash())), - _ => Err(HeaderSyncStartError::IncompleteAnchor), + _ => Err(HeaderSyncStartError::IncompleteTrustedSyncStart), } } } diff --git a/zebra-network/src/zakura/header_sync/error.rs b/zebra-network/src/zakura/header_sync/error.rs index a6c25cb4758..5cfb876f542 100644 --- a/zebra-network/src/zakura/header_sync/error.rs +++ b/zebra-network/src/zakura/header_sync/error.rs @@ -3,16 +3,16 @@ use super::*; /// Errors that prevent the header-sync reactor from starting. #[derive(Debug, Error)] pub enum HeaderSyncStartError { - /// The configured anchor is neither genesis nor a hash-matching checkpoint. - #[error("invalid Zakura header-sync anchor at height {anchor:?}")] - InvalidAnchor { - /// Rejected anchor. - anchor: (block::Height, block::Hash), + /// The configured trusted sync start is neither genesis nor a hash-matching checkpoint. + #[error("invalid Zakura header-sync trusted sync start at height {trusted_sync_start:?}")] + InvalidTrustedSyncStart { + /// Rejected trusted sync start. + trusted_sync_start: (block::Height, block::Hash), }, - /// Only one anchor field was configured. + /// Only one trusted-sync-start config field was configured. #[error("Zakura header-sync anchor_height and anchor_hash must be configured together")] - IncompleteAnchor, + IncompleteTrustedSyncStart, } /// Structured wire and stateless-validation errors for stream 5. @@ -117,8 +117,8 @@ pub enum HeaderSyncWireError { #[error("non-contiguous Zakura header-sync header run")] NonContiguousHeaders, - /// The first header in a range did not link to its anchor. - #[error("first Zakura header-sync range header does not link to anchor")] + /// The first header in a range did not link to its link target. + #[error("first Zakura header-sync range header does not link to its link hash")] FirstHeaderDoesNotLink, /// A header commitment did not match its supplied auxiliary data. diff --git a/zebra-network/src/zakura/header_sync/events.rs b/zebra-network/src/zakura/header_sync/events.rs index 49a5da785ad..4984897b012 100644 --- a/zebra-network/src/zakura/header_sync/events.rs +++ b/zebra-network/src/zakura/header_sync/events.rs @@ -21,7 +21,7 @@ pub struct HeaderSyncFrontiers { pub verified_block_hash: block::Hash, } -/// Where to reanchor the header frontier after a lazy history-tree rebuild whose durable header-root +/// Where to rebase the header frontier after a lazy history-tree rebuild whose durable header-root /// frontier folded *below* `best_header_tip - 1` (a gap left by a non-Zakura commit racing ahead). /// /// Mirrors the startup resume: the rebuilt tree sits at `parent_hash` (the confirmed frontier), and @@ -29,13 +29,13 @@ pub struct HeaderSyncFrontiers { /// Without this the header tip would stay above the tree and every forward range would re-trigger the /// identical rebuild forever. #[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub struct HeaderFrontierReanchor { +pub struct HeaderFrontierRebase { /// Height to resume the header frontier at — one above the confirmed frontier (`frontier + 1`). pub tip: block::Height, /// Hash of the resume header at `tip`. pub tip_hash: block::Hash, /// Hash of the confirmed header-root frontier at `tip - 1`; the rebuilt tree sits here, and it is - /// the overlap anchor for the resumed forward range. + /// the overlap link target for the resumed forward range. pub parent_hash: block::Hash, } @@ -44,8 +44,8 @@ pub struct HeaderFrontierReanchor { pub struct HeaderSyncStartup { /// Active network. pub network: Network, - /// Trusted anchor height and hash. - pub anchor: (block::Height, block::Hash), + /// Trusted sync-start height and hash. + pub trusted_sync_start: (block::Height, block::Hash), /// Cached state frontiers at startup. pub frontiers: HeaderSyncFrontiers, /// Durable best header tip loaded from storage at startup. @@ -82,13 +82,16 @@ pub struct HeaderSyncStartup { pub range_state_actions_enabled: bool, /// Enables relaying inbound `NewBlock` messages after local block acceptance is wired. pub inbound_new_block_acceptance_enabled: bool, + /// Enables the below-sync-start forward backfill (headers + verified roots below the trusted + /// sync start). Defaults to the dormant production constant; tests opt in per fixture. + pub backfill_enabled: bool, } impl HeaderSyncStartup { /// Build a startup config from the active network and durable/frontier facts. pub fn new( network: Network, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), frontiers: HeaderSyncFrontiers, best_header_tip: Option<(block::Height, block::Hash)>, config: ZakuraHeaderSyncConfig, @@ -97,7 +100,7 @@ impl HeaderSyncStartup { let last_checkpoint_height = network.checkpoint_list().max_height(); Self { network, - anchor, + trusted_sync_start, frontiers, best_header_tip, best_header_parent_hash: None, @@ -112,6 +115,7 @@ impl HeaderSyncStartup { shutdown: CancellationToken::new(), range_state_actions_enabled: false, inbound_new_block_acceptance_enabled: false, + backfill_enabled: super::state::BELOW_SYNC_START_BACKFILL_ENABLED, } } } @@ -283,11 +287,11 @@ pub enum HeaderSyncEvent { /// Best header tip the reload was requested against, used as a staleness guard: the tree is /// only installed if the reactor tip has not moved since the query was dispatched. best_header_tip: block::Height, - /// Where to reanchor the header frontier, or `None` when no reanchor is needed (the tree + /// Where to rebase the header frontier, or `None` when no rebase is needed (the tree /// folded to `best_header_tip - 1`, the common no-gap case) or the rebuild failed. `Some` when /// the durable header-root frontier folded *below* `best_header_tip - 1` — a gap left by a /// non-Zakura commit — so the tip must drop onto the rebuilt tree to make progress. - reanchor: Option, + rebase: Option, /// History tree reconstructed by state; `None` on failure. history_tree: Option>, }, @@ -382,8 +386,8 @@ pub enum HeaderSyncAction { CommitHeaderRange { /// Peer that supplied the range. peer: ZakuraPeerId, - /// Parent anchor hash for the first header. - anchor: block::Hash, + /// Hash the first header must link to (the parent of `start_height`). + link_hash: block::Hash, /// First header height. start_height: block::Height, /// Headers to commit. This is an output payload, not reactor state. @@ -392,14 +396,18 @@ pub enum HeaderSyncAction { body_sizes: Vec, /// Header-layer verified commitment roots for the confirmed prefix of `headers`. /// - /// `Some` only for below-checkpoint forward ranges (the confirmed prefix is persisted). - /// `None` for checkpoint-authenticated backward backfill ranges (they fold onto the previous - /// checkpoint's tree, not the forward frontier) and for above-checkpoint forward ranges (past - /// the VCT handoff boundary, where roots are neither requested nor needed). Both persist no - /// roots. + /// `Some` for every root-carrying range — below-checkpoint forward and below-sync-start + /// backfill alike (the confirmed prefix is persisted). `None` only for plain + /// above-checkpoint forward ranges (past the VCT handoff boundary, where roots are + /// neither requested nor needed), which persist no roots. verified_roots: Option>, /// Whether the range is expected to be finalized by checkpoint policy. finalized: bool, + /// Whether the range was issued by the below-sync-start backfill cursor. + /// + /// Backfill commits persist headers and roots like forward commits, but must not move + /// the shared sync-exchange header frontier below the trusted sync start. + backfill: bool, }, /// Ask state to rebuild the header-frontier history tree at the current best header tip. /// @@ -451,8 +459,8 @@ pub enum HeaderSyncAction { /// New best-header target hash. hash: block::Hash, }, - /// Notify production wiring that header sync re-anchored its best header target. - HeaderReanchored { + /// Notify production wiring that header sync rebased its best header target. + HeaderRebased { /// Previous best-header target. old: (block::Height, block::Hash), /// New best-header target. diff --git a/zebra-network/src/zakura/header_sync/mod.rs b/zebra-network/src/zakura/header_sync/mod.rs index 75a0d066654..2c9199272b5 100644 --- a/zebra-network/src/zakura/header_sync/mod.rs +++ b/zebra-network/src/zakura/header_sync/mod.rs @@ -52,7 +52,7 @@ pub use config::{ }; pub use error::{HeaderSyncStartError, HeaderSyncWireError}; pub use events::{ - ExpectedHeadersResponse, HeaderFrontierReanchor, HeaderSyncAction, HeaderSyncCommitFailureKind, + ExpectedHeadersResponse, HeaderFrontierRebase, HeaderSyncAction, HeaderSyncCommitFailureKind, HeaderSyncEvent, HeaderSyncFrontiers, HeaderSyncHandle, HeaderSyncMisbehavior, HeaderSyncStartup, }; diff --git a/zebra-network/src/zakura/header_sync/reactor.rs b/zebra-network/src/zakura/header_sync/reactor.rs index bd5de4fd23c..3e8dc73865d 100644 --- a/zebra-network/src/zakura/header_sync/reactor.rs +++ b/zebra-network/src/zakura/header_sync/reactor.rs @@ -194,15 +194,11 @@ impl HeaderSyncReactor { } HeaderSyncEvent::BestHeaderHistoryTreeLoaded { best_header_tip, - reanchor, + rebase, history_tree, } => { - self.handle_best_header_history_tree_loaded( - best_header_tip, - reanchor, - history_tree, - ) - .await; + self.handle_best_header_history_tree_loaded(best_header_tip, rebase, history_tree) + .await; } HeaderSyncEvent::HeaderRangeCommitted { start_height, @@ -294,7 +290,7 @@ impl HeaderSyncReactor { }) .await; } - FrontierChange::HeaderAdvanced | FrontierChange::HeaderReanchored => {} + FrontierChange::HeaderAdvanced | FrontierChange::HeaderRebased => {} } } @@ -457,7 +453,7 @@ impl HeaderSyncReactor { .or_insert_with(|| { PeerHeaderState::new( session, - self.state.anchor, + self.state.trusted_sync_start, self.startup.config.advertised_max_headers_per_response(), self.startup.config.advertised_max_inflight_requests(), self.startup.status_refresh_interval, @@ -643,7 +639,7 @@ impl HeaderSyncReactor { self.state.verified_block_tip = frontiers.verified_block_tip; self.state.verified_block_hash = frontiers.verified_block_hash; if self.state.best_header_tip <= self.state.verified_block_tip { - self.state.stale_anchor.reset(); + self.state.stale_link.reset(); } self.schedule().await; } @@ -653,7 +649,7 @@ impl HeaderSyncReactor { /// (`history_tree == None`) does not wedge future rebuilds; installs only on success and only if /// the best header tip has not moved since the query. /// - /// When `reanchor` is set the durable header-root frontier folded *below* `best_header_tip - 1` + /// When `rebase` is set the durable header-root frontier folded *below* `best_header_tip - 1` /// (a gap left by a non-Zakura commit racing ahead), so the tree does not reach the current tip's /// parent. Drop the header frontier onto the rebuilt tree — mirroring the startup resume — so the /// next forward range's parent matches the tree; otherwise the range would re-trigger the identical @@ -662,41 +658,37 @@ impl HeaderSyncReactor { async fn handle_best_header_history_tree_loaded( &mut self, best_header_tip: block::Height, - reanchor: Option, + rebase: Option, history_tree: Option>, ) { self.state.rebuild_in_flight = false; if let Some(history_tree) = history_tree { if best_header_tip == self.state.best_header_tip { self.state.best_header_history_tree = history_tree; - if let Some(reanchor) = reanchor { - self.reanchor_header_frontier(reanchor).await; + if let Some(rebase) = rebase { + self.rebase_header_frontier(rebase).await; } } } self.schedule().await; } - /// Reanchors the header frontier down onto a just-rebuilt tree that folded below the current tip. + /// Rebases the header frontier down onto a just-rebuilt tree that folded below the current tip. /// /// Discards the forward schedule/commit state stranded above the frontier and repositions the tip - /// at `reanchor.tip`, anchored on `reanchor.parent_hash` (where the tree sits), so `schedule` - /// re-derives a fresh overlap forward range from the reanchored tip. Analogous to - /// [`Self::reanchor_to_verified_block_tip`], but lands on the confirmed header-root frontier rather + /// at `rebase.tip`, linked on `rebase.parent_hash` (where the tree sits), so `schedule` + /// re-derives a fresh overlap forward range from the rebased tip. Analogous to + /// [`Self::rebase_to_verified_block_tip`], but lands on the confirmed header-root frontier rather /// than the verified block tip, keeping the header lead already re-validated below the frontier. - async fn reanchor_header_frontier(&mut self, reanchor: HeaderFrontierReanchor) { - metrics::counter!("sync.header.history_tree.reanchor").increment(1); + async fn rebase_header_frontier(&mut self, rebase: HeaderFrontierRebase) { + metrics::counter!("sync.header.history_tree.rebase").increment(1); self.state.schedule.clear_forward(); self.state .pending_commits .retain(|_, commit| commit.requested_range.priority != RangePriority::Forward); self.cancel_forward_outstanding(); - self.publish_best_tip_reanchored( - reanchor.tip, - reanchor.tip_hash, - Some(reanchor.parent_hash), - ) - .await; + self.publish_best_tip_rebased(rebase.tip, rebase.tip_hash, Some(rebase.parent_hash)) + .await; } async fn handle_header_range_committed( @@ -714,7 +706,9 @@ impl HeaderSyncReactor { None, None, ); - let committed_history_tree = self.pending_header_history_tree(start_height, tip_height); + // One scan resolves both the cursor that issued the range (priority) and the post-fold + // frontier tree, before the retain loop below drops the pending commits. + let committed = self.pending_committed_range(start_height, tip_height); let mut clear_assignments = Vec::new(); self.state.pending_commits.retain(|_, commit| { let delivered = commit.delivered_range.is_within(start_height, tip_height); @@ -726,6 +720,22 @@ impl HeaderSyncReactor { for range in clear_assignments { self.state.schedule.retire_request(range); } + + let backfill = matches!(committed, Some((RangePriority::Backfill, _))); + let committed_history_tree = committed.and_then(|(_, tree)| tree); + if backfill { + // Backfill commits advance the backfill frontier only: they never mark forward + // coverage, move `best_header_tip`, or produce body gaps. + self.handle_backfill_range_committed( + tip_height, + tip_hash, + tip_parent_hash, + committed_history_tree, + ); + self.schedule().await; + return; + } + self.state .schedule .mark_range_covered(start_height, tip_height); @@ -745,6 +755,44 @@ impl HeaderSyncReactor { self.schedule().await; } + /// Advances the backfill frontier for a committed backfill range. + /// + /// A bracket commit (`tip <= sync_start`) moves the frontier and installs the post-fold tree + /// (positioned at the tip's parent — the overlap link target of the next bracket). The stitch + /// commit (`tip == sync_start + 1`) confirms the sync-start root and completes the backfill; + /// the frontier itself stays at the sync start. A redelivered short prefix that does not + /// advance the frontier changes nothing. + fn handle_backfill_range_committed( + &mut self, + tip_height: block::Height, + tip_hash: block::Hash, + tip_parent_hash: Option, + committed_history_tree: Option>, + ) { + let sync_start = self.state.trusted_sync_start; + if tip_height <= sync_start.0 && tip_height > self.state.backfill_tip { + self.state.backfill_tip = tip_height; + self.state.backfill_hash = tip_hash; + self.state.backfill_parent_hash = tip_parent_hash; + if let Some(tree) = committed_history_tree { + self.state.backfill_history_tree = tree; + } + // Backfill's staleness rule (in place of forward coverage): drop queued, assigned, + // and in-flight backfill work that ended at or below the advanced frontier, so fanout + // duplicates of a committed bracket don't loop against the repositioned tree. Late + // deliveries for the cancelled requests are tolerated like late covered responses. + self.state + .schedule + .retire_stale_backfill(self.state.backfill_tip); + self.cancel_stale_backfill_outstanding(); + } else if next_height(sync_start.0) == Some(tip_height) { + self.state.backfill_sync_start_root_confirmed = true; + if let Some(tree) = committed_history_tree { + self.state.backfill_history_tree = tree; + } + } + } + async fn handle_header_range_commit_failed( &mut self, peer: ZakuraPeerId, @@ -877,7 +925,7 @@ impl HeaderSyncReactor { match msg { HeaderSyncMessage::Status(status) => { metrics::counter!("sync.header.peer.status.received").increment(1); - if status.anchor_height > status.tip_height { + if status.sync_start_height > status.tip_height { self.report_misbehavior(peer, HeaderSyncMisbehavior::InvalidStatus) .await; return; @@ -896,7 +944,7 @@ impl HeaderSyncReactor { } peer_state.advertised_tip = status.tip_height; peer_state.advertised_hash = status.tip_hash; - peer_state.anchor = status.anchor_height; + peer_state.sync_start_height = status.sync_start_height; peer_state.max_headers_per_response = clamp_advertised_range(status.max_headers_per_response); peer_state.max_inflight_requests = status @@ -1237,11 +1285,11 @@ impl HeaderSyncReactor { outstanding.expected_max_count, ), }; - if let Err(error) = validate_header_range_links(outstanding.range.anchor_hash, &headers) { + if let Err(error) = validate_header_range_links(outstanding.range.link_hash, &headers) { debug!( ?peer, ?error, - anchor_hash = ?outstanding.range.anchor_hash, + link_hash = ?outstanding.range.link_hash, start_height = ?outstanding.range.start_height, count = ?header_count, "Zakura header-sync rejected header range links" @@ -1259,7 +1307,7 @@ impl HeaderSyncReactor { return; } if self - .handle_possible_stale_anchor_link_failure(&peer, outstanding.range, &error) + .handle_possible_stale_link_failure(&peer, outstanding.range, &error) .await { self.schedule().await; @@ -1327,16 +1375,39 @@ impl HeaderSyncReactor { } } - // Only below-checkpoint forward ranges carry a verifiable frontier tree and confirmed roots. - // Backward (checkpoint-backfill) ranges are authenticated by the checkpoint hash, not ZIP-221 - // header commitments (they fold onto the previous checkpoint's tree, not the forward frontier - // this reactor caches). Above-checkpoint forward ranges request no roots (`want_tree_aux_roots` - // is false past the VCT handoff boundary) and are fully re-verified at block commit. Both - // commit their headers with no provisional roots. - let verified_roots = if outstanding.range.priority == RangePriority::Forward - && outstanding.range.want_tree_aux_roots - { - match self.validate_forward_header_aux_commitments( + // A backfill delivery covering the trusted sync start must reproduce the trusted hash at + // that height. This authenticates the non-finalized stitch range (whose end is not a + // checkpoint) at the reactor; state re-checks every checkpoint height as defense in depth. + if outstanding.range.priority == RangePriority::Backfill { + let sync_start = self.state.trusted_sync_start; + if sync_start.0 >= outstanding.range.start_height && sync_start.0 <= end_height { + let offset = usize::try_from(sync_start.0 .0 - outstanding.range.start_height.0) + .expect("in-span offset is bounded by the delivered header count"); + let delivered_hash = block::Hash::from(headers[offset].as_ref()); + if delivered_hash != sync_start.1 { + self.trace_range_validation_rejected( + &peer, + outstanding.range, + header_count, + "checkpoint", + "sync_start_hash_mismatch", + ); + self.report_misbehavior(peer.clone(), HeaderSyncMisbehavior::InvalidRange) + .await; + self.state.schedule.retry(outstanding.range); + self.schedule().await; + return; + } + } + } + + // Every root-carrying range is verified against its cursor's frontier tree before commit: + // below-checkpoint forward ranges against the forward frontier tree, below-sync-start + // backfill ranges against the backfill tree. Above-checkpoint forward ranges request no + // roots (`want_tree_aux_roots` is false past the VCT handoff boundary), are fully + // re-verified at block commit, and commit their headers with no provisional roots. + let verified_roots = if outstanding.range.want_tree_aux_roots { + match self.validate_header_aux_commitments_for_range( &peer, outstanding.range, &headers, @@ -1344,7 +1415,8 @@ impl HeaderSyncReactor { ) { Ok(verified_roots) => Some(verified_roots), Err(error) - if matches!(error, HeaderSyncWireError::MissingHeaderHistoryTree { .. }) => + if matches!(error, HeaderSyncWireError::MissingHeaderHistoryTree { .. }) + && outstanding.range.priority == RangePriority::Forward => { debug!( ?peer, @@ -1384,6 +1456,40 @@ impl HeaderSyncReactor { self.schedule().await; return; } + Err(error) + if matches!(error, HeaderSyncWireError::MissingHeaderHistoryTree { .. }) => + { + // The backfill tree mismatched the range parent. Nothing else moves the + // backfill region (the forward rebuild is keyed to `best_header_tip`, and + // full-block commits below the sync start do not occur in the intended + // deployment), so this indicates an internal bug rather than a racing commit. + // Do not dispatch the forward tree rebuild — it would install the wrong tree. + // Clear the assignment and retry; a persistently wedged backfill recovers on + // restart, which reseeds the cursor at genesis. + warn!( + ?peer, + ?error, + start_height = ?outstanding.range.start_height, + count = ?header_count, + "Zakura header-sync backfill tree mismatched its range parent" + ); + metrics::counter!("sync.header.backfill.tree_mismatch").increment(1); + self.trace_range_validation_rejected( + &peer, + outstanding.range, + header_count, + "header_aux", + header_sync_wire_error_kind(&error), + ); + self.state.schedule.clear_assignment(outstanding.range); + // A range that ended at or below the backfill frontier is a stale duplicate + // of an already-committed bracket and is dropped; anything else is retried. + if outstanding.range.end_height() > self.state.backfill_tip { + self.state.schedule.retry(outstanding.range); + } + self.schedule().await; + return; + } Err(error) => { debug!( ?peer, @@ -1428,18 +1534,20 @@ impl HeaderSyncReactor { ); let _ = self.dispatch_action(HeaderSyncAction::CommitHeaderRange { peer, - anchor: outstanding.range.anchor_hash, + link_hash: outstanding.range.link_hash, start_height: outstanding.range.start_height, headers, body_sizes, verified_roots: verified_roots.map(Box::new), finalized: outstanding.range.finalized, + backfill: outstanding.range.priority == RangePriority::Backfill, }); } - // Validates the header auxiliary commitments for the given range. - // Returns the verified root payload if it is valid, otherwise returns an error. - fn validate_forward_header_aux_commitments( + // Validates the header auxiliary commitments for the given range against its cursor's + // frontier tree: forward ranges use the forward frontier tree, backfill ranges the backfill + // tree. Returns the verified root payload if it is valid, otherwise returns an error. + fn validate_header_aux_commitments_for_range( &self, peer: &ZakuraPeerId, range: RangeRequest, @@ -1449,31 +1557,44 @@ impl HeaderSyncReactor { zebra_chain::parallel::commitment_aux_verify::VerifiedHeaderCommitmentRoots, HeaderSyncWireError, > { - if !range.want_tree_aux_roots || range.priority != RangePriority::Forward { + if !range.want_tree_aux_roots { return Err(HeaderSyncWireError::MissingHeaderHistoryTree { height: range.start_height, - hash: range.anchor_hash, + hash: range.link_hash, }); } let parent_height = previous_height(range.start_height) .ok_or(HeaderSyncWireError::HeightOutOfRange(range.start_height.0))?; - if !self.cached_header_history_tree_matches(parent_height, range.anchor_hash) { + let tree_matches = match range.priority { + RangePriority::Forward => { + self.cached_header_history_tree_matches(parent_height, range.link_hash) + } + RangePriority::Backfill => { + self.cached_backfill_history_tree_matches(parent_height, range.link_hash) + } + }; + if !tree_matches { debug!( ?peer, start_height = ?range.start_height, - anchor_hash = ?range.anchor_hash, + link_hash = ?range.link_hash, + priority = range.priority.label(), "Zakura header-sync cannot validate header auxiliary data without parent history tree" ); return Err(HeaderSyncWireError::MissingHeaderHistoryTree { height: parent_height, - hash: range.anchor_hash, + hash: range.link_hash, }); } + let parent_history_tree = match range.priority { + RangePriority::Forward => &self.state.best_header_history_tree, + RangePriority::Backfill => &self.state.backfill_history_tree, + }; validate_header_aux_commitments( &self.startup.network, - &self.state.best_header_history_tree, + parent_history_tree, headers, tree_aux_roots, ) @@ -1498,14 +1619,39 @@ impl HeaderSyncReactor { height_matches && hash_matches } - // Returns the history tree for the pending commit that matches the given start and tip heights. - // Backward (checkpoint-authenticated) commits carry no verified roots, so they never install a - // frontier tree; the caller keeps its existing tree in that case. - fn pending_header_history_tree( + // Returns true if the backfill history tree matches the given parent height and hash. + // Mirrors [`Self::cached_header_history_tree_matches`] for the backfill cursor. + fn cached_backfill_history_tree_matches( + &self, + parent_height: block::Height, + parent_hash: block::Hash, + ) -> bool { + let height_matches = header_history_tree_is_at_height( + &self.state.backfill_history_tree, + parent_height, + &self.startup.network, + ); + let hash_matches = (parent_height == self.state.backfill_tip + && parent_hash == self.state.backfill_hash) + || (previous_height(self.state.backfill_tip) == Some(parent_height) + && self.state.backfill_parent_hash == Some(parent_hash)); + + height_matches && hash_matches + } + + // Returns the priority and post-fold history tree of the pending commit matching the given + // start and tip heights. `None` when no pending commit matches (e.g. the startup + // best-header-tip reload, where the driver synthesizes the committed event); the tree is + // `None` for plain above-checkpoint ranges, which carry no verified roots — the caller keeps + // its existing tree in both cases. + fn pending_committed_range( &self, start_height: block::Height, tip_height: block::Height, - ) -> Option> { + ) -> Option<( + RangePriority, + Option>, + )> { self.state .pending_commits .values() @@ -1513,11 +1659,18 @@ impl HeaderSyncReactor { commit.delivered_range.start_height == start_height && commit.delivered_range.end_height() == tip_height }) - .and_then(|commit| commit.verified_roots.as_ref()) - .map(|verified_roots| Arc::new(verified_roots.tree().clone())) + .map(|commit| { + ( + commit.delivered_range.priority, + commit + .verified_roots + .as_ref() + .map(|verified_roots| Arc::new(verified_roots.tree().clone())), + ) + }) } - async fn handle_possible_stale_anchor_link_failure( + async fn handle_possible_stale_link_failure( &mut self, peer: &ZakuraPeerId, range: RangeRequest, @@ -1528,35 +1681,35 @@ impl HeaderSyncReactor { || range.finalized || self.state.best_header_tip <= self.state.verified_block_tip { - self.state.stale_anchor.reset(); + self.state.stale_link.reset(); return false; } - self.state.stale_anchor.record(peer.clone()); - metrics::counter!("sync.header.stale_anchor.link_failure").increment(1); + self.state.stale_link.record(peer.clone()); + metrics::counter!("sync.header.stale_link.link_failure").increment(1); - if !self.state.stale_anchor.should_reanchor() { + if !self.state.stale_link.should_rebase() { self.state.schedule.clear_assignment(range); self.state.schedule.retry(range); return true; } - self.reanchor_to_verified_block_tip().await; + self.rebase_to_verified_block_tip().await; true } - async fn reanchor_to_verified_block_tip(&mut self) { + async fn rebase_to_verified_block_tip(&mut self) { let height = self.state.verified_block_tip; let hash = self.state.verified_block_hash; - metrics::counter!("sync.header.stale_anchor.reanchored").increment(1); + metrics::counter!("sync.header.stale_link.rebased").increment(1); - self.state.stale_anchor.reset(); + self.state.stale_link.reset(); self.state.schedule.clear_forward(); self.state .pending_commits .retain(|_, commit| commit.requested_range.priority != RangePriority::Forward); self.cancel_forward_outstanding(); - self.publish_best_tip_reanchored(height, hash, None).await; + self.publish_best_tip_rebased(height, hash, None).await; } async fn handle_timeouts(&mut self) { @@ -1607,7 +1760,7 @@ impl HeaderSyncReactor { } self.state.refresh_forward_range(&self.startup); - self.state.refresh_backward_range(&self.startup); + self.state.refresh_backfill_range(&self.startup); let mut peer_ids: Vec = self.state.peers.keys().cloned().collect(); peer_ids.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); @@ -1793,7 +1946,7 @@ impl HeaderSyncReactor { self.broadcast_status_refresh().await; } - async fn publish_best_tip_reanchored( + async fn publish_best_tip_rebased( &mut self, height: block::Height, hash: block::Hash, @@ -1804,9 +1957,9 @@ impl HeaderSyncReactor { self.state.best_header_hash = hash; self.state.best_header_parent_hash = parent_hash; metrics::gauge!("sync.header.best_tip.height").set(height.0 as f64); - self.trace_frontier_reanchored(height, hash); + self.trace_frontier_rebased(height, hash); let _ = self.tip.send((height, hash)); - let _ = self.dispatch_action(HeaderSyncAction::HeaderReanchored { + let _ = self.dispatch_action(HeaderSyncAction::HeaderRebased { old, new: (height, hash), }); @@ -1820,7 +1973,7 @@ impl HeaderSyncReactor { self.state.verified_block_hash = hash; } if self.state.best_header_tip <= self.state.verified_block_tip { - self.state.stale_anchor.reset(); + self.state.stale_link.reset(); } } @@ -2167,8 +2320,8 @@ impl HeaderSyncReactor { insert_height(row, hs_trace::HEIGHT, *height); insert_hash(row, hs_trace::HASH, *hash); } - HeaderSyncAction::HeaderReanchored { old, new } => { - insert_optional_str(row, hs_trace::KIND, Some("header_reanchored")); + HeaderSyncAction::HeaderRebased { old, new } => { + insert_optional_str(row, hs_trace::KIND, Some("header_rebased")); insert_height(row, hs_trace::HEIGHT, new.0); insert_hash(row, hs_trace::HASH, new.1); insert_height(row, hs_trace::RANGE_START, old.0); @@ -2181,7 +2334,7 @@ impl HeaderSyncReactor { insert_peer(row, hs_trace::PEER, peer); insert_height(row, hs_trace::HEIGHT, status.tip_height); insert_hash(row, hs_trace::HASH, status.tip_hash); - insert_height(row, hs_trace::RANGE_START, status.anchor_height); + insert_height(row, hs_trace::RANGE_START, status.sync_start_height); insert_u64( row, hs_trace::ADVERTISED_CAP, @@ -2200,7 +2353,7 @@ impl HeaderSyncReactor { insert_peer(row, hs_trace::PEER, peer); insert_height(row, hs_trace::HEIGHT, status.tip_height); insert_hash(row, hs_trace::HASH, status.tip_hash); - insert_height(row, hs_trace::RANGE_START, status.anchor_height); + insert_height(row, hs_trace::RANGE_START, status.sync_start_height); insert_u64( row, hs_trace::ADVERTISED_CAP, @@ -2324,7 +2477,7 @@ impl HeaderSyncReactor { insert_peer(row, hs_trace::PEER, peer); insert_height(row, hs_trace::RANGE_START, range.start_height); insert_u64(row, hs_trace::RANGE_COUNT, u64::from(count)); - insert_hash(row, hs_trace::ANCHOR_HASH, range.anchor_hash); + insert_hash(row, hs_trace::LINK_HASH, range.link_hash); insert_optional_str(row, hs_trace::VALIDATION_STAGE, Some(validation_stage)); insert_optional_str(row, hs_trace::ERROR_KIND, Some(error_kind)); insert_optional_str( @@ -2415,7 +2568,7 @@ impl HeaderSyncReactor { }); } - fn trace_frontier_reanchored(&self, height: block::Height, hash: block::Hash) { + fn trace_frontier_rebased(&self, height: block::Height, hash: block::Hash) { self.emit_trace(hs_trace::HEADER_FRONTIER_REANCHORED, |row| { insert_height(row, hs_trace::HEIGHT, height); insert_hash(row, hs_trace::HASH, hash); @@ -2483,7 +2636,7 @@ impl HeaderSyncReactor { HeaderSyncStatus { tip_height: self.state.best_header_tip, tip_hash: self.state.best_header_hash, - anchor_height: self.state.anchor.0, + sync_start_height: self.state.trusted_sync_start.0, max_headers_per_response: self.startup.config.advertised_max_headers_per_response(), max_inflight_requests: self.startup.config.advertised_max_inflight_requests(), } @@ -2493,10 +2646,13 @@ impl HeaderSyncReactor { for peer in self.state.peers.values_mut() { let mut index = 0; while index < peer.outstanding.len() { - if self - .state - .schedule - .is_covered(peer.outstanding[index].range) + // Backfill ranges are exempt from covered checks (coverage tracks the forward + // frontier, and the stitch range deliberately overlaps forward-covered heights). + if peer.outstanding[index].range.priority == RangePriority::Forward + && self + .state + .schedule + .is_covered(peer.outstanding[index].range) { peer.outstanding.remove(index); peer.late_covered_responses = peer.late_covered_responses.saturating_add(1); @@ -2520,6 +2676,24 @@ impl HeaderSyncReactor { } } } + + /// Cancels in-flight backfill requests that ended at or below the backfill frontier — the + /// backfill counterpart of [`Self::cancel_covered_outstanding`]. + fn cancel_stale_backfill_outstanding(&mut self) { + let backfill_tip = self.state.backfill_tip; + for peer in self.state.peers.values_mut() { + let mut index = 0; + while index < peer.outstanding.len() { + let range = peer.outstanding[index].range; + if range.priority == RangePriority::Backfill && range.end_height() <= backfill_tip { + peer.outstanding.remove(index); + peer.late_covered_responses = peer.late_covered_responses.saturating_add(1); + } else { + index += 1; + } + } + } + } } /// Returns true if the history tree is at the given height. @@ -2598,7 +2772,7 @@ fn trace_header_sync_message_fields( HeaderSyncMessage::Status(status) => { insert_height(row, hs_trace::HEIGHT, status.tip_height); insert_hash(row, hs_trace::HASH, status.tip_hash); - insert_height(row, hs_trace::RANGE_START, status.anchor_height); + insert_height(row, hs_trace::RANGE_START, status.sync_start_height); insert_u64( row, hs_trace::ADVERTISED_CAP, diff --git a/zebra-network/src/zakura/header_sync/scheduler.rs b/zebra-network/src/zakura/header_sync/scheduler.rs index c3dd9080249..a52481a54ee 100644 --- a/zebra-network/src/zakura/header_sync/scheduler.rs +++ b/zebra-network/src/zakura/header_sync/scheduler.rs @@ -41,7 +41,7 @@ pub(super) struct CoveredRange { #[derive(Clone, Debug)] pub(super) struct RangeScheduler { pub(super) forward: VecDeque, - pub(super) backward: VecDeque, + pub(super) backfill: VecDeque, pub(super) assigned: HashMap>, pub(super) covered: Vec, } @@ -50,7 +50,7 @@ impl RangeScheduler { pub(super) fn new() -> Self { Self { forward: VecDeque::new(), - backward: VecDeque::new(), + backfill: VecDeque::new(), assigned: HashMap::new(), covered: Vec::new(), } @@ -60,12 +60,15 @@ impl RangeScheduler { self.ensure(range, RangePriority::Forward); } - pub(super) fn ensure_backward(&mut self, range: RangeRequest) { - self.ensure(range, RangePriority::Backward); + pub(super) fn ensure_backfill(&mut self, range: RangeRequest) { + self.ensure(range, RangePriority::Backfill); } pub(super) fn ensure(&mut self, range: RangeRequest, priority: RangePriority) { - if self.is_covered(range) + // Covered intervals track the forward frontier's committed spans; backfill progress is + // tracked by the backfill frontier instead, and the sync-start stitch range deliberately + // overlaps forward-covered heights, so backfill ranges bypass the covered check. + if (priority == RangePriority::Forward && self.is_covered(range)) || self.assigned.contains_key(&range) || self.assigned.keys().any(|assigned| { assigned.start_height == range.start_height && assigned.priority == priority @@ -75,7 +78,7 @@ impl RangeScheduler { } let queue = match priority { RangePriority::Forward => &mut self.forward, - RangePriority::Backward => &mut self.backward, + RangePriority::Backfill => &mut self.backfill, }; if !queue.contains(&range) && !queue.iter().any(|queued| { @@ -92,7 +95,7 @@ impl RangeScheduler { peer: &PeerHeaderState, ) -> Option { Self::pop_assignable(&mut self.forward, &self.assigned, peer_id, peer) - .or_else(|| Self::pop_assignable(&mut self.backward, &self.assigned, peer_id, peer)) + .or_else(|| Self::pop_assignable(&mut self.backfill, &self.assigned, peer_id, peer)) } pub(super) fn pop_assignable( @@ -128,7 +131,7 @@ impl RangeScheduler { let queue = match original.priority { RangePriority::Forward => &mut self.forward, - RangePriority::Backward => &mut self.backward, + RangePriority::Backfill => &mut self.backfill, }; for queued in queue { if *queued == original { @@ -142,12 +145,15 @@ impl RangeScheduler { } pub(super) fn retry(&mut self, range: RangeRequest) { - if self.is_covered(range) { - return; - } match range.priority { - RangePriority::Forward => self.forward.push_front(range), - RangePriority::Backward => self.backward.push_front(range), + RangePriority::Forward => { + if self.is_covered(range) { + return; + } + self.forward.push_front(range); + } + // Backfill ranges are exempt from covered checks (see `ensure`). + RangePriority::Backfill => self.backfill.push_front(range), } } @@ -165,7 +171,7 @@ impl RangeScheduler { pub(super) fn retire_request(&mut self, range: RangeRequest) { match range.priority { RangePriority::Forward => self.forward.retain(|queued| *queued != range), - RangePriority::Backward => self.backward.retain(|queued| *queued != range), + RangePriority::Backfill => self.backfill.retain(|queued| *queued != range), } self.clear_assignment(range); } @@ -176,6 +182,20 @@ impl RangeScheduler { .retain(|range, _| range.priority != RangePriority::Forward); } + /// Drops queued and assigned backfill ranges that ended at or below the backfill frontier. + /// + /// Backfill ranges are exempt from the forward covered machinery (see `ensure`), so this is + /// their staleness rule: once the frontier passed a range's end (a fanout duplicate or a + /// timed-out retry of an already-committed bracket), redelivering it can only mismatch the + /// advanced backfill tree. + pub(super) fn retire_stale_backfill(&mut self, backfill_tip: block::Height) { + self.backfill + .retain(|range| range.end_height() > backfill_tip); + self.assigned.retain(|range, _| { + range.priority != RangePriority::Backfill || range.end_height() > backfill_tip + }); + } + pub(super) fn mark_height_covered(&mut self, height: block::Height) { self.mark_covered_interval(CoveredRange { start: height, @@ -231,9 +251,10 @@ impl RangeScheduler { .iter() .any(|covered| covered.start <= range.start_height && covered.end >= end) }; + // Backfill ranges are exempt from covered pruning (see `ensure`). self.forward.retain(|range| !is_covered(range)); - self.backward.retain(|range| !is_covered(range)); - self.assigned.retain(|range, _| !is_covered(range)); + self.assigned + .retain(|range, _| range.priority == RangePriority::Backfill || !is_covered(range)); } } diff --git a/zebra-network/src/zakura/header_sync/service.rs b/zebra-network/src/zakura/header_sync/service.rs index 504ac770be3..1fb620f3072 100644 --- a/zebra-network/src/zakura/header_sync/service.rs +++ b/zebra-network/src/zakura/header_sync/service.rs @@ -310,7 +310,7 @@ pub(crate) async fn drive_header_sync_actions( | HeaderSyncAction::QueryMissingBlockBodies { .. } | HeaderSyncAction::BodyGaps { .. } | HeaderSyncAction::HeaderAdvanced { .. } - | HeaderSyncAction::HeaderReanchored { .. } => {} + | HeaderSyncAction::HeaderRebased { .. } => {} } } } diff --git a/zebra-network/src/zakura/header_sync/state.rs b/zebra-network/src/zakura/header_sync/state.rs index 7e0b1b38fe2..08699ff6930 100644 --- a/zebra-network/src/zakura/header_sync/state.rs +++ b/zebra-network/src/zakura/header_sync/state.rs @@ -10,16 +10,22 @@ use crate::zakura::{ pub(super) const HEADER_SYNC_ADVISORY_BACKOFF_FAILURES: u32 = 2; pub(super) const HEADER_SYNC_ADVISORY_BACKOFF: Duration = Duration::from_secs(60); pub(super) const HEADER_SYNC_ADVISORY_TTL: Duration = DEFAULT_LIVE_SERVICE_SUMMARY_TTL; -pub(super) const HEADER_SYNC_STALE_ANCHOR_LINK_FAILURES: u32 = 3; -pub(super) const HEADER_SYNC_STALE_ANCHOR_DISTINCT_PEERS: usize = 2; -/// Below-anchor checkpoint backfill is unused by the current sync wiring and cannot verify -/// roots (backward ranges fold onto the previous checkpoint's tree, which this reactor never -/// tracks), so it stays disabled. See [`HeaderSyncCore::refresh_backward_range`]. -pub(super) const BACKWARD_CHECKPOINT_BACKFILL_ENABLED: bool = false; +pub(super) const HEADER_SYNC_STALE_LINK_FAILURES: u32 = 3; +pub(super) const HEADER_SYNC_STALE_LINK_DISTINCT_PEERS: usize = 2; +/// Production default for the dormant below-sync-start forward backfill. +/// +/// When enabled (per-startup via [`HeaderSyncStartup::backfill_enabled`], which defaults to this +/// constant), a node whose trusted sync start is above genesis backfills the headers and verified +/// commitment roots below it with a second forward cursor: checkpoint-bounded finalized ranges +/// starting at genesis, root-verified against a dedicated backfill history tree, filling the +/// `commitment_roots_by_height` serving index. Restarts resume from genesis in v1 (identical +/// re-commits are idempotent and roots re-verify from the empty tree). Disabled until the node +/// wiring consumes backfilled data. See [`HeaderSyncCore::refresh_backfill_range`]. +pub(super) const BELOW_SYNC_START_BACKFILL_ENABLED: bool = false; #[derive(Clone, Debug)] pub(super) struct HeaderSyncCore { - pub(super) anchor: (block::Height, block::Hash), + pub(super) trusted_sync_start: (block::Height, block::Hash), pub(super) finalized_height: block::Height, pub(super) verified_block_tip: block::Height, pub(super) verified_block_hash: block::Hash, @@ -32,6 +38,21 @@ pub(super) struct HeaderSyncCore { /// roots can be folded and authenticated against header commitments. The empty tree is the /// natural pre-Heartwood value. pub(super) best_header_history_tree: Arc, + /// Highest backfilled header below the trusted sync start (its own root may be unconfirmed). + /// + /// The below-sync-start backfill is a second forward cursor: it starts at genesis and advances + /// checkpoint bracket by checkpoint bracket until it reaches the sync start, then confirms the + /// sync-start root with a final stitch range. See [`Self::refresh_backfill_range`]. + pub(super) backfill_tip: block::Height, + pub(super) backfill_hash: block::Hash, + pub(super) backfill_parent_hash: Option, + /// History tree positioned at the parent of the next backfill range. + /// + /// Seeded empty at genesis (the natural pre-Heartwood value) and repositioned as backfill + /// ranges commit, mirroring [`Self::best_header_history_tree`] for the backfill cursor. + pub(super) backfill_history_tree: Arc, + /// True once the stitch range has confirmed and persisted the sync-start height's own root. + pub(super) backfill_sync_start_root_confirmed: bool, pub(super) peers: HashMap, pub(super) parked_peers: HashSet, pub(super) seen: HeaderHashDedup, @@ -39,7 +60,7 @@ pub(super) struct HeaderSyncCore { pub(super) schedule: RangeScheduler, pub(super) pending_commits: HashMap, pub(super) advisory: HashMap, - pub(super) stale_anchor: StaleAnchorFailures, + pub(super) stale_link: StaleLinkFailures, /// True while a `QueryBestHeaderHistoryTree` rebuild is outstanding, so a run of forward ranges /// that all find the tree stale dispatches only one reload. pub(super) rebuild_in_flight: bool, @@ -47,12 +68,18 @@ pub(super) struct HeaderSyncCore { impl HeaderSyncCore { pub(super) fn new(startup: &HeaderSyncStartup) -> Result { - validate_anchor(&startup.network, startup.anchor)?; - let (best_header_tip, best_header_hash) = startup.best_header_tip.unwrap_or(startup.anchor); + validate_trusted_sync_start(&startup.network, startup.trusted_sync_start)?; + let (best_header_tip, best_header_hash) = startup + .best_header_tip + .unwrap_or(startup.trusted_sync_start); let best_header_history_tree = startup.best_header_history_tree.clone(); + // v1 always reseeds the backfill cursor at genesis; a future startup read can resume it + // from the highest contiguous backfilled frontier instead. This is the one insertion + // point for that resume. + let (backfill_tip, backfill_hash) = (block::Height(0), startup.network.genesis_hash()); Ok(Self { - anchor: startup.anchor, + trusted_sync_start: startup.trusted_sync_start, finalized_height: startup.frontiers.finalized_height, verified_block_tip: startup.frontiers.verified_block_tip, verified_block_hash: startup.frontiers.verified_block_hash, @@ -60,6 +87,11 @@ impl HeaderSyncCore { best_header_hash, best_header_parent_hash: startup.best_header_parent_hash, best_header_history_tree, + backfill_tip, + backfill_hash, + backfill_parent_hash: None, + backfill_history_tree: Arc::new(HistoryTree::default()), + backfill_sync_start_root_confirmed: false, peers: HashMap::new(), parked_peers: HashSet::new(), seen: HeaderHashDedup::default(), @@ -67,7 +99,7 @@ impl HeaderSyncCore { schedule: RangeScheduler::new(), pending_commits: HashMap::new(), advisory: HashMap::new(), - stale_anchor: StaleAnchorFailures::default(), + stale_link: StaleLinkFailures::default(), rebuild_in_flight: false, }) } @@ -92,7 +124,7 @@ impl HeaderSyncCore { let root_regime_end = next_height(last_checkpoint).unwrap_or(last_checkpoint); // Only persist roots for below-checkpoint heights this node forward-syncs but has // not committed yet. - let root_region_floor = self.anchor.0.max(self.finalized_height); + let root_region_floor = self.trusted_sync_start.0.max(self.finalized_height); let below_root_boundary = root_region_floor < last_checkpoint && self.best_header_tip < root_regime_end; let want_tree_aux_roots = below_root_boundary; @@ -132,7 +164,7 @@ impl HeaderSyncCore { self.schedule.ensure_forward(RangeRequest { start_height: start, count, - anchor_hash: if overlap_forward_range { + link_hash: if overlap_forward_range { self.best_header_parent_hash .expect("overlapped ranges have a parent hash") } else { @@ -144,43 +176,77 @@ impl HeaderSyncCore { }); } - pub(super) fn refresh_backward_range(&mut self, startup: &HeaderSyncStartup) { - // Below-anchor checkpoint backfill is explicitly disabled: backward ranges fold onto the - // previous checkpoint's tree, which this reactor never tracks, so their roots cannot be - // verified, and the current node wiring never consumes backfilled headers. Leaving the - // scheduling live would silently emit unsupported below-anchor `GetHeaders` requests. - // Re-enable once backfill has a consumer and checkpoint-bracket tree tracking. - if !BACKWARD_CHECKPOINT_BACKFILL_ENABLED { + /// Schedules the next below-sync-start backfill range, if any. + /// + /// Backfill is a second forward cursor sweeping genesis → trusted sync start with its own + /// history tree, reusing the forward code path: checkpoint-bounded finalized ranges with the + /// same one-block overlap so each range's tip root is confirmed by its successor, roots + /// verified against [`Self::backfill_history_tree`], and the confirmed prefix persisted. The + /// sync-start height's own root has no confirming successor inside the backfill region (the + /// forward path starts above it and never persists it), so a final non-finalized stitch range + /// `[sync_start ..= sync_start + 1]` re-fetches the sync-start header plus its successor to + /// confirm and persist that last root; the redelivered successor re-commits idempotently. + pub(super) fn refresh_backfill_range(&mut self, startup: &HeaderSyncStartup) { + if !startup.backfill_enabled { return; } - - if self.anchor.0 == block::Height(0) { + let sync_start = self.trusted_sync_start; + if sync_start.0 == block::Height(0) { return; } - let checkpoints = startup.network.checkpoint_list(); - // v1 backfill schedules one checkpoint bracket below the configured anchor. - // Iterating all deeper brackets is left to final node wiring/backfill policy. - let Some(previous_checkpoint) = checkpoints.max_height_in_range(..self.anchor.0) else { - return; - }; - let Some(previous_hash) = checkpoints.hash(previous_checkpoint) else { - return; - }; - let Some(start) = next_height(previous_checkpoint) else { - return; - }; - let count = count_between(start, self.anchor.0); - if count == 0 { - return; + + if self.backfill_tip < sync_start.0 { + // Root-carrying backfill ranges redeliver the frontier header so its root can be + // confirmed, exactly like the forward overlap. + let overlap = + self.backfill_parent_hash.is_some() && self.backfill_tip > block::Height(0); + let Some(next) = next_height(self.backfill_tip) else { + return; + }; + let start = if overlap { self.backfill_tip } else { next }; + // End at the next checkpoint so the range end is checkpoint-authenticated; the sync + // start is itself a checkpoint, so the cap below never produces a non-checkpoint end. + let end = startup + .network + .checkpoint_list() + .min_height_in_range(next..) + .map_or(sync_start.0, |checkpoint| checkpoint.min(sync_start.0)); + let count = count_between(start, end); + if count == 0 { + return; + } + self.schedule.ensure_backfill(RangeRequest { + start_height: start, + count, + link_hash: if overlap { + self.backfill_parent_hash + .expect("overlapped ranges have a parent hash") + } else { + self.backfill_hash + }, + finalized: true, + want_tree_aux_roots: true, + priority: RangePriority::Backfill, + }); + } else if self.backfill_tip == sync_start.0 && !self.backfill_sync_start_root_confirmed { + // Stitch: confirm the sync-start root with its successor header. Non-finalized (the + // end is not a checkpoint); the sync-start header is authenticated in-span against + // the trusted hash instead. + let Some(parent) = self.backfill_parent_hash else { + return; + }; + if next_height(sync_start.0).is_none() { + return; + } + self.schedule.ensure_backfill(RangeRequest { + start_height: sync_start.0, + count: 2, + link_hash: parent, + finalized: false, + want_tree_aux_roots: true, + priority: RangePriority::Backfill, + }); } - self.schedule.ensure_backward(RangeRequest { - start_height: start, - count, - anchor_hash: previous_hash, - finalized: true, - want_tree_aux_roots: true, - priority: RangePriority::Backward, - }); } } @@ -189,7 +255,7 @@ pub(super) struct PendingHeaderCommit { /// The full range requested from the peer. /// /// A peer may legally return a short prefix of the requested range. Keep the - /// requested range so success, local commit failure, and reanchor cleanup can + /// requested range so success, local commit failure, and rebase cleanup can /// clear or retry the scheduler assignment that was created for the original /// `GetHeaders` request. pub(super) requested_range: RangeRequest, @@ -199,27 +265,29 @@ pub(super) struct PendingHeaderCommit { /// and verified frontier-tree lookup must use this range rather than the /// original request. pub(super) delivered_range: RangeRequest, - /// `Some` for aux-validated forward ranges; `None` for checkpoint-authenticated backward - /// backfill ranges, which persist no provisional roots and never install a frontier tree. + /// `Some` for every root-carrying range — below-checkpoint forward and below-sync-start + /// backfill alike (both persist the confirmed prefix and install their cursor's frontier + /// tree). `None` only for plain above-checkpoint forward ranges (past the VCT handoff + /// boundary), which request no roots and persist none. pub(super) verified_roots: Option, } #[derive(Clone, Debug, Default)] -pub(super) struct StaleAnchorFailures { +pub(super) struct StaleLinkFailures { pub(super) count: u32, pub(super) peers: HashSet, } -impl StaleAnchorFailures { +impl StaleLinkFailures { pub(super) fn record(&mut self, peer: ZakuraPeerId) { self.count = self.count.saturating_add(1); self.peers.insert(peer); } - pub(super) fn should_reanchor(&self) -> bool { - self.count >= HEADER_SYNC_STALE_ANCHOR_LINK_FAILURES - && self.peers.len() >= HEADER_SYNC_STALE_ANCHOR_DISTINCT_PEERS + pub(super) fn should_rebase(&self) -> bool { + self.count >= HEADER_SYNC_STALE_LINK_FAILURES + && self.peers.len() >= HEADER_SYNC_STALE_LINK_DISTINCT_PEERS } pub(super) fn reset(&mut self) { @@ -282,7 +350,7 @@ pub(super) struct PeerHeaderState { pub(super) direction: ServicePeerDirection, pub(super) advertised_tip: block::Height, pub(super) advertised_hash: block::Hash, - pub(super) anchor: block::Height, + pub(super) sync_start_height: block::Height, pub(super) max_headers_per_response: u32, pub(super) max_inflight_requests: u16, pub(super) received_status: bool, @@ -299,7 +367,7 @@ pub(super) struct PeerHeaderState { impl PeerHeaderState { pub(super) fn new( session: HeaderSyncPeerSession, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), local_range: u32, local_inflight: u16, status_refresh_interval: Duration, @@ -309,9 +377,9 @@ impl PeerHeaderState { Self { direction: session.direction(), session, - advertised_tip: anchor.0, - advertised_hash: anchor.1, - anchor: anchor.0, + advertised_tip: trusted_sync_start.0, + advertised_hash: trusted_sync_start.1, + sync_start_height: trusted_sync_start.0, max_headers_per_response: clamp_advertised_range(local_range), max_inflight_requests: local_inflight.clamp(1, LOCAL_MAX_HS_INFLIGHT_PER_PEER), received_status: false, @@ -431,7 +499,7 @@ pub(super) struct OutstandingRange { pub(super) struct RangeRequest { pub(super) start_height: block::Height, pub(super) count: u32, - pub(super) anchor_hash: block::Hash, + pub(super) link_hash: block::Hash, pub(super) finalized: bool, pub(super) want_tree_aux_roots: bool, pub(super) priority: RangePriority, @@ -452,14 +520,14 @@ impl RangeRequest { #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] pub(super) enum RangePriority { Forward, - Backward, + Backfill, } impl RangePriority { pub(super) fn label(self) -> &'static str { match self { RangePriority::Forward => "forward", - RangePriority::Backward => "backward", + RangePriority::Backfill => "backfill", } } } diff --git a/zebra-network/src/zakura/header_sync/tests.rs b/zebra-network/src/zakura/header_sync/tests.rs index cbb08c97bf4..d106adfa720 100644 --- a/zebra-network/src/zakura/header_sync/tests.rs +++ b/zebra-network/src/zakura/header_sync/tests.rs @@ -443,16 +443,16 @@ fn checkpoint_regtest_with_hash( fn startup_for( network: Network, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), best_header_tip: Option<(block::Height, block::Hash)>, ) -> HeaderSyncStartup { let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, best_header_tip, ZakuraHeaderSyncConfig::default(), @@ -472,16 +472,16 @@ fn startup_for( #[test] fn startup_new_is_passive_until_local_hooks_are_wired() { let network = Network::Mainnet; - let anchor = (block::Height(0), network.genesis_hash()); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); let startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, - Some(anchor), + Some(trusted_sync_start), ZakuraHeaderSyncConfig::default(), LOCAL_MAX_MESSAGE_BYTES, ); @@ -492,10 +492,10 @@ fn startup_new_is_passive_until_local_hooks_are_wired() { fn startup_with_timeout( network: Network, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), request_timeout: std::time::Duration, ) -> HeaderSyncStartup { - let mut startup = startup_for(network, anchor, None); + let mut startup = startup_for(network, trusted_sync_start, None); startup.request_timeout = request_timeout; startup } @@ -503,16 +503,16 @@ fn startup_with_timeout( #[tokio::test] async fn peer_caps_reject_full_without_status_or_misbehavior_and_free_on_remove() { let network = Network::Mainnet; - let anchor = (block::Height(0), network.genesis_hash()); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, - Some(anchor), + Some(trusted_sync_start), ZakuraHeaderSyncConfig { peer_limits: ServicePeerLimits { max_inbound_peers: 1, @@ -597,8 +597,8 @@ async fn peer_caps_reject_full_without_status_or_misbehavior_and_free_on_remove( #[tokio::test] async fn stale_disconnect_from_displaced_connection_keeps_live_session() { let network = Network::Mainnet; - let anchor = (block::Height(0), network.genesis_hash()); - let mut startup = startup_for(network, anchor, Some(anchor)); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); + let mut startup = startup_for(network, trusted_sync_start, Some(trusted_sync_start)); startup.range_state_actions_enabled = false; let fixture = spawn_test_reactor(startup); let peer_id = peer(33); @@ -654,8 +654,8 @@ async fn stale_disconnect_from_displaced_connection_keeps_live_session() { #[tokio::test] async fn stale_disconnect_before_winner_connect_still_converges() { let network = Network::Mainnet; - let anchor = (block::Height(0), network.genesis_hash()); - let mut startup = startup_for(network, anchor, Some(anchor)); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); + let mut startup = startup_for(network, trusted_sync_start, Some(trusted_sync_start)); startup.range_state_actions_enabled = false; let fixture = spawn_test_reactor(startup); let peer_id = peer(34); @@ -695,8 +695,8 @@ async fn stale_disconnect_before_winner_connect_still_converges() { #[tokio::test] async fn unscoped_disconnect_removes_session_with_any_registration_id() { let network = Network::Mainnet; - let anchor = (block::Height(0), network.genesis_hash()); - let mut startup = startup_for(network, anchor, Some(anchor)); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); + let mut startup = startup_for(network, trusted_sync_start, Some(trusted_sync_start)); startup.range_state_actions_enabled = false; let fixture = spawn_test_reactor(startup); let peer_id = peer(35); @@ -894,16 +894,16 @@ async fn advisory_backoff_is_pruned_on_peer_disconnected() { #[tokio::test(flavor = "current_thread")] async fn admission_failure_after_advisory_selection_creates_no_outstanding_range() { let network = regtest_network(); - let anchor = (block::Height(0), network.genesis_hash()); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, - Some(anchor), + Some(trusted_sync_start), ZakuraHeaderSyncConfig { peer_limits: ServicePeerLimits { max_inbound_peers: 0, @@ -1186,7 +1186,7 @@ async fn stale_header_sync_teardown_keeps_replacement_session() { async fn advertise_tip( fixture: &ReactorFixture, peer_id: ZakuraPeerId, - anchor_height: block::Height, + sync_start_height: block::Height, tip_height: block::Height, max_headers_per_response: u32, max_inflight_requests: u16, @@ -1194,7 +1194,7 @@ async fn advertise_tip( advertise_tip_with_hash( fixture, peer_id, - anchor_height, + sync_start_height, tip_height, block::Hash([9; 32]), max_headers_per_response, @@ -1206,7 +1206,7 @@ async fn advertise_tip( async fn advertise_tip_with_hash( fixture: &ReactorFixture, peer_id: ZakuraPeerId, - anchor_height: block::Height, + sync_start_height: block::Height, tip_height: block::Height, tip_hash: block::Hash, max_headers_per_response: u32, @@ -1219,7 +1219,7 @@ async fn advertise_tip_with_hash( msg: HeaderSyncMessage::Status(HeaderSyncStatus { tip_height, tip_hash, - anchor_height, + sync_start_height, max_headers_per_response, max_inflight_requests, }), @@ -1233,7 +1233,7 @@ fn codec_round_trips_status() { let status = HeaderSyncStatus { tip_height: block::Height(10), tip_hash: block::Hash([9; 32]), - anchor_height: block::Height(1), + sync_start_height: block::Height(1), max_headers_per_response: DEFAULT_HS_RANGE, max_inflight_requests: DEFAULT_HS_MAX_INFLIGHT, }; @@ -1928,27 +1928,34 @@ async fn scheduler_narrows_large_ranges_before_tracking_fanout() { } #[tokio::test(flavor = "current_thread")] -async fn scheduler_creates_checkpoint_forward_before_backward_ranges() { +async fn forward_ranges_are_assigned_before_backfill() { let (network, checkpoint_hash) = checkpoint_regtest(block::Height(3)); - let mut fixture = spawn_test_reactor(startup_for( + let mut startup = startup_for( network, (block::Height(3), checkpoint_hash), Some((block::Height(3), checkpoint_hash)), - )); - let peer_id = peer(6); + ); + startup.backfill_enabled = true; + let mut fixture = spawn_test_reactor(startup); - connect_peer(&fixture, peer_id.clone()).await; - advertise_tip( - &fixture, - peer_id, - block::Height(0), - block::Height(8), - DEFAULT_HS_RANGE, - 10, - ) - .await; + // One outbound request per peer: the forward range's full fanout (3 peers) is assigned + // before the backfill bracket gets its first peer. + for seed in 1..=4 { + let peer_id = peer(seed); + connect_peer(&fixture, peer_id.clone()).await; + advertise_tip( + &fixture, + peer_id, + block::Height(0), + block::Height(8), + DEFAULT_HS_RANGE, + 10, + ) + .await; + } - loop { + let mut requests = Vec::new(); + while requests.len() < 4 { if let HeaderSyncAction::SendMessage { msg: HeaderSyncMessage::GetHeaders { @@ -1959,11 +1966,19 @@ async fn scheduler_creates_checkpoint_forward_before_backward_ranges() { .. } = next_non_query_action(&mut fixture.actions).await { - assert_eq!(start_height, block::Height(4)); - assert_eq!(count, 5); - break; + requests.push((start_height, count)); } } + assert_eq!( + &requests[..3], + &[(block::Height(4), 5); 3], + "the forward range exhausts its fanout first" + ); + assert_eq!( + requests[3], + (block::Height(1), 3), + "the backfill bracket is assigned only after forward work" + ); } #[tokio::test(flavor = "current_thread")] @@ -2525,7 +2540,7 @@ async fn covered_hedged_outstanding_ranges_do_not_commit_twice() { } #[tokio::test(flavor = "current_thread")] -async fn late_covered_response_does_not_reanchor_newer_outstanding_range() { +async fn late_covered_response_does_not_rebase_newer_outstanding_range() { let network = regtest_network(); let mut fixture = spawn_test_reactor(startup_for( network.clone(), @@ -2607,7 +2622,7 @@ async fn late_covered_response_does_not_reanchor_newer_outstanding_range() { msg: HeaderSyncMessage::GetHeaders { .. }, .. } - | HeaderSyncAction::HeaderReanchored { .. } + | HeaderSyncAction::HeaderRebased { .. } | HeaderSyncAction::Misbehavior { .. } => { panic!("late covered response must not trigger a new action: {action:?}") } @@ -3946,11 +3961,11 @@ async fn inbound_get_headers_over_cap_disconnects_without_state_read() { #[tokio::test(flavor = "current_thread")] async fn rejected_non_linking_range_traces_link_stage_and_error_kind() { let network = regtest_network(); - let anchor = (block::Height(0), network.genesis_hash()); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); let mut capture = TraceCapture::for_test("rejected_non_linking_range_traces_link_stage_and_error_kind") .unwrap(); - let mut startup = startup_for(network, anchor, Some(anchor)); + let mut startup = startup_for(network, trusted_sync_start, Some(trusted_sync_start)); startup.trace = ZakuraTrace::new(capture.tracer(), "01"); let mut fixture = spawn_test_reactor(startup); let peer_id = peer(64); @@ -3959,7 +3974,7 @@ async fn rejected_non_linking_range_traces_link_stage_and_error_kind() { advertise_tip( &fixture, peer_id.clone(), - anchor.0, + trusted_sync_start.0, block::Height(1), DEFAULT_HS_RANGE, 1, @@ -3993,13 +4008,13 @@ async fn rejected_non_linking_range_traces_link_stage_and_error_kind() { capture.flush().await; let reader = capture.reader().unwrap(); let header_sync = reader.table(HEADER_SYNC_TABLE.table()); - let anchor_hash = format!("{}", anchor.1); + let link_hash = format!("{}", trusted_sync_start.1); header_sync.assert_row( hs_trace::HEADER_RANGE_REJECTED, &[ (hs_trace::RANGE_START, TraceValue::U64(1)), (hs_trace::RANGE_COUNT, TraceValue::U64(1)), - (hs_trace::ANCHOR_HASH, TraceValue::Str(&anchor_hash)), + (hs_trace::LINK_HASH, TraceValue::Str(&link_hash)), (hs_trace::VALIDATION_STAGE, TraceValue::Str("link")), ( hs_trace::ERROR_KIND, @@ -4581,7 +4596,7 @@ async fn failed_rebuild_clears_guard_and_retriggers() { .handle .send(HeaderSyncEvent::BestHeaderHistoryTreeLoaded { best_header_tip: block::Height(4), - reanchor: None, + rebase: None, history_tree: None, }) .await @@ -4718,7 +4733,7 @@ async fn forward_ranges_request_roots_through_the_last_checkpoint() { } #[tokio::test(flavor = "current_thread")] -async fn forward_link_wedge_reanchors_to_verified_tip_without_banning() { +async fn forward_link_wedge_rebases_to_verified_tip_without_banning() { let network = regtest_network(); let verified = (block::Height(0), network.genesis_hash()); let stranded_tip = (block::Height(3), block::Hash([3; 32])); @@ -4778,25 +4793,25 @@ async fn forward_link_wedge_reanchors_to_verified_tip_without_banning() { assert_eq!(fixture.handle.best_header_tip(), verified); let expected_start = verified.0.next().expect("genesis has a successor"); - let mut saw_reanchor_action = false; + let mut saw_rebase_action = false; for _ in 0..8 { match next_non_query_action(&mut fixture.actions).await { - HeaderSyncAction::HeaderReanchored { old, new } => { + HeaderSyncAction::HeaderRebased { old, new } => { assert_eq!(old, stranded_tip); assert_eq!(new, verified); - saw_reanchor_action = true; + saw_rebase_action = true; } HeaderSyncAction::SendMessage { msg: HeaderSyncMessage::GetHeaders { start_height, - // This test exercises reanchor-and-resume, not the root regime; accept either + // This test exercises rebase-and-resume, not the root regime; accept either // flag (regtest's genesis-only `max_height` makes the resumed range's root // flag a degenerate edge). .. }, .. - } if saw_reanchor_action && start_height == expected_start => { + } if saw_rebase_action && start_height == expected_start => { assert_no_commit_or_misbehavior(&mut fixture.actions).await; return; } @@ -4806,11 +4821,11 @@ async fn forward_link_wedge_reanchors_to_verified_tip_without_banning() { _ => {} } } - panic!("after re-anchor, header sync did not emit the reanchor action and request forward from the verified tip"); + panic!("after rebase, header sync did not emit the rebase action and request forward from the verified tip"); } #[tokio::test(flavor = "current_thread")] -async fn single_peer_forward_link_failures_do_not_reanchor_globally() { +async fn single_peer_forward_link_failures_do_not_rebase_globally() { let network = regtest_network(); let verified = (block::Height(0), network.genesis_hash()); let stranded_tip = (block::Height(3), block::Hash([3; 32])); @@ -5003,9 +5018,9 @@ async fn truncated_finalized_backfill_is_rejected_before_commit() { assert_no_commit_or_misbehavior(&mut fixture.actions).await; } -// Backward (below-anchor) checkpoint backfill is explicitly disabled (see -// `backward_checkpoint_backfill_is_explicitly_disabled`), so finalized checkpoint-range -// validation is exercised through the forward genesis-backfill path below. +// Finalized checkpoint-range validation is exercised through the forward genesis-sync path below +// (a genesis sync start whose first forward range ends at the first checkpoint); the +// below-sync-start backfill cursor exercises the same validation in the `backfill_*` tests. #[tokio::test(flavor = "current_thread")] async fn checkpoint_backfill_rejects_non_contiguous_run_before_commit() { @@ -5065,11 +5080,15 @@ async fn checkpoint_backfill_rejects_non_contiguous_run_before_commit() { } #[tokio::test(flavor = "current_thread")] -async fn header_response_that_does_not_link_to_anchor_is_misbehavior_before_commit() { +async fn header_response_that_does_not_link_is_misbehavior_before_commit() { let checkpoint_hash = block::Hash::from(mainnet_header(&BLOCK_MAINNET_3_BYTES).as_ref()); let (network, _) = checkpoint_testnet_with_hash(block::Height(3), checkpoint_hash); - let anchor = (block::Height(0), network.genesis_hash()); - let mut fixture = spawn_test_reactor(startup_for(network, anchor, Some(anchor))); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); + let mut fixture = spawn_test_reactor(startup_for( + network, + trusted_sync_start, + Some(trusted_sync_start), + )); let peer_id = peer(46); connect_peer(&fixture, peer_id.clone()).await; @@ -5447,16 +5466,16 @@ fn hostile_vectors_are_rejected_for_allocation_and_unsolicited_headers() { #[tokio::test] async fn misbehavior_is_recorded_without_disconnecting_the_peer() { let network = Network::Mainnet; - let anchor = (block::Height(0), network.genesis_hash()); + let trusted_sync_start = (block::Height(0), network.genesis_hash()); let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { - finalized_height: anchor.0, - verified_block_tip: anchor.0, - verified_block_hash: anchor.1, + finalized_height: trusted_sync_start.0, + verified_block_tip: trusted_sync_start.0, + verified_block_hash: trusted_sync_start.1, }, - Some(anchor), + Some(trusted_sync_start), ZakuraHeaderSyncConfig::default(), LOCAL_MAX_MESSAGE_BYTES, ); @@ -5471,11 +5490,11 @@ async fn misbehavior_is_recorded_without_disconnecting_the_peer() { let probe_cancel = connect_peer_with_direction(&fixture, probe.clone(), ServicePeerDirection::Inbound).await; - // `anchor_height > tip_height` is an `InvalidStatus` misbehavior. + // `sync_start_height > tip_height` is an `InvalidStatus` misbehavior. let invalid_status = HeaderSyncMessage::Status(HeaderSyncStatus { tip_height: block::Height(0), tip_hash: block::Hash([0; 32]), - anchor_height: block::Height(1), + sync_start_height: block::Height(1), max_headers_per_response: 1, max_inflight_requests: 1, }); @@ -5804,7 +5823,7 @@ async fn short_served_forward_range_must_install_the_delivered_frontier_tree() { .await .unwrap(); - // The next forward range overlaps the committed tip: heights 3..=4 anchored on header 2. + // The next forward range overlaps the committed tip: heights 3..=4 linked on header 2. let (served_peer, start_height, count) = next_outbound_get_headers(&mut fixture.actions).await; assert_eq!(served_peer, peer_id); assert_eq!(start_height, block::Height(3)); @@ -5848,15 +5867,15 @@ async fn short_served_forward_range_must_install_the_delivered_frontier_tree() { } } -/// Regression test for the runtime lazy-rebuild reanchor. A non-Zakura path (checkpoint/legacy sync) +/// Regression test for the runtime lazy-rebuild rebase. A non-Zakura path (checkpoint/legacy sync) /// runs the header tip ahead of the folded tree, so the forward range that needs the tree triggers a /// `QueryBestHeaderHistoryTree` rebuild. When durable roots have a gap the rebuilt tree lands *below* -/// `best_header_tip - 1`, so the answer carries a [`HeaderFrontierReanchor`]. The reactor must drop its -/// tip onto the rebuilt tree (emitting `HeaderReanchored`) and then verify the resumed forward range +/// `best_header_tip - 1`, so the answer carries a [`HeaderFrontierRebase`]. The reactor must drop its +/// tip onto the rebuilt tree (emitting `HeaderRebased`) and then verify the resumed forward range /// against it — instead of keeping the stale higher tip and re-triggering the identical deterministic /// rebuild forever (the pre-fix loop, where the frontier was discarded before reaching the reactor). #[tokio::test(flavor = "current_thread")] -async fn lazy_rebuild_reanchors_tip_onto_lower_frontier_tree() { +async fn lazy_rebuild_rebases_tip_onto_lower_frontier_tree() { let genesis_hash = regtest_network().genesis_hash(); // A synthetic post-Heartwood chain 1..=4 with real ZIP-221 commitments, so the resumed range can @@ -5928,12 +5947,12 @@ async fn lazy_rebuild_reanchors_tip_onto_lower_frontier_tree() { } // Answer the rebuild with a tree that folded only to the confirmed frontier at height 1 — below - // `best_header_tip - 1` (2), a durable-root gap — plus the reanchor onto it. + // `best_header_tip - 1` (2), a durable-root gap — plus the rebase onto it. fixture .handle .send(HeaderSyncEvent::BestHeaderHistoryTreeLoaded { best_header_tip: block::Height(3), - reanchor: Some(HeaderFrontierReanchor { + rebase: Some(HeaderFrontierRebase { tip: block::Height(2), tip_hash: h2_hash, parent_hash: h1_hash, @@ -5943,14 +5962,14 @@ async fn lazy_rebuild_reanchors_tip_onto_lower_frontier_tree() { .await .unwrap(); - // The reactor drops the tip onto the rebuilt tree and resumes the forward range from the reanchor, + // The reactor drops the tip onto the rebuilt tree and resumes the forward range from the rebase, // verifying it against the reinstalled tree — never a second rebuild. - let mut saw_reanchor = false; + let mut saw_rebase = false; loop { match next_non_query_action(&mut fixture.actions).await { - HeaderSyncAction::HeaderReanchored { new, .. } => { - assert_eq!(new.0, block::Height(2), "tip reanchored onto the frontier"); - saw_reanchor = true; + HeaderSyncAction::HeaderRebased { new, .. } => { + assert_eq!(new.0, block::Height(2), "tip rebased onto the frontier"); + saw_rebase = true; } HeaderSyncAction::SendMessage { msg: @@ -5961,8 +5980,8 @@ async fn lazy_rebuild_reanchors_tip_onto_lower_frontier_tree() { }, .. } => { - // Ignore the pre-reanchor retry of the stale [4..] range; only serve the range the - // reactor resumes from the reanchor at height 2. + // Ignore the pre-rebase retry of the stale [4..] range; only serve the range the + // reactor resumes from the rebase at height 2. if start_height != block::Height(2) { continue; } @@ -5982,7 +6001,7 @@ async fn lazy_rebuild_reanchors_tip_onto_lower_frontier_tree() { break; } HeaderSyncAction::QueryBestHeaderHistoryTree { .. } => panic!( - "the reanchored tree was ignored: the resumed range re-triggered a durable-state \ + "the rebased tree was ignored: the resumed range re-triggered a durable-state \ rebuild instead of verifying against the reinstalled tree" ), HeaderSyncAction::Misbehavior { peer, reason } => { @@ -5992,21 +6011,20 @@ async fn lazy_rebuild_reanchors_tip_onto_lower_frontier_tree() { } } assert!( - saw_reanchor, - "the tip must be reanchored down onto the rebuilt frontier tree" + saw_rebase, + "the tip must be rebased down onto the rebuilt frontier tree" ); } -/// Regression test: backward (below-anchor) checkpoint backfill is unused by the current sync -/// wiring and does not work with root verification (backward ranges fold onto the previous -/// checkpoint's tree, which this reactor never tracks), so it must be explicitly disabled rather -/// than left scheduling silently. +/// Regression test: the below-sync-start backfill is dormant by default +/// (`BELOW_SYNC_START_BACKFILL_ENABLED = false`), because the current node wiring never consumes +/// backfilled headers or roots. Fixtures opt in per startup via `backfill_enabled`. /// -/// A node anchored at a checkpoint above genesis whose forward frontier already matches the -/// peer's tip has no forward work; any `GetHeaders` it emits is the backward backfill bracket. -/// It must emit none. +/// A node whose trusted sync start is a checkpoint above genesis and whose forward frontier +/// already matches the peer's tip has no forward work; any `GetHeaders` it emits is the backfill +/// bracket. It must emit none by default. #[tokio::test(flavor = "current_thread")] -async fn backward_checkpoint_backfill_is_explicitly_disabled() { +async fn below_sync_start_backfill_is_disabled_by_default() { let checkpoint_hash = block::Hash::from(mainnet_header(&BLOCK_MAINNET_3_BYTES).as_ref()); let (network, _) = checkpoint_testnet_with_hash(block::Height(3), checkpoint_hash); let mut fixture = spawn_test_reactor(startup_for( @@ -6027,7 +6045,7 @@ async fn backward_checkpoint_backfill_is_explicitly_disabled() { ) .await; - // Drain actions for a bounded window: no backward `GetHeaders` may be scheduled. + // Drain actions for a bounded window: no backfill `GetHeaders` may be scheduled. while let Ok(Some(action)) = tokio::time::timeout( std::time::Duration::from_millis(250), fixture.actions.recv(), @@ -6045,9 +6063,569 @@ async fn backward_checkpoint_backfill_is_explicitly_disabled() { } = action { panic!( - "backward checkpoint backfill must be explicitly disabled, but a below-anchor \ + "below-sync-start backfill must be disabled by default, but a below-sync-start \ range was requested: start {start_height:?} count {count}" ); } } } + +/// A custom network with real-mainnet-header checkpoints at genesis and height 3, and every +/// fixture height pre-Heartwood so supplied roots verify with the direct Sapling checks against +/// the empty backfill history tree. +fn backfill_fixture_network() -> (Network, (block::Height, block::Hash)) { + let sync_start_hash = block::Hash::from(mainnet_header(&BLOCK_MAINNET_3_BYTES).as_ref()); + let mainnet = Network::Mainnet; + let network = Parameters::build() + .with_network_name("HeadersyncBackfillTest") + .expect("custom network name is valid") + .with_genesis_hash(mainnet.genesis_hash()) + .expect("mainnet genesis hash is valid") + .with_activation_heights(ConfiguredActivationHeights { + overwinter: Some(1), + sapling: Some(2), + blossom: Some(3), + heartwood: Some(5), + canopy: Some(5), + ..Default::default() + }) + .expect("custom activation heights are in order") + .clear_funding_streams() + .with_checkpoints(ConfiguredCheckpoints::HeightsAndHashes(vec![ + (block::Height(0), mainnet.genesis_hash()), + (block::Height(3), sync_start_hash), + ( + block::Height(4), + block::Hash::from(mainnet_header(&BLOCK_MAINNET_4_BYTES).as_ref()), + ), + ])) + .expect("custom checkpoints are valid") + .to_network() + .expect("custom testnet parameters are valid"); + (network, (block::Height(3), sync_start_hash)) +} + +/// Backfill-enabled startup whose forward frontier already matches the peers' advertised tip +/// (height 4), so the reactor's only outstanding work is the below-sync-start backfill. +fn backfill_enabled_startup( + network: &Network, + sync_start: (block::Height, block::Hash), +) -> HeaderSyncStartup { + let best_tip = ( + block::Height(4), + block::Hash::from(mainnet_header(&BLOCK_MAINNET_4_BYTES).as_ref()), + ); + let mut startup = startup_for(network.clone(), sync_start, Some(best_tip)); + startup.backfill_enabled = true; + startup +} + +/// Waits for the next backfill `GetHeaders` and returns its `(start_height, count)`. +async fn next_get_headers(actions: &mut mpsc::Receiver) -> (block::Height, u32) { + loop { + if let HeaderSyncAction::SendMessage { + msg: + HeaderSyncMessage::GetHeaders { + start_height, + count, + want_tree_aux_roots, + }, + .. + } = next_non_query_action(actions).await + { + assert!( + want_tree_aux_roots, + "backfill ranges are always root-carrying" + ); + return (start_height, count); + } + } +} + +/// The backfill cursor schedules one checkpoint-bounded root-carrying bracket from genesis, and a +/// short (truncated) response to the finalized bracket is rejected before commit. +#[tokio::test(flavor = "current_thread")] +async fn backfill_schedules_checkpoint_bounded_range_from_genesis() { + let (network, sync_start) = backfill_fixture_network(); + let mut fixture = spawn_test_reactor(backfill_enabled_startup(&network, sync_start)); + let peer_id = peer(93); + + connect_peer(&fixture, peer_id.clone()).await; + advertise_tip( + &fixture, + peer_id.clone(), + block::Height(0), + block::Height(4), + DEFAULT_HS_RANGE, + 10, + ) + .await; + + assert_eq!( + next_get_headers(&mut fixture.actions).await, + (block::Height(1), 3), + "backfill bracket spans genesis to the first checkpoint" + ); + + // Finalized brackets require whole delivery: a truncated response is rejected. + let headers = [ + mainnet_header(&BLOCK_MAINNET_1_BYTES), + mainnet_header(&BLOCK_MAINNET_2_BYTES), + ]; + let matching_roots = header_matching_roots(&network, block::Height(1), &headers); + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_id.clone(), + msg: roots_message_from(block::Height(1), headers.to_vec(), matching_roots), + }) + .await + .unwrap(); + + match next_non_query_action(&mut fixture.actions).await { + HeaderSyncAction::Misbehavior { peer, reason } => { + assert_eq!(peer, peer_id); + assert_eq!(reason, HeaderSyncMisbehavior::InvalidRange); + } + action => panic!("unexpected action: {action:?}"), + } + assert_no_commit_or_misbehavior(&mut fixture.actions).await; +} + +/// A backfill bracket whose last header does not hash-match the checkpoint is misbehavior. +#[tokio::test(flavor = "current_thread")] +async fn backfill_checkpoint_end_hash_mismatch_is_misbehavior() { + // The network's height-3 checkpoint (and trusted sync start) diverge from the real chain, so + // a linked run of the real headers must fail the finalized checkpoint-end check. + let divergent_hash = block::Hash::from(mainnet_header(&BLOCK_MAINNET_1_BYTES).as_ref()); + let mainnet = Network::Mainnet; + let network = Parameters::build() + .with_network_name("HeadersyncBackfillDivergent") + .expect("custom network name is valid") + .with_genesis_hash(mainnet.genesis_hash()) + .expect("mainnet genesis hash is valid") + .with_activation_heights(ConfiguredActivationHeights { + overwinter: Some(1), + sapling: Some(2), + blossom: Some(3), + heartwood: Some(5), + canopy: Some(5), + ..Default::default() + }) + .expect("custom activation heights are in order") + .clear_funding_streams() + .with_checkpoints(ConfiguredCheckpoints::HeightsAndHashes(vec![ + (block::Height(0), mainnet.genesis_hash()), + (block::Height(3), divergent_hash), + ( + block::Height(4), + block::Hash::from(mainnet_header(&BLOCK_MAINNET_4_BYTES).as_ref()), + ), + ])) + .expect("custom checkpoints are valid") + .to_network() + .expect("custom testnet parameters are valid"); + let mut fixture = spawn_test_reactor(backfill_enabled_startup( + &network, + (block::Height(3), divergent_hash), + )); + let peer_id = peer(94); + + connect_peer(&fixture, peer_id.clone()).await; + advertise_tip( + &fixture, + peer_id.clone(), + block::Height(0), + block::Height(4), + DEFAULT_HS_RANGE, + 10, + ) + .await; + assert_eq!( + next_get_headers(&mut fixture.actions).await, + (block::Height(1), 3), + ); + + let headers = [ + mainnet_header(&BLOCK_MAINNET_1_BYTES), + mainnet_header(&BLOCK_MAINNET_2_BYTES), + mainnet_header(&BLOCK_MAINNET_3_BYTES), + ]; + let matching_roots = header_matching_roots(&network, block::Height(1), &headers); + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_id.clone(), + msg: roots_message_from(block::Height(1), headers.to_vec(), matching_roots), + }) + .await + .unwrap(); + + match next_non_query_action(&mut fixture.actions).await { + HeaderSyncAction::Misbehavior { peer, reason } => { + assert_eq!(peer, peer_id); + assert_eq!(reason, HeaderSyncMisbehavior::InvalidRange); + } + action => panic!("unexpected action: {action:?}"), + } + assert_no_commit_or_misbehavior(&mut fixture.actions).await; +} + +/// Backfill deliveries are root-verified against the backfill history tree: matching roots commit +/// with the confirmed prefix and `backfill: true`; a wrong root is misbehavior with no commit. +#[tokio::test(flavor = "current_thread")] +async fn backfill_verifies_roots_against_backfill_tree() { + let (network, sync_start) = backfill_fixture_network(); + let headers = [ + mainnet_header(&BLOCK_MAINNET_1_BYTES), + mainnet_header(&BLOCK_MAINNET_2_BYTES), + mainnet_header(&BLOCK_MAINNET_3_BYTES), + ]; + + // Wrong root: corrupt the Sapling root for height 3 (Sapling is active there, so the direct + // below-Heartwood check pins it to the header's own commitment). + { + let mut fixture = spawn_test_reactor(backfill_enabled_startup(&network, sync_start)); + let peer_id = peer(95); + connect_peer(&fixture, peer_id.clone()).await; + advertise_tip( + &fixture, + peer_id.clone(), + block::Height(0), + block::Height(4), + DEFAULT_HS_RANGE, + 10, + ) + .await; + assert_eq!( + next_get_headers(&mut fixture.actions).await, + (block::Height(1), 3), + ); + + let mut wrong_roots = header_matching_roots(&network, block::Height(1), &headers); + wrong_roots[2].sapling_root = sapling::tree::NoteCommitmentTree::default().root(); + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_id.clone(), + msg: roots_message_from(block::Height(1), headers.to_vec(), wrong_roots), + }) + .await + .unwrap(); + + match next_non_query_action(&mut fixture.actions).await { + HeaderSyncAction::Misbehavior { peer, reason } => { + assert_eq!(peer, peer_id); + assert_eq!(reason, HeaderSyncMisbehavior::InvalidRange); + } + action => panic!("unexpected action: {action:?}"), + } + assert_no_commit_or_misbehavior(&mut fixture.actions).await; + } + + // Matching roots: the bracket commits with the confirmed prefix (one shorter than the + // headers) and is flagged as a backfill commit. + let mut fixture = spawn_test_reactor(backfill_enabled_startup(&network, sync_start)); + let peer_id = peer(96); + connect_peer(&fixture, peer_id.clone()).await; + advertise_tip( + &fixture, + peer_id.clone(), + block::Height(0), + block::Height(4), + DEFAULT_HS_RANGE, + 10, + ) + .await; + assert_eq!( + next_get_headers(&mut fixture.actions).await, + (block::Height(1), 3), + ); + + let matching_roots = header_matching_roots(&network, block::Height(1), &headers); + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_id.clone(), + msg: roots_message_from(block::Height(1), headers.to_vec(), matching_roots), + }) + .await + .unwrap(); + + match next_non_query_action(&mut fixture.actions).await { + HeaderSyncAction::CommitHeaderRange { + peer, + start_height, + headers, + finalized, + backfill, + verified_roots, + .. + } => { + assert_eq!(peer, peer_id); + assert_eq!(start_height, block::Height(1)); + assert_eq!(headers.len(), 3); + assert!(finalized); + assert!(backfill); + let verified_roots = verified_roots.expect("backfill ranges carry verified roots"); + assert_eq!(verified_roots.confirmed_roots().len(), 2); + } + action => panic!("unexpected action: {action:?}"), + } +} + +/// A committed backfill bracket advances the backfill frontier (scheduling the sync-start +/// stitch next) without moving the forward frontier or producing body gaps; completing the +/// stitch confirms the sync-start root and ends the backfill. +#[tokio::test(flavor = "current_thread")] +async fn backfill_commit_advances_backfill_frontier_and_stitch_completes() { + let (network, sync_start) = backfill_fixture_network(); + let headers = [ + mainnet_header(&BLOCK_MAINNET_1_BYTES), + mainnet_header(&BLOCK_MAINNET_2_BYTES), + mainnet_header(&BLOCK_MAINNET_3_BYTES), + mainnet_header(&BLOCK_MAINNET_4_BYTES), + ]; + let mut fixture = spawn_test_reactor(backfill_enabled_startup(&network, sync_start)); + let peer_id = peer(97); + + connect_peer(&fixture, peer_id.clone()).await; + advertise_tip( + &fixture, + peer_id.clone(), + block::Height(0), + block::Height(4), + DEFAULT_HS_RANGE, + 10, + ) + .await; + assert_eq!( + next_get_headers(&mut fixture.actions).await, + (block::Height(1), 3), + ); + + // Deliver and commit the genesis bracket. + let matching_roots = header_matching_roots(&network, block::Height(1), &headers[..3]); + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_id.clone(), + msg: roots_message_from(block::Height(1), headers[..3].to_vec(), matching_roots), + }) + .await + .unwrap(); + loop { + if matches!( + next_non_query_action(&mut fixture.actions).await, + HeaderSyncAction::CommitHeaderRange { .. } + ) { + break; + } + } + fixture + .handle + .send(HeaderSyncEvent::HeaderRangeCommitted { + start_height: block::Height(1), + tip_height: block::Height(3), + tip_hash: block::Hash::from(headers[2].as_ref()), + tip_parent_hash: Some(block::Hash::from(headers[1].as_ref())), + }) + .await + .unwrap(); + + // The frontier advance schedules the stitch next; the backfill commit must not move the + // forward frontier or produce body gaps. + loop { + match next_non_query_action(&mut fixture.actions).await { + HeaderSyncAction::SendMessage { + msg: + HeaderSyncMessage::GetHeaders { + start_height, + count, + want_tree_aux_roots, + }, + .. + } => { + assert_eq!( + start_height, + block::Height(3), + "stitch starts at sync start" + ); + assert_eq!(count, 2, "stitch spans the sync start and its successor"); + assert!(want_tree_aux_roots); + break; + } + HeaderSyncAction::HeaderAdvanced { .. } | HeaderSyncAction::BodyGaps { .. } => { + panic!("backfill commits must not move the forward frontier or emit body gaps") + } + _ => {} + } + } + + // Deliver the stitch: the sync-start root is the sole confirmed root. + let stitch_roots = header_matching_roots(&network, block::Height(3), &headers[2..]); + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_id.clone(), + msg: roots_message_from(block::Height(3), headers[2..].to_vec(), stitch_roots), + }) + .await + .unwrap(); + loop { + if let HeaderSyncAction::CommitHeaderRange { + start_height, + headers, + finalized, + backfill, + verified_roots, + .. + } = next_non_query_action(&mut fixture.actions).await + { + assert_eq!(start_height, block::Height(3)); + assert_eq!(headers.len(), 2); + assert!(!finalized, "the stitch end is not a checkpoint"); + assert!(backfill); + let verified_roots = verified_roots.expect("the stitch carries verified roots"); + assert_eq!( + verified_roots.confirmed_roots().len(), + 1, + "exactly the sync-start root is confirmed" + ); + assert_eq!(verified_roots.confirmed_roots()[0].height, block::Height(3)); + break; + } + } + fixture + .handle + .send(HeaderSyncEvent::HeaderRangeCommitted { + start_height: block::Height(3), + tip_height: block::Height(4), + tip_hash: block::Hash::from(headers[3].as_ref()), + tip_parent_hash: Some(block::Hash::from(headers[2].as_ref())), + }) + .await + .unwrap(); + + // The backfill is complete: no further below-sync-start `GetHeaders` may be scheduled. + while let Ok(Some(action)) = tokio::time::timeout( + std::time::Duration::from_millis(250), + fixture.actions.recv(), + ) + .await + { + if let HeaderSyncAction::SendMessage { + msg: HeaderSyncMessage::GetHeaders { start_height, .. }, + .. + } = action + { + panic!("backfill is complete, but a range was requested at {start_height:?}"); + } + } +} + +/// A late fanout delivery of an already-committed backfill bracket is dropped without dispatching +/// the forward tree rebuild and without misbehavior. +#[tokio::test(flavor = "current_thread")] +async fn late_backfill_fanout_delivery_is_dropped_without_rebuild() { + let (network, sync_start) = backfill_fixture_network(); + let headers = [ + mainnet_header(&BLOCK_MAINNET_1_BYTES), + mainnet_header(&BLOCK_MAINNET_2_BYTES), + mainnet_header(&BLOCK_MAINNET_3_BYTES), + ]; + let mut fixture = spawn_test_reactor(backfill_enabled_startup(&network, sync_start)); + let peer_a = peer(98); + let peer_b = peer(99); + + // Peer B advertises a tip at the sync start, so it is eligible for the bracket but never for + // the stitch range (whose end is one above the sync start): its only in-flight work is the + // bracket this test makes late. + for (peer_id, tip) in [ + (peer_a.clone(), block::Height(4)), + (peer_b.clone(), block::Height(3)), + ] { + connect_peer(&fixture, peer_id.clone()).await; + advertise_tip( + &fixture, + peer_id, + block::Height(0), + tip, + DEFAULT_HS_RANGE, + 10, + ) + .await; + } + + // Wait until the bracket is in flight to both peers (request fanout). + let mut requested = std::collections::HashSet::new(); + while requested.len() < 2 { + if let HeaderSyncAction::SendMessage { + peer, + msg: HeaderSyncMessage::GetHeaders { start_height, .. }, + } = next_non_query_action(&mut fixture.actions).await + { + assert_eq!(start_height, block::Height(1)); + requested.insert(peer); + } + } + + // Peer A answers and the bracket commits, advancing the backfill frontier. + let matching_roots = header_matching_roots(&network, block::Height(1), &headers); + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_a, + msg: roots_message_from(block::Height(1), headers.to_vec(), matching_roots.clone()), + }) + .await + .unwrap(); + loop { + if matches!( + next_non_query_action(&mut fixture.actions).await, + HeaderSyncAction::CommitHeaderRange { .. } + ) { + break; + } + } + fixture + .handle + .send(HeaderSyncEvent::HeaderRangeCommitted { + start_height: block::Height(1), + tip_height: block::Height(3), + tip_hash: block::Hash::from(headers[2].as_ref()), + tip_parent_hash: Some(block::Hash::from(headers[1].as_ref())), + }) + .await + .unwrap(); + + // Peer B's late delivery of the same bracket is dropped: no forward tree rebuild + // (`QueryBestHeaderHistoryTree`), no misbehavior, and no duplicate commit. + fixture + .handle + .send(HeaderSyncEvent::WireMessage { + peer: peer_b, + msg: roots_message_from(block::Height(1), headers.to_vec(), matching_roots), + }) + .await + .unwrap(); + while let Ok(Some(action)) = tokio::time::timeout( + std::time::Duration::from_millis(250), + fixture.actions.recv(), + ) + .await + { + match action { + HeaderSyncAction::QueryBestHeaderHistoryTree { .. } => { + panic!("a late backfill delivery must not dispatch the forward tree rebuild") + } + HeaderSyncAction::Misbehavior { .. } => { + panic!("a late backfill fanout delivery is not misbehavior") + } + HeaderSyncAction::CommitHeaderRange { .. } => { + panic!("a late backfill fanout delivery must not re-commit") + } + _ => {} + } + } +} diff --git a/zebra-network/src/zakura/header_sync/validation.rs b/zebra-network/src/zakura/header_sync/validation.rs index b1b83aa477a..f745752cb5e 100644 --- a/zebra-network/src/zakura/header_sync/validation.rs +++ b/zebra-network/src/zakura/header_sync/validation.rs @@ -4,18 +4,18 @@ use zebra_chain::{ parallel::commitment_aux_verify::{self, SuppliedRootsError, VerifiedHeaderCommitmentRoots}, }; -pub(super) fn validate_anchor( +pub(super) fn validate_trusted_sync_start( network: &Network, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), ) -> Result<(), HeaderSyncStartError> { - let expected = if anchor.0 == block::Height(0) { + let expected = if trusted_sync_start.0 == block::Height(0) { Some(network.genesis_hash()) } else { - network.checkpoint_list().hash(anchor.0) + network.checkpoint_list().hash(trusted_sync_start.0) }; match expected { - Some(hash) if hash == anchor.1 => Ok(()), - _ => Err(HeaderSyncStartError::InvalidAnchor { anchor }), + Some(hash) if hash == trusted_sync_start.1 => Ok(()), + _ => Err(HeaderSyncStartError::InvalidTrustedSyncStart { trusted_sync_start }), } } @@ -181,16 +181,16 @@ pub async fn validate_headers_stateless( validate_pow_spawn_blocking(headers, context.network).await } -/// Check that a header range links to its anchor and is internally contiguous. +/// Check that a header range links to its link target and is internally contiguous. pub fn validate_header_range_links( - anchor: block::Hash, + link_hash: block::Hash, headers: &[Arc], ) -> Result<(), HeaderSyncWireError> { let Some(first) = headers.first() else { return Ok(()); }; - if first.previous_block_hash != anchor { + if first.previous_block_hash != link_hash { return Err(HeaderSyncWireError::FirstHeaderDoesNotLink); } diff --git a/zebra-network/src/zakura/header_sync/wire.rs b/zebra-network/src/zakura/header_sync/wire.rs index af097075e3d..79ac90abf80 100644 --- a/zebra-network/src/zakura/header_sync/wire.rs +++ b/zebra-network/src/zakura/header_sync/wire.rs @@ -68,7 +68,7 @@ const _: () = assert!( /// Native stream-5 header-sync message. #[derive(Clone, Debug, Eq, PartialEq)] pub enum HeaderSyncMessage { - /// Peer tip, anchor, and served-range advertisement. + /// Peer tip, sync start, and served-range advertisement. Status(HeaderSyncStatus), /// Request `count` headers starting at `start_height`. GetHeaders { diff --git a/zebra-network/src/zakura/testkit/blocksync_fuzz/mod.rs b/zebra-network/src/zakura/testkit/blocksync_fuzz/mod.rs index dba03ecec6b..d3903ae0326 100644 --- a/zebra-network/src/zakura/testkit/blocksync_fuzz/mod.rs +++ b/zebra-network/src/zakura/testkit/blocksync_fuzz/mod.rs @@ -317,7 +317,7 @@ fn spawn_action_driver( }) } -/// Publishes the scenario's timed frontier changes (header growth / reanchor / +/// Publishes the scenario's timed frontier changes (header growth / rebase / /// verified reset) into the shared sync exchange, driving the node's download target. fn spawn_timeline_driver( exchange: ZakuraSyncExchange, @@ -370,9 +370,9 @@ fn apply_tip_event( frontier.best_header = Frontier::new(height, corpus_hash(corpus, height)); (frontier, FrontierChange::HeaderAdvanced) } - TipEventKind::HeaderReanchor(height) => { + TipEventKind::HeaderRebase(height) => { frontier.best_header = Frontier::new(height, corpus_hash(corpus, height)); - (frontier, FrontierChange::HeaderReanchored) + (frontier, FrontierChange::HeaderRebased) } TipEventKind::VerifiedReset(height) => { frontier.verified_body = Frontier::new(height, corpus_hash(corpus, height)); diff --git a/zebra-network/src/zakura/testkit/blocksync_fuzz/scenario.rs b/zebra-network/src/zakura/testkit/blocksync_fuzz/scenario.rs index a64eceb40e4..c6fe9760942 100644 --- a/zebra-network/src/zakura/testkit/blocksync_fuzz/scenario.rs +++ b/zebra-network/src/zakura/testkit/blocksync_fuzz/scenario.rs @@ -2,7 +2,7 @@ //! //! A [`Scenario`] describes a synthetic chain, the node-under-test config, the peers //! it downloads from (each with a [`ServeProfile`]), and a [`TipEvent`] timeline that -//! drives header growth, reanchors, and verified-tip resets (the "large → small" +//! drives header growth, rebases, and verified-tip resets (the "large → small" //! changes). Everything is a deterministic function of `seed`, so a failing run //! replays from its seed (bit-exact once the Phase-2 clock lands). @@ -268,13 +268,13 @@ impl PeerSpec { pub(crate) enum TipEventKind { /// Advance the best-header download target to `height` (`HeaderAdvanced`). GrowTo(block::Height), - /// Move the best-header target down to `height` (`HeaderReanchored`). - HeaderReanchor(block::Height), + /// Move the best-header target down to `height` (`HeaderRebased`). + HeaderRebase(block::Height), /// Reset the verified-body tip down to `height` (`VerifiedReset`) — a reorg/rollback. VerifiedReset(block::Height), } -/// A timed change to the shared sync frontier (header growth / reanchor / reset). +/// A timed change to the shared sync frontier (header growth / rebase / reset). #[derive(Clone, Copy, Debug)] pub(crate) struct TipEvent { /// When, relative to run start, the change is published. @@ -299,7 +299,7 @@ pub(crate) struct Scenario { pub(crate) config: ZakuraBlockSyncConfig, /// The peers the node downloads from. pub(crate) peers: Vec, - /// Timed frontier changes (header growth, reanchor, verified reset). + /// Timed frontier changes (header growth, rebase, verified reset). pub(crate) timeline: Vec, /// How the mock commit pipeline drains the applyQ (default: instant). pub(crate) commit: CommitProfile, diff --git a/zebra-network/src/zakura/testkit/blocksync_fuzz/tests.rs b/zebra-network/src/zakura/testkit/blocksync_fuzz/tests.rs index d97a1064f57..d2b21e8f898 100644 --- a/zebra-network/src/zakura/testkit/blocksync_fuzz/tests.rs +++ b/zebra-network/src/zakura/testkit/blocksync_fuzz/tests.rs @@ -161,7 +161,7 @@ async fn fuzz_one_slow_peer_hol() { /// Ignored in Phase 1: a faithful *mid-sync* `VerifiedReset` needs the real /// `Committer`'s epoch / lowest-reset-wins rollback semantics (re-verifying every /// height above the reset). `MockApplyFrontier` only mirrors part of that, so the -/// re-sync stalls. The header-reanchor "large → small" path through the same +/// re-sync stalls. The header-rebase "large → small" path through the same /// `handle_chain_tip_reset` IS covered by `fuzz_large_to_small`. This scenario is the /// validation target for the high-fidelity `Committer` tier. #[ignore = "needs high-fidelity Committer for mid-sync reorg epoch/reset semantics"] @@ -677,8 +677,8 @@ async fn fuzz_churn_storm() { run_checked("fuzz_churn_storm", scenario, 32).await; } -/// Large → small: the header target grows in steps, reanchors down below the current -/// verified tip, then grows again to the full chain. Exercises header advance/reanchor +/// Large → small: the header target grows in steps, rebases down below the current +/// verified tip, then grows again to the full chain. Exercises header advance/rebase /// handling and uniform serve jitter. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn fuzz_large_to_small() { @@ -716,7 +716,7 @@ async fn fuzz_large_to_small() { }, TipEvent { at: Duration::from_millis(200), - kind: TipEventKind::HeaderReanchor(block::Height(250)), + kind: TipEventKind::HeaderRebase(block::Height(250)), }, TipEvent { at: Duration::from_millis(280), diff --git a/zebra-network/src/zakura/testkit/cluster.rs b/zebra-network/src/zakura/testkit/cluster.rs index c572636381d..8977bb02012 100644 --- a/zebra-network/src/zakura/testkit/cluster.rs +++ b/zebra-network/src/zakura/testkit/cluster.rs @@ -451,7 +451,7 @@ mod tests { store } - fn with_checkpoint_anchor(height: u32) -> Self { + fn with_checkpoint_sync_start(height: u32) -> Self { let mut store = Self::genesis_only(); let block = mainnet_block(block_bytes(height)); store @@ -498,7 +498,7 @@ mod tests { fn commit_headers( &mut self, - anchor: block::Hash, + link_hash: block::Hash, start: block::Height, headers: Vec>, finalized: bool, @@ -507,7 +507,7 @@ mod tests { return Err(kind); } - let mut expected_previous = anchor; + let mut expected_previous = link_hash; for (offset, header) in headers.iter().enumerate() { if header.previous_block_hash != expected_previous { return Err(HeaderSyncCommitFailureKind::InvalidPeerRange); @@ -611,7 +611,7 @@ mod tests { &mut self, seed: u8, network: Network, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), store: E2eHeaderStore, trace: ZakuraTrace, ) -> Result { @@ -621,7 +621,7 @@ mod tests { .map_err(|_| std::io::Error::other("test store mutex is poisoned"))?; let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, startup_store.frontiers(), Some(startup_store.best_header_tip()), ZakuraHeaderSyncConfig::default(), @@ -964,7 +964,7 @@ mod tests { } HeaderSyncAction::CommitHeaderRange { peer, - anchor, + link_hash, start_height, headers, finalized, @@ -975,7 +975,7 @@ mod tests { .store .lock() .expect("test store mutex is not poisoned") - .commit_headers(anchor, start_height, headers, finalized); + .commit_headers(link_hash, start_height, headers, finalized); match result { Ok((tip_height, tip_hash)) => { let frontiers = local @@ -1019,7 +1019,7 @@ mod tests { .handle .send(HeaderSyncEvent::BestHeaderHistoryTreeLoaded { best_header_tip, - reanchor: None, + rebase: None, history_tree: Some(Arc::new( zebra_chain::history_tree::HistoryTree::default(), )), @@ -1042,7 +1042,7 @@ mod tests { local.observed_gaps.lock().await.push((from, to)); } HeaderSyncAction::HeaderAdvanced { .. } => {} - HeaderSyncAction::HeaderReanchored { .. } => {} + HeaderSyncAction::HeaderRebased { .. } => {} HeaderSyncAction::NewBlockReceived { peer, height, @@ -1214,7 +1214,7 @@ mod tests { HeaderSyncMessage::Status(HeaderSyncStatus { tip_height: block::Height(height), tip_hash, - anchor_height: block::Height(0), + sync_start_height: block::Height(0), max_headers_per_response, max_inflight_requests, }) @@ -1282,18 +1282,18 @@ mod tests { network: Network, trace: &mut TraceCapture, ) -> super::super::ZakuraTestNodeBuilder { - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); ZakuraTestNode::builder(seed) .tracer(trace.tracer_for_node(seed)) .header_sync_driver( network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { finalized_height: block::Height(0), verified_block_tip: block::Height(0), - verified_block_hash: anchor.1, + verified_block_hash: trusted_sync_start.1, }, - Some(anchor), + Some(trusted_sync_start), ) } @@ -1366,7 +1366,7 @@ mod tests { | HeaderSyncAction::QueryMissingBlockBodies { .. } | HeaderSyncAction::BodyGaps { .. } | HeaderSyncAction::HeaderAdvanced { .. } - | HeaderSyncAction::HeaderReanchored { .. } => {} + | HeaderSyncAction::HeaderRebased { .. } => {} } } }) @@ -1598,18 +1598,18 @@ mod tests { ..ZakuraBlockSyncConfig::default() }; - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); let mut cluster = ZakuraTestCluster::new(); let victim = ZakuraTestNode::builder(60) .limits(limits) .tracer(capture.tracer_for_node(60)) .header_sync_driver( e2e_network([3]), - anchor, + trusted_sync_start, HeaderSyncFrontiers { finalized_height: block::Height(0), verified_block_tip: block::Height(0), - verified_block_hash: anchor.1, + verified_block_hash: trusted_sync_start.1, }, Some((block::Height(3), blocks[2].hash())), ) @@ -2669,12 +2669,12 @@ mod tests { )?; let mut cluster = HeaderSyncE2eCluster::new(); let network = e2e_network([1]); - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); let target = cluster.spawn_node( 2, network, - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new(capture.tracer_for_node(2), "02"), )?; @@ -2735,18 +2735,18 @@ mod tests { // tree-aux roots the test peer serves — otherwise a post-Heartwood schedule reads these real // mainnet vectors as `ChainHistoryRoot` and rejects the placeholder roots. let network = e2e_network_with_shielded_activation([4], 5); - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); let source = cluster.spawn_node( 1, network.clone(), - anchor, + trusted_sync_start, E2eHeaderStore::with_headers(4), ZakuraTrace::new(capture.tracer_for_node(1), "01"), )?; let empty = cluster.spawn_node( 2, network, - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new(capture.tracer_for_node(2), "02"), )?; @@ -2827,16 +2827,16 @@ mod tests { Ok(()) } - /// A checkpoint-anchored node syncs forward past its anchor, and below-anchor backward - /// backfill stays explicitly disabled: no `GetHeaders` for the bracket below the anchor is - /// ever sent, and the below-anchor headers stay absent (see - /// `backward_checkpoint_backfill_is_explicitly_disabled` for the reactor-level regression). + /// A node with a checkpoint trusted sync start syncs forward past it, and the + /// below-sync-start backfill stays disabled by default: no `GetHeaders` for the bracket + /// below the sync start is ever sent, and the below-sync-start headers stay absent (see + /// `below_sync_start_backfill_is_disabled_by_default` for the reactor-level regression). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn header_sync_e2e_checkpoint_forward_syncs_without_backward_backfill( + async fn header_sync_e2e_checkpoint_forward_syncs_without_below_sync_start_backfill( ) -> Result<(), BoxError> { let _guard = zebra_test::init(); let mut capture = TraceCapture::for_test_with_keep_override( - "header_sync_e2e_checkpoint_forward_syncs_without_backward_backfill", + "header_sync_e2e_checkpoint_forward_syncs_without_below_sync_start_backfill", false, )?; let (network, checkpoint_hash) = checkpoint_network(3); @@ -2852,7 +2852,7 @@ mod tests { 2, network, (block::Height(3), checkpoint_hash), - E2eHeaderStore::with_checkpoint_anchor(3), + E2eHeaderStore::with_checkpoint_sync_start(3), ZakuraTrace::new(capture.tracer_for_node(2), "02"), )?; assert_eq!(source, 0); @@ -2880,19 +2880,19 @@ mod tests { cluster.wait_for_tip(checkpointed, block::Height(4)).await?; - // Give the scheduler time to (incorrectly) emit a backward bracket if it were still - // enabled, then assert nothing below the anchor was requested or stored. + // Give the scheduler time to (incorrectly) emit a backfill bracket if it were + // enabled, then assert nothing below the sync start was requested or stored. tokio::time::sleep(Duration::from_millis(500)).await; assert!( !cluster.has_headers(checkpointed, 1..=3), - "below-anchor headers must not be backfilled while backward backfill is disabled" + "below-sync-start headers must not be backfilled while backfill is disabled" ); capture.flush().await; let reader = capture.reader()?; let target_trace = reader.node("02").table("header_sync"); target_trace.assert_header_range_request(4, 1); - let backward_requests = target_trace + let backfill_requests = target_trace .rows() .iter() .filter(|row| { @@ -2905,8 +2905,9 @@ mod tests { }) .count(); assert_eq!( - backward_requests, 0, - "backward checkpoint backfill is disabled: no below-anchor GetHeaders may be sent" + backfill_requests, 0, + "below-sync-start backfill is disabled by default: no below-sync-start GetHeaders \ + may be sent" ); assert_eq!( cluster.finalized_height(checkpointed).await, @@ -2928,12 +2929,12 @@ mod tests { )?; let mut cluster = HeaderSyncE2eCluster::new(); let network = e2e_network([]); - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); for seed in 1..=3 { cluster.spawn_node( seed, network.clone(), - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new( capture.tracer_for_node(u64::from(seed)), @@ -2998,12 +2999,12 @@ mod tests { )?; let mut cluster = HeaderSyncE2eCluster::new(); let network = e2e_network([]); - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); for seed in 1..=3 { cluster.spawn_node( seed, network.clone(), - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new( capture.tracer_for_node(u64::from(seed)), @@ -3055,11 +3056,11 @@ mod tests { )?; let mut cluster = HeaderSyncE2eCluster::new(); let network = e2e_network([]); - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); let victim = cluster.spawn_node( 1, network.clone(), - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new(capture.tracer_for_node(1), "01"), )?; @@ -3167,7 +3168,7 @@ mod tests { let bad_continuity_victim = cluster.spawn_node( 3, network.clone(), - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new(capture.tracer_for_node(3), "03"), )?; @@ -3205,7 +3206,7 @@ mod tests { let bad_pow_victim = cluster.spawn_node( 4, network.clone(), - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new(capture.tracer_for_node(4), "04"), )?; @@ -3234,7 +3235,7 @@ mod tests { let bad_daa_victim = cluster.spawn_node( 5, network, - anchor, + trusted_sync_start, E2eHeaderStore::genesis_only(), ZakuraTrace::new(capture.tracer_for_node(5), "05"), )?; @@ -3270,9 +3271,9 @@ mod tests { .await?; // Checkpoint-hash-mismatch backfill responses are covered at the reactor level by - // `checkpoint_backfill_rejects_checkpoint_hash_mismatch_before_commit` (via the forward - // genesis-backfill path); the backward below-anchor bracket that used to drive it here - // is explicitly disabled. + // `checkpoint_backfill_rejects_checkpoint_hash_mismatch_before_commit` (forward genesis + // sync) and `backfill_checkpoint_end_hash_mismatch_is_misbehavior` (the below-sync-start + // backfill cursor, disabled by default). let over_cap = e2e_peer(91); cluster.connect_peer(victim, over_cap.clone()).await; @@ -3385,7 +3386,7 @@ mod tests { false, )?; let network = e2e_network([4]); - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); // The genesis sync path is covered above. Start from its durable header state // so this test only exercises restart reload and scheduler rebuild. @@ -3396,14 +3397,14 @@ mod tests { restarted.spawn_node( 1, network.clone(), - anchor, + trusted_sync_start, E2eHeaderStore::with_headers(5), ZakuraTrace::new(capture.tracer_for_node(1), "01"), )?; let restarted_idx = restarted.spawn_node( 2, network, - anchor, + trusted_sync_start, restart_store, ZakuraTrace::new(capture.tracer_for_node(2), "02"), )?; diff --git a/zebra-network/src/zakura/testkit/mock_blocksync.rs b/zebra-network/src/zakura/testkit/mock_blocksync.rs index dcf874f1d5a..5688bd3e082 100644 --- a/zebra-network/src/zakura/testkit/mock_blocksync.rs +++ b/zebra-network/src/zakura/testkit/mock_blocksync.rs @@ -483,14 +483,14 @@ async fn spawn_mock_node( config: &HarnessConfig, trace: &mut HarnessTrace, ) -> Result { - let anchor = (block::Height(0), mainnet_genesis_hash()); + let trusted_sync_start = (block::Height(0), mainnet_genesis_hash()); let builder = ZakuraTestNode::builder(seed) .limits(config.limits()) .max_connections_per_ip(config.seeds.saturating_add(1)) .tracer(trace.tracer_for_node(seed)) .header_sync_driver( Config::default().network, - anchor, + trusted_sync_start, HeaderSyncFrontiers { finalized_height: initial_frontiers.finalized_height, verified_block_tip: initial_frontiers.verified_block_tip, diff --git a/zebra-network/src/zakura/testkit/node.rs b/zebra-network/src/zakura/testkit/node.rs index 92d89c088ab..ce6fcdb274b 100644 --- a/zebra-network/src/zakura/testkit/node.rs +++ b/zebra-network/src/zakura/testkit/node.rs @@ -218,7 +218,7 @@ pub struct ZakuraTestNodeBuilder { #[derive(Clone, Debug)] struct TestHeaderSyncStartup { network: Network, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), frontiers: HeaderSyncFrontiers, best_header_tip: Option<(block::Height, block::Hash)>, verified_block_tip_hash: block::Hash, @@ -338,16 +338,16 @@ impl ZakuraTestNodeBuilder { pub fn header_sync_driver( mut self, network: Network, - anchor: (block::Height, block::Hash), + trusted_sync_start: (block::Height, block::Hash), frontiers: HeaderSyncFrontiers, best_header_tip: Option<(block::Height, block::Hash)>, ) -> Self { self.header_sync = Some(TestHeaderSyncStartup { network, - anchor, + trusted_sync_start, frontiers, best_header_tip, - verified_block_tip_hash: anchor.1, + verified_block_tip_hash: trusted_sync_start.1, }); self } @@ -402,14 +402,14 @@ impl ZakuraTestNodeBuilder { let header_sync = if let Some(header_sync) = self.header_sync { let TestHeaderSyncStartup { network, - anchor, + trusted_sync_start, frontiers, best_header_tip, verified_block_tip_hash, } = header_sync; let mut startup = HeaderSyncStartup::new( network, - anchor, + trusted_sync_start, frontiers, best_header_tip, ZakuraHeaderSyncConfig::default(), @@ -433,7 +433,7 @@ impl ZakuraTestNodeBuilder { verified_block_tip: frontiers.verified_block_tip, verified_block_hash: verified_block_tip_hash, }, - best_header_tip.unwrap_or(anchor), + best_header_tip.unwrap_or(trusted_sync_start), handle.subscribe_tip(), self.block_sync_config.clone(), ); diff --git a/zebra-network/src/zakura/trace.rs b/zebra-network/src/zakura/trace.rs index 4144764894f..64eee5e3db9 100644 --- a/zebra-network/src/zakura/trace.rs +++ b/zebra-network/src/zakura/trace.rs @@ -347,8 +347,8 @@ pub mod header_sync_trace { pub const HEIGHT: &str = "height"; /// Hash field. pub const HASH: &str = "hash"; - /// Header anchor hash field. - pub const ANCHOR_HASH: &str = "anchor_hash"; + /// Header link hash field. + pub const LINK_HASH: &str = "link_hash"; /// Range start height field. pub const RANGE_START: &str = "range_start"; /// Range count field. @@ -367,7 +367,7 @@ pub mod header_sync_trace { pub const WANT_TREE_AUX_ROOTS: &str = "want_tree_aux_roots"; /// Whether the range is expected to terminate at a checkpoint. pub const FINALIZED: &str = "finalized"; - /// Header scheduler priority label (`forward` or `backward`). + /// Header scheduler priority label (`forward` or `backfill`). pub const RANGE_PRIORITY: &str = "range_priority"; /// Highest verified full-block/body height observed by the header scheduler. pub const VERIFIED_BLOCK_TIP: &str = "verified_block_tip"; @@ -412,8 +412,8 @@ pub mod header_sync_trace { pub const HEADER_PEER_DISCONNECT_REQUESTED: &str = "header_peer_disconnect_requested"; /// Header frontier advanced. pub const HEADER_FRONTIER_ADVANCED: &str = "header_frontier_advanced"; - /// Header frontier re-anchored down to the verified block frontier. - pub const HEADER_FRONTIER_REANCHORED: &str = "header_frontier_reanchored"; + /// Header frontier rebased down to the verified block frontier. + pub const HEADER_FRONTIER_REANCHORED: &str = "header_frontier_rebased"; /// Missing block bodies reported. pub const HEADER_MISSING_BODIES_REPORTED: &str = "header_missing_bodies_reported"; } diff --git a/zebra-state/src/service/finalized_state/zebra_db/block.rs b/zebra-state/src/service/finalized_state/zebra_db/block.rs index dc64a28448d..4e73050c73b 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block.rs @@ -1967,12 +1967,13 @@ impl DiskWriteBatch { /// and the tree-aux roots supplied for it. /// /// `tree_aux_roots` is the caller's confirmed prefix, aligned from the range start. For a - /// semantically-validated forward range it is exactly one shorter than `headers` (zero roots - /// for a single-header range): the range tip's root is only authenticated by the next range's - /// successor header, so it is never confirmed here. Checkpoint-authenticated backfill ranges - /// pass an empty vector and persist no roots. Both shapes are enforced, not merely expected, so - /// it is structurally impossible to persist the unconfirmed tip root and expose peer-supplied - /// data to startup history-tree reconstruction. + /// root-carrying range (below-checkpoint forward or below-sync-start backfill) it is exactly + /// one shorter than `headers` (zero roots for a single-header range): the range tip's root is + /// only authenticated by the next range's successor header, so it is never confirmed here. + /// Plain above-checkpoint ranges (past the VCT handoff boundary) request no roots and pass an + /// empty vector. Both shapes are enforced, not merely expected, so it is structurally + /// impossible to persist the unconfirmed tip root and expose peer-supplied data to startup + /// history-tree reconstruction. #[allow(clippy::unwrap_in_result)] pub fn prepare_header_range_batch_with_roots( &mut self, @@ -1993,15 +1994,15 @@ impl DiskWriteBatch { }); } - // A semantically-validated header range carries its *confirmed prefix* of roots: header - // `H + 1` authenticates the root for `H`, so over `[start..=tip]` the roots confirmed are + // A root-carrying header range carries its *confirmed prefix* of roots: header `H + 1` + // authenticates the root for `H`, so over `[start..=tip]` the roots confirmed are // `[start..=tip - 1]` and the tip's own root stays unconfirmed until the next overlapping // range delivers its successor header. That prefix is always exactly one shorter than the // headers (zero roots for a single-header range). // - // Checkpoint-authenticated backfill ranges (backward ranges) carry no provisional roots at - // all, so an empty vector is also accepted. Both accepted shapes keep the trust boundary a - // state invariant: the unconfirmed tip root can never be persisted, because a full-length + // Plain above-checkpoint ranges (past the VCT handoff boundary) request no roots, so an + // empty vector is also accepted. Both accepted shapes keep the trust boundary a state + // invariant: the unconfirmed tip root can never be persisted, because a full-length // (or otherwise-longer) vector is still rejected, and an empty vector persists nothing. if !(tree_aux_roots.is_empty() || tree_aux_roots.len() + 1 == headers.len()) { return Err(CommitHeaderRangeError::TreeAuxRootCountMismatch { diff --git a/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs b/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs index 7231137302b..46c225efd53 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs @@ -712,12 +712,12 @@ fn header_range_commit_persists_only_the_confirmed_root_prefix() { "the unconfirmed range tip must not have a persisted root", ); - // An empty vector is the checkpoint-authenticated backfill shape: it is accepted and persists no - // provisional roots, so the trust boundary is never crossed. + // An empty vector is the plain above-checkpoint shape (roots neither requested nor verified): + // it is accepted and persists no provisional roots, so the trust boundary is never crossed. let mut batch = DiskWriteBatch::new(); batch .prepare_header_range_batch_with_roots(&state, genesis.hash(), &headers, &[0, 0], &[]) - .expect("an empty root vector is accepted for checkpoint-authenticated backfill"); + .expect("an empty root vector is accepted for plain rootless ranges"); // A full-length vector would include the unauthenticated tip root, so it is rejected. let mut batch = DiskWriteBatch::new(); @@ -734,6 +734,80 @@ fn header_range_commit_persists_only_the_confirmed_root_prefix() { )); } +/// Below-sync-start backfill shapes: a header-range commit linked on an intermediate zakura +/// header resolves that anchor through the header index, an identical re-commit of an +/// already-committed range (the backfill stitch redelivers the sync-start successor) is +/// accepted, and neither regresses the best header tip. +#[test] +fn header_range_commit_accepts_intermediate_anchor_and_identical_recommit() { + let _init_guard = zebra_test::init(); + let (state, genesis, block1) = mainnet_state_with_genesis(); + let block2 = mainnet_block(2); + let block3 = mainnet_block(3); + let block4 = mainnet_block(4); + + let headers = vec![ + block1.header.clone(), + block2.header.clone(), + block3.header.clone(), + block4.header.clone(), + ]; + let confirmed_roots = vec![root_at(Height(1)), root_at(Height(2)), root_at(Height(3))]; + let mut batch = DiskWriteBatch::new(); + batch + .prepare_header_range_batch_with_roots( + &state, + genesis.hash(), + &headers, + &[0; 4], + &confirmed_roots, + ) + .expect("initial header range is valid"); + state + .write_batch(batch) + .expect("header range batch writes successfully"); + assert_eq!(state.best_header_tip(), Some((Height(4), block4.hash()))); + + // A below-tip re-commit of the identical prefix range (with its confirmed root) is accepted + // and does not move the header tip. + let mut batch = DiskWriteBatch::new(); + batch + .prepare_header_range_batch_with_roots( + &state, + genesis.hash(), + &headers[..2], + &[0; 2], + &[root_at(Height(1))], + ) + .expect("an identical below-tip re-commit is accepted"); + state + .write_batch(batch) + .expect("header range batch writes successfully"); + assert_eq!(state.best_header_tip(), Some((Height(4), block4.hash()))); + + // A range linked on an intermediate zakura header (a mid-backfill bracket anchor) resolves + // through the header index and re-commits identically. + let mut batch = DiskWriteBatch::new(); + batch + .prepare_header_range_batch_with_roots( + &state, + block2.hash(), + &headers[2..], + &[0; 2], + &[root_at(Height(3))], + ) + .expect("an intermediate zakura header anchor is accepted"); + state + .write_batch(batch) + .expect("header range batch writes successfully"); + assert_eq!(state.best_header_tip(), Some((Height(4), block4.hash()))); + assert_eq!( + state.zakura_header_commitment_roots_by_height_range(Height(1)..=Height(3)), + vec![root_at(Height(1)), root_at(Height(2)), root_at(Height(3))], + "confirmed roots survive identical re-commits", + ); +} + /// Pruning-readiness guard: a committed height whose body is removed (as online /// pruning deletes `tx_by_loc` rows) keeps its header readable from the retained /// consensus `block_header_by_height`, because the header readers are not gated diff --git a/zebrad/src/commands/start.rs b/zebrad/src/commands/start.rs index c099816cc05..2d3e4831dcf 100644 --- a/zebrad/src/commands/start.rs +++ b/zebrad/src/commands/start.rs @@ -2800,6 +2800,137 @@ mod zakura_header_sync_driver_tests { endpoint.shutdown().await; } + /// A `backfill: true` header-range commit lands below the trusted sync start, so the driver + /// must not publish it as the shared sync-exchange header frontier; an identical forward + /// commit does publish. + #[tokio::test] + async fn header_sync_driver_backfill_commit_does_not_publish_header_frontier() { + let network = zebra_chain::parameters::Network::Mainnet; + let genesis_hash = network.genesis_hash(); + let mut config = zebra_network::Config { + network: network.clone(), + ..zebra_network::Config::default() + }; + config.zakura.listen_addr = None; + let endpoint = zebra_network::zakura::spawn_zakura_endpoint_with_header_sync_driver( + &config, + |_supervisor, _trace| Arc::new(NoopZakuraService) as Arc, + Some(ZakuraHeaderSyncDriverStartup { + frontiers: HeaderSyncFrontiers { + finalized_height: block::Height(0), + verified_block_tip: block::Height(0), + verified_block_hash: genesis_hash, + }, + best_header_tip: Some((block::Height(0), genesis_hash)), + best_header_parent_hash: None, + best_header_history_tree: Arc::new( + zebra_chain::history_tree::HistoryTree::default(), + ), + verified_block_tip_hash: genesis_hash, + }), + ) + .await + .expect("Zakura endpoint starts") + .expect("v2_p2p starts an endpoint"); + + let initial = endpoint + .current_sync_frontier() + .expect("driver startup initializes exchange"); + assert_eq!(initial.frontier.best_header.height, block::Height(0)); + + let (action_tx, action_rx) = mpsc::channel(4); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let handles = ZakuraHeaderSyncDriverHandles { + endpoint: endpoint.clone(), + header_sync: endpoint + .header_sync() + .expect("driver startup starts header sync"), + }; + let block1 = mainnet_block(&BLOCK_MAINNET_1_BYTES); + let committed_hash = block1.hash(); + let state = service_fn(move |request: zebra_state::Request| async move { + assert!( + matches!(request, zebra_state::Request::CommitHeaderRange { .. }), + "unexpected state request: {request:?}" + ); + Ok::<_, zebra_state::BoxError>(zebra_state::Response::Committed(committed_hash)) + }); + let read_state = service_fn(|request: zebra_state::ReadRequest| async move { + panic!("unexpected read request: {request:?}"); + #[allow(unreachable_code)] + Ok::<_, zebra_state::BoxError>(zebra_state::ReadResponse::Tip(None)) + }); + let verifier = service_fn(|request: zebra_consensus::Request| async move { + panic!("unexpected verifier request: {request:?}"); + #[allow(unreachable_code)] + Ok::<_, zebra_consensus::BoxError>(block::Hash([0; 32])) + }); + let driver = tokio::spawn(drive_zakura_header_sync_actions( + action_rx, + handles, + state, + read_state, + verifier, + zebra_network::zakura::ZakuraTrace::noop(), + async move { + let _ = shutdown_rx.await; + }, + )); + + let peer = + zebra_network::zakura::ZakuraPeerId::new(vec![3; 32]).expect("test peer id is valid"); + let commit_action = + |backfill: bool| zebra_network::zakura::HeaderSyncAction::CommitHeaderRange { + peer: peer.clone(), + link_hash: genesis_hash, + start_height: block::Height(1), + headers: vec![block1.header.clone()], + body_sizes: vec![0], + verified_roots: None, + finalized: false, + backfill, + }; + + // The backfill commit succeeds in state but must not move the exchange frontier. + action_tx + .send(commit_action(true)) + .await + .expect("driver action channel stays open"); + tokio::time::sleep(Duration::from_millis(200)).await; + let after_backfill = endpoint + .current_sync_frontier() + .expect("exchange remains available"); + assert_eq!( + after_backfill.frontier.best_header.height, + block::Height(0), + "a backfill commit must not publish a header frontier update", + ); + + // The same commit as a forward range publishes the advanced frontier. + action_tx + .send(commit_action(false)) + .await + .expect("driver action channel stays open"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let update = endpoint + .current_sync_frontier() + .expect("exchange remains available"); + if update.frontier.best_header.height == block::Height(1) { + assert_eq!(update.frontier.best_header.hash, committed_hash); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("a forward commit publishes to the exchange"); + + let _ = shutdown_tx.send(()); + driver.await.expect("driver task exits cleanly"); + endpoint.shutdown().await; + } + #[tokio::test] async fn block_sync_driver_coalesces_stale_needed_queries() { let (action_tx, mut action_rx) = mpsc::channel(8); diff --git a/zebrad/src/commands/start/zakura/header_sync_driver.rs b/zebrad/src/commands/start/zakura/header_sync_driver.rs index b063d6830dd..945861ffa3f 100644 --- a/zebrad/src/commands/start/zakura/header_sync_driver.rs +++ b/zebrad/src/commands/start/zakura/header_sync_driver.rs @@ -12,7 +12,7 @@ use zebra_chain::{ }; use zebra_network::zakura::{ commit_state_trace as cs_trace, BlockSyncFrontiers, Frontier, FrontierChange, - HeaderFrontierReanchor, HeaderSyncAction, HeaderSyncCommitFailureKind, HeaderSyncEvent, + HeaderFrontierRebase, HeaderSyncAction, HeaderSyncCommitFailureKind, HeaderSyncEvent, HeaderSyncFrontiers, ZakuraEndpoint, ZakuraHeaderSyncDriverStartup, ZakuraTrace, DEFAULT_HS_RANGE, }; @@ -68,7 +68,7 @@ pub(crate) async fn zakura_header_sync_driver_startup( // Rebuild the ZIP-221 history tree so post-Heartwood header-sync root verification can resume // immediately after a restart. The read returns the tree positioned at the highest *contiguous* // confirmed header-root frontier, and that frontier's `(height, hash)` — the single authoritative - // value we use both to anchor overlap and to pick the resume height. + // value we use both to link the overlap range and to pick the resume height. let (best_header_history_tree, (frontier_height, frontier_hash)) = match read_state .clone() .oneshot(zebra_state::ReadRequest::BestHeaderHistoryTree { @@ -84,11 +84,11 @@ pub(crate) async fn zakura_header_sync_driver_startup( ))?, }; - // Resume one block above the contiguous frontier (where the reconstructed tree sits), anchored at + // Resume one block above the contiguous frontier (where the reconstructed tree sits), linked at // it, so the first forward range re-validates from there. If the persisted roots have a one-block // gap (a header-tip advance that never overlapped), this resumes from the gap instead of capping // all the way back to the verified tip. With no header lead there is nothing to resume, so the - // durable tip is kept and no overlap anchor is set. + // durable tip is kept and no overlap link target is set. let (best_header_tip, best_header_parent_hash) = if durable_best_header_tip.0 > verified_block_tip.0 { let resume_height = frontier_height @@ -145,18 +145,18 @@ pub(crate) async fn zakura_header_sync_driver_startup( }) } -/// Resolves the header-frontier reanchor a runtime lazy rebuild needs when the durable header-root +/// Resolves the header-frontier rebase a runtime lazy rebuild needs when the durable header-root /// `frontier` folded *below* `best_header_tip - 1` (a gap left by a non-Zakura commit racing ahead). /// /// Mirrors the startup resume in [`zakura_header_sync_driver_startup`]: resume one block above the -/// confirmed frontier, anchored on the frontier hash, looking up the resume header's hash. Returns +/// confirmed frontier, linked on the frontier hash, looking up the resume header's hash. Returns /// `Ok(None)` when the frontier already reached `best_header_tip - 1` (the common no-gap rebuild, no -/// reanchor needed). -async fn header_frontier_reanchor( +/// rebase needed). +async fn header_frontier_rebase( read_state: ReadState, best_header_tip: block::Height, frontier: (block::Height, block::Hash), -) -> Result, Report> +) -> Result, Report> where ReadState: Service< zebra_state::ReadRequest, @@ -170,7 +170,7 @@ where .next() .map_err(|_| eyre!("header frontier height overflow"))?; // No gap: the tree folded to `best_header_tip - 1`, so its position already matches the parent of - // the next forward range. Nothing to reanchor — the plain tree install suffices. + // the next forward range. Nothing to rebase — the plain tree install suffices. if resume_height >= best_header_tip { return Ok(None); } @@ -190,7 +190,7 @@ where "unexpected HeadersByHeightRange response: {response:?}" ))?, }; - Ok(Some(HeaderFrontierReanchor { + Ok(Some(HeaderFrontierRebase { tip: resume_height, tip_hash, parent_hash: frontier_hash, @@ -642,12 +642,13 @@ pub(crate) async fn drive_zakura_header_sync_actions { let count = u32::try_from(headers.len()).unwrap_or(u32::MAX); // Persist only the header-authenticated confirmed prefix. The range tip's root is @@ -659,7 +660,8 @@ pub(crate) async fn drive_zakura_header_sync_actions { emit_commit_state( @@ -822,7 +829,7 @@ pub(crate) async fn drive_zakura_header_sync_actions { // The fold can stop below `best_header_tip - 1` when durable roots have a gap // (a non-Zakura commit raced ahead). In that case the reactor must drop its tip - // onto the rebuilt tree; resolve that reanchor here, where the read access + // onto the rebuilt tree; resolve that rebase here, where the read access // lives, mirroring the startup resume. A resolution failure is reported as a // failed rebuild (both `None`) so the guard clears and the range re-triggers, // rather than installing a lower tree the stale tip could never match. - match header_frontier_reanchor( - read_state.clone(), - best_header_tip, - frontier, - ) - .await + match header_frontier_rebase(read_state.clone(), best_header_tip, frontier) + .await { - Ok(reanchor) => (Some(tree), reanchor), + Ok(rebase) => (Some(tree), rebase), Err(error) => { - warn!(?error, "failed to resolve Zakura header frontier reanchor"); + warn!(?error, "failed to resolve Zakura header frontier rebase"); (None, None) } } @@ -864,7 +867,7 @@ pub(crate) async fn drive_zakura_header_sync_actions { + HeaderSyncAction::HeaderRebased { old: _, new } => { publish_header_frontier( &handles.endpoint, new.0, new.1, - FrontierChange::HeaderReanchored, + FrontierChange::HeaderRebased, &trace, ); } @@ -1427,8 +1430,8 @@ fn trace_header_driver_action(trace: &ZakuraTrace, action: &HeaderSyncAction) { insert_cs_height(row, cs_trace::HEIGHT, *height); insert_cs_hash(row, cs_trace::HASH, *hash); } - HeaderSyncAction::HeaderReanchored { old, new } => { - insert_cs_str(row, cs_trace::ACTION, "header_reanchored"); + HeaderSyncAction::HeaderRebased { old, new } => { + insert_cs_str(row, cs_trace::ACTION, "header_rebased"); insert_cs_height(row, cs_trace::BEST_HEADER_TIP, old.0); insert_cs_height(row, cs_trace::HEIGHT, new.0); insert_cs_hash(row, cs_trace::HASH, new.1); @@ -1470,7 +1473,7 @@ fn trace_header_commit_finish( } fn header_range_tip_parent_hash( - anchor: block::Hash, + link_hash: block::Hash, start_height: block::Height, headers: &[std::sync::Arc], ) -> Option { @@ -1479,7 +1482,7 @@ fn header_range_tip_parent_hash( } if headers.len() == 1 { - return (start_height > block::Height(0)).then_some(anchor); + return (start_height > block::Height(0)).then_some(link_hash); } headers