Skip to content

fix: compare reorg windows by l1 height#360

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

fix: compare reorg windows by l1 height#360
romanornr wants to merge 1 commit into
mainfrom
fix/reorg-detector-sparse-l1-blocks-pi

Conversation

@romanornr

Copy link
Copy Markdown
Collaborator

Why

A fresh Via external-node or verifier database can contain via_btc_watch bootstrap metadata before via_l1_blocks has dense local coverage. The existing reorg detector bootstrap inserted only the current watched Bitcoin block when via_l1_blocks was empty, then detect_reorg() fetched a fixed reorg window from the DB and compared those rows to the canonical Bitcoin window by vector position.

That position-based comparison is unsafe when the local DB window is sparse: a locally stored block at the bootstrap height can be compared against the first canonical block in the window instead of the canonical block with the same height. During fresh external-node bootstrap, this can falsely classify sparse local state as a Bitcoin reorg and trigger soft-reorg handling that rewinds via_btc_watch below the bootstrap block. The runtime invariant should be that reorg detection compares Bitcoin block hashes by explicit L1 height, and sparse local coverage must not shift identities by row position.

This source change addresses both the main-node/external-node detector and the verifier detector because they shared the same bootstrap and comparison pattern.

What changed

  • Empty via_l1_blocks bootstrap now seeds the full configured reorg window ending at the current via_btc_watch block instead of inserting only one row.
  • Reorg detection now maps canonical block hashes by explicit Bitcoin height and compares DB rows against the same height.
  • Sparse DB windows are logged and compared only for heights present locally, preventing block 100891 from being compared against canonical block 100792 by position.
  • Concurrent Bitcoin block fetches now restore height ordering before returning results.
  • Regression tests cover the sparse-window case and mismatch-at-explicit-height behavior in both detector crates.

Performance / Complexity Impact

Operation Before After Notes
Initial empty-table seed O(1) fetch/insert O(w) fetch/insert w is the configured reorg window; this happens only when via_l1_blocks is empty.
Canonical fetch result handling O(w) O(w log w) Results from buffer_unordered are sorted by height to preserve ordering.
Reorg comparison O(n) O(n) DB rows are still scanned once; comparison indexes directly by explicit height within the canonical window.

The runtime path allocates one Vec<(height, hash)> for the canonical window comparison. The window is bounded by reorg_window, so memory growth remains bounded by existing configuration.

Boundaries and non-goals

This PR changes source and tests only. No live infrastructure actions, database mutations, deployments, restarts, Kubernetes/Helm changes, secret reads, or wallet-state changes were run from this branch.

Merging this PR can affect future main-node, external-node, and verifier images built from main; rollout to live environments remains a separate operator action after review and approval.

How to review

Focus review on:

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

Check that the detector no longer relies on vector-position identity for sparse DB windows, that the bootstrap window insertion is bounded by reorg_window, and that the main-node/external-node and verifier implementations preserve the same invariant.

Checks run

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

zkstack dev lint and local secret scanning were not run.

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/tests only and is not expected to deploy live services by itself. Future rollout to external-node, main-node, or verifier environments requires separate deployment approval and verification.

Copilot AI review requested due to automatic review settings May 17, 2026 20:21
@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 15 minutes and 32 seconds 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: e9323632-c25c-4f26-8eb8-41b3f22c2ac3

📥 Commits

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

📒 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.

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 fixes reorg detection correctness during bootstrap / sparse via_l1_blocks coverage by ensuring comparisons are done by explicit Bitcoin L1 height (not vector position) and by seeding an empty via_l1_blocks table with the full configured reorg window.

Changes:

  • Seed via_l1_blocks with the full reorg_window ending at the current via_btc_watch height when the table is empty.
  • Compare DB vs canonical Bitcoin hashes by explicit height (and warn when the DB window is sparse), preventing sparse-window misalignment false positives.
  • Preserve height ordering for concurrently fetched Bitcoin blocks by sorting fetch results before returning.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
core/node/via_main_node_reorg_detector/src/lib.rs Seeds full window on empty DB, sorts concurrent fetch results by height, and compares reorg windows by explicit L1 height (plus unit tests).
via_verifier/node/via_reorg_detector/src/lib.rs Mirrors the main-node/external-node detector fixes for verifier: full-window seeding, height-ordered fetch results, explicit-height comparison (plus unit tests).

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

@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 enhances the reorg detection logic in both the main node and verifier by introducing helper functions to handle sparse L1 block windows and ensuring fetched blocks are sorted by height. It also updates the initialization process to populate the database with a window of blocks using transactions. Feedback was provided regarding the duplication of these new helper functions across different modules, suggesting they be moved to a shared utility crate to adhere to DRY principles.

