Skip to content

wallet: add private broadcast support for wallet transactions#34457

Open
w0xlt wants to merge 5 commits intobitcoin:masterfrom
w0xlt:wprv_29012
Open

wallet: add private broadcast support for wallet transactions#34457
w0xlt wants to merge 5 commits intobitcoin:masterfrom
w0xlt:wprv_29012

Conversation

@w0xlt
Copy link
Contributor

@w0xlt w0xlt commented Jan 30, 2026

Summary

Extends the private broadcast feature (#29415) to wallet transactions. When -privatebroadcast=1 is enabled, wallet RPCs (sendtoaddress, send, sendall, sendmany) now broadcast transactions through short-lived Tor/I2P connections instead of announcing to all connected peers.

EDIT: The per-transaction private_broadcast flag in mapValue of the original proposal has been removed (rationale: #34533 (review)).

Previous approach

Key Changes

Centralized availability check (commit 1)

  • Moves the Tor/I2P reachability check from sendrawtransaction RPC to BroadcastTransaction()
  • All broadcast callers now get consistent error handling

Wallet integration (commit 2)

  • CommitTransaction(): Uses private broadcast when enabled, fails loudly if Tor/I2P unavailable
  • ResubmitWalletTransactions(): Rebroadcasts using the original broadcast method
  • Persists a private_broadcast flag in the wallet transaction's mapValue

Functional tests (commits 3-4)

  • Tests wallet send via SOCKS5 proxy
  • Tests flag persistence across restarts
  • Tests error handling when Tor/I2P unavailable

Behavior Matrix

Scenario TX Flag Node Setting Expected Behavior Tested
New tx Private - Via SOCKS5, flag set Test 1 ✓
Resubmit Private Public Skip Test 2 ✓
Resubmit Public Private Skip Test 3 ✓
Resubmit Private Private Rebroadcast Test 3 ✓
New tx Private - (no Tor) RPC error Test 4 ✓
Resubmit Private Private (no Tor) Log error Test 4 ✓

Why skip on mode mismatch? Rebroadcasting via a different method than originally used may allow correlation of transaction to origin, or may indicate origin has -privatebroadcast enabled.

@DrahtBot
Copy link
Contributor

DrahtBot commented Jan 30, 2026

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

Reviews

See the guideline for information on the review process.

Type Reviewers
Concept ACK andrewtoth, Bicaru20

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:

  • #34684 (refactor: Enable -Wswitch in exhaustive switch'es, Enable -Wcovered-switch-default by maflcko)
  • #34533 (wallet: resubmit transactions with private broadcast if enabled by vasild)
  • #34410 (test: let connections happen in any order in p2p_private_broadcast.py by vasild)
  • #34038 (logging: replace -loglevel with -trace, various API improvements by ajtowns)
  • #33954 (test: add functional test for outbound connection management by mzumsande)
  • #30343 (wallet, logging: Replace WalletLogPrintf() with LogInfo() by ryanofsky)
  • #29278 (Wallet: Add maxfeerate wallet startup option by ismaelsadeeq)

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.

LLM Linter (✨ experimental)

Possible places where named args for integral literals may be used (e.g. func(x, /*named_arg=*/0) in C++, and func(x, named_arg=0) in Python):

  • assert_raises_rpc_error(-1, "none of the Tor or I2P networks is reachable", self.sender_wallet.sendtoaddress, dest, 0.1) in test/functional/wallet_private_broadcast.py

2026-02-23 06:43:46

@andrewtoth
Copy link
Contributor

Concept ACK

I don't think we need to skip rebroadcast if we initially created the transaction with private broadcast disabled. This is only important the other way - if we initially broadcast via private broadcast, but then restart with private broadcast disabled.

may indicate origin has -privatebroadcast enabled.

Ideally every node has this enabled in the future. But also, one cannot conclude this if they receive a tx both from a persistently connected node and then later via a private broadcast. This would actually make the original broadcast more private, since this would indicate that perhaps the originator is not actually the originator.

Sending decoys will also help here.

@w0xlt
Copy link
Contributor Author

w0xlt commented Jan 31, 2026

@andrewtoth Thanks for the insight about plausible deniability.

Updated to only skip private→public (the other direction is now allowed):

Scenario TX Flag Node Setting Behavior
Resubmit Private Public Skip
Resubmit Public Private Rebroadcast privately
Resubmit Private Private Rebroadcast privately
  • Private→Public: Skip - Protects origin from being correlated
  • Public→Private: Allow - Provides plausible deniability

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.

Some conceptual thoughts:

  • The rebroadcasting behavior will probably lead to frequent 3 minute timeouts. If the tx didn't get mined within ~24 hours due to insufficient fees, but remains in our and our peer's mempools during this time, the peers wont's send us a GETDATA to our repeated submission. However, I think that this would not be a huge problem because after 3 attempts, we'd abort during the retry because we check there if the tx is already in the mempool.
  • while this improves the rebroadcast behavior, there is still the issue that transactions relevant for the wallet but submitted via RPC (sendrawtransaction) will be received back from the network, added to mapWallet, and then rebroadcast over clearnet if the user restarted without -privatebroadcast. In master, these txns would be rebroadcast normally over clearnet even without a restart.
  • should something be done about feebumping behavior? I think a user sending via private broadcast, restarting without the flag and then bumping a tx would lead to the tx being sent via clearnet.

@DrahtBot
Copy link
Contributor

DrahtBot commented Feb 7, 2026

🚧 At least one of the CI tasks failed.
Task lint: https://github.com/bitcoin/bitcoin/actions/runs/21778174634/job/62838065557
LLM reason (✨ experimental): Lint failure: trailing whitespace detected in Python code (ruff/trailing_whitespace).

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.

@w0xlt
Copy link
Contributor Author

w0xlt commented Feb 7, 2026

I think that this would not be a huge problem because after 3 attempts, we'd abort during the retry

Agreed.

there is still the issue that transactions relevant for the wallet but submitted via RPC (sendrawtransaction) will be received back from the network

As far as I can see there is no simple solution to this scenario. Even if we have a persistent database of privately broadcast transactions (like proposed in #34322) and the wallet could query it when a relevant transaction is received, the user can submit the transaction from a different node than the wallet's node. One approach would be for the wallet to mark all IsFromMe() transactions not originated through CommitTransaction() as private. Not sure if that is an acceptable solution but it would cover this case unless I am missing something.

should something be done about feebumping behavior?

Added a solution and a functional test for this case. Thanks for raising it.

@w0xlt
Copy link
Contributor Author

w0xlt commented Feb 7, 2026

CI error is unrelated

@Bicaru20
Copy link

Concept ACK

One approach would be for the wallet to mark all IsFromMe() transactions not originated through CommitTransaction() as private

I was worried about using this approach for one case:

  • If a user imports a wallet into another node (let's call it new node) from which the transaction was originated (original node), the new wallet would not know if the transaction was originated through CommitTransaction() or not.

Buut I modified the wallet_private_broadcast.py to test if that happened, but when you import the wallet to another node (the new node ), the information about whether if an unconfirmed transaction it is using a private broadcast or not is kept if the transaction was sent (from the original node) before importing the wallet to the new node .

The only case where it might cause trouble is if a user already has the same wallet in both the original node and new node and then sends a private transaction from one of them. Then the other one will not know whether the transaction is private or not. It is a weird case, and I am not even sure it would behave like this (I have not tested it).

I also left some comments in wallet_private_breadcast.py about an error I encountered.

Comment on lines +393 to +398
self.restart_node(0, extra_args=[
"-privatebroadcast=1",
"-v2transport=0",
"-listenonion",
"-torcontrol=127.0.0.1:1",
])

Choose a reason for hiding this comment

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

Suggested change
self.restart_node(0, extra_args=[
"-privatebroadcast=1",
"-v2transport=0",
"-listenonion",
"-torcontrol=127.0.0.1:1",
])
time.wait(0.1)
self.restart_node(0, extra_args=[
"-privatebroadcast=1",
"-v2transport=0",
"-listenonion",
"-torcontrol=127.0.0.1:1",
])

I had this error:

    TestFramework.socks5 (ERROR): socks5 request handling failed.
    Traceback (most recent call last):
    File "/home/bitcoin/test/functional/test_framework/socks5.py", line 185, in handle
    forward_sockets(self.conn, conn_to)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
    File "/home/bitcoin/test/functional/test_framework/socks5.py", line 68, in forward_sockets
    data = s.recv(4096)
  ConnectionResetError: [Errno 104] Connection reset by peer

After investigating, by putting the time.sleep(0.1) I was able to solve it. I believe the error is because the proxy does not have enough time to create the connexion.

PS: I am not sure if putting a time.sleep is the best approach.

Comment on lines +444 to +445
self.log.info("All wallet private broadcast tests passed!")

Choose a reason for hiding this comment

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

I had the same error after the log message Stopping nodes. I've solved it doing the same.

Suggested change
self.log.info("All wallet private broadcast tests passed!")
self.log.info("All wallet private broadcast tests passed!")
time.sleep(0.1)

@w0xlt
Copy link
Contributor Author

w0xlt commented Feb 11, 2026

@Bicaru20 Thanks for the review.

Regarding the wallet import, I believe there shouldn’t be any issues since the private flag persists.
As for the test error, it appears to be the same as in #34387. A solution was provided in #34410.
I’ll update the PR with the same solution shortly.

@w0xlt
Copy link
Contributor Author

w0xlt commented Feb 11, 2026

The only case where it might cause trouble is if a user already has the same wallet in both the original node and new node and then sends a private transaction from one of them.

@Bicaru20 Yes, in this case, the wallet that didn’t originate the transaction has no way of knowing how it was broadcast after receiving it through the mempool. That’s why I suggested that such a wallet, for example, should treat any transaction received via the mempool with owned inputs as private.

Regarding the import, I assumed you were backing up the wallet file and restoring it. If you restore the wallet using importdescriptors, there’s no way to determine how the unconfirmed transactions were originally broadcast.

@Bicaru20
Copy link

That’s why I suggested that such a wallet, for example, should treat any transaction received via the mempool with owned inputs as private.

Okay. I don't know why I understood another thing, my bad. Thanks for the clarification. I agree with your approach, then. It’s better to be much too private than too little.

Regarding the import, I assumed you were backing up the wallet file and restoring it

Yes, I just did a quick test to see the behavior in this case.

…on()

Move the Tor/I2P network reachability check from the sendrawtransaction RPC
to BroadcastTransaction(). This centralizes the check so that all callers
(including wallet RPCs) get consistent behavior when using private broadcast.

Changes:
- Add TransactionError::PRIVATE_BROADCAST_UNAVAILABLE enum value
- Add corresponding error message in common/messages.cpp
- Add RPC error code mapping in rpc/util.cpp
- Move the check from rpc/mempool.cpp to node/transaction.cpp
- Set err_string when the error occurs for proper error propagation
CommitTransaction() can throw std::runtime_error (e.g., on wallet DB
write failure), but sendButtonClicked() did not catch it, which would
crash the GUI. Add a try-catch around sendCoins() to display the error
in a message box instead.
Enable private broadcast for wallet transaction submission when
-privatebroadcast is set.

Changes to CommitTransaction():
- Fail early before AddToWallet() when -privatebroadcast is enabled but
  Tor/I2P is unreachable, so no wallet state is modified on permanent failure
- Use NO_MEMPOOL_PRIVATE_BROADCAST method when -privatebroadcast enabled

Changes to MaybeResendWalletTxs():
- Use private broadcast for resubmission when -privatebroadcast enabled,
  so periodic rebroadcasts also go through Tor/I2P
Add helper utilities to test_framework/socks5.py to reduce code duplication
in private broadcast tests:

- Socks5ProxyHelper: Simplifies SOCKS5 proxy setup with ephemeral ports,
  thread-safe destination tracking, and simple redirect factory
- add_addresses_to_addrman(): Helper to populate a node's addrman with
  test addresses
- FAKE_ONION_ADDRESSES: Shared list of fake .onion addresses for tests

Update p2p_private_broadcast.py to use ephemeral ports and the new
add_addresses_to_addrman helper.
Add a functional test for wallet-specific private broadcast behavior:

- Test 1: sendtoaddress with -privatebroadcast sends via SOCKS5 proxy
- Test 2: Error when Tor/I2P not reachable
- Test 3: Rebroadcast with -privatebroadcast uses private broadcast method
- Test 4: Rebroadcast without -privatebroadcast uses normal method

Uses Socks5ProxyHelper for simplified proxy setup and connection tracking.
@w0xlt
Copy link
Contributor Author

w0xlt commented Feb 23, 2026

Force-pushed with a simplified approach (rationale: #34533 (review)). The per-transaction private_broadcast flag in mapValue has been removed — the broadcast method is now purely a function of the current -privatebroadcast setting.

This simplification aligns with #34533 (comment) approach: "Hmm, as a user, this is what I would expect - if restarted with -privatebroadcast=0, then the wallet rebroadcasts should be done using the send-to-all method ("via the mempool"). I would expect that because I have switched off private broadcast."

Changes against master (5 commits):

  1. Centralize Tor/I2P reachability check — moved from sendrawtransaction RPC into BroadcastTransaction() so all callers (including wallet) get consistent behavior. Added TransactionError::PRIVATE_BROADCAST_UNAVAILABLE.
  2. Qt fix — sendButtonClicked() didn't catch std::runtime_error from CommitTransaction(), which would crash the GUI. Pre-existing bug, but now more reachable.
  3. Wallet integration — CommitTransaction() uses NO_MEMPOOL_PRIVATE_BROADCAST when -privatebroadcast is set, with an early-fail before AddToWallet() when Tor/I2P is unreachable. MaybeResendWalletTxs() uses private broadcast for periodic resubmission when the flag is set.
  4. Test infrastructure + functional test — Socks5ProxyHelper, add_addresses_to_addrman, FAKE_ONION_ADDRESSES helpers, and wallet_private_broadcast.py testing: send via SOCKS5 proxy, error on Tor/I2P unavailable, rebroadcast with/without -privatebroadcast.

@andrewtoth
Copy link
Contributor

Why was this marked draft?

@w0xlt w0xlt marked this pull request as ready for review February 25, 2026 02:18
@w0xlt
Copy link
Contributor Author

w0xlt commented Feb 25, 2026

I moved it to draft due to the discussion about the transaction private flag. It’s now marked as ready.

for (const std::shared_ptr<CWallet>& pwallet : GetWallets(context)) {
if (!pwallet->ShouldResend()) continue;
pwallet->ResubmitWalletTransactions(node::TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL, /*force=*/false);
const bool private_broadcast{gArgs.GetBoolArg("-privatebroadcast", DEFAULT_PRIVATE_BROADCAST)};
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm so this is done in #34533, except the method is hoisted above the loop.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, the two PRs overlap in the MaybeResendWalletTxs method.
After #34533 is merged, I’ll rebase on top of it.

@DrahtBot
Copy link
Contributor

DrahtBot commented Mar 2, 2026

🐙 This pull request conflicts with the target branch and needs rebase.

@DrahtBot DrahtBot removed the CI failed label Mar 2, 2026
@rkrux
Copy link
Contributor

rkrux commented Mar 13, 2026

Based on an initial glance:

  1. 3b3f319 "qt: handle CommitTransaction exceptions in send dialog" can be a separate PR, it's pretty self contained.
  2. dc2c27f "test: add Socks5ProxyHelper and add_addresses_to_addrman helpers" can be a separate PR as well. Would it be possible to use Socks5ProxyHelper class in p2p_private_broadcast test? The newly added start_with_custom_factory method in the class is unused at the moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants