net: send decoy transactions via private broadcast #35252

pull andrewtoth wants to merge 3 commits into bitcoin:master from andrewtoth:decoys changing 7 files +167 −11
  1. andrewtoth commented at 7:54 PM on May 9, 2026: contributor

    The private broadcast feature removes the link between a transaction and its originating node IP or onion address. However, a passive observer with a large number of sybil nodes can still fingerprint transactions that are broadcast via private broadcast. Any recipient of a transaction via a private broadcast connection can be certain that it was deliberately sent via private broadcast.

    This PR introduces private broadcast decoy connections. Randomly on average once every 3 hours, any node with Tor or I2P reachable opens an extra private broadcast connection. Instead of one of the node's own pending transactions, the connection announces the most recent transaction in the mempool. Because of normal transaction relay latency, the recipient may not have seen that transaction yet, and from its point of view the connection is indistinguishable from a user-submitted private broadcast. If the recipient already has the transaction, the private broadcast will timeout after 3 minutes. In this case there is still plausible deniability since this mimics user-submitted transactions. We open 3 connections for each submitted transaction. After the first successful connection the other connections may connect to nodes that have already received the transaction from the first recipient.

    -blocksonly nodes are excluded. If the mempool is empty, no decoy is sent that round.

    Every user-submitted private broadcast now has plausible deniability of being a decoy.

    This makes it more difficult for a network-level observer to distinguish the traffic pattern of a user-submitted broadcast from a decoy.

    Log lines such as

    New private-broadcast peer connected: transport: v2, version: 70016, peer=xxx
    

    still appear, but they no longer distinguish user-submitted transactions from decoys.

  2. net: start private broadcast thread without requiring -privatebroadcast
    The private broadcast thread is currently only started when
    -privatebroadcast is set. Decouple thread startup from that configuration
    option so that the same connection machinery can be reused for decoys.
    f9936ff872
  3. DrahtBot added the label P2P on May 9, 2026
  4. DrahtBot commented at 7:54 PM on May 9, 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/35252.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    Concept ACK kevkevinpal, 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:

    • #34628 (p2p: Replace per-peer transaction rate-limiting with global rate limits by ajtowns)
    • #34271 (net_processing: make m_tx_for_private_broadcast optional by vasild)
    • #31260 (scripted-diff: Type-safe settings retrieval by ryanofsky)
    • #30951 (net: option to disallow v1 connection on ipv4 and ipv6 peers by stratospher)
    • #17783 (common: Disallow calling IsArgSet() on ALLOW_LIST options by ryanofsky)

    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-->

  5. andrewtoth renamed this:
    p2p: send decoy transactions via private broadcast
    net: send decoy transactions via private broadcast
    on May 9, 2026
  6. kevkevinpal commented at 2:13 PM on May 10, 2026: contributor

    Concept ACK

    Correct me if I'm wrong, but with this change, then that transaction will be forwarded twice from the same node, once via flood and another via private broadcast?

    Wouldn't this add additional network load for nodes, albeit small, considering it is one transaction every 6 hours?

    I like the idea, just worth considering if it does add more bandwidth usage.

  7. andrewtoth commented at 2:53 PM on May 10, 2026: contributor

    Wouldn't this add additional network load for nodes, albeit small, considering it is one transaction every 6 hours?

    On average it would be once every 3 hours. However, since we make feeler connections every 2 minutes, I don't think the bandwidth increase is worth considering. The extra overhead of a private broadcast connection vs a feeler is just an inv -> getdata -> tx and ping + pong.

  8. 0xB10C commented at 9:37 PM on May 10, 2026: contributor

    Because of normal transaction relay latency, the recipient may not have seen that transaction yet, and from its point of view the connection is indistinguishable from a user-submitted private broadcast.

    It would be interesting to measure how well this works. If you are not well connected, for example because you only have 8 transaction relaying outbound connections (which receive invs slower than the inbounds you get) as a home node, you might pick decoys a large part of the network already knows about (especially if it's a high feerate transaction, as these propagate faster). Since the other side wouldn't request these if already in mempool, it's able to tell that these are decoys.

    This can be measured by running this PR and counting how many of the decoy transactions are actually requested from you. It might make sense to do this both for listening and non-listening nodes. For testing, a higher rate of decoys is probably useful: e.g. send a decoy every 5min.

  9. net_processing: periodically send decoy transactions via private broadcast
    Randomly on average once every 3 hours, open one extra private broadcast connection
    and announce the most recent mempool transaction.
    95419a3abf
  10. test: add functional test for private broadcast decoys d3e303875f
  11. andrewtoth force-pushed on May 11, 2026
  12. mzumsande commented at 2:27 AM on May 11, 2026: contributor

    Wouldn't any attacker interested in the origin of txns so much that they would run a large-scale Sybil attack almost certainly also have a permanent connection to each reachable node (which is easier and cheaper) - so that the proposed solution would be ineffective because the attacker very likely would already know about the last mempool transaction?

  13. mzumsande commented at 5:14 AM on May 11, 2026: contributor

    Also, I think it would be good to discuss the general issue this is addressing: Why is it bad if an attacker knows that private broadcast was used for a tx if they can't know the sender? Is the concern that this could lead to some kind of stigmatisation in some (even more) dystopian future, or something different?

  14. andrewtoth commented at 4:22 PM on May 11, 2026: contributor

    Since the other side wouldn't request these if already in mempool, it's able to tell that these are decoys.

    the proposed solution would be ineffective because the attacker very likely would already know about the last mempool transaction? @0xB10C @mzumsande addressing these comments together since they are essentially the same question.

    The way private broadcast works today is a user-submitted transaction immediately triggers 3 connections. These are opened sequentially, without waiting for the previous connection to complete. They are only stopped if we receive the transaction back from the network before they all get opened.

    What happens very often in practice is the first connection successfully transmits the tx, and the recipient node broadcasts it to all its peers. But, the sender has not yet received the tx back from one of its peers. The second connection now sends an inv to a node that has already received the tx from the first recipient node, and the second connection does not get a getdata in response and times out after 3 minutes.

    So today, the two cases for an observer are as follows:

    1. We receive a private broadcast connection for a tx we do not have, and request it via getdata. We are certain this tx was a user-submitted private broadcast tx.
    2. We receive a private broadcast connection for a tx we already received via a directly connected peer, so we do not send getdata and the connection times out after 3 minutes. We are certain this tx was a user-submitted private broadcast tx, even though we got it from a directly connected peer.

    For both scenarios 1 and 2, with decoys the observer can no longer be certain whether the tx was a user-submitted private broadcast or not.

    Both of these comments are describing scenario 2, which already occurs on the network today and fingerprints transactions that have already been received.

    It would be interesting to measure how well this works.

    I ran with a small patch overnight that sent decoys every 5 minutes, with some small stat gathering. I ran it on one well connected listening node and a node running with just a single connection to a trusted peer. Interestingly, the singly connected node had more success with receiving getdata responses than the well connected node. So, I think there are a lot of variables in play.

    Well-connected listening peer: private broadcast stats: getdatas=40/invs=179 (22.35%) Single-connection peer: private broadcast stats: getdatas=61/invs=175 (34.86%)

    Why is it bad if an attacker knows that private broadcast was used for a tx if they can't know the sender?

    Since private broadcast is an opt-in feature, any private broadcast transactions are fingerprinted. This is another vector for correlating transactions into common ownership.

    Is the concern that this could lead to some kind of stigmatisation in some (even more) dystopian future

    I didn't consider this, but decoys effectively nullify this risk as well.

    There is also the angle of traffic analysis. If private broadcast connection patterns are discernable to a network observer, they can use timing analysis to figure out which node sent a tx.

    And finally, if a node's debug logs are leaked, the New private-broadcast peer connected: transport: v2, version: 70016, peer=xxx logs cannot be used for timing analysis to determine which transactions it broadcast.

  15. in src/net_processing.cpp:573 in d3e303875f
     568 | @@ -567,6 +569,9 @@ class PeerManagerImpl final : public PeerManager
     569 |      /** Rebroadcast stale private transactions (already broadcast but not received back from the network). */
     570 |      void ReattemptPrivateBroadcast(CScheduler& scheduler);
     571 |  
     572 | +    /** Periodically open one extra private broadcast connection and send the most recent tx in the mempool */
     573 | +    void InitiatePrivateBroadcastDecoy(CScheduler& scheduler);
    


    vasild commented at 4:10 PM on May 14, 2026:

    Would it not be possible to implement this in a simpler way by pushing the tx to the already existent private broadcast pipeline, like non-decoys. I mean this:

    <details> <summary>[patch] Send decoy as a normal private broadcast</summary>

    --- i/src/net_processing.cpp
    +++ w/src/net_processing.cpp
    @@ -4451,12 +4451,16 @@ void PeerManagerImpl::ProcessMessage(Peer& peer, CNode& pfrom, const std::string
             const MempoolAcceptResult result = m_chainman.ProcessTransaction(ptx);
             const TxValidationState& state = result.m_state;
    
             if (result.m_result_type == MempoolAcceptResult::ResultType::VALID) {
                 ProcessValidTx(pfrom.GetId(), ptx, result.m_replaced_transactions);
                 pfrom.m_last_tx_time = GetTime<std::chrono::seconds>();
    +
    +            if (it is time for a new decoy, once in about 24h) {
    +                InitiateTxBroadcastPrivate(ptx);
    +            }
             }
             if (state.IsInvalid()) {
                 if (auto package_to_validate{ProcessInvalidTx(pfrom.GetId(), ptx, state, /*first_time_failure=*/true)}) {
                     const auto package_result{ProcessNewPackage(m_chainman.ActiveChainstate(), m_mempool, package_to_valid
                     LogDebug(BCLog::TXPACKAGES, "package evaluation for %s: %s\n", package_to_validate->ToString(),
                              package_result.m_state.IsValid() ? "package accepted" : "package rejected");
    

    </details>

    that is quickly after we receive the NetMsgType::TX message. That should also be sooner than the current PR approach of picking from the mempool and will probably increase the rate where we do get GETDATA for the tx over the private broadcast connection.

    Also, the current approach in this PR of scheduling just 1 connection per decoy could mess with a non-decoy private broadcast (which schedules 3 connections) - when we receive back the transaction we do assume 3 connections were fired and check how many times it was broadcasted, e.g. 1 and then reduce the connections by 3-1=2. So, after receiving back the decoy it will wrongly reduce by 2 and will hurt non-decoys.

  16. in src/net_processing.cpp:203 in d3e303875f
     198 | @@ -199,6 +199,8 @@ static constexpr size_t MAX_ADDR_PROCESSING_TOKEN_BUCKET{MAX_ADDR_TO_SEND};
     199 |  static constexpr size_t NUM_PRIVATE_BROADCAST_PER_TX{3};
     200 |  /** Private broadcast connections must complete within this time. Disconnect the peer if it takes longer. */
     201 |  static constexpr auto PRIVATE_BROADCAST_MAX_CONNECTION_LIFETIME{3min};
     202 | +/** Average interval between private broadcast decoy attempts */
     203 | +static constexpr auto PRIVATE_BROADCAST_DECOY_INTERVAL{3h};
    


    vasild commented at 4:26 PM on May 14, 2026:

    I think 3h is too aggressive. If we have 10k nodes, each one decoying every 3 hours, then there will be a decoy on the network approximately every second.

  17. in src/net.cpp:3597 in d3e303875f
    3593 | @@ -3594,7 +3594,7 @@ bool CConnman::Start(CScheduler& scheduler, const Options& connOptions)
    3594 |              std::thread(&util::TraceThread, "i2paccept", [this] { ThreadI2PAcceptIncoming(); });
    3595 |      }
    3596 |  
    3597 | -    if (gArgs.GetBoolArg("-privatebroadcast", DEFAULT_PRIVATE_BROADCAST)) {
    3598 | +    if (connOptions.m_start_private_broadcast_thread) {
    


    vasild commented at 4:30 PM on May 14, 2026:

    The intention here I guess is to do the decoys even if -privatebroadcast=0.

    The semantic of -privatebroadcast=1 is "send my transactions in a private way" and I think it is good to keep it that way.

    Should the users be given the option to switch off the decoys?

  18. vasild commented at 4:30 PM on May 14, 2026: contributor

    Concept ACK


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-19 06:51 UTC