Problem
loadtxoutset can be used on a node that hasn't synced past the snapshot block yet, and its existing chainstate then keeps validating in the background, starting from its current tip.
If that tip isn't part of the snapshot's history (e.g. because it was reorged out while the node was offline), loaded wallets keep the stale history's confirmations and lose some of the real ones.
This produces two failures:
- Stale confirmations after snapshot activation. Loaded wallets receive no chainstate-transition notification when the snapshot replaces the stale history, so the stale transactions keep gaining confirmations: in the test below, the coinbase of a stale block at height 200 shows 200 confirmations at height 399.
- Lost confirmations during background validation. When background validation later disconnects the stale block, wallets handle it as an active reorg but ignore the blocks it connects afterwards. A transaction mined in both the stale block and the snapshot chain then shows 0 confirmations until a rescan, even across restarts, and another wallet's 34 BTC balance shows 0 until the next block.
Expected behavior
Loading a snapshot should clear confirmations from blocks outside its history, and background validation should leave wallets alone, as it already does for block connections. Fixing only the latter would make the former permanent, since the background disconnect is what eventually clears those confirmations today.
Scope
Since the snapshot block has to be in the node's best header chain, this only happens when the snapshot is loaded before the node has reorged away from its stale tip (e.g. right after restarting).
A service that acts on wallet confirmations can potentially lose funds by releasing value for an orphaned payment shown as confirmed, or by retrying or refunding a real payment shown as unconfirmed. The affected code hasn't changed since v26.0, and mainnet snapshots have been loadable since v28.0.
This differs from #28598, which is about showing transactions from the not-yet-validated snapshot chain as confirmed.
<details> <summary>Functional test reproducing stale and lost confirmations</summary>
Passes on master and v31.1, with TODOs marking the correct values for assertions that reproduce the bugs.
diff --git a/test/functional/wallet_assumeutxo.py b/test/functional/wallet_assumeutxo.py
index 9c647fa22d8..4d449003f38 100755
--- a/test/functional/wallet_assumeutxo.py
+++ b/test/functional/wallet_assumeutxo.py
@@ -272,10 +272,36 @@ class AssumeutxoTest(BitcoinTestFramework):
self.log.info("Check balance of a wallet that is active during snapshot completion")
n2.restorewallet("w", "backup_w.dat")
- loaded = n2.loadtxoutset(dump_output['path'])
- self.connect_nodes(0, 2)
- self.wait_until(lambda: len(n2.getchainstates()['chainstates']) == 1)
- ensure_for(duration=1, f=lambda: (n2.getbalance() == 34))
+ w = n2.get_wallet_rpc("w")
+ n2.createwallet("watch", disable_private_keys=True)
+ watch = n2.get_wallet_rpc("watch")
+ watch.importdescriptors([{"desc": self.mini_wallet.get_descriptor(), "timestamp": "now"}]) # watches MiniWallet's address
+
+ # n2 (tip 199) mines a stale block 200 that also contains a transaction of the snapshot chain's block 200
+ shared_tx = n0.getblock(n0.getblockhash(200), 2)["tx"][1]
+ stale_block = self.generateblock(n2, output=self.mini_wallet.get_address(), transactions=[shared_tx["hex"]], sync_fun=self.no_op)["hash"]
+ stale_coinbase = n2.getblock(stale_block)["tx"][0]
+
+ n2.loadtxoutset(dump_output['path'])
+ dumb_sync_blocks(src=n0, dst=n2) # active chain reaches 399 before background validation starts
+ assert_equal(n2.getblockheader(stale_block)["confirmations"], -1)
+ assert_equal(watch.gettransaction(stale_coinbase)["confirmations"], 200) # TODO: should be 0
+
+ self.complete_background_validation(n2) # disconnects the stale block, then connects 200..299
+ assert_equal(n2.getrawtransaction(shared_tx["txid"], 1, n2.getblockhash(200))["confirmations"], 200)
+ assert_equal(watch.gettransaction(shared_tx["txid"])["confirmations"], 0) # TODO: should be 200
+ assert_equal(w.getwalletinfo()["lastprocessedblock"]["height"], 199) # TODO: should be 399
+ ensure_for(duration=1, f=lambda: w.getbalance() == 0) # TODO: should be 34
+
+ self.disconnect_nodes(0, 2)
+ self.generate(n2, 1, sync_fun=self.no_op)
+ assert_equal(w.getbalance(), 34) # the next block restores the wallet tip
+ self.restart_node(2)
+ n2.loadwallet("watch")
+ watch = n2.get_wallet_rpc("watch")
+ assert_equal(watch.gettransaction(shared_tx["txid"])["confirmations"], 0) # TODO: should be 201, persists across restarts
+ watch.rescanblockchain(200, 200)
+ assert_equal(watch.gettransaction(shared_tx["txid"])["confirmations"], 201)
self.log.info("Ensuring descriptors can be loaded after background sync")
n1.loadwallet(wallet_name)
</details>