rpc,net: Add private broadcast RPCs #34329

pull andrewtoth wants to merge 7 commits into bitcoin:master from andrewtoth:private_broadcast_rpcs changing 9 files +295 −10
  1. andrewtoth commented at 1:46 am on January 18, 2026: contributor

    Follow up from #29415

    Sending a transaction via private broadcast does not have any way for a user to track the status of the transaction before it gets returned by another peer. The default logs have been removed as well in #34267. Nor is there any way to abort a transaction once it has been added to the private broadcast queue.

    This adds two new RPCs:

    • getprivatebroadastinfo returns information about what transactions are in the private broadcast queue, including all the peers’ addresses we have chosen and timestamps.
    • abortprivatebroadcast stops broadcasting a transaction in the private broadcast queue.
  2. DrahtBot commented at 1:46 am on January 18, 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/34329.

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK nervana21, l0rinc, danielabrozzoni, sedited, achow101
    Stale ACK vasild

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

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #34322 (node: Persist private broadcast transactions over node restarts by andrewtoth)
    • #34271 (net_processing: make m_tx_for_private_broadcast optional by vasild)
    • #34049 (rpc: Disallow captures in RPCMethodImpl by ajtowns)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    LLM Linter (✨ experimental)

    Possible places where comparison-specific test macros should replace generic comparisons:

    • [test/functional/p2p_private_broadcast.py] assert len(peers) >= NUM_PRIVATE_BROADCAST_PER_TX -> Use assert_greater_than_or_equal(len(peers), NUM_PRIVATE_BROADCAST_PER_TX)

    2026-02-12 00:48:57

  3. DrahtBot added the label CI failed on Jan 18, 2026
  4. DrahtBot commented at 3:04 am on January 18, 2026: contributor

    🚧 At least one of the CI tasks failed. Task Windows native, fuzz, VS 2022: https://github.com/bitcoin/bitcoin/actions/runs/21104117103/job/60692665532 LLM reason (✨ experimental): Fuzzing failed because the RPC command “abortprivatebroadcast” is not registered as safe or not-safe for fuzzing in rpc.cpp.

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

  5. andrewtoth force-pushed on Jan 18, 2026
  6. andrewtoth force-pushed on Jan 18, 2026
  7. andrewtoth force-pushed on Jan 18, 2026
  8. in src/test/private_broadcast_tests.cpp:133 in ac6c2bd064
    128+        BOOST_REQUIRE(it_confirmed != infos.end());
    129+        BOOST_REQUIRE(it_unconfirmed != infos.end());
    130+        const auto& confirmed_tx{*it_confirmed};
    131+        const auto& unconfirmed_tx{*it_unconfirmed};
    132+
    133+        BOOST_CHECK_EQUAL(confirmed_tx.num_peer_reception_acks, 1);
    


    vasild commented at 9:15 am on January 21, 2026:

    I picked “confirmed” for PrivateBroadcast::SendStatus::confirmed, but looking at it now…

    “Confirmed” overlaps with an already established term, meaning “included in a block”. So, maybe better to use any other word, but not “confirmed”.

    “received”? “reception confirmed”? “broadcast”?

    Or at least keep this in mind ;-) Feel free to ignore.


    andrewtoth commented at 6:30 pm on January 25, 2026:
    Changed to “received”.
  9. in src/private_broadcast.cpp:128 in ac6c2bd064
    123+    }
    124+
    125+    // Ensure stable output order (m_transactions is an unordered_map).
    126+    std::sort(entries.begin(), entries.end(), [](const TxBroadcastInfo& a, const TxBroadcastInfo& b) {
    127+        return std::tie(a.tx->GetHash(), a.tx->GetWitnessHash()) < std::tie(b.tx->GetHash(), b.tx->GetWitnessHash());
    128+    });
    


    vasild commented at 9:22 am on January 21, 2026:
    Sorting seems unnecessary. It might be difficult to drop it later if users get used to it and start depending on it.
  10. in src/private_broadcast.h:39 in ac6c2bd064 outdated
    34+        CTransactionRef tx;
    35+        size_t num_sent{0};
    36+        std::optional<NodeClock::time_point> last_sent{};
    37+        size_t num_peer_reception_acks{0};
    38+        std::optional<NodeClock::time_point> last_peer_reception_ack{};
    39+    };
    


    vasild commented at 9:41 am on January 21, 2026:

    It would be useful to show also the addresses the transaction was sent to and some additional information. Like so:

     0[
     1    {
     2        "txid": "tx1",
     3        "sent": [
     4            {
     5                "address": "aaa.onion:8333",
     6                "begin": "2026.01...", // time when picked for sending
     7                "end": "2026.01..." // time when reception was confirmed by the peer
     8            },
     9            {
    10                "address": "bbb.onion:8333",
    11                "begin": "2026.01...",
    12                "end": "2026.01..."
    13            }
    14        ],
    15        "received": {
    16            "address": "1.2.3.4:8333", // received back from the network from this peer
    17            "when": "2026.01..." // at this time
    18        }
    19    }   
    20]
    

    This would mean to not immediately drop the statistics when we receive the transaction back from the network, but keep the stats for some time, in order to be able to show them to the user. Maybe keep this info for e.g. 24 hours or better, keep the last N entries.

    PS as a side effect, this would make it possible to see the round-trip time through the network.


    andrewtoth commented at 5:02 pm on January 24, 2026:

    Yes, this information would be interesting to provide. I will add the extra sent information.

    Maybe keep this info for e.g. 24 hours or better, keep the last N entries.

    This raises an interesting point about how we should store this information. I’m curious about your thoughts on #34322 as well @vasild.

    Perhaps we introduce a new option privatebroadcast_persist or something, that when set keeps track of all private broadcasts indefinitely. This would let us show the received information above, but if turned off it would alleviate the concerns raised in #34322.


    andrewtoth commented at 3:07 am on January 28, 2026:

    Re: your suggestions here #34329#pullrequestreview-3706202946.

    I’ve implemented them. This is a bit tricky, so I tried to break up the changes to do that in multiple commits. I think the way it works now will be compatible with your suggestions in #34322 as well, since we prune oldest finished txs now when adding a new one. We could just prune txs that are expired whenever we call GetBroadcastInfo there.

  11. in test/functional/p2p_private_broadcast.py:425 in ac6c2bd064
    420@@ -395,8 +421,8 @@ def run_test(self):
    421         with tx_originator.busy_wait_for_debug_log(expected_msgs=[rebroadcast_msg.encode()]):
    422             tx_originator.setmocktime(int(time.time()) + delta)
    423             tx_originator.mockscheduler(delta)
    424-        self.check_broadcasts("Rebroadcast", txs[1], 1, skip_destinations)
    425         tx_originator.setmocktime(0) # Let the clock tick again (it will go backwards due to this).
    426+        self.check_broadcasts("Rebroadcast", txs[1], 1, skip_destinations)
    


    vasild commented at 9:55 am on January 21, 2026:
    Why reorder this?

    andrewtoth commented at 6:49 pm on January 24, 2026:

    This was causing a consistent CI failure on macOS native in this PR. I’m not sure why it isn’t reproducing in other PRs or other CI jobs. The mocktime being advanced causes the node to timeout a peer that was opened since it has been advanced more than 180 seconds in check_broadcasts. This assertion fails since tx and ping are not received.

    0            assert_equal(peer.message_count, {
    1                "version": 1,
    2                "verack": 1,
    3                "inv": 1,
    4                "tx": 1,
    5                "ping": 1
    6            })
    

    I don’t know exactly why a connection is open before we reach there though. Could possibly be that the job is slow enough to cause a real 2-3 minute rebroadcast before we set mocktime. I’m still investigating, but if you have any insight please let me know.


    andrewtoth commented at 6:30 pm on January 25, 2026:
    I removed the reorder. Let’s see what CI says.
  12. in src/net_processing.h:127 in ac6c2bd064
    119@@ -118,6 +120,12 @@ class PeerManager : public CValidationInterface, public NetEventsInterface
    120     /** Get peer manager info. */
    121     virtual PeerManagerInfo GetInfo() const = 0;
    122 
    123+    /** Get info about transactions currently being privately broadcast. */
    124+    virtual std::vector<PrivateBroadcast::TxBroadcastInfo> GetPrivateBroadcastInfo() const = 0;
    125+
    126+    /** Abort private broadcast attempts for a transaction (identified by txid or wtxid). */
    127+    virtual std::vector<CTransactionRef> AbortPrivateBroadcast(const uint256& id) = 0;
    


    vasild commented at 10:01 am on January 21, 2026:
    Would be good to document the return value. It may not be immediately obvious why it is a vector.
  13. in src/rpc/mempool.cpp:234 in ac6c2bd064
    229+            const NodeContext& node{EnsureAnyNodeContext(request.context)};
    230+            PeerManager& peerman{EnsurePeerman(node)};
    231+
    232+            const auto removed_txs{peerman.AbortPrivateBroadcast(id)};
    233+            if (removed_txs.empty()) {
    234+                throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Transaction not in private broadcast queue");
    


    vasild commented at 10:03 am on January 21, 2026:
    Could point the user here to go look at the getprivatebroadcastinfo RPC to see what is in the queue.
  14. vasild approved
  15. vasild commented at 10:13 am on January 21, 2026: contributor

    ACK ac6c2bd06485ff6e7ed538a234f20491d3cf47cb

    Nice and straight forward, thanks!

    I think we can do better by showing more info, but this does not make the current PR worse.

  16. DrahtBot removed the label CI failed on Jan 22, 2026
  17. andrewtoth force-pushed on Jan 25, 2026
  18. andrewtoth commented at 6:32 pm on January 25, 2026: contributor

    Thank you for your review @vasild. I have taken all your suggestions except tracking the “received” in #34329 (review), since I’m not sure yet how we should store info after we get the transaction back.

    git diff ac6c2bd06485ff6e7ed538a234f20491d3cf47cb..61078dab77c86a26b38afc3b1bb2ab97b8263fb0

  19. andrewtoth force-pushed on Jan 25, 2026
  20. DrahtBot added the label CI failed on Jan 25, 2026
  21. DrahtBot commented at 7:10 pm on January 25, 2026: contributor

    🚧 At least one of the CI tasks failed. Task test max 6 ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/21337454291/job/61411547620 LLM reason (✨ experimental): Compilation failed: PickTxForSend called with only 1 argument in private_broadcast_tests.cpp, missing the will_send_to_address parameter.

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

  22. DrahtBot removed the label CI failed on Jan 25, 2026
  23. in src/test/private_broadcast_tests.cpp:129 in ccf91e48a3 outdated
    124+        const auto& unconfirmed_tx{*it_unconfirmed};
    125+
    126+        BOOST_CHECK_EQUAL(confirmed_tx.peers.size(), 1);
    127+        BOOST_CHECK_EQUAL(unconfirmed_tx.peers.size(), 1);
    128+        BOOST_CHECK(confirmed_tx.peers[0].received.has_value());
    129+        BOOST_CHECK(!unconfirmed_tx.peers[0].received.has_value());
    


    danielabrozzoni commented at 11:10 am on January 26, 2026:

    nit: we could also check that the node addresses match. I took a stab at adding this:

     0diff --git a/src/test/private_broadcast_tests.cpp b/src/test/private_broadcast_tests.cpp
     1index 4dbc768d74..9a86137c02 100644
     2--- a/src/test/private_broadcast_tests.cpp
     3+++ b/src/test/private_broadcast_tests.cpp
     4@@ -30,7 +30,9 @@ BOOST_AUTO_TEST_CASE(basic)
     5 
     6     PrivateBroadcast pb;
     7     const NodeId recipient1{1};
     8-    const CService addr1{};
     9+    in_addr ipv4Addr;
    10+    ipv4Addr.s_addr = 0xa0b0c001;
    11+    const CService addr1(ipv4Addr, 1111);
    12 
    13     // No transactions initially.
    14     BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1, /*will_send_to_address=*/addr1).has_value());
    15@@ -71,7 +73,7 @@ BOOST_AUTO_TEST_CASE(basic)
    16 
    17     // A second pick must return the other transaction.
    18     const NodeId recipient2{2};
    19-    const CService addr2{};
    20+    const CService addr2(ipv4Addr, 2222);
    21     const auto tx_for_recipient2{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient2, /*will_send_to_address=*/addr2).value()};
    22     BOOST_CHECK(tx_for_recipient2 == tx1 || tx_for_recipient2 == tx2);
    23     BOOST_CHECK_NE(tx_for_recipient1, tx_for_recipient2);
    24@@ -124,7 +126,9 @@ BOOST_AUTO_TEST_CASE(basic)
    25         const auto& unconfirmed_tx{*it_unconfirmed};
    26 
    27         BOOST_CHECK_EQUAL(confirmed_tx.peers.size(), 1);
    28+        BOOST_CHECK_EQUAL(confirmed_tx.peers[0].address.ToStringAddrPort(), addr1.ToStringAddrPort());
    29         BOOST_CHECK_EQUAL(unconfirmed_tx.peers.size(), 1);
    30+        BOOST_CHECK_EQUAL(unconfirmed_tx.peers[0].address.ToStringAddrPort(), addr2.ToStringAddrPort());
    31         BOOST_CHECK(confirmed_tx.peers[0].received.has_value());
    32         BOOST_CHECK(!unconfirmed_tx.peers[0].received.has_value());
    33     }
    

    andrewtoth commented at 3:04 am on January 28, 2026:
    Taken, added you as co-author.
  24. in src/rpc/mempool.cpp:222 in 2ac1cc811a outdated
    217+            {
    218+                {RPCResult::Type::ARR, "removed_transactions", "Transactions removed from the private broadcast queue",
    219+                    {
    220+                        {RPCResult::Type::OBJ, "", "",
    221+                            {
    222+                                {RPCResult::Type::STR_HEX, "txid", "The transaction hash in hex"},
    


    danielabrozzoni commented at 11:50 am on January 26, 2026:
    I think it would be useful to show how many peers the transaction was sent to and how many acknowledged receipt, so users are not surprised if a transaction is later mined after aborting a private broadcast. Happy to work on this in a follow up PR if you think that makes sense :)

    andrewtoth commented at 3:04 am on January 28, 2026:
    Yes, I think this PR is big enough now as it is. A follow up to add this would be great.
  25. danielabrozzoni approved
  26. danielabrozzoni commented at 12:53 pm on January 26, 2026: member

    tACK 61078dab77c86a26b38afc3b1bb2ab97b8263fb0

    Code looks good to me, I left a couple of non-blocking nits.

    I tested locally using a signet node, I tried broadcasting transactions, seeing the confirmation/reception times and aborting the broadcast.

  27. DrahtBot requested review from vasild on Jan 26, 2026
  28. in src/rpc/mempool.cpp:161 in 61078dab77
    156+                                {RPCResult::Type::ARR, "peers", "Per-peer send and acknowledgment information for this transaction",
    157+                                    {
    158+                                        {RPCResult::Type::OBJ, "", "",
    159+                                            {
    160+                                                {RPCResult::Type::STR, "address", "The address of the peer to which the transaction was sent"},
    161+                                                {RPCResult::Type::NUM_TIME, "sent", "The time this transaction was sent to this peer via private broadcast (seconds since epoch)"},
    


    vasild commented at 2:07 pm on January 26, 2026:

    “was picked for sending” (or selected) would be more accurate than “was sent”.

    0                                                {RPCResult::Type::NUM_TIME, "sent", "The time this transaction was picked for sending to this peer via private broadcast (seconds since epoch)"},
    

    andrewtoth commented at 3:04 am on January 28, 2026:
    Done, added you as co-author.
  29. vasild approved
  30. vasild commented at 2:24 pm on January 26, 2026: contributor

    ACK 61078dab77c86a26b38afc3b1bb2ab97b8263fb0

    … except tracking the “received” in #34329 (review), since I’m not sure yet how we should store info after we get the transaction back

    I think at this point, within the scope of this PR, we could keep the transactions in the same place - PrivateBroadcast::m_transactions:

    • Don’t delete entries from m_transactions when they are received back after a round-trip through the network - rename Remove() to e.g. StopBroadcasting(). Same for when giving up on stale transactions that don’t make it to the mempool anymore or manually abandoned by the user.
    • Make sure those “stopped” entries are not considered by the methods PickTxForSend(), HavePendingTransactions() and GetStale().
    • Add some cap to the size of m_transactions.
    • Add a status field in the RPC output per transaction, which could be “success: received back from 1.2.3.4:8333”, “abandoned: not eligible for our mempool: xyz”, “abandoned by the user via RPC”.
    • Plus a timestamp of the status field.
  31. andrewtoth force-pushed on Jan 28, 2026
  32. andrewtoth force-pushed on Jan 28, 2026
  33. andrewtoth commented at 3:03 am on January 28, 2026: contributor

    @danielabrozzoni @vasild thank you for your reviews. I have taken most of your suggestions. Also rebased due to #34267.

    New commits added to faciliate extra information in getprivatebroadcastinfo:

    • Rename Remove to StopBroadcasting and pass either the node’s address if received or a message if aborted. 7a83e570fa94bc572233bf0f3c62f7b010e57e55
    • Store private broadcast transactions even after they are stopped, storing the received from node address or message, and a timestamp. adf16cb57e880c77415a5ee47017150136e68cac
    • Prune finished transactions if there are move than 1000. 4194ab3c93c1d87ecdacbaef7da91f280edd46c3

    git range-diff 22bde74d1d8f861323eabb8dc60401bbf1226544..61078dab77c86a26b38afc3b1bb2ab97b8263fb0 c0e6556e4f51b454c3f6e9069044761c34e99f81..37f7e5577853f3655de08710e688256e144e3cf1

  34. andrewtoth force-pushed on Jan 28, 2026
  35. DrahtBot added the label CI failed on Jan 28, 2026
  36. DrahtBot commented at 3:09 am on January 28, 2026: contributor

    🚧 At least one of the CI tasks failed. Task lint: https://github.com/bitcoin/bitcoin/actions/runs/21423156818/job/61686652517 LLM reason (✨ experimental): Trailing whitespace detected in src/private_broadcast.h, causing the lint check to fail.

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

  37. DrahtBot removed the label CI failed on Jan 28, 2026
  38. in src/net_processing.cpp:1871 in db23ae3935
    1866+    to_stop.reserve(snapshot.size());
    1867+    for (const auto& entry : snapshot) {
    1868+        if (entry.tx->GetHash().ToUint256() == id || entry.tx->GetWitnessHash().ToUint256() == id) {
    1869+            to_stop.push_back(entry.tx);
    1870+        }
    1871+    }
    


    w0xlt commented at 8:50 am on February 1, 2026:

    The function iterates over all transactions including finished ones from GetBroadcastInfo(), but only active (non-finished) transactions are abortable. to_stop.reserve(snapshot.size()) over-reserves since it is including finished transactions.

    0    for (const auto& entry : snapshot) {
    1      if (entry.final_state.has_value()) continue; // Skip already finished
    2      if (entry.tx->GetHash().ToUint256() == id || entry.tx->GetWitnessHash().ToUint256() == id) {
    3        to_stop.push_back(entry.tx);
    4      }
    5    }
    

    andrewtoth commented at 7:59 pm on February 1, 2026:
    Done, also collapsed the 2 loops into one loop.
  39. in src/rpc/mempool.cpp:259 in db23ae3935 outdated
    254+            const NodeContext& node{EnsureAnyNodeContext(request.context)};
    255+            PeerManager& peerman{EnsurePeerman(node)};
    256+
    257+            const auto removed_txs{peerman.AbortPrivateBroadcast(id)};
    258+            if (removed_txs.empty()) {
    259+                throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Transaction not in private broadcast queue. Check getprivatebroadcastinfo.");
    


    w0xlt commented at 8:56 am on February 1, 2026:
    RPC_INVALID_ADDRESS_OR_KEY is typically used for Bitcoin address/key validation errors. Maybe RPC_INVALID_PARAMETER would be more accurate.

    andrewtoth commented at 7:39 pm on February 1, 2026:

    This error is typically used when a transaction or block is not found by hash. In mempool.cpp there are several that throw with error message Transaction not in mempool. In blockchain.cpp, there are several that throw with message Block not found. In rawtransaction.cpp it is returned for getrawtransaction when the tx is not found.

    I think this is the correct error code to use in this case.

  40. andrewtoth force-pushed on Feb 1, 2026
  41. andrewtoth commented at 7:59 pm on February 1, 2026: contributor

    Thank you for your review @w0xlt. Addressed #34329 (review).

    git diff db23ae39358168823593d28c3114590900d7e887..5d3c79a96fab2018a3e13bbe8d6ca6080e38ec91.

  42. in src/net_processing.cpp:1873 in 5d3c79a96f
    1868+        if (entry.final_state.has_value()) continue;
    1869+        if (entry.tx->GetHash().ToUint256() != id && entry.tx->GetWitnessHash().ToUint256() != id) continue;
    1870+        to_stop.push_back(entry.tx);
    1871+        const auto peer_acks{m_tx_for_private_broadcast.StopBroadcasting(entry.tx, "Manually aborted via abortprivatebroadcast RPC")};
    1872+        if (!peer_acks.has_value()) continue;
    1873+        if (NUM_PRIVATE_BROADCAST_PER_TX > peer_acks.value()) {
    


    w0xlt commented at 9:21 am on February 2, 2026:

    Only add entry.tx to to_stop if StopBroadcasting returns a non-nullopt value. Another thread may have already stopped it.

    0        const auto peer_acks{m_tx_for_private_broadcast.StopBroadcasting(entry.tx, "Manually aborted via abortprivatebroadcast RPC")};
    1        if (!peer_acks.has_value()) continue;
    2        to_stop.push_back(entry.tx);
    3        if (NUM_PRIVATE_BROADCAST_PER_TX > peer_acks.value()) {
    
  43. in test/functional/p2p_private_broadcast.py:406 in 5d3c79a96f outdated
    401+        assert_equal(len(info), 1)
    402+        assert_equal(info[0]["final_state"]["aborted"], "Manually aborted via abortprivatebroadcast RPC")
    403+        # The abort time is recorded on the node; allow a little slack around the RPC call.
    404+        assert abort_time_before - 5 <= info[0]["final_state"]["time"] <= abort_time_after + 5
    405+        assert "received_from" not in info[0]["final_state"]
    406+
    


    w0xlt commented at 9:21 am on February 2, 2026:
    0        self.log.info("Checking abortprivatebroadcast fails for non-existent transaction")
    1        assert_raises_rpc_error(-5, "Transaction not in private broadcast queue",
    2                                tx_originator.abortprivatebroadcast, "0" * 64)
    
  44. in src/test/private_broadcast_tests.cpp:170 in 5d3c79a96f
    170 
    171-    BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient).has_value());
    172+    // Fill up to the cap with other finished transactions that all have newer finish times.
    173+    SetMockTime(Now<NodeSeconds>() + 1h);
    174+    for (uint32_t i = 0; i < MAX_STORED_TRANSACTIONS - 1; ++i) {
    175+        auto tx{MakeDummyTx(/*id=*/i + 2, /*num_witness=*/0)};
    


    vasild commented at 11:12 am on February 2, 2026:

    Maybe would be more readable:

    0    for (uint32_t i = 2; i <= MAX_STORED_TRANSACTIONS; ++i) {
    1        auto tx{MakeDummyTx(/*id=*/i, /*num_witness=*/0)};
    
  45. in src/test/private_broadcast_tests.cpp:156 in 5d3c79a96f
    155+    BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient, /*will_send_to_address=*/CService{}).has_value());
    156+}
    157+
    158+BOOST_AUTO_TEST_CASE(prune_finished)
    159+{
    160+    SetMockTime(Now<NodeSeconds>());
    


    vasild commented at 11:14 am on February 2, 2026:

    The pattern:

    0SetMockTime(Now<NodeSeconds>());
    1SetMockTime(Now<NodeSeconds>() - 1h);
    2SetMockTime(Now<NodeSeconds>() + 1h);
    

    while unlikely makes it possible something unexpected to happen if 1h passes in between (👀). Would be more robust to assign to a variable, whose value will not change during the test:

    0const auto start = Now<NodeSeconds>();
    1SetMockTime(start);
    2SetMockTime(start - 1h);
    3SetMockTime(start + 1h);
    
  46. in src/test/private_broadcast_tests.cpp:223 in 5d3c79a96f
    223+        BOOST_CHECK_EQUAL(it->final_state->result.error(), "aborted");
    224+        time_aborted = TicksSinceEpoch<std::chrono::seconds>(it->final_state->time);
    225+    }
    226+
    227+    // Further abort attempts should return nullopt and must not overwrite the aborted final state.
    228+    SetMockTime(Now<NodeSeconds>() + 1s);
    


    vasild commented at 11:23 am on February 2, 2026:
    Same here. If the system clock, for some reason (ntp adjustment?) goes back for 1 second, then this will have unintended result.
  47. in src/net_processing.cpp:1661 in 5d3c79a96f
    1657@@ -1658,7 +1658,7 @@ void PeerManagerImpl::ReattemptPrivateBroadcast(CScheduler& scheduler)
    1658                 LogDebug(BCLog::PRIVBROADCAST, "Giving up broadcast attempts for txid=%s wtxid=%s: %s",
    1659                          stale_tx->GetHash().ToString(), stale_tx->GetWitnessHash().ToString(),
    1660                          mempool_acceptable.m_state.ToString());
    1661-                m_tx_for_private_broadcast.Remove(stale_tx);
    1662+                m_tx_for_private_broadcast.StopBroadcasting(stale_tx, mempool_acceptable.m_state.ToString());
    


    vasild commented at 11:50 am on February 2, 2026:
    m_state.ToString() could be quite short, e.g. “tx-size”, “transaction failed” or “bad-txns-inputs-missingorspent”, etc. Maybe prefix this with “Not acceptable to our mempool: %s”.
  48. in src/private_broadcast.h:89 in 5d3c79a96f outdated
    92+     * @retval nullopt The transaction was not in the storage, or was already stopped.
    93+     */
    94+    std::optional<size_t> StopBroadcasting(const CTransactionRef& tx, std::string aborted)
    95+        EXCLUSIVE_LOCKS_REQUIRED(!m_mutex)
    96+    {
    97+        return StopBroadcasting(tx, util::Unexpected<std::string>{std::move(aborted)});
    


    vasild commented at 12:00 pm on February 2, 2026:
    It is a good convention to pass in parameters as const reference. I think passing by value and then moving from the temporary might be as effective, but I find the const reference parameters more readable.

    andrewtoth commented at 5:13 am on February 3, 2026:
    I think std::string_view has made passing const std::string& obsolete. In this case we want to own the std::string, so passing by value is appropriate. The callers can move the string into the function or a temporary is automatically moved. Using a const ref here would always require making an extra copy.

    andrewtoth commented at 3:51 pm on February 5, 2026:
    Actually, I think these should be passed as rvalue refs. Updated.
  49. in src/private_broadcast.h:74 in 5d3c79a96f outdated
    77-    std::optional<size_t> Remove(const CTransactionRef& tx)
    78-        EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);
    79+    std::optional<size_t> StopBroadcasting(const CTransactionRef& tx, const CService& received_from)
    80+        EXCLUSIVE_LOCKS_REQUIRED(!m_mutex)
    81+    {
    82+        return StopBroadcasting(tx, util::Expected<CService, std::string>{received_from});
    


    vasild commented at 12:02 pm on February 2, 2026:
    I find this usage of util::Expected unusual. Is not std::variant more suitable for this?

    andrewtoth commented at 5:13 am on February 3, 2026:
    util::Expected is a std::variant under the hood. It is generally used for when you have an expected happy path result or an unexpected error, which I think is appropriate for storing this data.

    vasild commented at 5:12 pm on February 3, 2026:
    I find this very confusing because I see nothing “expected” or “unexpected” about stopping a transaction by “address” (received back) or by “string” (manually aborted, or not suitable for our mempool). I mean both stop-methods are equally (un)expected to me.

    andrewtoth commented at 11:28 pm on February 3, 2026:
    When a tx gets submitted for private broadcast, we expect that it will find its way to a peer and make it back to our mempool. If it gets stale and then has a mempool conflict, something unexpected has happened. When it was initially submitted the mempool checks passed. If it is submitted, a user intended to broadcast the tx and expects to have it make its way back to us. They could unexpectedly decide to abort the tx before this happens.

    w0xlt commented at 8:14 pm on February 5, 2026:

    util::Expected<CService, std::string> is being used here as an union (received-from-address vs. abort-reason-string) rather than for its intended success/error semantics.

    A std::variant<CService, std::string> would better communicate intent. The current approach works but can confuse future maintainers.


    andrewtoth commented at 1:24 am on February 6, 2026:
    Updated to a std::variant.
  50. in src/private_broadcast.cpp:36 in 5d3c79a96f
    32+        struct FinishedEntry {
    33+            NodeClock::time_point time;
    34+            CTransactionRef tx;
    35+        };
    36+        std::vector<FinishedEntry> finished;
    37+        finished.reserve(m_transactions.size());
    


    vasild commented at 12:35 pm on February 2, 2026:
    This micro optimization might overshoot and turn out to have negative performance impact. Maybe just drop it.
  51. in src/private_broadcast.cpp:40 in 5d3c79a96f outdated
    36+        std::vector<FinishedEntry> finished;
    37+        finished.reserve(m_transactions.size());
    38+        for (const auto& [txref, state] : m_transactions) {
    39+            if (const auto final_state{state.final_state}) {
    40+                finished.push_back(FinishedEntry{.time = state.final_state->time, .tx = txref});
    41+            }
    


    vasild commented at 12:35 pm on February 2, 2026:
    The variable final_state is not used?
  52. in src/private_broadcast.cpp:29 in 5d3c79a96f outdated
    25+        it->second.sent_to.clear();
    26+        inserted = true;
    27+    }
    28+
    29+    // Bound memory usage by pruning oldest finished transactions. If we still exceed the cap
    30+    // but all transactions are unfinished, that's ok.
    


    vasild commented at 12:44 pm on February 2, 2026:
    I think the current code is fine, but just mentioning: trimming only during “add” could leave us with more than 1000 finalized transactions. For example, if 1100 are added, all of them pending, then no new ones are added (no calls to Add(), so the trimming code is never executed anymore) and one by one those 1100 are finalized. So, in theory, to enforce this, the trimming code should be run also when a state changes from non-finalized to finalized. Maybe an overkill, feel free to ignore.

    andrewtoth commented at 5:13 am on February 3, 2026:
    Good observation. I think it’s unlikely to happen, and not a big deal if it does. If there are already 1100 entries, then keeping them there until another is added is not making anything worse. This would also require a user to add a lot of txs at once, and then stop adding altogether. I’m not sure if that’s a common usage pattern. We can deal with this in a follow-up when we add persistence to this data.
  53. andrewtoth force-pushed on Feb 3, 2026
  54. andrewtoth commented at 5:14 am on February 3, 2026: contributor

    Thank you for your reviews @w0xlt and @vasild. I have taken most of your suggestions, and added you both as co-authors.

    git diff 5d3c79a96fab2018a3e13bbe8d6ca6080e38ec91..e0c75536ce6e1f9392c50ebf022a1840a46d0dfd

  55. andrewtoth force-pushed on Feb 5, 2026
  56. andrewtoth commented at 3:52 pm on February 5, 2026: contributor

    Addressed #34329 (review). Pass stop reason strings as rvalue refs.

    git diff e0c75536ce6e1f9392c50ebf022a1840a46d0dfd..844e08c0677f04288ed0cb38d05c68e09bae7e67

  57. achow101 added this to the milestone 31.0 on Feb 5, 2026
  58. andrewtoth force-pushed on Feb 5, 2026
  59. andrewtoth force-pushed on Feb 6, 2026
  60. andrewtoth commented at 1:41 am on February 6, 2026: contributor

    Addressed #34329 (review). Thanks @vasild and @w0xlt.

    git diff 844e08c0677f04288ed0cb38d05c68e09bae7e67..0649a6a01ff74659af432443ae6e1f60606c844a

  61. DrahtBot added the label CI failed on Feb 6, 2026
  62. l0rinc commented at 7:32 pm on February 7, 2026: contributor

    Sending a transaction via private broadcast does not have any way for a user to track the status of the transaction before it gets returned by another peer.

    +1, I understand why this is important to get into the same release as the private broadcast, but not sure about the abortprivatebroadcast and related refactors, I think those could be done after the freeze as well and in separate PRs, since this change is a bit bigger than I anticipated. Could you maybe split it into an urgent minimal PR and follow-ups that aren’t necessary for this release?

  63. net: Store recipient node address in private broadcast 573bb542be
  64. andrewtoth force-pushed on Feb 10, 2026
  65. andrewtoth commented at 3:09 pm on February 10, 2026: contributor

    @l0rinc thanks for that feedback. Indeed, the changes made to persist finished private broadcast transactions in memory are difficult to review. I do feel that abortprivatebroadcast is an important RPC to have in the upcoming release though. There can be many cases where a transaction might never return to a node’s mempool, and right now the only way to stop opening connections in these cases is to restart the node.

    I’ve reverted the changes up to 61078dab77c86a26b38afc3b1bb2ab97b8263fb0, which had 2 ACKs from @danielabrozzoni and @vasild. I’ve rebased due to #34267, and addressed comments #34329 (review) and #34329 (review). I think this makes it much easier to review, and we can figure out persisting finished transactions in memory or on disk in a follow-up after v31.

    git range-diff 22bde74d1d8f861323eabb8dc60401bbf1226544..61078dab77c86a26b38afc3b1bb2ab97b8263fb0 64294c89094d5ab10d87236729cc267fde0a24ca..a034fbd7d907da99726c20e6bf09239d2dfd711b

  66. DrahtBot removed the label CI failed on Feb 10, 2026
  67. nervana21 commented at 0:03 am on February 11, 2026: contributor
    Concept ACK
  68. in src/net_processing.cpp:1870 in a034fbd7d9
    1865+std::vector<CTransactionRef> PeerManagerImpl::AbortPrivateBroadcast(const uint256& id)
    1866+{
    1867+    const auto snapshot{m_tx_for_private_broadcast.GetBroadcastInfo()};
    1868+    std::vector<CTransactionRef> to_remove;
    1869+    to_remove.reserve(snapshot.size());
    1870+    for (const auto& entry : snapshot) {
    


    l0rinc commented at 10:33 am on February 11, 2026:

    I think there might be a small race here : we take a snapshot and then remove in a separate pass, but we don’t hold one lock across both operations.

    If another thread removes a tx between GetBroadcastInfo() and Remove(), this method can currently return a tx that this call did not actually remove. Given the contract (Transactions removed from the private broadcast queue), should we return only txs removed by this call (or otherwise relax the docs)?

    Maybe something like:

     0std::vector<CTransactionRef> PeerManagerImpl::AbortPrivateBroadcast(const uint256& id)
     1{
     2    const auto snapshot{m_tx_for_private_broadcast.GetBroadcastInfo()};
     3    std::vector<CTransactionRef> removed_txs;
     4    removed_txs.reserve(snapshot.size());
     5
     6    size_t connections_cancelled{0};
     7    for (const auto& [tx, _] : snapshot) {
     8        if (tx->GetHash().ToUint256() != id && tx->GetWitnessHash().ToUint256() != id) continue;
     9        if (const auto peer_acks{m_tx_for_private_broadcast.Remove(tx)}) {
    10            removed_txs.push_back(tx);
    11            if (NUM_PRIVATE_BROADCAST_PER_TX > *peer_acks) {
    12                connections_cancelled += (NUM_PRIVATE_BROADCAST_PER_TX - *peer_acks);
    13            }
    14        }
    15    }
    16    m_connman.m_private_broadcast.NumToOpenSub(connections_cancelled);
    17
    18    return removed_txs;
    19}
    

    l0rinc commented at 5:07 pm on February 13, 2026:
    Applied correctly without the removed_txs.reserve 👍
  69. in src/net_processing.cpp:1886 in a034fbd7d9
    1881+            connections_cancelled += (NUM_PRIVATE_BROADCAST_PER_TX - peer_acks.value());
    1882+        }
    1883+    }
    1884+
    1885+    if (connections_cancelled > 0) {
    1886+        m_connman.m_private_broadcast.NumToOpenSub(connections_cancelled);
    


    l0rinc commented at 10:36 am on February 11, 2026:
    shouldn’t NumToOpenSub handle connections_cancelled > 0 explicitly? It seems to handle it implicitly, so the guard may not be needed.

    l0rinc commented at 5:07 pm on February 13, 2026:
    NumToOpenSub unchanges, but connections_cancelled guard removed 👍

    vasild commented at 5:37 am on February 21, 2026:
    So, this could end up calling NumToOpenSub(0). That is fine. At worst NumToOpenSub() will do a few loops of its do-while compare-and-swap thing with the same new and current values. Thanks for simplifying!
  70. in test/functional/p2p_private_broadcast.py:394 in a034fbd7d9 outdated
    389+        assert_equal(len(abort_res["removed_transactions"]), 1)
    390+        assert_equal(abort_res["removed_transactions"][0]["txid"], tx_abort["txid"])
    391+        assert_equal(abort_res["removed_transactions"][0]["wtxid"], tx_abort["wtxid"])
    392+        assert_equal(abort_res["removed_transactions"][0]["hex"].lower(), tx_abort["hex"].lower())
    393+        assert all(t["wtxid"] != tx_abort["wtxid"] for t in tx_originator.getprivatebroadcastinfo()["transactions"])
    394+
    


    l0rinc commented at 10:46 am on February 11, 2026:

    Might make sense to verify that abortprivatebroadcast fails for missing ids:

    0        self.log.info("Checking abortprivatebroadcast fails for non-existent transaction")
    1        assert_raises_rpc_error(
    2            -5,
    3            "Transaction not in private broadcast queue",
    4            tx_originator.abortprivatebroadcast,
    5            "0" * 64,
    6        )
    
  71. in src/test/private_broadcast_tests.cpp:57 in 573bb542be
    54     BOOST_CHECK(tx_for_recipient1 == tx1 || tx_for_recipient1 == tx2);
    55 
    56     // A second pick must return the other transaction.
    57     const NodeId recipient2{2};
    58-    const auto tx_for_recipient2{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient2).value()};
    59+    const CService addr2{};
    


    l0rinc commented at 11:53 am on February 11, 2026:

    573bb542be80b63b1713a0b76bedaa5e37c3783f Could we use distinct non-default addresses (or at least distinct ports) for addr1/addr2 here? With both as CService{}, this test won’t catch address propagation mixups, something like:

     0diff --git a/src/test/private_broadcast_tests.cpp b/src/test/private_broadcast_tests.cpp
     1--- a/src/test/private_broadcast_tests.cpp	(revision 573bb542be80b63b1713a0b76bedaa5e37c3783f)
     2+++ b/src/test/private_broadcast_tests.cpp	(date 1770810667358)
     3@@ -28,8 +28,9 @@
     4     SetMockTime(Now<NodeSeconds>());
     5 
     6     PrivateBroadcast pb;
     7-    const NodeId recipient1{1};
     8-    const CService addr1{};
     9+    constexpr NodeId recipient1{1};
    10+    constexpr in_addr ipv4_addr{0x01010101}; // Dummy IPv4 address: 1.1.1.1.
    11+    const CService addr1{ipv4_addr, /*port=*/1111};
    12 
    13     // No transactions initially.
    14     BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1, /*will_send_to_address=*/addr1).has_value());
    15@@ -53,13 +54,13 @@
    16     BOOST_CHECK(tx_for_recipient1 == tx1 || tx_for_recipient1 == tx2);
    17 
    18     // A second pick must return the other transaction.
    19-    const NodeId recipient2{2};
    20-    const CService addr2{};
    21+    constexpr NodeId recipient2{2};
    22+    const CService addr2{ipv4_addr, /*port=*/2222};
    23     const auto tx_for_recipient2{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient2, /*will_send_to_address=*/addr2).value()};
    24     BOOST_CHECK(tx_for_recipient2 == tx1 || tx_for_recipient2 == tx2);
    25     BOOST_CHECK_NE(tx_for_recipient1, tx_for_recipient2);
    26 
    27-    const NodeId nonexistent_recipient{0};
    28+    constexpr NodeId nonexistent_recipient{0};
    29 
    30     // Confirm transactions <-> recipients mapping is correct.
    31     BOOST_CHECK(!pb.GetTxForNode(nonexistent_recipient).has_value());
    32@@ -92,7 +93,8 @@
    33     BOOST_CHECK_EQUAL(pb.Remove(tx_for_recipient2).value(), 0);
    34     BOOST_CHECK(!pb.Remove(tx_for_recipient2).has_value());
    35 
    36-    BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient, /*will_send_to_address=*/CService{}).has_value());
    37+    const CService addr_nonexistent{ipv4_addr, /*port=*/3333};
    38+    BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/nonexistent_recipient, /*will_send_to_address=*/addr_nonexistent).has_value());
    39 }
    40 
    41 BOOST_AUTO_TEST_SUITE_END()
    

    That would simplify the next commit so that it can focus purely on GetBroadcastInfo changes

  72. in src/test/private_broadcast_tests.cpp:69 in cc10837fc1
    64+        const auto& info1{*it1};
    65+        const auto& info2{*it2};
    66+
    67+        BOOST_CHECK_EQUAL(info1.peers.size(), 0);
    68+        BOOST_CHECK_EQUAL(info2.peers.size(), 0);
    69+    }
    


    l0rinc commented at 12:05 pm on February 11, 2026:

    there’s a lot of repetition here alone and parts of this code block are repeated a few more times here - can we add a local helper lambdas to dedup these?

     0diff --git a/src/test/private_broadcast_tests.cpp b/src/test/private_broadcast_tests.cpp
     1--- a/src/test/private_broadcast_tests.cpp	(revision a2c339144982836e4f0773604b2d99c46dd143b8)
     2+++ b/src/test/private_broadcast_tests.cpp	(date 1770811873480)
     3@@ -52,21 +52,19 @@
     4     BOOST_REQUIRE(tx1->GetWitnessHash() != tx2->GetWitnessHash());
     5 
     6     BOOST_CHECK(pb.Add(tx2));
     7-
     8-    {
     9+    const auto find_tx_info{[](auto& infos, const CTransactionRef& tx) -> const PrivateBroadcast::TxBroadcastInfo& {
    10+        const auto it{std::ranges::find(infos, tx->GetWitnessHash(), [](const auto& info) { return info.tx->GetWitnessHash(); })};
    11+        BOOST_REQUIRE(it != infos.end());
    12+        return *it;
    13+    }};
    14+    const auto check_peer_counts{[&](size_t tx1_peer_count, size_t tx2_peer_count) {
    15         const auto infos{pb.GetBroadcastInfo()};
    16         BOOST_CHECK_EQUAL(infos.size(), 2);
    17+        BOOST_CHECK_EQUAL(find_tx_info(infos, tx1).peers.size(), tx1_peer_count);
    18+        BOOST_CHECK_EQUAL(find_tx_info(infos, tx2).peers.size(), tx2_peer_count);
    19+    }};
    20 
    21-        const auto it1{std::ranges::find_if(infos, [&](const auto& info) { return info.tx->GetWitnessHash() == tx1->GetWitnessHash(); })};
    22-        const auto it2{std::ranges::find_if(infos, [&](const auto& info) { return info.tx->GetWitnessHash() == tx2->GetWitnessHash(); })};
    23-        BOOST_REQUIRE(it1 != infos.end());
    24-        BOOST_REQUIRE(it2 != infos.end());
    25-        const auto& info1{*it1};
    26-        const auto& info2{*it2};
    27-
    28-        BOOST_CHECK_EQUAL(info1.peers.size(), 0);
    29-        BOOST_CHECK_EQUAL(info2.peers.size(), 0);
    30-    }
    31+    check_peer_counts(/*tx1_peer_count=*/0, /*tx2_peer_count=*/0);
    32 
    33     const auto tx_for_recipient1{pb.PickTxForSend(/*will_send_to_nodeid=*/recipient1, /*will_send_to_address=*/addr1).value()};
    34     BOOST_CHECK(tx_for_recipient1 == tx1 || tx_for_recipient1 == tx2);
    35@@ -78,20 +76,7 @@
    36     BOOST_CHECK(tx_for_recipient2 == tx1 || tx_for_recipient2 == tx2);
    37     BOOST_CHECK_NE(tx_for_recipient1, tx_for_recipient2);
    38 
    39-    {
    40-        const auto infos{pb.GetBroadcastInfo()};
    41-        BOOST_CHECK_EQUAL(infos.size(), 2);
    42-
    43-        const auto it1{std::ranges::find_if(infos, [&](const auto& info) { return info.tx->GetWitnessHash() == tx1->GetWitnessHash(); })};
    44-        const auto it2{std::ranges::find_if(infos, [&](const auto& info) { return info.tx->GetWitnessHash() == tx2->GetWitnessHash(); })};
    45-        BOOST_REQUIRE(it1 != infos.end());
    46-        BOOST_REQUIRE(it2 != infos.end());
    47-        const auto& info1{*it1};
    48-        const auto& info2{*it2};
    49-
    50-        BOOST_CHECK_EQUAL(info1.peers.size(), 1);
    51-        BOOST_CHECK_EQUAL(info2.peers.size(), 1);
    52-    }
    53+    check_peer_counts(/*tx1_peer_count=*/1, /*tx2_peer_count=*/1);
    54 
    55     const NodeId nonexistent_recipient{0};
    56 
    57@@ -114,23 +99,19 @@
    58     BOOST_CHECK(pb.DidNodeConfirmReception(recipient1));
    59     BOOST_CHECK(!pb.DidNodeConfirmReception(recipient2));
    60 
    61-    {
    62-        const auto infos{pb.GetBroadcastInfo()};
    63-        BOOST_CHECK_EQUAL(infos.size(), 2);
    64-
    65-        const auto it_confirmed{std::ranges::find_if(infos, [&](const auto& info) { return info.tx->GetWitnessHash() == tx_for_recipient1->GetWitnessHash(); })};
    66-        const auto it_unconfirmed{std::ranges::find_if(infos, [&](const auto& info) { return info.tx->GetWitnessHash() == tx_for_recipient2->GetWitnessHash(); })};
    67-        BOOST_REQUIRE(it_confirmed != infos.end());
    68-        BOOST_REQUIRE(it_unconfirmed != infos.end());
    69-        const auto& confirmed_tx{*it_confirmed};
    70-        const auto& unconfirmed_tx{*it_unconfirmed};
    71-
    72-        BOOST_CHECK_EQUAL(confirmed_tx.peers.size(), 1);
    73-        BOOST_CHECK_EQUAL(confirmed_tx.peers[0].address.ToStringAddrPort(), addr1.ToStringAddrPort());
    74-        BOOST_CHECK_EQUAL(unconfirmed_tx.peers.size(), 1);
    75-        BOOST_CHECK_EQUAL(unconfirmed_tx.peers[0].address.ToStringAddrPort(), addr2.ToStringAddrPort());
    76-        BOOST_CHECK(confirmed_tx.peers[0].received.has_value());
    77-        BOOST_CHECK(!unconfirmed_tx.peers[0].received.has_value());
    78+    const auto infos{pb.GetBroadcastInfo()};
    79+    BOOST_CHECK_EQUAL(infos.size(), 2);
    80+    {
    81+        const auto& [tx, peers]{find_tx_info(infos, tx_for_recipient1)};
    82+        BOOST_CHECK_EQUAL(peers.size(), 1);
    83+        BOOST_CHECK_EQUAL(peers[0].address.ToStringAddrPort(), addr1.ToStringAddrPort());
    84+        BOOST_CHECK(peers[0].received.has_value());
    85+    }
    86+    {
    87+        const auto& [tx, peers]{find_tx_info(infos, tx_for_recipient2)};
    88+        BOOST_CHECK_EQUAL(peers.size(), 1);
    89+        BOOST_CHECK_EQUAL(peers[0].address.ToStringAddrPort(), addr2.ToStringAddrPort());
    90+        BOOST_CHECK(!peers[0].received.has_value());
    91     }
    92 
    93     BOOST_CHECK_EQUAL(pb.GetStale().size(), 1);
    
  73. in src/private_broadcast.cpp:126 in cc10837fc1 outdated
    121+                .sent = status.picked,
    122+                .received = status.confirmed,
    123+            });
    124+        }
    125+        entries.push_back(std::move(info));
    126+    }
    


    l0rinc commented at 12:30 pm on February 11, 2026:

    nit: alternatively we could just construct the peers before attempting to add them to the info and filling it later:

    0    for (const auto& [tx, sent_to] : m_transactions) {
    1        std::vector<PeerSendInfo> peers;
    2        peers.reserve(sent_to.size());
    3        for (const auto& status : sent_to) {
    4            peers.emplace_back(PeerSendInfo{.address = status.address, .sent = status.picked, .received = status.confirmed});
    5        }
    6        entries.emplace_back(TxBroadcastInfo{.tx = tx, .peers = std::move(peers)});
    7    }
    

    or maybe even

    0    for (const auto& sent_to : m_transactions | std::views::values) {
    1        std::vector<PeerSendInfo> peers;
    2        peers.reserve(sent_to.size());
    3        std::ranges::transform(sent_to, std::back_inserter(peers), [](const auto& status) {
    4            return PeerSendInfo{.address = status.address, .sent = status.picked, .received = status.confirmed};
    5        });
    6    }
    
  74. in test/functional/p2p_private_broadcast.py:312 in a10e5a176e
    307+        assert_equal(len(pending), 1)
    308+        assert_equal(pending[0]["hex"].lower(), tx["hex"].lower())
    309+        peers = pending[0]["peers"]
    310+        assert len(peers) >= NUM_PRIVATE_BROADCAST_PER_TX
    311+        assert all("address" in p and "sent" in p for p in peers)
    312+        assert sum(1 for p in peers if "received" in p) >= NUM_PRIVATE_BROADCAST_PER_TX
    


    l0rinc commented at 1:47 pm on February 11, 2026:

    some people prefer to use the dedicated asserts that return better :

    0        assert_greater_than_or_equal(sum(1 for p in peers if "received" in p), NUM_PRIVATE_BROADCAST_PER_TX)
    

    l0rinc commented at 5:06 pm on February 13, 2026:
    nit: assert len(peers) >= NUM_PRIVATE_BROADCAST_PER_TX still uses this comparison, but rest seems fine 👍
  75. in test/functional/p2p_private_broadcast.py:382 in a10e5a176e outdated
    375@@ -366,6 +376,11 @@ def run_test(self):
    376         self.log.info("Waiting for normal broadcast to another peer")
    377         self.destinations[1]["node"].wait_for_inv([inv])
    378 
    379+        self.log.info("Checking getprivatebroadcastinfo no longer reports the transaction after it is received back")
    380+        pbinfo = tx_originator.getprivatebroadcastinfo()
    381+        pending = [t for t in pbinfo["transactions"] if t["txid"] == txs[0]["txid"] and t["wtxid"] == txs[0]["wtxid"]]
    382+        assert_equal(len(pending), 0)
    


    l0rinc commented at 1:51 pm on February 11, 2026:

    alternatively we can avoid stopping after the first match is found:

    0        assert not any(t["txid"] == txs[0]["txid"] and t["wtxid"] == txs[0]["wtxid"] for t in pbinfo["transactions"])
    

    l0rinc commented at 5:06 pm on February 13, 2026:
    This wasn’t applied, but it’s a nit anyway
  76. l0rinc changes_requested
  77. l0rinc commented at 2:03 pm on February 11, 2026: contributor

    I went over the change, my biggest concern is the race in remove and the verbose tests and some minor commit churn - left examples for each suggestion to speed up the process.

    Concept ACK

  78. net: Add PrivateBroadcast::GetBroadcastInfo
    Co-authored-by: Daniela Brozzoni <danielabrozzoni@protonmail.com>
    Co-authored-by: l0rinc <pap.lorinc@gmail.com>
    5e64982541
  79. rpc: Add getprivatebroadcastinfo
    Co-authored-by: Vasil Dimov <vd@freebsd.org>
    996f20c18a
  80. test: Cover getprivatebroadcastinfo in p2p_private_broadcast
    Co-authored-by: l0rinc <pap.lorinc@gmail.com>
    15dff452eb
  81. rpc: Add abortprivatebroadcast
    Co-authored-by: l0rinc <pap.lorinc@gmail.com>
    557260ca14
  82. test: Cover abortprivatebroadcast in p2p_private_broadcast
    Co-authored-by: l0rinc <pap.lorinc@gmail.com>
    c3378be10b
  83. doc: Mention private broadcast RPCs in release notes 2a1d0db799
  84. andrewtoth force-pushed on Feb 12, 2026
  85. andrewtoth commented at 0:55 am on February 12, 2026: contributor

    Thank you for the review @l0rinc, I have taken all your suggestions.

    git diff a034fbd7d907da99726c20e6bf09239d2dfd711b..2a1d0db7994eb2aa8527944f62161b6b8af682da

  86. DrahtBot added the label CI failed on Feb 12, 2026
  87. andrewtoth closed this on Feb 12, 2026

  88. andrewtoth reopened this on Feb 12, 2026

  89. DrahtBot removed the label CI failed on Feb 12, 2026
  90. andrewtoth commented at 2:19 pm on February 13, 2026: contributor

    @vasild @danielabrozzoni @w0xlt @l0rinc thank you all for your reviews so far!

    Feature freeze for v31 is in 7 days, and I think it’s important to have these RPCs in for private broadcast. I’ve reduced the scope to not track finished transactions. This makes it much easier to review and we can figure out persistence afterwards.

    Any more reviews would be greatly appreciated :pray:

  91. in src/rpc/mempool.cpp:149 in 2a1d0db799
    144+        "Returns information about transactions that are currently being privately broadcast.\n",
    145+        {},
    146+        RPCResult{
    147+            RPCResult::Type::OBJ, "", "",
    148+            {
    149+                {RPCResult::Type::ARR, "transactions", "",
    


    nervana21 commented at 4:49 pm on February 13, 2026:

    This would be helpful for downstream consumers of RPCs

    0                {RPCResult::Type::ARR, "transactions", "Transactions currently in the private broadcast queue",
    

    andrewtoth commented at 4:55 pm on February 13, 2026:
    Will add if I have to push again.
  92. nervana21 commented at 4:49 pm on February 13, 2026: contributor

    tACK 2a1d0db7994eb2aa8527944f62161b6b8af682da

    I’ve understood and run the tests, which all pass on my machine. I thoroughly reviewed all code changes and fellow reviewer comments. I’ve also lightly reviewed related private broadcast PRs in order to have a better context of these changes.

  93. DrahtBot requested review from danielabrozzoni on Feb 13, 2026
  94. DrahtBot requested review from l0rinc on Feb 13, 2026
  95. DrahtBot requested review from vasild on Feb 13, 2026
  96. l0rinc approved
  97. l0rinc commented at 5:20 pm on February 13, 2026: contributor

    ACK 2a1d0db7994eb2aa8527944f62161b6b8af682da

    Rebased and ran the test locally, lightly tested the PRCs directly, reviewed the code and the diff, LGTM.

    Latest push fixed the race in AbortPrivateBroadcast, removed connections_cancelled > 0 guard, added negative functional coverage, adjusted addr1/addr2/addr_nonexistent test ports, reduced test verbosity, adjusted GetBroadcastInfo() to emplace_back the TxBroadcastInfo and moved the final vector.

    The remaining nits are not important, can be merged as-is - though I would wait for @vasild’s blessing as well.

  98. in src/net_processing.cpp:1876 in 2a1d0db799
    1871+    for (const auto& [tx, _] : snapshot) {
    1872+        if (tx->GetHash().ToUint256() != id && tx->GetWitnessHash().ToUint256() != id) continue;
    1873+        if (const auto peer_acks{m_tx_for_private_broadcast.Remove(tx)}) {
    1874+            removed_txs.push_back(tx);
    1875+            if (NUM_PRIVATE_BROADCAST_PER_TX > *peer_acks) {
    1876+                connections_cancelled += (NUM_PRIVATE_BROADCAST_PER_TX - *peer_acks);
    


    danielabrozzoni commented at 11:09 am on February 16, 2026:

    I think this might end up closing more connections than needed, but it’s not a big deal because ReattemptPrivateBroadcast will take care of re-opening them.

    For example:

    • We send a transaction with sendrawtransaction, NumToOpenAdd(3) is called, m_num_to_open = 3
    • One connection is opened in ThreadPrivateBroadcast, NumToOpenSub(1) is called, m_num_to_open = 2
    • One connection is opened in ThreadPrivateBroadcast, NumToOpenSub(1) is called, m_num_to_open = 1
    • We abort the broadcast before any peer acked the transaction. We will call NumToOpenSub(NUM_PRIVATE_BROADCAST_PER_TX - *peer_acks), so NumToOpenSub(3). In this specific case, m_num_to_open would end up being equal to zero. However, if we were broadcasting multiple transactions at the same time, we would not open some connections for some other transaction.

    andrewtoth commented at 3:09 pm on February 16, 2026:

    Yes, the private broadcasting is inherently racy and ReattemptPrivateBroadcast is used to make sure transactions get broadcasted eventually.

    For this scenario, if we were broadcasting multiple transactions simultaneously, then the second connection that is opened will pick a different transaction. So, only 1 connection will have been used by the transaction we are aborting.


    danielabrozzoni commented at 3:44 pm on February 16, 2026:
    Ah, that makes a lot of sense. Thanks for the explanation!
  99. danielabrozzoni commented at 11:27 am on February 16, 2026: member

    tACK 2a1d0db7994eb2aa8527944f62161b6b8af682da

    Code looks good to me, I did some testing on signet and everything was working as intended.

    I suspect we might cancel too many connections when calling abort… However, I don’t think it’s really problematic, as ReattemptPrivateBroadcast would reopen these connections if needed.

  100. sedited approved
  101. sedited commented at 12:02 pm on February 19, 2026: contributor
    ACK 2a1d0db7994eb2aa8527944f62161b6b8af682da
  102. achow101 commented at 9:09 pm on February 19, 2026: member
    ACK 2a1d0db7994eb2aa8527944f62161b6b8af682da
  103. achow101 merged this on Feb 19, 2026
  104. achow101 closed this on Feb 19, 2026

  105. w0xlt commented at 10:12 pm on February 19, 2026: contributor
    Post-merge ACK
  106. in doc/release-notes-29415.md:19 in 2a1d0db799
    11@@ -12,3 +12,8 @@ P2P and network changes
    12   2. If the originator sends two otherwise unrelated transactions, they
    13      will not be linkable. This is because a separate connection is used
    14      for broadcasting each transaction. (#29415)
    15+
    16+- New RPCs have been added to introspect and control private broadcast:
    17+  `getprivatebroadcastinfo` reports transactions currently being privately
    18+  broadcast, and `abortprivatebroadcast` removes matching
    19+  transactions from the private broadcast queue.
    


    vasild commented at 5:51 am on February 21, 2026:
    0- New RPCs have been added to introspect and control private broadcast:
    1  `getprivatebroadcastinfo` reports transactions currently being privately
    2  broadcast, and `abortprivatebroadcast` removes matching
    3  transactions from the private broadcast queue. (#34329)
    
  107. vasild commented at 5:58 am on February 21, 2026: contributor

    ACK 2a1d0db7994eb2aa8527944f62161b6b8af682da

    The changes to store finished transactions that were stripped from here deserve a followup.

    I would wait for @vasild’s blessing as well

    Not commenting here was not intentional as I was mostly offline recently ⛷️ . I am glad this was merged without my further input. I think it is unhealthy to have a single-man bottlenecks. Amazing team work! ❤️

  108. sedited referenced this in commit 925759d172 on Feb 23, 2026
  109. sedited referenced this in commit 8640523843 on Feb 23, 2026
  110. andrewtoth deleted the branch on Feb 28, 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-03-04 06:13 UTC

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