fix: repoint canonical index on BSC reorg - #6898
Conversation
|
🤖 Kimi Code ReviewThis PR fixes a critical consensus bug where the canonical number→hash index was not updated for reorged heights during per-block canonicalization (BSC-style import). This caused Overall Assessment: Correct and necessary fix. The logic properly walks back to the common ancestor to repoint all affected heights, mirroring the existing Specific Feedback
|
🤖 Codex Code ReviewFindings
Aside from that, the branch-repointing change and the regression test look good: they address the stale I could not run the targeted test locally because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewI now have enough context for a thorough review. Here it is: PR Review: fix: repoint canonical index on BSC reorgOverviewThis PR fixes a real, production-confirmed bug in the BSC block-import path. When a heavier chain reorgs heights at or below the current head and then extends past it, The approach is sound, the root-cause analysis is correct, and the test is solid. CorrectnessThe happy path is correct. The Silent fallback to broken behavior in two places. // blockchain.rs:1953-1960
let new_canonical_blocks = match self.storage.get_block_header_by_hash(hash)? {
Some(header) => {
crate::fork_choice::find_link_with_canonical_chain(&self.storage, &header)
.await?
.unwrap_or_default() // ← covers Ok(None): not linked to canonical chain
}
None => Vec::new(), // ← header not found in DB
};
Suggestion: Replace the two silent fallbacks with at least a None => {
warn!(hash=%hash, number=%number, "advance_canonical_head: header not in store, falling back to head-only");
Vec::new()
}and .unwrap_or_else(|| {
warn!(hash=%hash, number=%number, "advance_canonical_head: block not linked to canonical chain, falling back to head-only");
Vec::new()
})Error handling
Visibility changeMaking Test
The test correctly models the BSC import sequence. It fails before the fix and passes after. The two assertions (forward: chain B is canonical; reverse: chain A is gone) are exactly right. One minor note: the test comment says "Reorgs heights 1-3 (<= head)" — worth making explicit in a comment that heights 1–3 are no-ops when processed individually (due to the SummaryThe fix is correct and well-tested. Two issues worth addressing:
Neither issue affects correctness under the normal production path the PR targets. Automated review by Claude (Anthropic) · sonnet · custom prompt |
Lines of code reportTotal lines added: Detailed view |
Greptile SummaryThis PR fixes a BSC-specific bug where the canonical
Confidence Score: 5/5Safe to merge. The change is a targeted, well-tested fix that adds one extra DB header lookup per BSC block import on the forward-sync path, with no impact on the L1 engine-API or L2 paths. The fix is mechanically equivalent to the already-audited apply_fork_choice path: it reuses find_link_with_canonical_chain without altering its logic, passes the branch to the same forkchoice_update_inner call, and preserves the number <= latest guard that prevents concurrent-advance races. The regression test recreates the exact real-world scenario (BSC mainnet block 105536921) and exercises both positive and negative invariants. Fallback behaviour when the header or link is absent is identical to the pre-fix code. No unrelated code is touched. No files require special attention. The only structural change is in crates/blockchain/blockchain.rs; the other two files are a one-line visibility tweak and a new test.
|
| Filename | Overview |
|---|---|
| crates/blockchain/blockchain.rs | Core fix: advance_canonical_head now walks back to the common canonical ancestor before calling forkchoice_update, correctly repointing every reorged height instead of only the new head. Fallback to Vec::new() (head-only) when the header/link is unavailable preserves the original behaviour for blocks not yet in storage. |
| crates/blockchain/fork_choice.rs | One-line visibility change: find_link_with_canonical_chain is widened from private to pub(crate) to allow reuse from blockchain.rs. No logic changes. |
| test/tests/blockchain/smoke_tests.rs | Adds advance_canonical_head_repoints_reorged_heights: a well-scoped regression test that recreates the exact BSC scenario (per-block canonicalization with a heavier fork), asserting both positive (chain B canonical) and negative (chain A stale hashes gone) invariants. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant BSC as BSC Sync / NewBlock
participant ACH as advance_canonical_head
participant FLCC as find_link_with_canonical_chain
participant FCU as forkchoice_update_inner
BSC->>ACH: "(number=4, hash=B4)"
ACH->>ACH: "latest=3, number>latest → proceed"
ACH->>ACH: get_block_header_by_hash(B4) → header
ACH->>FLCC: find_link_with_canonical_chain(B4_header)
Note over FLCC: Walk B4→B3→B2→B1→genesis<br/>Collecting non-canonical pairs
FLCC-->>ACH: Ok(Some([(3,B3),(2,B2),(1,B1)]))
ACH->>FCU: "forkchoice_update([(3,B3),(2,B2),(1,B1)], head=4,B4)"
Note over FCU: Write B3,B2,B1 to canonical index<br/>Delete entries above head (none)<br/>Write B4 as head
FCU-->>ACH: Ok(())
Note over ACH: Heights 1-4 all point to chain B ✓
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant BSC as BSC Sync / NewBlock
participant ACH as advance_canonical_head
participant FLCC as find_link_with_canonical_chain
participant FCU as forkchoice_update_inner
BSC->>ACH: "(number=4, hash=B4)"
ACH->>ACH: "latest=3, number>latest → proceed"
ACH->>ACH: get_block_header_by_hash(B4) → header
ACH->>FLCC: find_link_with_canonical_chain(B4_header)
Note over FLCC: Walk B4→B3→B2→B1→genesis<br/>Collecting non-canonical pairs
FLCC-->>ACH: Ok(Some([(3,B3),(2,B2),(1,B1)]))
ACH->>FCU: "forkchoice_update([(3,B3),(2,B2),(1,B1)], head=4,B4)"
Note over FCU: Write B3,B2,B1 to canonical index<br/>Delete entries above head (none)<br/>Write B4 as head
FCU-->>ACH: Ok(())
Note over ACH: Heights 1-4 all point to chain B ✓
Reviews (1): Last reviewed commit: "fix: repoint canonical index on BSC reor..." | Re-trigger Greptile
Problem
The BSC block-import paths (
p2p/rlpx/connection/server.rsNewBlock import andp2p/sync/full.rsforward sync) canonicalize each block viaBlockchain::advance_canonical_head, which:number <= latest, andvec toforkchoice_update.forkchoice_update_innerwrites only the pairs it's handed and deletes entries above the head — it never rewrites heights between the common ancestor and the head. So when a heavier chain reorgs blocks at heights<= the current headand then extends past it, the reorged heights are stored (by hash) but theirblock_number -> canonical_hashindex entries are never repointed: they keep pointing at the reorged-out fork.BLOCKHASH/get_block_hashread that by-number index, so they return stale hashes for the reorged heights. Any contract readingBLOCKHASHof such a height (e.g. blockhash-based randomness) then diverges from canonical execution.This wedged a BSC mainnet node at block 105536921: blocks 105536905–105536909 had a stale canonical index after a 5-block reorg; tx 205 (
settle(betId)) readBLOCKHASHof a reorged height, computed the wrong outcome, skipped its payout, and produced a block gas total 49,559 short of the header — rejected on every retry.The L1 engine-API and L2 paths are unaffected: they canonicalize through
apply_fork_choice->find_link_with_canonical_chain, which already rewrites the whole branch.Fix
advance_canonical_headnow mirrorsapply_fork_choice: when the new head extends the chain (number > latest), it walks back to the common canonical ancestor viafind_link_with_canonical_chainand hands the whole branch toforkchoice_update, so every reorged height is repointed — not just the head. Thenumber <= latestguard (which avoids rewinding a concurrently-advanced head) is preserved, and it falls back to head-only if the header/link can't be found.find_link_with_canonical_chainis nowpub(crate).Linear-extension overhead: one extra header lookup + one
is_canonicalcheck.Test
advance_canonical_head_repoints_reorged_heights(test/tests/blockchain/smoke_tests.rs): canonicalizes chain A viaadvance_canonical_head, then imports a heavier chain B that reorgs heights 1–3 and extends to 4, and asserts every chain-B height (including the reorged ones) is canonical. Fails before the fix ("height 1 should be canonical after reorg"), passes after. All 7 smoke tests pass; clippy + fmt clean.