Skip to content

feat(network): verify Zakura header-sync commitment roots before persisting#458

Draft
p0mvn wants to merge 12 commits into
ironwood-mainfrom
roman/zakura-header-anchor-root-validation
Draft

feat(network): verify Zakura header-sync commitment roots before persisting#458
p0mvn wants to merge 12 commits into
ironwood-mainfrom
roman/zakura-header-anchor-root-validation

Conversation

@p0mvn

@p0mvn p0mvn commented Jul 4, 2026

Copy link
Copy Markdown

Zakura Header-Sync Commitment Roots

In the header-sync network layer, maintain history tree cache to authenticate peer-provided note commitment roots and auth data.

Goals:

  • Do not write anything unvalidated into the database

Design:

  • We request header batches up to 4000 (i.e. [X - 3999, X])
  • Each header response carries headers plus peer-supplied tree roots for the same heights.
  • A block header commits to the history tree as of its parent, so roots for height H are not confirmed by header H. They become confirmed only after header H + 1 validates against the tree that includes H.
  • Therefore a contiguous delivery [start..=end] confirms roots only for [start..=end - 1]. The root for end remains unconfirmed and must be re-delivered in the next overlapping range.
  • As a result, forward header sync requests overlapping batches: [X - 3999, X], [X, X + 3999], etc. The overlap block is requested twice so its root can be confirmed by the successor header in the second batch.

Risks

  • Startup: reconstructs the tree by appending durable confirmed roots from the verified body tip up to best_header_tip - 1, stopping at the first missing header/root frontier and returning the highest contiguous frontier. Header sync then resumes from frontier + 1 with an overlapping request, rather than blindly capping back to the verified body tip. The best tip can be way ahead of the verified tip, so the CPU work may be meaningful. I have not observed practical issues, but I can see needing to move this computation off to a background worker in the future.
  • Reorg: If the live header frontier becomes stale, header sync reanchors to the verified block tip, clears forward work, and reloads the history tree at that verified tip. We delete conflicting provisional headers and roots from state before writing the replacement header suffix.
  • Live consensus: when a gossiped block is accepted ahead of header sync, header sync refreshes its in-memory tree with, reloading at the committed tip.

Implementation Details

There was something called backward/checkpoint header backfill which allows specifying an anchor height/hash and syncing backwards. Initial implementation broke it by allowing forward sync only. Had to rebuild to account for that.

Testing

  • 15 runs of 5K heights with restarts and no failures over sand blast. Sync to tip.

@v12-auditor

v12-auditor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found three issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-95614 🟠 High
Backward Ranges Never Commit

Backward checkpoint header ranges are now routed through validate_forward_header_aux_commitments() before the reactor can dispatch CommitHeaderRange. refresh_backward_range() schedules these ranges with want_tree_aux_roots: true and priority: RangePriority::Backward, but the validator returns MissingHeaderHistoryTree for every non-Forward range. The MissingHeaderHistoryTree branch clears the assignment, retries the same range, reschedules, and returns before the only CommitHeaderRange dispatch. A valid peer response for a backward checkpoint bracket therefore cannot commit and is re-requested indefinitely.

F-95615 🟡 Medium
Fork Tree Mismatch

The reactor stores each validated pending header commit with a key that includes the peer, start height, and count, and the scheduler can assign the same range to multiple peers. After state commits one of those responses, handle_header_range_committed() asks pending_header_history_tree() for the tree to install. That helper ignores the peer, committed tip hash, and committed parent hash, and returns the first pending commit whose start and end heights match. If two peers provide individually valid divergent forks for the same height range, a success event for one fork can install the history tree calculated from the other fork.

F-95616 🟡 Medium
Uncapped Header Rebuild

BestHeaderHistoryTree reconstruction applies MAX_HEADER_SYNC_HEIGHT_RANGE only to each read window, not to the total distance between the verified block tip and the best header tip. Startup passes the durable best header tip directly into this read request, and best_header_history_tree() loops from verified_block_tip + 1 through best_header_tip - 1. Each iteration reads headers and roots and folds the contiguous prefix before advancing to the next window. A large persisted header lead therefore causes startup to replay the entire lead, with no total span cap, time budget, or cached reconstructed frontier.

And one more auto-invalidated finding.

Analyzed 19 files, diff 4cc3b5b...1f5d673.

