Skip to content

fix: handle sparse L1 reorg detector windows#359

Open
romanornr wants to merge 1 commit into
mainfrom
fix/reorg-detector-sparse-l1-blocks-codex
Open

fix: handle sparse L1 reorg detector windows#359
romanornr wants to merge 1 commit into
mainfrom
fix/reorg-detector-sparse-l1-blocks-codex

Conversation

@romanornr

Copy link
Copy Markdown
Collaborator

Why

A fresh Via external-node or verifier database can start with via_btc_watch metadata and wallet-related state while via_l1_blocks is empty or only partially populated. The reorg detector used that table as the local Bitcoin-chain checkpoint source, but the bootstrap path inserted only the current via_btc_watch height when the table was empty.

That single-row bootstrap was unsafe because reorg detection later fetched a full fixed-size canonical Bitcoin window and compared DB rows to canonical rows by iterator position. With a sparse local window ending at block 100891, the first DB row for 100891 could be compared to the first canonical row for 100792, producing a false reorg. On an external node, soft-reorg handling can then reset via_btc_watch below the wallet bootstrap block, making wallet rows invisible after restart and causing startup failures.

The runtime invariant enforced by this PR is that reorg detection must compare Bitcoin block hashes by explicit L1 height, not by positional assumptions about locally stored rows. Sparse local via_l1_blocks coverage is treated as a bootstrap/sync gap unless a stored height actually disagrees with the canonical Bitcoin hash for the same height.

What changed

  • The main-node/external-node reorg detector and verifier reorg detector now carry explicit block heights through asynchronous Bitcoin fetches and sort fetched results before use.
  • Empty via_l1_blocks bootstrap now inserts the full reorg window ending at the current via_btc_watch block instead of inserting only one row.
  • Reorg detection now builds a canonical hash lookup by block height and compares each stored DB row against the canonical hash for the same height.
  • Sparse but matching local windows are backfilled from the already fetched canonical window with ON CONFLICT DO NOTHING inserts.
  • Regression tests cover the sparse-window case where the DB contains only block 100891 while the canonical comparison window is 100792..=100891.

Performance / Complexity Impact

Operation Before After Notes
Fetch canonical window O(n) network fetches O(n) network fetches + O(n log n) sort The sort makes ordering explicit after buffer_unordered; n is bounded by the reorg window.
Compare DB rows to canonical rows O(m) positional zip O(n) map build + O(n) coverage check + O(m) DB-row comparison Avoids false comparisons when DB rows are sparse; n is bounded by the reorg window.
Empty-table bootstrap 1 block fetch / insert O(n) block fetches / inserts Only runs when via_l1_blocks is empty; creates a coherent detector window.
Sparse matching window No backfill O(n) attempted inserts Uses existing ON CONFLICT DO NOTHING; only runs when DB coverage is sparse and no reorg is detected.

The added allocations are bounded by the configured reorg window: one vector of (height, hash) pairs and one height-to-hash lookup map during detection.

Boundaries and non-goals

This PR updates source code and tests only. No live infrastructure actions, database writes, wallet state changes, Kubernetes/Helm changes, host restarts, or deployments were run from this branch.

Merging this PR can affect future main-node, external-node, and verifier images built from main. Rollout to Hetzner external nodes, verifier nodes, or any other live environment remains a separate operator approval and verification step after review.

Deployment desired state in sibling infrastructure repositories is not changed by this PR. The referenced Hetzner external-node rehearsal is operational evidence for the source bug, not proof of current live state.

How to review

Focus on the reorg detector invariants in:

  • core/node/via_main_node_reorg_detector/src/lib.rs
  • via_verifier/node/via_reorg_detector/src/lib.rs

The main review points are:

  • async block fetch ordering no longer leaks into sync or compare behavior;
  • sparse DB rows cannot compare block 100891 against canonical block 100792 by position;
  • sparse-but-matching windows are backfilled without declaring a reorg;
  • actual hash mismatches at stored heights still produce the same reorg handling path;
  • the same invariant is applied to both main-node/external-node and verifier detector implementations.

Checks run

cargo fmt --package via_main_node_reorg_detector --package via_verifier_reorg_detector
cargo test -p via_main_node_reorg_detector
cargo test -p via_verifier_reorg_detector
git diff --check

cargo test -p via_verifier_reorg_detector emitted an existing unrelated warning in via_verifier/lib/verifier_dal/src/withdrawals_dal.rs for an unused result variable.

Author checklist

  • PR title follows Conventional Commits.
  • Tests, docs, formatting, and linting were updated or marked not applicable.

Live infrastructure and deployment impact

