Silent Payments: Sending (take 2) #35302

pull Eunovo wants to merge 11 commits into bitcoin:master from Eunovo:implement-bip352-sending changing 43 files +2700 −261
  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:

    • #36597 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36597.svg"></sub> (wallet: return broadcast errors from CommitTransaction by pseudoramdom)
    • #36539 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36539.svg"></sub> (gui: Use PSBT v0 by davidgumberg)
    • #36494 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36494.svg"></sub> (wallet: reject unknown options in sendall by thomasbuilds)
    • #36489 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36489.svg"></sub> (wallet: rpc: Add snake_case aliases for wallet funding options by maflcko)
    • #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)
    • #36074 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36074.svg"></sub> (scripted-diff: [test] Add util/check.h includes for assertions by maflcko)
    • #36040 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36040.svg"></sub> (Wallet: Don't backdate locktime rbf by Bicaru20)
    • #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)
    • #35511 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/35511.svg"></sub> (RFC: consensus: Make CAmount a class by hodlinator)
    • #34698 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/34698.svg"></sub> (wallet: handle MiniMiner bump fee calculation failures by shuv-amp)
    • #34405 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/34405.svg"></sub> (wallet: skip APS when no partial spend exists by 8144225309)
    • #32857 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/32857.svg"></sub> (wallet: allow skipping script paths by Sjors)

    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 typos and grammar issues:

    • src/wallet/spend.cpp: elligible → eligible [misspelling]

    <sup>2026-10-22 09:41:50</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. Eunovo force-pushed on Oct 3, 2026
  55. Eunovo marked this as ready for review on Oct 3, 2026
  56. 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.

  57. DrahtBot added the label CI failed on Oct 3, 2026
  58. DrahtBot removed the label Needs rebase on Oct 3, 2026
  59. 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)).

  60. Eunovo force-pushed on Oct 4, 2026
  61. DrahtBot removed the label CI failed on Oct 4, 2026
  62. 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.

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


    Eunovo commented at 1:03 PM on October 13, 2026:

    Done.

  64. in src/common/bip352.h:117 in 6c94651bea outdated
     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](/github-metadata-backup-bitcoin-bitcoin/contributor/for/)_each_target
    + [@for](/github-metadata-backup-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>


    rustaceanrob commented at 6:29 AM on October 13, 2026:

    And if we do go this route, this can be simpler with:

    return std::tie(a.m_version, a.m_scan_pubkey, a.m_spend_pubkey, a.m_extension_data)
         < std::tie(b.m_version, b.m_scan_pubkey, b.m_spend_pubkey, b.m_extension_data);
    

    Eunovo commented at 1:04 PM on October 13, 2026:

    Changed to use std::tie

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


    Eunovo commented at 1:03 PM on October 13, 2026:

    Done

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

    Concept ACK

  67. in src/wallet/transaction.h:301 in e2780927a0 outdated
     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:

    <details> <summary>Wallet flag</summary>

    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),
     };
    

    </details>

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


    rustaceanrob commented at 5:08 AM on October 14, 2026:

    Out of band it was raised to me that the wallet flag would be detrimental for users, as in preventing a wallet open on downgrade is not desirable in basically any circumstance. I can empathize with that approach, although I would recommend something that we can put in the release notes, stack exchange, etc. A dummy replacement value could make this easier to share as expected behavior (00 + silent payments in hex):

    diff --git a/src/wallet/test/wallet_transaction_tests.cpp b/src/wallet/test/wallet_transaction_tests.cpp
    index 9606f002f2..134d5a301f 100644
    --- a/src/wallet/test/wallet_transaction_tests.cpp
    +++ b/src/wallet/test/wallet_transaction_tests.cpp
    @@ -67,7 +67,7 @@ BOOST_AUTO_TEST_CASE(silent_payments_flag_roundtrip)
             return CWalletTx(deserialize, ss, no_variants);
         }};
    
    -    // A silent payments tx is written as replaced by itself, which is read back as the flag
    +    // A silent payments tx is written as replaced by the placeholder txid, which is read back as the flag
         CWalletTx sp_wtx{tx, TxStateInactive{}};
         sp_wtx.m_is_sp_tx = true;
         const CWalletTx sp_loaded{roundtrip(sp_wtx)};
    diff --git a/src/wallet/transaction.h b/src/wallet/transaction.h
    index 9bf4f9e5d1..5e5ac8606a 100644
    --- a/src/wallet/transaction.h
    +++ b/src/wallet/transaction.h
    @@ -187,6 +187,9 @@ public:
         }
     };
    
    +//!  zero padding and "silent payments" in ASCII
    +static constexpr Txid SILENT_PAYMENTS_PLACEHOLDER_TXID{"000000000000000000000000000000000073696c656e74207061796d656e7473"};
    +
     /**
      * A transaction with a bunch of additional info that only the owner cares about.
      * It includes any unrecorded transactions needed to link it back to the block chain.
    @@ -294,11 +297,11 @@ public:
             } else if (m_is_sp_tx) {
                 // Older releases don't know about silent payments and could fee bump this tx by
                 // adding inputs, which would invalidate its silent payments outputs. Mark it as
    -            // replaced by itself so they refuse to bump it. No tx can replace itself, so on load
    -            // this placeholder flags the tx as a silent payments tx. A new value is not used to
    -            // flag it, since releases that throw on unknown values could not load the wallet.
    +            // replaced by a placeholder txid so they refuse to bump it. On load, this placeholder
    +            // flags the tx as a silent payments tx. A new value is not used to flag it, since
    +            // releases that throw on unknown values could not load the wallet.
                 // Once the tx is replaced, the flag is not kept, since it can no longer be bumped.
    -            string_values["replaced_by_txid"] = GetHash().ToString();
    +            string_values["replaced_by_txid"] = SILENT_PAYMENTS_PLACEHOLDER_TXID.ToString();
             }
             string_values["fromaccount"] = "";
             if (nOrderPos != -1) string_values["n"] = util::ToString(nOrderPos);
    @@ -353,9 +356,9 @@ public:
                 else if (key == "to") m_comment_to = value;
                 else if (key == "replaces_txid") m_replaces_txid = Txid::FromHex(value);
                 else if (key == "replaced_by_txid") {
    -                // A tx replaced by itself is the placeholder written for silent payments txs, see Serialize
    +                // The placeholder written for silent payments txs, see Serialize
                     const auto txid{Txid::FromHex(value)};
    -                if (txid == GetHash()) {
    +                if (txid == SILENT_PAYMENTS_PLACEHOLDER_TXID) {
                         m_is_sp_tx = true;
                     } else {
                         m_replaced_by_txid = txid;
    diff --git a/test/functional/wallet_backwards_compatibility.py b/test/functional/wallet_backwards_compatibility.py
    index d343c71a4c..96a7870587 100755
    --- a/test/functional/wallet_backwards_compatibility.py
    +++ b/test/functional/wallet_backwards_compatibility.py
    @@ -35,6 +35,7 @@ from test_framework.util import (
     )
    
     LAST_KEYPOOL_INDEX = 9 # Index of the last derived address with the keypool size of 10
    +SILENT_PAYMENTS_PLACEHOLDER_TXID = "000000000000000000000000000000000073696c656e74207061796d656e7473"
    
     class BackwardsCompatibilityTest(BitcoinTestFramework):
         def set_test_params(self):
    @@ -311,8 +312,8 @@ class BackwardsCompatibilityTest(BitcoinTestFramework):
                 node_old.sendrawtransaction(tx_hex)
                 node_old.loadwallet(wallet_name)
                 old_wallet = node_old.get_wallet_rpc(wallet_name)
    -            assert_raises_rpc_error(-4, f"Cannot bump transaction {txid} which was already bumped by transaction {txid}", old_walle
    t.bumpfee, txid)
    -            assert_raises_rpc_error(-4, f"Cannot bump transaction {txid} which was already bumped by transaction {txid}", old_walle
    t.psbtbumpfee, txid)
    +            assert_raises_rpc_error(-4, f"Cannot bump transaction {txid} which was already bumped by transaction {SILENT_PAYMENTS_P
    LACEHOLDER_TXID}", old_wallet.bumpfee, txid)
    +            assert_raises_rpc_error(-4, f"Cannot bump transaction {txid} which was already bumped by transaction {SILENT_PAYMENTS_P
    LACEHOLDER_TXID}", old_wallet.psbtbumpfee, txid)
                 old_wallet.unloadwallet()
    
                 # Upgrade again: master still has the recipient data and can bump the tx
    

    Eunovo commented at 1:18 PM on October 21, 2026:

    Done.

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

  69. rustaceanrob commented at 5:31 AM on October 13, 2026: member
  70. Eunovo force-pushed on Oct 13, 2026
  71. DrahtBot added the label CI failed on Oct 13, 2026
  72. DrahtBot commented at 10:44 AM on October 13, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task i686, no IPC: https://github.com/bitcoin/bitcoin/actions/runs/38374955165/job/115220901904</sub> <sub>LLM reason (✨ experimental): CI failed during compilation: GCC 15’s -Wmaybe-uninitialized warning in prevector.h was treated as an error while building the Qt wallet tests.</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>

  73. Eunovo force-pushed on Oct 13, 2026
  74. Eunovo commented at 1:18 PM on October 13, 2026: contributor

    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 is the intended behaviour.

    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.

    send and sendall (with inputs selected) now follow the same rules.

    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.

    The persisted SP recipients information is only useful for bumping the transaction. This can only happen before it is confirmed.

    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.

    m_is_sp_tx is only useful before the transaction is bumped.

    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.

    AttemptSelectionSP has been updated to use the "negative" group. GetSelectionValue() is now used in place of effective_value for sorting the eligible groups.

  75. Eunovo force-pushed on Oct 13, 2026
  76. DrahtBot removed the label CI failed on Oct 13, 2026
  77. in src/rpc/output_script.cpp:1 in fd61ed1bf0 outdated


    rustaceanrob commented at 7:50 AM on October 14, 2026:

    We may want to introduce an RPC for this, but not sure if it should be introduced as part of this PR. A scanner in python that passes the test vectors is relatively straightforward, and we can open this separately.

    <details> <summary>Python scanner</summary>

    diff --git a/test/functional/feature_framework_unit_tests.py b/test/functional/feature_framework_unit_tests.py
    index 7da6fb53c3..6947ca3d7b 100755
    --- a/test/functional/feature_framework_unit_tests.py
    +++ b/test/functional/feature_framework_unit_tests.py
    @@ -16,6 +16,7 @@ from test_framework.test_framework import TEST_EXIT_PASSED, TEST_EXIT_FAILED
     # the output of `git grep unittest.TestCase ./test/functional/test_framework`
     TEST_FRAMEWORK_MODULES = [
         "address",
    +    "bip352",
         "crypto.bip324_cipher",
         "blocktools",
         "compressor",
    diff --git a/test/functional/test_framework/bip352.py b/test/functional/test_framework/bip352.py
    new file mode 100644
    index 0000000000..74b56929a4
    --- /dev/null
    +++ b/test/functional/test_framework/bip352.py
    @@ -0,0 +1,104 @@
    +#!/usr/bin/env python3
    +# Copyright (c) 2026-present The Bitcoin Core developers
    +# Distributed under the MIT software license, see the accompanying
    +# file COPYING or http://www.opensource.org/licenses/mit-license.php.
    +"""BIP352 silent payments scanning, without labels."""
    +
    +from io import BytesIO
    +import json
    +from pathlib import Path
    +import unittest
    +
    +from .crypto.secp256k1 import G, GE
    +from .key import H_POINT, ORDER, TaggedHash
    +from .messages import COutPoint, CTransaction, deser_string_vector, from_hex
    +from .script import hash160
    +from .test_node import TestNode
    +
    +K_MAX = 2323
    +
    +# (outpoint, scriptSig, witness stack, prevout scriptPubKey)
    +ScanInput = tuple[COutPoint, bytes, list[bytes], bytes]
    +
    +
    +def get_input_pubkey(script_sig: bytes, witness: list[bytes], prevout_spk: bytes) -> GE | None:
    +    """Return the public key of an input as a GE, or None if the input is not eligible."""
    +    if len(prevout_spk) == 34 and prevout_spk[:2] == b"\x51\x20":
    +        stack = list(witness)
    +        if len(stack) > 1 and stack[-1][:1] == b"\x50":
    +            stack.pop()
    +        if len(stack) > 1 and stack[-1][1:33] == bytes.fromhex(H_POINT):
    +            return None
    +        return GE.from_bytes_xonly(prevout_spk[2:])
    +    if len(prevout_spk) == 22 and prevout_spk[:2] == b"\x00\x14":
    +        if witness and len(witness[-1]) == 33:
    +            return GE.from_bytes(witness[-1])
    +        return None
    +    if len(prevout_spk) == 23 and prevout_spk[:2] == b"\xa9\x14":
    +        if script_sig[:3] == b"\x16\x00\x14" and len(script_sig) == 23 and witness and len(witness[-1]) == 33:
    +            return GE.from_bytes(witness[-1])
    +        return None
    +    if len(prevout_spk) == 25 and prevout_spk[:3] == b"\x76\xa9\x14" and prevout_spk[23:] == b"\x88\xac":
    +        for i in range(len(script_sig), 32, -1):
    +            pubkey = script_sig[i - 33:i]
    +            if hash160(pubkey) == prevout_spk[3:23]:
    +                return GE.from_bytes(pubkey)
    +    return None
    +
    +
    +def scan(inputs: list[ScanInput], output_spks: list[bytes], scan_privkey: bytes, spend_pubkey: bytes) -> list[dict[str, str]]:
    +    """Find the outputs paying to a silent payments recipient.
    +
    +    Return a list of {"output": x-only output key, "tweak": spend key tweak}, both hex.
    +    """
    +    pubkeys = [pubkey for pubkey in (get_input_pubkey(*i[1:]) for i in inputs) if pubkey is not None]
    +    A = sum(pubkeys, GE())
    +    if A.infinity:
    +        return []
    +    smallest_outpoint = min(outpoint.serialize() for outpoint, *_ in inputs)
    +    input_hash = int.from_bytes(TaggedHash("BIP0352/Inputs", smallest_outpoint + A.to_bytes_compressed()), "big")
    +    ecdh_shared_secret = (input_hash * int.from_bytes(scan_privkey, "big") % ORDER) * A
    +    B_spend = GE.from_bytes(spend_pubkey)
    +    outputs = {spk[2:] for spk in output_spks if len(spk) == 34 and spk[:2] == b"\x51\x20"}
    +    found: list[dict[str, str]] = []
    +    for k in range(K_MAX):
    +        tweak = TaggedHash("BIP0352/SharedSecret", ecdh_shared_secret.to_bytes_compressed() + k.to_bytes(4, "big"))
    +        P_k = (B_spend + int.from_bytes(tweak, "big") * G).to_bytes_xonly()
    +        if P_k not in outputs:
    +            break
    +        found.append({"output": P_k.hex(), "tweak": tweak.hex()})
    +    return found
    +
    +
    +def scan_tx(node: TestNode, tx_hex: str, scan_privkey: bytes, spend_pubkey: bytes) -> list[dict[str, str]]:
    +    """Scan a transaction, looking up its prevouts in the UTXO set of node, or with getrawtransaction."""
    +    tx = from_hex(CTransaction(), tx_hex)
    +    inputs: list[ScanInput] = []
    +    for i, txin in enumerate(tx.vin):
    +        txid, n = f"{txin.prevout.hash:064x}", txin.prevout.n
    +        prevout = node.gettxout(txid, n, include_mempool=False) or node.getrawtransaction(txid, True)["vout"][n]
    +        witness = tx.wit.vtxinwit[i].scriptWitness.stack if i < len(tx.wit.vtxinwit) else []
    +        inputs.append((txin.prevout, txin.scriptSig, witness, bytes.fromhex(prevout["scriptPubKey"]["hex"])))
    +    return scan(inputs, [txout.scriptPubKey for txout in tx.vout], scan_privkey, spend_pubkey)
    +
    +
    +class TestFrameworkBIP352(unittest.TestCase):
    +    def test_receiving_vectors(self) -> None:
    +        with (Path(__file__).parents[3] / "src/test/data/bip352_send_and_receive_vectors.json").open() as vectors_file:
    +            for test in json.load(vectors_file):
    +                for receiving in test["receiving"]:
    +                    given = receiving["given"]
    +                    if given["labels"]:
    +                        continue
    +                    inputs = [(
    +                        COutPoint(int(vin["txid"], 16), vin["vout"]),
    +                        bytes.fromhex(vin["scriptSig"]),
    +                        deser_string_vector(BytesIO(bytes.fromhex(vin["txinwitness"]))) if vin["txinwitness"] else [],
    +                        bytes.fromhex(vin["prevout"]["scriptPubKey"]["hex"]),
    +                    ) for vin in given["vin"]]
    +                    output_spks = [bytes.fromhex("5120" + output) for output in given["outputs"]]
    +                    spend_privkey = int(given["key_material"]["spend_priv_key"], 16)
    +                    found = scan(inputs, output_spks, bytes.fromhex(given["key_material"]["scan_priv_key"]),
    +                                 (spend_privkey * G).to_bytes_compressed())
    +                    expected = [{"output": output["pub_key"], "tweak": output["priv_key_tweak"]} for output in receiving["expected"
    ]["outputs"]]
    +                    self.assertEqual(sorted(found, key=lambda o: o["output"]), sorted(expected, key=lambda o: o["output"]), test["c
    omment"])
    

    </details>


    Eunovo commented at 1:18 PM on October 21, 2026:

    Done.

  78. Yudis-bit commented at 1:55 PM on October 15, 2026: none

    Failing when the key is missing at creation time makes sense, but the issue is during automatic coin selection.

    If a wallet holds both tr(unspendable, pk(leaf)) and a wpkh utxo, CheckSilentPaymentsInput passes because provider exists, and AttemptSelectionSP treats it as eligible since internal_key != NUMS_H. If coin selection picks that taproot utxo, send aborts with Missing the private key... even though the wpkh coin alone could fund the tx.

    Should AvailableCoins (or CheckSilentPaymentsInput) check if the provider can actually resolve the key first, so coin selection falls back to the wpkh utxo instead of failing the send?

  79. in src/wallet/spend.h:225 in 0c84361c40 outdated
     220 | + * Return an output of the same size as the one paying amount to dest, to estimate the
     221 | + * transaction size or check for dust before the inputs are known. A silent payments output
     222 | + * script is derived from the inputs, so a P2TR output is returned for a silent payments
     223 | + * destination, since BIP352 v0 outputs are P2TR outputs.
     224 | + */
     225 | +CTxOut GetDummyTxOut(const PaymentDestination& dest, CAmount amount);
    


    rustaceanrob commented at 5:17 AM on October 18, 2026:

    This function seems like it could introduce surface for bugs. In the case of scriptChange for instance, the scriptChange variable is used in the actual transaction later. I would prefer if these calls were inlined if possible. In 3/4 cases it is easily replaceable, and in the case of scriptChange, I would recommend swapping for an optional similar to what was done elsewhere in the PR. It has the nice effect of showing if the change is nullopt when we expect a script for a new CTxOut, then the error is thrown in that same line.

    diff --git a/src/wallet/feebumper.cpp b/src/wallet/feebumper.cpp
    index 1655346ac7..b85e3c6d5a 100644
    --- a/src/wallet/feebumper.cpp
    +++ b/src/wallet/feebumper.cpp
    @@ -360,7 +360,8 @@ util::Expected<BumpTransaction, BumpError> CreateRateBumpTransaction(CWallet& wa
             if (!outputs.empty()) {
                 temp_mtx.vout.clear();
                 for (const auto& [dest, amount] : outputs) {
    -                temp_mtx.vout.push_back(GetDummyTxOut(dest, amount));
    +                // Empty placeholder OK, temp_mtx is dropped after the fee rate check
    +                temp_mtx.vout.emplace_back(amount, dest.GetStaticScript().value_or(GetScriptForDestination(WitnessV1Taproot{})));
                 }
             }
    
    diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp
    index 515b299f95..f9b534c8c2 100644
    --- a/src/wallet/spend.cpp
    +++ b/src/wallet/spend.cpp
    @@ -1322,21 +1322,16 @@ void DiscourageFeeSniping(CMutableTransaction& tx, FastRandomContext& rng_fast,
         }
     }
    
    -CTxOut GetDummyTxOut(const PaymentDestination& dest, CAmount amount)
    -{
    -    if (const auto script{dest.GetStaticScript()}) return CTxOut{amount, *script};
    -    // BIP352 v0 outputs are P2TR outputs, which all have the same size
    -    return CTxOut{amount, GetScriptForDestination(WitnessV1Taproot{})};
    -}
    -
     uint64_t GetSerializeSizeForRecipient(const CRecipient& recipient)
     {
    -    return ::GetSerializeSize(GetDummyTxOut(recipient.dest, recipient.nAmount));
    +    const CScript script{recipient.dest.GetStaticScript().value_or(GetScriptForDestination(WitnessV1Taproot{}))};
    +    return ::GetSerializeSize(CTxOut{recipient.nAmount, script});
     }
    
     bool IsDust(const CRecipient& recipient, const CFeeRate& dustRelayFee)
     {
    -    return ::IsDust(GetDummyTxOut(recipient.dest, recipient.nAmount), dustRelayFee);
    +    const CScript script{recipient.dest.GetStaticScript().value_or(GetScriptForDestination(WitnessV1Taproot{}))};
    +    return ::IsDust(CTxOut{recipient.nAmount, script}, dustRelayFee);
     }
    
     std::vector<SilentPaymentsDestination> GetSilentPaymentsDestinations(const std::vector<CRecipient>& recipients, const CCoinControl&
     coin_control, const CreatedTransactionResult& txr)
    @@ -1442,7 +1437,7 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
         }
    
         // Create change script that will be used if we need change
    -    CScript scriptChange;
    +    std::optional<CScript> scriptChange;
         bilingual_str error; // possible error str
    
         // coin control: send change to custom address
    @@ -1451,7 +1446,7 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
             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 = coin_control.destChange->GetStaticScript();
         } 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
    @@ -1473,9 +1468,10 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
             // A valid destination implies a change script (and
             // vice-versa). An empty change script will abort later, if the
             // change keypool ran out, but change is required.
    -        CHECK_NONFATAL(IsValidDestination(dest) != scriptChange.empty());
    +        CHECK_NONFATAL(IsValidDestination(dest) == scriptChange.has_value());
         }
    -    CTxOut change_prototype_txout(0, scriptChange);
    +    const bool sp_change{coin_control.destChange && coin_control.destChange->IsSilentPayment()};
    +    CTxOut change_prototype_txout(0, sp_change ? GetScriptForDestination(WitnessV1Taproot{}) : scriptChange.value_or(CScript{}));
         coin_selection_params.change_output_size = GetSerializeSize(change_prototype_txout);
    
         // Get size of spending the change output
    @@ -1623,7 +1619,9 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
             txNew.vout.emplace_back(vecSend[i].nAmount, sp_it != sp_outputs.end() ? GetScriptForDestination(sp_it->second) : *Assert(ve
    cSend[i].dest.GetStaticScript()));
         }
         if (change_amount > 0) {
    -        CTxOut newTxOut(change_amount, scriptChange);
    +        if (!scriptChange) return util::Error{_("Change amount is non-zero but no change address was set")};
    +        CTxOut newTxOut(change_amount, *scriptChange);
             if (!change_pos) {
                 // Insert change txn at random position:
                 change_pos = rng_fast.randrange(txNew.vout.size() + 1);
    @@ -1764,11 +1762,6 @@ static util::Result<CreatedTransactionResult> CreateTransactionInternal(
             return util::Error{Untranslated(STR_INTERNAL_BUG("Fee needed > fee paid"))};
         }
    
    -    // Give up if change keypool ran out and change is required
    -    if (scriptChange.empty() && change_pos) {
    -        return util::Error{error};
    -    }
    -
         if (sign && !wallet.SignTransaction(txNew)) {
             return util::Error{_("Signing transaction failed")};
         }
    diff --git a/src/wallet/spend.h b/src/wallet/spend.h
    index 8ffdc60099..1485dbcb5a 100644
    --- a/src/wallet/spend.h
    +++ b/src/wallet/spend.h
    @@ -219,14 +219,6 @@ util::Result<SelectionResult> SelectCoins(const CWallet& wallet, CoinsResult& av
                                               const CAmount& nTargetValue, const CCoinControl& coin_control,
                                               const CoinSelectionParams& coin_selection_params) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wall
    et);
    
    -/**
    - * Return an output of the same size as the one paying amount to dest, to estimate the
    - * transaction size or check for dust before the inputs are known. A silent payments output
    - * script is derived from the inputs, so a P2TR output is returned for a silent payments
    - * destination, since BIP352 v0 outputs are P2TR outputs.
    - */
    -CTxOut GetDummyTxOut(const PaymentDestination& dest, CAmount amount);
    -
    

    Yudis-bit commented at 6:11 AM on October 18, 2026:

    The proposed change to CreateTransactionInternal introduces an empty error return on custom change failure paths.

    In CreateTransactionInternal, bilingual_str error is only assigned when keypool reservation fails under the else branch. Under if (coin_control.destChange), error remains default-constructed. If destChange is a silent payments destination, destChange->GetStaticScript() returns std::nullopt. If change_amount > 0 and scriptChange is not populated, executing if (!scriptChange) return util::Error{error}; returns an empty string, yielding {"code": -4, "message": ""} via RPC. The failure path with coin_control.destChange set cannot reuse error and requires an explicit diagnostic.

    Inlining dest.GetStaticScript().value_or(GetScriptForDestination(WitnessV1Taproot{})) across feebumper.cpp, GetSerializeSizeForRecipient, IsDust, and change_prototype_txout duplicates the BIP352 v0 output structure across multiple wallet subsystems. It also necessitates the ad-hoc ternary sp_change solely to re-create the dummy taproot output for change_prototype_txout. A centralized sizing helper keeps the size assumption encapsulated, while unassigned change scripts can be guarded directly at the point scriptChange is resolved.


    rustaceanrob commented at 11:32 AM on October 18, 2026:

    In CreateTransactionInternal, bilingual_str error is only assigned when keypool reservation fails under the else branch. Under if (coin_control.destChange), error remains default-constructed. If destChange is a silent payments destination, destChange->GetStaticScript() returns std::nullopt. If change_amount > 0 and scriptChange is not populated, executing if (!scriptChange) return util::Error{error}; returns an empty string, yielding {"code": -4, "message": ""} via RPC. The failure path with coin_control.destChange set cannot reuse error and requires an explicit diagnostic.

    A simple "you did not set the error in the nullopt case" would've sufficed.

    Inlining dest.GetStaticScript().value_or(GetScriptForDestination(WitnessV1Taproot{})) across feebumper.cpp, GetSerializeSizeForRecipient, IsDust, and change_prototype_txout duplicates the BIP352 v0 output structure across multiple wallet subsystems. It also necessitates the ad-hoc ternary sp_change solely to re-create the dummy taproot output for change_prototype_txout. A centralized sizing helper keeps the size assumption encapsulated, while unassigned change scripts can be guarded directly at the point scriptChange is resolved.

    There is no cost to creating the taproot output so I'm not sure what this comment is trying to imply. Doing this inline allows reviewers to convince themselves that a dummy taproot output will not be signed under any circumstance, and is only used for computing size and dust. In the case of the catch all condition (scriptChange.empty() && change_pos), this would pass for a template taproot output script AFICT. We should just use the type system to ensure we don't sign anything that can result in loss of funds.


    Yudis-bit commented at 2:05 PM on October 18, 2026:

    Fair enough, just wanted to flag the empty error on destChange.


    Eunovo commented at 1:14 PM on October 21, 2026:

    The purpose of GetDummyTxOut is to avoid duplicating the dummy script logic for silent payments outputs. I don't think this function inherently introduces bugs; I think it does the opposite by making it possible to change the dummy script logic in one place. I rewrote the scriptChange logic, but left the other code as is.

  80. Eunovo force-pushed on Oct 21, 2026
  81. Eunovo force-pushed on Oct 21, 2026
  82. DrahtBot added the label CI failed on Oct 21, 2026
  83. Eunovo commented at 1:17 PM on October 21, 2026: contributor

    If a wallet holds both tr(unspendable, pk(leaf)) and a wpkh utxo, CheckSilentPaymentsInput passes because provider exists, and AttemptSelectionSP treats it as eligible since internal_key != NUMS_H. If coin selection picks that taproot utxo, send aborts with Missing the private key... even though the wpkh coin alone could fund the tx.

    Thanks for pointing this out. I have updated the rules in CheckSilentPaymentsInput to exclude eligible coins with missing private keys.

  84. 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.
    59eccfe5da
  85. common, rpc: 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>
    79ed2003a5
  86. wallet, rpc: use `PaymentDestination` in `CRecipient` and `CCoinControl`
    Change `CRecipient::dest` to hold a `PaymentDestination` and
    `CCoinControl::destChange` to hold an optional `PaymentDestination`,
    in preparation for sending to silent payments addresses. A
    `std::nullopt` change destination means a change address is generated.
    No behavior change.
    
    Co-authored-by: rustaceanrob <rob.netzke@gmail.com>
    b9ef87b4bc
  87. 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.
    50117f2bf3
  88. 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: outputs of an unknown witness version, which BIP352
    receivers skip, and external inputs that would be used for the shared
    secret, since the wallet does not have their private keys.
    
    The transaction must also have at least one input eligible for the
    BIP352 shared secret. Add `SelectCoinsForSilentPayments`, which runs
    the normal selection and, if it has no eligible input, presets an
    eligible coin and selects again. Confirmed coins and coins with a
    higher selection amount are tried first.
    
    Co-authored-by: josibake <josibake@protonmail.com>
    e90bcbe082
  89. 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.
    
    Co-authored-by: josibake <josibake@protonmail.com>
    a8d5921304
  90. wallet/rpc: enable sp addr in send, sendmany and sendtoaddress
    Make `ParseOutputs` return `PaymentDestination`s and take an
    `allow_silent_payments` option, and set it in `send`, `sendmany` and
    `sendtoaddress`. Other callers keep rejecting silent payments addresses.
    
    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>
    c7213ca27a
  91. 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.
    The coins picked with `inputs` are checked the same way as those of
    `send`.
    63a8a15738
  92. Eunovo force-pushed on Oct 21, 2026
  93. test: add BIP352 scanning to the functional test framework
    Add a Python implementation of BIP352 scanning, without labels, so the
    functional tests can check silent payments outputs independently of the
    node. It is tested against the BIP352 receiving test vectors.
    
    Co-authored-by: rustaceanrob <rob.netzke@gmail.com>
    d19540986c
  94. 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`, and as the `change_address` of `send`, whose change
    destination is recorded with the recipients. `fundrawtransaction` and
    `walletcreatefundedpsbt` refuse an sp `change_address`.
    
    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.
    1968843440
  95. 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.
    3e55df3748
  96. Eunovo force-pushed on Oct 22, 2026
  97. DrahtBot removed the label CI failed on Oct 22, 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-10-30 09:40 UTC