Skip to content

Conversation

@vasild
Copy link
Contributor

@vasild vasild commented Feb 9, 2024

Parts of this PR are isolated in independent smaller PRs to ease review:


To improve privacy, broadcast locally submitted transactions (from the sendrawtransaction RPC) to the P2P network only via Tor or I2P short-lived connections, or to IPv4/IPv6 peers but through the Tor network.

  • Introduce a new connection type for private broadcast of transactions with the following properties:

    • started whenever there are local transactions to be sent
    • opened to Tor or I2P peers or IPv4/IPv6 via the Tor proxy
    • opened regardless of max connections limits
    • after handshake is completed one local transaction is pushed to the peer, PING is sent and after receiving PONG the connection is closed
    • ignore all incoming messages after handshake is completed (except PONG)
  • Broadcast transactions submitted via sendrawtransaction using this new mechanism, to a few peers. Keep doing this until we receive back this transaction from one of our ordinary peers (this takes about 1 second on mainnet).

  • The transaction is stored in peerman and does not enter the mempool.

  • Once we get an INV from one of our ordinary peers, then the normal flow executes: we request the transaction with GETDATA, receive it with a TX message, put it in our mempool and broadcast it to all our existent connections (as if we see it for the first time).

  • After we receive the full transaction as a TX message, in reply to our GETDATA request, only then consider the transaction has propagated through the network and remove it from the storage in peerman, ending the private broadcast attempts.

The messages exchange should look like this:

tx-sender >--- connect -------> tx-recipient
tx-sender >--- VERSION -------> tx-recipient (dummy VERSION with no revealing data)
tx-sender <--- VERSION -------< tx-recipient
tx-sender <--- WTXIDRELAY ----< tx-recipient (maybe)
tx-sender <--- SENDADDRV2 ----< tx-recipient (maybe)
tx-sender <--- SENDTXRCNCL ---< tx-recipient (maybe)
tx-sender <--- VERACK --------< tx-recipient
tx-sender >--- VERACK --------> tx-recipient
tx-sender >--- INV/TX --------> tx-recipient
tx-sender <--- GETDATA/TX ----< tx-recipient
tx-sender >--- TX ------------> tx-recipient
tx-sender >--- PING ----------> tx-recipient
tx-sender <--- PONG ----------< tx-recipient
tx-sender disconnects

Whenever a new transaction is received from sendrawtransaction RPC, the node will send it to a few (NUM_PRIVATE_BROADCAST_PER_TX) recipients right away. If after some time we still have not heard anything about the transaction from the network, then it will be sent to 1 more peer (see PeerManagerImpl::ReattemptPrivateBroadcast()).

A few considerations:

  • The short-lived private broadcast connections are very cheap and fast wrt network traffic. It is expected that some of those peers could blackhole the transaction. Just one honest/proper peer is enough for successful propagation.
  • The peers that receive the transaction could deduce that this is initial transaction broadcast from the transaction originator. This is ok, they can't identify the sender.

How to test this?

Thank you, @stratospher and @andrewtoth!

Start bitcoind with -privatebroadcast=1 -debug=privatebroadcast.

Create a wallet and get a new address, go to the Signet faucet and request some coins to that address:

build/bin/bitcoin-cli -chain="signet" createwallet test
build/bin/bitcoin-cli -chain="signet" getnewaddress

Get a new address for the test transaction recipient:

build/bin/bitcoin-cli -chain="signet" loadwallet test
new_address=$(build/bin/bitcoin-cli -chain="signet" getnewaddress)

Create the transaction:

# Option 1: `createrawtransaction` and `signrawtransactionwithwallet`:

txid=$(build/bin/bitcoin-cli -chain="signet" listunspent | jq -r '.[0] | .txid')
vout=$(build/bin/bitcoin-cli -chain="signet" listunspent | jq -r '.[0] | .vout')
echo "txid: $txid"
echo "vout: $vout"

tx=$(build/bin/bitcoin-cli -chain="signet" createrawtransaction "[{\"txid\": \"$txid\", \"vout\": $vout}]" "[{\"$new_address\": 0.00001000}]" 0 false)
echo "tx: $tx"

signed_tx=$(build/bin/bitcoin-cli -chain="signet" signrawtransactionwithwallet "$tx" | jq -r '.hex')
echo "signed_tx: $signed_tx"

# OR Option 2: `walletcreatefundedpsbt` and `walletprocesspsbt`:
# This makes it not have to worry about inputs and also automatically sends back change to the wallet.
# Start `bitcoind` with `-fallbackfee=0.00003000` for instance for 3 sat/vbyte fee.

