wallet: fix removeprunedfunds bug with conflicting transactions #34358

pull mzumsande wants to merge 1 commits into bitcoin:master from mzumsande:202601_importprunedfund_bug changing 2 files +37 −2
  1. mzumsande commented at 2:40 am on January 21, 2026: contributor

    removeprunedfunds removes all entries from mapTxSpends for the inputs of the pruned tx. However, this is incorrect, because there could be multiple entries from conflicting transactions (that shouldn’t be removed as well). This could lead to the wallet creating invalid transactions, trying to double spend utxos. The bug persists when the conflicting tx was mined, because the wallet trusts its internal accounting instead of calling AddToSpends again.

    The added test should fail on master.

  2. DrahtBot added the label Wallet on Jan 21, 2026
  3. DrahtBot commented at 2:40 am on January 21, 2026: contributor

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

    Code Coverage & Benchmarks

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

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK fjahr, furszy, vasild, achow101

    If your review is incorrectly listed, please copy-paste <!–meta-tag:bot-skip–> into the comment that the bot should ignore.

  4. mzumsande force-pushed on Jan 21, 2026
  5. DrahtBot added the label CI failed on Jan 21, 2026
  6. DrahtBot removed the label CI failed on Jan 21, 2026
  7. fjahr commented at 11:51 pm on January 21, 2026: contributor

    Concept ACK

    Confirmed that the new test fails on master and reviewed the test code. Need to spend some extra time with the wallet code.

  8. in test/functional/wallet_importprunedfunds.py:143 in 372843c016
    138+        addr = node.getnewaddress()
    139+        tx1_id = node.send(outputs=[{addr: 1}], inputs=[utxo])["txid"]
    140+        tx1_fee = node.gettransaction(tx1_id)["fee"]
    141+
    142+        # Create a conflicting tx
    143+        output_value = utxo["amount"] + tx1_fee - Decimal("0.00001")
    


    vasild commented at 2:04 pm on January 22, 2026:

    I was puzzled by this but then figured out that tx1_fee is negative. Maybe clarify that:

    0        # Create a conflicting tx with a larger fee (tx1_fee is negative)
    1        output_value = utxo["amount"] + tx1_fee - Decimal("0.00001")
    

    mzumsande commented at 2:08 am on January 26, 2026:
    done
  9. in src/wallet/wallet.cpp:2406 in 372843c016 outdated
    2403+            for (const auto& txin : it->second.tx->vin) {
    2404+                auto range = mapTxSpends.equal_range(txin.prevout);
    2405+                for (auto iter = range.first; iter != range.second; ++iter) {
    2406+                    if (iter->second == hash) {
    2407+                        mapTxSpends.erase(iter);
    2408+                        break;
    


    vasild commented at 2:55 pm on January 22, 2026:
    Just a question, it appears that there is an implicit constraint for mapTxSpends that it cannot contain duplicate entries - same key and same value, right? This constraint is not enforced by CWallet::AddToSpends().

    mzumsande commented at 2:07 am on January 26, 2026:
    yes, this should be enforced by the more general wallet logic, the callers of CWallet::AddToSpends(): e.g. AddToWallet() only calls AddToSpend() if fInsertedNew is set. I think if duplicates would occur, it would be a bug elsewhere. If people think that we need extra defensiveness here I could change the fix to iterate over all entries, but I think I prefer the current version.

    vasild commented at 10:55 am on January 27, 2026:
    Yeah, the current version is fine. If something is to be done about duplicates, then I guess, the proper thing is to handle those in CWallet::AddToSpends(), which seems unrelated, or at least out of scope of this PR.
  10. in test/functional/wallet_importprunedfunds.py:16 in 372843c016 outdated
    13@@ -14,6 +14,7 @@
    14 from test_framework.test_framework import BitcoinTestFramework
    15 from test_framework.util import (
    16     assert_equal,
    


    vasild commented at 2:59 pm on January 22, 2026:

    At the end of the commit message:

    … instead of calling AddToSpends again

    I am not sure I understand that. Do you imply that there is another solution, or that it would be better if AddToSpends() is called at some point “again”?


    mzumsande commented at 2:20 am on January 26, 2026:

    I meant that it would be conceivable that during block connection, the wallet would correct its mistake, and add the tx to AddToSpends() then, even if was wrong before when the txns were not confirmed. However, this is not the case, so the tx would be missing from mapTxSpends indefinitely. Which makes sense because calling AddToSpends() multiple times could create duplicates (see above), but wasn’t obvious to me from a high-level point of view.

    Besides, it could maybe make sense to refactor the whole mapTxSpends logic in a follow-up to make it less mistake-prone, but I think that would better be a separate PR.

  11. vasild approved
  12. vasild commented at 3:04 pm on January 22, 2026: contributor

    ACK 372843c0167438ebc67882663b991f2363a53c5a

    I studied the code around this and the fix looks sound. mapTxSpends could contain more than one entry for the given previout and we want to delete not all of them but just the one that corresponds to the removed transaction.

    I verified that the newly added test passes with the changes in this PR and fails without them.

    Also, this change fixes the failure in https://github.com/bitcoin/bitcoin/pull/34359

  13. DrahtBot requested review from fjahr on Jan 22, 2026
  14. wallet: fix removeprunedfunds bug with conflicting transactions
    removeprunedfunds removes all entries from mapTxSpends for the
    inputs of the pruned tx. However, this is incorrect, because there could be
    multiple entries from conflicting transactions (that shouldn't be
    removed as well). This could lead to the wallet creating invalid
    transactions, trying to double spend utxos.
    The bug persists when the conflicting tx was mined, because
    the wallet trusts its internal accounting instead of calling
    AddToSpends again.
    1f60ca360e
  15. mzumsande force-pushed on Jan 26, 2026
  16. mzumsande commented at 2:22 am on January 26, 2026: contributor

    372843c to 1f60ca3:

    just a small test doc change

  17. fjahr commented at 11:51 am on January 26, 2026: contributor

    tACK 1f60ca360eb83fa7982b1aac402eaaf477294197

    Reviewed the code and ensured that the new test fails on master. Also played around with the test code to confirm that it works with multiple inputs as well etc.

  18. DrahtBot requested review from vasild on Jan 26, 2026
  19. in src/wallet/wallet.cpp:2403 in 1f60ca360e
    2397@@ -2398,8 +2398,15 @@ util::Result<void> CWallet::RemoveTxs(WalletBatch& batch, std::vector<Txid>& txs
    2398         for (const auto& it : erased_txs) {
    2399             const Txid hash{it->first};
    2400             wtxOrdered.erase(it->second.m_it_wtxOrdered);
    2401-            for (const auto& txin : it->second.tx->vin)
    2402-                mapTxSpends.erase(txin.prevout);
    2403+            for (const auto& txin : it->second.tx->vin) {
    2404+                auto range = mapTxSpends.equal_range(txin.prevout);
    2405+                for (auto iter = range.first; iter != range.second; ++iter) {
    


    furszy commented at 5:02 pm on January 26, 2026:

    nano nit:

    0                auto [first, last] = mapTxSpends.equal_range(txin.prevout);
    1                for (auto iter = first; iter != last; ++iter) {
    
  20. in test/functional/wallet_importprunedfunds.py:151 in 1f60ca360e
    146+        tx2_id = node.sendrawtransaction(signed_tx2["hex"])
    147+        assert_not_equal(tx2_id, tx1_id)
    148+
    149+        # Both txs should be in the wallet, tx2 replaced tx1 in mempool
    150+        assert tx1_id in [tx["txid"] for tx in node.listtransactions()]
    151+        assert tx2_id in [tx["txid"] for tx in node.listtransactions()]
    


    furszy commented at 5:06 pm on January 26, 2026:

    nano nit: One RPC call instead of two.

    0available_txs_ids = {tx["txid"] for tx in node.listtransactions()}
    1assert tx1_id in available_txs_ids
    2assert tx2_id in available_txs_ids
    
  21. in test/functional/wallet_importprunedfunds.py:158 in 1f60ca360e
    153+        # Remove the replaced tx from wallet
    154+        node.removeprunedfunds(tx1_id)
    155+
    156+        # The UTXO should still be considered spent (by tx2)
    157+        available_utxos = [u["txid"] for u in node.listunspent(minconf=0)]
    158+        assert utxo["txid"] not in available_utxos, "UTXO should still be spent by conflicting tx"
    


    furszy commented at 5:07 pm on January 26, 2026:
    nano nit: Could be explicit on the tx2 existence check.
  22. furszy commented at 5:08 pm on January 26, 2026: member
    utACK 1f60ca360eb83fa7982b1aac402eaaf477294197
  23. vasild approved
  24. vasild commented at 10:59 am on January 27, 2026: contributor
    ACK 1f60ca360eb83fa7982b1aac402eaaf477294197
  25. achow101 commented at 6:50 pm on January 28, 2026: member
    ACK 1f60ca360eb83fa7982b1aac402eaaf477294197
  26. achow101 merged this on Jan 28, 2026
  27. achow101 closed this on Jan 28, 2026

  28. mzumsande commented at 1:00 am on January 29, 2026: contributor
    @fanquake maybe consider for backport (tldr: bug where the wallet may create invalid txns after removeprunedfunds was called)
  29. mzumsande deleted the branch on Jan 29, 2026
  30. fanquake commented at 10:03 am on January 29, 2026: member
    @mzumsande I’ve backported this change to 30.x in #34283.
  31. fanquake referenced this in commit 290526bc6d on Jan 29, 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-02-11 18:13 UTC

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