net: always complete all initial private broadcast connections #36277

pull andrewtoth wants to merge 2 commits into bitcoin:master from andrewtoth:always_send changing 8 files +218 −68
  1. andrewtoth commented at 4:47 PM on September 16, 2026: contributor

    Make sure we send out all three connections when initiating a private broadcast.

    Use MarkReceived instead of Remove to mark a tx as having been received back. Prevent additional stale retry connections by tracking disconnects and explicitly granting new connections with TryGrantRetry.

    Intended as an alternative to #34707, addressing the theoretical timing attack described in #34707 (comment). Also fixes theoretical timing attacks described in #29415 (review). This is based on the idea described in #29415 (review):

    always send 3 times regardless if the tx is received in our mempool or is otherwise invalid

  2. DrahtBot added the label P2P on Sep 16, 2026
  3. DrahtBot commented at 4:47 PM on September 16, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/36277.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Approach ACK vasild

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #35502 (refactor: extract per-message helpers from ProcessMessage (move-only) by w0xlt)
    • #34707 (net: keep finished private broadcast txs in memory by andrewtoth)

    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.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. vasild commented at 5:07 PM on September 16, 2026: contributor

    Concept ACK, will review

  5. in src/net_processing.cpp:1772 in e4204baff7
    1767 | @@ -1767,7 +1768,8 @@ void PeerManagerImpl::ReattemptPrivateBroadcast(CScheduler& scheduler)
    1768 |                  LogDebug(BCLog::PRIVBROADCAST, "Giving up broadcast attempts for txid=%s wtxid=%s: %s",
    1769 |                           stale_tx->GetHash().ToString(), stale_tx->GetWitnessHash().ToString(),
    1770 |                           mempool_acceptable.m_state.ToString());
    1771 | -                m_tx_for_private_broadcast.Remove(stale_tx);
    1772 | +                // Mark received so it will get cleaned up.
    1773 | +                m_tx_for_private_broadcast.MarkReceived(stale_tx);
    


    vasild commented at 3:26 PM on September 18, 2026:

    Why replace Remove() with MarkReceived() here? The transaction has not been actually received.


    andrewtoth commented at 2:40 PM on September 19, 2026:

    If ReattemptPrivateBroadcast gets scheduled before we complete all connections but the transaction now conflicts with our mempool, it will be removed and won't achieve the goal of this patch.

    The transaction has not been actually received.

    Right but this is an easy way to make sure no more connections are scheduled for this transaction.


    andrewtoth commented at 8:04 PM on September 20, 2026:

    I renamed this method to MarkResolved, to handle both cases.

  6. in src/net_processing.cpp:1856 in e4204baff7
    1856 | -        m_connman.m_private_broadcast.NumToOpenAdd(1);
    1857 | +    if (node.IsPrivateBroadcastConn()) {
    1858 | +        m_tx_for_private_broadcast.NodeDisconnected(nodeid);
    1859 | +        // Retry a connection if we didn't complete the handshake.
    1860 | +        if (!node.fSuccessfullyConnected && m_tx_for_private_broadcast.HavePendingTransactions()) {
    1861 | +            m_connman.m_private_broadcast.NumToOpenAdd(1);
    


    vasild commented at 3:31 PM on September 18, 2026:

    Maybe elaborate this comment to something like:

    // We consider a transaction has been sent to a peer if we sent them an INV.
    // Normally they should request the transaction with GETDATA, but this may
    // not happen if they are already aware of the transaction.
    // If we didn't send them an INV, then schedule a new connection to compensate
    // for this send failure. Since whether we sent them INV or not is not readily
    // available here we use fSuccessfullyConnected instead because INV is sent right
    // after a successful connection.
    

    vasild commented at 4:19 AM on September 20, 2026:

    After some more thinking on this - we do not need the NumToOpenAdd(1) call here at all. It is to compensate for a failed send. IIRC the origin of this was when the "retry stale" logic was running after 10 minutes. But it was changed to run every 2-3 minutes. I think that in the unlikely event that the send fails after opening the connection, the other 2 connections will suffice for the broadcast (just 1 suffices). In the even more unlikely event that they don't either, then the "retry stale" logic will pick it up in 2-3 minutes. Right?


    andrewtoth commented at 4:33 PM on September 20, 2026:

    This was discussed in #29415 (review). If we connect but fail the handshake (which is quite common) we do not reach PickTxForSend, so we don't increase send_statuses.size(). If we also implement your suggestion #36277 (review), then we would never open a new connection and could leave a pending transaction stranded.

    I think this would also just make the broadcasting less reliable, since many of the first 3 initial txs would just fail handshakes.


    andrewtoth commented at 8:04 PM on September 20, 2026:

    Took the comment.

  7. in src/private_broadcast.cpp:22 in e4204baff7
      13 | @@ -14,12 +14,21 @@ PrivateBroadcast::AddResult PrivateBroadcast::Add(const CTransactionRef& tx)
      14 |      EXCLUSIVE_LOCKS_REQUIRED(!m_mutex)
      15 |  {
      16 |      LOCK(m_mutex);
      17 | +    // Cleanup finished transactions
      18 | +    std::erase_if(m_transactions, [this](const auto& entry) {
      19 | +        const auto& state{entry.second};
      20 | +        return state.received && !IsPending(state) &&
      21 | +               std::ranges::all_of(state.send_statuses, [](const auto& status) { return status.disconnected; });
      22 | +    });
    


    vasild commented at 3:59 PM on September 18, 2026:

    What about removing transactions from the list only when the size of the list reaches m_max_transactions? Then maybe send_status.disconnected and PrivateBroadcast::NodeDisconnected() will not be needed?


    andrewtoth commented at 8:05 PM on September 20, 2026:

    Done. Removed NodeDisconnected. This simplifies it :thumbsup:.

  8. in src/net_processing.cpp:4734 in e4204baff7
    4728 | @@ -4726,15 +4729,10 @@ void PeerManagerImpl::ProcessMessage(Peer& peer, CNode& pfrom, const std::string
    4729 |          const uint256& hash = peer.m_wtxid_relay ? wtxid.ToUint256() : txid.ToUint256();
    4730 |          AddKnownTx(peer, hash);
    4731 |  
    4732 | -        if (const auto num_broadcasted{m_tx_for_private_broadcast.Remove(ptx)}) {
    4733 | +        if (m_tx_for_private_broadcast.MarkReceived(ptx)) {
    4734 |              LogDebug(BCLog::PRIVBROADCAST, "Received our privately broadcast transaction (txid=%s) from the "
    4735 | -                                           "network from %s; stopping private broadcast attempts",
    4736 | +                                           "network from %s; stopping private broadcast retries",
    


    vasild commented at 4:08 PM on September 18, 2026:

    After receiving the transaction (with this PR) we may send a few times more, so drop the last part:

    -                                           "network from %s; stopping private broadcast attempts",
    +                                           "network from %s",
    
  9. in src/private_broadcast.h:111 in e4204baff7
     102 | @@ -100,6 +103,30 @@ class PrivateBroadcast
     103 |      std::optional<size_t> Remove(const CTransactionRef& tx)
     104 |          EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);
     105 |  
     106 | +    /**
     107 | +     * Mark a transaction as being received back from the network.
     108 | +     * @param[in] tx Transaction received from the network.
     109 | +     * @return Whether the transaction was found in the storage.
     110 | +     */
     111 | +    bool MarkReceived(const CTransactionRef& tx)
    


    vasild commented at 4:10 PM on September 18, 2026:

    Add comments for the new functions before class PrivateBroadcast.

  10. in src/net_processing.cpp:1851 in e4204baff7 outdated
    1848 | @@ -1847,11 +1849,12 @@ void PeerManagerImpl::FinalizeNode(const CNode& node)
    1849 |          LOCK(m_headers_presync_mutex);
    1850 |          m_headers_presync_stats.erase(nodeid);
    1851 |      }
    1852 | -    if (node.IsPrivateBroadcastConn() &&
    1853 | -        !m_tx_for_private_broadcast.DidNodeConfirmReception(nodeid) &&
    


    vasild commented at 4:30 PM on September 18, 2026:

    DidNodeConfirmReception() is now unused, can be removed.


    andrewtoth commented at 8:05 PM on September 20, 2026:

    Added a second commit that removes it.

  11. in src/private_broadcast.h:263 in e4204baff7
     256 | @@ -228,6 +257,10 @@ class PrivateBroadcast
     257 |      struct TxSendStatus {
     258 |          NodeClock::time_point time_added{NodeClock::now()};
     259 |          std::vector<SendStatus> send_statuses;
     260 | +        /// Total number of sends granted, including initial count.
     261 | +        size_t send_limit{INITIAL_BROADCAST_COUNT};
     262 | +        /// Whether the transaction was received back from the network.
     263 | +        bool received{false};
    


    vasild commented at 4:38 PM on September 18, 2026:

    Naming nit: I would find it more understandable if received is called received_by_us. Because nearby send_statuses contains information whether the transaction has been received by the peers we send it to.


    andrewtoth commented at 8:05 PM on September 20, 2026:

    Renamed to resolved.

  12. in src/private_broadcast.h:261 in e4204baff7
     256 | @@ -228,6 +257,10 @@ class PrivateBroadcast
     257 |      struct TxSendStatus {
     258 |          NodeClock::time_point time_added{NodeClock::now()};
     259 |          std::vector<SendStatus> send_statuses;
     260 | +        /// Total number of sends granted, including initial count.
     261 | +        size_t send_limit{INITIAL_BROADCAST_COUNT};
    


    vasild commented at 4:42 PM on September 18, 2026:

    Naming nit: it is not a "limit" because we will always aim to send it this many times and will not settle for less. Maybe planned_sends?


    andrewtoth commented at 8:06 PM on September 20, 2026:

    Renamed to planned_sends.

  13. in src/net_processing.cpp:205 in e4204baff7
     201 | @@ -202,7 +202,7 @@ static constexpr double MAX_ADDR_RATE_PER_SECOND{0.1};
     202 |   *  is exempt from this limit). */
     203 |  static constexpr size_t MAX_ADDR_PROCESSING_TOKEN_BUCKET{MAX_ADDR_TO_SEND};
     204 |  /** For private broadcast, send a transaction to this many peers. */
     205 | -static constexpr size_t NUM_PRIVATE_BROADCAST_PER_TX{3};
     206 | +static constexpr size_t NUM_PRIVATE_BROADCAST_PER_TX{PrivateBroadcast::INITIAL_BROADCAST_COUNT};
    


    vasild commented at 4:44 PM on September 18, 2026:

    I do not see a value in NUM_PRIVATE_BROADCAST_PER_TX now. Consider using PrivateBroadcast::INITIAL_BROADCAST_COUNT all over the place and dropping NUM_PRIVATE_BROADCAST_PER_TX.

    Also the name NUM_PRIVATE_BROADCAST_PER_TX is/was a bit misleading - we could send more times than that. Should be NUM_INITIAL_PRIVATE_BROADCAST_PER_TX.

  14. in src/net_processing.cpp:1762 in e4204baff7 outdated
    1758 | @@ -1759,6 +1759,7 @@ void PeerManagerImpl::ReattemptPrivateBroadcast(CScheduler& scheduler)
    1759 |              LOCK(cs_main);
    1760 |              auto mempool_acceptable = m_chainman.ProcessTransaction(stale_tx, /*test_accept=*/true);
    1761 |              if (mempool_acceptable.m_result_type == MempoolAcceptResult::ResultType::VALID) {
    1762 | +                if (!m_tx_for_private_broadcast.TryGrantRetry(stale_tx)) continue;
    


    vasild commented at 5:13 PM on September 18, 2026:

    This increments the send_limit counter for a transaction that came from GetStale() and is acceptable in the mempool. However such a transaction might still have unused "allowance". For example: broadcast 1 times, to be sent 2 more times (send_limit is 3), not received back. Such transaction will have 2 more sends and does not need send_limit to be incremented from 3 to 4.


    andrewtoth commented at 8:10 PM on September 20, 2026:

    Right, we would grant an extra connection if ReattemptPrivateBroadcast was called before all 3 initial connections were made. Fixed by also checking that the tx is no longer pending.

  15. vasild commented at 5:19 PM on September 18, 2026: contributor

    Approach ACK e4204baff71dfef93597ce5c92ee8111b5b380b0

    The code looks solid. It introduces a rigid number of times a transaction will be broadcast, regardless of whether it is received back or not. It starts at 3 and may be incremented if the transaction is stale in order to induce a new broadcast.

    It also tracks when the connections are closed so that it does not remove a transaction from the list in the middle of a connection.

    I did not review the tests yet. Will review them for a full ACK.

    Thanks!

    <details> <summary>Show Signature</summary>

    -----BEGIN PGP SIGNED MESSAGE-----
    Hash: SHA256
    
    Approach ACK e4204baff71dfef93597ce5c92ee8111b5b380b0
    
    The code looks solid. It introduces a rigid number of times a transaction will be broadcast, regardless of whether it is received back or not. It starts at 3 and may be incremented if the transaction is stale in order to induce a new broadcast.
    
    It also tracks when the connections are closed so that it does not remove a transaction from the list in the middle of a connection.
    
    I did not review the tests yet. Will review them for a full ACK.
    
    Thanks!
    -----BEGIN PGP SIGNATURE-----
    
    iQRPBAEBCAA5FiEE5k2NRWFNsHVF2czBVN8G9ktVy78FAmqtcfUbFIAAAAAABAAO
    bWFudTIsMi41KzEuMTIsMiwzAAoJEFTfBvZLVcu/mJwf/jA0KM2pZZ4MF7NrK6Vh
    BVpc344gxh1913MWk9U4ArzFtKhgU9oreZ99UiZAcOZRaYrwvYwN5nP/v8MKneO9
    2S6WoilOcWdTo4GuKbtqFlasXc8L/VQNjlczWY2kBJrsCsDu4POH5EHmY9dIZbnA
    8Y1ZvX+RUNQnIYY11KBjVWg6+cwocYRdlOdkz8fij4d4rI5Bpek6oAWJY8Cb6a8v
    lqjEFfkp9bx9oGkuktJlNubIABXO/e/jyYB1+1+dA4y09LS0R4qfXE0Z7PSjBoQB
    AJpYkVwFasGB3H5NuxjffKfLXFKdbxv7M+vl8zxmIiIiDcWic/7o6EHCYUrp7NxA
    fdZQvoWqwbteWHAWNLbSavprq2ftqVOOzyPhfEyXu7uklpU8M3HzRNFm+ocB7XNb
    VZULG69A+p7wfSbBBQScNm8a7RSnF05zp8v8ONsgNIKS6C0Cdi4HqWXEFt3PKQFg
    bcqT4ze0pz3VxkdTnEW2G25kJnnCyfYw3ILt606N8TfU1PAXOrG24aahQnU5EsVD
    b4qNTi32YWIOoHcjpLOYojPJ0DC3LSuJbK9V9/fvFo1V+9OtKH0uo7yz4mDZsaN2
    3IrWYQbWXlwY8v3SmoRdeNKaQO46cZheliL5ni5KFLpLbfwgGbXIMUGA18GbB2cg
    6qTfo5lwJ0rVRxNNi4xfuX0no6S4fXZiM4rx9wXAbF4UJNfoW2z+VCkNbqqDzk2/
    qqOKiY6lvNHN7R33lDWj9WKJnh9p2CZTpNynd8s0cz0OVVAuP2Q/L7Ci+wXty+eG
    NVeD/HSqmSGb+jwepQJXfJ4TMMyOKt/c0vphzpID17BZnsH48iSZ8axX4w/LR8ef
    VLigikN2oxnqT4BFQPNh0u5GET8lOS5bkUdpMCFyfGb7uCUbLnd93qW75LYJcRVp
    SodDVfYpgVM/3JVK/eXfVvfQtNW1iZYQwxlnn7bfrIsSOtz8hwaOnZ2cQJDW7VUD
    Riv9xqnU7aEEMKs8QPkvnhXV1tgP9RLhP7ECDc+VoqF77wjvu7SVkstDxkeCngcO
    E2A0GOcXbuLzE0Vp7irlHMjGwJWNp+D1dHpXsf1Lk5Z/48At/z+GAdVcQijsWMbH
    3TiUWg0VYdyVhmEMnTwqBaI/MN9cUmC+Gh8l/5Id3UwL8Wsedc88/rtM3dph/TSc
    0k/+07QRhLtJ2ELOh9emua2CSAua2iPcMmH0uHws0TB8MjayCmU/kDzE+FpAOAxN
    oiXeda3qrX8zGp3qzwJtuadkjG1oMtGzHJGEPAQ8HV3E6uPk6wZloHp7Y2GIV0g/
    H32YRC73S3zTF4W273Q/2jihs2ealLqFa/SZt0vwMi/oPfz6Zi8IVOtgNz8bka+w
    o0M=
    =9DC2
    -----END PGP SIGNATURE-----
    

    vasild's public key is on openpgp.org

    </details>

  16. andrewtoth force-pushed on Sep 20, 2026
  17. net: always complete all initial private broadcast connections
    Make sure we send out all three connections when intiating a private broadcast.
    
    Use MarkResolved instead of Remove to mark a tx as having been received back or mempool-conflicted.
    Prevent additional stale retry connections by tracking disconnects and explicitly granting new connections with TryGrantRetry.
    d5324e53dc
  18. net: remove PrivateBroadcast::DidNodeConfirmReception
    This method is no longer called in production.
    dad979bf01
  19. andrewtoth force-pushed on Sep 20, 2026
  20. DrahtBot added the label CI failed on Sep 20, 2026
  21. DrahtBot removed the label CI failed on Sep 20, 2026
  22. andrewtoth commented at 8:29 PM on September 20, 2026: contributor

    Thanks @vasild for your detailed review. I have taken most of your suggestions.

    • Renamed MarkReceived -> MarkResolved.
    • Cleanup finished txs at capacity, so we can remove NodeDisconnected and related disconnected state.
    • Removed DidNodeConfirmReception.
    • Other various renames and cleanups.

    https://github.com/bitcoin/bitcoin/compare/e4204baff71dfef93597ce5c92ee8111b5b380b0..dad979bf010adeed16d9eebe9fb822f5c519b90c


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-09-20 21:51 UTC

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