psbt=$(build/bin/bitcoin-cli -chain="signet" walletcreatefundedpsbt "[]" "[{\"$new_address\": 0.00001000}]" | jq -r '.psbt')
echo "psbt: $psbt"

signed_tx=$(build/bin/bitcoin-cli -chain="signet" walletprocesspsbt "$psbt" | jq -r '.hex')
echo "signed_tx: $signed_tx"

Finally, send the transaction:

raw_tx=$(build/bin/bitcoin-cli -chain="signet" sendrawtransaction "$signed_tx")
echo "raw_tx: $raw_tx"

High-level explanation of the commits
  • New logging category and config option to enable private broadcast

    • log: introduce a new category for private broadcast
    • init: introduce a new option to enable/disable private broadcast
  • Implement the private broadcast connection handling on the CConnman side:

    • net: introduce a new connection type for private broadcast
    • net: implement opening PRIVATE_BROADCAST connections
  • Prepare BroadcastTransaction() for private broadcast requests:

    • net_processing: rename RelayTransaction to better describe what it does
    • node: extend node::TxBroadcast with a 3rd option
    • net_processing: store transactions for private broadcast in PeerManager
  • Implement the private broadcast connection handling on the PeerManager side:

    • net_processing: reorder the code that handles the VERSION message
    • net_processing: move the debug log about receiving VERSION earlier
    • net_processing: modernize PushNodeVersion()
    • net_processing: move a debug check in VERACK processing earlier
    • net_processing: handle ConnectionType::PRIVATE_BROADCAST connections
    • net_processing: stop private broadcast of a transaction after round-trip
    • net_processing: retry private broadcast
  • Engage the new functionality from sendrawtransaction:

    • rpc: use private broadcast from sendrawtransaction RPC if -privatebroadcast is ON
  • New tests:

    • test: add functional test for private broadcast
    • test: add unit test for the private broadcast storage

This PR would resolve the following issues:
#3828 Clients leak IPs if they are recipients of a transaction
#14692 Can't configure bitocoind to only send tx via Tor but receive clearnet transactions
#19042 Tor-only transaction broadcast onlynet=onion alternative
#24557 Option for receive events with all networks, but send transactions and/or blocks only with anonymous network[s]?
#25450 Ability to broadcast wallet transactions only via dedicated oneshot Tor connections
#32235 Tor: TX circuit isolation

Issues that are related, but (maybe?) not to be resolved by this PR:
#21876 Broadcast a transaction to specific nodes
#28636 new RPC: sendrawtransactiontopeer


Further extensions:

  • Have the wallet do the private broadcast as well, Could the wallet count unconfirmed non-mempool change? #11887 would have to be resolved.
  • Have the submitpackage RPC do the private broadcast as well, draft diff in the comment below, thanks ismaelsadeeq!
  • Add some stats via RPC, so that the user can better monitor what is going on during and after the broadcast. Currently this can be done via the debug log, but that is not convenient.
  • Make the private broadcast storage, currently in peerman, persistent over node restarts.
  • Add (optional) random delay before starting to broadcast the transaction in order to avoid correlating unrelated transactions based on the time when they were broadcast. Suggested independently of this PR here.
  • Consider periodically sending transactions that did not originate from the node as decoy, discussed here.
  • Consider waiting for peer's FEEFILTER message and if the transaction that was sent to the peer is below that threshold, then assume the peer is going to drop it. Then use this knowledge to retry more aggressively with another peer, instead of the current 10 min. See comment below.
  • It may make sense to be able to override the default policy -- eg so submitrawtransaction can go straight to the mempool and relay, even if txs are normally privately relayed. See comment below.
  • As a side effect we have a new metric available - the time it takes for a transaction to reach a random node in the network (from the point of view of the private broadcast recipient the tx originator is a random node somewhere in the network). This can be useful for monitoring, unrelated to privacy characteristics of this feature.

A previous incarnation of this can be found at #27509. It puts the transaction in the mempool and (tries to) hide it from the outside observers. This turned out to be too error prone or maybe even impossible.

@DrahtBot
Copy link
Contributor

DrahtBot commented Feb 9, 2024

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

Code Coverage & Benchmarks

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

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK pinheadmz, l0rinc, andrewtoth, w0xlt, mzumsande
Concept ACK zzzi2p, nothingmuch, jonatack, kdmukai, kevkevinpal, RandyMcMillan, naiyoma, ajtowns, danielabrozzoni

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

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #34181 (refactor: [p2p] Make ProcessMessage private again, Use references when non-null by maflcko)
  • #34059 (refactor: Use NodeClock::time_point for m_addr_token_timestamp by maflcko)
  • #33300 (fuzz: compact block harness by Crypt-iQ)
  • #32394 (net: make m_nodes_mutex non-recursive by vasild)
  • #29278 (Wallet: Add maxfeerate wallet startup option by ismaelsadeeq)
  • #28690 (build: Introduce internal kernel library by sedited)
  • #28463 (p2p: Increase inbound capacity for block-relay only connections by mzumsande)
  • #21283 (Implement BIP 370 PSBTv2 by achow101)

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.

