Skip to content

reindex: replay the most-work branch, not every reachable block - #491

Open
bkeroack wants to merge 7 commits into
masterfrom
fix/reindex-side-chain-connect
Open

reindex: replay the most-work branch, not every reachable block#491
bkeroack wants to merge 7 commits into
masterfrom
fix/reindex-side-chain-connect

Conversation

@bkeroack

Copy link
Copy Markdown
Contributor

The bug

-reindex rebuilds the block index and UTXO set by replaying blk*.dat. Those files are a block tree, not a chain — Core and satd both persist every block they fully receive, including ones a later reorg orphaned — so any datadir that has been live through a reorg has fork points on disk.

The replay (reindex_from_flat_files) walked that tree breadth-first and connected every genesis-reachable block as if it extended the tip, with no chainwork comparison and no reorg handling:

let height = parent.height + 1;
let batch = connect::connect_block(...)?;   // no best-chain selection
self.store.write_batch(batch)?;
{ let mut tip = self.tip.write(); tip.hash = hash; tip.height = height; }

At the first fork, two outcomes:

  • Loud. Branches spending a coin in common: the losing block connects first (it is valid on top of its parent), the winning block is then handed an already-spent input, and the reindex aborts with bad-txns-inputs-missingorspent.
  • Silent, and worse. Branches that don't conflict: the losing block connects successfully, applying its coinbase and spends on top of the winning chain, and the reindex runs to completion reporting success over a UTXO set that no longer matches consensus.

How it was found

A mainnet -reindex against a Bitcoin Core blocks/ dir (the migration path CORE_DIFFERENCES.md points operators at) ran for three days and died at height 916308 — the first fork point in the files, where stale …056ea and main-chain …fae7 share parent …f3222fa4ad88. A full scan of the 5661 copied files found 959,048 genesis-reachable blocks, zero corrupt records, and 17 fork points, all between 916308 and 956336 — i.e. from the height where that node finished IBD and went live onward. The block data was fine; the traversal was not.

The fix

Replace the tree BFS with an explicit plan (plan_reindex_chain):

  • Select the branch with the most cumulative chainwork — not depth. Depth agrees with work on mainnet but is the wrong metric across a difficulty transition. Equal work keeps the branch seen first in flat-file order, the reindex analogue of the consensus first-seen rule.
  • Order it genesis→tip and connect only that branch. connect_block now always receives a block whose parent is the current tip.
  • Blocks off the branch are written to the block index as DataStored and never connected, so getblockheader on an orphaned hash keeps working across a reindex and the branch stays available to find_best_valid_tip if later extended. PoW is re-checked before indexing one; no height_hash entry is ever written for it (that's fix(chain): never let header acceptance clobber the active height index (951k root cause) #322's active-chain pollution reached through a different door).
  • Duplicate records for the same block are collapsed at scan time. A repeated children edge re-walked that block's entire subtree once per copy — the production datadir had three duplicate genesis records.

reindex_chainstate (-reindex-chainstate) replays by height along the existing block index and was never affected.

Tests

reindex_from_flat_files had zero test coverage, which is why this shipped. Added:

Four end-to-end cases driving the real replay over real flat files:

  • ..._ignores_non_conflicting_stale_sibling — the silent-corruption half
  • ..._survives_conflicting_stale_sibling — the production abort, reproduced at height 102 with a mature-coinbase double spend
  • ..._follows_the_reorg_winner — winning branch written to disk after the abandoned one
  • ..._tolerates_duplicate_records — duplicate copies must not double-apply
  • plus ..._honors_stop_at_height_without_indexing_side_chain

All four end-to-end cases were verified to fail against the old traversal — two with Connect(MissingOrSpentInput) (the production error), two on the corrupted UTXO set — so they're real regression tests.

Four planner unit tests replace the two reachable_tip_height tests: orphans/short forks, work-beats-depth, equal-work first-seen, genesis-only.

cargo test --workspace green; cargo clippy --workspace --all-targets clean.

Operator impact

Anyone who ran -reindex on 0.4.x or earlier against a datadir that had seen a reorg should re-run it. An aborted reindex left nothing usable; a "completed" one may have a UTXO set that silently disagrees with consensus. Noted in the 0.5.0 release notes and upgrade notes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv

