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.