@1440000bytes

This comment was marked as abuse.

@vasild
Copy link
Contributor Author

vasild commented Feb 10, 2024

@1440000bytes, thanks for asking! There is some discussion at #27509 (the previous attempt on this).

Is it necessary to open new short lived tor/i2p connections for broadcasting the transaction?

Yes, it is. See below.

What are the trade-offs in this implementation vs a simple implementation to relay tx to one or more peers that our node is already connected to?

Sending the transaction over clearnet reveals the IP address/geolocation of the sender. A spy with many connections to the network could try to guess who was the originator. So, why not send it to our Tor peers only? Because it is relatively easy for a spy to fingerprint and link clearnet and Tor connections to the same peer. That is, a long running connection over Tor could be linked to a long running clearnet connection. This is why the proposed changes open a short-lived connection that does not reveal any of the identity of the sender.

Would this benefit nodes that don't have clearnet connections, e.g. Tor/I2P-only nodes? Yes! In the case where the sender sends two otherwise unrelated transactions over the same long-running Tor connection, the recipient will know that they have the same origin, even though they are not related on-chain. Using single shot connections fixes that too.

Related issues:

Linked in the OP, thanks!

@epiccurious
Copy link
Contributor

v2 Transport will be enabled by default in the next release (#29347).

If there were eventually a change to force clearnet transactions over v2 transport (so the details of the communications were encrypted), would that solve the same problem that this PR is aiming to solve?

@vasild
Copy link
Contributor Author

vasild commented Feb 11, 2024

@epiccurious, p2p encryption "solves" the spying from intermediate routers on clearnet (aka man-in-the-middle). Tor, I2P and CJDNS solve that too. While this PR uses only Tor and I2P it would solve that problem. But there is more - it will as well solve issues with spying bitcoin nodes.

vasild added a commit to vasild/bitcoin that referenced this pull request Feb 11, 2024
@l0rinc
Copy link
Contributor

l0rinc commented Dec 18, 2025

code review diff ACK 8937221

Github diff seems broken - https://github.com/bitcoin/bitcoin/compare/662270f5b8d96434faec9e6f55f566f4f2ba1261..89372213048adf37a47427112a1ff836ee84c50e

git fetch origin 89372213048adf37a47427112a1ff836ee84c50e && git range-diff 662270f5b8d96434faec9e6f55f566f4f2ba1261...89372213048adf37a47427112a1ff836ee84c50e is also very verbose and confusing, so ended up checking out old branch, rebasing it locally and soft resetting it on rebased PR head.

patch since last ACK
diff --git a/doc/release-notes-29415.md b/doc/release-notes-29415.md
index 001d744b03..d5040a3193 100644
--- a/doc/release-notes-29415.md
+++ b/doc/release-notes-29415.md
@@ -1,11 +1,12 @@
 P2P and network changes
 -----------------------
 
-- Normally local transactions are broadcast to all connected peers. Now,
-  for the `sendrawtransaction` RPC this behavior can be changed to only
-  do the broadcast via the Tor or I2P networks. A new boolean option
-  `-privatebroadcast` has been added to enable this behavior. This
-  improves the privacy of the transaction originator in two aspects:
+- Normally local transactions are broadcast to all connected peers with
+  which we do transaction relay. Now, for the `sendrawtransaction` RPC
+  this behavior can be changed to only do the broadcast via the Tor or
+  I2P networks. A new boolean option `-privatebroadcast` has been added
+  to enable this behavior. This improves the privacy of the transaction
+  originator in two aspects:
   1. Their IP address (and thus geolocation) is never known to the
      recipients.
   2. If the originator sends two otherwise unrelated transactions, they
diff --git a/src/net.cpp b/src/net.cpp
index 91ddbb9d2f..d92cb72ccc 100644
--- a/src/net.cpp
+++ b/src/net.cpp
@@ -3239,8 +3239,7 @@ void CConnman::ThreadPrivateBroadcast()
         std::optional<Proxy> proxy;
         const std::optional<Network> net{m_private_broadcast.PickNetwork(proxy)};
         if (!net.has_value()) {
-            LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Warning,
-                          "Connections needed but none of the Tor or I2P networks is reachable");
+            LogWarning("[privatebroadcast] Connections needed but none of the Tor or I2P networks is reachable");
             m_interrupt_net->sleep_for(5s);
             continue;
         }