No live infrastructure actions were run.

Merging this PR changes source and tests only and is not expected to deploy live services by itself. Future images built from main will include the corrected reorg detector behavior; live rollout remains a separate deployment approval and verification step.

Copilot AI review requested due to automatic review settings May 17, 2026 19:54
@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@romanornr has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 43 minutes before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7abb831c-d147-4c84-96fd-1783d493de13

📥 Commits

Reviewing files that changed from the base of the PR and between e744659 and 06181e4.

📒 Files selected for processing (2)
  • core/node/via_main_node_reorg_detector/src/lib.rs
  • via_verifier/node/via_reorg_detector/src/lib.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request refactors the reorg detection logic for both the main node and the verifier, introducing batch block fetching and a more robust reorg start height calculation. It also adds logic to backfill missing L1 block hashes in the database. Feedback focuses on improving maintainability by moving duplicated utility functions to a shared crate and removing a redundant validation loop in the reorg detection logic.

Comment on lines +355 to +415
fn reorg_window_start(block_height: i64, window: i64) -> i64 {
if block_height <= 1 {
block_height
} else {
block_height.saturating_sub(window.saturating_sub(1)).max(1)
}
}

fn find_reorg_start_height(
db_blocks: &[(i64, String)],
chain_blocks: &[(i64, String)],
start_height: i64,
end_height: i64,
) -> anyhow::Result<Option<i64>> {
let expected_len = (end_height - start_height + 1) as usize;
if chain_blocks.len() != expected_len {
anyhow::bail!(
"Fetched {} canonical L1 blocks for expected window {}..={}",
chain_blocks.len(),
start_height,
end_height
);
}

let chain_blocks_by_height = chain_blocks
.iter()
.map(|(height, hash)| (*height, hash.as_str()))
.collect::<HashMap<_, _>>();

for height in start_height..=end_height {
if !chain_blocks_by_height.contains_key(&height) {
anyhow::bail!("Canonical L1 block window is missing height {}", height);
}
}

if db_blocks.len() != expected_len {
tracing::warn!(
"Sparse via_l1_blocks reorg window {}..={}: found {} of {} DB rows; comparing only stored heights",
start_height,
end_height,
db_blocks.len(),
expected_len
);
}

for (db_number, db_hash) in db_blocks {
let Some(chain_hash) = chain_blocks_by_height.get(db_number) else {
anyhow::bail!(
"DB L1 block {} is outside canonical reorg window",
db_number
);
};

if *chain_hash != db_hash {
tracing::warn!("Reorg detected at block {}", db_number);
return Ok(Some(*db_number));
}
}

Ok(None)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The functions reorg_window_start and find_reorg_start_height (and their associated tests) are duplicated exactly between the main node and verifier reorg detector implementations. To improve maintainability and ensure that logic changes are applied consistently, consider moving these shared utilities to a common crate or a shared module within the workspace.

Comment on lines +384 to +388
for height in start_height..=end_height {
if !chain_blocks_by_height.contains_key(&height) {
anyhow::bail!("Canonical L1 block window is missing height {}", height);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This loop is redundant because the length of chain_blocks is already verified against the expected window size on line 370. Since chain_blocks is constructed from a continuous range of heights in fetch_blocks, a correct length guarantees that all heights in the window are present.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens Via’s Bitcoin L1 reorg detection logic against sparse/partially-populated via_l1_blocks tables (common on fresh external-node / verifier DBs) by ensuring comparisons are performed by explicit L1 height rather than by positional zipping of DB rows with canonical windows.

Changes:

  • Carry (height, block) through async Bitcoin block fetches and explicitly sort results after buffer_unordered.
  • Bootstrap an empty via_l1_blocks table by inserting the full configured reorg window (ending at the current via_btc_watch height) instead of a single row.
  • Compare stored DB block hashes to canonical hashes by height, and backfill missing-but-matching window entries via ON CONFLICT DO NOTHING inserts; add regression tests for sparse-window behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
via_verifier/node/via_reorg_detector/src/lib.rs Verifier reorg detector now fetches/sorts blocks with heights, compares DB↔canonical by height, bootstraps/backfills full windows, and adds sparse-window regression tests.
core/node/via_main_node_reorg_detector/src/lib.rs Main-node/external-node reorg detector applies the same height-keyed canonical comparison and full-window bootstrap/backfill behavior, plus matching regression tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +355 to +361
fn reorg_window_start(block_height: i64, window: i64) -> i64 {
if block_height <= 1 {
block_height
} else {
block_height.saturating_sub(window.saturating_sub(1)).max(1)
}
}
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