Skip to content

Conversation

vasild
Copy link
Contributor

@vasild vasild commented Feb 9, 2024

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 (if we take the last commit: fixup!)
tx-sender <--- GETDATA/TX ----< tx-recipient (if we take the last commit: fixup!)
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 10-15 mins 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: support overriding the proxy selection in ConnectNode()
    • net: implement opening PRIVATE_BROADCAST connections
  • Prepare BroadcastTransaction() for private broadcast requests:

    • net_processing: rename RelayTransaction to better describe what it does
    • node: change a tx-relay on/off flag to a tri-state
    • 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: 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
  • Independent test framework improvement:

    • test: move create_malleated_version() to messages.py for reuse
  • New functional test that exercies some of the new code:

    • test: add functional test for local tx relay
  • Add an intermediate step that sends INV message and waits for a request for the transaction. If reviewers like this, then I will squash it into the relevant commit, or otherwise drop it:

    • fixup! net_processing: handle ConnectionType::PRIVATE_BROADCAST connections

This PR would close 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 closed 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.

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
Concept ACK zzzi2p, nothingmuch, jonatack, kdmukai, kevkevinpal, RandyMcMillan
Stale ACK pinheadmz, mzumsande, andrewtoth

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #33300 (fuzz: compact block harness by Crypt-iQ)
  • #33231 (net: Prevent node from binding to the same CService by w0xlt)
  • #33191 (net: Provide block templates to peers on request by ajtowns)
  • #32747 (Introduce SockMan ("lite"): low-level socket handling for HTTP by pinheadmz)
  • #32547 (Mining: Avoid copying template CBlocks by luke-jr)
  • #32394 (net: make m_nodes_mutex non-recursive by vasild)
  • #32326 (net: improve the interface around FindNode() and avoid a recursive mutex lock by vasild)
  • #32065 (i2p: make a time gap between creating transient sessions and using them by vasild)
  • #30595 (kernel: Introduce initial C header API by TheCharlatan)
  • #30277 ([DO NOT MERGE] Erlay: bandwidth-efficient transaction relay protocol (Full implementation) by sr-gi)
  • #30116 (p2p: Fill reconciliation sets (Erlay) attempt 2 by sr-gi)
  • #29278 (Wallet: Add maxfeerate wallet startup option by ismaelsadeeq)
  • #28690 (build: Introduce internal kernel library by TheCharlatan)
  • #28584 (Fuzz: extend CConnman tests by vasild)
  • #28463 (p2p: Increase inbound capacity for block-relay only connections by mzumsande)
  • #27865 (wallet: Track no-longer-spendable TXOs separately 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
@DrahtBot DrahtBot requested a review from jonatack August 13, 2025 22:07
vasild and others added 14 commits September 1, 2025 10:33
Co-authored-by: brunoerg <brunoely.gc@gmail.com>
We will open a short-lived connection to a random Tor or I2P peer,
send our transaction to that peer and close the connection.
Normally `ConnectNode()` could choose whether to use a proxy and which
one. Make it possible to override this from the callers and same for
`OpenNetworkConnection()` - pass down the proxy to `ConnectNode()`.

Document both functions.
Implement opening `ConnectionType::PRIVATE_BROADCAST` connections with
the following properties:
* Only to Tor or I2P (or IPv4/IPv6 through the Tor proxy, if provided)
* Open such connections only when requested and don't maintain N opened
  connections of this type.
* Since this is substantially different than what
  `OpenNetworkConnection()` does, open the private broadcast connections
  from a different thread instead of modifying `OpenNetworkConnection()`
  to also open those types of connections.
Rename `PeerManager::RelayTransaction()` to
`PeerManager::ScheduleTxForBroadcastToAll()`. The transaction is not relayed
when the method returns. It is only scheduled for broadcasting at a later
time. Also, there will be another method which only schedules for broadcast
to Tor or I2P peers.
Previously the `bool relay` argument to `BroadcastTransaction()`
designated:

```
relay=true: add to the mempool and broadcast to all peers
relay=false: add to the mempool
```

Extend this with a third option to not add the transaction to the
mempool and broadcast privately.

This is a non-functional change - the new third option is not handled
inside `BroadcastTransaction()` and is not used by any of the callers.

The idea for the new `node/types.h` and the comments in it by Ryan.

Co-authored-by: Ryan Ofsky <ryan@ofsky.org>
Extend `PeerManager` with a transaction storage and a new method
`ScheduleTxForPrivateBroadcast()` which:
* adds a transaction to that storage and
* calls `CConnman::PrivateBroadcastAdd()` to open dedicated privacy
  connections that will pick an entry from the transaction storage and
  broadcast it.
Change the order in which code snippets are executed as a result of
receiving the `VERSION` message. Move the snippets that do
`MakeAndPushMessage()` near the end. This will help with handling of
private broadcast connections - they do not require any of that.

This is a non-functional change.
For connections of type `ConnectionType::PRIVATE_BROADCAST`:
* After receiving VERACK, relay a transaction from the list of
  transactions for private broadcast and disconnect
* Don't process any messages after VERACK
* Don't send any messages other than the minimum required for the
  transaction relay
Remove the transaction from the list of transactions to broadcast after
we receive it from the network.

Only remove the transaction if it is the same as the one we sent: both
txid and wtxid match. Don't remove transactions that have the same txid
and different wxtid. Such transactions show that some of the private
broadcast recipients malleated the witness and the transaction made it
back to us. The witness could be either:
* invalid, in which case the transaction will not be accepted in
  anybody's pool; or
* valid, in which case either the original or the malleated transaction
  will make it to nodes' mempools and eventually be mined. Our response
  is to keep broadcasting the original. If the malleated transaction
  wins then we will eventually stop broadcasting the original when it
  gets stale and gets removed from the "to broadcast" storage cause it
  is not acceptable in our mempool.
Periodically check for stale transactions in peerman and if found,
reschedule new connections to be opened by connman for broadcasting
them.
@vasild
Copy link
Contributor Author

vasild commented Sep 1, 2025

dbd76e68c3...2bd4714fa9: rebase due to conflicts and address #29415 (comment)

I am back from some afk time.

@vasild
Copy link
Contributor Author

vasild commented Sep 3, 2025

2bd4714fa9...8fbff5233f: extend the functional test to send two transactions that have the same txid but different wtxid, made possible by #32385. Thank you, @stratospher!

The functional test has grown quite extensive. It is a further load on reviewers. That is in the last two commits. If you don't feel like reviewing it, you can omit those last two commits and review up to bb814cd rpc: use private broadcast from sendrawtransaction RPC if -privatebroadcast is ON. In that case I am going to remove the last two commits from here and put them into a separate PR.

@vasild
Copy link
Contributor Author

vasild commented Sep 3, 2025

8fbff5233f...4327327dd0: fix python linter f-string without any placeholders

@DrahtBot
Copy link
Contributor

DrahtBot commented Sep 3, 2025

🚧 At least one of the CI tasks failed.
Task lint: https://github.com/bitcoin/bitcoin/runs/49482001469
LLM reason (✨ experimental): (empty)

Hints

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.

@andrewtoth
Copy link
Contributor

andrewtoth commented Sep 5, 2025

I tried sending a tx with fee rate 0.1 sat/vbyte using 4327327.

I connected to 3 nodes and disconnected after receiving the PONG, and then attempted one more connection every 10 minutes.
I finally received the tx back from another peer after 13 hours and 90 connection attempts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.