diff --git a/src/net_processing.cpp b/src/net_processing.cpp
index ec27aa2c99..dac0e3ac80 100644
--- a/src/net_processing.cpp
+++ b/src/net_processing.cpp
@@ -1652,17 +1652,16 @@ void PeerManagerImpl::ReattemptPrivateBroadcast(CScheduler& scheduler)
                          stale_tx->GetHash().ToString(), stale_tx->GetWitnessHash().ToString());
                 ++num_for_rebroadcast;
             } else {
-                LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                              "Giving up broadcast attempts for txid=%s wtxid=%s: %s",
-                              stale_tx->GetHash().ToString(), stale_tx->GetWitnessHash().ToString(),
-                              mempool_acceptable.m_state.ToString());
+                LogInfo("[privatebroadcast] Giving up broadcast attempts for txid=%s wtxid=%s: %s",
+                        stale_tx->GetHash().ToString(), stale_tx->GetWitnessHash().ToString(),
+                        mempool_acceptable.m_state.ToString());
                 m_tx_for_private_broadcast.Remove(stale_tx);
             }
         }
-    }
 
-    // This could overshoot, but that is ok - we will open some private connections in vain.
-    m_connman.m_private_broadcast.NumToOpenAdd(num_for_rebroadcast);
+        // This could overshoot, but that is ok - we will open some private connections in vain.
+        m_connman.m_private_broadcast.NumToOpenAdd(num_for_rebroadcast);
+    }
 
     const auto delta{2min + FastRandomContext().randrange<std::chrono::milliseconds>(1min)};
     scheduler.scheduleFromNow([&] { ReattemptPrivateBroadcast(scheduler); }, delta);
@@ -2007,7 +2006,9 @@ void PeerManagerImpl::StartScheduledTasks(CScheduler& scheduler)
     const auto delta = 10min + FastRandomContext().randrange<std::chrono::milliseconds>(5min);
     scheduler.scheduleFromNow([&] { ReattemptInitialBroadcast(scheduler); }, delta);
 
-    scheduler.scheduleFromNow([&] { ReattemptPrivateBroadcast(scheduler); }, 0min);
+    if (m_opts.private_broadcast) {
+        scheduler.scheduleFromNow([&] { ReattemptPrivateBroadcast(scheduler); }, 0min);
+    }
 }
 
 void PeerManagerImpl::ActiveTipChange(const CBlockIndex& new_tip, bool is_ibd)
@@ -3534,10 +3535,9 @@ void PeerManagerImpl::PushPrivateBroadcastTx(CNode& node)
     }
     const CTransactionRef& tx{*opt_tx};
 
-    LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                  "P2P handshake completed, sending INV for txid=%s%s, peer=%d%s",
-                  tx->GetHash().ToString(), tx->HasWitness() ? strprintf(", wtxid=%s", tx->GetWitnessHash().ToString()) : "",
-                  node.GetId(), node.LogIP(fLogIPs));
+    LogInfo("[privatebroadcast] P2P handshake completed, sending INV for txid=%s%s, peer=%d%s",
+            tx->GetHash().ToString(), tx->HasWitness() ? strprintf(", wtxid=%s", tx->GetWitnessHash().ToString()) : "",
+            node.GetId(), node.LogIP(fLogIPs));
 
     MakeAndPushMessage(node, NetMsgType::INV, std::vector<CInv>{{CInv{MSG_TX, tx->GetHash().ToUint256()}}});
 }
@@ -3676,9 +3676,8 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
             if (fRelay) {
                 MakeAndPushMessage(pfrom, NetMsgType::VERACK);
             } else {
-                LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                              "Disconnecting: does not support transactions relay (connected in vain), peer=%d%s",
-                              pfrom.GetId(), pfrom.LogIP(fLogIPs));
+                LogInfo("[privatebroadcast] Disconnecting: does not support transactions relay (connected in vain), peer=%d%s",
+                        pfrom.GetId(), pfrom.LogIP(fLogIPs));
                 pfrom.fDisconnect = true;
             }
             return;
