Merge bitcoin/bitcoin#34371: wallet: allow importprunedfunds for spending transactions
What changed, and why it matters
This change fixes a Bitcoin Core wallet bug where the `importprunedfunds` RPC command could only re-import transactions that sent money to the wallet, not transactions that spent money from it. After this fix, both incoming and outgoing transactions can be re-imported, so a pruned wallet's balance and transaction history stay accurate.
No immediate security response required; this is a correctness fix for wallet balance accuracy. Users running pruned wallets who rely on `importprunedfunds` should upgrade to a release containing this fix to avoid incorrect balances after removing spending transactions.
Security signals we found
Logic bug in wallet transaction import scope
Incorrect balance possible after removing and re-importing spending transaction
Fix routes import through existing involvement check (IsMine + IsFromMe)
Functional test added for spending-transaction re-import
Evidence from the diff
The patch changes importprunedfunds in src/wallet/rpc/backup.cpp to route imported transactions through AddToWalletIfInvolvingMe() instead of directly calling AddToWallet() after an IsMine() check. AddToWalletIfInvolvingMe() already checks both IsMine() and IsFromMe(), so spending transactions (which may have no wallet-owned outputs) are now accepted. The method declaration is moved from the private to the public section of CWallet in wallet.h so it can be called from the RPC code. A functional test is added that creates a transaction spending from the wallet with no outputs back to it, removes it with removeprunedfunds, and successfully re-imports it with importprunedfunds.
Changed components
src/wallet/rpc/backup.cppsrc/wallet/wallet.htest/functional/wallet_importprunedfunds.pyInspect captured patch +36 / −20
### src/wallet/rpc/backup.cpp
@@ -83,9 +83,9 @@ RPCMethod importprunedfunds()
unsigned int txnIndex = vIndex[it - vMatch.begin()];
- CTransactionRef tx_ref = MakeTransactionRef(tx);
- if (pwallet->IsMine(*tx_ref)) {
- pwallet->AddToWallet(std::move(tx_ref), TxStateConfirmed{merkleBlock.header.GetHash(), height, static_cast<int>(txnIndex)});
+ const CTransactionRef tx_ref = MakeTransactionRef(tx);
+ auto tx_state = TxStateConfirmed{merkleBlock.header.GetHash(), height, static_cast<int>(txnIndex)};
+ if (pwallet->AddToWalletIfInvolvingMe(tx_ref, tx_state, /*rescanning_old_block=*/false)) {
return UniValue::VNULL;
}
### src/wallet/wallet.h
@@ -345,23 +345,6 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
void AddToSpends(const COutPoint& outpoint, const Txid& txid) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
void AddToSpends(const CWalletTx& wtx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
- /**
- * Add a transaction to the wallet, or update it. confirm.block_* should
- * be set when the transaction was known to be included in a block. When
- * block_hash.IsNull(), then wallet state is not updated in AddToWallet, but
- * notifications happen and cached balances are marked dirty.
- *
- * TODO: One exception to this is that the abandoned state is cleared under the
- * assumption that any further notification of a transaction that was considered
- * abandoned is an indication that it is not safe to be considered abandoned.
- * Abandoned state should probably be more carefully tracked via different
- * chain notifications or by checking mempool presence when necessary.
- *
- * Should be called with rescanning_old_block set to true, if the transaction is
- * not discovered in real time, but during a rescan of old blocks.
- */
- bool AddToWalletIfInvolvingMe(const CTransactionRef& tx, const SyncTxState& state, bool rescanning_old_block) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
-
/** Mark a transaction (and its in-wallet descendants) as conflicting with a particular block. */
void MarkConflicted(const uint256& hashBlock, int conflicting_height, const Txid& hashTx);
@@ -622,6 +605,23 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
* @return the recently added wtx pointer or nullptr if there was a db write error.
*/
CWalletTx* AddToWallet(CTransactionRef tx, const TxState& state, const UpdateWalletTxFn& update_wtx=nullptr, bool rescanning_old_block = false);
+
+ /**
+ * Add a transaction to the wallet, or update it. confirm.block_* should
+ * be set when the transaction was known to be included in a block. When
+ * block_hash.IsNull(), then wallet state is not updated in AddToWallet, but
+ * notifications happen and cached balances are marked dirty.
+ *
+ * TODO: One exception to this is that the abandoned state is cleared under the
+ * assumption that any further notification of a transaction that was considered
+ * abandoned is an indication that it is not safe to be considered abandoned.
+ * Abandoned state should probably be more carefully tracked via different
+ * chain notifications or by checking mempool presence when necessary.
+ *
+ * Should be called with rescanning_old_block set to true, if the transaction is
+ * not discovered in real time, but during a rescan of old blocks.
+ */
+ bool AddToWalletIfInvolvingMe(const CTransactionRef& tx, const SyncTxState& state, bool rescanning_old_block) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
bool LoadToWallet(CWalletTx&& wtx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
void transactionAddedToMempool(const CTransactionRef& tx) override;
void blockConnected(const kernel::ChainstateRole& role, const interfaces::BlockInfo& block) override;
### test/functional/wallet_importprunedfunds.py
@@ -16,6 +16,7 @@
assert_equal,
assert_not_equal,
assert_raises_rpc_error,
+ find_vout_for_address,
wallet_importprivkey,
)
from test_framework.wallet_util import generate_keypair
@@ -119,6 +120,21 @@ def run_test(self):
w1.removeprunedfunds(txnid3)
assert txnid3 not in [tx['txid'] for tx in w1.listtransactions()]
+ # Import a spending transaction with no wallet outputs (issue #21647)
+ txid = self.nodes[0].sendtoaddress(address3, 0.1)
+ vout = find_vout_for_address(self.nodes[0], txid, address3)
+ self.generate(self.nodes[0], 1)
+ external_addr = self.nodes[0].getnewaddress()
+ spend_txid = w1.sendall(recipients=[external_addr], inputs=[{"txid": txid, "vout": vout}])["txid"]
+ self.sync_mempools()
+ self.generate(self.nodes[0], 1)
+ spend_raw = w1.gettransaction(spend_txid)['hex']
+ spend_proof = self.nodes[0].gettxoutproof([spend_txid])
+ w1.removeprunedfunds(spend_txid)
+ assert not [tx for tx in w1.listtransactions() if tx['txid'] == spend_txid]
+ w1.importprunedfunds(spend_raw, spend_proof)
+ assert [tx for tx in w1.listtransactions() if tx['txid'] == spend_txid]
+
# Check various RPC parameter validation errors
assert_raises_rpc_error(-22, "TX decode failed", w1.importprunedfunds, b'invalid tx'.hex(), proof1)
assert_raises_rpc_error(-5, "Transaction given doesn't exist in proof", w1.importprunedfunds, rawtxn2, proof1)Why this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.