From 573bb542be80b63b1713a0b76bedaa5e37c3783f Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sun, 25 Jan 2026 13:17:52 -0500 Subject: [PATCH 1/7] net: Store recipient node address in private broadcast --- src/net_processing.cpp | 2 +- src/private_broadcast.cpp | 4 ++-- src/private_broadcast.h | 7 +++++-- src/test/private_broadcast_tests.cpp | 10 ++++++---- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index e5b4bc7772d..1aefbf77cb3 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -3531,7 +3531,7 @@ void PeerManagerImpl::PushPrivateBroadcastTx(CNode& node) { Assume(node.IsPrivateBroadcastConn()); - const auto opt_tx{m_tx_for_private_broadcast.PickTxForSend(node.GetId())}; + const auto opt_tx{m_tx_for_private_broadcast.PickTxForSend(node.GetId(), CService{node.addr})}; if (!opt_tx) { LogDebug(BCLog::PRIVBROADCAST, "Disconnecting: no more transactions for private broadcast (connected in vain), peer=%d%s", node.GetId(), node.LogIP(fLogIPs)); node.fDisconnect = true; diff --git a/src/private_broadcast.cpp b/src/private_broadcast.cpp index c7c311c0e89..fd36a94088e 100644 --- a/src/private_broadcast.cpp +++ b/src/private_broadcast.cpp @@ -31,7 +31,7 @@ std::optional PrivateBroadcast::Remove(const CTransactionRef& tx) return std::nullopt; } -std::optional PrivateBroadcast::PickTxForSend(const NodeId& will_send_to_nodeid) +std::optional PrivateBroadcast::PickTxForSend(const NodeId& will_send_to_nodeid, const CService& will_send_to_address) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) { LOCK(m_mutex); @@ -43,7 +43,7 @@ std::optional PrivateBroadcast::PickTxForSend(const NodeId& wil if (it != m_transactions.end()) { auto& [tx, sent_to]{*it}; - sent_to.emplace_back(will_send_to_nodeid, NodeClock::now()); + sent_to.emplace_back(will_send_to_nodeid, will_send_to_address, NodeClock::now()); return tx; } diff --git a/src/private_broadcast.h b/src/private_broadcast.h index e88db6bbb7b..3beed5eef4c 100644 --- a/src/private_broadcast.h +++ b/src/private_broadcast.h @@ -54,9 +54,11 @@ public: * and oldest send/confirm times. * @param[in] will_send_to_nodeid Will remember that the returned transaction * was picked for sending to this node. + * @param[in] will_send_to_address Address of the peer to which this transaction + * will be sent. * @return Most urgent transaction or nullopt if there are no transactions. */ - std::optional PickTxForSend(const NodeId& will_send_to_nodeid) + std::optional PickTxForSend(const NodeId& will_send_to_nodeid, const CService& will_send_to_address) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex); /** @@ -99,10 +101,11 @@ private: /// Status of a transaction sent to a given node. struct SendStatus { const NodeId nodeid; /// Node to which the transaction will be sent (or was sent). + const CService address; /// Address of the node. const NodeClock::time_point picked; ///< When was the transaction picked for sending to the node. std::optional confirmed; ///< When was the transaction reception confirmed by the node (by PONG). - SendStatus(const NodeId& nodeid, const NodeClock::time_point& picked) : nodeid{nodeid}, picked{picked} {} + SendStatus(const NodeId& nodeid, const CService& address, const NodeClock::time_point& picked) : nodeid{nodeid}, address{address}, picked{picked} {} }; /// Cumulative stats from all the send attempts for a transaction. Used to prioritize transactions. diff --git a/src/test/private_broadcast_tests.cpp b/src/test/private_broadcast_tests.cpp index 6c5ef36fc34..e0ab64cd4c2 100644 --- a/src/test/private_broadcast_tests.cpp +++ b/src/test/private_broadcast_tests.cpp @@ -29,9 +29,10 @@ BOOST_AUTO_TEST_CASE(basic) PrivateBroadcast pb; const NodeId recipient1{1}; + const CService addr1{}; // No transactions initially. - BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1).has_value()); + BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1, /*will_send_to_address=*/addr1).has_value()); BOOST_CHECK_EQUAL(pb.GetStale().size(), 0); BOOST_CHECK(!pb.HavePendingTransactions()); @@ -48,12 +49,13 @@ BOOST_AUTO_TEST_CASE(basic) BOOST_CHECK(pb.Add(tx2)); - const auto tx_for_recipient1{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1).value()}; + const auto tx_for_recipient1{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1, /*will_send_to_address=*/addr1).value()}; BOOST_CHECK(tx_for_recipient1 == tx1 || tx_for_recipient1 == tx2); // A second pick must return the other transaction. const NodeId recipient2{2}; - const auto tx_for_recipient2{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient2).value()}; + const CService addr2{}; + const auto tx_for_recipient2{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient2, /*will_send_to_address=*/addr2).value()}; BOOST_CHECK(tx_for_recipient2 == tx1 || tx_for_recipient2 == tx2); BOOST_CHECK_NE(tx_for_recipient1, tx_for_recipient2); @@ -90,7 +92,7 @@ BOOST_AUTO_TEST_CASE(basic) BOOST_CHECK_EQUAL(pb.Remove(tx_for_recipient2).value(), 0); BOOST_CHECK(!pb.Remove(tx_for_recipient2).has_value()); - BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient).has_value()); + BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient, /*will_send_to_address=*/CService{}).has_value()); } BOOST_AUTO_TEST_SUITE_END() From 5e64982541f301773156a87988c60ca7797a3f06 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 17 Jan 2026 18:29:37 -0500 Subject: [PATCH 2/7] net: Add PrivateBroadcast::GetBroadcastInfo Co-authored-by: Daniela Brozzoni Co-authored-by: l0rinc --- src/private_broadcast.cpp | 19 +++++++++++++ src/private_broadcast.h | 17 +++++++++++ src/test/private_broadcast_tests.cpp | 42 ++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/private_broadcast.cpp b/src/private_broadcast.cpp index fd36a94088e..3900f10a932 100644 --- a/src/private_broadcast.cpp +++ b/src/private_broadcast.cpp @@ -104,6 +104,25 @@ std::vector PrivateBroadcast::GetStale() const return stale; } +std::vector PrivateBroadcast::GetBroadcastInfo() const + EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) +{ + LOCK(m_mutex); + std::vector entries; + entries.reserve(m_transactions.size()); + + for (const auto& [tx, sent_to] : m_transactions) { + std::vector peers; + peers.reserve(sent_to.size()); + for (const auto& status : sent_to) { + peers.emplace_back(PeerSendInfo{.address = status.address, .sent = status.picked, .received = status.confirmed}); + } + entries.emplace_back(TxBroadcastInfo{.tx = tx, .peers = std::move(peers)}); + } + + return entries; +} + PrivateBroadcast::Priority PrivateBroadcast::DerivePriority(const std::vector& sent_to) { Priority p; diff --git a/src/private_broadcast.h b/src/private_broadcast.h index 3beed5eef4c..286344248d9 100644 --- a/src/private_broadcast.h +++ b/src/private_broadcast.h @@ -30,6 +30,17 @@ class PrivateBroadcast { public: + struct PeerSendInfo { + CService address; + NodeClock::time_point sent; + std::optional received; + }; + + struct TxBroadcastInfo { + CTransactionRef tx; + std::vector peers; + }; + /** * Add a transaction to the storage. * @param[in] tx The transaction to add. @@ -97,6 +108,12 @@ public: std::vector GetStale() const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex); + /** + * Get stats about all transactions currently being privately broadcast. + */ + std::vector GetBroadcastInfo() const + EXCLUSIVE_LOCKS_REQUIRED(!m_mutex); + private: /// Status of a transaction sent to a given node. struct SendStatus { diff --git a/src/test/private_broadcast_tests.cpp b/src/test/private_broadcast_tests.cpp index e0ab64cd4c2..73bdbbb54f6 100644 --- a/src/test/private_broadcast_tests.cpp +++ b/src/test/private_broadcast_tests.cpp @@ -7,6 +7,7 @@ #include #include +#include #include BOOST_FIXTURE_TEST_SUITE(private_broadcast_tests, BasicTestingSetup) @@ -29,12 +30,15 @@ BOOST_AUTO_TEST_CASE(basic) PrivateBroadcast pb; const NodeId recipient1{1}; - const CService addr1{}; + in_addr ipv4Addr; + ipv4Addr.s_addr = 0xa0b0c001; + const CService addr1{ipv4Addr, 1111}; // No transactions initially. BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1, /*will_send_to_address=*/addr1).has_value()); BOOST_CHECK_EQUAL(pb.GetStale().size(), 0); BOOST_CHECK(!pb.HavePendingTransactions()); + BOOST_CHECK_EQUAL(pb.GetBroadcastInfo().size(), 0); // Make a transaction and add it. const auto tx1{MakeDummyTx(/*id=*/1, /*num_witness=*/0)}; @@ -48,17 +52,32 @@ BOOST_AUTO_TEST_CASE(basic) BOOST_REQUIRE(tx1->GetWitnessHash() != tx2->GetWitnessHash()); BOOST_CHECK(pb.Add(tx2)); + const auto find_tx_info{[](auto& infos, const CTransactionRef& tx) -> const PrivateBroadcast::TxBroadcastInfo& { + const auto it{std::ranges::find(infos, tx->GetWitnessHash(), [](const auto& info) { return info.tx->GetWitnessHash(); })}; + BOOST_REQUIRE(it != infos.end()); + return *it; + }}; + const auto check_peer_counts{[&](size_t tx1_peer_count, size_t tx2_peer_count) { + const auto infos{pb.GetBroadcastInfo()}; + BOOST_CHECK_EQUAL(infos.size(), 2); + BOOST_CHECK_EQUAL(find_tx_info(infos, tx1).peers.size(), tx1_peer_count); + BOOST_CHECK_EQUAL(find_tx_info(infos, tx2).peers.size(), tx2_peer_count); + }}; + + check_peer_counts(/*tx1_peer_count=*/0, /*tx2_peer_count=*/0); const auto tx_for_recipient1{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1, /*will_send_to_address=*/addr1).value()}; BOOST_CHECK(tx_for_recipient1 == tx1 || tx_for_recipient1 == tx2); // A second pick must return the other transaction. const NodeId recipient2{2}; - const CService addr2{}; + const CService addr2{ipv4Addr, 2222}; const auto tx_for_recipient2{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient2, /*will_send_to_address=*/addr2).value()}; BOOST_CHECK(tx_for_recipient2 == tx1 || tx_for_recipient2 == tx2); BOOST_CHECK_NE(tx_for_recipient1, tx_for_recipient2); + check_peer_counts(/*tx1_peer_count=*/1, /*tx2_peer_count=*/1); + const NodeId nonexistent_recipient{0}; // Confirm transactions <-> recipients mapping is correct. @@ -80,6 +99,21 @@ BOOST_AUTO_TEST_CASE(basic) BOOST_CHECK(pb.DidNodeConfirmReception(recipient1)); BOOST_CHECK(!pb.DidNodeConfirmReception(recipient2)); + const auto infos{pb.GetBroadcastInfo()}; + BOOST_CHECK_EQUAL(infos.size(), 2); + { + const auto& [tx, peers]{find_tx_info(infos, tx_for_recipient1)}; + BOOST_CHECK_EQUAL(peers.size(), 1); + BOOST_CHECK_EQUAL(peers[0].address.ToStringAddrPort(), addr1.ToStringAddrPort()); + BOOST_CHECK(peers[0].received.has_value()); + } + { + const auto& [tx, peers]{find_tx_info(infos, tx_for_recipient2)}; + BOOST_CHECK_EQUAL(peers.size(), 1); + BOOST_CHECK_EQUAL(peers[0].address.ToStringAddrPort(), addr2.ToStringAddrPort()); + BOOST_CHECK(!peers[0].received.has_value()); + } + BOOST_CHECK_EQUAL(pb.GetStale().size(), 1); BOOST_CHECK_EQUAL(pb.GetStale()[0], tx_for_recipient2); @@ -92,7 +126,9 @@ BOOST_AUTO_TEST_CASE(basic) BOOST_CHECK_EQUAL(pb.Remove(tx_for_recipient2).value(), 0); BOOST_CHECK(!pb.Remove(tx_for_recipient2).has_value()); - BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient, /*will_send_to_address=*/CService{}).has_value()); + BOOST_CHECK_EQUAL(pb.GetBroadcastInfo().size(), 0); + const CService addr_nonexistent{ipv4Addr, 3333}; + BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient, /*will_send_to_address=*/addr_nonexistent).has_value()); } BOOST_AUTO_TEST_SUITE_END() From 996f20c18af02281034c51af4b2766d8f4d37a2c Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 17 Jan 2026 18:32:36 -0500 Subject: [PATCH 3/7] rpc: Add getprivatebroadcastinfo Co-authored-by: Vasil Dimov --- src/net_processing.cpp | 6 ++++ src/net_processing.h | 4 +++ src/rpc/mempool.cpp | 66 ++++++++++++++++++++++++++++++++++++++++++ src/test/fuzz/rpc.cpp | 1 + 4 files changed, 77 insertions(+) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 1aefbf77cb3..7ab05cd0789 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -542,6 +542,7 @@ public: bool GetNodeStateStats(NodeId nodeid, CNodeStateStats& stats) const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); std::vector GetOrphanTransactions() override EXCLUSIVE_LOCKS_REQUIRED(!m_tx_download_mutex); PeerManagerInfo GetInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); + std::vector GetPrivateBroadcastInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void SendPings() override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void InitiateTxBroadcastToAll(const Txid& txid, const Wtxid& wtxid) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void InitiateTxBroadcastPrivate(const CTransactionRef& tx) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); @@ -1855,6 +1856,11 @@ PeerManagerInfo PeerManagerImpl::GetInfo() const }; } +std::vector PeerManagerImpl::GetPrivateBroadcastInfo() const +{ + return m_tx_for_private_broadcast.GetBroadcastInfo(); +} + void PeerManagerImpl::AddToCompactExtraTransactions(const CTransactionRef& tx) { if (m_opts.max_extra_txs <= 0) diff --git a/src/net_processing.h b/src/net_processing.h index 504e708d702..4aac8daa41e 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -118,6 +119,9 @@ public: /** Get peer manager info. */ virtual PeerManagerInfo GetInfo() const = 0; + /** Get info about transactions currently being privately broadcast. */ + virtual std::vector GetPrivateBroadcastInfo() const = 0; + /** * Initiate a transaction broadcast to eligible peers. * Queue the witness transaction id to `Peer::TxRelay::m_tx_inventory_to_send` diff --git a/src/rpc/mempool.cpp b/src/rpc/mempool.cpp index 66ce1c61582..35870368705 100644 --- a/src/rpc/mempool.cpp +++ b/src/rpc/mempool.cpp @@ -137,6 +137,71 @@ static RPCHelpMan sendrawtransaction() }; } +static RPCHelpMan getprivatebroadcastinfo() +{ + return RPCHelpMan{ + "getprivatebroadcastinfo", + "Returns information about transactions that are currently being privately broadcast.\n", + {}, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::ARR, "transactions", "", + { + {RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR_HEX, "txid", "The transaction hash in hex"}, + {RPCResult::Type::STR_HEX, "wtxid", "The transaction witness hash in hex"}, + {RPCResult::Type::STR_HEX, "hex", "The serialized, hex-encoded transaction data"}, + {RPCResult::Type::ARR, "peers", "Per-peer send and acknowledgment information for this transaction", + { + {RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR, "address", "The address of the peer to which the transaction was sent"}, + {RPCResult::Type::NUM_TIME, "sent", "The time this transaction was picked for sending to this peer via private broadcast (seconds since epoch)"}, + {RPCResult::Type::NUM_TIME, "received", /*optional=*/true, "The time this peer acknowledged reception of the transaction (seconds since epoch)"}, + }}, + }}, + }}, + }}, + }}, + RPCExamples{ + HelpExampleCli("getprivatebroadcastinfo", "") + + HelpExampleRpc("getprivatebroadcastinfo", "") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue + { + const NodeContext& node{EnsureAnyNodeContext(request.context)}; + const PeerManager& peerman{EnsurePeerman(node)}; + const auto txs{peerman.GetPrivateBroadcastInfo()}; + + UniValue transactions(UniValue::VARR); + for (const auto& tx_info : txs) { + UniValue o(UniValue::VOBJ); + o.pushKV("txid", tx_info.tx->GetHash().ToString()); + o.pushKV("wtxid", tx_info.tx->GetWitnessHash().ToString()); + o.pushKV("hex", EncodeHexTx(*tx_info.tx)); + UniValue peers(UniValue::VARR); + for (const auto& peer : tx_info.peers) { + UniValue p(UniValue::VOBJ); + p.pushKV("address", peer.address.ToStringAddrPort()); + p.pushKV("sent", TicksSinceEpoch(peer.sent)); + if (peer.received.has_value()) { + p.pushKV("received", TicksSinceEpoch(*peer.received)); + } + peers.push_back(std::move(p)); + } + o.pushKV("peers", std::move(peers)); + transactions.push_back(std::move(o)); + } + + UniValue ret(UniValue::VOBJ); + ret.pushKV("transactions", std::move(transactions)); + return ret; + }, + }; +} + static RPCHelpMan testmempoolaccept() { return RPCHelpMan{ @@ -1329,6 +1394,7 @@ void RegisterMempoolRPCCommands(CRPCTable& t) { static const CRPCCommand commands[]{ {"rawtransactions", &sendrawtransaction}, + {"rawtransactions", &getprivatebroadcastinfo}, {"rawtransactions", &testmempoolaccept}, {"blockchain", &getmempoolancestors}, {"blockchain", &getmempooldescendants}, diff --git a/src/test/fuzz/rpc.cpp b/src/test/fuzz/rpc.cpp index ba052d3c738..cfb36afde7c 100644 --- a/src/test/fuzz/rpc.cpp +++ b/src/test/fuzz/rpc.cpp @@ -147,6 +147,7 @@ const std::vector RPC_COMMANDS_SAFE_FOR_FUZZING{ "getorphantxs", "getpeerinfo", "getprioritisedtransactions", + "getprivatebroadcastinfo", "getrawaddrman", "getrawmempool", "getrawtransaction", From 15dff452eb61ae9e2fd7b48c957e795c4c397443 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 17 Jan 2026 18:33:10 -0500 Subject: [PATCH 4/7] test: Cover getprivatebroadcastinfo in p2p_private_broadcast Co-authored-by: l0rinc --- test/functional/p2p_private_broadcast.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/functional/p2p_private_broadcast.py b/test/functional/p2p_private_broadcast.py index 49438417903..97c90bf49a2 100755 --- a/test/functional/p2p_private_broadcast.py +++ b/test/functional/p2p_private_broadcast.py @@ -36,6 +36,7 @@ from test_framework.test_framework import ( ) from test_framework.util import ( assert_equal, + assert_greater_than_or_equal, assert_not_equal, assert_raises_rpc_error, p2p_port, @@ -301,6 +302,16 @@ class P2PPrivateBroadcast(BitcoinTestFramework): self.log.info(f"{label}: ok: outbound connection i={i} is private broadcast of txid={tx['txid']}") broadcasts_done += 1 + # Verify the tx we just observed is tracked in getprivatebroadcastinfo. + pbinfo = self.nodes[0].getprivatebroadcastinfo() + pending = [t for t in pbinfo["transactions"] if t["txid"] == tx["txid"] and t["wtxid"] == tx["wtxid"]] + assert_equal(len(pending), 1) + assert_equal(pending[0]["hex"].lower(), tx["hex"].lower()) + peers = pending[0]["peers"] + assert len(peers) >= NUM_PRIVATE_BROADCAST_PER_TX + assert all("address" in p and "sent" in p for p in peers) + assert_greater_than_or_equal(sum(1 for p in peers if "received" in p), NUM_PRIVATE_BROADCAST_PER_TX) + def run_test(self): tx_originator = self.nodes[0] tx_receiver = self.nodes[1] @@ -366,6 +377,11 @@ class P2PPrivateBroadcast(BitcoinTestFramework): self.log.info("Waiting for normal broadcast to another peer") self.destinations[1]["node"].wait_for_inv([inv]) + self.log.info("Checking getprivatebroadcastinfo no longer reports the transaction after it is received back") + pbinfo = tx_originator.getprivatebroadcastinfo() + pending = [t for t in pbinfo["transactions"] if t["txid"] == txs[0]["txid"] and t["wtxid"] == txs[0]["wtxid"]] + assert_equal(len(pending), 0) + self.log.info("Sending a transaction that is already in the mempool") skip_destinations = len(self.destinations) tx_originator.sendrawtransaction(hexstring=txs[0]["hex"], maxfeerate=0) From 557260ca14ac5fb4732f4ce0692a2bf364bb5238 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 17 Jan 2026 18:33:28 -0500 Subject: [PATCH 5/7] rpc: Add abortprivatebroadcast Co-authored-by: l0rinc --- src/net_processing.cpp | 21 ++++++++++++++++ src/net_processing.h | 13 ++++++++++ src/rpc/mempool.cpp | 56 ++++++++++++++++++++++++++++++++++++++++++ src/test/fuzz/rpc.cpp | 1 + 4 files changed, 91 insertions(+) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 7ab05cd0789..80622d06efb 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -543,6 +543,7 @@ public: std::vector GetOrphanTransactions() override EXCLUSIVE_LOCKS_REQUIRED(!m_tx_download_mutex); PeerManagerInfo GetInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); std::vector GetPrivateBroadcastInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); + std::vector AbortPrivateBroadcast(const uint256& id) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void SendPings() override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void InitiateTxBroadcastToAll(const Txid& txid, const Wtxid& wtxid) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); void InitiateTxBroadcastPrivate(const CTransactionRef& tx) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex); @@ -1861,6 +1862,26 @@ std::vector PeerManagerImpl::GetPrivateBroadc return m_tx_for_private_broadcast.GetBroadcastInfo(); } +std::vector PeerManagerImpl::AbortPrivateBroadcast(const uint256& id) +{ + const auto snapshot{m_tx_for_private_broadcast.GetBroadcastInfo()}; + std::vector removed_txs; + + size_t connections_cancelled{0}; + for (const auto& [tx, _] : snapshot) { + if (tx->GetHash().ToUint256() != id && tx->GetWitnessHash().ToUint256() != id) continue; + if (const auto peer_acks{m_tx_for_private_broadcast.Remove(tx)}) { + removed_txs.push_back(tx); + if (NUM_PRIVATE_BROADCAST_PER_TX > *peer_acks) { + connections_cancelled += (NUM_PRIVATE_BROADCAST_PER_TX - *peer_acks); + } + } + } + m_connman.m_private_broadcast.NumToOpenSub(connections_cancelled); + + return removed_txs; +} + void PeerManagerImpl::AddToCompactExtraTransactions(const CTransactionRef& tx) { if (m_opts.max_extra_txs <= 0) diff --git a/src/net_processing.h b/src/net_processing.h index 4aac8daa41e..36ae021f679 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -122,6 +123,18 @@ public: /** Get info about transactions currently being privately broadcast. */ virtual std::vector GetPrivateBroadcastInfo() const = 0; + /** + * Abort private broadcast attempts for transactions currently being privately broadcast. + * + * @param[in] id A transaction identifier. It will be matched against both txid and wtxid for + * all transactions in the private broadcast queue. + * + * @return Transactions removed from the private broadcast queue. If the provided id matches a + * txid that corresponds to multiple transactions with different wtxids, multiple + * transactions may be returned. + */ + virtual std::vector AbortPrivateBroadcast(const uint256& id) = 0; + /** * Initiate a transaction broadcast to eligible peers. * Queue the witness transaction id to `Peer::TxRelay::m_tx_inventory_to_send` diff --git a/src/rpc/mempool.cpp b/src/rpc/mempool.cpp index 35870368705..12eef3ce755 100644 --- a/src/rpc/mempool.cpp +++ b/src/rpc/mempool.cpp @@ -202,6 +202,61 @@ static RPCHelpMan getprivatebroadcastinfo() }; } +static RPCHelpMan abortprivatebroadcast() +{ + return RPCHelpMan{ + "abortprivatebroadcast", + "Abort private broadcast attempts for a transaction currently being privately broadcast.\n" + "The transaction will be removed from the private broadcast queue.\n", + { + {"id", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "A transaction identifier to abort. It will be matched against both txid and wtxid for all transactions in the private broadcast queue.\n" + "If the provided id matches a txid that corresponds to multiple transactions with different wtxids, multiple transactions will be removed and returned."}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::ARR, "removed_transactions", "Transactions removed from the private broadcast queue", + { + {RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR_HEX, "txid", "The transaction hash in hex"}, + {RPCResult::Type::STR_HEX, "wtxid", "The transaction witness hash in hex"}, + {RPCResult::Type::STR_HEX, "hex", "The serialized, hex-encoded transaction data"}, + }}, + }}, + } + }, + RPCExamples{ + HelpExampleCli("abortprivatebroadcast", "\"id\"") + + HelpExampleRpc("abortprivatebroadcast", "\"id\"") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue + { + const uint256 id{ParseHashV(self.Arg("id"), "id")}; + + const NodeContext& node{EnsureAnyNodeContext(request.context)}; + PeerManager& peerman{EnsurePeerman(node)}; + + const auto removed_txs{peerman.AbortPrivateBroadcast(id)}; + if (removed_txs.empty()) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Transaction not in private broadcast queue. Check getprivatebroadcastinfo."); + } + + UniValue removed_transactions(UniValue::VARR); + for (const auto& tx : removed_txs) { + UniValue o(UniValue::VOBJ); + o.pushKV("txid", tx->GetHash().ToString()); + o.pushKV("wtxid", tx->GetWitnessHash().ToString()); + o.pushKV("hex", EncodeHexTx(*tx)); + removed_transactions.push_back(std::move(o)); + } + UniValue ret(UniValue::VOBJ); + ret.pushKV("removed_transactions", std::move(removed_transactions)); + return ret; + }, + }; +} + static RPCHelpMan testmempoolaccept() { return RPCHelpMan{ @@ -1395,6 +1450,7 @@ void RegisterMempoolRPCCommands(CRPCTable& t) static const CRPCCommand commands[]{ {"rawtransactions", &sendrawtransaction}, {"rawtransactions", &getprivatebroadcastinfo}, + {"rawtransactions", &abortprivatebroadcast}, {"rawtransactions", &testmempoolaccept}, {"blockchain", &getmempoolancestors}, {"blockchain", &getmempooldescendants}, diff --git a/src/test/fuzz/rpc.cpp b/src/test/fuzz/rpc.cpp index cfb36afde7c..e8daf9d390a 100644 --- a/src/test/fuzz/rpc.cpp +++ b/src/test/fuzz/rpc.cpp @@ -90,6 +90,7 @@ const std::vector RPC_COMMANDS_NOT_SAFE_FOR_FUZZING{ // RPC commands which are safe for fuzzing. const std::vector RPC_COMMANDS_SAFE_FOR_FUZZING{ + "abortprivatebroadcast", "analyzepsbt", "clearbanned", "combinepsbt", From c3378be10b0a90e81b46e53eb85c41eb8caabac5 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 17 Jan 2026 18:33:42 -0500 Subject: [PATCH 6/7] test: Cover abortprivatebroadcast in p2p_private_broadcast Co-authored-by: l0rinc --- test/functional/p2p_private_broadcast.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/functional/p2p_private_broadcast.py b/test/functional/p2p_private_broadcast.py index 97c90bf49a2..f43863eeb5f 100755 --- a/test/functional/p2p_private_broadcast.py +++ b/test/functional/p2p_private_broadcast.py @@ -382,6 +382,25 @@ class P2PPrivateBroadcast(BitcoinTestFramework): pending = [t for t in pbinfo["transactions"] if t["txid"] == txs[0]["txid"] and t["wtxid"] == txs[0]["wtxid"]] assert_equal(len(pending), 0) + self.log.info("Checking abortprivatebroadcast removes a pending private-broadcast transaction") + tx_abort = wallet.create_self_transfer() + tx_originator.sendrawtransaction(hexstring=tx_abort["hex"], maxfeerate=0.1) + assert any(t["wtxid"] == tx_abort["wtxid"] for t in tx_originator.getprivatebroadcastinfo()["transactions"]) + abort_res = tx_originator.abortprivatebroadcast(tx_abort["txid"]) + assert_equal(len(abort_res["removed_transactions"]), 1) + assert_equal(abort_res["removed_transactions"][0]["txid"], tx_abort["txid"]) + assert_equal(abort_res["removed_transactions"][0]["wtxid"], tx_abort["wtxid"]) + assert_equal(abort_res["removed_transactions"][0]["hex"].lower(), tx_abort["hex"].lower()) + assert all(t["wtxid"] != tx_abort["wtxid"] for t in tx_originator.getprivatebroadcastinfo()["transactions"]) + + self.log.info("Checking abortprivatebroadcast fails for non-existent transaction") + assert_raises_rpc_error( + -5, + "Transaction not in private broadcast queue", + tx_originator.abortprivatebroadcast, + "0" * 64, + ) + self.log.info("Sending a transaction that is already in the mempool") skip_destinations = len(self.destinations) tx_originator.sendrawtransaction(hexstring=txs[0]["hex"], maxfeerate=0) From 2a1d0db7994eb2aa8527944f62161b6b8af682da Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 17 Jan 2026 18:33:49 -0500 Subject: [PATCH 7/7] doc: Mention private broadcast RPCs in release notes --- doc/release-notes-29415.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/release-notes-29415.md b/doc/release-notes-29415.md index d5040a3193d..c0e0f3dc888 100644 --- a/doc/release-notes-29415.md +++ b/doc/release-notes-29415.md @@ -12,3 +12,8 @@ P2P and network changes 2. If the originator sends two otherwise unrelated transactions, they will not be linkable. This is because a separate connection is used for broadcasting each transaction. (#29415) + +- New RPCs have been added to introspect and control private broadcast: + `getprivatebroadcastinfo` reports transactions currently being privately + broadcast, and `abortprivatebroadcast` removes matching + transactions from the private broadcast queue.