@@ -4203,9 +4202,8 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
         if (pfrom.IsPrivateBroadcastConn()) {
             const auto pushed_tx_opt{m_tx_for_private_broadcast.GetTxForNode(pfrom.GetId())};
             if (!pushed_tx_opt) {
-                LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                              "Disconnecting: got GETDATA without sending an INV, peer=%d%s",
-                              pfrom.GetId(), fLogIPs ? strprintf(", peeraddr=%s", pfrom.addr.ToStringAddrPort()) : "");
+                LogInfo("[privatebroadcast] Disconnecting: got GETDATA without sending an INV, peer=%d%s",
+                        pfrom.GetId(), fLogIPs ? strprintf(", peeraddr=%s", pfrom.addr.ToStringAddrPort()) : "");
                 pfrom.fDisconnect = true;
                 return;
             }
@@ -4221,9 +4219,8 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
                 peer->m_ping_queued = true; // Ensure a ping will be sent: mimic a request via RPC.
                 MaybeSendPing(pfrom, *peer, GetTime<std::chrono::microseconds>());
             } else {
-                LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                              "Disconnecting: got an unexpected GETDATA message, peer=%d%s",
-                              pfrom.GetId(), fLogIPs ? strprintf(", peeraddr=%s", pfrom.addr.ToStringAddrPort()) : "");
+                LogInfo("[privatebroadcast] Disconnecting: got an unexpected GETDATA message, peer=%d%s",
+                        pfrom.GetId(), fLogIPs ? strprintf(", peeraddr=%s", pfrom.addr.ToStringAddrPort()) : "");
                 pfrom.fDisconnect = true;
             }
             return;
