wallet: Update tx chain state during loading during AttachChain instead of before #35294

pull achow101 wants to merge 2 commits into bitcoin:master from achow101:wallet-chain-load-inconsistency changing 4 files +94 −13
  1. achow101 commented at 12:15 AM on May 15, 2026: member

    When loading a wallet, updating the state based on the chain data prior to AttachChain results in some blocks being missed, which leads to inconsistent state information that can cause assertion failures. Moving the state updating inside of AttachChain after the chain notifications handler has been attached ensures that the state will always be up to date with the tip that the wallet is tracking.

    Fixes #34599

  2. DrahtBot added the label Wallet on May 15, 2026
  3. DrahtBot commented at 12:15 AM on May 15, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35294.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    Concept ACK rkrux
    Stale ACK shuv-amp, dergoegge

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #27865 (wallet: Track no-longer-spendable TXOs separately by achow101)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. DrahtBot added the label CI failed on May 15, 2026
  5. achow101 force-pushed on May 18, 2026
  6. DrahtBot removed the label CI failed on May 18, 2026
  7. shuv-amp commented at 8:08 AM on May 19, 2026: none

    ACK 18bc686c6e97fb5522a93412240ddca99057f1b7

    Reviewed the change and tested locally.

    Moving the tx state update out of LoadToWallet() makes sense to me. During DB load, the wallet may already have a chain pointer, but it is not yet registered for chain notifications. In AttachChain(), notifications are registered before the state update and any needed rescan, so the wallet no longer updates tx states against a chain tip that can move without the wallet seeing the intervening block connections.

    The new test fails on the pre-fix parent (ddb94fd3e1) with a wallet transaction still reporting 0 confirmations after restart, and passes on this PR head:

    test/functional/wallet_chain_reprocess.py --configfile=$PWD/build/test/config.ini --timeout-factor=4
    
  8. in test/functional/wallet_chain_reprocess.py:18 in 18bc686c6e
      13 | +
      14 | +class WalletChainReprocess(BitcoinTestFramework):
      15 | +    def set_test_params(self):
      16 | +        self.setup_clean_chain = True
      17 | +        self.num_nodes = 2
      18 | +        #self.extra_args = [["-checkblockindex=0"]]
    


    rkrux commented at 1:06 PM on May 19, 2026:
    -        #self.extra_args = [["-checkblockindex=0"]]
    

    achow101 commented at 7:01 PM on May 19, 2026:

    Removed

  9. in test/functional/wallet_chain_reprocess.py:25 in 18bc686c6e
      20 | +    def skip_test_if_missing_module(self):
      21 | +        self.skip_if_no_wallet()
      22 | +
      23 | +    def test_process_all_blocks_after_unclean(self):
      24 | +        self.log.info("Testing that all blocks are reprocessed by the wallet after an unclean shutdown")
      25 | +        self.nodes[0].createwallet("target", load_on_startup=False)
    


    rkrux commented at 1:08 PM on May 19, 2026:
    -        self.nodes[0].createwallet("target", load_on_startup=False)
    +        self.nodes[0].createwallet("target")
    

    This argument doesn't seem necessary.


    achow101 commented at 7:01 PM on May 19, 2026:

    Removed

  10. in test/functional/wallet_chain_reprocess.py:36 in 18bc686c6e
      31 | +        self.restart_node(0)
      32 | +        self.connect_nodes(0, 1)
      33 | +        self.nodes[0].loadwallet("target")
      34 | +        wallet = self.nodes[0].get_wallet_rpc("target")
      35 | +
      36 | +        # mine 1000 blocks, include a transaction in each one that is a child
    


    rkrux commented at 1:08 PM on May 19, 2026:

    Is there a particular reason to mine 1000 blocks?


    rkrux commented at 2:31 PM on May 19, 2026:

    updating the state based on the chain data prior to AttachChain results in some blocks being missed

    I believe the broad reason is to make the processing of few blocks by the wallet to be missed (on master without the fix here). Ultimately, this can happen as long as there are enough blocks for the node to process (post an unclean shutdown) so that block processing keeps on happening even after the loading of wallet (for the sync_all to come into effect).

    For which I think a lower number can also be sufficient - 100 works on my system. I checked it by comparing the node's chain height right before the sync_all call on line 56 and the node's chain height before the unclean shutdown - it showed a more than 50 block difference.

    Either way, it would be very helpful to mention the reason as a comment in the test here for review and future reference.


    achow101 commented at 7:02 PM on May 19, 2026:

    Yes, it's so that there's enough blocks that on the restart, the node is still processing blocks while and after the wallet is loading. I chose 1000 because that was a number very likely to trigger the problem.

    Added a comment and reduced to 100.

  11. rkrux commented at 1:12 PM on May 19, 2026: contributor

    Concept ACK 18bc686c6e97fb5522a93412240ddca99057f1b7

    This is indeed quite a subtle and peculiar issue!

  12. wallet: Update tx chain state after chain notifications are attached
    Updating transactions against the current chain state can only be done
    in AttachChain after chain notifications have been attached. AttachChain
    will guarantee that any blocks missing between the stored best block and
    the current tip are rescanned, and any blocks connected during
    AttachChain will be processed after loading completes.
    
    This fixes an issue where tx states were updated with no guarantee that
    any blocks connected prior to AttachChain are scanned, resulting in
    incorrect tx states.
    1a35042f5a
  13. in src/wallet/wallet.cpp:3261 in 03a92323af
    3253 | @@ -3258,6 +3254,18 @@ bool CWallet::AttachChain(const std::shared_ptr<CWallet>& walletInstance, interf
    3254 |          walletInstance->SetLastBlockProcessedInMem(-1, uint256());
    3255 |      }
    3256 |  
    3257 | +    // Update the state of every transaction now that we are connected to the chain
    3258 | +    // Do this in reverse order to ensure that any abandoning will work recursively
    3259 | +    for (auto& [_, wtx] : std::ranges::reverse_view(walletInstance->wtxOrdered)) {
    3260 | +        wtx->updateState(chain);
    3261 | +        // After loading updating the state of all txs, abandon any coinbase that is no longer in the active chain.
    


    rkrux commented at 2:11 PM on May 19, 2026:

    After loading updating the state of all txs

    This doesn't seem entirely correct now because the state of all transactions are not updated due to the following abandon call being present in the same loop that also updates the transaction state.


    achow101 commented at 7:02 PM on May 19, 2026:

    Updated

  14. achow101 force-pushed on May 19, 2026
  15. fanquake commented at 10:10 AM on May 21, 2026: member

    @dergoegge CI is passing now, so can run through Antithesis?

  16. dergoegge commented at 10:27 AM on May 21, 2026: member

    so can run through Antithesis?

    Running

  17. dergoegge approved
  18. dergoegge commented at 12:40 PM on May 22, 2026: member

    tACK 4de5c3b692b6958b36451a0bd6ac368f8f605b4f

    This fixes the issue.

  19. DrahtBot requested review from rkrux on May 22, 2026
  20. in test/functional/wallet_chain_reprocess.py:36 in 4de5c3b692
      31 | +        self.nodes[0].loadwallet("target")
      32 | +        wallet = self.nodes[0].get_wallet_rpc("target")
      33 | +
      34 | +        # In order to ensure that wallet transaction states match the chain, we want to mine
      35 | +        # enough blocks such that the chainstate will still be moving forward while the
      36 | +        # wallet is loading. 1000 blocks should be sufficiednt.
    


    rkrux commented at 11:23 AM on May 25, 2026:

    In 4de5c3b692b6958b36451a0bd6ac368f8f605b4f "test: Check all wallet tx states are correct after unclean shutdown"

    s/1000/100 s/sufficiednt/sufficient


    achow101 commented at 7:52 PM on May 26, 2026:

    Done

  21. in test/functional/wallet_chain_reprocess.py:48 in 4de5c3b692 outdated
      43 | +            txids.append(res["txid"])
      44 | +            self.generate(self.nodes[0], 1, sync_fun=self.no_op)
      45 | +
      46 | +        # Sync nodes, kill node 0, and restart
      47 | +        self.sync_all()
      48 | +        wallet.unloadwallet()
    


    rkrux commented at 12:34 PM on May 25, 2026:

    In 4de5c3b "test: Check all wallet tx states are correct after unclean shutdown"

    Nit: I don't believe unloading the wallet is necessary because all the blocks contain wallet transaction so the best block will be written to disk on every block connected notification just like unloading the wallet would do.

    -        wallet.unloadwallet()
    

    achow101 commented at 7:35 PM on May 26, 2026:

    Since the tests run with -unsafesqlitesync, not all data will be flushed at any given time. While we don't currently fsync everything on a wallet close (we probably should though), unloading the wallet at least calls sqlite3_close which should help to guarantee that all of the wallet data is persisted. My main concern is that killing the process while the wallet is loaded might result in data loss which could cause the test to fail intermittently or behave unexepctedly. Unloading the wallet should help mitigate such issues.

  22. in test/functional/wallet_chain_reprocess.py:51 in 4de5c3b692 outdated
      46 | +        # Sync nodes, kill node 0, and restart
      47 | +        self.sync_all()
      48 | +        wallet.unloadwallet()
      49 | +        self.nodes[0].kill_process()
      50 | +        self.start_node(0)
      51 | +        self.connect_nodes(0, 1)
    


    rkrux commented at 12:37 PM on May 25, 2026:

    Considering the subtleness of the issue, can we add few assertions here to make it explicit for the reader regarding node/wallet height states? Maybe the following diff:

    diff --git a/test/functional/wallet_chain_reprocess.py b/test/functional/wallet_chain_reprocess.py
    index be4c748fca..1158fa964c 100755
    --- a/test/functional/wallet_chain_reprocess.py
    +++ b/test/functional/wallet_chain_reprocess.py
    @@ -45,13 +45,20 @@ class WalletChainReprocess(BitcoinTestFramework):
     
             # Sync nodes, kill node 0, and restart
             self.sync_all()
    +        node_chain_height_before_kill = self.nodes[0].getblockchaininfo()["blocks"]
    +        wallet_scan_height_before_kill = wallet.getwalletinfo()["lastprocessedblock"]["height"]
    +        assert_equal(wallet_scan_height_before_kill, node_chain_height_before_kill)
             self.nodes[0].kill_process()
             self.start_node(0)
             self.connect_nodes(0, 1)
    +        node_chain_height_after_kill = self.nodes[0].getblockchaininfo()["blocks"]
    +        assert_greater_than(node_chain_height_before_kill, node_chain_height_after_kill)
    +        assert_greater_than(wallet_scan_height_before_kill, node_chain_height_after_kill)
     
             self.nodes[0].loadwallet("target")
             wallet = self.nodes[0].get_wallet_rpc("target")
    +        wallet_scan_height_after_kill = wallet.getwalletinfo()["lastprocessedblock"]["height"]
    +        assert_greater_than(wallet_scan_height_before_kill, wallet_scan_height_after_kill)
     
             # Wait for node0 to be synced
             self.sync_all()
    

    achow101 commented at 7:53 PM on May 26, 2026:

    Done

  23. rkrux commented at 12:43 PM on May 25, 2026: contributor

    LGTM broadly 4de5c3b692b6958b36451a0bd6ac368f8f605b4f.

    Few minor suggestions.

  24. DrahtBot requested review from rkrux on May 25, 2026
  25. achow101 force-pushed on May 26, 2026
  26. DrahtBot added the label CI failed on May 26, 2026
  27. achow101 force-pushed on May 26, 2026
  28. in test/functional/wallet_chain_reprocess.py:56 in 33a0ce85ec outdated
      51 | +        wallet.unloadwallet()
      52 | +        self.nodes[0].kill_process()
      53 | +        self.start_node(0)
      54 | +        self.connect_nodes(0, 1)
      55 | +        restart_tip_height = self.nodes[0].getblockchaininfo()["blocks"]
      56 | +        assert_greater_than(tip_height, restart_tip_height)
    


    maflcko commented at 8:30 AM on May 28, 2026:

    https://github.com/bitcoin/bitcoin/actions/runs/26480361555/job/77976026440?pr=35294#step:9:3403:

    216/469 - wallet_chain_reprocess.py failed, Duration: 3 s
    
    stdout:
    2026-05-26T23:34:49.429029Z TestFramework (INFO): PRNG seed is: 2150906247185331291
    2026-05-26T23:34:49.429675Z TestFramework (INFO): Initializing test directory /Users/runner/work/bitcoin/bitcoin/repo_archive/ci/scratch_ ₿🧪_/test_runner/test_runner_₿_🏃_20260526_232554/wallet_chain_reprocess_251
    2026-05-26T23:34:49.962081Z TestFramework (INFO): Testing that all blocks are reprocessed by the wallet after an unclean shutdown
    2026-05-26T23:34:52.293951Z TestFramework (ERROR): Unexpected exception:
    Traceback (most recent call last):
      File "/Users/runner/work/bitcoin/bitcoin/repo_archive/test/functional/test_framework/test_framework.py", line 143, in main
        self.run_test()
        ~~~~~~~~~~~~~^^
      File "/Users/runner/work/bitcoin/bitcoin/repo_archive/ci/scratch_ ₿🧪_/build-aarch64-apple-darwin24.6.0/test/functional/wallet_chain_reprocess.py", line 76, in run_test
        self.test_process_all_blocks_after_unclean()
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
      File "/Users/runner/work/bitcoin/bitcoin/repo_archive/ci/scratch_ ₿🧪_/build-aarch64-apple-darwin24.6.0/test/functional/wallet_chain_reprocess.py", line 56, in test_process_all_blocks_after_unclean
        assert_greater_than(tip_height, restart_tip_height)
        ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/Users/runner/work/bitcoin/bitcoin/repo_archive/test/functional/test_framework/util.py", line 96, in assert_greater_than
        raise AssertionError("%s <= %s" % (str(thing1), str(thing2)))
    AssertionError: 302 <= 302
    

    achow101 commented at 6:43 PM on May 28, 2026:

    Hopefully fixed

  29. achow101 force-pushed on May 28, 2026
  30. DrahtBot removed the label CI failed on May 28, 2026
  31. in src/wallet/wallet.cpp:3259 in 1a35042f5a outdated
    3253 | @@ -3263,6 +3254,18 @@ bool CWallet::AttachChain(const std::shared_ptr<CWallet>& walletInstance, interf
    3254 |          walletInstance->SetLastBlockProcessedInMem(-1, uint256());
    3255 |      }
    3256 |  
    3257 | +    // Update the state of every transaction now that we are connected to the chain
    3258 | +    // Do this in reverse order to ensure that any abandoning will work recursively
    3259 | +    for (auto& [_, wtx] : std::ranges::reverse_view(walletInstance->wtxOrdered)) {
    


    l0rinc commented at 3:48 PM on June 5, 2026:

    1a35042 wallet: Update tx chain state after chain notifications are attached:

    Do we need the reverse traversal here, or could we do the state updates and abandonments in separate passes instead? It seems simpler to first update every transaction state, then run the inactive coinbase abandonment pass afterward.

    diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
    --- a/src/wallet/wallet.cpp	(revision d33afaf84e54c0262f3b6f1e93ac2edf877747de)
    +++ b/src/wallet/wallet.cpp	(revision 866e54d2e3f0851256950ad6c737c6032f67527d)
    @@ -76,7 +76,6 @@
     #include <condition_variable>
     #include <exception>
     #include <optional>
    -#include <ranges>
     #include <stdexcept>
     #include <thread>
     #include <tuple>
    @@ -3254,17 +3253,18 @@
             walletInstance->SetLastBlockProcessedInMem(-1, uint256());
         }
     
    -    // Update the state of every transaction now that we are connected to the chain
    -    // Do this in reverse order to ensure that any abandoning will work recursively
    -    for (auto& [_, wtx] : std::ranges::reverse_view(walletInstance->wtxOrdered)) {
    -        wtx->updateState(chain);
    -        // After updating the state of the tx, abandon if it is a coinbase that is no longer in the active chain.
    -        // This could happen during an external wallet load, or if the user replaced the chain data.
    -        if (wtx->IsCoinBase() && wtx->isInactive()) {
    -            walletInstance->AbandonTransaction(*wtx);
    +    // Update the state of every transaction now that we are connected to the chain.
    +    for (auto& [_, wtx] : walletInstance->mapWallet) {
    +        wtx.updateState(chain);
    +    }
    +
    +    // After updating all tx records, abandon any coinbase that is no longer in the active chain.
    +    // This could happen during an external wallet load, or if the user replaced the chain data.
    +    for (auto& [_, wtx] : walletInstance->mapWallet) {
    +        if (wtx.IsCoinBase() && wtx.isInactive()) {
    +            walletInstance->AbandonTransaction(wtx);
             }
         }
    -
     
         if (tip_height && *tip_height != rescan_height)
         {
    

    achow101 commented at 6:45 PM on June 5, 2026:

    We want to minimize iterating the entire set of transactions since there can be a lot of them, and be very slow. Iterating in reverse order allows us to do both at the same time.

  32. in test/functional/wallet_chain_reprocess.py:64 in d33afaf84e outdated
      59 | +        self.nodes[0].loadwallet("target")
      60 | +        wallet = self.nodes[0].get_wallet_rpc("target")
      61 | +        assert_greater_than(wallet_height, wallet.getwalletinfo()["lastprocessedblock"]["height"])
      62 | +
      63 | +        # Wait for node0 to be synced
      64 | +        self.sync_all()
    


    l0rinc commented at 3:58 PM on June 5, 2026:

    Can we also check that the wallet's processed block has caught back up to the node tip after the restart catch-up path?

    diff --git a/test/functional/wallet_chain_reprocess.py b/test/functional/wallet_chain_reprocess.py
    --- a/test/functional/wallet_chain_reprocess.py	(revision 866e54d2e3f0851256950ad6c737c6032f67527d)
    +++ b/test/functional/wallet_chain_reprocess.py	(revision 6c6e9e004a4c698a4a1a691d1fb09863271d173f)
    @@ -62,6 +62,7 @@
     
             # Wait for node0 to be synced
             self.sync_all()
    +        assert_equal(wallet.getwalletinfo()["lastprocessedblock"]["height"], self.nodes[0].getblockchaininfo()["blocks"])
     
             # Check every transaction is confirmed
             for txid in txids:
    

    achow101 commented at 6:47 PM on June 5, 2026:

    Done

  33. l0rinc approved
  34. l0rinc commented at 4:07 PM on June 5, 2026: contributor

    I just jumped here for a different reason, the change makes high-level sense, but I'm not well-versed enough with the details to provide a more meaningful review, left two nits.

  35. test: Check all wallet tx states are correct after unclean shutdown 3556f80f0e
  36. achow101 force-pushed on Jun 5, 2026
  37. DrahtBot added the label CI failed on Jun 5, 2026

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-06-07 11:53 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me