diff --git a/src/interfaces/chain.h b/src/interfaces/chain.h index 7ff9ba85a1a8..2a6d5d8d516e 100644 --- a/src/interfaces/chain.h +++ b/src/interfaces/chain.h @@ -131,6 +131,11 @@ class Chain public: virtual ~Chain() = default; + // This method will remove the specified transaction and all of its descendants + // (transactions that spend outputs from this transaction or its descendants) + // from the mempool. + virtual bool removeTxFromMempool(const uint256& txid) = 0; + //! Get current chain height, not including genesis block (returns 0 if //! chain only contains genesis block, nullopt if chain does not contain //! any blocks) diff --git a/src/ipc/capnp/chain.capnp b/src/ipc/capnp/chain.capnp index 2511b8f72b76..d2d405074ab9 100644 --- a/src/ipc/capnp/chain.capnp +++ b/src/ipc/capnp/chain.capnp @@ -70,6 +70,7 @@ interface Chain $Proxy.wrap("interfaces::Chain") { deleteRwSettings @51 (context :Proxy.Context, name :Text, action: Int32) -> (result :Bool); requestMempoolTransactions @52 (context :Proxy.Context, notifications :ChainNotifications) -> (); hasAssumedValidChain @53 (context :Proxy.Context) -> (result :Bool); + removeTxFromMempool @54 (context :Proxy.Context, txid :Data) -> (result :Bool); } interface ChainNotifications $Proxy.wrap("interfaces::Chain::Notifications") { @@ -88,10 +89,9 @@ interface ChainClient $Proxy.wrap("interfaces::ChainClient") { verify @2 (context :Proxy.Context) -> (result :Bool); load @3 (context :Proxy.Context) -> (result :Bool); start @4 (context :Proxy.Context, scheduler :Void) -> (); - flush @5 (context :Proxy.Context) -> (); - stop @6 (context :Proxy.Context) -> (); - setMockTime @7 (context :Proxy.Context, time :Int64) -> (); - schedulerMockForward @8 (context :Proxy.Context, time :Int64) -> (); + stop @5 (context :Proxy.Context) -> (); + setMockTime @6 (context :Proxy.Context, time :Int64) -> (); + schedulerMockForward @7 (context :Proxy.Context, time :Int64) -> (); } struct FeeCalculation $Proxy.wrap("FeeCalculation") { diff --git a/src/kernel/mempool_removal_reason.cpp b/src/kernel/mempool_removal_reason.cpp index df27590c7ab6..fea87bf0e742 100644 --- a/src/kernel/mempool_removal_reason.cpp +++ b/src/kernel/mempool_removal_reason.cpp @@ -16,6 +16,7 @@ std::string RemovalReasonToString(const MemPoolRemovalReason& r) noexcept case MemPoolRemovalReason::BLOCK: return "block"; case MemPoolRemovalReason::CONFLICT: return "conflict"; case MemPoolRemovalReason::REPLACED: return "replaced"; + case MemPoolRemovalReason::MANUAL: return "manual"; } assert(false); } diff --git a/src/kernel/mempool_removal_reason.h b/src/kernel/mempool_removal_reason.h index 53c2ff1c31c0..98ee531772b3 100644 --- a/src/kernel/mempool_removal_reason.h +++ b/src/kernel/mempool_removal_reason.h @@ -17,6 +17,7 @@ enum class MemPoolRemovalReason { BLOCK, //!< Removed for block CONFLICT, //!< Removed for conflict with in-block transaction REPLACED, //!< Removed for replacement + MANUAL, //!< Manually removed }; std::string RemovalReasonToString(const MemPoolRemovalReason& r) noexcept; diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index 5e44dca71f3d..626e967e2dbd 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -555,6 +555,19 @@ class ChainImpl : public Chain LOCK(::cs_main); return Assert(chainman().ActiveChain()[height])->GetBlockHash(); } + bool removeTxFromMempool(const uint256& txid) override + { + NodeContext& node = m_node; + if (!node.mempool) return false; + CTxMemPool& mempool = *node.mempool; + LOCK(mempool.cs); + CTransactionRef tx = mempool.get(txid); + if (tx) { + mempool.removeRecursive(*tx, MemPoolRemovalReason::MANUAL); + return true; + } + return false; + } bool haveBlockOnDisk(int height) override { LOCK(::cs_main); diff --git a/src/rpc/mempool.cpp b/src/rpc/mempool.cpp index e9bae93642c2..11ff2c807ab7 100644 --- a/src/rpc/mempool.cpp +++ b/src/rpc/mempool.cpp @@ -258,6 +258,49 @@ static RPCHelpMan testmempoolaccept() }; } + +static RPCHelpMan removetxfrommempool() +{ + return RPCHelpMan{"removetxfrommempool", + "Removes a transaction from the mempool.\n" + "This can be used for testing purposes, removing stuck transactions, or clearing conflicting transactions.\n" + "\nWarning: This will remove the specified transaction AND all of its descendants from the mempool.\n" + "Descendants are transactions that spend outputs from this transaction or any of its descendants.\n", + { + {"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The transaction id to remove from the mempool"}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::BOOL, "removed", "True if the transaction was in the mempool and was successfully removed"}, + } + }, + RPCExamples{ + HelpExampleCli("removetxfrommempool", "\"mytxid\"") + + HelpExampleRpc("removetxfrommempool", "\"mytxid\"") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue + { + uint256 hash = ParseHashV(request.params[0], "txid"); + NodeContext& node = EnsureAnyNodeContext(request.context); + CTxMemPool& mempool = EnsureMemPool(node); + UniValue result(UniValue::VOBJ); + bool removed = false; + { + LOCK(mempool.cs); + CTransactionRef tx = mempool.get(hash); + if (tx) { + // Transaction found in mempool, remove it and its descendants + mempool.removeRecursive(*tx, MemPoolRemovalReason::MANUAL); + removed = true; + } + } + result.pushKV("removed", removed); + return result; + }, + }; +} + static std::vector MempoolEntryDescription() { return { @@ -1146,6 +1189,7 @@ void RegisterMempoolRPCCommands(CRPCTable& t) {"blockchain", &savemempool}, {"hidden", &getorphantxs}, {"rawtransactions", &submitpackage}, + {"blockchain", &removetxfrommempool}, }; for (const auto& c : commands) { t.appendCommand(c.name, &c); diff --git a/src/test/fuzz/rpc.cpp b/src/test/fuzz/rpc.cpp index 580a6338a849..c0b6dd89228b 100644 --- a/src/test/fuzz/rpc.cpp +++ b/src/test/fuzz/rpc.cpp @@ -163,6 +163,7 @@ const std::vector RPC_COMMANDS_SAFE_FOR_FUZZING{ "prioritisetransaction", "pruneblockchain", "reconsiderblock", + "removetxfrommempool", "scanblocks", "scantxoutset", "sendmsgtopeer", // when no peers are connected, no p2p message is sent diff --git a/test/functional/rpc_mempool_info.py b/test/functional/rpc_mempool_info.py index 231d93a7b15e..67a8c6abb0f4 100755 --- a/test/functional/rpc_mempool_info.py +++ b/test/functional/rpc_mempool_info.py @@ -16,6 +16,117 @@ class RPCMempoolInfoTest(BitcoinTestFramework): def set_test_params(self): self.num_nodes = 1 + def test_removetxfrommempool(self): + """Test for removetxfrommempool RPC functionality""" + self.log.info("Starting removetxfrommempool tests") + node = self.nodes[0] + initial_mempool_size = len(node.getrawmempool()) + + # Basic transaction removal + tx1 = self.wallet.send_self_transfer(from_node=node) + txid1 = tx1["txid"] + assert txid1 in node.getrawmempool() + assert_equal(len(node.getrawmempool()), initial_mempool_size + 1) + result = node.removetxfrommempool(txid1) + assert_equal(result["removed"], True) + + # Verify transaction is no longer in mempool + assert txid1 not in node.getrawmempool() + assert_equal(len(node.getrawmempool()), initial_mempool_size) + + # Removing non-existent transaction + fake_txid = "0" * 64 + result = node.removetxfrommempool(fake_txid) + assert_equal(result["removed"], False) + + # Recursive removal (parent-child transactions) + # Create parent transaction + parent_tx = self.wallet.send_self_transfer(from_node=node) + parent_txid = parent_tx["txid"] + + # Create child transaction spending from parent + child_tx = self.wallet.send_self_transfer( + from_node=node, + utxo_to_spend=parent_tx["new_utxo"] + ) + child_txid = child_tx["txid"] + mempool = node.getrawmempool() + assert parent_txid in mempool + assert child_txid in mempool + assert_equal(len(mempool), initial_mempool_size + 2) + + # Remove parent transaction + result = node.removetxfrommempool(parent_txid) + assert_equal(result["removed"], True) + + # Verify both parent and child are removed + mempool = node.getrawmempool() + assert parent_txid not in mempool + assert child_txid not in mempool + assert_equal(len(mempool), initial_mempool_size) + + # Transaction chain removal + # Create a chain of 3 transactions: tx1 -> tx2 -> tx3 + chain_tx1 = self.wallet.send_self_transfer(from_node=node) + chain_tx2 = self.wallet.send_self_transfer( + from_node=node, + utxo_to_spend=chain_tx1["new_utxo"] + ) + chain_tx3 = self.wallet.send_self_transfer( + from_node=node, + utxo_to_spend=chain_tx2["new_utxo"] + ) + + # Verify all transactions are in mempool + mempool = node.getrawmempool() + assert_equal(len(mempool), initial_mempool_size + 3) + for tx in [chain_tx1, chain_tx2, chain_tx3]: + assert tx["txid"] in mempool + + # Remove the middle transaction (chain_tx2) + result = node.removetxfrommempool(chain_tx2["txid"]) + assert_equal(result["removed"], True) + + # chain_tx2 and chain_tx3 should be removed, chain_tx1 should remain + mempool = node.getrawmempool() + assert chain_tx1["txid"] in mempool + assert chain_tx2["txid"] not in mempool + assert chain_tx3["txid"] not in mempool + assert_equal(len(mempool), initial_mempool_size + 1) + + # Test with invalid txid format + assert_raises_rpc_error(-8, "txid must be of length 64", node.removetxfrommempool, "invalid") + + # Test with wrong length hex (too short) + assert_raises_rpc_error(-8, "txid must be of length 64", node.removetxfrommempool, "a" * 63) + + # Test with wrong length hex (too long) + assert_raises_rpc_error(-8, "txid must be of length 64", node.removetxfrommempool, "a" * 65) + + # Test with no parameters + assert_raises_rpc_error(-1, "", node.removetxfrommempool) + + # Test with valid hex but non-existent txid + valid_but_fake_txid = "a" * 64 + result = node.removetxfrommempool(valid_but_fake_txid) + assert_equal(result["removed"], False) + + # Test removing the same transaction twice + double_remove_tx = self.wallet.send_self_transfer(from_node=node) + double_remove_txid = double_remove_tx["txid"] + result1 = node.removetxfrommempool(double_remove_txid) + assert_equal(result1["removed"], True) + result2 = node.removetxfrommempool(double_remove_txid) + assert_equal(result2["removed"], False) + + # Clean up remaining transactions + node.removetxfrommempool(chain_tx1["txid"]) + + # Verify mempool is clean + assert_equal(len(node.getrawmempool()), initial_mempool_size) + + self.log.info("All removetxfrommempool tests completed successfully") + def run_test(self): self.wallet = MiniWallet(self.nodes[0]) confirmed_utxo = self.wallet.get_utxo() @@ -94,6 +205,8 @@ def create_tx(**kwargs): self.log.info("Missing txid") assert_raises_rpc_error(-3, "Missing txid", self.nodes[0].gettxspendingprevout, [{'vout' : 3}]) + self.log.info("Test removetxfrommempool RPC") + self.test_removetxfrommempool() if __name__ == '__main__': RPCMempoolInfoTest(__file__).main()