@@ -4467,10 +4464,9 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
         AddKnownTx(*peer, hash);
 
         if (const auto num_broadcasted{m_tx_for_private_broadcast.Remove(ptx)}) {
-            LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                          "Received our privately broadcast transaction (txid=%s) from the "
-                          "network from peer=%d%s; stopping private broadcast attempts",
-                          txid.ToString(), pfrom.GetId(), pfrom.LogIP(fLogIPs));
+            LogInfo("[privatebroadcast] Received our privately broadcast transaction (txid=%s) from the "
+                    "network from peer=%d%s; stopping private broadcast attempts",
+                    txid.ToString(), pfrom.GetId(), pfrom.LogIP(fLogIPs));
             if (NUM_PRIVATE_BROADCAST_PER_TX > num_broadcasted.value()) {
                 // Not all of the initial NUM_PRIVATE_BROADCAST_PER_TX connections were needed.
                 // Tell CConnman it does not need to start the remaining ones.
@@ -4984,10 +4980,8 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
                         pfrom.PongReceived(ping_time);
                         if (pfrom.IsPrivateBroadcastConn()) {
                             m_tx_for_private_broadcast.NodeConfirmedReception(pfrom.GetId());
-                            LogPrintLevel(
-                                BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                                "Got a PONG (the transaction will probably reach the network), marking for disconnect, peer=%d%s",
-                                pfrom.GetId(), pfrom.LogIP(fLogIPs));
+                            LogInfo("[privatebroadcast] Got a PONG (the transaction will probably reach the network), marking for disconnect, peer=%d%s",
+                                    pfrom.GetId(), pfrom.LogIP(fLogIPs));
                             pfrom.fDisconnect = true;
                         }
                     } else {
@@ -5702,10 +5696,8 @@ bool PeerManagerImpl::SendMessages(CNode* pto)
     // not sent. This here is just an optimization.
     if (pto->IsPrivateBroadcastConn()) {
         if (pto->m_connected + PRIVATE_BROADCAST_MAX_CONNECTION_LIFETIME < current_time) {
-            LogPrintLevel(
-                BCLog::PRIVBROADCAST, BCLog::Level::Info,
-                "Disconnecting: did not complete the transaction send within %d seconds, peer=%d%s",
-                count_seconds(PRIVATE_BROADCAST_MAX_CONNECTION_LIFETIME), pto->GetId(), pto->LogIP(fLogIPs));
+            LogInfo("[privatebroadcast] Disconnecting: did not complete the transaction send within %d seconds, peer=%d%s",
+                    count_seconds(PRIVATE_BROADCAST_MAX_CONNECTION_LIFETIME), pto->GetId(), pto->LogIP(fLogIPs));
             pto->fDisconnect = true;
         }
         return true;
diff --git a/src/net_processing.h b/src/net_processing.h
index ecfb6985af..09f348c86b 100644
--- a/src/net_processing.h
+++ b/src/net_processing.h
@@ -90,6 +90,8 @@ public:
         //! Number of headers sent in one getheaders message result (this is
         //! a test-only option).
         uint32_t max_headers_result{MAX_HEADERS_RESULTS};
+        //! Whether private broadcast is used for sending transactions.
+        bool private_broadcast{DEFAULT_PRIVATE_BROADCAST};
     };
 
     static std::unique_ptr<PeerManager> make(CConnman& connman, AddrMan& addrman,
diff --git a/src/node/peerman_args.cpp b/src/node/peerman_args.cpp
index 59dc592d66..9745d69d5a 100644
--- a/src/node/peerman_args.cpp
+++ b/src/node/peerman_args.cpp
@@ -23,6 +23,8 @@ void ApplyArgsManOptions(const ArgsManager& argsman, PeerManager::Options& optio
     if (auto value{argsman.GetBoolArg("-capturemessages")}) options.capture_messages = *value;
 
     if (auto value{argsman.GetBoolArg("-blocksonly")}) options.ignore_incoming_txs = *value;
+
+    if (auto value{argsman.GetBoolArg("-privatebroadcast")}) options.private_broadcast = *value;
 }
 
 } // namespace node

My interpretation of the changes:

  • Migrated previous LogPrintLevel(BCLog::PRIVBROADCAST, BCLog::Level::Warning log constructs to LogWarning("[privatebroadcast] after LogPrintLevel removal; this also compressed the diff by quite a few lines. BCLog::PRIVBROADCAST is still needed for remaining debug logs;
  • Guard immediate private broadcast (reattempt) by the feature being active m_opts.private_broadcast - newly stored in PeerManager::Options;
  • Don't needlessly notify listeners when 0 new txs should be reattempted;
  • Release notes rewording.

I'm happy to reack if further changes are applied, but I think it's also fine to do the nits in follow-ups.


self.destinations_lock = threading.Lock()

def destinations_factory(requested_to_addr, requested_to_port):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i got this intermittent failure, not sure what the cause might be.

Checking that rebroadcast works
Task exception was never retrieved
future: <Task finished name='Task-59' coro=<NetworkThread.create_listen_server() done, defined at /home/ubuntu/Projects/bitcoin/test/functional/test_framework/p2p.py:775> exception=OSError(98, "error while attempting to bind on address ('127.0.0.1', 24713): address already in use")>
Traceback (most recent call last):
  File "/home/ubuntu/Projects/bitcoin/test/functional/test_framework/p2p.py", line 796, in create_listen_server
    listener = await cls.network_event_loop.create_server(peer_protocol, addr, port)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/asyncio/base_events.py", line 1572, in create_server
    raise OSError(err.errno, msg) from None
OSError: [Errno 98] error while attempting to bind on address ('127.0.0.1', 24713): address already in use

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it be that there was another program listening on 127.0.0.1:24713, maybe not even bitcoin related?

Did you run just one functional test at the time, or two or more concurrently, e.g. in different terminals?

Can you reproduce with this patch and post the full log (e.g /tmp/bitcoin_func_test_.../test_framework.log:

[patch] add debug log before bind
diff --git i/test/functional/p2p_private_broadcast.py w/test/functional/p2p_private_broadcast.py
index 4c3739d8a0..4ae33853b9 100755
--- i/test/functional/p2p_private_broadcast.py
+++ w/test/functional/p2p_private_broadcast.py
@@ -212,12 +212,13 @@ class P2PPrivateBroadcast(BitcoinTestFramework):
                     def on_listen_done(addr, port):
                         nonlocal actual_to_addr
                         nonlocal actual_to_port
                         actual_to_addr = addr
                         actual_to_port = port
 
+                    self.log.debug(f"Binding on 127.0.0.1:{ports_base + i} to serve connection to proxy i={i}")
                     self.network_thread.listen(
                         addr="127.0.0.1",
                         port=ports_base + i,
                         p2p=listener,
                         callback=on_listen_done)
                     # Wait until the callback has been called.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The below patch enables dynamic port allocation and prevents this kind of error.

diff --git a/test/functional/p2p_private_broadcast.py b/test/functional/p2p_private_broadcast.py
index 4c3739d8a0..803444ccba 100755
--- a/test/functional/p2p_private_broadcast.py
+++ b/test/functional/p2p_private_broadcast.py
@@ -36,7 +36,6 @@ from test_framework.test_framework import (
     BitcoinTestFramework,
 )
 from test_framework.util import (
-    MAX_NODES,
     assert_equal,
     assert_not_equal,
     assert_raises_rpc_error,
@@ -181,9 +180,6 @@ class P2PPrivateBroadcast(BitcoinTestFramework):
         self.socks5_server = Socks5Server(socks5_server_config)
         self.socks5_server.start()
 
-        # Tor ports are the highest among p2p/rpc/tor, so this should be the first available port.
-        ports_base = tor_port(MAX_NODES) + 1
-
         self.destinations = []
 
         self.destinations_lock = threading.Lock()
@@ -215,9 +211,12 @@ class P2PPrivateBroadcast(BitcoinTestFramework):
                         actual_to_addr = addr
                         actual_to_port = port
 
+                    # Use port=0 to let the OS assign an available port. This
+                    # avoids "address already in use" errors when tests run
+                    # concurrently or ports are still in TIME_WAIT state.
                     self.network_thread.listen(
                         addr="127.0.0.1",
-                        port=ports_base + i,
+                        port=0,
                         p2p=listener,
                         callback=on_listen_done)
                     # Wait until the callback has been called.
diff --git a/test/functional/test_framework/p2p.py b/test/functional/test_framework/p2p.py
index 346a7c6526..74f6262fc7 100755
--- a/test/functional/test_framework/p2p.py
+++ b/test/functional/test_framework/p2p.py
@@ -794,8 +794,12 @@ class NetworkThread(threading.Thread):
             # `proto` functions
 
             listener = await cls.network_event_loop.create_server(peer_protocol, addr, port)
-            logger.debug("Listening server on %s:%d should be started" % (addr, port))
-            cls.listeners[(addr, port)] = listener
+            # When port=0, the OS assigns an available ephemeral port. Retrieve
+            # the actual port so callers know where the server is listening.
+            actual_port = listener.sockets[0].getsockname()[1]
+            logger.debug("Listening server on %s:%d should be started" % (addr, actual_port))
+            cls.listeners[(addr, actual_port)] = listener
+            port = actual_port
 
         cls.protos[(addr, port)] = proto
         callback(addr, port)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the test code is correct, and I think the comment is wrong as well:

        # Tor ports are the highest among p2p/rpc/tor, so this should be the first available port.
        ports_base = tor_port(MAX_NODES) + 1

This will not pick "the first available port". Port ranges are tightly packed, so this will just pick the first used port of the next test. The test failure here is expected.

Generally, it is only possible to use ports that are assigned to the test itself. One way to achieve that is by calling tor_port(self.num_nodes) (or self.num_nodes + i). This way, the port is known to be unused, because that node does not exist. See also #33260

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The below patch enables dynamic port allocation

The patch above works and looks reasonable.

One way to achieve that is by calling tor_port(self.num_nodes)

This works as well:

-        ports_base = tor_port(MAX_NODES) + 1
+        ports_base = tor_port(self.num_nodes)

I will address in a followup.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Followup in #34300

@DrahtBot DrahtBot requested a review from naiyoma December 19, 2025 10:08
@andrewtoth
Copy link
Contributor

ACK 8937221

@l0rinc git range-diff 808f1d972be35f4c66bdc30ab0f4064dab0c43c0..662270f5b8d96434faec9e6f55f566f4f2ba1261 13891a8a685d255cb13dd5018e3d5ccc18b07c34..89372213048adf37a47427112a1ff836ee84c50e is the proper command.

It shows the changes:

  • Update non-Debug level LogPrintLevel to LogInfo and LogWarning due to silent conflict.
  • Don't add num_to_broadcast if there are no stale txs (nit: could check instead if num_to_broadcast > 0 since there could be stale txs that all get removed).
  • Track private_broadcast bool in PeerManagerImpl opts and don't bother scheduling ReattemptPrivateBroadcast if it's unset.

Copy link
Contributor

@l0rinc l0rinc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went over the code again (not just the diff) and noticed a few things I'd like to get clarity on. I don't mind if you decide to tackle these in follow-ups, since I'm not 100% sure about them.

}
const CTransactionRef& tx{*opt_tx};

LogInfo("[privatebroadcast] P2P handshake completed, sending INV for txid=%s%s, peer=%d%s",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not exactly sure what the protocol is here - is it okay to print sensitive data to console?

Given that this is a private tx, the user shouldn't share these with others - is that obvious to the user? Anyone sharing debug.log would leak which transactions they originated...

We do have a dedicated -debug=privatebroadcast category, should this be LogDebug instead?
Or have a LogInfo without details followed by a logdebug with the txid?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting angle. I did not think about this before. Local txid is no more secret if we use private broadcast compared to if we use current broadcast-to-all method. Do we ever log local txids with broadcast-to-all? I only found a debug log in CTxMemPool::RemoveUnbroadcastTx().

should this be LogDebug instead?

What would be the rationale - that it is ok to log semi-sensitive information with LogDebug() and not with LogInfo()? But then if a user shares their debug.log, then they might do that with or without enabling debug messages.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that it is ok to log semi-sensitive information with LogDebug() and not with LogInfo()?

Yes, that's a deliberate choice


I think this should be done in a follow-up - we may want to desensitise other logs as well while we're there...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Congrats on the merge <3
Added this as a follow-up: #34267

Copy link
Contributor

@w0xlt w0xlt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 8937221 with nit #29415 (comment)

Copy link
Contributor

@mzumsande mzumsande left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-ACK 8937221

@ryanofsky ryanofsky self-assigned this Jan 12, 2026
@ryanofsky ryanofsky merged commit 796f18e into bitcoin:master Jan 12, 2026
25 checks passed
l0rinc added a commit to l0rinc/bitcoin that referenced this pull request Jan 12, 2026
Private broadcast is a privacy feature, and users may share `debug.log` for support.
Unconditional log messages about private broadcast (and especially those including a `(w)txid`) can leak which transactions a user originated and/or that they used private broadcast.

Log private broadcast events with `LogDebug(BCLog::PRIVBROADCAST, ...)` so they are only emitted when `-debug=privatebroadcast` was explictly enabled.
Keep the Tor/I2P reachability warning unconditional, but phrase it without a dedicated "[privatebroadcast]" tag.

Ref: bitcoin#29415 (comment)
l0rinc added a commit to l0rinc/bitcoin that referenced this pull request Jan 12, 2026
Private broadcast is a privacy feature, and users may share `debug.log` for support.
Unconditional log messages about private broadcast (and especially those including a `(w)txid`) can leak which transactions a user originated and/or that they used private broadcast.

Log private broadcast events with `LogDebug(BCLog::PRIVBROADCAST, ...)` so they are only emitted when `-debug=privatebroadcast` was explicitly enabled.
Keep the Tor/I2P reachability warning unconditional, but phrase it without a dedicated "[privatebroadcast]" tag.

Ref: bitcoin#29415 (comment)
Copy link
Contributor

@janb84 janb84 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

post merge cr ACK 8937221

Great privacy enhancement PR !

vasild added a commit to vasild/bitcoin that referenced this pull request Jan 13, 2026
Make `PeerManagerImpl::m_tx_for_private_broadcast` optional because it
is needed for in private broadcast mode (`-privatebroadcast=1`).

Requested in:
bitcoin#29415 (comment)
https://github.com/bitcoin/bitcoin/pull/29415/files#r2620864832
vasild added a commit to vasild/bitcoin that referenced this pull request Jan 13, 2026
Make `PeerManagerImpl::m_tx_for_private_broadcast` optional because it
is needed for in private broadcast mode (`-privatebroadcast=1`).

Requested in:
bitcoin#29415 (comment)
https://github.com/bitcoin/bitcoin/pull/29415/files#r2620864832
achow101 added a commit that referenced this pull request Jan 14, 2026
ce63d37 test: use dynamic port allocation to avoid test conflicts (woltx)

Pull request description:

  Use `port=0` for dynamic port allocation in test framework components to avoid intermittent "address already in use" errors when running tests concurrently or when ports are stuck in TIME_WAIT state. Example: #29415 (comment)

  Changes:

    - Update `socks5.py` and `p2p.py` to support dynamic port allocation
    - Convert `feature_proxy.py` and `feature_anchors.py` to use `port=0`

ACKs for top commit:
  achow101:
    ACK ce63d37
  vasild:
    ACK ce63d37
  mzumsande:
    re-ACK ce63d37

Tree-SHA512: 4efcedca3bde209fbd1bdc2a4ae04b7d53515587d86e421ce61064f78c675c71b45d9782b514c5e7cfc0e92842c947d49f7a3fddb03fe619fcdec9b565f0ecbd
fanquake added a commit that referenced this pull request Jan 15, 2026
3e34067 test: use ephemeral ports in p2p_private_broadcast.py (w0xlt)

Pull request description:

  The test `p2p_private_broadcast.py` gets some Python P2P nodes to listen and instructs the SOCKS5 proxy to redirect connections to them instead of to the requested addresses. This way the `bitcoind` which uses the proxy is tricked to think it has connected to real routable internet IP addresses or `.onion` addresses.

  Picking the ports where to Python P2P nodes to listen however is tricky to be done in a non-conflicting way, given that other tests may run in parallel. #34186 made it possible to let the OS select a free port, so use that in
  `p2p_private_broadcast.py`.

  ---

  _Suggested in #29415 (comment)

ACKs for top commit:
  l0rinc:
    code review ACK 3e34067
  polespinasa:
    tACK 3e34067
  mzumsande:
    utACK 3e34067

Tree-SHA512: e94efd33a1845e1767aaada55f91c60bc5fc1166c281ef578a391e95e2791a922d84aa6ed1ce06e7d6ca1a65f84da52fd79d9b2f40705c1944a53c67b7392e4d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.