fix(chain): let the indexer decide how far to rescan - #2262
Open
LLFourn wants to merge 1 commit into
Open
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2262 +/- ##
==========================================
+ Coverage 78.65% 78.71% +0.05%
==========================================
Files 30 31 +1
Lines 5909 5966 +57
Branches 279 282 +3
==========================================
+ Hits 4648 4696 +48
- Misses 1185 1194 +9
Partials 76 76
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`IndexedTxGraph::reindex` was a single pass over `TxGraph::full_txs`, which iterates a `HashMap`, so the same graph reindexed twice could produce two different `last_revealed` results. Each match inside `KeychainTxOutIndex::_index_txout` bumps `last_revealed` and replenishes the lookahead, widening the derived window the remaining outputs are judged against. An output far enough out to need that widening was skipped whenever it happened to be visited first, and no pass revisited it. This is not merely misuse of the API. The lookahead exists precisely to catch indices the persisted frontier does not know about -- a restored wallet, another signer on the same descriptor, an externally built PSBT paying one of our far indices -- so whenever it does its job the frontier moves mid-walk, and the order dependence is present in the intended use. Add `Indexer::rescan`, which is handed the whole graph and returns what indexing it produced. The default implementation offers every full transaction and floating output once, which is all an indexer needs when what it recognizes is fixed up front; `reindex` becomes a call to it. `KeychainTxOutIndex` overrides it and looks repeatedly, stopping when a pass leaves its revealed frontier unmoved. Putting the loop behind the trait rather than in `IndexedTxGraph` keeps the decision where the knowledge is. An indexer that does not widen what it matches is walked exactly once, as before, so the looping cannot leak into implementations that have no use for it. And `KeychainTxOutIndex` can key the loop on its own frontier instead of on whether a changeset came back empty: the changeset also carries staged spk cache entries, which move without the frontier moving, so an emptiness test spends an extra full walk on the ordinary restore path. The regression test uses a single transaction with both outputs rather than two transactions, because the graph's walk order is a `HashMap` order: a two-transaction test would pass a single-pass implementation about half the time. `index_tx` walks `tx.output` in vout order, so the far index at vout 0 is always judged against the initial window and always missed until the near index at vout 1 lifts the frontier.
LLFourn
force-pushed
the
reindex-fixed-point
branch
from
August 19, 2026 05:16
32162e1 to
38b3abc
Compare
IndexedTxGraph::reindex a fixed point
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
IndexedTxGraph::reindexwas a single pass overTxGraph::full_txs, which iterates aHashMap. That made it nondeterministic: the same graph reindexed twice could produce two differentlast_revealedresults.Each match inside
KeychainTxOutIndex::_index_txoutbumpslast_revealedand then callsreplenish_inner_index, whose stop index islast_revealed + 1 + lookahead. So a match widens the derived window the remaining outputs are judged against. An output far enough out to need that widening was skipped if it happened to be visited first, and no pass revisited it.Concretely, with
lookahead1000 and an empty frontier the derived set is0..1001. Given one tx paying index 900 and another paying 1850: visited 900-first, the window grows to 1901 and both are found; visited 1850-first, 1850 is missed and stays missed. Same inputs, different index, decided byHashMapiteration order.The usual objection — that callers should reveal before reindexing — does not hold. The lookahead exists precisely to catch indices the persisted frontier does not know about: a restored wallet, another signer on the same descriptor, an externally built PSBT paying one of our far indices. Whenever the lookahead does its job the frontier moves mid-walk, so the order dependence is present in the intended use, not just in misuse.
The change
Add
Indexer::rescan, handed the wholeTxGraphand returning the indexing it produced:The default implementation offers every full transaction and floating output to
index_tx/index_txoutexactly once — all an indexer needs when what it recognizes is fixed up front — andIndexedTxGraph::reindexbecomes a call to it.KeychainTxOutIndexoverrides it and looks repeatedly, stopping when a pass leaves its revealed frontier unmoved.Whether to look more than once belongs to the indexer, because the indexer is the only thing that knows whether its recognition set can still grow. Two things follow from putting it there rather than in
IndexedTxGraph:SpkTxOutIndexis unaffected. There is no convergence requirement imposed on implementors, and no way for a third-party indexer to be spun forever by a loop it never asked for.KeychainTxOutIndexcan key the loop on its own frontier rather than on whether a changeset came back empty. That matters: the changeset also carries staged spk cache entries, which move without the frontier moving. Achangeset.is_empty()loop therefore spends an extra full walk on the ordinary restore path. I measured this — withpersist_spks = true, restoring viafrom_changesetwith a correct frontier takes 1 pass keyed on the frontier versus 2 keyed on changeset emptiness.Notes to the reviewers
On the test shape. The regression test uses one transaction with two of our outputs rather than two transactions. A two-transaction test would depend on graph walk order — the very thing that is unreliable — and so would pass a single-pass implementation about half the time, which is a test that fails to fail.
index_txwalkstx.output, aVec, in vout order, so a single tx paying a far index at vout 0 and a near one at vout 1, against an empty frontier, misses the far output on every pass on every run. It is deterministic by construction; I verified it fails against the old implementation on five consecutive runs (left: Some(9),right: Some(15)).On cost.
KeychainTxOutIndex::rescanre-walks the whole graph per look, so it is O(looks × txs), with looks bounded by the number of distinct frontier advances plus one. Measured: the settled/restore case is 1 look; the recovery case in the test (empty frontier, far output only reachable after the near one lands) is 3. Making a later look re-examine only the outputs that did not already match would need the indexer to say what to re-offer rather than just whether, which is a bigger change than this fix.Trait change.
rescanis a defaulted method, so existingIndexerimplementations keep compiling unchanged. It is generic over the anchorA, which makesIndexerno longer object-safe — nothing in the workspace usesdyn Indexer.Known adjacent gap, not addressed here.
index_tx_graph_changeset— used byinsert_tx,insert_txout,apply_updateandapply_block_relevant— is still a single pass and has the same order dependence. Feeding the one-transaction case above throughinsert_txyieldslast_revealed = Some(9)and only one of the two outpoints, so a live wallet can drop the far UTXO until the next restart re-runsreindex. It needs the incremental paths to go through the same mechanism rather than their own copy; I kept this PR toreindexto stay reviewable, and am happy to follow up.Changelog notice
Indexer::rescan, which indexes an entireTxGraphand lets an implementation decide how many looks that takes. Defaulted, so existing implementations are unaffected.IndexedTxGraph::reindexno longer depends onHashMapiteration order. It previously made a single pass and could miss outputs at derivation indices that only came into range after another output in the same walk advanced the lookahead;KeychainTxOutIndexnow looks until its revealed frontier stops moving.Checklists
All Submissions:
New Features:
Bugfixes: