From 290526bc6dec1cdf0f6db8415b05a794260de14a Mon Sep 17 00:00:00 2001 From: Martin Zumsande Date: Wed, 21 Jan 2026 08:20:44 +0700 Subject: [PATCH] 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. Github-Pull: #34358 Rebased-From: 1f60ca360eb83fa7982b1aac402eaaf477294197 --- src/wallet/wallet.cpp | 11 ++++++-- test/functional/wallet_importprunedfunds.py | 28 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 622ee963afa..15ee7a4eedc 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2383,8 +2383,15 @@ util::Result CWallet::RemoveTxs(WalletBatch& batch, std::vector& txs for (const auto& it : erased_txs) { const Txid hash{it->first}; wtxOrdered.erase(it->second.m_it_wtxOrdered); - for (const auto& txin : it->second.tx->vin) - mapTxSpends.erase(txin.prevout); + for (const auto& txin : it->second.tx->vin) { + auto range = mapTxSpends.equal_range(txin.prevout); + for (auto iter = range.first; iter != range.second; ++iter) { + if (iter->second == hash) { + mapTxSpends.erase(iter); + break; + } + } + } for (unsigned int i = 0; i < it->second.tx->vout.size(); ++i) { m_txos.erase(COutPoint(hash, i)); } diff --git a/test/functional/wallet_importprunedfunds.py b/test/functional/wallet_importprunedfunds.py index d45be4aa188..060549d3b64 100755 --- a/test/functional/wallet_importprunedfunds.py +++ b/test/functional/wallet_importprunedfunds.py @@ -14,6 +14,7 @@ from test_framework.messages import ( from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, + assert_not_equal, assert_raises_rpc_error, wallet_importprivkey, ) @@ -129,6 +130,33 @@ class ImportPrunedFundsTest(BitcoinTestFramework): mb.header.nTime += 1 # modify arbitrary block header field to change block hash assert_raises_rpc_error(-5, "Block not found in chain", w1.importprunedfunds, rawtxn1, mb.serialize().hex()) + self.log.info("Test removeprunedfunds with conflicting transactions") + node = self.nodes[0] + + # Create a transaction + utxo = node.listunspent()[0] + addr = node.getnewaddress() + tx1_id = node.send(outputs=[{addr: 1}], inputs=[utxo])["txid"] + tx1_fee = node.gettransaction(tx1_id)["fee"] + + # Create a conflicting tx with a larger fee (tx1_fee is negative) + output_value = utxo["amount"] + tx1_fee - Decimal("0.00001") + raw_tx2 = node.createrawtransaction(inputs=[utxo], outputs=[{addr: output_value}]) + signed_tx2 = node.signrawtransactionwithwallet(raw_tx2) + tx2_id = node.sendrawtransaction(signed_tx2["hex"]) + assert_not_equal(tx2_id, tx1_id) + + # Both txs should be in the wallet, tx2 replaced tx1 in mempool + assert tx1_id in [tx["txid"] for tx in node.listtransactions()] + assert tx2_id in [tx["txid"] for tx in node.listtransactions()] + + # Remove the replaced tx from wallet + node.removeprunedfunds(tx1_id) + + # The UTXO should still be considered spent (by tx2) + available_utxos = [u["txid"] for u in node.listunspent(minconf=0)] + assert utxo["txid"] not in available_utxos, "UTXO should still be spent by conflicting tx" + if __name__ == '__main__': ImportPrunedFundsTest(__file__).main()