reindex: replay the most-work branch, not every reachable block - #491
reindex: replay the most-work branch, not every reachable block#491bkeroack wants to merge 7 commits into
Conversation
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:
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 That isn't hypothetical: the new test Fix
Explicitly not claimedA height index naming a complete, self-consistent alternative branch would still be replayed faithfully — catching that needs chainwork-based selection inside Regression protectionEvery new test was verified to fail against the pre-fix code, not just pass against the new code:
|
Third commit: chainwork selection inside
|
Deep review — five independent reviewersLenses: 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
|
`-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
cedcc43 to
6cb2b4b
Compare
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
The bug
-reindexrebuilds the block index and UTXO set by replayingblk*.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:At the first fork, two outcomes:
bad-txns-inputs-missingorspent.How it was found
A mainnet
-reindexagainst a Bitcoin Coreblocks/dir (the migration pathCORE_DIFFERENCES.mdpoints operators at) ran for three days and died at height 916308 — the first fork point in the files, where stale…056eaand main-chain…fae7share 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):connect_blocknow always receives a block whose parent is the current tip.DataStoredand never connected, sogetblockheaderon an orphaned hash keeps working across a reindex and the branch stays available tofind_best_valid_tipif later extended. PoW is re-checked before indexing one; noheight_hashentry 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).childrenedge 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_fileshad 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..._honors_stop_at_height_without_indexing_side_chainAll 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_heighttests: orphans/short forks, work-beats-depth, equal-work first-seen, genesis-only.cargo test --workspacegreen;cargo clippy --workspace --all-targetsclean.Operator impact
Anyone who ran
-reindexon 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