wallet: add private broadcast support for wallet transactions #34457

pull w0xlt wants to merge 5 commits into bitcoin:master from w0xlt:wprv_29012 changing 11 files +510 −45
  1. w0xlt commented at 8:06 am on January 30, 2026: contributor

    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#pullrequestreview-3829186494).

    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.

  2. DrahtBot added the label Wallet on Jan 30, 2026
  3. DrahtBot commented at 8:07 am on January 30, 2026: contributor

    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

  4. DrahtBot added the label Needs rebase on Jan 30, 2026
  5. andrewtoth commented at 9:29 pm on January 30, 2026: 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.

  6. w0xlt force-pushed on Jan 31, 2026
  7. w0xlt commented at 3:26 am on January 31, 2026: contributor

    @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
  8. DrahtBot removed the label Needs rebase on Jan 31, 2026
  9. mzumsande commented at 4:01 pm on February 2, 2026: contributor

    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.
  10. w0xlt force-pushed on Feb 7, 2026
  11. w0xlt force-pushed on Feb 7, 2026
  12. DrahtBot added the label CI failed on Feb 7, 2026
  13. DrahtBot commented at 10:00 am on February 7, 2026: contributor

    🚧 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).

    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.

  14. w0xlt commented at 10:13 am on February 7, 2026: contributor

    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.

  15. w0xlt commented at 10:15 am on February 7, 2026: contributor
    CI error is unrelated
  16. w0xlt force-pushed on Feb 7, 2026
  17. w0xlt force-pushed on Feb 7, 2026
  18. w0xlt force-pushed on Feb 7, 2026
  19. w0xlt force-pushed on Feb 7, 2026
  20. DrahtBot removed the label CI failed on Feb 7, 2026
  21. in test/functional/wallet_private_broadcast.py:398 in 3645cccf8a
    393+        self.restart_node(0, extra_args=[
    394+            "-privatebroadcast=1",
    395+            "-v2transport=0",
    396+            "-listenonion",
    397+            "-torcontrol=127.0.0.1:1",
    398+        ])
    


    Bicaru20 commented at 8:08 pm on February 11, 2026:
    0        time.wait(0.1)
    1        self.restart_node(0, extra_args=[
    2            "-privatebroadcast=1",
    3            "-v2transport=0",
    4            "-listenonion",
    5            "-torcontrol=127.0.0.1:1",
    6        ])
    

    I had this error:

    0    TestFramework.socks5 (ERROR): socks5 request handling failed.
    1    Traceback (most recent call last):
    2    File "/home/bitcoin/test/functional/test_framework/socks5.py", line 185, in handle
    3    forward_sockets(self.conn, conn_to)
    4    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
    5    File "/home/bitcoin/test/functional/test_framework/socks5.py", line 68, in forward_sockets
    6    data = s.recv(4096)
    7  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.

  22. in test/functional/wallet_private_broadcast.py:445 in 3645cccf8a outdated
    440+        self.test_error_when_tor_i2p_unavailable()
    441+        self.test_bumpfee_refused_without_private_broadcast()
    442+        self.test_bumpfee_with_private_broadcast()
    443+
    444+        self.log.info("All wallet private broadcast tests passed!")
    445+
    


    Bicaru20 commented at 8:12 pm on February 11, 2026:

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

    0        self.log.info("All wallet private broadcast tests passed!")
    1        time.sleep(0.1)
    
  23. Bicaru20 commented at 8:22 pm on February 11, 2026: none

    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.

  24. w0xlt commented at 8:35 pm on February 11, 2026: contributor

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

  25. w0xlt commented at 8:48 pm on February 11, 2026: contributor

    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.

  26. Bicaru20 commented at 10:03 pm on February 11, 2026: none

    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.

  27. w0xlt marked this as a draft on Feb 16, 2026
  28. node: move private broadcast availability check to BroadcastTransaction()
    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
    5f5b60544d
  29. qt: handle CommitTransaction exceptions in send dialog
    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.
    3b3f319a42
  30. wallet: integrate private broadcast for wallet transactions
    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
    ad4d639186
  31. test: add Socks5ProxyHelper and add_addresses_to_addrman helpers
    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.
    dc2c27f691
  32. test: add wallet_private_broadcast.py functional test
    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.
    eb2493bbec
  33. w0xlt force-pushed on Feb 23, 2026
  34. w0xlt commented at 6:43 am on February 23, 2026: contributor

    Force-pushed with a simplified approach (rationale: #34533#pullrequestreview-3829186494). 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.
  35. DrahtBot added the label CI failed on Feb 23, 2026
  36. andrewtoth commented at 0:48 am on February 25, 2026: contributor
    Why was this marked draft?
  37. w0xlt marked this as ready for review on Feb 25, 2026
  38. w0xlt commented at 2:19 am on February 25, 2026: contributor
    I moved it to draft due to the discussion about the transaction private flag. It’s now marked as ready.
  39. in src/wallet/wallet.cpp:2148 in ad4d639186
    2144@@ -2143,7 +2145,10 @@ void MaybeResendWalletTxs(WalletContext& context)
    2145 {
    2146     for (const std::shared_ptr<CWallet>& pwallet : GetWallets(context)) {
    2147         if (!pwallet->ShouldResend()) continue;
    2148-        pwallet->ResubmitWalletTransactions(node::TxBroadcast::MEMPOOL_AND_BROADCAST_TO_ALL, /*force=*/false);
    2149+        const bool private_broadcast{gArgs.GetBoolArg("-privatebroadcast", DEFAULT_PRIVATE_BROADCAST)};
    


    andrewtoth commented at 3:45 am on February 25, 2026:
    Hmm so this is done in #34533, except the method is hoisted above the loop.

    w0xlt commented at 5:31 am on February 25, 2026:
    Yes, the two PRs overlap in the MaybeResendWalletTxs method. After #34533 is merged, I’ll rebase on top of it.
  40. DrahtBot added the label Needs rebase on Mar 2, 2026
  41. DrahtBot commented at 4:48 pm on March 2, 2026: contributor
    🐙 This pull request conflicts with the target branch and needs rebase.
  42. DrahtBot removed the label CI failed on Mar 2, 2026

github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-03-03 03:13 UTC

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