This is from a ci failure, but I think it can happen in reality as well. It would be trivial to fix the test, but maybe this should be fixed in the wallet?
The basic idea is that some block connection events on the scheduler thread may be processed while the wallet is failing to load due to a pre-prune locator. Thus, on the next load, the corrupt locator is used and the rescan is silently skipped.
From https://github.com/bitcoin/bitcoin/actions/runs/42834240037/job/129901508274?pr=36827#step:11:23736 shows that the balance is wrong:
File "D:\a\bitcoin\bitcoin/test/functional/wallet_assumeutxo.py", line 271, in run_test
assert_equal(w.getbalance(), 34)
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
File "D:\a\bitcoin\bitcoin\test\functional\test_framework\util.py", line 95, in assert_equal
raise AssertionError("not(%s)" % " == ".join(str(arg) for arg in (thing1, thing2) + args))
AssertionError: not(15.00000000 == 34)
<details><summary>LLM summary of the bug (experimental)</summary>
Timeline:
- dumb_sync_blocks() submits blocks through height 359.
- Their BlockConnected callbacks are queued on the scheduler.
- At 15:06:17.180791, the test performs the deliberately failing loadwallet("w").
- Loading w registers it for validation callbacks before it discovers that the required pre-snapshot blocks are unavailable.
- The scheduler then delivers already-queued notifications for blocks 357–359 to this newly registered wallet:
15:06:17.184199 BlockConnected height=357 15:06:17.184613 [w] AddToWallet ... height=357 15:06:17.184868 BlockConnected height=359 15:06:17.184911 [w] Releasing wallet w..
- The callback at height 357 finds a wallet transaction, so CWallet::blockConnected() persists the wallet’s best-block locator at 357.
- The load still fails as expected because blocks needed before the snapshot are unavailable. But the failed load has now mutated w on disk.
- After snapshot validation completes, the final loadwallet("w") trusts that poisoned locator and rescans only 357–399:
[w] Rescanning last 42 blocks (from block 357) [w] mapWallet.size() = 15
That produces exactly the observed 15 payments: heights 357, 360, …, 399.
So the problem is not getbalance, and not snapshot validation completing too early. It is a race between wallet-load failure and queued validation callbacks. The callback registration in AttachChain() happens before the availability preflight; its failing path disconnects and drains notifications, but draining allows the already-queued callbacks to update and persist the wallet.
A robust fix belongs in wallet loading: perform the “are all rescan blocks available?” preflight before registering the wallet for validation notifications, or otherwise ensure callbacks delivered during a failed attach cannot write wallet state. A test-only synchronization barrier before the expected-failing loadwallet would avoid the flake, but would mask the underlying persistence bug.
</details>