wallet: wrong confirmations after loading an AssumeUTXO snapshot on a stale tip #36562

issue l0rinc opened this issue on October 18, 2026
  1. l0rinc commented at 5:47 AM on October 18, 2026: contributor

    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:

    1. 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.
    2. 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>

  2. ja7ad commented at 10:20 AM on October 18, 2026: none

    @l0rinc This makes sense to me. For the two cases (when a snapshot is activated and the current tip isn't in the snapshot's history), a possible fix would be to let the wallets know that the blocks up to the fork point were disconnected, thereby removing any stale confirmations immediately. In that case, the wallet could then ignore any disconnects from background validation, as it does currently with the blocks background validation connects to. This way neither of the two problems leaves the other one hanging. Curious whether this direction sounds reasonable, or whether there is a simpler solution I'm missing.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-10-30 09:40 UTC