When ScanForWalletTransactions processes a block containing both a transaction to a key just outside the look-ahead pool and a pool-expanding transaction that triggers TopUp, and the former appears first in vtx order, the wallet misses the earlier transaction — leaving the balance wrong with no indication a second scan is needed. The fix tracks the last vtx position where the pool expanded and re-scans only the prefix [0, last_expansion_pos) — every transaction processed before the pool reached its final state.
The first commit demonstrates the incorrect behaviour; the second contains the fix.
<details> <summary>Bug details...</summary> <br>
ScanForWalletTransactions iterates over a block's transactions in the order the miner placed them (vtx order). A descriptor wallet maintains a look-ahead pool of pre-derived keys so it can recognise incoming payments. When a payment arrives at a key near the pool boundary, MarkUnusedAddresses → TopUp
extends the pool.
If within a single block:
- Tx_lookahead pays to key index
N(just outside the current pool[0, N-1]), AND - Tx_expand pays to key index
N-1(last in pool — triggers TopUp, extending pool to[0, 2N-1])
and Tx_lookahead appears at an earlier vtx position than Tx_expand, the scan misses Tx_lookahead:
- Tx_lookahead processed: pool =
[0, N-1]→IsMinereturns false → missed - Tx_expand processed: key
N-1found → TopUp fires → pool =[0, 2N-1]
FastWalletRescanFilter::UpdateIfNeeded() fires only at the start of the next block iteration — there is no mechanism to re-examine Tx_lookahead within the same scan pass.
The transaction is not permanently lost — because Tx_expand extended the pool as a side effect, a second explicit rescanblockchain call recovers Tx_lookahead. But the wallet shows a wrong balance with no indication a second scan is needed.
This bug does not affect live blockConnected when both transactions pass through the mempool first: transactionAddedToMempool(Tx_expand) pre-extends the pool before the block arrives, so vtx ordering does not matter. It only manifests in ScanForWalletTransactions (rescan paths) for descriptor wallets.
</details>
<details> <summary>Affected callers...</summary> <br>
Callers affected (all use ScanForWalletTransactions):
rescanblockchainimportdescriptors(viaRescanFromTime)restorewalletand wallet migration watchonly/solvable wallets (viaAttachChain)
</details>
<details> <summary>Fix details and performance...</summary> <br>
Fix: during the per-block vtx loop, snapshot range_end per HD descriptor after every transaction. Track the last vtx position where any descriptor's range_end increased (last_expansion_pos). After the full pass, re-scan only the prefix [0, last_expansion_pos) — transactions processed before the pool reached its final state. Transactions at last_expansion_pos and beyond were already seen with the fully-expanded pool and are not re-visited.
Using the last (not first) expansion position matters when multiple TopUp events occur in one block. With interleaved vtx order [lookahead_1, expand_1, lookahead_2, expand_2], stopping at the first expansion position (1) re-scans only [0, 1) and misses lookahead_2 at position 2. Stopping at the last (3) re-scans [0, 3) and finds both.
The re-scan repeats with the new last_expansion_pos if a transaction within the prefix itself causes a further pool expansion. The prefix strictly shrinks each iteration, guaranteeing termination without a safety cap.
Performance: the prefix re-scan fires for any block where TopUp fires during the vtx loop — effectively, any block containing a wallet-relevant transaction during a rescan. The re-visited prefix is [0, last_expansion_pos), proportional to where the last pool-expanding transaction sits within the block. Transactions at that position and beyond are visited exactly once.
</details>
<details> <summary>Test coverage...</summary> <br>
Regression test: test/functional/wallet_rescan_intrablock_ordering.py
- Part 1: loadwallet rescan — Tx_lookahead found after fix
- Part 2: importdescriptors full rescan from genesis — Tx_lookahead found after fix
- Part 3: second rescanblockchain recovers Tx_lookahead (pre-fix behaviour, kept as sanity check)
- Part 4: two-level cascade — two successive TopUp events fire in one block, both lookahead txs found, validating the bounded multi-pass loop
</details>
<details> <summary>Manual reproduction (<code>regtest</code>)</summary>
bitcoind -regtest -keypool=5 -fallbackfee=0.0001 -daemon
bitcoin-cli -regtest -named createwallet wallet_name=funding
bitcoin-cli -regtest generatetoaddress 102 $(bitcoin-cli -regtest -rpcwallet=funding getnewaddress)
bitcoin-cli -regtest -named createwallet wallet_name=test
RECV_DESC=$(bitcoin-cli -regtest -rpcwallet=test listdescriptors \
| jq -r '[.descriptors[] | select(.internal==false and .range!=null)][0].desc')
END_RANGE=$(bitcoin-cli -regtest -rpcwallet=test listdescriptors \
| jq '[.descriptors[] | select(.internal==false and .range!=null)][0].range[1]')
ADDR_EXPAND=$(bitcoin-cli -regtest deriveaddresses "$RECV_DESC" "[$END_RANGE,$END_RANGE]" | jq -r '.[0]')
ADDR_LOOKAHEAD=$(bitcoin-cli -regtest deriveaddresses "$RECV_DESC" "[$((END_RANGE+1)),$((END_RANGE+1))]" | jq -r '.[0]')
bitcoin-cli -regtest unloadwallet test
TXID1=$(bitcoin-cli -regtest -rpcwallet=funding sendtoaddress "$ADDR_LOOKAHEAD" 0.001)
TXID2=$(bitcoin-cli -regtest -rpcwallet=funding sendtoaddress "$ADDR_EXPAND" 0.001)
bitcoin-cli -regtest generateblock "raw(51)" "[\"$TXID1\",\"$TXID2\"]"
# First scan — TXID1 missing, wrong balance
bitcoin-cli -regtest loadwallet test
bitcoin-cli -regtest -rpcwallet=test listtransactions
# Second scan — TXID1 recovered
bitcoin-cli -regtest -rpcwallet=test rescanblockchain
bitcoin-cli -regtest -rpcwallet=test listtransactions
</details>
Notes:
Originally noted by furszy in #31629 (comment); his "case 2" describes a related but harder inter-block variant: new blocks arriving via
blockConnectedduring an active rescan with a not-yet-expanded pool. That problem remains a potential follow-up. This PR fixes the simpler intra-block sub-case where the miss happens entirely within a single block's vtx loop, with no concurrent block arrivals required.Perhaps the issue is related to the symptom was previously reported in #19808 but could not be reproduced at the time and was closed without a fix.
#34681 refactors
ScanForWalletTransactionsinto aChainScannerclass and touches the same code. If that PR merges first, this fix would need to move intoChainScanner::ScanBlock.molnard found a related gap this PR doesn't close: if a parent tx pays a look-ahead key and a later tx in the same block spends that parent's output, and the spend sits at or after the block's last pool expansion, the prefix rescan recovers the parent but never revisits the spend — the parent output can remain classified as unspent. Fixing this properly needs a different approach (retry based on each tx's
SyncTransaction()result until a full pass causes no change, rather than a shrinking-prefix rescan), which is a separate piece of work from this fix. Tracked as a follow-up, same as the furszy case above.