Comment on lines +14 to +76
fn chain_hashes_by_height(start_height: i64, chain_blocks: &[Block]) -> Vec<(i64, String)> {
(start_height..)
.zip(chain_blocks)
.map(|(height, block)| (height, block.block_hash().to_string()))
.collect()
}

fn is_sparse_l1_block_window(
db_blocks: &[(i64, String)],
start_height: i64,
end_height: i64,
) -> bool {
let expected_len = (end_height - start_height + 1) as usize;
db_blocks.len() != expected_len
|| db_blocks
.windows(2)
.any(|blocks| blocks[1].0 != blocks[0].0 + 1)
|| db_blocks
.first()
.is_some_and(|(height, _)| *height != start_height)
|| db_blocks
.last()
.is_some_and(|(height, _)| *height != end_height)
}

fn find_reorg_start_block(
db_blocks: &[(i64, String)],
chain_hashes: &[(i64, String)],
) -> anyhow::Result<Option<i64>> {
let Some((start_height, _)) = chain_hashes.first() else {
anyhow::bail!("Cannot detect reorg against an empty canonical chain window");
};
let Some((end_height, _)) = chain_hashes.last() else {
anyhow::bail!("Cannot detect reorg against an empty canonical chain window");
};

let mut previous_height = start_height - 1;
for (db_height, db_hash) in db_blocks {
if db_height < start_height || db_height > end_height {
anyhow::bail!(
"DB L1 block {db_height} is outside canonical comparison window {start_height}..={end_height}"
);
}
if *db_height <= previous_height {
anyhow::bail!("DB L1 blocks are not strictly ordered by height");
}

let chain_index = (*db_height - start_height) as usize;
let (chain_height, chain_hash) = &chain_hashes[chain_index];
if chain_height != db_height {
anyhow::bail!(
"Canonical L1 block window is not indexed by expected height {db_height}; found {chain_height}"
);
}
if chain_hash != db_hash {
return Ok(Some(*db_height));
}

previous_height = *db_height;
}

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 helper functions chain_hashes_by_height, is_sparse_l1_block_window, and find_reorg_start_block are duplicated exactly in via_verifier/node/via_reorg_detector/src/lib.rs. To improve maintainability and adhere to the DRY (Don't Repeat Yourself) principle, consider moving these functions to a shared utility module (e.g., within via_btc_client or a common library crate) that both detectors can utilize.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7da86629c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +184 to +190
let start_height = block_height
.saturating_sub(self.config.reorg_window() - 1)
.max(1);
let blocks = self.fetch_blocks(start_height, block_height).await?;

let mut transaction = storage.start_transaction().await?;
for (height, block) in (start_height..=block_height).zip(blocks) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle zero bootstrap height during init

If via_btc_watch metadata is absent or reset to 0, get_last_processed_l1_block() returns 0, so this path computes start_height = 1 and then zips (1..=0) with fetched blocks, inserting nothing into via_l1_blocks. On the next tick, detect_reorg() aborts with "No blocks found to detect reorg", which prevents sync_l1_blocks() from ever repopulating state; the service stays in a permanent error loop instead of recovering. The same regression pattern is present in the verifier detector init path as well.

Useful? React with 👍 / 👎.

Comment on lines +163 to +169
let start_height = block_height
.saturating_sub(self.config.reorg_window() - 1)
.max(1);
let blocks = self.fetch_blocks(start_height, block_height).await?;

let mut transaction = storage.start_transaction().await?;
for (height, block) in (start_height..=block_height).zip(blocks) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard verifier init against empty bootstrap range

When via_btc_watch last processed height is 0 (missing metadata or restart-to-zero), this computes start_height = 1 and calls fetch_blocks(1, 0), producing an empty set that inserts no via_l1_blocks rows. init() then succeeds, but every loop iteration fails in detect_reorg() with "No blocks found to detect reorg", so the verifier reorg detector never reaches sync_l1_blocks() to recover automatically.

Useful? React with 👍 / 👎.

Comment on lines +43 to +45
let Some((start_height, _)) = chain_hashes.first() else {
anyhow::bail!("Cannot detect reorg against an empty canonical chain window");
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Don’t error on empty canonical window at height zero

This new guard turns an empty canonical fetch window into a hard error, which breaks recovery when the last stored L1 block is 0 (a state the previous code tolerated). In that case detect_reorg() computes start_height = 1, fetches no chain blocks, then bails here before sync_l1_blocks() can append blocks 1..N; prior logic simply skipped comparison and allowed syncing to proceed. After upgrading with existing via_l1_blocks row 0, the detector can get stuck logging errors instead of self-healing.

Useful? React with 👍 / 👎.

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