@bkeroack

Copy link
Copy Markdown
Contributor Author

Follow-up: audit of every block-selection path (2nd commit)

Asked whether the first commit was a durable fix or a bandaid, so I audited every path that picks the next block to connect:

path picks next block by enforced prev == running tip?
IBD connect_stored_block hash, caller-driven yes, always has
accept_block hash, real chainwork/reorg logic yes
background.rs own cursor, separate chain n/a (own invariant)
reindex_from_flat_files tree BFS → most-work path fixed in commit 1, but nothing enforced it
reindex_replay (-reindex-chainstate) height→hash index no

The last row is the same corruption class and was still live. The height→hash index is derived state, and it has been observed polluted with a fork block in production (#322, and the bad-cb-height reindex loop that followed). Given such an index, reindex_replay connected the fork block, carried on with the main chain on top of it, and returned Ok — a successful -reindex-chainstate over a UTXO set assembled from two branches, with nothing in the log to indicate it.

That isn't hypothetical: the new test reindex_chainstate_refuses_a_spliced_branch was run against the unguarded code and the reindex completes:

panicked at: a spliced replay must fail, not complete: ()

Fix

require_extends_tip, called from all three reindex connect paths. Same invariant and same error as the IBD path — deliberately placed on the connect rather than inside any one selection strategy, so a future bug in either picker surfaces loudly before it can touch the UTXO set. On the flat-file path it now holds by construction (the plan walks parent pointers); the assert is there so a change to the planner can't quietly reintroduce a side-chain connect.

Explicitly not claimed

A height index naming a complete, self-consistent alternative branch would still be replayed faithfully — catching that needs chainwork-based selection inside reindex_replay, a larger change. The guard covers the shape actually observed: a fork block spliced into an otherwise-correct index.

Regression protection

Every new test was verified to fail against the pre-fix code, not just pass against the new code:

test pre-fix failure
..._ignores_non_conflicting_stale_sibling stale coinbase leaked into the UTXO set
..._survives_conflicting_stale_sibling Connect(MissingOrSpentInput)
..._follows_the_reorg_winner tip height 11 instead of 8
..._tolerates_duplicate_records duplicate subtree walk
reindex_chainstate_refuses_a_spliced_branch reindex returned Ok over a spliced UTXO set

cargo test --workspace green; cargo clippy --workspace --all-targets clean.

@bkeroack

Copy link
Copy Markdown
Contributor Author

Third commit: chainwork selection inside reindex_replay — the gap from the last comment is now closed

The previous comment flagged what the guard did not cover:

A height index naming a complete, self-consistent alternative branch would still be replayed faithfully — catching that needs chainwork selection inside reindex_replay, a larger change.

That's now done, and it also upgrades the polluted-index case from refuses to start to rebuilds correctly.

What changed

New chain::replay_plan derives the replay chain from the block index rather than the height→hash index. The block index is self-authenticating where it counts: every entry carries the block header, and a header names its parent by hash. Selection picks the most-work fully-connectable branch and recomputes cumulative chainwork and height from those headers — so neither the height→hash index nor the entries' own chainwork/height fields can misdirect the replay.

Block status is honored during selection, so invalidateblock survives a chainstate reindex (invalid block + all descendants excluded), and a header-only hole in a branch's ancestry demotes that branch instead of wedging the replay.

Resolution is a memoized walk to genesis — linear in block count. find_best_valid_tip can't be reused: its per-candidate is_connectable walks back to the active chain, which during a reindex is genesis, making it quadratic.

The non-obvious part: the prefetcher's MTP

The plan is also threaded into the prefetcher. This is load-bearing, not tidiness. The prefetcher runs ahead of the connect cursor, so for the heights it's working on, the height index still holds the pre-reindex chain. Once the replayed chain can differ from what the index names — which is the entire point of this commit — leaving compute_mtp on the index would hand the connect thread an MTP computed over the wrong branch. MTP gates BIP113 locktimes.

The IBD caller passes None: there the index is authoritative, written forward as blocks connect and guarded by connect_stored_block.

Smaller items

  • Refuse to resume a partial chainstate that isn't on the selected branch (rather than building one UTXO set from two chains).
  • Flush before planning, so for_each_block_index — which reads through the coin cache to the backing store — can't miss an entry still in the dirty cache.
  • max_indexed_height removed rather than left as a trap: a height→hash probe whose only remaining caller was the replay's progress total. The plan's tip height replaces it.

Tests

Seven planner unit tests: work-beats-depth, forged chainwork/height fields ignored, invalid subtree excluded, header-only gap demotes a branch, deterministic hash tie-break, orphan strand, genesis-only.

Two end-to-end changes:

  • reindex_chainstate_ignores_a_polluted_height_index — replaces the previous "refuses a spliced branch" test. A polluted entry is now simply irrelevant: the replay completes on the correct branch, the stale coinbase never enters the UTXO set, the coin count matches the pre-reindex set, and the bad height entry is rewritten. Verified to fail against the height-index picker (BadPrevBlock).
  • reindex_chainstate_refuses_to_resume_onto_a_different_branch — cross-branch resume fails closed.

cargo test --workspace green; cargo clippy --workspace --all-targets clean.

Remaining known limit

Selection needs the block index to be present and readable. If the index itself is missing entries (not merely wrong), the affected branches are excluded and a full -reindex from the block files is the recovery path — which is what the error message says.

@bkeroack

bkeroack commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Deep review — five independent reviewers

Lenses: selection algorithm / UTXO-consensus safety / side-chain indexing & regression / scale & ops / adversarial test review. Everything acted on below I verified myself first.

Two definite bugs, fixed in cedcc432

Both are in the guards this PR added, not the selection it rewrote. The convergence was unusual: 4 of 5 reviewers found the first independently, 3 of 5 the second.

1. The tie-break incumbent was read from the index this PR exists to distrust.
state.rs re-derived it with get_block_hash_by_height(required_height) — the height→hash index replay_plan's own module doc calls "observed polluted with a fork block in production (#322)" — at exactly the height where that pollution was seen. The incumbent wins an exact chainwork tie, so a polluted row handed the win to an equal-work stale sibling and rebuilt the entire chainstate onto the orphan. The inverse of this PR's purpose, through the one input that wasn't hardened.

The authoritative hash was already in hand and discarded: main.rs read store.get_tip() and kept only .height. Now threads (hash, height).

Reproduced before fixing — with the old lookup the replay lands on the sibling:

assertion `left == right` failed: the tie-break must follow the authoritative tip, not the polluted row
  left: 584274ea…  (the polluted row's sibling)
 right: 6f436e71…  (the authoritative tip)

New regression test reindex_chainstate_tie_break_ignores_a_polluted_height_index. The existing polluted-index test pollutes a mid-chain height, where the loser is excluded on work and the incumbent never gets a say — only a tie at the tip exercises this.

2. The fail-closed floor destroyed its own precondition, so it could only fire once.
required_height came from a tip that clear_chainstate() then dropped, and was never persisted. The retry — an operator re-running, or Restart=always in contrib/systemd/satd.service bringing it back within seconds — reads the genesis tip the failed run left behind, computes a floor of 0, and passes. Replay connects nothing, reports success, and the node serves height 0 with an empty UTXO set while clear_chainstate has already stamped the tx/address/filter indexes complete: Electrum and Esplora answer "no history" for every address. Precisely what the guard's comment says it prevents, one restart away.

The check now runs before the clear, on the intact block index. Nothing is destroyed until the replay is known possible, so a retry re-runs it identically. Costs one extra block-index scan on a job measured in minutes to hours.

Also fixed

  • Verify the block read at flat_pos is the block the plan selected. A damaged block index — the thing -reindex-chainstate repairs — can point at a different well-formed record. Nothing noticed: connect_block would write that block's hash, height row and UTXO delta while the caller set the in-memory tip to the plan's hash, and the next require_extends_tip compares against the in-memory tip and passes. Split-brain to completion.
  • Log which chain was selected, both paths. The failure this PR fixes was "it replayed the wrong chain", and diagnosing it took a three-day run plus a custom scan of 5661 files because nothing ever said which branch was chosen.
  • Name block/height/flat-position on read failure.
  • Two false doc claims corrected. The flat-file tie-break is not first-seen — BFS dequeues in height order, so on an exact tie the shallower tip wins and scan order only decides within a height (the two planners can therefore disagree on such a tie). And index_reindex_side_chain claimed "the reorg path validates before connecting"; it does not — reorg_to goes through connect_block, which checks neither PoW nor difficulty.

Filed rather than fixed here

Correction to the PR description: "All four end-to-end cases were verified to fail against the old traversal" is wrong for tolerates_duplicate_records. Master's loop already did header_by_hash.remove(&hash) then continue on the second copy. The other three do genuinely fail against the old code.

Confirmed clean

Chainwork accumulation matches connect_block exactly (so a reindexed node cannot pick a different tip than a synced one); no cycles, no recursion, no OOM — the scan dedups before inserting the child edge, making the genesis-reachable subgraph provably a tree; determinism holds on both paths; memory figures in the doc comments are arithmetically correct (~518 MB flat-file peak, and the new drop(children) is a net improvement for the connect phase); undo data is written for every replayed block; the batch is atomic across all CFs; P2P cannot start mid-replay; no path writes a height_hash row for an off-branch block (verified against the complete set of writers, not the PR description); DataStored is the correct status and find_best_valid_tip accepts these entries; the prefetcher's compute_mtp(plan) change is necessary and correctly scoped, and the asymmetry with the direct path is right rather than a bug.

cargo clippy --workspace --all-targets and cargo test --workspace clean.

bkeroack and others added 5 commits August 2, 2026 09:48
`-reindex` rebuilds the chain by replaying `blk*.dat`. Those files are a
block tree, not a chain: Core and satd both persist every block they fully
receive, so any datadir that has been live through a reorg has fork points
on disk. The replay BFS'd that tree and connected every genesis-reachable
block as if it extended the tip, with no chainwork comparison and no reorg
handling. At the first fork:

  * if the two branches spent a coin in common, the losing block connected
    first and the winning block was handed an already-spent input — the
    reindex aborted with `bad-txns-inputs-missingorspent`;
  * if they did not conflict, the losing block connected *successfully*,
    applying its UTXO delta on top of the winning chain, and the reindex
    reported success over a UTXO set that no longer matched consensus.

Found on a mainnet `-reindex` over a Core blocks dir: it ran for three days
and died at height 916308, the first fork point on disk — which on a node
that IBD'd and then went live is right where it started seeing real-time
reorgs.

Replace the tree BFS with an explicit plan: pick the branch with the most
cumulative chainwork (not depth — depth agrees on mainnet but is wrong
across a difficulty transition), order it genesis→tip, and connect only
that. Blocks off the branch are written to the block index as `DataStored`
and never connected, so `getblockheader` on an orphaned hash keeps working
across a reindex and the branch stays available to `find_best_valid_tip`
should it later be extended. PoW is re-checked before indexing one, and no
height→hash entry is ever written for it (#322's pollution, reached
through a different door). Duplicate records for the same block are now
collapsed at scan time — a repeated `children` edge re-walked that block's
whole subtree per copy.

`reindex_chainstate` replays by height along the existing block index and
was never affected.

Tests: `reindex_from_flat_files` had none. Adds four end-to-end cases over
real flat files (non-conflicting stale sibling, double-spending stale
sibling, reorg winner written second, duplicate records) plus `stop_at`,
and four planner unit tests. All four end-to-end cases fail against the
old traversal — two with `MissingOrSpentInput`, two on the corrupted UTXO
set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
…hain

Audit of every path that picks the next block to connect, prompted by
asking whether the branch-selection fix was durable or a bandaid:

  * IBD `connect_stored_block`  — picks by hash, and has always enforced
                                  `prev_blockhash == tip` before connecting.
  * `accept_block`              — real chainwork/reorg logic.
  * `background.rs`             — its own cursor over a separate chain.
  * `reindex_from_flat_files`   — fixed in the previous commit, but nothing
                                  enforced the result.
  * `reindex_replay`            — picks by height→hash and enforced nothing.

That last one is the same corruption class, still live. The height index is
derived state and has been observed polluted with a fork block (#322, and
the `bad-cb-height` reindex loop after it). Given such an index the replay
connected the fork block, continued with the main chain on top of it, and
returned Ok — a "successful" `-reindex-chainstate` over a UTXO set built
from two branches, with nothing in the log to say so. Confirmed by running
the new test against the unguarded code: it completes.

Add `require_extends_tip` and call it from all three reindex connect
paths. On the flat-file path it holds by construction (the plan walks
parent pointers) and exists so a future change to the planner cannot
quietly reintroduce a side-chain connect; on the chainstate path it is
load-bearing. Same invariant, same error, as the IBD path — the fail-closed
guard is on the connect rather than on any one selection strategy.

Not claimed: a height index naming a complete, self-consistent alternative
branch replays that branch. Catching that needs chainwork-based selection
in `reindex_replay`, which is a larger change; the guard covers the
observed shape (a fork block spliced into an otherwise-correct index).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
Completes the fix. The previous commit added a linkage guard so a replay
misdirected by a polluted height→hash index fails loudly instead of
committing a spliced UTXO set. It stopped short of the real problem: a
damaged index still decided which chain `-reindex-chainstate` replayed, so
the outcome was a refusal to start rather than a correct rebuild — and an
index naming a complete, self-consistent alternative branch was replayed
without complaint.

Derive the chain from the block index instead, which is self-authenticating
where it counts: every entry carries the block header, and a header names
its parent by hash. `chain::replay_plan` selects the most-work
fully-connectable branch and recomputes cumulative chainwork and height
from those headers, so neither the height→hash index nor the entries' own
`chainwork`/`height` fields can misdirect the replay. Status is honored
during selection, so `invalidateblock` survives a chainstate reindex and a
header-only hole in a branch's ancestry demotes it rather than wedging the
replay.

Resolution is a memoized walk to genesis, linear in the block count rather
than quadratic in chain length — `find_best_valid_tip` cannot be reused
here because its per-candidate `is_connectable` walks back to the active
chain, which during a reindex is genesis.

The plan also feeds the prefetcher, including its MTP computation. That
matters: the prefetcher runs ahead of the connect cursor, so for the
heights it is working on the height index still holds the pre-reindex
chain. Left alone it would have handed the connect thread an MTP computed
over the wrong branch — and MTP gates BIP113 locktimes — precisely because
the replayed chain can now differ from what the index names. The IBD caller
passes no plan: there the index is authoritative, written forward as blocks
connect and guarded by `connect_stored_block`.

Also: refuse to resume a partial chainstate that is not on the selected
branch, and flush before planning so `for_each_block_index` cannot miss an
entry still in the dirty cache.

`max_indexed_height` — a height→hash probe whose only remaining caller was
the replay's progress total — is removed rather than left as a trap; the
plan's tip height replaces it.

Tests: seven planner unit tests (work beats depth, forged
chainwork/height fields ignored, invalid subtree excluded, header-only gap
demotes a branch, deterministic hash tie-break, orphan strand, genesis
only) and two end-to-end — a polluted height index is now ignored rather
than merely rejected, and a cross-branch resume fails closed. The polluted
-index case was verified to fail against the height-index picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
…election

Fixes from an independent review of this branch. Two reviewers found the
same fail-open in the chainstate planner, which is the serious one.

**Coverage.** Selection admits only `DataStored`/`Valid` blocks and requires
every ancestor to qualify, so one ineligible block low in the chain
truncates the plan — or empties it, leaving `plan.tip_height() == 0`. The
replay then connected nothing, logged "Chainstate reindex complete" and
returned Ok. A pruned datadir hits this every time (every block below the
horizon is `Pruned`), so the node came up serving height 0 with an empty
UTXO set — while `clear_chainstate` had already stamped the tx and address
indexes complete, so Electrum and Esplora would answer "no history" for
every address. Before this branch that case failed loudly on the unreadable
block; the plan turned a fail-closed abort into a silent wipe, which is the
exact class of bug the branch exists to remove. main.rs now captures the tip
height before clearing (the tip lives in the metadata CF that
`clear_chainstate` drops) and the replay refuses to proceed if the plan
cannot reach it. The flat-file path gets the matching check: records scanned
but no chain out of genesis is now an error, not a height-0 "success".

**Forged work.** Both planners drove selection from `header.bits` without
checking that any work backed it. An 80-byte header always deserializes, so
one flipped bit in an `nBits` exponent yields a well-formed header claiming
an astronomical target — and `connect_block` checks no PoW either, so it
would have been selected, connected, and persisted as the tip with its fake
chainwork, pinning the node on a branch `find_best_valid_tip` could never
reorg away from. Checking the hash against the claimed target closes it:
a harder target the block does not meet is rejected, an easier one only
lowers its own score. Notably `index_reindex_side_chain` already did this
and said why — the check was missing on the path that picks the active
chain.

**Equal-work ties** now keep the incumbent, matching `find_best_valid_tip`
(which returns the active tip rather than switching on equal work). The
previous lowest-hash rule would, on a coin flip, rebuild a node holding an
equal-work stale sibling onto the orphan. The doc comment claiming the two
already agreed was simply wrong.

**Side blocks above the tip** are no longer indexed. `accept_headers`
restores a height→hash row for any data-carrying entry whose height is
vacant, so an indexed side block above the selected tip would have had one
written on the next headers message — reintroducing the active-chain
pollution the omission exists to prevent.

Also: `drop(children)` after planning (~166 MB held across the whole
multi-day connect phase), corrected memory figures in both planners' docs
(phase 1 is ~518 MB peak, not 140 MB; the replay planner ~350 MB, not 130),
`-stopatheight` clamped to the plan tip like the sibling path, and the
duplicate-collapse comment no longer argues for a polarity it does not use.

Tests: three new (truncated-chain refusal, forged-work exclusion,
incumbent tie-break) plus a side-blocks-above-tip assertion, each verified
to fail against the code it guards. Planner fixtures are now honestly mined
rather than carrying nonce 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
Deep review (five independent reviewers) found two definite bugs, both in
the guards this PR added rather than in the selection it rewrote.

1. The tie-break incumbent was re-derived with
   `get_block_hash_by_height(required_height)` — the height→hash index
   that `replay_plan`'s own module doc calls "observed polluted with a
   fork block in production (#322)", read at exactly the height where
   that pollution was seen. The incumbent *wins* an exact chainwork tie,
   so a polluted row handed the win to an equal-work stale sibling and
   rebuilt the whole chainstate onto the orphan — the inverse of this
   PR's purpose, reached through the one input that was not hardened.
   Four of five reviewers found it independently.

   The authoritative hash was already in hand and thrown away:
   `main.rs` read `store.get_tip()` and kept only `.height`. It now
   threads `(hash, height)` through, so the incumbent is as trustworthy
   as everything else in selection.

   Reproduced before fixing: with the old lookup the replay rebuilds
   onto the sibling instead of the real tip.

2. The fail-closed coverage floor destroyed its own precondition.
   `required_height` came from a tip that `clear_chainstate()` then
   dropped, and was never persisted — so the guard could only ever fire
   once. The retry (an operator re-running, or `Restart=always` in the
   systemd unit, which brings it back within seconds) read the genesis
   tip the failed run left behind, computed a floor of 0, and sailed
   through: replay connects nothing, reports success, and the node
   serves height 0 with an empty UTXO set while `clear_chainstate` has
   already stamped the tx, address and filter indexes complete — so
   Electrum and Esplora answer "no history" for every address. Exactly
   what the guard's own comment says it prevents.

   The check now runs BEFORE the clear, in `main.rs`, using the intact
   block index. Nothing is destroyed until the replay is known to be
   possible, so a retry re-runs it against the same inputs and reaches
   the same answer. Costs one extra block-index scan.

Also:

- Verify the block read at `flat_pos` is the block the plan selected. A
  damaged block index — the thing `-reindex-chainstate` repairs — can
  point at a different well-formed record, and nothing downstream
  noticed: `connect_block` would write that block's hash, height row and
  UTXO delta while the caller set the in-memory tip to the plan's hash,
  and the next `require_extends_tip` compares against the in-memory tip
  and passes.

- Log which chain was selected, on both paths. The failure this PR fixes
  was "it replayed the wrong chain", and diagnosing it took a three-day
  run plus a custom scan of 5661 flat files because nothing ever said
  which branch was chosen.

- Name the block, height and flat-file position when a read fails.

- Two doc claims corrected. The flat-file tie-break is not first-seen: BFS
  dequeues in height order, so on an exact tie the *shallower* tip wins and
  scan order only decides within a height. And `index_reindex_side_chain`
  claimed "the reorg path validates before connecting" — it does not;
  `reorg_to` goes through `connect_block`, which checks neither PoW nor
  difficulty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
@bkeroack
bkeroack force-pushed the fix/reindex-side-chain-connect branch from cedcc43 to 6cb2b4b Compare August 2, 2026 15:52
bkeroack and others added 2 commits August 2, 2026 17:17
Round-1 review caught the guard added in 6cb2b4b landing on the wrong
path. `reindex_connect_direct` verifies that the block read at `flat_pos`
is the block the plan selected — but that function is only the
prefetch-miss fallback. The replay runs through
`reindex_connect_prefetched`, which had no such check.

The prefetch worker labels a block with the hash the *plan* asked for
(`prefetch::preprocess_block` takes it from `plan.hash_at(height)`) while
reading the bytes from the *index's* `flat_pos`, and never reconciles the
two. So a corrupt position produced a `PreprocessedBlock` claiming to be
the planned block while holding a different one. `connect_block` wrote
that block's hash, height row and UTXO delta; the caller then set the
in-memory tip to the plan's hash. Every later `require_extends_tip`
compares against the in-memory tip and passes, so the replay ran to
completion and reported success with the persisted chainstate and the
in-memory tip naming different blocks.

`require_extends_tip` does not cover this. On the prefetched path it sees
the real block's header, so the wrong record must at least be a child of
the current tip — but a stale sibling of the planned block is exactly
that, and siblings on disk are the shape this corruption takes. On the
direct path it sees the index's header for the planned hash, which
extends the chain by construction and so constrains nothing.

Both paths now call one `require_planned_block` helper.

Reproduced before fixing: with the check removed, a `PreprocessedBlock`
carrying a sibling under the planned block's hash connects and returns
`Ok`. The test drives the connect directly — the prefetcher is a race
between its workers and the connect cursor, so a whole-replay fixture
cannot guarantee the hit lands on this path rather than the fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
Round-1 review, second pass. Two more on the prefetch-miss path, both the
same mistake: the direct read reached for the block index in places the
prefetch path had already been taught not to.

1. MTP came from `get_median_time_past`, which resolves each of the
   eleven preceding heights through the height→hash index. The prefetch
   path takes it from the plan (`prefetch::compute_mtp`) for exactly this
   reason, so the consensus input depended on which path a block arrived
   by — and at replay startup the prefetcher is cold, so the first blocks
   are guaranteed to take the miss.

   The rows it reads sit BELOW the connect cursor, where a resumed replay
   never rewrites them: they are whatever the original sync left, which
   is exactly where the #322 pollution (a fork block owning a height) was
   observed. MTP gates BIP113 locktimes, so this decides validity against
   a branch the node is not replaying.

   Both paths now call `compute_mtp` with the plan.

2. The indexed header was trusted for the extends-tip guard and for the
   parent lookup. `get_block_index` keys on the hash, but nothing
   constrains the stored header to be the one that hashes to that key,
   and an index damaged in the header bytes is what `-reindex-chainstate`
   is run to repair. A forged `prev_blockhash` passed the guard while
   `connect_block` received a differently-parented block; a forged `bits`
   inflated the branch's work in selection and went unchallenged at
   connect. Verifying the record's hash does not catch either: the block
   on disk IS the planned block; only the index's copy of its header
   lies.

   `require_planned_block` now also requires the indexed header to equal
   the block's, and the direct path works from `block.header` alone
   afterward. The prefetched path passes its entry through the same check
   — its `pre.parent`, and the chainwork taken from it, were resolved by
   the worker from that same header.

Both reproduced before fixing. With the MTP source reverted, block 13
connects as `LocktimeNotFinal` — the polluted row's timestamp rejecting a
block the replayed branch makes final. With the header check reverted, a
block whose indexed `bits` the block file contradicts connects and
returns Ok.

The new MTP test is behavioral rather than an assertion on the number: a
non-final, time-locked transaction straddles the two candidate MTPs, so
the polluted value rejects the block as non-final while the correct one
lets it through to fail at input resolution instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
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.

1 participant