net: keep finished private broadcast txs in memory #34707

pull andrewtoth wants to merge 4 commits into bitcoin:master from andrewtoth:private_broadcast_store changing 7 files +181 −35
  1. andrewtoth commented at 1:46 AM on March 2, 2026: contributor

    Follow up from #34329.

    Private broadcast transaction data is removed from memory when either 1) the tx is received back from another peer and inserted into our mempool or 2) the tx is no longer acceptable to our mempool.

    We want to persist this data for a number of reasons. See #30471.

    This PR changes the behavior of the private broadcast queue to keep received txs in memory. The peer's address we received the tx from and the time is now also returned via getprivatebroadcastinfo RPC.

    Keeping mempool rejected txs in memory is left for a follow-up.

    This is designed to facilitate follow up work including:

    • rebroadcasting txs that are in our mempool but have not been confirmed after a while. This can obviate rebroadcasting wallet txs if private broadcast is enabled (see #34533 (comment)).
    • adding a delay when submitting private broadcast txs so they will be broadcast at a later time (see #34118).
    • persisting private broadcast state to disk for resuming after restart or crash (see #34322).
    • expiring txs after a while so they are removed from the queue without having to manually abort.
  2. DrahtBot added the label P2P on Mar 2, 2026
  3. DrahtBot commented at 1:47 AM on March 2, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK vasild
    Stale ACK optout21, danielabrozzoni

    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:

    • #35016 (net: deduplicate private broadcast state and snapshot types by takeshikurosawaa)

    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. andrewtoth force-pushed on Mar 2, 2026
  5. DrahtBot added the label CI failed on Mar 2, 2026
  6. DrahtBot commented at 2:04 AM on March 2, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task macOS native: https://github.com/bitcoin/bitcoin/actions/runs/22558096885/job/65339168667</sub> <sub>LLM reason (✨ experimental): private_broadcast_tests failed with an assertion error in netaddress.cpp (ToStringAddr), causing the test subprocess to abort.</sub>

    <details><summary>Hints</summary>

    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.

    </details>

  7. DrahtBot removed the label CI failed on Mar 2, 2026
  8. in src/net_processing.cpp:4498 in fa857a980c
    4493 | @@ -4491,10 +4494,10 @@ void PeerManagerImpl::ProcessMessage(Peer& peer, CNode& pfrom, const std::string
    4494 |          const uint256& hash = peer.m_wtxid_relay ? wtxid.ToUint256() : txid.ToUint256();
    4495 |          AddKnownTx(peer, hash);
    4496 |  
    4497 | -        if (const auto num_broadcasted{m_tx_for_private_broadcast.Remove(ptx)}) {
    4498 | -            LogDebug(BCLog::PRIVBROADCAST, "Received our privately broadcast transaction (txid=%s) from the "
    4499 | +        if (const auto num_broadcasted{m_tx_for_private_broadcast.Received(ptx, CService{pfrom.addr})}) {
    4500 | +        LogDebug(BCLog::PRIVBROADCAST, "Received our privately broadcast transaction (txid=%s) from the "
    


    optout21 commented at 10:37 AM on March 6, 2026:

    nit: indenting is off

  9. in src/private_broadcast.cpp:50 in fa857a980c
      45 | +    const auto it{m_transactions.find(tx)};
      46 | +    if (it == m_transactions.end()) return std::nullopt;
      47 | +    if (it->second.received_from.has_value()) return std::nullopt;
      48 | +    it->second.received_from.emplace(received_from);
      49 | +    it->second.received_time.emplace(NodeClock::now());
      50 | +    it->second.mempool_rejection_reason.emplace("missing-inputs");
    


    optout21 commented at 10:40 AM on March 6, 2026:

    What's the reason for using "missing-inputs" here?


    andrewtoth commented at 1:27 AM on March 11, 2026:

    Removed this line.

  10. in src/private_broadcast.cpp:62 in fa857a980c
      57 | +{
      58 | +    LOCK(m_mutex);
      59 | +    const auto it{m_transactions.find(tx)};
      60 | +    if (it == m_transactions.end()) return;
      61 | +    it->second.mempool_rejection_reason = std::move(rejection_reason);
      62 | +    if (!it->second.mempool_rejection_reason.has_value()) {
    


    optout21 commented at 10:44 AM on March 6, 2026:

    I find the dependency between rejection_reason and received_from confusing here. If the TX can be accepted in the mempool, the received from info is cleared. Could you elaborate on the why?

    Update: I found it described in the header comment, but I still don't understand fully (may be my problem).


    andrewtoth commented at 1:31 AM on March 11, 2026:

    I updated this logic and added a clarifying comment. It only clears the received from info now if there is a transition from mempool rejected -> mempool acceptable. This means the tx has been evicted from our mempool for some reason that does not cause a conflict with it. In this case, it is like we never received the tx from a peer. It would be confusing to have a state where we received it but it is not in our mempool and not conflicted or confirmed. So, we clear received state and try the broadcasting flow again.

    Now we also pick txs if they have are acceptable to our mempool and have no received from state. So, it is necessary to clear this data to ever have this tx picked for private broadcast again.


    andrewtoth commented at 1:45 PM on March 11, 2026:

    I thought about this some more, and it seems it was as confusing as it was because I was trying to use a single piece of state to represent two different possible states. If mempool_rejection_reason was not nullopt it could mean either the tx is not in the mempool and is not acceptable, or is currently in the mempool.

    I split this out into an enum with 3 states: IN_MEMPOOL, ACCEPTABLE, REJECTED.

    Now only if we are REJECTED will we store a reason string.

    This simplifies the logic since only ACCEPTABLE txs will be picked for broadcast and returned as pending.

  11. in src/private_broadcast.h:161 in fa857a980c
     154 | @@ -126,6 +155,9 @@ class PrivateBroadcast
     155 |      };
     156 |  
     157 |      struct TxState {
     158 | +        std::optional<std::string> mempool_rejection_reason;
     159 | +        std::optional<CService> received_from;
     160 | +        std::optional<NodeClock::time_point> received_time;
     161 |          std::vector<SendStatus> sent_to;
    


    optout21 commented at 10:55 AM on March 6, 2026:

    nit: Ordering according to the chronology of setting the fields would make more sense (i.e., sent_to first).

  12. optout21 commented at 11:14 AM on March 6, 2026: contributor

    ACK d5d7c28e1b4b76daddad95a23cbf56209ca0b86e

    Previous: ConceptACK bd31f7391dd45b2582092762466e40ad2594b178 Another round of review, no concerns (but keeping the status for now). Prev: Pre-review, left some minor comments. A general conceptual point is raised in separate comment (main-thread).

  13. optout21 commented at 11:20 AM on March 6, 2026: contributor

    A more important conceptual point I'd like to raise: this work is the precursor of features that are related to locally-originated transactions, and not private broadcast. I think that it is important to clarify this and use the right nomenclature at this early stage.

    As I see:

    • there where wishes/proposals related to local-originated transaction handling, private broadcast being one of them
    • private broadcast for locally-originating txs was implemented (🔥), and it introduced a short-lived queue needed for private broadcast
    • other features are starting to be implemented on top of the private broadcast queue, but logically they are not strictly related to the private nature of the broadcast, but the locally-generated property of it.
    • a good test is to answer the question: would this feature make sense even if the broadcast was not private, but 'regular' broadcast? If the answer is yes or likely yes, than the feaure is not related to the private broadcast, and its naming should not be based on it.
    • it should be even possible to decouple private broadcast from other local-originating features (though it makes sense to use private bc always).
    • With the current direction of things I even propose that the new RPC's names (getprivatebroadcastinfo) should not be based on the 'private' feature, but 'local tx' . Also, if the mentioned features are being considered, the name of the queue could be reconsidered at this point.

    In short, for the newly considered features I consider the "locally-originated TX" to be more relevant than the "private" nature of the broadcast, and suggest using it in namings.

    (fyi @vasild)

  14. in src/rpc/mempool.cpp:148 in afca84489c outdated
     147 | @@ -148,7 +148,7 @@ static RPCHelpMan getprivatebroadcastinfo()
     148 |          RPCResult{
    


    vasild commented at 11:45 AM on March 9, 2026:

    In the commit message of fa857a980c2fd6a1f88cea8d0cff984824352609 net: track received peer and mempool status in private broadcast:

    "... or not accepted to mempool in stale check"

    s/accepted/acceptable/


    andrewtoth commented at 1:33 AM on March 11, 2026:

    Oops forgot to update the commit message. Will do when I push again.

  15. in src/private_broadcast.cpp:85 in afca84489c
      89 | +        [](const auto& el) { return DerivePriority(el.second.sent_to); })};
      90 | +
      91 | +    if (it != pending.end()) {
      92 | +        auto& [tx, state]{*it};
      93 | +        state.sent_to.emplace_back(will_send_to_nodeid, will_send_to_address, NodeClock::now());
      94 | +        return it->first;
    


    vasild commented at 2:15 PM on March 9, 2026:

    In commit fa857a980c2fd6a1f88cea8d0cff984824352609 net: track received peer and mempool status in private broadcast the return is unnecessary changed from

    return tx;
    

    to

    return it->first;
    

    I find the former more readable.

  16. in src/net_processing.cpp:1874 in afca84489c outdated
    1869 | @@ -1868,7 +1870,8 @@ std::vector<CTransactionRef> PeerManagerImpl::AbortPrivateBroadcast(const uint25
    1870 |      std::vector<CTransactionRef> removed_txs;
    1871 |  
    1872 |      size_t connections_cancelled{0};
    1873 | -    for (const auto& [tx, _] : snapshot) {
    1874 | +    for (const auto& tx_info : snapshot) {
    1875 | +        const auto& tx{tx_info.tx};
    


    vasild commented at 2:30 PM on March 9, 2026:

    Unnecessary change.


    andrewtoth commented at 1:33 AM on March 11, 2026:

    It's necessary in a later commit. I moved it to the one that requires this change.

  17. in src/net_processing.cpp:1663 in afca84489c
    1658 |                           "Reattempting broadcast of stale txid=%s wtxid=%s",
    1659 |                           stale_tx->GetHash().ToString(), stale_tx->GetWitnessHash().ToString());
    1660 |                  ++num_for_rebroadcast;
    1661 |              } else {
    1662 | +                auto reason{mempool_acceptable.m_state.ToString()};
    1663 |                  LogDebug(BCLog::PRIVBROADCAST, "Giving up broadcast attempts for txid=%s wtxid=%s: %s",
    


    vasild commented at 9:34 AM on March 10, 2026:

    "Giving up" brings the sense of finality. With this change we may start sending the tx again in the future. Maybe change to "Stopping". Also, I think it would be more clear to put "not acceptable in our mempool: %s" in there.

                    LogDebug(BCLog::PRIVBROADCAST, "Stopping broadcast attempts for txid=%s wtxid=%s: not acceptable in our mempool: %s",
    
  18. in src/private_broadcast.cpp:27 in afca84489c
      24 | +    const bool was_finished{it->second.mempool_rejection_reason.has_value()};
      25 | +    it->second.mempool_rejection_reason.reset();
      26 | +    it->second.received_from.reset();
      27 | +    it->second.received_time.reset();
      28 | +    return was_finished;
      29 |  }
    


    vasild commented at 10:01 AM on March 10, 2026:

    There is this notion of "if acceptable in our mempool - send it, otherwise don't send it".

    Here in this PR we reset the state from Add() for an already existent transaction to "acceptable in our mempool". However, this will not result in "send it". That is because PeerManagerImpl::ReattemptPrivateBroadcast() will reevaluate and reset mempool acceptance state before deciding whether a transaction is eligible for rebroadcast. So the effect of resetting the state to "acceptable" in Add() will be short lived and would have no effect.

    Maybe keep this PR to just retaining the transactions after broadcast is concluded, without changing the functionality of Add()?


    andrewtoth commented at 3:38 PM on March 10, 2026:

    That is because PeerManagerImpl::ReattemptPrivateBroadcast() will reevaluate and reset mempool acceptance state before deciding whether a transaction is eligible for rebroadcast.

    It may reevaluate and reset mempool acceptance, but it may also be sent first from one of the connections that are created. I think we can solve this issue by checking if a tx exists in the mempool and continuing before updating mempool state in ReattemptPrivateBroadcast.


    andrewtoth commented at 1:37 AM on March 11, 2026:

    I removed this change to Add logic in this PR. But, it required also removing "Sending a transaction that is already in the mempool" from the functional test. Since resending a tx already in the queue now will not trigger a rebroadcast.

  19. in src/private_broadcast.cpp:132 in afca84489c outdated
     128 | +    return std::ranges::any_of(m_transactions, [](const auto& el) {
     129 | +        return !el.second.mempool_rejection_reason.has_value();
     130 | +    });
     131 |  }
     132 |  
     133 |  std::vector<CTransactionRef> PrivateBroadcast::GetStale() const
    


    vasild commented at 10:06 AM on March 10, 2026:

    GetStale() needs to be changed to omit the concluded (finished) transactions.


    andrewtoth commented at 3:45 PM on March 10, 2026:

    I was thinking more about this and I don't see a way to have "finished" transactions.

    If a tx is acceptable to our mempool, it is not yet finished because it has not been received. If a tx exists in our mempool, it is not yet finished because it has not been mined and may be evicted. If a tx is not acceptable to our mempool, it or a conflicting tx may have been mined. However, it may also be the case that its parent tx has been evicted from our mempool as well and not yet mined. Either way, checking the confirmed utxo set will also show this tx as unacceptable, when it will be acceptable if the parent enters the mempool again. This tx is not yet finished since it may be acceptable to our mempool again if the parent returns.

    So, is there a heuristic we can use to determine if a tx is concluded (finished)? I don't see one. We need to keep txs in the queue indefinitely. One way is to have an expiry on txs which you suggested in #34322, but I believe that is out of scope for this PR.


    vasild commented at 5:13 PM on March 17, 2026:

    A transaction is certainly finished if it is mined or if a conflicting transaction is mined, no?

    Currently (on master without this PR) we consider a transaction finished once we receive it back from the network without even checking whether it will make it to our mempool. Then we call Remove() and forget about the transaction.

    This is deliberate to avoid infinite broadcasting of unacceptable transactions. That leaves retries of transactions that made it back but after a while need to be broadcast again at the hands of the users. I think that is worth reviewing and better be done in a focused PR that does just that. That would slim the current PR to just keeping the finished transactions in memory, without changing the meaning of "finished".


    andrewtoth commented at 7:35 PM on March 20, 2026:

    A transaction is certainly finished if it is mined or if a conflicting transaction is mined, no?

    How to determine this is tricky. There are a lot of ways to potentially solve this problem with different trade-offs. Let's leave this to a follow-up.

  20. in src/private_broadcast.cpp:76 in afca84489c
      72 |      LOCK(m_mutex);
      73 |  
      74 | +    auto pending{m_transactions | std::views::filter([](const auto& el) {
      75 | +        return !el.second.mempool_rejection_reason.has_value();
      76 | +    })};
      77 | +
    


    vasild commented at 10:08 AM on March 10, 2026:

    This is repeated also in PrivateBroadcast::HavePendingTransactions(). Better introduce a new method PrivateBroadcast::GetPendingTransactions() and use it here and in HavePendingTransactions().


    optout21 commented at 10:17 AM on March 12, 2026:

    I second to the proposal for a separate HavePendingTransactions(). For simplicity, it can just call GetPendingTransactions() and test for emptiness, or do a similar search with early exit. I don't think performance is relevant here (queues will be small generally), but it's nice to express intent precisely.


    andrewtoth commented at 12:09 PM on March 12, 2026:

    I took this suggestion. There is now both HavePendingTransactions and GetPendingTransactions. I just left a thumbs up and resolved this comment though and didn't leave a message.


    optout21 commented at 1:30 PM on March 12, 2026:

    Sorry, my mistake (I pattern-matched !GetPendingTransactions().empty() and missed that it's exactly in HavePendingTransactions :D)

  21. vasild commented at 10:14 AM on March 10, 2026: contributor

    Reviewed up to and including fa857a980c net: track received peer and mempool status in private broadcast. @optout21, good points. Consider also #34533 and #3828. In the light of that, a sensitive transaction may not have originated locally. So "local" may not be the best term to use for these.

  22. andrewtoth force-pushed on Mar 11, 2026
  23. andrewtoth force-pushed on Mar 11, 2026
  24. DrahtBot added the label CI failed on Mar 11, 2026
  25. andrewtoth commented at 1:50 AM on March 11, 2026: contributor

    Thank you for your reviews @optout21 and @vasild.

    • Add no longer clears received state, so it is a simpler change. However, it does not rebroadcast txs if they are already received and sent again with sendrawtransaction. Removed unit test and functional test that covered this.
    • Only clear received state if transitioning from mempool rejected -> mempool acceptable. This happens if the tx is evicted but is not conflicted. In this case it is no longer "received" and we want to start the broadcast flow again.
    • Check if a tx already exists in mempool in ReattemptPrivateBroadcast before checking mempool acceptance.

    Also addressed several other refactor and style changes, and split up and reordered commits for easier review.

    git range-diff 9cad97f6cdf1bd660732cd10e844a6a7e0771ea0..afca84489c26f920d842f34d00f8418349255a65 eed316189376d72fc959e8d2e45cafef279f77da..9f58d9a1691b10341784e13ae32549bb0244bbda

    I consider the "locally-originated TX" to be more relevant than the "private" nature of the broadcast, and suggest using it in namings. @optout21 that's an interesting point. I do think it is out of scope for this PR though. Could be raised independently.

  26. andrewtoth force-pushed on Mar 11, 2026
  27. andrewtoth commented at 1:47 PM on March 11, 2026: contributor

    Updated test to fix a bad rebase. Also, addressed feedback to make rebroadcast logic more clear #34707 (review).

    git diff 9f58d9a1691b10341784e13ae32549bb0244bbda..c9b19d76dd336b2342d6d766b408410e1b93107b

  28. andrewtoth force-pushed on Mar 11, 2026
  29. DrahtBot removed the label CI failed on Mar 11, 2026
  30. andrewtoth force-pushed on Mar 11, 2026
  31. andrewtoth force-pushed on Mar 12, 2026
  32. in src/private_broadcast.h:78 in 56a655d946 outdated
      73 | +     * Record that a transaction was received back from the network and
      74 | +     * pause rebroadcast attempts until the mempool state is reset.
      75 | +     * @param[in] tx Transaction received from the network.
      76 | +     * @param[in] received_from Peer address we received the transaction from.
      77 | +     * @retval !nullopt The number of times the transaction was sent and confirmed
      78 | +     * by the recipient (if the transaction existed and was removed).
    


    optout21 commented at 10:25 AM on March 12, 2026:

    Nit: "was sent and confirmed by the recipient" -> "was sent and confirmed by a recipient" Reading "the recipient" made me think that it's always the same recipient.


    andrewtoth commented at 7:39 PM on March 20, 2026:

    Done, and updated the docstring for Removed as well.

  33. andrewtoth commented at 2:32 PM on March 12, 2026: contributor

    Changed the approach to use an enum for the mempool states we care about: IN_MEMPOOL, ACCEPTABLE, REJECTED.

    This should make it easier to reason about what is happening in this PR. I also split up the commits so they are easier to review.

  34. andrewtoth force-pushed on Mar 20, 2026
  35. andrewtoth renamed this:
    net: keep finished private broadcast txs in memory, rebroadcast if evicted from mempool
    net: keep finished private broadcast txs in memory
    on Mar 20, 2026
  36. andrewtoth commented at 7:33 PM on March 20, 2026: contributor

    I updated this PR to only track transactions that are in the happy-path of received back from the network. Tracking txs that are removed due to mempool rejection and rebroadcasting is left for follow-ups.

    This should make this much simpler to review.

    I also aligned naming to what is done in #34873.

  37. andrewtoth force-pushed on Mar 20, 2026
  38. DrahtBot added the label CI failed on Mar 20, 2026
  39. DrahtBot removed the label CI failed on Mar 20, 2026
  40. in src/private_broadcast.cpp:26 in 3c84f7fa7f outdated
      23 | +        if (!state.received_from.has_value()) {
      24 | +            return false;
      25 | +        }
      26 | +        state.received_from.reset();
      27 | +        state.received_time.reset();
      28 | +        state.send_statuses.clear();
    


    optout21 commented at 10:24 AM on March 23, 2026:

    3c84f7f net: track received peer in private broadcast:

    In PrivateBroadcast::Add, when the TX was already present, but has not been received back yet, it returns true. However, the documentation says for the return value: "The transaction was already present.". I feel an inconsistency here: either the code should return false in this case, or the documentation be more precise.


    andrewtoth commented at 1:38 PM on March 23, 2026:

    I think the documentation should be updated. We need to return true in this case to maintain the same behavior. Previously, a received tx is removed, so calling Add again would return true and add the tx with a fresh state.

  41. in src/net_processing.cpp:1872 in 3c84f7fa7f outdated
    1867 | @@ -1868,7 +1868,8 @@ std::vector<CTransactionRef> PeerManagerImpl::AbortPrivateBroadcast(const uint25
    1868 |      std::vector<CTransactionRef> removed_txs;
    1869 |  
    1870 |      size_t connections_cancelled{0};
    1871 | -    for (const auto& [tx, _] : snapshot) {
    1872 | +    for (const auto& tx_info : snapshot) {
    1873 | +        const auto& tx{tx_info.tx};
    


    optout21 commented at 10:31 AM on March 23, 2026:

    3c84f7f net: track received peer in private broadcast:

    This change has became unnecessary, it is no longer needed by later commit (was discussed previously in Resolved thread).


    andrewtoth commented at 1:42 PM on March 23, 2026:

    Removing this change causes a compilation error. It is needed when we add new fields to TxBroadcastInfo. The later commit that was discussed previously has been rolled into this one commit. In the previous version, updates to TxBroadcastInfo happened later and only TxState was updated, which is why the change wasn't needed earlier.

    ../src/net_processing.cpp: In member function ‘virtual std::vector<std::shared_ptr<const CTransaction> > {anonymous}::PeerManagerImpl::AbortPrivateBroadcast(const uint256&)’:
    ../src/net_processing.cpp:1871:22: error: only 2 names provided for structured binding
     1871 |     for (const auto& [tx, _] : snapshot) {
          |                      ^~~~~~~
    ../src/net_processing.cpp:1871:22: note: while ‘const PrivateBroadcast::TxBroadcastInfo’ decomposes into 4 elements
    

    optout21 commented at 2:28 PM on March 23, 2026:

    Got it. Sg. like (const auto& [tx, _a, _b, _c, _d] : snapshot) would be needed, so the changed version is better.

  42. optout21 commented at 10:32 AM on March 23, 2026: contributor

    Minor comments provided.

  43. andrewtoth force-pushed on Mar 23, 2026
  44. andrewtoth commented at 10:36 PM on March 23, 2026: contributor

    Thanks for your review @optout21. Took your suggestion #34707 (review). Only diff is changing docstring of PrivateBroadcast::Add().

    git diff 673124c53013a91d0ca7a70de9c54d521bb3c308..d5d7c28e1b4b76daddad95a23cbf56209ca0b86e

  45. in src/private_broadcast.cpp:166 in d5d7c28e1b


    vasild commented at 11:15 AM on March 26, 2026:

    Just curious - why change the type of the DerivePriority()'s parameter? Do you think that maybe in the future it might be needed to use also other fields from TxSendStatus to derive the priority?


    andrewtoth commented at 4:50 PM on April 4, 2026:

    This commit was dropped, so no longer relevant.

    Do you think that maybe in the future it might be needed to use also other fields from TxSendStatus to derive the priority?

    Yes. If we want to periodically rebroadcast txs that have been received but are still acceptable to our utxo set (not mined or conflicted), we would need more information here. But this needs more discussion and we can update this parameter when we decide to do something like that.


    rkrux commented at 10:57 AM on April 21, 2026:

    In 3c73bd73e5d180f7196a6b3e5d01bea18c80f0b7 test: extract find_tx_info to static helper - It reads a bit oddly that this is the first commit and the extracted out helper is used in the third commit. Reorder to make this the second commit instead so that it's immediately followed by its usage?


    andrewtoth commented at 8:47 PM on April 24, 2026:

    Reordered the commits.

  46. in src/test/private_broadcast_tests.cpp:27 in d5d7c28e1b
      23 | @@ -24,6 +24,17 @@ static CTransactionRef MakeDummyTx(uint32_t id, size_t num_witness)
      24 |      return MakeTransactionRef(mtx);
      25 |  }
      26 |  
      27 | +static auto FindTxInfo(const std::vector<PrivateBroadcast::TxBroadcastInfo>& infos, const CTransactionRef& tx)
    


    vasild commented at 11:25 AM on March 26, 2026:

    Correct me if I am wrong, anonymous namespace and static (on global functions) achieve the same purpose - the symbols will stay within that file when compiled, so they cannot collide with symbols from other files. If that is the case, then better use just one - either static or a namespace.


    andrewtoth commented at 4:50 PM on April 4, 2026:

    Removed static.

  47. in src/private_broadcast.h:148 in d5d7c28e1b
     140 | @@ -124,6 +141,12 @@ class PrivateBroadcast
     141 |          SendStatus(const NodeId& nodeid, const CService& address, const NodeClock::time_point& picked) : nodeid{nodeid}, address{address}, picked{picked} {}
     142 |      };
     143 |  
     144 | +    struct TxSendStatus {
     145 | +        std::vector<SendStatus> send_statuses;
     146 | +        std::optional<CService> received_from;
     147 | +        std::optional<NodeClock::time_point> received_time;
     148 | +    };
    


    vasild commented at 1:28 PM on March 26, 2026:

    received_from and received_time must be either both set or both not set. To enforce this we can have one optional holding a struct with two members: from and when.

        struct TxSendStatus {
            std::vector<SendStatus> send_statuses;
            struct Received {
                CService from;
                NodeClock::time_point when;
            }
            std::optional<Received> received;
        };
    

    andrewtoth commented at 4:50 PM on April 4, 2026:

    Done.

  48. in src/net_processing.cpp:4495 in d5d7c28e1b outdated
    4491 | @@ -4403,10 +4492,10 @@ void PeerManagerImpl::ProcessMessage(Peer& peer, CNode& pfrom, const std::string
    4492 |          const uint256& hash = peer.m_wtxid_relay ? wtxid.ToUint256() : txid.ToUint256();
    4493 |          AddKnownTx(peer, hash);
    4494 |  
    4495 | -        if (const auto num_broadcasted{m_tx_for_private_broadcast.Remove(ptx)}) {
    4496 | +        if (const auto num_broadcasted{m_tx_for_private_broadcast.MarkReceived(ptx, CService{pfrom.addr})}) {
    


    vasild commented at 4:39 PM on March 26, 2026:

    Inbound Tor connections would probably look like 127.0.0.1 (the address of the Tor router). I am not sure if we should bother trying to make this more user friendly. If yes, then something like:

    if pfrom.m_inbound_onion is true
        CService s;
        s.SetSpecial("inboundconnectionfromtoraaaaaaaaaaaaaaaaaaaaaaaaaaan5wyd.onion");
        use s
    else
        use pfrom.addr
    

    andrewtoth commented at 5:00 PM on April 4, 2026:

    I decided not to take this. I'm not sure this is the best way to signal to a user that this is an inbound tor connection. They might mistake it for the actual address and be confused when it doesn't exist. I think a cleaner approach might be to use a variant of {CService, std::string} and write "inbound tor" for the string? But I don't think the code complexity would be worth it. I'm not entirely convinced using 127.0.0.1 will cause any issues.

  49. in test/functional/p2p_private_broadcast.py:448 in d5d7c28e1b outdated
     448 | -        self.log.info("Sending a transaction that is already in the mempool")
     449 | +        info = [t for t in pbinfo["transactions"] if t["wtxid"] == txs[0]["wtxid"]]
     450 | +        assert_equal(len(info), 1)
     451 | +        assert "received_from" in info[0]
     452 | +        assert "received_time" in info[0]
     453 |          skip_destinations = len(self.destinations)
    


    vasild commented at 4:48 PM on March 26, 2026:

    This line is now not needed, can drop it.

  50. in test/functional/p2p_private_broadcast.py:447 in d5d7c28e1b outdated
     443 | +        self.log.info("Checking getprivatebroadcastinfo reports receive metadata after it is received back")
     444 |          pbinfo = tx_originator.getprivatebroadcastinfo()
     445 | -        pending = [t for t in pbinfo["transactions"] if t["txid"] == txs[0]["txid"] and t["wtxid"] == txs[0]["wtxid"]]
     446 | -        assert_equal(len(pending), 0)
     447 | -
     448 | -        self.log.info("Sending a transaction that is already in the mempool")
    


    vasild commented at 4:49 PM on March 26, 2026:

    Why remove this test? It is passing, so maybe restore it?

    --- i/test/functional/p2p_private_broadcast.py
    +++ w/test/functional/p2p_private_broadcast.py
    @@ -444,12 +444,17 @@ class P2PPrivateBroadcast(BitcoinTestFramework):
             info = [t for t in pbinfo["transactions"] if t["wtxid"] == txs[0]["wtxid"]]
             assert_equal(len(info), 1)
             assert "received_from" in info[0]
             assert "received_time" in info[0]
             skip_destinations = len(self.destinations)
     
    +        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)
    +        self.check_broadcasts("Broadcast of mempool transaction", txs[0], NUM_PRIVATE_BROADCAST_PER_TX, skip_destinations)
    +
             self.log.info("Sending a transaction with a dependency in the mempool")
             skip_destinations = len(self.destinations)
             tx_originator.sendrawtransaction(hexstring=txs[1]["hex"], maxfeerate=0.1)
             self.check_broadcasts("Dependency in mempool", txs[1], NUM_PRIVATE_BROADCAST_PER_TX, skip_destinations)
     
             self.log.info("Sending a transaction with a dependency not in the mempool (should be rejected)")
    
  51. in src/test/private_broadcast_tests.cpp:148 in d5d7c28e1b
     143 | +{
     144 | +    PrivateBroadcast pb;
     145 | +
     146 | +    const auto missing_tx{MakeDummyTx(/*id=*/999, /*num_witness=*/0)};
     147 | +    const auto tx{MakeDummyTx(/*id=*/42, /*num_witness=*/0)};
     148 | +    in_addr ipv4Addr;
    


    vasild commented at 5:02 PM on March 26, 2026:

    The naming of this variable is usingCamelCase, should_be_snake.

  52. in src/private_broadcast.cpp:147 in d5d7c28e1b
     146 | -        entries.emplace_back(TxBroadcastInfo{.tx = tx, .peers = std::move(peers)});
     147 | +        entries.emplace_back(TxBroadcastInfo{
     148 | +            .tx = tx,
     149 | +            .peers = std::move(peers),
     150 | +            .received_from = state.received_from,
     151 | +            .received_time = state.received_time,
    


    vasild commented at 6:03 PM on March 26, 2026:

    I have been thinking that the public structs TxBroadcastInfo and PeerSendInfo are the same as the private ones TxSendStatus and SendStatus. Before it wasn't so striking, but now it becomes more obvious as more fields are added and they have to be added to both and copied here in GetBroadcastInfo()

    We can have just one set of structs and return m_transactions from GetBroadcastInfo(). The callers of GetBroadcastInfo() don't care whether it is a vector or unordered_map as long as they can iterate over it.

    Here is a change that does that, on top of this PR. If you do not want to bloat this PR with it, I will submit as a followup:

    <details> <summary>[patch] Simplify GetBroadcastInfo(); 71 insertions(+), 109 deletions(-)</summary>

    diff --git i/src/net_processing.cpp w/src/net_processing.cpp
    index ed3e5ebf98..db8a007a04 100644
    --- i/src/net_processing.cpp
    +++ w/src/net_processing.cpp
    @@ -539,13 +539,13 @@ public:
         void CheckForStaleTipAndEvictPeers() override;
         util::Expected<void, std::string> FetchBlock(NodeId peer_id, const CBlockIndex& block_index) override
             EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
         bool GetNodeStateStats(NodeId nodeid, CNodeStateStats& stats) const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
         std::vector<node::TxOrphanage::OrphanInfo> GetOrphanTransactions() override EXCLUSIVE_LOCKS_REQUIRED(!m_tx_download_mutex);
         PeerManagerInfo GetInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
    -    std::vector<PrivateBroadcast::TxBroadcastInfo> GetPrivateBroadcastInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
    +    PrivateBroadcast::Transactions GetPrivateBroadcastInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
         std::vector<CTransactionRef> 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);
         void SetBestBlock(int height, std::chrono::seconds time) override
         {
    @@ -1854,25 +1854,24 @@ PeerManagerInfo PeerManagerImpl::GetInfo() const
         return PeerManagerInfo{
             .median_outbound_time_offset = m_outbound_time_offsets.Median(),
             .ignores_incoming_txs = m_opts.ignore_incoming_txs,
         };
     }
     
    -std::vector<PrivateBroadcast::TxBroadcastInfo> PeerManagerImpl::GetPrivateBroadcastInfo() const
    +PrivateBroadcast::Transactions PeerManagerImpl::GetPrivateBroadcastInfo() const
     {
         return m_tx_for_private_broadcast.GetBroadcastInfo();
     }
     
     std::vector<CTransactionRef> PeerManagerImpl::AbortPrivateBroadcast(const uint256& id)
     {
         const auto snapshot{m_tx_for_private_broadcast.GetBroadcastInfo()};
         std::vector<CTransactionRef> removed_txs;
     
         size_t connections_cancelled{0};
    -    for (const auto& tx_info : snapshot) {
    -        const auto& tx{tx_info.tx};
    +    for (const auto& [tx, status] : 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);
                 }
    diff --git i/src/net_processing.h w/src/net_processing.h
    index 36ae021f67..9472aa7735 100644
    --- i/src/net_processing.h
    +++ w/src/net_processing.h
    @@ -118,13 +118,13 @@ public:
         virtual std::vector<node::TxOrphanage::OrphanInfo> GetOrphanTransactions() = 0;
     
         /** Get peer manager info. */
         virtual PeerManagerInfo GetInfo() const = 0;
     
         /** Get info about transactions currently being privately broadcast. */
    -    virtual std::vector<PrivateBroadcast::TxBroadcastInfo> GetPrivateBroadcastInfo() const = 0;
    +    virtual PrivateBroadcast::Transactions GetPrivateBroadcastInfo() const = 0;
     
         /**
          * Abort private broadcast attempts for transactions currently being privately broadcast.
          *
          * [@param](/bitcoin-bitcoin/contributor/param/)[in] id A transaction identifier. It will be matched against both txid and wtxid for
          *               all transactions in the private broadcast queue.
    diff --git i/src/private_broadcast.cpp w/src/private_broadcast.cpp
    index 0c70fff7d8..e0349032be 100644
    --- i/src/private_broadcast.cpp
    +++ w/src/private_broadcast.cpp
    @@ -124,37 +124,20 @@ std::vector<CTransactionRef> PrivateBroadcast::GetStale() const
                 stale.push_back(tx);
             }
         }
         return stale;
     }
     
    -std::vector<PrivateBroadcast::TxBroadcastInfo> PrivateBroadcast::GetBroadcastInfo() const
    +PrivateBroadcast::Transactions PrivateBroadcast::GetBroadcastInfo() const
         EXCLUSIVE_LOCKS_REQUIRED(!m_mutex)
     {
         LOCK(m_mutex);
    -    std::vector<TxBroadcastInfo> entries;
    -    entries.reserve(m_transactions.size());
    -
    -    for (const auto& [tx, state] : m_transactions) {
    -        std::vector<PeerSendInfo> peers;
    -        peers.reserve(state.send_statuses.size());
    -        for (const auto& status : state.send_statuses) {
    -            peers.emplace_back(PeerSendInfo{.address = status.address, .sent = status.picked, .received = status.confirmed});
    -        }
    -        entries.emplace_back(TxBroadcastInfo{
    -            .tx = tx,
    -            .peers = std::move(peers),
    -            .received_from = state.received_from,
    -            .received_time = state.received_time,
    -        });
    -    }
    -
    -    return entries;
    +    return m_transactions;
     }
     
    -PrivateBroadcast::Priority PrivateBroadcast::DerivePriority(const TxSendStatus& tx_status)
    +PrivateBroadcast::Priority PrivateBroadcast::DerivePriority(const Status& tx_status)
     {
         Priority p;
         p.num_picked = tx_status.send_statuses.size();
         for (const auto& send_status : tx_status.send_statuses) {
             p.last_picked = std::max(p.last_picked, send_status.picked);
             if (send_status.confirmed.has_value()) {
    @@ -176,17 +159,17 @@ std::optional<PrivateBroadcast::TxAndSendStatusForNode> PrivateBroadcast::GetSen
                 }
             }
         }
         return std::nullopt;
     }
     
    -std::vector<PrivateBroadcast::TransactionMap::iterator> PrivateBroadcast::GetPendingTransactions()
    +std::vector<PrivateBroadcast::Transactions::iterator> PrivateBroadcast::GetPendingTransactions()
         EXCLUSIVE_LOCKS_REQUIRED(m_mutex)
     {
         AssertLockHeld(m_mutex);
    -    std::vector<TransactionMap::iterator> result;
    +    std::vector<Transactions::iterator> result;
         for (auto it{m_transactions.begin()}; it != m_transactions.end(); ++it) {
             if (!it->second.received_from.has_value()) {
                 result.push_back(it);
             }
         }
         return result;
    diff --git i/src/private_broadcast.h w/src/private_broadcast.h
    index 5c70754df2..3c39ad298a 100644
    --- i/src/private_broadcast.h
    +++ w/src/private_broadcast.h
    @@ -28,25 +28,47 @@
      * - Query whether a given recipient node has confirmed reception
      * - Query whether any transactions that need sending are currently on the list
      */
     class PrivateBroadcast
     {
     public:
    -    struct PeerSendInfo {
    -        CService address;
    -        NodeClock::time_point sent;
    -        std::optional<NodeClock::time_point> received;
    +    /// 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<NodeClock::time_point> confirmed; ///< When was the transaction reception confirmed by the node (by PONG).
    +
    +        SendStatus(const NodeId& nodeid, const CService& address, const NodeClock::time_point& picked) : nodeid{nodeid}, address{address}, picked{picked} {}
         };
     
    -    struct TxBroadcastInfo {
    -        CTransactionRef tx;
    -        std::vector<PeerSendInfo> peers;
    -        std::optional<CService> received_from;
    -        std::optional<NodeClock::time_point> received_time;
    +    /// Status of a transaction, including all send attempts and a possible reception info.
    +    struct Status {
    +        std::vector<SendStatus> send_statuses; /// All send attempts.
    +        std::optional<CService> received_from; /// When we receive back the transaction from the network, this is the peer we got it from.
    +        std::optional<NodeClock::time_point> received_time; /// Time of receiving back the transaction.
         };
     
    +    // No need for salted hasher because we are going to store just a bunch of locally originating transactions.
    +
    +    struct CTransactionRefHash {
    +        size_t operator()(const CTransactionRef& tx) const
    +        {
    +            return static_cast<size_t>(tx->GetWitnessHash().ToUint256().GetUint64(0));
    +        }
    +    };
    +
    +    struct CTransactionRefComp {
    +        bool operator()(const CTransactionRef& a, const CTransactionRef& b) const
    +        {
    +            return a->GetWitnessHash() == b->GetWitnessHash(); // If wtxid equals, then txid also equals.
    +        }
    +    };
    +
    +    using Transactions = std::unordered_map<CTransactionRef, Status, CTransactionRefHash, CTransactionRefComp>;
    +
         /**
          * Add a transaction to the storage, or reset a transaction's state if the
          * transaction has been marked received.
          * [@param](/bitcoin-bitcoin/contributor/param/)[in] tx The transaction to add.
          * [@retval](/bitcoin-bitcoin/contributor/retval/) true The transaction was added or reset.
          * [@retval](/bitcoin-bitcoin/contributor/retval/) false The transaction was already present and not marked received.
    @@ -124,32 +146,16 @@ public:
         std::vector<CTransactionRef> GetStale() const
             EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);
     
         /**
          * Get stats about all transactions currently being privately broadcast.
          */
    -    std::vector<TxBroadcastInfo> GetBroadcastInfo() const
    +    Transactions GetBroadcastInfo() const
             EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);
     
     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<NodeClock::time_point> confirmed; ///< When was the transaction reception confirmed by the node (by PONG).
    -
    -        SendStatus(const NodeId& nodeid, const CService& address, const NodeClock::time_point& picked) : nodeid{nodeid}, address{address}, picked{picked} {}
    -    };
    -
    -    struct TxSendStatus {
    -        std::vector<SendStatus> send_statuses;
    -        std::optional<CService> received_from;
    -        std::optional<NodeClock::time_point> received_time;
    -    };
    -
         /// Cumulative stats from all the send attempts for a transaction. Used to prioritize transactions.
         struct Priority {
             size_t num_picked{0}; ///< Number of times the transaction was picked for sending.
             NodeClock::time_point last_picked{}; ///< The most recent time when the transaction was picked for sending.
             size_t num_confirmed{0}; ///< Number of nodes that have confirmed reception of a transaction (by PONG).
             NodeClock::time_point last_confirmed{}; ///< The most recent time when the transaction was confirmed.
    @@ -167,46 +173,28 @@ private:
         /// A pair of a transaction and a sent status for a given node. Convenience return type of GetSendStatusByNode().
         struct TxAndSendStatusForNode {
             const CTransactionRef& tx;
             SendStatus& send_status;
         };
     
    -    // No need for salted hasher because we are going to store just a bunch of locally originating transactions.
    -
    -    struct CTransactionRefHash {
    -        size_t operator()(const CTransactionRef& tx) const
    -        {
    -            return static_cast<size_t>(tx->GetWitnessHash().ToUint256().GetUint64(0));
    -        }
    -    };
    -
    -    struct CTransactionRefComp {
    -        bool operator()(const CTransactionRef& a, const CTransactionRef& b) const
    -        {
    -            return a->GetWitnessHash() == b->GetWitnessHash(); // If wtxid equals, then txid also equals.
    -        }
    -    };
    -
    -    using TransactionMap = std::unordered_map<CTransactionRef, TxSendStatus, CTransactionRefHash, CTransactionRefComp>;
    -
         /**
          * Derive the sending priority of a transaction.
          * [@param](/bitcoin-bitcoin/contributor/param/)[in] status The send status of the transaction.
          */
    -    static Priority DerivePriority(const TxSendStatus& status);
    +    static Priority DerivePriority(const Status& status);
     
         /**
          * Find which transaction we sent to a given node (marked by PickTxForSend()).
          * [@return](/bitcoin-bitcoin/contributor/return/) That transaction together with the send status or nullopt if we did not
          * send any transaction to the given node.
          */
         std::optional<TxAndSendStatusForNode> GetSendStatusByNode(const NodeId& nodeid)
             EXCLUSIVE_LOCKS_REQUIRED(m_mutex);
     
    -    std::vector<TransactionMap::iterator> GetPendingTransactions()
    +    std::vector<Transactions::iterator> GetPendingTransactions()
             EXCLUSIVE_LOCKS_REQUIRED(m_mutex);
     
         mutable Mutex m_mutex;
    -    TransactionMap m_transactions GUARDED_BY(m_mutex);
    +    Transactions m_transactions GUARDED_BY(m_mutex);
     };
     
     #endif // BITCOIN_PRIVATE_BROADCAST_H
    diff --git i/src/rpc/mempool.cpp w/src/rpc/mempool.cpp
    index caa0c9e636..bdc61a3e8c 100644
    --- i/src/rpc/mempool.cpp
    +++ w/src/rpc/mempool.cpp
    @@ -174,34 +174,36 @@ static RPCHelpMan 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()};
    +            const auto pbinfo{peerman.GetPrivateBroadcastInfo()};
     
                 UniValue transactions(UniValue::VARR);
    -            for (const auto& tx_info : txs) {
    +            for (const auto& [tx, status] : pbinfo) {
                     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));
    +                o.pushKV("txid", tx->GetHash().ToString());
    +                o.pushKV("wtxid", tx->GetWitnessHash().ToString());
    +                o.pushKV("hex", EncodeHexTx(*tx));
                     UniValue peers(UniValue::VARR);
    -                for (const auto& peer : tx_info.peers) {
    +                for (const auto& peer : status.send_statuses) {
                         UniValue p(UniValue::VOBJ);
                         p.pushKV("address", peer.address.ToStringAddrPort());
    -                    p.pushKV("sent", TicksSinceEpoch<std::chrono::seconds>(peer.sent));
    -                    if (peer.received.has_value()) {
    -                        p.pushKV("received", TicksSinceEpoch<std::chrono::seconds>(*peer.received));
    +                    p.pushKV("sent", TicksSinceEpoch<std::chrono::seconds>(peer.picked));
    +                    if (peer.confirmed.has_value()) {
    +                        p.pushKV("received", TicksSinceEpoch<std::chrono::seconds>(*peer.confirmed));
                         }
                         peers.push_back(std::move(p));
                     }
                     o.pushKV("peers", std::move(peers));
    -                if (tx_info.received_from.has_value()) {
    -                    o.pushKV("received_from", tx_info.received_from->ToStringAddrPort());
    -                    o.pushKV("received_time", TicksSinceEpoch<std::chrono::seconds>(*tx_info.received_time));
    +                if (status.received_from.has_value()) {
    +                    o.pushKV("received_from", status.received_from->ToStringAddrPort());
    +                }
    +                if (status.received_time.has_value()) {
    +                    o.pushKV("received_time", TicksSinceEpoch<std::chrono::seconds>(status.received_time.value()));
                     }
                     transactions.push_back(std::move(o));
                 }
     
                 UniValue ret(UniValue::VOBJ);
                 ret.pushKV("transactions", std::move(transactions));
    diff --git i/src/test/private_broadcast_tests.cpp w/src/test/private_broadcast_tests.cpp
    index 715cafb190..0cf58e5d0e 100644
    --- i/src/test/private_broadcast_tests.cpp
    +++ w/src/test/private_broadcast_tests.cpp
    @@ -21,19 +21,12 @@ static CTransactionRef MakeDummyTx(uint32_t id, size_t num_witness)
             mtx.vin[0].scriptWitness = CScriptWitness{};
             mtx.vin[0].scriptWitness.stack.resize(num_witness);
         }
         return MakeTransactionRef(mtx);
     }
     
    -static auto FindTxInfo(const std::vector<PrivateBroadcast::TxBroadcastInfo>& infos, const CTransactionRef& tx)
    -{
    -    const auto it{std::ranges::find(infos, tx->GetWitnessHash(), [](const auto& info) { return info.tx->GetWitnessHash(); })};
    -    BOOST_REQUIRE(it != infos.end());
    -    return it;
    -}
    -
     } // namespace
     
     BOOST_FIXTURE_TEST_SUITE(private_broadcast_tests, BasicTestingSetup)
     
     BOOST_AUTO_TEST_CASE(basic)
     {
    @@ -63,14 +56,14 @@ BOOST_AUTO_TEST_CASE(basic)
         BOOST_REQUIRE(tx1->GetWitnessHash() != tx2->GetWitnessHash());
     
         BOOST_CHECK(pb.Add(tx2));
         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(FindTxInfo(infos, tx1)->peers.size(), tx1_peer_count);
    -        BOOST_CHECK_EQUAL(FindTxInfo(infos, tx2)->peers.size(), tx2_peer_count);
    +        BOOST_CHECK_EQUAL(infos.at(tx1).send_statuses.size(), tx1_peer_count);
    +        BOOST_CHECK_EQUAL(infos.at(tx2).send_statuses.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);
    @@ -105,24 +98,22 @@ 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 info_it{FindTxInfo(infos, tx_for_recipient1)};
    -        const auto& peers{info_it->peers};
    +        const auto& peers{infos.at(tx_for_recipient1).send_statuses};
             BOOST_CHECK_EQUAL(peers.size(), 1);
             BOOST_CHECK_EQUAL(peers[0].address.ToStringAddrPort(), addr1.ToStringAddrPort());
    -        BOOST_CHECK(peers[0].received.has_value());
    +        BOOST_CHECK(peers[0].confirmed.has_value());
         }
         {
    -        const auto info_it{FindTxInfo(infos, tx_for_recipient2)};
    -        const auto& peers{info_it->peers};
    +        const auto& peers{infos.at(tx_for_recipient2).send_statuses};
             BOOST_CHECK_EQUAL(peers.size(), 1);
             BOOST_CHECK_EQUAL(peers[0].address.ToStringAddrPort(), addr2.ToStringAddrPort());
    -        BOOST_CHECK(!peers[0].received.has_value());
    +        BOOST_CHECK(!peers[0].confirmed.has_value());
         }
     
         BOOST_CHECK_EQUAL(pb.GetStale().size(), 1);
         BOOST_CHECK_EQUAL(pb.GetStale()[0], tx_for_recipient2);
     
         SetMockTime(Now<NodeSeconds>() + 10h);
    @@ -164,36 +155,35 @@ BOOST_AUTO_TEST_CASE(mark_received)
     
         // MarkReceived succeeds and returns the number of confirmed sends.
         BOOST_CHECK_EQUAL(pb.MarkReceived(tx, received_from).value(), 1);
     
         {
             const auto infos{pb.GetBroadcastInfo()};
    -        const auto info{FindTxInfo(infos, tx)};
    -        BOOST_CHECK_EQUAL(info->received_from->ToStringAddrPort(), received_from.ToStringAddrPort());
    -        BOOST_CHECK(info->received_time.has_value());
    +        const auto& info{infos.at(tx)};
    +        BOOST_CHECK_EQUAL(info.received_from->ToStringAddrPort(), received_from.ToStringAddrPort());
    +        BOOST_CHECK(info.received_time.has_value());
         }
         BOOST_CHECK(!pb.HavePendingTransactions());
         BOOST_CHECK(!pb.PickTxForSend(/*will_send_to_nodeid=*/recipient2, /*will_send_to_address=*/addr2).has_value());
     
         // Subsequent MarkReceived returns nullopt and does not overwrite.
         in_addr ipv4Addr2;
         ipv4Addr2.s_addr = 0xa0b0c099;
         const CService received_from2{ipv4Addr2, 4444};
         BOOST_CHECK(!pb.MarkReceived(tx, received_from2).has_value());
     
         {
             const auto infos{pb.GetBroadcastInfo()};
    -        const auto info{FindTxInfo(infos, tx)};
    -        BOOST_CHECK_EQUAL(info->received_from->ToStringAddrPort(), received_from.ToStringAddrPort());
    +        BOOST_CHECK_EQUAL(infos.at(tx).received_from->ToStringAddrPort(), received_from.ToStringAddrPort());
             BOOST_CHECK(!pb.HavePendingTransactions());
         }
     
         // Re-adding after received clears the received state and makes it pending again.
         BOOST_CHECK(pb.Add(tx));
         BOOST_CHECK(pb.HavePendingTransactions());
         const auto infos{pb.GetBroadcastInfo()};
    -    const auto info{FindTxInfo(infos, tx)};
    -    BOOST_CHECK(!info->received_from.has_value());
    -    BOOST_CHECK(!info->received_time.has_value());
    +    const auto& info{infos.at(tx)};
    +    BOOST_CHECK(!info.received_from.has_value());
    +    BOOST_CHECK(!info.received_time.has_value());
     }
     
     BOOST_AUTO_TEST_SUITE_END()
    

    </details>


    andrewtoth commented at 5:01 PM on April 4, 2026:

    I had similar thoughts, but didn't want to bloat the PR. I will leave this for your follow-up.


    vasild commented at 11:29 AM on April 16, 2026:

    A followup has been opened in https://github.com/bitcoin/bitcoin/pull/35016

  53. vasild commented at 6:04 PM on March 26, 2026: contributor

    Approach ACK d5d7c28e1b4b76daddad95a23cbf56209ca0b86e

  54. DrahtBot added the label Needs rebase on Apr 4, 2026
  55. andrewtoth force-pushed on Apr 4, 2026
  56. DrahtBot removed the label Needs rebase on Apr 4, 2026
  57. andrewtoth commented at 4:48 PM on April 4, 2026: contributor

    Thank you for the detailed review @vasild. I've taken many of your suggestions.

    Rebased due to #34873, which meant dropping the first commit.

    git range-diff 902708e..d5d7c28 master..HEAD

  58. in src/private_broadcast.h:86 in f60a433037
      82 | +     * Record that a transaction was received back from the network and
      83 | +     * pause rebroadcast attempts until the mempool state is reset.
      84 | +     * @param[in] tx Transaction received from the network.
      85 | +     * @param[in] received_from Peer address we received the transaction from.
      86 | +     * @retval !nullopt The number of times the transaction was sent and confirmed
      87 | +     * by a recipient (if the transaction existed and was removed).
    


    takeshikurosawaa commented at 12:06 AM on April 13, 2026:

    Nit: the MarkReceived() docstring still says "if the transaction existed and was removed", but after this change the transaction is kept in storage. Should that wording be updated?

  59. andrewtoth force-pushed on Apr 13, 2026
  60. andrewtoth commented at 2:15 AM on April 13, 2026: contributor

    Addressed nit (https://github.com/bitcoin/bitcoin/pull/34707#discussion_r3070373291 thanks @takeshikurosawaa).

    git diff f60a433037b925e1e23c9ca91151511734634d36..a6db9ea77b245519186f1341b1bf89ace130a4fa

  61. in src/private_broadcast.cpp:45 in a6db9ea77b
      40 | +    EXCLUSIVE_LOCKS_REQUIRED(!m_mutex)
      41 | +{
      42 | +    LOCK(m_mutex);
      43 | +    const auto it{m_transactions.find(tx)};
      44 | +    if (it == m_transactions.end() || it->second.received.has_value()) return std::nullopt;
      45 | +    it->second.received.emplace(Received{received_from, NodeClock::now()});
    


    vasild commented at 1:50 PM on April 14, 2026:

    C++ nit: this creates a temporary and then moves it into the optional. Can be simpler and avoid the temporary and the moving, constructing the object inplace into the optional storage:

        it->second.received.emplace(received_from, NodeClock::now());
    

    andrewtoth commented at 8:45 PM on April 24, 2026:

    Done.

  62. in src/net_processing.cpp:4410 in a6db9ea77b outdated
    4403 | @@ -4404,7 +4404,7 @@ void PeerManagerImpl::ProcessMessage(Peer& peer, CNode& pfrom, const std::string
    4404 |          const uint256& hash = peer.m_wtxid_relay ? wtxid.ToUint256() : txid.ToUint256();
    4405 |          AddKnownTx(peer, hash);
    4406 |  
    4407 | -        if (const auto num_broadcasted{m_tx_for_private_broadcast.Remove(ptx)}) {
    4408 | +        if (const auto num_broadcasted{m_tx_for_private_broadcast.MarkReceived(ptx, CService{pfrom.addr})}) {
    4409 |              LogDebug(BCLog::PRIVBROADCAST, "Received our privately broadcast transaction (txid=%s) from the "
    4410 |                                             "network from %s; stopping private broadcast attempts",
    4411 |                       txid.ToString(), pfrom.LogPeer());
    


    vasild commented at 2:08 PM on April 14, 2026:

    There is no cap on the number of transactions kept into memory. I think this is fine, just mentioning. This is unlikely to create problems. A cap can be added in a followup if deemed necessary. If the node is restarted the list will be truncated to 0 because it is kept in memory only.


    andrewtoth commented at 8:46 PM on April 24, 2026:

    I agree it is unlikely to create problems. You can use abortprivatebroadcast to remove transactions from memory. An expiry can be added in a follow-up, which can be used to remove transaction from the queue after some time.

  63. vasild approved
  64. vasild commented at 2:09 PM on April 14, 2026: contributor

    ACK a6db9ea77b245519186f1341b1bf89ace130a4fa

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

    -----BEGIN PGP SIGNED MESSAGE-----
    Hash: SHA256
    
    ACK a6db9ea77b245519186f1341b1bf89ace130a4fa
    -----BEGIN PGP SIGNATURE-----
    
    iQQzBAEBCAAdFiEE5k2NRWFNsHVF2czBVN8G9ktVy78FAmneSo0ACgkQVN8G9ktV
    y79XxR//VLYZqFCLl0RxyVk2nSd5QZVVdCmOA0auZXXsXlDg4QaJE6BfgohTS3sw
    0lLxmMAfR+UiWVH1ABq1HQ5RIugI2HHZC2xAljhDK3iRIWUNKFqXskgzQRrcDB6i
    VuLNyFdTs0DTrc5xvGmOXy7AQwDaltrG1gUdB82wlOQeJnzmCSB/Vs4wo5APSP2V
    MHqlfJ2XIzp4QJI0/8ODV4D4f2wPc7S9+JW7H5/UY1jFW0m852kUSu1fXjrySl+y
    cPXHaigcKmwC7MRRK0fkdO/3GUD1reSYzrIN720LvR3Ww+bASVwfQlh2GcunJC7o
    /msYp2aGicrA6alNiEg+YBhZY2JZ+GC9V84VSF+jUprRTF6VGEgxv9XVk7gnrZqL
    +VQsG1cmyabzVow16Ko7KWegI13Kp058/xKkU2Ygot1eyVwgzzWiFr1jMEg7z+Wp
    zwLmtuMXsS2n+VMnwKTXUIaYdt1In/ldxMJaerm0jwEvl2nDuZaL/wQ/h0Lk2wVG
    W2psvURfUW/1f15YqlsoPkBEw4rjhiyhaxZw02cVUiK+09OfRePMJkPSwr2w1yzU
    w5H0jH2TVa5btFEr9biuFv1Rc0/lc/i8tByTFdutJ5j8FOogJxOe1G8hBICc+I+L
    TSqy0bkcy5xv35Blp2un0mFp4icgYZhrWabXLUI+EGRCVU8erp8Jxkc1nvNbkUkx
    XKbwn2oyKDVO03lOmkFocBS019gxihblPTaXd55XC3zvpv5YymbStSILt17okoC8
    RVRnacuiCsfntnoEAzqRaBK02T9RiqlnyzbBVl1YeZRTnlfVWPptXb/8kZSyRrFO
    kNjYu+3C83bt0XK9oPpsNtDpHzK9uB24lsiLwjDNHyXH12VY0vrU6cblvcb51BtT
    9SJRmTim2d3IbiDh/e3Yo9NPIBrmOZxpHHGa/d4W5FxHybcf5qzFtdNlZTfPr9jZ
    yrL3Yt/z4w/48wzvEt0BrX0u/H0ASaVGYvL1G3sX+aSWlO7sk+1q35UKhzB6/3Ex
    BqsE/CTLt36vX+9fhFx4mj1l7/99Tj/n9xAzkJhvkNgTXfIFTpCBpz5xRxwJWtJR
    vORZ2nfKMHX5oGYT47nA2Hf+70cSVdbR2exEfVZDupzkEPIAMOY7tXoeapNSO/5E
    4OVIzSli1WbUyNwu5yD4hAMTmwFNFozrNiFLJo0FzzHQXI/ZXJzHwMdBapj98cGp
    HB/jbb0xAjEv6PnT/WBqgVitiggDAQhH2JjGoihu5D8t+fUaLimX1+RDAh/Jkvbg
    2Vgqvr0+NIyaiz/s9o07zuxB8Y8D5+OWzrbBpu8Uv4ISakXopRMxvSbpjzv5yUPV
    HvNM8FvyR6IFXSbuHXmIlGUPI9/XiQ==
    =Bbwh
    -----END PGP SIGNATURE-----
    

    vasild's public key is on openpgp.org

    </details>

  65. DrahtBot requested review from optout21 on Apr 14, 2026
  66. in src/private_broadcast.cpp:22 in 0e5ef5da36 outdated
      19 | +        auto& state{it->second};
      20 | +        if (!state.received.has_value()) {
      21 | +            return false;
      22 | +        }
      23 | +        state.received.reset();
      24 | +        state.send_statuses.clear();
    


    danielabrozzoni commented at 3:23 PM on April 16, 2026:

    I think we should also set time_added to now() here, otherwise you're not resetting the entire state of the transaction. However, this would mean making TxSendStatus::time_added non-const.

    The downside of leaving as-is is quite minimal: it means that if you call Add on an already present tx to reset its state, and the tx was added more than INITIAL_STALE_DURATION (5 min) ago, the tx would immediately become stale.


    andrewtoth commented at 8:46 PM on April 24, 2026:

    Done.

  67. danielabrozzoni approved
  68. danielabrozzoni commented at 3:48 PM on April 16, 2026: member

    tACK a6db9ea77b245519186f1341b1bf89ace130a4fa

    One note: since we don't persist mempool invalid transactions for now, if we broadcast a transaction and it gets included in a block before we receive a TX message for it, I think we would then reattempt broadcasting it in ReattemptBroadcast, see that it's not mempool valid anymore, and call Remove on it. I don't think it's a big problem, just a edge case I noticed.

  69. in src/rpc/mempool.cpp:172 in 0e5ef5da36
     164 | @@ -165,6 +165,11 @@ static RPCMethod getprivatebroadcastinfo()
     165 |                                                  {RPCResult::Type::NUM_TIME, "received", /*optional=*/true, "The time this peer acknowledged reception of the transaction (seconds since epoch)"},
     166 |                                              }},
     167 |                                      }},
     168 | +                                {RPCResult::Type::OBJ, "received", /*optional=*/true, "Information about how this transaction was received back from the network",
     169 | +                                    {
     170 | +                                        {RPCResult::Type::STR, "address", "The peer address from which this transaction was received back from the network"},
     171 | +                                        {RPCResult::Type::NUM_TIME, "time", "The time this transaction was received back from the network (seconds since epoch)"},
     172 | +                                    }},
    


    rkrux commented at 10:56 AM on April 21, 2026:

    In 0e5ef5da3664970587a1b66a54bae4f29af93cb9 net: track received peer in private broadcast - Doesn't this warrant a release note because the API is being updated?


    andrewtoth commented at 8:47 PM on April 24, 2026:

    Added release notes.

  70. rkrux commented at 10:58 AM on April 21, 2026: contributor

    Started reviewing at a6db9ea77b245519186f1341b1bf89ace130a4fa, left quick comments.

  71. fanquake added the label Private Broadcast on Apr 21, 2026
  72. in src/private_broadcast.h:212 in 0e5ef5da36
     208 | @@ -189,10 +209,16 @@ class PrivateBroadcast
     209 |      struct TxSendStatus {
     210 |          const NodeClock::time_point time_added{NodeClock::now()};
     211 |          std::vector<SendStatus> send_statuses;
     212 | +        std::optional<Received> received;
    


    rkrux commented at 2:24 PM on April 21, 2026:

    In 0e5ef5da3664970587a1b66a54bae4f29af93cb9 "net: track received peer in private broadcast"

    With the addition of received, this struct doesn't feel like a send status anymore and instead more like a communication status because bidirectional information is being stored. My preference is for s/TxSendStatus/TxCommunicationStatus but since the term broadcast is already ubiquitous here, so s/TxSendStatus/TxBroadcastStatus?


    andrewtoth commented at 8:47 PM on April 24, 2026:

    Renamed.

  73. in src/private_broadcast.h:215 in 0e5ef5da36
     208 | @@ -189,10 +209,16 @@ class PrivateBroadcast
     209 |      struct TxSendStatus {
     210 |          const NodeClock::time_point time_added{NodeClock::now()};
     211 |          std::vector<SendStatus> send_statuses;
     212 | +        std::optional<Received> received;
     213 |      };
     214 | +
     215 | +    using TransactionMap = std::unordered_map<CTransactionRef, TxSendStatus, CTransactionRefHash, CTransactionRefComp>;
    


    rkrux commented at 2:31 PM on April 21, 2026:

    In 0e5ef5d "net: track received peer in private broadcast"

    Similarly, s/TransactionMap/TransactionBroadcastMap?


    andrewtoth commented at 8:47 PM on April 24, 2026:

    Removed the alias along with GetPendingTransactions().

  74. in src/private_broadcast.h:57 in 0e5ef5da36
      52 | +
      53 |      struct TxBroadcastInfo {
      54 |          CTransactionRef tx;
      55 |          NodeClock::time_point time_added;
      56 |          std::vector<PeerSendInfo> peers;
      57 | +        std::optional<Received> received;
    


    rkrux commented at 2:34 PM on April 21, 2026:

    In 0e5ef5da3664970587a1b66a54bae4f29af93cb9 "net: track received peer in private broadcast"

    Though bidirectional information is being stored in this struct, I feel the term broadcast falls short now. But I understand that renaming it would increase the diff quite a lot, so not needed in this PR.

    However, I do notice that received is present both in TxBroadcastInfo and in PeerSendInfo as well. So to disambiguate, maybe call this received as received_by_us?


    andrewtoth commented at 8:48 PM on April 24, 2026:

    Renamed.

  75. in src/private_broadcast.cpp:106 in 0e5ef5da36
     102 | @@ -83,7 +103,7 @@ bool PrivateBroadcast::HavePendingTransactions()
     103 |      EXCLUSIVE_LOCKS_REQUIRED(!m_mutex)
     104 |  {
     105 |      LOCK(m_mutex);
     106 | -    return !m_transactions.empty();
     107 | +    return !GetPendingTransactions().empty();
    


    rkrux commented at 2:37 PM on April 21, 2026:

    In 0e5ef5d "net: track received peer in private broadcast"

    This seems like a costly(-ier) way to figure out if there is any pending transaction. We can short-circuit early in the iteration.

    This diff passes unit tests.

    diff --git a/src/private_broadcast.cpp b/src/private_broadcast.cpp
    index f43ebd7eca..42c163bc4a 100644
    --- a/src/private_broadcast.cpp
    +++ b/src/private_broadcast.cpp
    @@ -6,6 +6,7 @@
     #include <util/check.h>
     
     #include <algorithm>
    +#include <ranges>
     
     
     bool PrivateBroadcast::Add(const CTransactionRef& tx)
    @@ -103,7 +104,9 @@ bool PrivateBroadcast::HavePendingTransactions()
         EXCLUSIVE_LOCKS_REQUIRED(!m_mutex)
     {
         LOCK(m_mutex);
    -    return !GetPendingTransactions().empty();
    +    return std::ranges::any_of(m_transactions | std::views::values, [](const TxSendStatus& send_status) {
    +        return !send_status.received.has_value();
    +    });
     }
     
     std::vector<CTransactionRef> PrivateBroadcast::GetStale() const
    

    andrewtoth commented at 8:48 PM on April 24, 2026:

    Done.

  76. rkrux commented at 2:42 PM on April 21, 2026: contributor

    Partial review at a6db9ea77b245519186f1341b1bf89ace130a4fa

  77. net: track received peer in private broadcast
    Introduce MarkReceived method in PrivateBroadcast to record which peer
    relayed the transaction back to us, and at what time. The transaction stays in the
    queue instead of being removed, preserving broadcast metadata.
    However, the transaction will not be selected for broadcasting again.
    
    The received metadata is cleared when a transaction is added to the queue again,
    allowing the transaction to be broadcast again.
    2bbcc955f3
  78. test: extract find_tx_info to static helper
    Non-functional refactor.
    e97f6904dd
  79. test: add unit test covering MarkReceived 0b1f5f4aec
  80. andrewtoth force-pushed on Apr 24, 2026
  81. doc: release notes for 34707 1df06de4ed
  82. DrahtBot added the label CI failed on Apr 24, 2026
  83. andrewtoth commented at 8:45 PM on April 24, 2026: contributor

    Thank you for your reviews @vasild, @danielabrozzoni, @rkrux.

    • Reset time_added in Add(). Cover this in unit test.
    • Renamed TxSendStatus -> TxBroadcastStatus, received -> received_by_us.
    • Removed GetPendingTransactions() and replaced with std::ranges::any_of in HavePendingTransactions() and std::views::filter in PickTxForSend().
    • Added release notes
    • Addressed other nits

    git diff a6db9ea77b245519186f1341b1bf89ace130a4fa..1df06de4ed97fd95758f4cdcd0d3fe80a3a6dda0

  84. DrahtBot removed the label CI failed on Apr 24, 2026
  85. vasild approved
  86. vasild commented at 3:39 PM on April 27, 2026: contributor

    ACK 1df06de4ed97fd95758f4cdcd0d3fe80a3a6dda0

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

    -----BEGIN PGP SIGNED MESSAGE-----
    Hash: SHA256
    
    ACK 1df06de4ed97fd95758f4cdcd0d3fe80a3a6dda0
    -----BEGIN PGP SIGNATURE-----
    
    iQQzBAEBCAAdFiEE5k2NRWFNsHVF2czBVN8G9ktVy78FAmnvgxMACgkQVN8G9ktV
    y78DDx/+I6r/ELxFl2o0qTH797wMwquButcIQvdx0mFk6bNlc/WWSNWGvAQJJgD/
    1GkLOsDIzznZqs1ePl6CkxWugucdSZqVfH0Oz65mEk+MyzGsSZqhYAhue6MFBi7f
    GtKXAkp7LCw9fyatGdrGmNhBK9cMYekr1uDPdVndS5hxYes1j+MsqQraM5By6Jvk
    td0HdW2BU92AittiryBswAQW8SHH/FneQl9iWxo8sfsvlHrES/+LUfNAtmXECCLh
    ZRT1C/Tz2ZWSoSrLP7hZHzrzm4QJYIJyEEdouEL84nYNhrU9FEWkcABFqPKb7HvB
    4o4+4ZNc+F8Z5+D6Je49xwlRTNj4JCtMpgHeboRL2YO7YJNBqXzPrLJZXowEpYbj
    5M0b9v3R3hEkFiethfdBfQ6cYlUZ9Gsa6CiKzeZKhPzLGQEpMlpoiR1dG96KNZxB
    nz6RIKFynIxXE2qiWuIk0cSHJvzMfMiOeoGUivb4ypSBHyGb1HhEN7tGd3ao+Kt8
    rbKSlG8bIP6kTv59a8f91KJPGTCCiV2J0qi1EodvfG8injxqxCIGyG9VT2tIBpuZ
    l02PPxXmCIGbF84pxMTgXTqn9Ui2bsn0fpaY211hh3wYDkvcdYL9twfwONalciOn
    8C2Hc3iY6YVqld+IxYqBZVVTefbUW/svLANBiMtvnBvDBWg2JCJBa7FYlLgop+wv
    KMYB9dI9TZcVc9QDsx2tOHqjBSDkDZaztgXDymXbntL+aQwTtVpe/LDY25Mcm9Ob
    ptzS0KjNd9xGSqmL6GK00GhjzqKzIGZF7GmYQSw9aLGc+r6sYzN5+h5aYB/X3b13
    Zai8/SWIV6/arwdjlFyqk7LtP1T/ORmIx3mXZXjOBQXAGSToBjn9P9HgwfDv1oG+
    D4owDx/JTaMHv9GfmCoS/156Cwemu51wymjwdlzZfgRVjq9lD1G/29shQd4EqsJO
    gEA4SkBEYhRPjNpBSEo3ttpeRGJRdLS2CAETPINkRmKqHo4yVytVWoBNCoSs3d8Q
    9zyZz6cZSFCMcaBpytuoE+d4tMOOaEtnvenxf3t4+TsFHMi3Ee/QazDNyfjxW0dw
    RZL0KrxrOiGnoyomPZuKf/PPeCXYFjn6zXm70yN+c7f0qOvnOkS8dGVz1ywGaens
    0e98CzB7SofqjFqJeGc0hZTQsnt6kutYHkbWWQiCMGplOSp317ogNojAuoOFYwTA
    4LzLoxgoeLVItlN9Wi23do7DTVeiJtlyffTZUo9SOX1bHduZDU3T7wkOj7RwW4hu
    5JuKTet+w2Frh/vY9V/g1oSSgQ7/RSyB7mC8dEyZpn05+tyGepN5tRMdPJOy+pv1
    k2uXsaJ2wX4q/ECdZGGyZjhXAI1eFA==
    =1o2j
    -----END PGP SIGNATURE-----
    

    vasild's public key is on openpgp.org

    </details>

  87. DrahtBot requested review from danielabrozzoni on Apr 27, 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-05-02 18:12 UTC

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