Silent Payments: Sending (take 2) #35302

pull Eunovo wants to merge 12 commits into bitcoin:master from Eunovo:implement-bip352-sending changing 44 files +3013 −273
  1. Eunovo commented at 4:16 AM on May 16, 2026: contributor

    This PR is part of integrating silent payments into Bitcoin Core. It is the second iteration of #28201. Status and tracking for the project is managed in #28536

    Prerequisite PRs:

    Sending

    Silent Payments logic Key things to note in this PR:

    • PaymentDestination is added to contain CTxDestination or SilentPaymentsDestination
    • coin selection logic for silent payments requires at least one shared secret derivation-eligible coin to be present in the result set
    • recipient silent payments destinations are now persisted to the wallet to make it possible to RBF
    • silent payment txs created on the latest wallet software then loaded into older software are not replaceable
    • any operation that produces a silent payments tx that is not safe to fee bump later is blocked; this includes operations that create psbts and send with add_to_wallet=False
  2. DrahtBot commented at 4:16 AM on May 16, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    External sites

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept ACK rkrux, w0xlt, rustaceanrob

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

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #36384 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36384.svg"></sub> (rpc: Use self.Arg<_>(name) helper over manual and duplicate parsing by maflcko)
    • #36344 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36344.svg"></sub> (wallet: reject duplicate inputs in sendall by Bruce039)
    • #36268 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36268.svg"></sub> (refactor: prune unused semi-colons by fanquake)
    • #36167 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36167.svg"></sub> ([RFC] Enable -Wunused by fanquake)
    • #35713 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/35713.svg"></sub> (Remove boost as a unit test runner by rustaceanrob)
    • #35569 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/35569.svg"></sub> (Encapsulation for CTransaction by purpleKarrot)
    • #35444 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/35444.svg"></sub> (wallet: make descriptor SPKM mutex non-recursive by w0xlt)
    • #35317 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/35317.svg"></sub> (wallet: fix ignored subtract_fee_from_outputs option by stutxo)
    • #34698 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/34698.svg"></sub> (wallet: handle MiniMiner bump fee calculation failures by shuv-amp)
    • #30343 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/30343.svg"></sub> (wallet, logging: Replace WalletLogPrintf() with LogInfo() by ryanofsky)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

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

    • CreateTransaction(wallet, recipients, /*change_pos=*/std::nullopt, new_coin_control, false) in src/wallet/spend.cpp

    <sup>2026-10-04 19:01:57</sup>

  3. rkrux commented at 9:11 AM on May 20, 2026: contributor

    Concept ACK 03c835d

  4. in src/wallet/scriptpubkeyman.cpp:1484 in f9a10e9ea9
    1476 | @@ -1477,6 +1477,30 @@ bool DescriptorScriptPubKeyMan::AddCryptedKey(const CKeyID& key_id, const CPubKe
    1477 |      return true;
    1478 |  }
    1479 |  
    1480 | +Key DescriptorScriptPubKeyMan::GetPrivKeyForSilentPayment(const CScript& scriptPubKey) const
    1481 | +{
    1482 | +    std::vector<std::vector<unsigned char>> solutions;
    1483 | +    TxoutType whichType = Solver(scriptPubKey, solutions);
    1484 | +    if (whichType == TxoutType::NONSTANDARD || whichType == TxoutType::MULTISIG || whichType == TxoutType::WITNESS_UNKNOWN ) return {};
    


    theStack commented at 1:21 AM on May 23, 2026:

    in f9a10e9ea9b57f6b21a6dbfd1239c3ba60f721f8: I think it would be more robust to express this condition using the explicit list of relevant output script types as specified in the BIP, i.e. return early if whichType is not any of P2TR, P2WPKH, P2SH-P2WPKH or P2PKH. Otherwise, the condition has to be extended whenever the TxoutType enum class gets extended (e.g. P2A has been added since and is not there in the condition).


    Eunovo commented at 11:07 AM on June 7, 2026:

    Done.

  5. in src/wallet/scriptpubkeyman.cpp:1488 in f9a10e9ea9
    1483 | +    TxoutType whichType = Solver(scriptPubKey, solutions);
    1484 | +    if (whichType == TxoutType::NONSTANDARD || whichType == TxoutType::MULTISIG || whichType == TxoutType::WITNESS_UNKNOWN ) return {};
    1485 | +    std::unique_ptr<FlatSigningProvider> coin_keys = GetSigningProvider(scriptPubKey, true);
    1486 | +    if (!coin_keys || coin_keys->keys.size() != 1) return {};
    1487 | +    const auto& [_, key] = *coin_keys->keys.begin();
    1488 | +    (void) _;
    


    theStack commented at 1:34 AM on May 23, 2026:

    f9a10e9ea9b57f6b21a6dbfd1239c3ba60f721f8: nit: is this line needed? Not sure what the exact rules in C++ are about the single underscore identifier, but I haven't seen this fake-use pattern (probably with the intention to avoid warnings) in other similar places where we use it.


    Eunovo commented at 11:07 AM on June 7, 2026:

    Removed.

  6. in src/wallet/rpc/spend.cpp:436 in e3ef349ad5
     432 | @@ -420,6 +433,11 @@ RPCMethod sendmany()
     433 |              ParseOutputs(sendTo),
     434 |              InterpretSubtractFeeFromOutputInstructions(request.params[4], sendTo.getKeys())
     435 |      );
     436 | +    auto it = std::find_if(recipients.begin(), recipients.end(), [](const auto& r) { return std::holds_alternative<V0SilentPaymentDestination>(r.dest); });
    


    theStack commented at 1:41 AM on May 23, 2026:

    in e3ef349ad5ad2bc283644b5faaa1a6fa234e16bd: could slightly simplify by using std::any_of instead (returns a bool instead of an iterator)


    Eunovo commented at 11:08 AM on June 7, 2026:

    Done.

  7. in src/wallet/rpc/spend.cpp:1280 in e3ef349ad5
    1294 |              outputs = NormalizeOutputs(request.params[0]);
    1295 |              std::vector<CRecipient> recipients = CreateRecipients(
    1296 |                      ParseOutputs(outputs),
    1297 |                      InterpretSubtractFeeFromOutputInstructions(options["subtract_fee_from_outputs"], outputs.getKeys())
    1298 |              );
    1299 | -            CCoinControl coin_control;
    


    theStack commented at 1:42 AM on May 23, 2026:

    in e3ef349ad5ad2bc283644b5faaa1a6fa234e16bd: any reason why the coin_control instance got moved up?


    Eunovo commented at 11:08 AM on June 7, 2026:

    Reverted.

  8. in src/wallet/rpc/spend.cpp:171 in e3ef349ad5
     167 | @@ -168,6 +168,14 @@ static void PreventOutdatedOptions(const UniValue& options)
     168 |      }
     169 |  }
     170 |  
     171 | +static void IsSilentPaymentsEnabled(const CWallet &pwallet)
    


    theStack commented at 1:45 AM on May 23, 2026:

    in e3ef349ad5ad2bc283644b5faaa1a6fa234e16bd: for functions starting with Is, I'd usually expect to return a boolean value. maybe rename with using Ensure as a prefix instead?


    Eunovo commented at 11:08 AM on June 7, 2026:

    Done.

  9. Eunovo commented at 11:45 AM on May 25, 2026: contributor

    @theStack I'll attend to your reviews when I rebase on https://github.com/bitcoin/bitcoin/pull/35301

  10. DrahtBot added the label Needs rebase on May 28, 2026
  11. Eunovo force-pushed on Jun 7, 2026
  12. Eunovo force-pushed on Jun 7, 2026
  13. DrahtBot added the label CI failed on Jun 7, 2026
  14. DrahtBot commented at 11:15 AM on June 7, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task lint: https://github.com/bitcoin/bitcoin/actions/runs/27090770600/job/79953877713</sub> <sub>LLM reason (✨ experimental): (empty)</sub>

    <details><summary>Hints</summary>

    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.

    </details>

  15. DrahtBot removed the label Needs rebase on Jun 7, 2026
  16. Eunovo force-pushed on Jun 7, 2026
  17. Eunovo force-pushed on Jun 8, 2026
  18. Eunovo force-pushed on Jun 8, 2026
  19. DrahtBot removed the label CI failed on Jun 8, 2026
  20. DrahtBot added the label Needs rebase on Jun 12, 2026
  21. Eunovo force-pushed on Jun 23, 2026
  22. DrahtBot added the label CI failed on Jun 23, 2026
  23. DrahtBot removed the label Needs rebase on Jun 23, 2026
  24. Eunovo force-pushed on Jun 24, 2026
  25. in test/functional/wallet_silentpayments_sending.py:323 in 989d3548c6
     318 | +            expected_error_fund=(-4, "No silent payment eligible inputs were found."),
     319 | +        )
     320 | +
     321 | +    def test_address_reuse(self):
     322 | +        self.log.info("Testing that two sends to the same SP address produce distinct outputs")
     323 | +        self.nodes[0].createwallet(wallet_name="address_reuse_sender", descriptors=True)
    


    rkrux commented at 1:41 PM on June 24, 2026:

    In 989d3548c6933a4bc31b82a6f950029440e1d232 "tests: add sending functional tests"

    descriptors=True

    Please remove all occurrences of this createwallet argument, it's not required post disallowing loading legacy wallets.

    I see a new SP specific wallet flag is introduced in the SP receiving PR #32966. I have not checked the implementation yet but from a pure functionality POV, the addition of that new wallet flag should not necessitate the passing of the descriptors flag now (which I assume, in all likelihood, is not happening and thus this flag should be removed from the tests).


    Eunovo commented at 9:38 AM on June 26, 2026:

    Done

  26. in test/functional/wallet_silentpayments_sending.py:403 in 989d3548c6
     398 | +            assert txid not in node.getrawmempool()
     399 | +            self.generate(node, 1)
     400 | +            assert wallet.gettransaction(new_txid)["confirmations"] == 1
     401 | +
     402 | +        def send_psbt(w):
     403 | +            psbt = w.walletcreatefundedpsbt([], [{SP_ADDR_1: 5}], 0, {"replaceable": True})["psbt"]
    


    rkrux commented at 1:44 PM on June 24, 2026:

    In 989d354 "tests: add sending functional tests"

    "replaceable": True

    Please remove all occurrences of this in transaction/psbt creation/fund/send RPCs that are being used in this test. PR #35433 seeks to deprecate it (hopefully in the upcoming release) because of prevalence of fullrbf in the network and we shouldn't encourage the use of this argument in the functional tests.


    Eunovo commented at 9:38 AM on June 26, 2026:

    Done

  27. in test/functional/wallet_silentpayments_sending.py:426 in 989d3548c6 outdated
     421 | +        self.generate(node, 1)  # rbf_sendall funding UTXO now has 2 confs; extra UTXO has 1 conf
     422 | +
     423 | +        txid = wallet.sendall(recipients=[SP_ADDR_1], options={"replaceable": True, "maxconf": 1})["txid"]
     424 | +        assert txid in node.getrawmempool()
     425 | +        original_input_count = len(node.getrawtransaction(txid, True)["vin"])
     426 | +        result = wallet.bumpfee(txid, fee_rate=500)
    


    rkrux commented at 1:58 PM on June 24, 2026:

    In 989d354 "tests: add sending functional tests"

    https://github.com/bitcoin/bitcoin/blob/d84fc352cbc1363df5cd6024a22e73fc63283e4f/src/wallet/rpc/spend.cpp#L1003-L1006

    1. There's an outputs argument in the (psbt)bumpfee RPCs that allows to add new outputs in the replacing transaction. Please add a test case covering the behaviour when this argument is passed while replacing a silent payment transaction.
    2. Please also add a test case covering the scenario where the user is able to rbf-cancel the SP transaction by sending the funds to themselves.

    Eunovo commented at 9:38 AM on June 26, 2026:

    Added new tests for bumpfee with the outputs replaced.

  28. rkrux commented at 2:09 PM on June 24, 2026: contributor

    Reviewed only the last testing commit partially 989d354 "tests: add sending functional tests".

  29. DrahtBot removed the label CI failed on Jun 24, 2026
  30. Eunovo force-pushed on Jun 26, 2026
  31. DrahtBot added the label CI failed on Jun 26, 2026
  32. DrahtBot commented at 9:37 AM on June 26, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task Windows native, fuzz, VS: https://github.com/bitcoin/bitcoin/actions/runs/28226175984/job/83618564020</sub> <sub>LLM reason (✨ experimental): CI failed because the fuzz RPC target errored out: scantxforsilentpayments RPC command is missing from rpc.cpp’s RPC_COMMANDS_SAFE_FOR_FUZZING/RPC_COMMANDS_NOT_SAFE_FOR_FUZZING.</sub>

    <details><summary>Hints</summary>

    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.

    </details>

  33. Eunovo force-pushed on Jun 26, 2026
  34. Eunovo force-pushed on Jun 26, 2026
  35. Eunovo force-pushed on Jun 26, 2026
  36. DrahtBot removed the label CI failed on Jun 26, 2026
  37. in test/functional/wallet_silentpayments_sending.py:10 in 22e42cb9b5 outdated
       5 | +from test_framework.segwit_addr import bech32_encode, convertbits, Encoding
       6 | +from test_framework.test_framework import BitcoinTestFramework
       7 | +from test_framework.util import assert_equal, assert_raises_rpc_error
       8 | +
       9 | +XPRV = "tprv8ZgxMBicQKsPevADjDCWsa6DfhkVXicu8NQUzfibwX2MexVwW4tCec5mXdCW8kJwkzBRRmAay1KZya4WsehVvjTGVW6JLqiqd8DdZ4xSg52"
      10 | +XPUB = "tpubD6NzVbkrYhZ4YNXVQbNhMK1WqguFsUXceaVJKbmno2aZ3B6QfbMeraaYvnBSGpV3vxLyTTK9DYT1yoEck4XUScMzXoQ2U2oSmE2JyMedq3H"
    


    rkrux commented at 12:48 PM on June 27, 2026:

    In 22e42cb9b58a3406ab1870be71aa38f861fbba4d "tests: add sending functional tests"

    After PR #35543 is merged, we don't need to (manually find and) hardcode xprvs and xpubs in the functional tests anymore. Something like the following can be done, similar to how ECKey.generate() is used:

    from test_framework.extendedkey import ExtendedPrivateKey
    ...
    extended_key = ExtendedPrivateKey.generate()
    xprv, xpub = extended_key.to_string(), extended_key.pubkey().to_string()
    

    Eunovo commented at 9:04 AM on June 29, 2026:

    Will fix when I rebase.


    Eunovo commented at 3:25 PM on July 24, 2026:

    Fixed.

  38. in test/functional/wallet_silentpayments_sending.py:50 in 22e42cb9b5
      45 | +
      46 | +    def skip_test_if_missing_module(self):
      47 | +        self.skip_if_no_wallet()
      48 | +
      49 | +    def init_wallet(self, *, node):
      50 | +        pass
    


    rkrux commented at 12:49 PM on June 27, 2026:

    In 22e42cb9b58a3406ab1870be71aa38f861fbba4d "tests: add sending functional tests"

    I assume it's taken from wallet_taproot.py? It's dead code and should be removed.


    Eunovo commented at 9:22 AM on June 29, 2026:

    Done.

  39. in test/functional/wallet_silentpayments_sending.py:112 in 22e42cb9b5
     107 | +            return {}
     108 | +
     109 | +        if single:
     110 | +            txid = wallet.sendtoaddress(addr0, amt0)
     111 | +            assert txid in node.getrawmempool()
     112 | +            assert wallet.gettransaction(txid)["confirmations"] == 0
    


    rkrux commented at 12:52 PM on June 27, 2026:

    In 22e42cb9b58a3406ab1870be71aa38f861fbba4d "tests: add sending functional tests"

    There are many asserts in the file of the form assert X == Y. Not sure why the Drahtbot has not mentioned in this PR (because it has commented on several PRs before), but the preference is to use the assert_equal(X, Y) utility function from the testing framework.


    Eunovo commented at 9:22 AM on June 29, 2026:

    Done.

  40. in test/functional/wallet_silentpayments_sending.py:538 in 22e42cb9b5
     533 | +        for _ in range(3):
     534 | +            self.nodes[0].invalidateblock(self.nodes[0].getbestblockhash())
     535 | +        self.nodes[0].syncwithvalidationinterfacequeue()
     536 | +
     537 | +        for txid in txids:
     538 | +            assert wallet.gettransaction(txid)["confirmations"] <= 0
    


    rkrux commented at 12:56 PM on June 27, 2026:

    In 22e42cb "tests: add sending functional tests"

    Similar to the equality: s/assert X <= Y/assert_greater_than(Y, X)


    Eunovo commented at 9:22 AM on June 29, 2026:

    Done.

  41. in test/functional/wallet_silentpayments_sending.py:327 in 22e42cb9b5 outdated
     322 | +        assert finalized["complete"]
     323 | +        txid = self.nodes[0].sendrawtransaction(finalized["hex"])
     324 | +        verify_inputs(txid)
     325 | +        self.generate(self.nodes[0], 1)
     326 | +        self.assert_sp_outputs(txid, (self.scan_key_1, self.spend_key_1, 1))
     327 | +        cleanup()
    


    rkrux commented at 1:04 PM on June 27, 2026:

    In 22e42cb "tests: add sending functional tests"

    For all these cases where the testing functionality is preceded by setup() and succeeded by cleanup(), a local context manager can be used for readability. Refer how a custom WalletUnlock context manager is used.

    https://github.com/bitcoin/bitcoin/blob/7a74f6529357cf78ee74f0316b9502664f56889e/test/functional/test_framework/wallet_util.py#L95-L112


    Eunovo commented at 8:55 AM on June 29, 2026:

    It seems a bit overkill to create a Context Manager class for this one test function. I think the current setup-cleanup code is better for this case


    rkrux commented at 9:50 AM on June 30, 2026:

    There is a precedent (or soon going to be) of using context managers in a single test file: https://github.com/bitcoin/bitcoin/pull/35003/changes#diff-815ba9df1be928d33d72629e1cdd6208a68493b24a4cdee0c823fb0375acd44aR34-R47

    I think it seems reasonable to create and use a context manager if it improves readability. I also feel it's a nice-to-have and not a must-have.


    Eunovo commented at 6:40 AM on July 1, 2026:

    Changed the code to use the generator context manager pattern.

  42. rkrux commented at 1:14 PM on June 27, 2026: contributor

    Thanks for addressing the previous suggestions in both the source code and the tests - git range-diff 989d354...22e42cb

    Partial review of the test commit 22e42cb "tests: add sending functional tests".

  43. Eunovo force-pushed on Jun 29, 2026
  44. Eunovo force-pushed on Jul 1, 2026
  45. Eunovo force-pushed on Jul 7, 2026
  46. DrahtBot added the label CI failed on Jul 7, 2026
  47. Eunovo commented at 3:09 PM on July 7, 2026: contributor

    CI failure seems unrelated.

  48. DrahtBot added the label Needs rebase on Jul 13, 2026
  49. Eunovo force-pushed on Jul 24, 2026
  50. DrahtBot removed the label CI failed on Jul 24, 2026
  51. DrahtBot removed the label Needs rebase on Jul 24, 2026
  52. DrahtBot added the label Needs rebase on Aug 4, 2026
  53. Eunovo force-pushed on Oct 3, 2026
  54. wallet: return util::Expected from CreateRateBumpTransaction
    Return the bump transaction and its fees in a `BumpTransaction` struct,
    or the error result and messages in a `BumpError`, instead of through
    output parameters.
    a0a6b3740a
  55. common: introduce `PaymentDestination`
    A silent payments address has no fixed scriptPubKey: it is derived from
    the inputs of the paying transaction, so it does not fit in
    CTxDestination.
    
    Add `PaymentDestination`, which holds either a CTxDestination or a
    `bip352::SilentPaymentsDestination` and can be parsed from either kind
    of address. Use it in `validateaddress`, so it accepts silent payments
    addresses.
    
    Co-authored-by: rustaceanrob <rob.netzke@gmail.com>
    9673fafc44
  56. wallet, rpc: use `PaymentDestination` in `CRecipient` and `CCoinControl`
    Change `CRecipient::dest` and `CCoinControl::destChange` to hold a
    `PaymentDestination`, in preparation for sending to silent payments
    addresses. No behavior change.
    
    Co-authored-by: rustaceanrob <rob.netzke@gmail.com>
    c2cc82fa3b
  57. wallet/rpc: avoid parsing outputs twice in send and walletcreatefundedpsbt
    Both rpcs parse the outputs into recipients for `FundTransaction`, but
    also passed them to `ConstructTransaction`, which parsed them again
    only for the resulting outputs to be cleared.
    
    Make the outputs of `ConstructTransaction` optional and pass none from
    both rpcs. In `walletcreatefundedpsbt`, the sequence/replaceable check
    now runs before the outputs are parsed.
    a01dc8e712
  58. wallet: return util::Expected with an error type from coin selection attempts
    Return a SelectionErrorType from AttemptSelection and ChooseSelectionResult, so callers can tell why selection failed instead of checking whether the error has a message.
    09127c62b1
  59. wallet: coin selection for silent payments transactions
    Add a `m_silent_payments` flag to `CCoinControl`, set when funding a
    transaction that pays to a silent payments destination.
    
    When it is set, skip coins that cannot be spent in a silent payments
    transaction: taproot outputs with script path spend data, since the
    key path is assumed unavailable, and `WITNESS_UNKNOWN` outputs.
    
    Coin selection must also pick at least one input eligible for the
    BIP352 shared secret. If the eligible coins alone cannot fund the
    transaction, preset each eligible coin in turn, in descending order of
    effective value, and select the rest from the remaining coins.
    
    Co-authored-by: josibake <josibake@protonmail.com>
    2d78bd7d30
  60. wallet: create silent payments outputs in `CreateTransaction`
    Add `CreateSilentPaymentsOutputs`, which sums the private keys of the
    eligible selected inputs and derives the taproot outputs of the silent
    payments destinations. Fail if the key of an eligible input is missing,
    instead of creating outputs the recipient cannot find. Add
    `GetPrivKeyForSilentPayments` to get the (tweaked, for taproot) private
    key of an input.
    
    `CreateTransactionInternal` uses these outputs for silent payments
    recipients, and for a silent payments change destination, which fee
    bumping uses. Until the inputs are selected, `GetDummyTxOut` stands in
    a P2TR output for size and dust estimation. Prefer taproot change when
    sending to silent payments, since their outputs are taproot outputs.
    5aa623fe30
  61. wallet/rpc: enable sp addr in send, sendmany and sendtoaddress
    Add `ParsePaymentOutputs`, which accepts silent payments addresses, and
    use it in `send`, `sendmany` and `sendtoaddress`. `ParseOutputs` keeps
    rejecting them.
    
    When paying to a silent payments address, set the silent payments flag
    in `CCoinControl`. This requires an unlocked wallet with private keys
    and no external signer, since the outputs are derived from the private
    keys of the inputs.
    
    Co-authored-by: josibake <josibake@protonmail.com>
    9a00ebeed8
  62. wallet/rpc: add sp addr support to sendall
    Accept silent payments addresses in the recipients of `sendall`. The
    outputs are added once the inputs are known, so the silent payments
    outputs can be derived from them.
    
    Since `sendall` is meant to leave no coin behind, fail if any coin
    would be skipped because it cannot be spent in a silent payments
    transaction, and let the user pick the coins with `inputs` instead.
    63f9a716d5
  63. rpc: add `scantxforsilentpayments`
    Add the `scantxforsilentpayments` utility RPC, which scans a raw
    transaction for the silent payments outputs of a recipient, given its
    scan private key and spend public key, and returns each output found
    with the tweak that spends it. The prevouts are looked up in the UTXO
    set, the mempool, and the txindex if enabled.
    
    This lets the functional tests check the silent payments outputs the
    wallet creates.
    b8b9878d02
  64. Eunovo force-pushed on Oct 3, 2026
  65. Eunovo marked this as ready for review on Oct 3, 2026
  66. Eunovo commented at 10:03 PM on October 3, 2026: contributor

    This PR has changed drastically due to feedback from #35301 and other edge cases that were spotted and handled. See #35302#issue-4458487609 for some key ideas in this PR.

  67. DrahtBot added the label CI failed on Oct 3, 2026
  68. DrahtBot removed the label Needs rebase on Oct 3, 2026
  69. Yudis-bit commented at 3:48 AM on October 4, 2026: none

    The Windows native, VS failure in CI is reproducible on MSVC and directly related to src/qt/walletmodel.cpp:457:

    src\qt\walletmodel.cpp(457,38): error C2280: 'WalletModel::UnlockContext::UnlockContext(WalletModel::UnlockContext &&)': attempting to reference a deleted function
    

    The ternary operator is_silent_payments ? requestUnlock() : UnlockContext(this, false, false) requires the common type of both prvalue operands to be move-constructible under MSVC, but move constructors were explicitly deleted in 9fa43b5af6 (UnlockContext(UnlockContext&&) = delete;). GCC and Clang elide the temporary, but MSVC strictly enforces the standard requirement here.

    Initializing sp_ctx via an immediately-invoked lambda avoids the move constructor requirement entirely through guaranteed copy elision:

        const auto sp_ctx = [&] {
            if (is_silent_payments) {
                return requestUnlock();
            }
            return UnlockContext(this, /*valid=*/false, /*relock=*/false);
        }();
    

    Alternatively, move semantics could be restored to UnlockContext in src/qt/walletmodel.h by making relock non-const and transferring ownership with relock(std::exchange(other.relock, false)).

  70. wallet: support fee bumping silent payments transactions
    Silent payments outputs depend on the inputs, so bumping a silent
    payments tx by adding inputs needs the original recipients. Store them
    in the wallet database (`sprecipients`) when the tx is committed, and
    use them in `bumpfee` to recompute the outputs. Refuse to bump a silent
    payments tx whose recipients are missing.
    
    So the recipients are always recorded, silent payments txs must be
    added to the wallet when created: `send` and `sendall` refuse
    `psbt=true`, `add_to_wallet=false` and incomplete signing for them, and
    `psbtbumpfee` refuses to bump them. Allow sp addresses in the `outputs`
    of `bumpfee`.
    
    Stop older releases from bumping such a tx by writing its own txid as
    its `replaced_by_txid`, which also flags it as a silent payments tx on
    load.
    e2780927a0
  71. test: add silent payments sending functional tests
    Test sending to silent payments addresses with `sendtoaddress`,
    `sendmany`, `send` and `sendall`, and check with
    `scantxforsilentpayments` that the recipients find their outputs.
    
    Cover eligible input types, coin selection with eligible and
    ineligible inputs, wallets and inputs that cannot send silent payments,
    transactions that must be added to the wallet, fee bumping, and reorgs.
    6c94651bea
  72. Eunovo force-pushed on Oct 4, 2026
  73. DrahtBot removed the label CI failed on Oct 4, 2026
  74. Yudis-bit commented at 7:59 PM on October 7, 2026: none

    A few subtle edge cases around descriptor key derivation, RBF state persistence, and coin selection diverge across the branch.

    In src/wallet/spend.cpp, IsInputForSharedSecretDerivation assumes any Taproot output where internal_key != NUMS_H is eligible. For descriptors where the wallet only owns a leaf key (tr(unspendable_key, pk(leaf))), this returns true, but CreateSilentPaymentsOutputs subsequently fails with Missing the private key of silent payments eligible input COutPoint(...) when GetPrivKeyForSilentPayments cannot tweak the missing internal key. On the PUBKEYHASH branch, IsInputForSharedSecretDerivation also assumes unresolvable inputs are compressed if provider->GetPubKey fails.

    This causes an inconsistency between send and sendall. AvailableCoins skips all Taproot coins with script path spend data, causing sendall without inputs to fail with an error instructing the user to choose coins via inputs. If those coins are supplied to send, line 382 explicitly rejects them with Found script data for ... Only key path spends are allowed. If supplied to sendall, the transaction succeeds when the wallet owns both keys because sendall's input path bypasses HasTaprootScriptPath.

    In src/wallet/feebumper.cpp:460, CommitTransaction calls EraseSpRecipients(oldWtx.GetHash()), erasing the original transaction's recipient metadata from the wallet database. If the replacement is evicted from the mempool or dropped while an older block template mines the original transaction, the record of who it paid to is permanently lost. Since database records are keyed per transaction, keeping historical recipient records is harmless and avoids state loss during reorgs.

    Relatedly, multiplexing m_is_sp_tx into string_values["replaced_by_txid"] using GetHash().ToString() in src/wallet/transaction.h only persists until the transaction is bumped. Once replaced, m_replaced_by_txid becomes the new transaction id. On the next wallet load, key == "replaced_by_txid" sees the replacement txid rather than GetHash(), leaving m_is_sp_tx as false. Combined with EraseSpRecipients, a replaced transaction loses its silent payments identity entirely across restarts.

    In AttemptSelectionSP (spend.cpp:1058), anchor candidates are restricted to positive_group. At high fee rates, a small eligible coin (such as a 1,000 sat P2WPKH UTXO) can have effective_value <= 0 and land in negative_group. Even if the wallet holds large ineligible outputs (e.g. 5 BTC P2WSH) that easily cover the payment and fees, candidate collection comes up empty and fails with INSUFFICIENT_FUNDS. Additionally, when subtracting fees from outputs, candidates are sorted by effective_value while target reduction uses nominal value.

  75. in src/rpc/rawtransaction_util.cpp:117 in 6c94651bea
     111 | @@ -108,11 +112,13 @@ UniValue NormalizeOutputs(const UniValue& outputs_in)
     112 |      return outputs;
     113 |  }
     114 |  
     115 | -std::vector<std::pair<CTxDestination, CAmount>> ParseOutputs(const UniValue& outputs)
     116 | +//! Parse normalized outputs, decoding each address with decode, which returns std::nullopt if the address is invalid
     117 | +template <typename Destination, typename Decode>
     118 | +static std::vector<std::pair<Destination, CAmount>> ParseOutputsWith(const UniValue& outputs, Decode decode)
    


    rustaceanrob commented at 7:00 AM on October 9, 2026:

    I see what is going on with this template type, but why not just use a boolean argument and return a PaymentDestination? I think this makes it more explicit as to what this function is doing. I found the two templated functions hard to follow.

    <details> <summary>Suggested diff</summary>

    diff --git a/src/rpc/rawtransaction_util.cpp b/src/rpc/rawtransaction_util.cpp
    index 7123d5fde7..6b8ed82898 100644
    --- a/src/rpc/rawtransaction_util.cpp
    +++ b/src/rpc/rawtransaction_util.cpp
    @@ -112,13 +112,11 @@ UniValue NormalizeOutputs(const UniValue& outputs_in)
         return outputs;
     }
    
    -//! Parse normalized outputs, decoding each address with decode, which returns std::nullopt if the address is invalid
    -template <typename Destination, typename Decode>
    -static std::vector<std::pair<Destination, CAmount>> ParseOutputsWith(const UniValue& outputs, Decode decode)
    +std::vector<std::pair<PaymentDestination, CAmount>> ParseOutputs(const UniValue& outputs, bool allow_silent_payments)
     {
         // Duplicate checking
    -    std::set<Destination> destinations;
    -    std::vector<std::pair<Destination, CAmount>> parsed_outputs;
    +    std::set<PaymentDestination> destinations;
    +    std::vector<std::pair<PaymentDestination, CAmount>> parsed_outputs;
         bool has_data{false};
         const auto& keys{outputs.getKeys()};
         const auto& values{outputs.getValues()};
    @@ -131,15 +129,18 @@ static std::vector<std::pair<Destination, CAmount>> ParseOutputsWith(const UniVa
                 }
                 has_data = true;
                 std::vector<unsigned char> data = ParseHexV(value.getValStr(), "Data");
    -            Destination destination{CNoDestination{CScript() << OP_RETURN << data}};
    +            PaymentDestination destination{CNoDestination{CScript() << OP_RETURN << data}};
                 CAmount amount{0};
                 parsed_outputs.emplace_back(destination, amount);
             } else {
    -            std::optional<Destination> destination{decode(name_)};
    +            auto destination{PaymentDestination::FromString(name_)};
                 CAmount amount{AmountFromValue(value)};
                 if (!destination) {
                     throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, std::string("Invalid Bitcoin address: ") + name_);
                 }
    +            if (!allow_silent_payments && destination->IsSilentPayment()) {
    +                throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Silent payments are not supported by this RPC");
    +            }
    
                 if (!destinations.insert(*destination).second) {
                     throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid parameter, duplicated address: ") + name_);
    @@ -150,32 +151,14 @@ static std::vector<std::pair<Destination, CAmount>> ParseOutputsWith(const UniVa
         return parsed_outputs;
     }
    
    -std::vector<std::pair<CTxDestination, CAmount>> ParseOutputs(const UniValue& outputs)
    -{
    -    return ParseOutputsWith<CTxDestination>(outputs, [](const std::string& address) -> std::optional<CTxDestination> {
    -        CTxDestination destination{DecodeDestination(address)};
    -        if (!IsValidDestination(destination)) return std::nullopt;
    -        return destination;
    -    });
    -}
    -
    -std::vector<std::pair<PaymentDestination, CAmount>> ParsePaymentOutputs(const UniValue& outputs)
    -{
    -    return ParseOutputsWith<PaymentDestination>(outputs, [](const std::string& address) -> std::optional<PaymentDestination> {
    -        auto destination{PaymentDestination::FromString(address)};
    -        if (!destination) return std::nullopt;
    -        return std::move(*destination);
    -    });
    -}
    -
     void AddOutputs(CMutableTransaction& rawTx, const UniValue& outputs_in)
     {
         UniValue outputs(UniValue::VOBJ);
         outputs = NormalizeOutputs(outputs_in);
    
    -    std::vector<std::pair<CTxDestination, CAmount>> parsed_outputs = ParseOutputs(outputs);
    +    std::vector<std::pair<PaymentDestination, CAmount>> parsed_outputs = ParseOutputs(outputs, /*allow_silent_payments=*/false);
         for (const auto& [destination, nAmount] : parsed_outputs) {
    -        CScript scriptPubKey = GetScriptForDestination(destination);
    +        CScript scriptPubKey = *CHECK_NONFATAL(destination.GetStaticScript());
    
             CTxOut out(nAmount, scriptPubKey);
             rawTx.vout.push_back(out);
    diff --git a/src/rpc/rawtransaction_util.h b/src/rpc/rawtransaction_util.h
    index 3850285cc4..449a5e2216 100644
    --- a/src/rpc/rawtransaction_util.h
    +++ b/src/rpc/rawtransaction_util.h
    @@ -52,11 +52,8 @@ void AddInputs(CMutableTransaction& rawTx, const UniValue& inputs_in, bool rbf);
     /** Normalize univalue-represented outputs */
     UniValue NormalizeOutputs(const UniValue& outputs_in);
    
    -/** Parse normalized outputs into destination, amount tuples. Silent payments addresses are rejected. */
    -std::vector<std::pair<CTxDestination, CAmount>> ParseOutputs(const UniValue& outputs);
    -
    -/** Parse normalized outputs into destination, amount tuples, accepting silent payments addresses */
    -std::vector<std::pair<PaymentDestination, CAmount>> ParsePaymentOutputs(const UniValue& outputs);
    +/** Parse normalized outputs into destination, amount tuples. Silent payments addresses are rejected unless allow_silent_payments i
    s true. */
    +std::vector<std::pair<PaymentDestination, CAmount>> ParseOutputs(const UniValue& outputs, bool allow_silent_payments);
    
     /** Normalize, parse, and add outputs to the transaction */
     void AddOutputs(CMutableTransaction& rawTx, const UniValue& outputs_in);
    diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp
    index 776ac12a35..2684b4ce70 100644
    --- a/src/wallet/rpc/spend.cpp
    +++ b/src/wallet/rpc/spend.cpp
    @@ -33,13 +33,12 @@ using common::TransactionErrorString;
     using node::TransactionError;
    
     namespace wallet {
    -template <typename Destination>
    -static std::vector<CRecipient> CreateRecipients(const std::vector<std::pair<Destination, CAmount>>& outputs, const std::set<int>& s
    ubtract_fee_outputs)
    +std::vector<CRecipient> CreateRecipients(const std::vector<std::pair<PaymentDestination, CAmount>>& outputs, const std::set<int>& s
    ubtract_fee_outputs)
     {
         std::vector<CRecipient> recipients;
         for (size_t i = 0; i < outputs.size(); ++i) {
             const auto& [destination, amount] = outputs.at(i);
    -        CRecipient recipient{PaymentDestination{destination}, amount, subtract_fee_outputs.contains(i)};
    +        CRecipient recipient{destination, amount, subtract_fee_outputs.contains(i)};
             recipients.push_back(recipient);
         }
         return recipients;
    @@ -371,7 +370,7 @@ RPCMethod sendtoaddress()
             sffo_set.insert(0);
         }
    
    -    std::vector<CRecipient> recipients{CreateRecipients(ParsePaymentOutputs(address_amounts), sffo_set)};
    +    std::vector<CRecipient> recipients{CreateRecipients(ParseOutputs(address_amounts, /*allow_silent_payments=*/true), sffo_set)};
         const bool verbose{request.params[10].isNull() ? false : request.params[10].get_bool()};
    
         return SendMoney(*pwallet, coin_control, recipients, comment, comment_to, verbose);
    @@ -465,7 +464,7 @@ RPCMethod sendmany()
         SetFeeEstimateMode(*pwallet, coin_control, /*conf_target=*/request.params[6], /*estimate_mode=*/request.params[7], /*fee_rate=*
    /request.params[8], /*override_min_fee=*/false);
    
         std::vector<CRecipient> recipients = CreateRecipients(
    -            ParsePaymentOutputs(sendTo),
    +            ParseOutputs(sendTo, /*allow_silent_payments=*/true),
                 InterpretSubtractFeeFromOutputInstructions(request.params[4], sendTo.getKeys())
         );
         const bool verbose{request.params[9].isNull() ? false : request.params[9].get_bool()};
    @@ -856,11 +855,11 @@ RPCMethod fundrawtransaction()
             throw JSONRPCError(RPC_DESERIALIZATION_ERROR, "TX decode failed");
         }
         UniValue options = request.params[1];
    -    std::vector<std::pair<CTxDestination, CAmount>> destinations;
    +    std::vector<std::pair<PaymentDestination, CAmount>> destinations;
         for (const auto& tx_out : tx.vout) {
             CTxDestination dest;
             ExtractDestination(tx_out.scriptPubKey, dest);
    -        destinations.emplace_back(dest, tx_out.nValue);
    +        destinations.emplace_back(PaymentDestination{dest}, tx_out.nValue);
         }
         std::vector<std::string> dummy(destinations.size(), "dummy");
         std::vector<CRecipient> recipients = CreateRecipients(
    @@ -1131,14 +1130,7 @@ static RPCMethod bumpfee_helper(std::string method_name)
                 // inputs, and the transaction must be added to the wallet when it is created, see
                 // EnsureSilentPaymentsTxIsAddedToWallet. A PSBT may be signed elsewhere and its inputs
                 // may change before signing, so only allow silent payments addresses for bumpfee RPC.
    -            const UniValue normalized_outputs{NormalizeOutputs(options["outputs"])};
    -            if (want_psbt) {
    -                for (auto& [dest, amount] : ParseOutputs(normalized_outputs)) {
    -                    outputs.emplace_back(PaymentDestination{std::move(dest)}, amount);
    -                }
    -            } else {
    -                outputs = ParsePaymentOutputs(normalized_outputs);
    -            }
    +            outputs = ParseOutputs(NormalizeOutputs(options["outputs"]), /*allow_silent_payments=*/!want_psbt);
             }
    
             if (options.exists("original_change_index")) {
    @@ -1336,7 +1328,7 @@ RPCMethod send()
                 UniValue outputs(UniValue::VOBJ);
                 outputs = NormalizeOutputs(request.params[0]);
                 std::vector<CRecipient> recipients = CreateRecipients(
    -                    ParsePaymentOutputs(outputs),
    +                    ParseOutputs(outputs, /*allow_silent_payments=*/true),
                         InterpretSubtractFeeFromOutputInstructions(options["subtract_fee_from_outputs"], outputs.getKeys())
                 );
                 if (std::ranges::any_of(recipients, [](const auto& r) { return r.dest.IsSilentPayment(); })) {
    @@ -1525,7 +1517,7 @@ RPCMethod sendall()
                 CMutableTransaction rawTx{ConstructTransaction(options["inputs"], /*outputs_in=*/std::nullopt, options["locktime"], rbf
    , coin_control.m_version)};
                 // Output i pays to the address in key i of the normalized outputs
                 const UniValue outputs{NormalizeOutputs(recipient_key_value_pairs)};
    -            const auto parsed_outputs{ParsePaymentOutputs(outputs)};
    +            const auto parsed_outputs{ParseOutputs(outputs, /*allow_silent_payments=*/true)};
                 std::map<size_t, SilentPaymentsDestination> sp_destinations;
                 for (size_t i = 0; i < parsed_outputs.size(); ++i) {
                     if (const auto* sp = parsed_outputs[i].first.GetSilentPaymentsDestination()) {
    @@ -1895,7 +1887,7 @@ RPCMethod walletcreatefundedpsbt()
         UniValue outputs(UniValue::VOBJ);
         outputs = NormalizeOutputs(request.params[1]);
         std::vector<CRecipient> recipients = CreateRecipients(
    -            ParseOutputs(outputs),
    +            ParseOutputs(outputs, /*allow_silent_payments=*/false),
                 InterpretSubtractFeeFromOutputInstructions(options["subtractFeeFromOutputs"], outputs.getKeys())
         );
         // Automatically select coins, unless at least one is manually selected. Can
    

    </details>

  76. in src/common/bip352.h:117 in 6c94651bea
     112 | +        if (!IsValid()) throw std::ios_base::failure("Invalid silent payments destination");
     113 | +    }
     114 | +
     115 |      bool operator==(const SilentPaymentsDestination&) const = default;
     116 | +
     117 | +    friend bool operator<(const SilentPaymentsDestination& a, const SilentPaymentsDestination& b) {
    


    rustaceanrob commented at 7:19 AM on October 9, 2026:

    The BIP allows for multiple outputs to the same recipient, which is particularly good for privacy:

    ^ Why allow for more than one output? Allowing Alice to break her payment to Bob into multiple amounts opens up a number of privacy improving techniques for Alice, making the transaction look like a CoinJoin or better hiding the change amount by splitting both the payment and change outputs into multiple amounts. It also allows for Alice and Carol to both have their own unique output paying Bob in the event they are in a collaborative transaction and both paying Bob's silent payment address.

    Can we remove this and only check for duplicate CTxDestination in ParseOutputs? AFICT use of set in sendall is to compute the distribution of the remaining value over the addresses without amounts specified. I have a patch here that gets around it, but I will spend some time trying to make it a bit cleaner.

    <details> <summary>Diff removing the use of set</summary>

    diff --git a/src/rpc/rawtransaction_util.cpp b/src/rpc/rawtransaction_util.cpp
    index 6b8ed82898..3284f4368e 100644
    --- a/src/rpc/rawtransaction_util.cpp
    +++ b/src/rpc/rawtransaction_util.cpp
    @@ -115,7 +115,7 @@ UniValue NormalizeOutputs(const UniValue& outputs_in)
     std::vector<std::pair<PaymentDestination, CAmount>> ParseOutputs(const UniValue& outputs, bool allow_silent_payments)
     {
         // Duplicate checking
    -    std::set<PaymentDestination> destinations;
    +    std::set<CTxDestination> destinations;
         std::vector<std::pair<PaymentDestination, CAmount>> parsed_outputs;
         bool has_data{false};
         const auto& keys{outputs.getKeys()};
    @@ -142,7 +142,8 @@ std::vector<std::pair<PaymentDestination, CAmount>> ParseOutputs(const UniValue&
                     throw JSONRPCError(RPC_INVALID_PARAMETER, "Silent payments are not supported by this RPC");
                 }
    
    -            if (!destinations.insert(*destination).second) {
    +            const CTxDestination* tx_destination{destination->GetTxDestination()};
    +            if (tx_destination && !destinations.insert(*tx_destination).second) {
                     throw JSONRPCError(RPC_INVALID_PARAMETER, std::string("Invalid parameter, duplicated address: ") + name_);
                 }
                 parsed_outputs.emplace_back(std::move(*destination), amount);
    diff --git a/src/wallet/rpc/spend.cpp b/src/wallet/rpc/spend.cpp
    index 2684b4ce70..17c1bf1a46 100644
    --- a/src/wallet/rpc/spend.cpp
    +++ b/src/wallet/rpc/spend.cpp
    @@ -1440,7 +1440,6 @@ RPCMethod sendall()
                 PreventOutdatedOptions(options);
    
    
    -            std::set<PaymentDestination> addresses_without_amount;
                 UniValue recipient_key_value_pairs(UniValue::VARR);
                 const UniValue& recipients{request.params[0]};
                 for (unsigned int i = 0; i < recipients.size(); ++i) {
    @@ -1449,15 +1448,12 @@ RPCMethod sendall()
                         UniValue rkvp(UniValue::VOBJ);
                         rkvp.pushKV(recipient.get_str(), 0);
                         recipient_key_value_pairs.push_back(std::move(rkvp));
    -                    // Store the decoded destination, so it matches the outputs below
    -                    // also when the address was given in another case (e.g. uppercase bech32)
    -                    addresses_without_amount.insert(PaymentDestination::FromString(recipient.get_str()).value_or(PaymentDestination
    {}));
                     } else {
                         recipient_key_value_pairs.push_back(recipient);
                     }
                 }
    
    -            if (addresses_without_amount.size() == 0) {
    +            if (std::ranges::none_of(recipients.getValues(), [](const UniValue& recipient) { return recipient.isStr(); })) {
                     throw JSONRPCError(RPC_INVALID_PARAMETER, "Must provide at least one address without a specified amount");
                 }
    
    @@ -1518,9 +1514,15 @@ RPCMethod sendall()
                 // Output i pays to the address in key i of the normalized outputs
                 const UniValue outputs{NormalizeOutputs(recipient_key_value_pairs)};
                 const auto parsed_outputs{ParseOutputs(outputs, /*allow_silent_payments=*/true)};
    -            std::map<size_t, SilentPaymentsDestination> sp_destinations;
    +            std::vector<std::pair<PaymentDestination, std::optional<CAmount>>> sendall_outputs;
    +            sendall_outputs.reserve(parsed_outputs.size());
                 for (size_t i = 0; i < parsed_outputs.size(); ++i) {
    -                if (const auto* sp = parsed_outputs[i].first.GetSilentPaymentsDestination()) {
    +                const auto& [dest, amount] = parsed_outputs[i];
    +                sendall_outputs.emplace_back(dest, recipients[i].isStr() ? std::nullopt : std::optional{amount});
    +            }
    +            std::map<size_t, SilentPaymentsDestination> sp_destinations;
    +            for (size_t i = 0; i < sendall_outputs.size(); ++i) {
    +                if (const auto* sp = sendall_outputs[i].first.GetSilentPaymentsDestination()) {
                         sp_destinations.emplace(i, *sp);
                     }
                 }
    @@ -1603,10 +1605,10 @@ RPCMethod sendall()
                     }
                     sp_outputs = std::move(*sp_result);
                 }
    -            for (size_t i = 0; i < parsed_outputs.size(); ++i) {
    -                const auto& [dest, amount] = parsed_outputs[i];
    +            for (size_t i = 0; i < sendall_outputs.size(); ++i) {
    +                const auto& [dest, amount] = sendall_outputs[i];
                     const auto sp_it{sp_outputs.find(i)};
    -                rawTx.vout.emplace_back(amount, sp_it != sp_outputs.end() ? GetScriptForDestination(sp_it->second) : *CHECK_NONFATA
    L(dest.GetStaticScript()));
    +                rawTx.vout.emplace_back(amount.value_or(0), sp_it != sp_outputs.end() ? GetScriptForDestination(sp_it->second) : *C
    HECK_NONFATAL(dest.GetStaticScript()));
                 }
    
                 std::vector<COutPoint> outpoints_spent;
    @@ -1660,15 +1662,16 @@ RPCMethod sendall()
                     throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, "Insufficient funds for fees after creating specified outputs.");
                 }
    
    -            const CAmount per_output_without_amount{remainder / (long)addresses_without_amount.size()};
    +            const auto num_outputs_without_amount{std::ranges::count_if(sendall_outputs, [](const auto& output) { return !output.se
    cond; })};
    +            const CAmount per_output_without_amount{remainder / num_outputs_without_amount};
    
                 bool gave_remaining_to_first{false};
                 for (size_t i = 0; i < rawTx.vout.size(); ++i) {
                     CTxOut& out = rawTx.vout[i];
    -                if (addresses_without_amount.contains(parsed_outputs[i].first)) {
    +                if (!sendall_outputs[i].second) {
                         out.nValue = per_output_without_amount;
                         if (!gave_remaining_to_first) {
    -                        out.nValue += remainder % addresses_without_amount.size();
    +                        out.nValue += remainder % num_outputs_without_amount;
                             gave_remaining_to_first = true;
                         }
                         if (IsDust(out, pwallet->chain().relayDustFee())) {
    diff --git a/test/functional/wallet_sendall.py b/test/functional/wallet_sendall.py
    index 8a5ced520e..144f2f672f 100755
    --- a/test/functional/wallet_sendall.py
    +++ b/test/functional/wallet_sendall.py
    @@ -169,7 +169,7 @@ class SendallTest(BitcoinTestFramework):
                     [{self.recipient: 5}]
                 )
    
    - [@for](/bitcoin-bitcoin/contributor/for/)_each_target
    + [@for](/bitcoin-bitcoin/contributor/for/)_each_target(allow_sp=False)
         def sendall_duplicate_recipient(self):
             self.log.info("Test duplicate destination")
             self.add_utxos([1, 8, 3, 9])
    

    </details>

  77. in src/common/paymentdestination.h:34 in 6c94651bea
      29 | +{
      30 | +private:
      31 | +    std::variant<CTxDestination, bip352::SilentPaymentsDestination> m_destination;
      32 | +
      33 | +public:
      34 | +    PaymentDestination() = default;
    


    rustaceanrob commented at 11:46 AM on October 9, 2026:

    I don't think it makes sense to have this. The type can be correct by construction and not rely on the doc comment for the convention. If the default constructor is deleted we can also remove the IsValid method as well. The need for this in part stems from the coin control field being a plain CTxDestination/PaymentDestination, but I think it should be an optional there to express the intent. A nullopt implies the change will be generated. This removes the overloaded meaning of the CTxNoDestination to mean either invalid or needs an address.

    <details> <summary>Suggested diff with optional change dest</summary>

    diff --git a/src/common/paymentdestination.cpp b/src/common/paymentdestination.cpp
    index a908cccf47ec..f39430979f4a 100644
    --- a/src/common/paymentdestination.cpp
    +++ b/src/common/paymentdestination.cpp
    @@ -47,11 +47,3 @@ std::optional<CScript> PaymentDestination::GetStaticScript() const
         if (const auto* dest{GetTxDestination()}) return GetScriptForDestination(*dest);
         return std::nullopt;
     }
    -
    -bool PaymentDestination::IsValid() const
    -{
    -    if (const auto* dest{GetTxDestination()}) {
    -        return !std::holds_alternative<CNoDestination>(*dest);
    -    }
    -    return IsSilentPayment();
    -}
    diff --git a/src/common/paymentdestination.h b/src/common/paymentdestination.h
    index 9315b6ff81f2..3fc1cb304458 100644
    --- a/src/common/paymentdestination.h
    +++ b/src/common/paymentdestination.h
    @@ -22,8 +22,6 @@
      * which maps to a fixed scriptPubKey, or a SilentPaymentsDestination, whose
      * scriptPubKey can only be derived once the inputs of the paying transaction
      * are known.
    - *
    - * A default constructed PaymentDestination holds a CNoDestination.
      */
     class PaymentDestination
     {
    @@ -31,7 +29,6 @@ class PaymentDestination
         std::variant<CTxDestination, bip352::SilentPaymentsDestination> m_destination;
     
     public:
    -    PaymentDestination() = default;
         /** Build a payment destination from a CTxDestination. This does not validate the CTxDestination. */
         explicit PaymentDestination(CTxDestination dest) : m_destination(std::move(dest)) {}
         explicit PaymentDestination(bip352::SilentPaymentsDestination dest) : m_destination(std::move(dest)) {}
    @@ -49,9 +46,6 @@ class PaymentDestination
         /** Return the SilentPaymentsDestination, or nullptr if this is not a silent payments destination. */
         const bip352::SilentPaymentsDestination* GetSilentPaymentsDestination() const { return std::get_if<bip352::SilentPaymentsDestination>(&m_destination); }
     
    -    /** Whether this is a valid CTxDestination or a SilentPaymentsDestination */
    -    bool IsValid() const;
    -
         /** Whether this is a silent payments destination. */
         bool IsSilentPayment() const { return std::holds_alternative<bip352::SilentPaymentsDestination>(m_destination); }
     
    diff --git a/src/qt/sendcoinsdialog.cpp b/src/qt/sendcoinsdialog.cpp
    index 08faf73399d1..2377e97082c6 100644
    --- a/src/qt/sendcoinsdialog.cpp
    +++ b/src/qt/sendcoinsdialog.cpp
    @@ -931,7 +931,7 @@ void SendCoinsDialog::coinControlChangeChecked(int state)
     {
         if (state == Qt::Unchecked)
         {
    -        m_coin_control->destChange = PaymentDestination{};
    +        m_coin_control->destChange.reset();
             ui->labelCoinControlChangeLabel->clear();
         }
         else
    @@ -947,7 +947,7 @@ void SendCoinsDialog::coinControlChangeEdited(const QString& text)
         if (model && model->getAddressTableModel())
         {
             // Default to no change address until verified
    -        m_coin_control->destChange = PaymentDestination{};
    +        m_coin_control->destChange.reset();
             ui->labelCoinControlChangeLabel->setStyleSheet("QLabel{color:red;}");
     
             const CTxDestination dest = DecodeDestination(text.toStdString());
    diff --git a/src/test/paymentdestination_tests.cpp b/src/test/paymentdestination_tests.cpp
    index 4ba4e79a4e60..f6c6da52e906 100644
    --- a/src/test/paymentdestination_tests.cpp
    +++ b/src/test/paymentdestination_tests.cpp
    @@ -22,16 +22,6 @@ BOOST_FIXTURE_TEST_SUITE(paymentdestination_tests, BasicTestingSetup)
     static const std::string SP_ADDRESS{"sp1qq22l5s6l9460ww6t4tkzsy2a7zejurcmzz35pt0ffrzk5erlaykdcqugecjjnjqf7ggq39vl6wexjlm00n66z94v675n7wcux6d2krr68gdjvfn2"};
     static const std::string P2WPKH_ADDRESS{"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"};
     
    -BOOST_AUTO_TEST_CASE(default_destination)
    -{
    -    const PaymentDestination dest;
    -    BOOST_CHECK(!dest.IsSilentPayment());
    -    BOOST_CHECK(!dest.GetSilentPaymentsDestination());
    -    BOOST_REQUIRE(dest.GetTxDestination());
    -    BOOST_CHECK(std::holds_alternative<CNoDestination>(*dest.GetTxDestination()));
    -    BOOST_CHECK(dest == PaymentDestination{CNoDestination{}});
    -}
    -
     BOOST_AUTO_TEST_CASE(from_string_silent_payments)
     {
         const auto sp_dest{bip352::DecodeSilentPaymentsAddress(SP_ADDRESS, Params())};
    diff --git a/src/wallet/coincontrol.h b/src/wallet/coincontrol.h
    index b074587da89a..cb127d7bf1d6 100644
    --- a/src/wallet/coincontrol.h
    +++ b/src/wallet/coincontrol.h
    @@ -84,7 +84,7 @@ class CCoinControl
     {
     public:
         //! Custom change destination, if not set an address is generated
    -    PaymentDestination destChange;
    +    std::optional<PaymentDestination> destChange;
         //! Override the default change type if set, ignored if destChange is set
         std::optional<OutputType> m_change_type;
         //! If false, only safe inputs will be used
    @@ -123,6 +123,8 @@ class CCoinControl
     
         CCoinControl();
     
    +    const bip352::SilentPaymentsDestination* GetSilentPaymentsChange() const { return destChange ? destChange->GetSilentPaymentsDestination() : nullptr; }
    +
         /**
          * Returns true if there are pre-selected inputs.
          */
    diff --git a/src/wallet/feebumper.cpp b/src/wallet/feebumper.cpp
    index 136345573d5a..eb60ded37726 100644
    --- a/src/wallet/feebumper.cpp
    +++ b/src/wallet/feebumper.cpp
    @@ -311,15 +311,14 @@ util::Expected<BumpTransaction, BumpError> CreateRateBumpTransaction(CWallet& wa
             for (size_t i = 0; i < tx->vout.size(); ++i) {
                 const CTxOut& output = tx->vout[i];
     
    -            PaymentDestination dest;
    -            if (const auto it = sp_scripts.find(output.scriptPubKey); it != sp_scripts.end()) {
    -                dest = PaymentDestination{wtx.m_sprecipients[it->second]};
    -                new_coin_control.m_silent_payments = true;
    -            } else {
    +            const auto it{sp_scripts.find(output.scriptPubKey)};
    +            const PaymentDestination dest{[&] {
    +                if (it != sp_scripts.end()) return PaymentDestination{wtx.m_sprecipients[it->second]};
                     CTxDestination tx_dest;
                     ExtractDestination(output.scriptPubKey, tx_dest);
    -                dest = PaymentDestination{tx_dest};
    -            }
    +                return PaymentDestination{tx_dest};
    +            }()};
    +            if (it != sp_scripts.end()) new_coin_control.m_silent_payments = true;
     
                 if (original_change_index.has_value() ? original_change_index.value() == i : OutputIsChange(wallet, output)) {
                     new_coin_control.destChange = dest;
    @@ -333,15 +332,15 @@ util::Expected<BumpTransaction, BumpError> CreateRateBumpTransaction(CWallet& wa
         // If no recipients, means that we are sending coins to a change address
         if (recipients.empty()) {
             // Just as a sanity check, ensure that the change address exist
    -        if (!new_coin_control.destChange.IsValid()) {
    +        if (!new_coin_control.destChange) {
                 errors.emplace_back(Untranslated("Unable to create transaction. Transaction must have at least one recipient"));
                 return util::Unexpected{BumpError{Result::INVALID_PARAMETER, std::move(errors)}};
             }
     
             // Add change as recipient with SFFO flag enabled, so fees are deduced from it.
             // If the output differs from the original tx output (because the user customized it) a new change output will be created.
    -        recipients.emplace_back(CRecipient{new_coin_control.destChange, new_outputs_value, /*fSubtractFeeFromAmount=*/true});
    -        new_coin_control.destChange = PaymentDestination{};
    +        recipients.emplace_back(CRecipient{*new_coin_control.destChange, new_outputs_value, /*fSubtractFeeFromAmount=*/true});
    +        new_coin_control.destChange.reset();
         }
     
         if (coin_control.m_feerate) {
    @@ -396,7 +395,7 @@ util::Expected<BumpTransaction, BumpError> CreateRateBumpTransaction(CWallet& wa
         // CreateTransaction: recipients first, then a silent payments change destination if the
         // transaction has a change output
         std::vector<bip352::SilentPaymentsDestination> sp_recipients{GetSilentPaymentsDestinations(recipients)};
    -    if (const auto* sp = new_coin_control.destChange.GetSilentPaymentsDestination(); sp && txr.change_pos) {
    +    if (const auto* sp = new_coin_control.GetSilentPaymentsChange(); sp && txr.change_pos) {
             sp_recipients.push_back(*sp);
         }
     
    diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp
    index 4c50a47bbfab..407ed7002df1 100644
    --- a/src/wallet/spend.cpp
    +++ b/src/wallet/spend.cpp
    @@ -1433,12 +1433,12 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
         bilingual_str error; // possible error str
     
         // coin control: send change to custom address
    -    if (coin_control.destChange.IsValid()) {
    +    if (coin_control.destChange) {
             // A silent payments change output script is derived once the inputs are selected, see below
    -        if (coin_control.destChange.IsSilentPayment() && !coin_control.m_silent_payments) {
    +        if (coin_control.destChange->IsSilentPayment() && !coin_control.m_silent_payments) {
                 return util::Error{_("Silent payments change requires a silent payments transaction")};
             }
    -        scriptChange = GetDummyTxOut(coin_control.destChange, /*amount=*/0).scriptPubKey;
    +        scriptChange = GetDummyTxOut(*coin_control.destChange, /*amount=*/0).scriptPubKey;
         } else { // no coin control: send change to newly generated address
             // Note: We use a new key here to keep it from being obvious which side is the change.
             //  The drawback is that by not reusing a previous key, the change may be lost if a
    @@ -1591,7 +1591,7 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
             // A silent payments change destination is keyed after all recipients, so its output is
             // numbered after those of recipients with the same scan key. It is only derived if there
             // is a change output.
    -        if (const auto* sp = coin_control.destChange.GetSilentPaymentsDestination(); sp && change_amount > 0) {
    +        if (const auto* sp = coin_control.GetSilentPaymentsChange(); sp && change_amount > 0) {
                 sp_dests.emplace(vecSend.size(), *sp);
             }
             auto res{CreateSilentPaymentsOutputs(wallet, sp_dests, result.GetInputSet())};
    @@ -1828,7 +1828,7 @@ util::Result<CreatedTransactionResult> CreateTransaction(
     
             // Reuse the change destination from the first creation attempt to avoid skipping BIP44 indexes.
             // A silent payments change output script depends on the selected inputs, so it is not reused.
    -        if (txr_ungrouped.change_pos && !coin_control.destChange.IsSilentPayment()) {
    +        if (txr_ungrouped.change_pos && !coin_control.GetSilentPaymentsChange()) {
                 CTxDestination change_dest;
                 ExtractDestination(txr_ungrouped.tx->vout[*txr_ungrouped.change_pos].scriptPubKey, change_dest);
                 tmp_cc.destChange = PaymentDestination{change_dest};
    diff --git a/src/wallet/test/fuzz/spend.cpp b/src/wallet/test/fuzz/spend.cpp
    index 4f61d492e513..1c620ca58d16 100644
    --- a/src/wallet/test/fuzz/spend.cpp
    +++ b/src/wallet/test/fuzz/spend.cpp
    @@ -50,7 +50,8 @@ FUZZ_TARGET(wallet_create_transaction, .init = initialize_setup)
         coin_control.m_avoid_partial_spends = fuzzed_data_provider.ConsumeBool();
         coin_control.m_include_unsafe_inputs = fuzzed_data_provider.ConsumeBool();
         if (fuzzed_data_provider.ConsumeBool()) coin_control.m_confirm_target = fuzzed_data_provider.ConsumeIntegralInRange<unsigned int>(0, 999'000);
    -    coin_control.destChange = PaymentDestination{fuzzed_data_provider.ConsumeBool() ? fuzzed_wallet.GetDestination(fuzzed_data_provider) : ConsumeTxDestination(fuzzed_data_provider)};
    +    const CTxDestination change_dest{fuzzed_data_provider.ConsumeBool() ? fuzzed_wallet.GetDestination(fuzzed_data_provider) : ConsumeTxDestination(fuzzed_data_provider)};
    +    if (!std::holds_alternative<CNoDestination>(change_dest)) coin_control.destChange = PaymentDestination{change_dest};
         if (fuzzed_data_provider.ConsumeBool()) coin_control.m_change_type = fuzzed_data_provider.PickValueInArray(OUTPUT_TYPES);
         if (fuzzed_data_provider.ConsumeBool()) coin_control.m_feerate = CFeeRate(ConsumeMoney(fuzzed_data_provider, /*max=*/COIN));
         coin_control.m_allow_other_inputs = fuzzed_data_provider.ConsumeBool();
    

    </details>

  78. w0xlt commented at 2:54 AM on October 10, 2026: contributor

    Concept ACK

  79. in src/wallet/transaction.h:301 in e2780927a0
     297 | +            // adding inputs, which would invalidate its silent payments outputs. Mark it as
     298 | +            // replaced by itself so they refuse to bump it. No tx can replace itself, so on load
     299 | +            // this placeholder flags the tx as a silent payments tx. A new value is not used to
     300 | +            // flag it, since releases that throw on unknown values could not load the wallet.
     301 | +            // Once the tx is replaced, the flag is not kept, since it can no longer be bumped.
     302 | +            string_values["replaced_by_txid"] = GetHash().ToString();
    


    rustaceanrob commented at 10:23 AM on October 10, 2026:

    I appreciate the thought on how to make this backwards compatible, but I fear this would be very confusing as a user. When bumping a transaction the user likely has some urgency, and this would leave the UI in an opaque/hard to understand state. An alternative could be to set a new and unknown wallet flag, and older software versions would refuse to open the wallet. I think this would make the issue far more clear, that the user cannot use the wallet at all if there is a silent payments send. Below is the simple version where the flag is set once, but it can also be set/unset each time there is a pending silent payment:

    diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp
    index 28a839e2b4..292a8ba050 100644
    --- a/src/wallet/wallet.cpp
    +++ b/src/wallet/wallet.cpp
    @@ -2138,6 +2138,10 @@ void CWallet::CommitTransaction(
         LOCK(cs_wallet);
         WalletLogPrintf("CommitTransaction:\n%s\n", util::RemoveSuffixView(tx->ToString(), "\n"));
    
    +    if (!sp_recipients.empty() && !IsWalletFlagSet(WALLET_FLAG_SILENT_PAYMENTS_SENT)) {
    +        SetWalletFlag(WALLET_FLAG_SILENT_PAYMENTS_SENT);
    +    }
    +
         // Add tx to wallet, because if it has change it's also ours,
         // otherwise just for transaction history.
         CWalletTx* wtx = AddToWallet(tx, TxStateInactive{}, [&](CWalletTx& wtx, bool new_tx) {
    diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h
    index 181a2c6d17..b70c1be657 100644
    --- a/src/wallet/wallet.h
    +++ b/src/wallet/wallet.h
    @@ -164,7 +164,8 @@ inline constexpr uint64_t KNOWN_WALLET_FLAGS =
         |   WALLET_FLAG_LAST_HARDENED_XPUB_CACHED
         |   WALLET_FLAG_DISABLE_PRIVATE_KEYS
         |   WALLET_FLAG_DESCRIPTORS
    -    |   WALLET_FLAG_EXTERNAL_SIGNER;
    +    |   WALLET_FLAG_EXTERNAL_SIGNER
    +    |   WALLET_FLAG_SILENT_PAYMENTS_SENT;
    
     inline constexpr uint64_t MUTABLE_WALLET_FLAGS =
             WALLET_FLAG_AVOID_REUSE;
    @@ -176,7 +177,8 @@ static const std::map<WalletFlags, std::string> WALLET_FLAG_TO_STRING{
         {WALLET_FLAG_LAST_HARDENED_XPUB_CACHED, "last_hardened_xpub_cached"},
         {WALLET_FLAG_DISABLE_PRIVATE_KEYS, "disable_private_keys"},
         {WALLET_FLAG_DESCRIPTORS, "descriptor_wallet"},
    -    {WALLET_FLAG_EXTERNAL_SIGNER, "external_signer"}
    +    {WALLET_FLAG_EXTERNAL_SIGNER, "external_signer"},
    +    {WALLET_FLAG_SILENT_PAYMENTS_SENT, "silent_payments_sent"}
     };
    
     static const std::map<std::string, WalletFlags> STRING_TO_WALLET_FLAG{
    @@ -186,7 +188,8 @@ static const std::map<std::string, WalletFlags> STRING_TO_WALLET_FLAG{
         {WALLET_FLAG_TO_STRING.at(WALLET_FLAG_LAST_HARDENED_XPUB_CACHED), WALLET_FLAG_LAST_HARDENED_XPUB_CACHED},
         {WALLET_FLAG_TO_STRING.at(WALLET_FLAG_DISABLE_PRIVATE_KEYS), WALLET_FLAG_DISABLE_PRIVATE_KEYS},
         {WALLET_FLAG_TO_STRING.at(WALLET_FLAG_DESCRIPTORS), WALLET_FLAG_DESCRIPTORS},
    -    {WALLET_FLAG_TO_STRING.at(WALLET_FLAG_EXTERNAL_SIGNER), WALLET_FLAG_EXTERNAL_SIGNER}
    +    {WALLET_FLAG_TO_STRING.at(WALLET_FLAG_EXTERNAL_SIGNER), WALLET_FLAG_EXTERNAL_SIGNER},
    +    {WALLET_FLAG_TO_STRING.at(WALLET_FLAG_SILENT_PAYMENTS_SENT), WALLET_FLAG_SILENT_PAYMENTS_SENT}
     };
    
     /** A wrapper to reserve an address from a wallet
    diff --git a/src/wallet/walletutil.h b/src/wallet/walletutil.h
    index 8a973c59aa..be3c530ae8 100644
    --- a/src/wallet/walletutil.h
    +++ b/src/wallet/walletutil.h
    @@ -54,6 +54,8 @@ enum WalletFlags : uint64_t {
    
         //! Indicates that the wallet needs an external signer
         WALLET_FLAG_EXTERNAL_SIGNER = (1ULL << 35),
    +
    +    WALLET_FLAG_SILENT_PAYMENTS_SENT = (1ULL << 36),
     };
    

    There is no way to make RBF fully backward compatible, so I think we should fail as loudly as possible IMO

  80. rustaceanrob commented at 10:36 AM on October 10, 2026: member

    Concept ACK, have lots of opinions on approach.

    It seems a01dc8e7123fd44d0786c41503e13c78ada1f516 could potentially be split out into a small PR. I think it is a simple and big readability improvement. Perhaps 09127c62b12b25d1fe26035e9fe3bcdd27b15c13 also could be split off.


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-10-11 10:51 UTC

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