{
 "number": 35901,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/35901",
 "title": "wallet: Fix ScanForWalletTransactions missing tx when look-ahead pool expands mid-block",
 "author": "pablomartin4btc",
 "author_association": "MEMBER",
 "created_at": "2026-08-05T17:29:00Z",
 "updated_at": "2026-09-14T13:19:34Z",
 "age_days": 42,
 "draft": false,
 "labels": [
  "Wallet",
  "Needs rebase"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "1d9de0e62d237b4feea4d6ee5515312d7b2ba9fd",
 "head_ref": "wallet/rescan-intrablock-topup",
 "head_repo": "pablomartin4btc/bitcoin",
 "head_history": [
  {
   "t": "2026-08-05T17:34:39Z",
   "sha": "d4d404845727cefc4d38f3a7c73fcab01a3d9af6"
  },
  {
   "t": "2026-08-05T18:18:14Z",
   "sha": "a826c73d2ddcbc8d2c8723a01093c94ff596434c"
  },
  {
   "t": "2026-08-11T22:16:30Z",
   "sha": "65cdcb069eb63b005a6cab1fba20f677c98e3189"
  },
  {
   "t": "2026-08-11T23:10:17Z",
   "sha": "60f13340fec2aff42a1256ae83525719b745647b"
  },
  {
   "t": "2026-08-12T03:19:17Z",
   "sha": "278564b95c0607fd54892d42a60a001e6b32a236"
  },
  {
   "t": "2026-08-28T20:39:03Z",
   "sha": "1d9de0e62d237b4feea4d6ee5515312d7b2ba9fd"
  }
 ],
 "additions": 286,
 "deletions": 2,
 "changed_files": 3,
 "commit_count": 2,
 "size_bucket": "M",
 "mergeable_state": "dirty",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "concept_ack": [
     {
      "login": "achow101",
      "url": "https://github.com/bitcoin/bitcoin/pull/35901#pullrequestreview-4900292627"
     },
     {
      "login": "molnard",
      "url": "https://github.com/bitcoin/bitcoin/pull/35901#pullrequestreview-5041935034"
     }
    ],
    "stale_ack": [
     {
      "login": "jeanpablojp",
      "url": "https://github.com/bitcoin/bitcoin/pull/35901#issuecomment-5264417632"
     }
    ]
   },
   "conflicts": [
    {
     "number": 34681,
     "title": "wallet: move rescan logic into ChainScanner and wallet/scan",
     "author": "Eunovo"
    }
   ]
  }
 },
 "acks_parsed": {
  "achow101": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-08-10T19:36:13Z",
   "stale": false
  },
  "jeanpablojp": {
   "kind": "ack",
   "hash": "278564b95c0607fd54892d42a60a001e6b32a236",
   "t": "2026-08-12T08:41:43Z",
   "stale": true
  },
  "molnard": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-08-27T15:13:36Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 1,
  "concept_ack": 2,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "achow101",
   "jeanpablojp",
   "molnard"
  ]
 },
 "signals": {
  "needs_rebase": true,
  "ci_failed": false,
  "mergeable_state": "dirty",
  "last_author_activity": "2026-08-29T16:47:48Z",
  "last_reviewer_activity": "2026-08-29T02:15:14Z",
  "last_reviewer": "jeanpablojp",
  "author_silent_days": 18,
  "waiting_on_author_days": 0,
  "days_since_update": 3
 },
 "refs": {
  "mentioned": [
   19808,
   34681
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 19808,
    "type": "issue",
    "state": "closed",
    "merged": false,
    "merged_at": null,
    "title": "wallet: Imports with pre-existing balance somtimes don't have any balance after a rescan"
   },
   {
    "number": 34681,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-09-14",
    "title": "wallet: move rescan logic into ChainScanner and wallet/scan"
   }
  ],
  "conflicts": [
   34681
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/wallet/wallet.cpp"
 ],
 "body": "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 \u2014 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`) \u2014 every transaction processed before the pool reached its final state.\n\nThe first commit demonstrates the incorrect behaviour; the second contains the fix.\n\nBug details...\n\n`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` \u2192 `TopUp`\nextends the pool.\n\nIf within a single block:\n- **Tx_lookahead** pays to key index `N` (just outside the current pool `[0, N-1]`), AND\n- **Tx_expand** pays to key index `N-1` (last in pool \u2014 triggers TopUp, extending pool to `[0, 2N-1]`)\n\nand Tx_lookahead appears at an earlier vtx position than Tx_expand, the scan misses Tx_lookahead:\n\n1. Tx_lookahead processed: pool = `[0, N-1]` \u2192 `IsMine` returns false \u2192 missed\n2. Tx_expand processed: key `N-1` found \u2192 TopUp fires \u2192 pool = `[0, 2N-1]`\n\n`FastWalletRescanFilter::UpdateIfNeeded()` fires only at the **start of the next block** iteration \u2014 there is no mechanism to re-examine Tx_lookahead within the same scan pass.\n\nThe transaction is not permanently lost \u2014 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.\n\nThis 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.\n\nAffected callers...\n\n**Callers affected** (all use `ScanForWalletTransactions`):\n- `rescanblockchain`\n- `importdescriptors` (via `RescanFromTime`)\n- `restorewallet` and wallet migration watchonly/solvable wallets (via `AttachChain`)\n\nFix details and performance...\n\n**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`) \u2014 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.\n\nUsing 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.\n\nThe 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.\n\n**Performance:** the prefix re-scan fires for any block where TopUp fires during the vtx loop \u2014 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.\n\nTest coverage...\n\n**Regression test:** `test/functional/wallet_rescan_intrablock_ordering.py`\n- Part 1: loadwallet rescan \u2014 Tx_lookahead found after fix\n- Part 2: importdescriptors full rescan from genesis \u2014 Tx_lookahead found after fix\n- Part 3: second rescanblockchain recovers Tx_lookahead (pre-fix behaviour, kept as sanity check)\n- Part 4: two-level cascade \u2014 two successive TopUp events fire in one block, both lookahead txs found, validating the bounded multi-pass loop\n\nManual reproduction (<code>regtest</code>)\n\n```bash\nbitcoind -regtest -keypool=5 -fallbackfee=0.0001 -daemon\n\nbitcoin-cli -regtest -named createwallet wallet_name=funding\nbitcoin-cli -regtest generatetoaddress 102 $(bitcoin-cli -regtest -rpcwallet=funding getnewaddress)\n\nbitcoin-cli -regtest -named createwallet wallet_name=test\nRECV_DESC=$(bitcoin-cli -regtest -rpcwallet=test listdescriptors \\\n  | jq -r '[.descriptors[] | select(.internal==false and .range!=null)][0].desc')\nEND_RANGE=$(bitcoin-cli -regtest -rpcwallet=test listdescriptors \\\n  | jq '[.descriptors[] | select(.internal==false and .range!=null)][0].range[1]')\nADDR_EXPAND=$(bitcoin-cli -regtest deriveaddresses \"$RECV_DESC\" \"[$END_RANGE,$END_RANGE]\" | jq -r '.[0]')\nADDR_LOOKAHEAD=$(bitcoin-cli -regtest deriveaddresses \"$RECV_DESC\" \"[$((END_RANGE+1)),$((END_RANGE+1))]\" | jq -r '.[0]')\n\nbitcoin-cli -regtest unloadwallet test\n\nTXID1=$(bitcoin-cli -regtest -rpcwallet=funding sendtoaddress \"$ADDR_LOOKAHEAD\" 0.001)\nTXID2=$(bitcoin-cli -regtest -rpcwallet=funding sendtoaddress \"$ADDR_EXPAND\" 0.001)\nbitcoin-cli -regtest generateblock \"raw(51)\" \"[\\\"$TXID1\\\",\\\"$TXID2\\\"]\"\n\n# First scan \u2014 TXID1 missing, wrong balance\nbitcoin-cli -regtest loadwallet test\nbitcoin-cli -regtest -rpcwallet=test listtransactions\n\n# Second scan \u2014 TXID1 recovered\nbitcoin-cli -regtest -rpcwallet=test rescanblockchain\nbitcoin-cli -regtest -rpcwallet=test listtransactions\n```\n\n_**Notes**_:\n\n* Originally noted by furszy in https://github.com/bitcoin/bitcoin/pull/31629#issuecomment-2657775368; his \"case 2\" describes a related but harder inter-block variant: new blocks arriving via `blockConnected` during 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.\n\n* 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.\n\n* #34681 refactors `ScanForWalletTransactions` into a `ChainScanner` class and touches the same code. If that PR merges first, this fix would need to move into `ChainScanner::ScanBlock`.\n\n* molnard [found](https://github.com/bitcoin/bitcoin/pull/35901#pullrequestreview-5041935034) 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 \u2014 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.",
 "commits": [
  {
   "sha": "c4f65f7e3bec50d296f805baba1c01dfe9b8cb13",
   "date": "2026-08-12T03:18:43Z",
   "message": "test: Wallet misses tx when look-ahead pool expands mid-block\n\nDemonstrates that ScanForWalletTransactions misses a transaction paying\nto a key just outside the look-ahead pool when the pool-expanding\ntransaction appears later in the same block's vtx order.\n\nPart 1: wallet reload triggers rescan of one block; tx_lookahead is\n        missed on the first scan (wrong balance, no indication a\n        second scan is needed).\nPart 2: importdescriptors with timestamp=0 triggers a full rescan;\n        same miss.\nPart 3: a second rescanblockchain recovers tx_lookahead \u2014 the pool\n        was extended as a side-effect of finding tx_expand, so the\n        key is visible on re-scan.\n\nTODO: Parts 1 and 2 assertions will be flipped in the fix commit."
  },
  {
   "sha": "1d9de0e62d237b4feea4d6ee5515312d7b2ba9fd",
   "date": "2026-08-28T20:07:15Z",
   "message": "wallet: Fix ScanForWalletTransactions when look-ahead pool expands mid-block\n\nScanForWalletTransactions iterates over a block's transactions in vtx\norder. When a pool-expanding transaction appears *after* a transaction\npaying to a key just outside the look-ahead pool, the earlier\ntransaction is missed: it was processed with a smaller pool (IsMine\nreturned false), and FastWalletRescanFilter::UpdateIfNeeded() only\nfires at the next block boundary so there is no mechanism to\nre-examine it within the same scan pass.\n\nFix: track the last vtx position where any HD descriptor's range_end\nincreased (last_expansion_pos). After the full forward pass, re-scan\nonly the prefix [0, last_expansion_pos) \u2014 every transaction that was\nprocessed before the pool reached its final state. Transactions at\nlast_expansion_pos and beyond were already seen with the\nfully-expanded pool and are not re-visited, so AddToWallet is not\ncalled a second time for them (walletnotify does not double-fire).\nThe prefix strictly shrinks each iteration, guaranteeing termination\nwithout a safety cap.\n\nUsing the *last* (not first) expansion position matters when multiple\nTopUp events occur in a single block. With interleaved vtx order\n[lookahead_1, expand_1, lookahead_2, expand_2], stopping at the first\nexpansion position (1) re-scans only [0, 1) and misses lookahead_2\nat position 2. Stopping at the last expansion position (3) re-scans\n[0, 3) and finds both.\n\nAffected callers (all use ScanForWalletTransactions): rescanblockchain,\nimportdescriptors (via RescanFromTime), restorewallet and wallet\nmigration watchonly/solvable wallets (via AttachChain).\n\nThis bug does not affect the live blockConnected path when transactions\npass through the mempool first: transactionAddedToMempool(Tx_expand)\npre-extends the pool before the block arrives.\n\nExtend the regression test with Part 4 to cover the two-level\ncascade scenario, and flip Parts 1 and 2 from the bug-demonstrating\nassertions in the previous commit to the fixed behavior."
  }
 ],
 "timeline": [
  {
   "t": "2026-08-05T17:34:39Z",
   "kind": "force_push",
   "who": "pablomartin4btc",
   "commit": "d4d404845727cefc4d38f3a7c73fcab01a3d9af6"
  },
  {
   "t": "2026-08-05T18:18:14Z",
   "kind": "force_push",
   "who": "pablomartin4btc",
   "commit": "a826c73d2ddcbc8d2c8723a01093c94ff596434c"
  },
  {
   "t": "2026-08-10T01:03:58Z",
   "kind": "comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "text": "This fixes a real bug. With the `wallet.cpp` hunk reverted the new test fails at Part 1 and the transaction is absent from `listtransactions`, and putting the hunk back makes it pass. Unit suite clean, `wallet_*` functional clean. The extra pass is also safe to repeat: `CWalletTx::Update` returns false when the wtxid and the state index are unchanged, so a second pass does not write again or count anything twice.\n\nTwo things came up when I was validating. Neither breaks anything, and both look cheap to tidy if you agree with them.\n\nThe extra pass runs more often than the description suggests. What the loop tests is whether `range_end` moved during the block, and any receive to a key at or past `next_index` moves it, so it isn't limited to the ordering you describe. With `-blockfilterindex=1`, 10 receives to 10 unused keys in 10 separate blocks, the rescan fetched 10 blocks and walked all 10 of them twice. Nothing goes wrong on the second walk, it just re-finds what it already had. The fast variant only fetches blocks the filter matched, which are largely the blocks that expand the pool, so \"most rescans process each block exactly once\" probably wants rewording even if the code stays as it is.\n\nThe second walk also re-runs `-walletnotify`. `AddToWallet` notifies on every call, so the script fires again for transactions that did not change: one invocation on master, two on this branch, same wallet and same txid, with `debug.log` logging the second as `no-change Confirmed`. The option is documented as \"execute command when a wallet transaction changes\", and on the second walk nothing changed.\n\nBoth go away if the re-walk covers only the prefix before the first expansion: keep the lowest vtx position where `range_end` moved and stop there. In the common case the expanding transaction is the only match in the block, so nothing gets walked twice. Does that miss a case I'm not seeing?"
  },
  {
   "t": "2026-08-10T19:32:29Z",
   "kind": "review_comment",
   "who": "achow101",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "a826c73d2ddcbc8d2c8723a01093c94ff596434c",
   "in_reply_to": null,
   "text": "In a826c73d2ddcbc8d2c8723a01093c94ff596434c \"wallet: re-process block in ScanForWalletTransactions if pool expands mid-block\"\n\nnit: This can be in the `for` rather than an explicit break."
  },
  {
   "t": "2026-08-10T19:36:13Z",
   "kind": "review",
   "who": "achow101",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "a826c73d2ddcbc8d2c8723a01093c94ff596434c",
   "text": "Concept ACK-ish\n\nThis does mean that every time we find a transaction with an output in our lookahead, we end up having to scan that block twice."
  },
  {
   "t": "2026-08-11T22:16:30Z",
   "kind": "force_push",
   "who": "pablomartin4btc",
   "commit": "65cdcb069eb63b005a6cab1fba20f677c98e3189"
  },
  {
   "t": "2026-08-11T23:10:17Z",
   "kind": "force_push",
   "who": "pablomartin4btc",
   "commit": "60f13340fec2aff42a1256ae83525719b745647b"
  },
  {
   "t": "2026-08-12T03:19:17Z",
   "kind": "force_push",
   "who": "pablomartin4btc",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236"
  },
  {
   "t": "2026-08-12T03:27:45Z",
   "kind": "review_comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "a826c73d2ddcbc8d2c8723a01093c94ff596434c",
   "in_reply_to": 3752730062,
   "text": "The overall structure changed since this comment \u2014 the old two-pass loop is replaced by a `last_expansion_pos` approach \u2014 but the spirit of the suggestion is there: the new inner for loop uses `pos_in_block < *last_expansion_pos` as its bound rather than an explicit `break`. Thanks!"
  },
  {
   "t": "2026-08-12T04:25:33Z",
   "kind": "comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "text": "@jeanpablojp,\n\nThanks for the thorough review and for actually running the test!\n\n[quoted text omitted]\nOn \"runs more often\" \u2014 still partially valid: the re-scan fires whenever range_end moves during a block, not only in the vtx-ordering scenario. What changed from the previous version is that the re-scan is now bounded to the prefix [0, `last_expansion_pos`) rather than the full block, so the overhead is smaller. Fair to say the PR description needs rewording there.\n\n[quoted text omitted]\nOn -walletnotify \u2014 you were testing the old version, which re-walked the full block and re-synced every already-known tx. In the current code the re-scan covers only [0, `last_expansion_pos`). In the common case (single expansion at position P), `last_expansion_pos` = P, so the prefix [0, P) contains only the transactions processed before the pool expanded \u2014 i.e. the missed lookahead, which is recorded for the first time in the re-scan, not a second time. No double notification. In the **cascade case** an expander from the first expansion lands inside the prefix and does get a second `AddToWallet` call, so **one extra notification per cascade level**. But for the common **single-expansion case the double notification is gone**.\n\n[quoted text omitted]\nOn first vs last \u2014 I didn't take that suggestion exactly. Part 4 of the test shows the case you asked about: with vtx order `[lookahead_1, expand_1, lookahead_2, expand_2]`, stopping at the first expansion position (1) re-scans only [0, 1) \u2014 finds `lookahead_1` but still misses `lookahead_2` at position 2. `last_expansion_pos` (3) re-scans [0, 3) and finds both. For the single-expansion common case the two are equivalent (`first_expansion_pos` vs `last_expansion_pos`)."
  },
  {
   "t": "2026-08-12T04:28:30Z",
   "kind": "comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\n@achow101, the re-scan is bounded at `last_expansion_pos`, so it's only the prefix of the block \u2014 not the full block again. In the common single-expansion case the expansion transaction is at vtx position P; the re-scan covers [0, P), and the expansion tx plus everything after it is visited exactly once. You're right that any block where the pool expands mid-scan gets a partial re-walk; I'll tighten the performance language in the description."
  },
  {
   "t": "2026-08-12T04:58:56Z",
   "kind": "comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "text": "-<ins>_**Updates**_</ins>:\n- Addressed feedback from both @jeanpablojp and @achow101 (replies inline).\n- Updated the PR description to accurately reflect the current implementation:\n  - Intro and fix details now describe the `last_expansion_pos` approach (the description was still describing the old pass-counter/full-rerun version);\n  - Performance section corrected: the prefix re-scan fires for any block where TopUp fires during the vtx loop (any wallet-relevant block during rescan), not only in the specific vtx-ordering scenario @jeanpablojp tested."
  },
  {
   "t": "2026-08-12T08:41:43Z",
   "kind": "comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "text": "ACK 278564b95c0607fd54892d42a60a001e6b32a236"
  },
  {
   "t": "2026-08-27T14:24:57Z",
   "kind": "review_comment",
   "who": "molnard",
   "assoc": "NONE",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": null,
   "text": "In 278564b95c0607fd54892d42a60a001e6b32a236\n\nWe can avoid some calls to `collect_range_ends()` by checking the return value of `SyncTransaction()`. If it returns false, the transaction did not involve the wallet, so `TopUp()` could not have been called and `collect_range_ends()` cannot return a different result."
  },
  {
   "t": "2026-08-27T14:35:33Z",
   "kind": "review_comment",
   "who": "molnard",
   "assoc": "NONE",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": null,
   "text": "```suggestion\n                        range_ends = std::move(cur);\n```\n\nThis is normally much cheaper."
  },
  {
   "t": "2026-08-27T14:48:48Z",
   "kind": "review_comment",
   "who": "molnard",
   "assoc": "NONE",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": null,
   "text": "In 278564b:\n\nIt may be simpler to keep the call to `SyncTransaction()` in one place. The initial full-block scan and the subsequent prefix rescans can be combined by using `scan_end` as the exclusive upper bound:\n\n```cpp\nauto range_ends = collect_range_ends();\nsize_t scan_end = block.vtx.size();\n\nwhile (scan_end > 0) {\n    std::optional<size_t> next_scan_end;\n\n    for (size_t pos_in_block = 0; pos_in_block < scan_end; ++pos_in_block) {\n        SyncTransaction(\n            block.vtx[pos_in_block],\n            TxStateConfirmed{\n                block_hash,\n                block_height,\n                static_cast<int>(pos_in_block),\n            },\n            /*rescanning_old_block=*/true);\n\n        auto cur = collect_range_ends();\n        if (cur != range_ends) {\n            next_scan_end = pos_in_block;\n            range_ends = std::move(cur);\n        }\n    }\n\n    if (!next_scan_end.has_value()) break;\n    scan_end = *next_scan_end;\n}\n```\n\nThe first pass scans the entire block. Each subsequent pass scans only the prefix before the last detected expansion."
  },
  {
   "t": "2026-08-27T15:13:36Z",
   "kind": "review",
   "who": "molnard",
   "assoc": "NONE",
   "state": "COMMENTED",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "text": "Concept ACK. The bug is valid, but I think the prefix-only rescan leaves the following case unresolved:\n\n### Scenario examined\n\n1. A parent transaction pays key `N`, **outside** the initial look-ahead pool.\n2. A later transaction pays key `N-1` and expands the pool.\n3. A subsequent child transaction spends the parent output to an external address.\n\n### Observed behavior\n\n- The first pass misses the parent.\n- The expander extends the pool.\n- The child is not recognized because the parent has not yet been added to the wallet.\n- The prefix rescan subsequently finds the parent, but it stops before the expander and therefore never revisits the child.\n\n### Consequence\n\nThe parent output can remain classified as unspent, leaving the wallet balance incorrect until another rescan.\n\n### Suggested coverage\n\nAdd a functional test case with the tx order:\n\n```\n- lookahead parent\n- expander\n- child spending the lookahead parent\n```\n\nConsider whether transactions after the expansion must also be reconsidered when the prefix pass discovers a previously missed wallet transaction.\n\n## Update\n\nI was thinking about a solution... Briefly, a possible implementation is to iterate through the block and keep track of transactions for which `SyncTransaction()` already returned `true`, excluding them from later iterations (fix: walletnotify). Repeat this until an iteration causes no descriptor expansion.\n\nThis allows previously missed transactions to be retried in block order after the look-ahead pool expands."
  },
  {
   "t": "2026-08-28T20:12:52Z",
   "kind": "review_comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": 3872753832,
   "text": "I do agree, will take your suggestion, thanks!"
  },
  {
   "t": "2026-08-28T20:24:19Z",
   "kind": "review_comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": 3872661031,
   "text": "Nice catch! Capturing the return value from `SyncTransaction`, especially when it's `false`, avoids wasted work on every non-wallet-relevant tx \u2014 which is the majority of transactions in most blocks during a rescan! I'll take this suggestion, thanks!"
  },
  {
   "t": "2026-08-28T20:33:32Z",
   "kind": "review_comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": 3872872400,
   "text": "I'll take it \u2014 merges the initial full-block `for` loop and the separate `while`-wrapped prefix rescan into one `while` (`scan_end > 0`) loop, shrinking `scan_end` to the last detected expansion each pass. Same termination guarantee, less duplication, and the natural single place for the early-skip from the other [suggestion](https://github.com/bitcoin/bitcoin/pull/35901/changes#r3872661031) instead of repeating it in two loop bodies."
  },
  {
   "t": "2026-08-28T20:39:03Z",
   "kind": "force_push",
   "who": "pablomartin4btc",
   "commit": "1d9de0e62d237b4feea4d6ee5515312d7b2ba9fd"
  },
  {
   "t": "2026-08-28T20:39:32Z",
   "kind": "review_comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": 3872661031,
   "text": "Done."
  },
  {
   "t": "2026-08-28T20:39:40Z",
   "kind": "review_comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": 3872753832,
   "text": "Done."
  },
  {
   "t": "2026-08-28T20:39:52Z",
   "kind": "review_comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "278564b95c0607fd54892d42a60a001e6b32a236",
   "in_reply_to": 3872872400,
   "text": "Done, thanks!"
  },
  {
   "t": "2026-08-28T20:47:18Z",
   "kind": "comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\nThanks for working through this \u2014 traced it and agree, the parent-then-child scenario is a real gap. The prefix rescan recovers the parent (its position is before the last expansion), but if the child spends it at or after that boundary, the child is never revisited, so the spend is never recorded and the parent output stays classified as unspent.\n\nClosing this properly needs a different approach \u2014 tracking per-tx `SyncTransaction()` success and retrying until a full pass causes no further change, as you suggested, which is a different algorithm with its own termination argument, not a small addition to this one. Given this PR already scopes out a comparable-complexity related case (@furszy's inter-block variant) as a follow-up rather than folding it in, I'd like to treat this the same way \u2014 documenting it as a second known limitation rather than expanding this fix. Added it as a note to the [PR description](https://github.com/bitcoin/bitcoin/pull/35901#issue-5073541017).\n\nTook your other three suggestions as-is, done in 1d9de0e62d237b4feea4d6ee5515312d7b2ba9fd, thanks!"
  },
  {
   "t": "2026-08-28T20:52:25Z",
   "kind": "comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "text": "-<ins>_**Updates**_</ins>:\n  - Addressed @molnard's [feedback](https://github.com/bitcoin/bitcoin/pull/35901#pullrequestreview-5041935034):\n    - [Skip](https://github.com/bitcoin/bitcoin/pull/35901#discussion_r3872661031) `collect_range_ends()` when `SyncTransaction()` returns `false` \u2014 if the tx doesn't touch the wallet, the look-ahead pool can't have expanded, so the check is guaranteed unchanged.\n    - `range_ends = std::move(cur);` [instead](https://github.com/bitcoin/bitcoin/pull/35901#discussion_r3872753832) of a copy.\n    - [Consolidated](https://github.com/bitcoin/bitcoin/pull/35901#discussion_r3872872400) the initial full-block scan and the shrinking-prefix re-scan into a single `while` `(scan_end > 0)` loop \u2014 same termination guarantee, less duplicated code, and the natural single spot for the `SyncTransaction`-return-value skip above.\n    - The additional \"child spends parent\" gap raised in the top-level review is real, but fixing it needs a different algorithm (retry based on each tx's `SyncTransaction()` result until a pass causes no change) \u2014 documented as a follow-up in the PR description, alongside the existing furszy inter-block carve-out, rather than folded into this fix."
  },
  {
   "t": "2026-08-29T02:15:14Z",
   "kind": "comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "text": "On 1d9de0e6 there is a second case the prefix does not reach, with no spend in it. A receive only becomes the wallet's once the prefix pass recovers an earlier one, and it sits past `scan_end`. The balance comes out 0.00050000 short.\n\nThe case\n\nkeypool 5, pool `[0, 4]`, one block, vtx order A pays key 5, E pays key 4, B pays key 10. E is the only one found on the first pass. Its TopUp takes the pool to `[0, 9]`, which leaves B one key outside, and puts `scan_end` at E's position. The prefix pass then recovers A, which tops the pool up past key 10, but B sits after `scan_end` and is never looked at again. Master misses A and B both, and the full re-walk this branch started with finds all three.\n\nSo the documented limitation is narrower than the real one. Two channels make a transaction relevant after it has already been walked, the pool growing again and `IsFromMe`, and once the prefix has shrunk past a position it reaches neither.\n\nmolnard's update already has the fix, and it covers this case as written. The variant I ran swaps the stopping condition for nothing new matched. `collect_range_ends` and the `range_ends` map both go away, and termination stops depending on expansions at all. It is not slower either, on an ordinary rescan or on a pathological one.\n\nThe variant, and what I ran it against\n\n```cpp\nstd::vector<size_t> pending(block.vtx.size());\nfor (size_t i = 0; i < pending.size(); ++i) pending[i] = i;\nwhile (!pending.empty()) {\n    std::vector<size_t> unmatched;\n    for (size_t pos_in_block : pending) {\n        if (!SyncTransaction(block.vtx[pos_in_block], TxStateConfirmed{block_hash, block_height, static_cast<int>(pos_in_block)}, /*rescanning_old_block=*/true)) {\n            unmatched.push_back(pos_in_block);\n        }\n    }\n    if (unmatched.size() == pending.size()) break; // nothing new matched\n    pending = std::move(unmatched);\n}\n```\n\nBoth versions find the two cases with the right balance and pass your test's four parts. On the variant above, walletnotify stays at one invocation and the unit and `wallet_*` functional suites are clean.\n\nBenchmark\n\nRescanning 50 blocks of 401 transactions with one wallet receive each, the branch and the variant are indistinguishable, 0.146s against 0.143s with overlapping ranges. On a block where each pass reveals one more transaction, 30 of them behind 300 filler, the branch takes 0.126s and the variant 0.084s, ranges apart, because a transaction that has matched is never walked again. Medians of seven runs and five."
  },
  {
   "t": "2026-08-29T16:47:48Z",
   "kind": "comment",
   "who": "pablomartin4btc",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\nThanks @jeanpablojp for working on this. I'm analysing how your fix behaves against the current state of the PR and @molnard's pending follow-up, plus his earlier feedback that's already addressed. Will report back soon."
  }
 ],
 "labels_log": [
  {
   "t": "2026-08-05T17:29:05Z",
   "action": "labeled",
   "label": "Wallet",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-05T17:35:28Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-05T19:44:16Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-11T23:10:43Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-12T00:32:56Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-14T13:19:32Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 24243,
 "text_tokens_estimate": 6060,
 "changed_paths": [
  "src/wallet/wallet.cpp",
  "test/functional/test_runner.py",
  "test/functional/wallet_rescan_intrablock_ordering.py"
 ],
 "files": [
  {
   "path": "src/wallet/wallet.cpp",
   "add": 35,
   "del": 2
  },
  {
   "path": "test/functional/test_runner.py",
   "add": 1,
   "del": 0
  },
  {
   "path": "test/functional/wallet_rescan_intrablock_ordering.py",
   "add": 250,
   "del": 0
  }
 ],
 "test_lines": 251,
 "git": {
  "head": "1d9de0e62d237b4feea4d6ee5515312d7b2ba9fd",
  "head_matches_backup": true,
  "base": "87bc4c74c4dff3e5e25abc294934a02f28027a45",
  "commits": [
   {
    "sha": "c4f65f7e3b",
    "subject": "test: Wallet misses tx when look-ahead pool expands mid-block",
    "files": 2,
    "add": 174,
    "del": 0
   },
   {
    "sha": "1d9de0e62d",
    "subject": "wallet: Fix ScanForWalletTransactions when look-ahead pool expands mid-block",
    "files": 2,
    "add": 124,
    "del": 14
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "d0c1859ac2777d8e",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}