@p0mvn
p0mvn marked this pull request as draft July 4, 2026 06:28
(`TreeAuxRootHeightMismatch` / `validate_tree_aux_root_heights`) before the roots reach
state. State re-checks the count and alignment invariants in `CommitHeaderRange`
(`prepare_header_range_batch_with_roots`) as defense in depth, and never
(`TreeAuxRootHeightMismatch` / `validate_tree_aux_root_heights`) and authenticates the roots

@p0mvn p0mvn Jul 4, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: most of these are AI notes. Please refer to PR description for my hand-written notes.

@p0mvn

p0mvn commented Jul 4, 2026

Copy link
Copy Markdown
Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit bb3687f. Configure here.

@p0mvn
p0mvn marked this pull request as ready for review July 4, 2026 07:32
@p0mvn
p0mvn force-pushed the roman/zakura-header-anchor-root-validation branch from 1774d10 to 8e1c1fe Compare July 5, 2026 06:52
Comment on lines +1248 to +1260
// The tree is behind this range's parent — a non-Zakura path (checkpoint/legacy
// sync) committed ahead of the header-commit frontier, so `best_header_tip` was
// bumped past where the tree is folded. This is the *only* reload trigger: rebuild
// the tree at the current frontier from durable state, then retry the range. In the
// normal Zakura path (header sync leading) this never fires. Only arm the in-flight
// guard once the reload action is actually queued — `dispatch_action` is a
// non-blocking `try_send` that drops on a full/closed channel, and arming the guard
// on a dropped send would suppress every future rebuild permanently (the guard is
// only cleared when the reload completes). A dropped send just leaves the guard
// clear so the next retry re-dispatches.
if !self.state.rebuild_in_flight
&& self.dispatch_action(HeaderSyncAction::QueryBestHeaderHistoryTree {
verified_block_tip: self.state.verified_block_tip,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: this is the reload on the hot path. In practice, it is never hit because in Zakura sync headers stay ahead of bodies.

Comment on lines +84 to +101
// Commitment-root work only runs through the VCT fast-sync handoff boundary.
// The root at `last_checkpoint` is confirmed by the next header, so root-carrying
// ranges stop once the frontier reaches `last_checkpoint + 1`.
let last_checkpoint = startup.last_checkpoint_height;
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 below_root_boundary =
root_region_floor < last_checkpoint && self.best_header_tip < root_regime_end;
let want_tree_aux_roots = below_root_boundary;

// Root-carrying ranges redeliver the tip header so its root can be confirmed.
let overlap_forward_range = below_root_boundary
&& self
.best_header_parent_hash
.is_some_and(|_| self.best_header_tip > block::Height(0));
let Some(start) = (if overlap_forward_range {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewer: this is the key logic where we decide on the overlapping ranges and whether to fetch roots

@p0mvn

p0mvn commented Jul 5, 2026

Copy link
Copy Markdown
Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Tip bump clears overlap anchor
    • FullBlockCommitted and NewBlockAccepted tip bumps now preserve and publish the committed header parent hash, and regression tests verify forward overlap remains anchored at the bumped tip.

Create PR

Or push these changes by commenting:

@cursor push 5427871ad0
Preview (5427871ad0)
diff --git a/zebra-network/src/zakura/header_sync/reactor.rs b/zebra-network/src/zakura/header_sync/reactor.rs
--- a/zebra-network/src/zakura/header_sync/reactor.rs
+++ b/zebra-network/src/zakura/header_sync/reactor.rs
@@ -132,8 +132,12 @@
             HeaderSyncEvent::FullBlockCommitted {
                 height,
                 hash,
-                header: _,
-            } => self.handle_full_block_committed(height, hash).await,
+                header,
+            } => {
+                let parent_hash = previous_height(height).map(|_| header.previous_block_hash);
+                self.handle_full_block_committed(height, hash, parent_hash)
+                    .await
+            }
             HeaderSyncEvent::NewBlockAccepted {
                 peer,
                 height,
@@ -444,7 +448,12 @@
         self.publish_candidate_state();
     }
 
-    async fn handle_full_block_committed(&mut self, height: block::Height, hash: block::Hash) {
+    async fn handle_full_block_committed(
+        &mut self,
+        height: block::Height,
+        hash: block::Hash,
+        parent_hash: Option<block::Hash>,
+    ) {
         self.state.pending_new_blocks.remove(&hash);
         let _ = self.state.seen.insert(hash);
         self.update_verified_block_tip(height, hash);
@@ -456,7 +465,7 @@
         // left untouched — a below-checkpoint forward range that then finds the tree behind rebuilds it
         // lazily (the single `MissingHeaderHistoryTree` reload path).
         if height > self.state.best_header_tip {
-            self.publish_best_tip(height, hash, None).await;
+            self.publish_best_tip(height, hash, parent_hash).await;
         }
         self.schedule().await;
     }
@@ -480,7 +489,8 @@
         self.state.schedule.mark_height_covered(height);
         self.cancel_covered_outstanding();
         if height > self.state.best_header_tip {
-            self.publish_best_tip(height, hash, None).await;
+            let parent_hash = previous_height(height).map(|_| block.header.previous_block_hash);
+            self.publish_best_tip(height, hash, parent_hash).await;
         }
 
         let destinations = self.eligible_tip_destinations(&peer, height);

diff --git a/zebra-network/src/zakura/header_sync/tests.rs b/zebra-network/src/zakura/header_sync/tests.rs
--- a/zebra-network/src/zakura/header_sync/tests.rs
+++ b/zebra-network/src/zakura/header_sync/tests.rs
@@ -2805,6 +2805,48 @@
     assert_no_commit_or_misbehavior(&mut fixture.actions).await;
 }
 
+#[tokio::test(flavor = "current_thread")]
+async fn full_block_commit_tip_bump_keeps_overlap_anchor() {
+    let network = regtest_network();
+    let block = mainnet_block(&BLOCK_MAINNET_1_BYTES);
+    let hash = block.hash();
+    let height = block.coinbase_height().expect("test block has height");
+    let mut fixture = spawn_test_reactor(startup_for(
+        network.clone(),
+        (block::Height(0), network.genesis_hash()),
+        None,
+    ));
+    let peer_id = peer(144);
+
+    connect_peer(&fixture, peer_id.clone()).await;
+    fixture
+        .handle
+        .send(HeaderSyncEvent::FullBlockCommitted {
+            height,
+            hash,
+            header: block.header.clone(),
+        })
+        .await
+        .unwrap();
+    advertise_tip(
+        &fixture,
+        peer_id,
+        block::Height(0),
+        block::Height(5),
+        DEFAULT_HS_RANGE,
+        1,
+    )
+    .await;
+
+    let (start_height, _count, want_tree_aux_roots) =
+        next_forward_get_headers(&mut fixture.actions).await;
+    assert_eq!(
+        start_height, height,
+        "tip bumps from full-block commits must keep overlap anchored at the bumped tip",
+    );
+    assert!(want_tree_aux_roots);
+}
+
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn inbound_unseen_valid_new_block_is_seen_and_forwarded_to_eligible_peers() {
     let network = Network::Mainnet;
@@ -4389,6 +4431,49 @@
     );
 }
 
+#[tokio::test(flavor = "current_thread")]
+async fn accepted_new_block_tip_bump_keeps_overlap_anchor() {
+    let network = regtest_network();
+    let block = mainnet_block(&BLOCK_MAINNET_1_BYTES);
+    let hash = block.hash();
+    let height = block.coinbase_height().expect("test block has height");
+    let source = peer(145);
+    let mut fixture = spawn_test_reactor(startup_for(
+        network.clone(),
+        (block::Height(0), network.genesis_hash()),
+        None,
+    ));
+
+    connect_peer(&fixture, source.clone()).await;
+    fixture
+        .handle
+        .send(HeaderSyncEvent::NewBlockAccepted {
+            peer: source.clone(),
+            height,
+            hash,
+            block: block.clone(),
+        })
+        .await
+        .unwrap();
+    advertise_tip(
+        &fixture,
+        source,
+        block::Height(0),
+        block::Height(5),
+        DEFAULT_HS_RANGE,
+        1,
+    )
+    .await;
+
+    let (start_height, _count, want_tree_aux_roots) =
+        next_forward_get_headers(&mut fixture.actions).await;
+    assert_eq!(
+        start_height, height,
+        "tip bumps from accepted NewBlock events must keep overlap anchored at the bumped tip",
+    );
+    assert!(want_tree_aux_roots);
+}
+
 /// The root-carrying regime runs *through* the last checkpoint inclusive — the VCT handoff block at
 /// `last_checkpoint` reads its own persisted root — and stops one block above. Confirming the root at
 /// `last_checkpoint` needs the `last_checkpoint + 1` header, so the range is capped there.

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit d7394a5. Configure here.

Comment thread zebra-network/src/zakura/header_sync/reactor.rs
@p0mvn

p0mvn commented Jul 5, 2026

Copy link
Copy Markdown
Author

@v12-auditor review

@v12-auditor

v12-auditor Bot commented Jul 5, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found two issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-96350 🟡 Medium
Header Tree Reload Ignores Frontier

ReadResponse::BestHeaderHistoryTree explicitly returns a frontier because the rebuilt tree can stop below the requested best_header_tip when a persisted root or header is missing. Startup handles that contract by resuming from frontier + 1 and using the frontier hash as the overlap anchor. The runtime lazy-rebuild path discards the returned frontier with { tree, .. } and sends only the requested best_header_tip and tree to the reactor. The reactor installs the tree whenever the requested tip still equals its current best_header_tip, but it does not lower or reanchor best_header_tip or best_header_parent_hash to the returned frontier. After a root gap, the next forward range still starts from the stale higher tip, fails the cached-parent-tree check, rebuilds the same lower-frontier tree, and retries indefinitely.

F-96351 🟠 High
Rootless Header Ranges Cannot Progress

The forward scheduler intentionally switches to plain header ranges by setting want_tree_aux_roots = false after the VCT/root handoff boundary. That mode has no valid non-empty response in the changed serving and wire paths. The serving driver preserves the headers and returns an empty tree_aux_roots vector when roots were not requested, but handle_header_range_response_ready() clears every non-empty response whenever want_tree_aux_roots is false. Even if that clearing were removed, the wire encoder and decoder still call validate_tree_aux_roots_len(headers.len(), tree_aux_roots.len()), while decode also rejects roots when they were not requested, so a non-empty rootless Headers frame cannot pass the protocol. Requesters treat the resulting empty response as a retry condition, so valid above-handoff headers are hidden and the same range can livelock.

And one more auto-invalidated finding.

Analyzed 19 files, diff 760e8e7...d7394a5.

@evan-forbes evan-forbes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall makes sense! so I think I'm aligned. The optional request part is still a bit murky to me.

We also have backwards sync still, we're not using that and afaiu does not work here therefore we should just disable very explicitly.

I didn't see but definitely could have missed a test to confirm that we're not disconnecting from peers who don't send us roots but might be worthwhile. This to me could just be a decoding test to make sure we don't disconnect from the peer if no roots are sent.

ai findings I'replicated. Perhaps I can just open a new PR with the tests targeting this one? can optionally add fixes but would defer to you on how we fix precisely

1. Blocker — for your question about wedging; short-served ranges permanently wedge the scheduler. I re-verified this one by hand, it's real. The serving side now truncates headers to root coverage (header_sync_driver.rs:485 — a server can never serve the root for its own header tip, since that root is deliberately unpersisted). But on the requester: a successful-but-short response commits the short prefix, prune_covered never fires because the requested end was never covered, and there is no other success-path clear_assignment (only disconnect, Local commit-failure, and timeout clear it — reactor.rs:682 is the Local-only path). The stale assignment then blocks RangeScheduler::ensure's start-height dedupe (scheduler.rs:68-75) from ever queueing that start height again. Concrete case: a follower requesting up to a header-leading peer's tip — exactly the 2-node bootstrap shape — wedges below the checkpoint. The testkit cluster can't reproduce it because its mock server serves roots for every header (no truncation).

2. Major — for your question about memory leaks; pending-commit tree install keyed on requested, not delivered, count. pending_header_history_tree (reactor.rs:1690-1704) matches on the requested range end, so every legally-short delivery discards the just-verified tree, and the next range pays a MissingHeaderHistoryTree rebuild that folds every durable root from verified tip to frontier — O(frontier) per short range, quadratic while headers race ahead of bodies. Findings 1, 2, and a pending-commits memory leak (~0.5–1 MB per leaked entry) share one root cause: requested-vs-delivered range identity. One fix covers all three — key the bookkeeping by delivered count and add a success-path clear_assignment (or cap request ends at peer_tip − 1 for root-carrying ranges).

3. Potential blocker, not 100% we could hit this as a test is difficult — legacy unverified roots are trusted wholesale. Answering your migration question directly: there is no migration for existing roots. No format-version bump (constants.rs untouched), no cleanup, no re-validation. The old code persisted every peer root unverified ("advisory until verified during block commit"); the new startup rebuild folds those rows via append_confirmed_roots, whose doc literally assumes "roots … were verified when they were persisted." One poisoned legacy root → the frontier tree is wrong → every honest forward range fails InvalidHeaderCommitment and honest peers get scored InvalidRange forever. The lazy rebuild never fires (it only triggers when the tree is behind, never when it's wrong), and a restart rebuilds the same poison. A one-time delete-range of provisional roots above the body tip on upgrade would close this cheaply.

> note: I could see how the above could bite us, but I'm also okay with just expecting folks to resync or snapshot to avoid adding a feature we will only use hopefully once

4. Low -- Opaque UX issue — unbounded startup fold. The rebuild replays the whole header lead with no span cap or progress logging. It's correctly on spawn_blocking (no runtime stall), but the fleet has seen a 3.34M-header lead — that's minutes of silent startup, per restart, and finding 2 makes the same cost recur at runtime.

5. Low -- Docs are out of sync with the code: the design doc's "Increment 6e (current)" claims the QueryBestHeaderHistoryTree reload machinery was removed, but the shipped code adds and load-bears it (it's the sole recovery path when checkpoint/legacy/gossip commits outrun the tree). Also commitment_aux_verify.rs:206/223 comments misstate the network-upgrade boundaries (says Heartwood–NU6_2 / NU6_3+; actually Heartwood–Canopy / NU5+ — the logic is right, the comments aren't).

@p0mvn

p0mvn commented Jul 5, 2026

Copy link
Copy Markdown
Author

Thanks. Trying to add tests to confirm the findings

@p0mvn

p0mvn commented Jul 5, 2026

Copy link
Copy Markdown
Author

@v12-auditor review

@v12-auditor

v12-auditor Bot commented Jul 5, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found two issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-96371 🟠 High
Stale connection teardown deregisters the live peer

The Zakura P2P supervisor refactor in handler.rs removed the monotonic conn_id generation guard (ZakuraPeerConnectionEntry, RegisteredPeerCleanupGuard, next_registration_id) and replaced the single generation-bound peer entry with parallel maps keyed only by ZakuraPeerId. When a second connection for the same peer wins duplicate arbitration (register_authenticated returns Upgraded when the newcomer's transcript hash is strictly smaller), the new Upgraded branch overwrites active_by_peer/outbound_by_peer/disconnect_by_peer/registered_at and increments active_by_ip for the newcomer, but no longer cancels the displaced incumbent's disconnect_token and no longer decrements its IP count (the pre-diff code did both). The displaced connection keeps serving; when it later exits, its teardown calls registry.remove_peer(&peer_id, caps) and supervisor.deregister(&peer_id, remote_ip) unconditionally, removing the currently-registered winner and firing header-sync PeerDisconnected for a peer whose QUIC connection and serve task are still alive. Because native_connection_transcript_hash returns the initiator node-id (remote for inbound, local for outbound), a cross-direction duplicate (a normal simultaneous-open / redial, or one an attacker induces and directionally biases with a chosen node id) reliably reaches this Upgraded path.

F-96374 🔵 Low
Rootless retry omits the empty-response backoff

Within handle_headers_for_outstanding, the two no-usable-data outcomes for a below-checkpoint root-carrying range are throttled inconsistently. The empty-headers path re-queues the range with a future deadline of Instant::now() + empty_headers_retry_delay() and clear_assignment_on_timeout: true, so re-dispatch is deferred by ~1s (reactor.rs:1108-1128). The rootless path instead calls clear_assignment + retry (which push-fronts the range) followed by an immediate schedule() (reactor.rs:1082-1084). Since the peer's assignable slot frees the instant the outstanding entry is popped and try_send_get_headers applies no outbound rate limit, the same peer is re-assigned and re-queried immediately, so the rootless loop runs at round-trip speed while the equivalent empty-response case is deliberately rate-limited to ~1/s.

And two more auto-invalidated findings.

Analyzed 22 files, diff 760e8e7...e9bc369.

p0mvn added 11 commits July 5, 2026 22:15
Robustness fixes to the below-checkpoint header-sync history-tree
reconstruction/rebuild path, from a self-audit:

- Arm the rebuild in-flight guard only when the QueryBestHeaderHistoryTree
  action is actually queued. dispatch_action is a non-blocking try_send that
  drops on a full/closed channel; arming the guard on a dropped send would
  suppress every future rebuild permanently (the guard clears only when the
  reload completes). A dropped send now leaves the guard clear so the next
  range retry re-dispatches.

- Re-base the reconstruction at the current snapshot tip when the caller's
  cached verified_block_tip has no stored tree. The finalized history tree is
  tip-only, so a concurrent checkpoint/legacy commit advancing the finalized
  tip during the round-trip made read::tree::history_tree return None, then Err,
  then a network-paced no-progress rebuild loop. Reading the tip from the same
  snapshot is atomic and is the correct committed base below the checkpoint.

- Clamp finalized_height with max() in handle_state_frontiers_changed so a
  stale or out-of-order frontier update cannot move the root-regime floor
  backward. verified_block_tip is left free (it can reorg above the checkpoint).

Adds failed_rebuild_clears_guard_and_retriggers, and updates
docs/design/verified-commitment-trees.md (6.4 robustness details, 13 the
sync.header.history_tree.rebuild counter).
…w it

The runtime header-frontier history-tree lazy rebuild discarded the frontier
that `ReadResponse::BestHeaderHistoryTree` returns, keeping only the tree
(`{ tree, .. }`) and echoing the requested `best_header_tip` back to the
reactor. When durable roots have a gap — a non-Zakura path (checkpoint/legacy
sync) committed below the checkpoint without persisting header-commit roots —
the fold stops below `best_header_tip - 1`, so the rebuilt tree never reaches
the current tip's parent. The reactor installed the low tree but left the tip
high, so every forward range re-detected the behind tree and re-triggered the
identical deterministic rebuild forever (a network-paced no-progress loop that
also re-ran the O(gap) durable-root fold each retry).

The startup path already honors the frontier contract (resume from
`frontier + 1`, anchor on the frontier hash); the runtime path did not. This
threads the frontier from where it is known (the zebrad driver, which owns the
state read) to where it is actionable (the reactor, which owns the tip):

- Add `HeaderFrontierReanchor` and carry it on `BestHeaderHistoryTreeLoaded`.
- Driver `header_frontier_reanchor` resolves the resume anchor on a gap
  (mirroring the startup resume), `None` in the common no-gap case.
- Reactor `reanchor_header_frontier` drops the tip onto the rebuilt tree,
  clearing the stranded forward schedule and repositioning tip/parent-hash,
  then reschedules so the resumed range verifies against the reinstalled tree.
- Generalize `publish_best_tip_reanchored` to take the overlap parent hash.

Adds `lazy_rebuild_reanchors_tip_onto_lower_frontier_tree` (verified to fail
without the reanchor) and the `sync.header.history_tree.reanchor` counter;
documents both in docs/design/verified-commitment-trees.md (6.4, 13).
@p0mvn
p0mvn force-pushed the roman/zakura-header-anchor-root-validation branch from 5f44263 to 46f41c6 Compare July 6, 2026 04:36
@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

@v12-auditor review

@v12-auditor

v12-auditor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found two issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-96410 🟠 High
Mismatched pending history tree

The reactor can bind a successful state commit to the wrong in-memory history tree when the same header range is fanned out to multiple peers. Each peer response inserts a PendingHeaderCommit containing that peer’s VerifiedHeaderCommitmentRoots, but HeaderRangeCommitted reports only the committed height range and hashes. pending_header_history_tree() then selects the first pending commit with matching start and end heights, without checking the committing peer, committed tip hash, or parent hash. A racing peer with an alternate valid header range for the same heights can therefore cause the node to publish the state-committed tip while installing a history tree derived from a different pending range.

F-96412 🟠 High
Plain ranges become empty

Zebra cannot serve non-empty plain header ranges after header sync stops requesting tree-aux roots. The forward scheduler sets want_tree_aux_roots to false after the root-carrying regime, and the driver then reads and returns headers with an empty root vector. Before encoding the response, the serving reactor clears every non-empty response when roots were not requested, because the encoder still requires the root count to equal the header count. The requester receives an empty Headers response, treats it as advisory-only, and retries instead of advancing the header frontier.

And one more auto-invalidated finding.

Analyzed 26 files, diff 185c30e...46f41c6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants