Silent Payments: Implement bip352 (take 2) #35301

pull Eunovo wants to merge 7 commits into bitcoin:master from Eunovo:implement-bip352 changing 22 files +7031 −17
  1. Eunovo commented at 3:46 AM on May 16, 2026: contributor

    This PR is part of integrating silent payments into Bitcoin Core. It is the second iteration of #28122, now based on https://github.com/bitcoin-core/secp256k1/pull/1765.

    This project is tracked in #28536.

    BIP352 This PR focuses strictly on the BIP logic and attempts to separate it from the wallet and transaction implementation details. This is accomplished by working directly with public and private keys, instead of needing a wallet backend and transactions for testing. Labels for the receiver are optional and thus deferred for a later PR.

    Test vectors from the BIP are included as unit tests.

  2. DrahtBot commented at 3:46 AM on May 16, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35301.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

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

    Type Reviewers
    Concept ACK w0xlt, rkrux, josibake

    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:

    • #36122 (BIP460: CISA for Taproot key path spends by fjahr)
    • #36087 (util: Add and use AssertUnreachable by maflcko)
    • #35793 (Implement BIP 54 (Consensus Cleanup) without mainnet activation by darosior)
    • #35642 (headersync: do parameter search at runtime by sipa)
    • #32729 (test,script: add sigop helpers (without consensus migration) by l0rinc)

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

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

    LLM Linter (✨ experimental)

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

    • Coin{CTxOut{{}, spk}, 0, false} in src/test/bip352_tests.cpp
    • Coin{CTxOut{{}, GetScriptForDestination(WitnessV0KeyHash{pubkey})}, 0, false} in src/test/bip352_tests.cpp
    • Coin{CTxOut{{}, GetScriptForDestination(WitnessV0KeyHash{sender_pubkey})}, 0, false} in src/test/bip352_tests.cpp

    <sup>2026-08-24 16:14:56</sup>

  3. in src/common/bip352.cpp:25 in 6355a90494
      20 | +#include <script/solver.h>
      21 | +#include <script/script_error.h>
      22 | +#include <util/check.h>
      23 | +#include <util/strencodings.h>
      24 | +
      25 | +extern secp256k1_context* secp256k1_context_sign; // TODO: this is hacky, is there a better solution?
    


    theStack commented at 1:43 AM on May 18, 2026:

    in 6355a904945d0d5704032e464f20a69a5d82a91b: Since #34225 (commit f36d89f4363a25f7948a0f7096201ef8e15045d8), the sign context can be accessed via GetSecp256k1SignContext, i.e. by using this in the API calls below, this line and the symbol visibility change in key.cpp are not needed anymore


    Eunovo commented at 4:03 PM on May 18, 2026:

    Done.

  4. in src/common/bip352.h:103 in 6355a90494
      98 | + * Label public keys can be stored in a cache, mapping the public key to the label tweak. This cache
      99 | + * is used during scanning to determine if a label was used and if so to retrieve the label tweak.
     100 | + *
     101 | + * @param scan_key                 The recipient's scan_key, used to salt the hash
     102 | + * @param m                        An integer m (only use m = 0 for the change label)
     103 | + * @return std::<CPubKey, uint156> The label public key and label tweak.
    


    theStack commented at 1:59 AM on May 18, 2026:

    in 6355a904945d0d5704032e464f20a69a5d82a91b: typo: s/uint156/uint256/


    Eunovo commented at 4:03 PM on May 18, 2026:

    Done.

  5. in src/common/bip352.h:156 in 6355a90494 outdated
     151 | + * @param spend_pubkey                                      The recipient's spend public key.
     152 | + * @param output_pub_keys                                   The taproot output public keys.
     153 | + * @param labels                                            The recipient's labels.
     154 | + * @return std::<optional<std::vector<SilentPaymentOutput>> The found outputs, nullopt if none found.
     155 | + */
     156 | +std::optional<std::vector<SilentPaymentOutput>> ScanForSilentPaymentOutputs(const CKey& scan_key, const PrevoutsSummary& prevouts_summary, const CPubKey& spend_pubkey, const std::vector<XOnlyPubKey>& output_pub_keys, const std::map<CPubKey, uint256>& labels);
    


    theStack commented at 2:04 AM on May 18, 2026:

    in 6355a904945d0d5704032e464f20a69a5d82a91b: nit: missing doxygen @param entry for prevouts_summary above


    Eunovo commented at 4:04 PM on May 18, 2026:

    Done.

  6. in src/common/bip352.cpp:211 in 6355a90494 outdated
     206 | +    generated_output_ptrs.reserve(recipients.size());
     207 | +
     208 | +    for (size_t i = 0; i < recipients.size(); i++) {
     209 | +        secp256k1_silentpayments_recipient recipient_obj;
     210 | +        ret = secp256k1_ec_pubkey_parse(secp256k1_context_static, &recipient_obj.scan_pubkey, recipients[i].m_scan_pubkey.data(), recipients[i].m_scan_pubkey.size());
     211 | +        ret = secp256k1_ec_pubkey_parse(secp256k1_context_static, &recipient_obj.spend_pubkey, recipients[i].m_spend_pubkey.data(), recipients[i].m_spend_pubkey.size());
    


    theStack commented at 2:10 AM on May 18, 2026:

    in 6355a904945d0d5704032e464f20a69a5d82a91b:

            ret &= secp256k1_ec_pubkey_parse(secp256k1_context_static, &recipient_obj.spend_pubkey, recipients[i].m_spend_pubkey.data(), recipients[i].m_spend_pubkey.size());
    

    to ensure both pubkey_parse are successful (or alternatively, could place an extra assert(ret) line after the first call)


    Eunovo commented at 4:06 PM on May 18, 2026:

    I added an assert(ret); after the first call, so that it is easier to determine which of the pubkeys is invalid, in the event of a crash.

  7. in src/common/bip352.cpp:336 in 6355a90494
     331 | +        secp256k1_silentpayments_found_output found_output{};
     332 | +        secp256k1_xonly_pubkey tx_output_obj;
     333 | +        found_output_objs.push_back(found_output);
     334 | +        found_output_ptrs.push_back(&found_output_objs[i]);
     335 | +        ret = secp256k1_xonly_pubkey_parse(secp256k1_context_static, &tx_output_obj, tx_outputs[i].data());
     336 | +        assert(ret);
    


    theStack commented at 2:21 AM on May 18, 2026:

    in 6355a904945d0d5704032e464f20a69a5d82a91b: IIUC, this call could fail if a transaction is scanned where one of the P2TR outputs encodes an invalid x-only pubkey (i.e. not on the curve), so I suppose this should be changed to e.g. if (!ret) continue; to avoid a crash (unless we demand from the caller that tx_outputs only contain valid x-only pubkeys already)


    Eunovo commented at 4:04 PM on May 18, 2026:

    Changed to if (!ret) continue;

  8. in src/common/bip352.cpp:32 in 6355a90494
      27 | +namespace bip352 {
      28 | +
      29 | +class PrevoutsSummaryImpl
      30 | +{
      31 | +private:
      32 | +    //! The actual secnonce itself
    


    theStack commented at 2:24 AM on May 18, 2026:

    in 6355a904945d0d5704032e464f20a69a5d82a91b: comment doesn't apply


    Eunovo commented at 4:04 PM on May 18, 2026:

    Done.

  9. in src/addresstype.h:162 in ba4b734ec8
     158 | @@ -140,7 +159,7 @@ struct PayToAnchor : public WitnessUnknown
     159 |   *  * WitnessUnknown: TxoutType::WITNESS_UNKNOWN destination (P2W??? address)
     160 |   *  A CTxDestination is the internal data type encoded in a bitcoin address
     161 |   */
     162 | -using CTxDestination = std::variant<CNoDestination, PubKeyDestination, PKHash, ScriptHash, WitnessV0ScriptHash, WitnessV0KeyHash, WitnessV1Taproot, PayToAnchor, WitnessUnknown>;
     163 | +using CTxDestination = std::variant<CNoDestination, V0SilentPaymentDestination, PubKeyDestination, PKHash, ScriptHash, WitnessV0ScriptHash, WitnessV0KeyHash, WitnessV1Taproot, PayToAnchor, WitnessUnknown>;
    


    theStack commented at 2:30 AM on May 18, 2026:

    in ba4b734ec89d590951574d2714867afab27d1347: nit: could add a corresponding new entry to the comment list a few lines above


    Eunovo commented at 4:11 PM on May 18, 2026:

    Done.

  10. in src/bech32.h:40 in ba4b734ec8
      36 | @@ -37,6 +37,7 @@ enum class Encoding {
      37 |   *  and we would never encode an address with such a massive value */
      38 |  enum CharLimit : size_t {
      39 |      BECH32 = 90,            //!< BIP173/350 imposed character limit for Bech32(m) encoded addresses. This guarantees finding up to 4 errors.
      40 | +    SILENT_PAYMENTS = 1024, //!< BIP352 imposed 1024 character limit on Bech32m encoded silent payment addresses. This guarantees finding up to 3 errors
    


    theStack commented at 2:37 AM on May 18, 2026:

    in ba4b734ec89d590951574d2714867afab27d1347: pedantic nit: according to BIP-352 the limit is 1023 (not sure which of the two values make more sense, as I'm not very familiar with BIP-173; for SPV0 it doesn't matter anyways)


    Eunovo commented at 4:10 PM on May 18, 2026:

    Changed to 1023 to match the BIP specification. AFAICT, there is no reason to use 1024; the most likely reason for it being 1024 is that the BIP might have stated 1024 and was updated to 1023 at some point in the past.

  11. Eunovo force-pushed on May 18, 2026
  12. w0xlt commented at 6:21 PM on May 19, 2026: contributor

    Concept ACK

  13. rkrux commented at 6:47 PM on May 19, 2026: contributor

    Concept ACK aca8a8f

  14. in src/kernel/chainparams.h:116 in 602835e080
     112 | @@ -113,6 +113,7 @@ class CChainParams
     113 |      const std::vector<std::string>& DNSSeeds() const { return vSeeds; }
     114 |      const std::vector<unsigned char>& Base58Prefix(Base58Type type) const { return base58Prefixes[type]; }
     115 |      const std::string& Bech32HRP() const { return bech32_hrp; }
     116 | +    const std::string& SilentPaymentHRP() const { return silent_payment_hrp; }
    


    theStack commented at 2:01 PM on May 20, 2026:

    in 602835e08070cab981842ce4cd5065730b3e8c48 (and 6b71f145ccfdbfe41f7f666d373efb0a68e30375 ff.): nitty nit: personally, I would slightly prefer to use the plural form in the code base since it's the widely used protocol name

        const std::string& SilentPaymentsHRP() const { return silent_payments_hrp; }
    

    Eunovo commented at 3:00 PM on May 21, 2026:

    Done. I also pluralised the name in other function names, variable names and comments.

  15. in src/key_io.cpp:178 in 6b71f145cc
     174 | +                error_str = strprintf("Silent payment version is v0 but data is not the correct size (expected %d, got %d).", SILENT_PAYMENT_V0_DATA_SIZE, data.size());
     175 | +                return CNoDestination();
     176 | +            }
     177 | +            CPubKey scan_pubkey{data.begin(), data.begin() + CPubKey::COMPRESSED_SIZE};
     178 | +            CPubKey spend_pubkey{data.begin() + CPubKey::COMPRESSED_SIZE, data.begin() + 2*CPubKey::COMPRESSED_SIZE};
     179 | +            return V0SilentPaymentDestination{scan_pubkey, spend_pubkey};
    


    theStack commented at 2:35 PM on May 20, 2026:

    in 6b71f145ccfdbfe41f7f666d373efb0a68e30375: Related to a recent off-band discussion we had, I wonder if we should check the validity of the pubkeys (i.e. following the compressed pubkey format and being on the curve) already at this point, e.g. via

                if (!scan_pukey.IsFullyValid() || !spend_pubkey.IsFullyValid()) return CNoDestination();
    

    We don't do the same when decoding taproot addresses (currently the only other address format that directly encodes public keys, without hashing), but the difference with SP here is that an actual output script can't even be derived in this case, so it could make more sense to reject as early as possible.


    Eunovo commented at 2:59 PM on May 21, 2026:

    Done.

  16. in src/test/data/bip352_send_and_receive_vectors.json:1 in aca8a8f3da outdated
       0 | @@ -0,0 +1,2760 @@
       1 | +[
    


    theStack commented at 2:54 PM on May 20, 2026:

    in aca8a8f3da02b925f8975ffa6f31468fafdc2ef9: looks like test vectors .json file needs to be updated (to BIP-352 version 1.1.1, see latest change https://github.com/bitcoin/bips/pull/2142).


    Eunovo commented at 2:59 PM on May 21, 2026:

    Updated to the test_vectors in https://github.com/bitcoin/bips/pull/2142

  17. in src/common/bip352.cpp:82 in 0287192f09 outdated
      77 | +    } else if (type == TxoutType::WITNESS_V0_KEYHASH && !txin.scriptWitness.stack.empty()) {
      78 | +        // We ensure the witness stack is not empty before exctracting the public key
      79 | +        // since there are scenarios where it can be, e.g., before the transaction has been signed.
      80 | +        //
      81 | +        // TODO: having this check here feels a bit hacky, will revisit with a more comprehensive solution
      82 | +        pubkey = CPubKey{txin.scriptWitness.stack.back()};
    


    theStack commented at 2:56 PM on May 20, 2026:

    in 0287192f099299fb7a0b7f83f5e3bfb3174ed152: unless I'm missing something, there is nothing wrong in checking that the witness stack is non-empty before accessing it, and the TODO could simply be removed


    Eunovo commented at 2:58 PM on May 21, 2026:

    Removed.

  18. in src/key_io.cpp:80 in 6b71f145cc
      75 | +        data_in.reserve(66);
      76 | +        // Set 0 as the silent payments version
      77 | +        std::vector<unsigned char> data_out = {0};
      78 | +        // ConvertBits will expand each 8-bit byte into 5-bit chunks,
      79 | +        // i.e. (67 * 8 / 5) = 107.2 -> so we reserve 108
      80 | +        data_out.reserve(108);
    


    theStack commented at 4:27 PM on May 20, 2026:

    in 6b71f145ccfdbfe41f7f666d373efb0a68e30375: pedantic nit: the version byte is not part of the ConvertBits input, i.e. this should be

            // ConvertBits will expand each 8-bit byte into 5-bit chunks,
            // i.e. 1 + (66 * 8 / 5) = 106.6 -> so we reserve 107
            data_out.reserve(107);
    

    (verified that data_out has indeed a size of 107 by adding debug outputs)


    Eunovo commented at 2:58 PM on May 21, 2026:

    Updated.

  19. in src/test/bip352_tests.cpp:94 in aca8a8f3da
      89 | +            const std::vector<UniValue>& silent_payment_addresses = given["recipients"].getValues();
      90 | +            for (size_t i = 0; i < silent_payment_addresses.size(); ++i) {
      91 | +                const CTxDestination& tx_dest = DecodeDestination(silent_payment_addresses[i].get_str());
      92 | +                if (const auto* sp = std::get_if<V0SilentPaymentDestination>(&tx_dest)) {
      93 | +                    sp_dests[i] = *sp;
      94 | +                }
    


    theStack commented at 4:31 PM on May 20, 2026:

    in aca8a8f3da02b925f8975ffa6f31468fafdc2ef9: could do a round-trip test in the if body, to also add test coverage for encoding SP addresses, e.g.

           auto encoded_sp_addr = EncodeDestination(*sp);
           BOOST_CHECK(encoded_sp_addr == silent_payment_addresses[i].get_str());
    

    Eunovo commented at 2:58 PM on May 21, 2026:

    I added some tests for Encoding and Decoding V0SilentPaymentsDestination to key_io_tests.cpp in https://github.com/bitcoin/bitcoin/pull/35301/commits/15a2bdd5a18ac6d543b669f9230ea5bd7352c497

  20. in src/test/bip352_tests.cpp:37 in aca8a8f3da
      32 | +    key.SetSeed(seed);
      33 | +    for (auto index : path) {
      34 | +        BOOST_CHECK(key.Derive(key, index));
      35 | +    }
      36 | +    return key.key;
      37 | +}
    


    theStack commented at 4:35 PM on May 20, 2026:

    in aca8a8f3da02b925f8975ffa6f31468fafdc2ef9: this function is currently unused


    Eunovo commented at 2:57 PM on May 21, 2026:

    Removed.

  21. in src/common/bip352.cpp:264 in 0287192f09 outdated
     259 | +    }
     260 | +    return tr_dests;
     261 | +}
     262 | +
     263 | +const unsigned char* LabelLookupCallback(const unsigned char* key, const void* context) {
     264 | +    auto label_context = static_cast<const std::map<CPubKey, uint256>*>(context);
    


    theStack commented at 12:17 AM on May 21, 2026:

    in 5b0d46e13980f227908186dfdd529d030ff7400a: I suppose using std::unordered_map for the labels cache would be the better choice for performance reasons, at least if we ever support a large number of labels (it doesn't matter until actual SP receiving support is implemented though, and can be re-evaluated and benchmarked then)


    Eunovo commented at 10:02 AM on May 25, 2026:

    Changed to std::unordered_map, but that required that I change the labels cache from map<CPubKey, uint256> to unordered_map<CKeyID, uint256, SaltedSipHasher> because CPubKey doesn't have a hash function suitable for use with unordered_map.

  22. Eunovo force-pushed on May 21, 2026
  23. rkrux commented at 3:01 PM on May 21, 2026: contributor

    Does https://github.com/bitcoin-core/secp256k1/pull/1765 need to be merged first for this to be reviewed (and later merged)?

  24. theStack commented at 3:22 PM on May 21, 2026: contributor

    Does bitcoin-core/secp256k1#1765 need to be merged first for this to be reviewed (and later merged)?

    There are no major API changes expected at this point in bitcoin-core/secp256k1#1765, so I'd say this PR can be already reviewed now. For merging it though, the SP module merge and secp256k1 subtree update have to go in first. Obviously, any review in https://github.com/bitcoin-core/secp256k1/pull/1765 would be much appreciated :)

  25. DrahtBot added the label CI failed on May 21, 2026
  26. DrahtBot commented at 4:40 PM on May 21, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task Windows native, fuzz, VS: https://github.com/bitcoin/bitcoin/actions/runs/26233980188/job/77201857783</sub> <sub>LLM reason (✨ experimental): Fuzz testing failed because script_fuzz_target hit an assertion in src/test/fuzz/script.cpp:161 (tx_destination_1 == DecodeDestination(encoded_dest)).</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>

  27. Eunovo force-pushed on May 22, 2026
  28. in src/common/bip352.cpp:288 in 5b0d46e139 outdated
     283 | +    assert(ret);
     284 | +    ret = secp256k1_silentpayments_recipient_create_labeled_spend_pubkey(secp256k1_context_static, &labeled_spend_obj, &spend_obj, &label_obj);
     285 | +    assert(ret);
     286 | +    size_t pubkeylen = CPubKey::COMPRESSED_SIZE;
     287 | +    CPubKey labeled_spend_pubkey;
     288 | +    ret = secp256k1_ec_pubkey_serialize(secp256k1_context_static, (unsigned char*)labeled_spend_pubkey.begin(), &pubkeylen, &labeled_spend_obj, SECP256K1_EC_COMPRESSED);
    


    theStack commented at 3:03 PM on May 22, 2026:

    in 5b0d46e13980f227908186dfdd529d030ff7400a: could assert(ret) here as well, as this serialization should never fail


    Eunovo commented at 10:00 AM on May 25, 2026:

    Done.

  29. in src/key.cpp:19 in 5b0d46e139
      15 | @@ -16,7 +16,7 @@
      16 |  #include <secp256k1_recovery.h>
      17 |  #include <secp256k1_schnorrsig.h>
      18 |  
      19 | -static secp256k1_context* secp256k1_context_sign = nullptr;
      20 | +secp256k1_context* secp256k1_context_sign = nullptr;
    


    theStack commented at 3:07 PM on May 22, 2026:

    in 5b0d46e13980f227908186dfdd529d030ff7400a: this change isn't needed anymore, since you are using the GetSecp256k1SignContext() access function now


    Eunovo commented at 9:59 AM on May 25, 2026:

    Done.

  30. in src/addresstype.h:153 in 7df966bc37
     148 | +        return false;
     149 | +    }
     150 | +
     151 | +private:
     152 | +    CPubKey m_scan_pubkey;
     153 | +    CPubKey m_spend_pubkey;
    


    theStack commented at 3:11 PM on May 22, 2026:

    in 7df966bc37fe640660ff8a9cd55a9fd5331527dc: consistency micro-nit: in other destination classes, the private: part comes before the public one, so could move this up


    Eunovo commented at 9:59 AM on May 25, 2026:

    Done.

  31. in src/addresstype.h:141 in 7df966bc37
     136 | +    const CPubKey& GetSpendPubKey() const { return m_spend_pubkey; }
     137 | +
     138 | +    friend bool operator==(const V0SilentPaymentsDestination& a, const V0SilentPaymentsDestination& b) {
     139 | +        if (a.m_scan_pubkey != b.m_scan_pubkey) return false;
     140 | +        if (a.m_spend_pubkey != b.m_spend_pubkey) return false;
     141 | +        return true;
    


    theStack commented at 3:13 PM on May 22, 2026:

    in 7df966bc37fe640660ff8a9cd55a9fd5331527dc: nit: I think this could be simplified to a one-liner

            return (a.m_scan_pubkey == b.m_scan_pubkey) && (a.m_spend_pubkey == b.m_spend_pubkey);
    

    without changing the logic or performance (didn't verify though).


    Eunovo commented at 9:59 AM on May 25, 2026:

    Done.

  32. theStack commented at 3:22 PM on May 22, 2026: contributor

    Thanks for the quick follow-up! Left a few more comments, most of them being nits. Mostly reviewed the parts around the SP module API calls so far, will take a closer look at higher-level parts of the protocol (particularly the pubkey extraction logic in GetPubKeyFromInput) within the next days.

  33. Eunovo force-pushed on May 25, 2026
  34. DrahtBot removed the label CI failed on May 25, 2026
  35. in src/key_io.cpp:172 in 061edd8f42 outdated
     168 | +            }
     169 | +            auto version = dec.data[0];  // retrieve the version
     170 | +            if (version >= 31) {
     171 | +                error_str = strprintf("This implementation only supports sending to Silent payments addresses v0 through v30 (got %d).", version);
     172 | +                return CNoDestination();
     173 | +            }
    


    theStack commented at 12:32 AM on May 27, 2026:

    in 061edd8f427674914d98a22e118122fd8dd0a0c8: currently, SP addresses with (not yet specified) versions 1-30 are already accepted and get shoehorned into V0SilentPaymentsDestinations. Is that intentional? I guess it's not and it's fine to only allow V0 destinations for now, but if yes, we should probably add tests for v1-v30 addresses; might be a bit tricky though as the encoding round-trip tests would obviously fail.


    Eunovo commented at 11:07 AM on May 29, 2026:

    I added a new struct, called UnknownSilentPaymentsVersion, to handle versions 1 to 30. I added some valid and invalid addresses with a version greater than zero.

  36. in src/common/bip352.cpp:238 in 80d3a4853a
     233 | +{
     234 | +    bool ret;
     235 | +    std::map<size_t, WitnessV1Taproot> tr_dests;
     236 | +    std::vector<V0SilentPaymentsDestination> recipients;
     237 | +    recipients.reserve(sp_dests.size());
     238 | +    for (const auto& [_, addr] : sp_dests) {
    


    w0xlt commented at 11:27 PM on May 27, 2026:

    GenerateSilentPaymentsTaprootDestinations() documents sp_dests keys as final tx.vout positions, but returns generated outputs under contiguous indexes 0..n-1.

    If SP outputs are mixed with regular outputs, callers would assign them to the wrong positions; the original map keys should be preserved.

    Diff:

    diff --git a/src/common/bip352.cpp b/src/common/bip352.cpp
    index 1580e1f8f8..0ac71d456f 100644
    --- a/src/common/bip352.cpp
    +++ b/src/common/bip352.cpp
    @@ -234,8 +234,11 @@ std::optional<std::map<size_t, WitnessV1Taproot>> GenerateSilentPaymentsTaprootD
         bool ret;
         std::map<size_t, WitnessV1Taproot> tr_dests;
         std::vector<V0SilentPaymentsDestination> recipients;
    +    std::vector<size_t> positions;
         recipients.reserve(sp_dests.size());
    -    for (const auto& [_, addr] : sp_dests) {
    +    positions.reserve(sp_dests.size());
    +    for (const auto& [pos, addr] : sp_dests) {
    +        positions.push_back(pos);
             recipients.push_back(addr);
         }
         std::vector<secp256k1_xonly_pubkey> outputs = CreateOutputs(recipients, plain_keys, taproot_keys, smallest_outpoint);
    @@ -245,7 +248,7 @@ std::optional<std::map<size_t, WitnessV1Taproot>> GenerateSilentPaymentsTaprootD
             unsigned char xonly_pubkey_bytes[32];
             ret = secp256k1_xonly_pubkey_serialize(secp256k1_context_static, xonly_pubkey_bytes, &outputs[i]);
             assert(ret);
    -        tr_dests[i] = WitnessV1Taproot{XOnlyPubKey{xonly_pubkey_bytes}};
    +        tr_dests[positions[i]] = WitnessV1Taproot{XOnlyPubKey{xonly_pubkey_bytes}};
         }
         return tr_dests;
     }
    

    Test:

    diff --git a/src/test/bip352_tests.cpp b/src/test/bip352_tests.cpp
    index cd5fda38da..bc05fcf107 100644
    --- a/src/test/bip352_tests.cpp
    +++ b/src/test/bip352_tests.cpp
    @@ -27,6 +27,25 @@ CKey ParseHexToCKey(std::string hex) {
         return output;
     };
     
    +BOOST_AUTO_TEST_CASE(bip352_preserves_requested_output_indexes)
    +{
    +    CKey sender_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000001");
    +    CKey scan_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000002");
    +    CKey spend_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000003");
    +    V0SilentPaymentsDestination sp_dest{scan_key.GetPubKey(), spend_key.GetPubKey()};
    +    std::map<size_t, V0SilentPaymentsDestination> sp_dests{{2, sp_dest}, {5, sp_dest}};
    +    COutPoint smallest_outpoint{Txid::FromHex("0000000000000000000000000000000000000000000000000000000000000001").value(), 0};
    +
    +    auto generated = bip352::GenerateSilentPaymentsTaprootDestinations(sp_dests, {sender_key}, {}, smallest_outpoint);
    +
    +    BOOST_REQUIRE(generated.has_value());
    +    BOOST_CHECK_EQUAL(generated->size(), sp_dests.size());
    +    BOOST_CHECK_EQUAL(generated->count(0), 0);
    +    BOOST_CHECK_EQUAL(generated->count(1), 0);
    +    BOOST_CHECK_EQUAL(generated->count(2), 1);
    +    BOOST_CHECK_EQUAL(generated->count(5), 1);
    +}
    +
     BOOST_AUTO_TEST_CASE(bip352_send_and_receive_test_vectors)
     {
         UniValue tests;
    

    Eunovo commented at 11:07 AM on May 29, 2026:

    Done.

  37. in src/common/bip352.cpp:145 in 80d3a4853a outdated
     140 | +    );
     141 | +    if (!ret) return std::nullopt;
     142 | +    return prevouts_summary;
     143 | +}
     144 | +
     145 | +std::optional<PrevoutsSummary> GetSilentPaymentsPrevoutsSummary(const std::vector<CTxIn>& vin, const std::map<COutPoint, Coin>& coins)
    


    w0xlt commented at 12:08 AM on May 28, 2026:

    GetSilentPaymentsPrevoutsSummary() currently still builds scan data when a transaction has an eligible input plus another input spending an unknown SegWit version (>1) prevout.

    BIP352 v0 says those transactions must be skipped entirely, so this should return no prevouts summary as soon as any spent prevout is witness v2+.

    Diff:

    diff --git a/src/common/bip352.cpp b/src/common/bip352.cpp
    index 1580e1f8f8..2ccae373c8 100644
    --- a/src/common/bip352.cpp
    +++ b/src/common/bip352.cpp
    @@ -152,6 +152,12 @@ std::optional<PrevoutsSummary> GetSilentPaymentsPrevoutsSummary(const std::vecto
         for (const CTxIn& txin : vin) {
             const Coin& coin = coins.at(txin.prevout);
             Assert(!coin.IsSpent());
    +        int witness_version{0};
    +        std::vector<unsigned char> witness_program;
    +        // BIP352 v0 skips transactions spending future witness versions.
    +        if (coin.out.scriptPubKey.IsWitnessProgram(witness_version, witness_program) && witness_version > 1) {
    +            return std::nullopt;
    +        }
             tx_outpoints.emplace_back(txin.prevout);
             auto pubkey = GetPubKeyFromInput(txin, coin.out.scriptPubKey);
             if (pubkey.has_value()) {
    

    Test:

    diff --git a/src/test/bip352_tests.cpp b/src/test/bip352_tests.cpp
    index cd5fda38da..b27823acd4 100644
    --- a/src/test/bip352_tests.cpp
    +++ b/src/test/bip352_tests.cpp
    @@ -27,6 +27,25 @@ CKey ParseHexToCKey(std::string hex) {
         return output;
     };
     
    +BOOST_AUTO_TEST_CASE(bip352_skips_transactions_spending_unknown_segwit_versions)
    +{
    +    CKey key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000001");
    +    CPubKey pubkey = key.GetPubKey();
    +    COutPoint eligible_outpoint{Txid::FromHex("0000000000000000000000000000000000000000000000000000000000000001").value(), 0};
    +    COutPoint unknown_segwit_outpoint{Txid::FromHex("0000000000000000000000000000000000000000000000000000000000000002").value(), 0};
    +
    +    CTxIn eligible_input{eligible_outpoint};
    +    eligible_input.scriptWitness.stack.emplace_back(64, 0);
    +    eligible_input.scriptWitness.stack.emplace_back(pubkey.begin(), pubkey.end());
    +
    +    std::map<COutPoint, Coin> coins;
    +    coins[eligible_outpoint] = Coin{CTxOut{{}, GetScriptForDestination(WitnessV0KeyHash{pubkey})}, 0, false};
    +    coins[unknown_segwit_outpoint] = Coin{CTxOut{{}, GetScriptForDestination(WitnessUnknown{2, std::vector<unsigned char>(32, 1)})}, 0, false};
    +
    +    BOOST_REQUIRE(bip352::GetSilentPaymentsPrevoutsSummary({eligible_input}, coins).has_value());
    +    BOOST_CHECK(!bip352::GetSilentPaymentsPrevoutsSummary({eligible_input, CTxIn{unknown_segwit_outpoint}}, coins).has_value());
    +}
    +
     BOOST_AUTO_TEST_CASE(bip352_send_and_receive_test_vectors)
     {
         UniValue tests;
    

    Eunovo commented at 11:08 AM on May 29, 2026:

    Done.

  38. in src/key_io.cpp:161 in 80d3a4853a outdated
     157 |          if (dec.data.empty()) {
     158 |              error_str = "Empty Bech32 data section";
     159 |              return CNoDestination();
     160 |          }
     161 | +        if (is_silent_payment) {
     162 | +            if (!ConvertBits<5, 8, false>([&](unsigned char c) { data.push_back(c); }, dec.data.begin() + 1, dec.data.end())) {
    


    w0xlt commented at 12:31 AM on May 28, 2026:

    Silent Payments decoding looks too permissive: it detects SP addresses by HRP prefix and then accepts both BECH32 and BECH32M without requiring dec.hrp == params.SilentPaymentsHRP().

    That can make spx... or Bech32-checksummed SP payloads decode as valid SP destinations, while BIP352 should require the exact SP HRP and Bech32m.

    Diff:

    diff --git a/src/key_io.cpp b/src/key_io.cpp
    index 335f02a5ba..e77f616cc7 100644
    --- a/src/key_io.cpp
    +++ b/src/key_io.cpp
    @@ -158,6 +158,14 @@ CTxDestination DecodeDestination(const std::string& str, const CChainParams& par
                 return CNoDestination();
             }
             if (is_silent_payment) {
    +            if (dec.hrp != params.SilentPaymentsHRP()) {
    +                error_str = strprintf("Invalid or unsupported prefix for Silent Payments address (expected %s, got %s).", params.SilentPaymentsHRP(), dec.hrp);
    +                return CNoDestination();
    +            }
    +            if (dec.encoding != bech32::Encoding::BECH32M) {
    +                error_str = "Silent Payments address must use Bech32m checksum";
    +                return CNoDestination();
    +            }
                 if (!ConvertBits<5, 8, false>([&](unsigned char c) { data.push_back(c); }, dec.data.begin() + 1, dec.data.end())) {
                     return CNoDestination();
                 }
    

    Test:

    diff --git a/src/test/data/key_io_invalid.json b/src/test/data/key_io_invalid.json
    index 0505dc9e8b..13b3fb0526 100644
    --- a/src/test/data/key_io_invalid.json
    +++ b/src/test/data/key_io_invalid.json
    @@ -209,6 +209,12 @@
         [
             "TB1Q3F9WGNXE9ZMTTMDN5VKVKHYZ8Y0LCV72YV7V5LSXTJXEYHNHEHASLYL0TZ"
         ],
    +    [
    +        "spx1qq22l5s6l9460ww6t4tkzsy2a7zejurcmzz35pt0ffrzk5erlaykdcqugecjjnjqf7ggq39vl6wexjlm00n66z94v675n7wcux6d2krr68g37pn04"
    +    ],
    +    [
    +        "sp1qq22l5s6l9460ww6t4tkzsy2a7zejurcmzz35pt0ffrzk5erlaykdcqugecjjnjqf7ggq39vl6wexjlm00n66z94v675n7wcux6d2krr68gcwu9kg"
    +    ],
         [
             "sp1qqgqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2qugecjjnjqf7ggq39vl6wexjlm00n66z94v675n7wcux6d2krr68g25havg"
         ],
    

    Eunovo commented at 11:07 AM on May 29, 2026:

    Done.

  39. in src/common/bip352.cpp:319 in 80d3a4853a
     314 | +    found_output_objs.reserve(tx_outputs.size());
     315 | +    found_output_ptrs.reserve(tx_outputs.size());
     316 | +    tx_output_objs.reserve(tx_outputs.size());
     317 | +    tx_output_ptrs.reserve(tx_outputs.size());
     318 | +
     319 | +    for (size_t i = 0; i < tx_outputs.size(); i++) {
    


    w0xlt commented at 12:53 AM on May 28, 2026:

    ScanForSilentPaymentsOutputs skips invalid x-only taproot outputs, but still stores pointers using the original tx_outputs index into the compacted tx_output_objs vector.

    If an invalid output appears before a valid one, this can take &tx_output_objs[i] out of bounds or point at the wrong object during scanning.

    Diff:

    diff --git a/src/common/bip352.cpp b/src/common/bip352.cpp
    index 1580e1f8f8..8d019f3469 100644
    --- a/src/common/bip352.cpp
    +++ b/src/common/bip352.cpp
    @@ -316,19 +316,19 @@ std::optional<std::vector<SilentPaymentsOutput>> ScanForSilentPaymentsOutputs(
         tx_output_objs.reserve(tx_outputs.size());
         tx_output_ptrs.reserve(tx_outputs.size());
     
    -    for (size_t i = 0; i < tx_outputs.size(); i++) {
    -        secp256k1_silentpayments_found_output found_output{};
    +    for (const XOnlyPubKey& tx_output : tx_outputs) {
             secp256k1_xonly_pubkey tx_output_obj;
    -        found_output_objs.push_back(found_output);
    -        found_output_ptrs.push_back(&found_output_objs[i]);
    -        ret = secp256k1_xonly_pubkey_parse(secp256k1_context_static, &tx_output_obj, tx_outputs[i].data());
    +        ret = secp256k1_xonly_pubkey_parse(secp256k1_context_static, &tx_output_obj, tx_output.data());
             if (!ret) {
                 // It is possible that a P2TR output encodes an invalid x-only pubkey.
                 continue;
             }
             tx_output_objs.push_back(tx_output_obj);
    -        tx_output_ptrs.push_back(&tx_output_objs[i]);
    +        tx_output_ptrs.push_back(&tx_output_objs.back());
    +        found_output_objs.emplace_back();
    +        found_output_ptrs.push_back(&found_output_objs.back());
         }
    +    if (tx_output_ptrs.empty()) return {};
     
         // Parse the pubkeys into secp pubkey and xonly_pubkey objects
         ret = secp256k1_ec_pubkey_parse(secp256k1_context_static, &spend_pubkey_obj, recipient_spend_pubkey.data(), recipient_spend_pubkey.size());
    
    diff --git a/src/test/bip352_tests.cpp b/src/test/bip352_tests.cpp
    index cd5fda38da..ca9424507d 100644
    --- a/src/test/bip352_tests.cpp
    +++ b/src/test/bip352_tests.cpp
    @@ -197,5 +197,39 @@ BOOST_AUTO_TEST_CASE(bip352_send_and_receive_test_vectors)
             }
         }
     }
    +
    +BOOST_AUTO_TEST_CASE(bip352_scan_skips_invalid_taproot_outputs)
    +{
    +    CKey sender_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000001");
    +    CKey scan_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000002");
    +    CKey spend_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000003");
    +    const COutPoint outpoint{Txid::FromHex("0000000000000000000000000000000000000000000000000000000000000001").value(), 0};
    +
    +    std::map<size_t, V0SilentPaymentsDestination> sp_dests;
    +    sp_dests.emplace(0, V0SilentPaymentsDestination{scan_key.GetPubKey(), spend_key.GetPubKey()});
    +    const auto sp_tr_dests = bip352::GenerateSilentPaymentsTaprootDestinations(sp_dests, {sender_key}, {}, outpoint);
    +    BOOST_REQUIRE(sp_tr_dests.has_value());
    +    const XOnlyPubKey expected_output{sp_tr_dests->begin()->second};
    +
    +    CTxIn txin{outpoint};
    +    const CPubKey sender_pubkey{sender_key.GetPubKey()};
    +    txin.scriptWitness.stack.emplace_back();
    +    txin.scriptWitness.stack.emplace_back(sender_pubkey.begin(), sender_pubkey.end());
    +
    +    std::map<COutPoint, Coin> coins;
    +    coins[outpoint] = Coin{CTxOut{{}, GetScriptForDestination(WitnessV0KeyHash{sender_pubkey})}, 0, false};
    +    const auto prevouts_summary = bip352::GetSilentPaymentsPrevoutsSummary({txin}, coins);
    +    BOOST_REQUIRE(prevouts_summary.has_value());
    +
    +    std::vector<XOnlyPubKey> output_pub_keys;
    +    output_pub_keys.emplace_back(ParseHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"));
    +    output_pub_keys.push_back(expected_output);
    +
    +    std::unordered_map<CKeyID, uint256, SaltedSipHasher> labels;
    +    const auto found_outputs = bip352::ScanForSilentPaymentsOutputs(scan_key, *prevouts_summary, spend_key.GetPubKey(), output_pub_keys, labels);
    +    BOOST_REQUIRE(found_outputs.has_value());
    +    BOOST_REQUIRE_EQUAL(found_outputs->size(), 1);
    +    BOOST_CHECK(found_outputs->front().output == expected_output);
    +}
     BOOST_AUTO_TEST_SUITE_END()
     } // namespace wallet
    

    Eunovo commented at 11:08 AM on May 29, 2026:

    Done.

  40. w0xlt commented at 12:58 AM on May 28, 2026: contributor

    A few review comments:

  41. Eunovo force-pushed on May 29, 2026
  42. DrahtBot added the label CI failed on May 29, 2026
  43. DrahtBot removed the label CI failed on May 29, 2026
  44. in src/common/bip352.h:120 in b0a0c54f59 outdated
     115 | + * @param label                       The label tweak
     116 | + * @return V0SilentPaymentsDestination The silent payments destination, with `B_spend -> B_spend + label * G`.
     117 | + *
     118 | + * @see CreateLabelTweak(const CKey& scan_key, const int m);
     119 | + */
     120 | +V0SilentPaymentsDestination GenerateSilentPaymentsLabeledAddress(const V0SilentPaymentsDestination& recipient, const uint256& label);
    


    theStack commented at 8:32 PM on May 30, 2026:

    in b0a0c54f5925c83728d0081f3009eb947b55895c: I think for this function, passing the label (pubkey) rather than the label tweak makes more sense, so the generator point multiplication (already done in CreateLabelTweak via the secp256k1_silentpayments_label_create API function) doesn't have to be repeated manually via .GetPubKey(). The label tweak only becomes relevant once a SP outputs need to be spent, but shouldn't be necessary in a function for address generation.

    **
     * [@brief](/bitcoin-bitcoin/contributor/brief/) Generate a silent payments labeled address.
     *
     * [@param](/bitcoin-bitcoin/contributor/param/) recipient                   The recipient's silent payments destination (i.e. scan and spend public keys).
     * [@param](/bitcoin-bitcoin/contributor/param/) label                       The label
     * [@return](/bitcoin-bitcoin/contributor/return/) V0SilentPaymentsDestination The silent payments destination, with `B_spend -> B_spend + label`.
     *
     * [@see](/bitcoin-bitcoin/contributor/see/) CreateLabelTweak(const CKey& scan_key, const int m);
     */
    V0SilentPaymentsDestination GenerateSilentPaymentsLabeledAddress(const V0SilentPaymentsDestination& recipient, const CPubKey& label);
    

    Eunovo commented at 11:15 PM on June 9, 2026:

    Done.

  45. in src/common/bip352.h:109 in b0a0c54f59
     104 | + * @param m                        An integer m (only use m = 0 for the change label)
     105 | + * @return std::<CPubKey, uint256> The label public key and label tweak.
     106 | + *
     107 | + * @see GenerateSilentPaymentsLabeledAddress
     108 | + */
     109 | +std::pair<CPubKey, uint256> CreateLabelTweak(const CKey& scan_key, int m);
    


    theStack commented at 8:53 PM on May 30, 2026:

    in b0a0c54f5925c83728d0081f3009eb947b55895c: nit: as it's returning more than only the tweak (I suspect in a past iteration it did only that though; that would explain why the tweak is passed to GenerateSilentPaymentsLabeledAddress below currently), could rename the function. Maybe CreateLabelData or simply CreateLabel?


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

    Done.

  46. in src/common/bip352.cpp:266 in b0a0c54f59 outdated
     261 | +
     262 | +const unsigned char* LabelLookupCallback(const unsigned char* key, const void* context) {
     263 | +    auto label_context = static_cast<const std::unordered_map<CKeyID, uint256, SaltedSipHasher>*>(context);
     264 | +    CPubKey label{key, key + CPubKey::COMPRESSED_SIZE};
     265 | +    // Find the pubkey in the map
     266 | +    auto it = label_context->find(label.GetID());
    


    theStack commented at 9:04 PM on May 30, 2026:

    in b0a0c54f5925c83728d0081f3009eb947b55895c: could use directly CPubKey as the label cache map key type, since by using CKeyID additional hashing steps (via .GetID(), performing Hash160, i.e. SHA-256 + RIPEMD-160) are involved for every lookup, which I suspect would be slower. The only remaining gain would be a smaller size of the map in memory (20 bytes vs. 33 bytes keys), but I doubt that this would ever matter in practice.


    Eunovo commented at 10:35 AM on June 7, 2026:

    CPubKey doesn't have a hash function for use with std::unordered_map. We'll have to check if it's better to use a CPubKey + std::map<CPubKey, uint256> or CKeyID + std::unordered_map<CKeyID, uint256, SaltedSipHasher>. The current plan is to eventually support at least 100_000 labels with the Bitcoin Core wallet; we can run a benchmark with this in mind.


    Eunovo commented at 8:49 AM on June 22, 2026:

    I created a benchmark to test this here. After 3 runs, the ordered_map cache seems to perform slightly better most of the time:

    ➜  2025-implement-bip352-receiving git:(4cf3c054f7) build/bin/bench_bitcoin '-filter=BIP352ScanNoMatch.*'            
    
    
    |               ns/op |                op/s |    err% |     total | benchmark
    |--------------------:|--------------------:|--------:|----------:|:----------
    |           99,684.29 |           10,031.67 |    0.7% |      0.01 | `BIP352ScanNoMatchNoLabels`
    |           98,520.27 |           10,150.20 |    0.3% |      0.01 | `BIP352ScanNoMatchWith100kLabels`
    |           97,405.70 |           10,266.34 |    0.4% |      0.01 | `BIP352ScanNoMatchWith100kLabels_OrderedMap`
    |          101,070.67 |            9,894.07 |    0.5% |      0.01 | `BIP352ScanNoMatchWith1kLabels`
    |          100,686.00 |            9,931.87 |    0.4% |      0.01 | `BIP352ScanNoMatchWith1kLabels_OrderedMap`
    ➜  2025-implement-bip352-receiving git:(4cf3c054f7) build/bin/bench_bitcoin '-filter=BIP352ScanNoMatch.*'
    
    
    |               ns/op |                op/s |    err% |     total | benchmark
    |--------------------:|--------------------:|--------:|----------:|:----------
    |          101,105.09 |            9,890.70 |    0.7% |      0.01 | `BIP352ScanNoMatchNoLabels`
    |          102,756.00 |            9,731.79 |    1.6% |      0.01 | `BIP352ScanNoMatchWith100kLabels`
    |          100,361.90 |            9,963.94 |    0.6% |      0.01 | `BIP352ScanNoMatchWith100kLabels_OrderedMap`
    |          101,470.67 |            9,855.06 |    0.7% |      0.01 | `BIP352ScanNoMatchWith1kLabels`
    |          100,385.78 |            9,961.57 |    0.6% |      0.01 | `BIP352ScanNoMatchWith1kLabels_OrderedMap`
    ➜  2025-implement-bip352-receiving git:(4cf3c054f7) build/bin/bench_bitcoin '-filter=BIP352ScanNoMatch.*'
    
    
    |               ns/op |                op/s |    err% |     total | benchmark
    |--------------------:|--------------------:|--------:|----------:|:----------
    |          101,449.10 |            9,857.16 |    0.7% |      0.01 | `BIP352ScanNoMatchNoLabels`
    |           99,939.60 |           10,006.04 |    0.3% |      0.01 | `BIP352ScanNoMatchWith100kLabels`
    |           98,678.55 |           10,133.92 |    0.5% |      0.01 | `BIP352ScanNoMatchWith100kLabels_OrderedMap`
    |          101,501.11 |            9,852.11 |    0.5% |      0.01 | `BIP352ScanNoMatchWith1kLabels`
    |           99,560.60 |           10,044.13 |    0.2% |      0.01 | `BIP352ScanNoMatchWith1kLabels_OrderedMap`
    
    

    Steps to Reproduce:


    Eunovo commented at 9:59 AM on June 23, 2026:

    I have reverted back to using std::map<CPubKey, uint256> as the labels cache

  47. theStack commented at 9:10 PM on May 30, 2026: contributor

    Looks good overall, left just some more findings regarding labels and the label cache. It might be worth it to introduce a dedicated "serialized label" type (e.g. std::array<byte, 33>) and avoid using CPubKey, but this could still be done in a later follow-up PR.

  48. Eunovo force-pushed on Jun 7, 2026
  49. DrahtBot added the label Needs rebase on Jun 19, 2026
  50. Eunovo force-pushed on Jun 23, 2026
  51. DrahtBot removed the label Needs rebase on Jun 23, 2026
  52. Eunovo force-pushed on Jun 26, 2026
  53. in src/common/bip352.cpp:83 in 8de4f67123
      78 | +        pubkey = CPubKey{txin.scriptWitness.stack.back()};
      79 | +    } else if (type == TxoutType::PUBKEYHASH || type == TxoutType::SCRIPTHASH) {
      80 | +        // Use the script interpreter to get the stack after executing the scriptSig
      81 | +        std::vector<std::vector<unsigned char>> stack;
      82 | +        ScriptError serror;
      83 | +        Assert(EvalScript(stack, txin.scriptSig, MANDATORY_SCRIPT_VERIFY_FLAGS, DUMMY_CHECKER, SigVersion::BASE, &serror));
    


    theStack commented at 5:01 PM on July 1, 2026:

    At this line and in some other places above in the GetPubKeyFromInput function, passing in a transaction that is not consensus-valid could lead to a crash in theory. Assuming consensus-validity seems reasonable for the typical SP scanning scenario, but we probably should document it as precondition in the Doxygen header, for belts-and-suspenders? (Not sure if it's realistic, but I'm thinking of e.g. a potential future RPC that allows scanning for individual user-supplied transactions, where this could become an issue).


    Eunovo commented at 8:23 AM on July 2, 2026:

    (Not sure if it's realistic, but I'm thinking of e.g. a potential future RPC that allows scanning for individual user-supplied transactions, where this could become an issue).

    Funny you should mention this; I recently added this RPC in the sending PR https://github.com/bitcoin/bitcoin/pull/35302/commits/ee27c74b26212bd883636793268fa27b2002ccf0


    theStack commented at 8:04 AM on July 3, 2026:

    (Not sure if it's realistic, but I'm thinking of e.g. a potential future RPC that allows scanning for individual user-supplied transactions, where this could become an issue).

    Funny you should mention this; I recently added this RPC in the sending PR ee27c74

    Ah funny indeed, I wasn't aware of this. I guess this RPC could currently crash triggered by malicious user input (e.g. with bogus scriptSig and a P2PKH previous output script), as it doesn't consensus-validate the tx before extracting pubkeys?


    Eunovo commented at 11:09 AM on July 7, 2026:

    I guess this RPC could currently crash triggered by malicious user input (e.g. with bogus scriptSig and a P2PKH previous output script), as it doesn't consensus-validate the tx before extracting pubkeys

    It can. I have fixed the problem by removing the Asserts in GetPubKeyFromInput that could cause a crash with unexpected scripts.

  54. in src/addresstype.h:16 in a4771f7651
      12 | @@ -13,6 +13,7 @@
      13 |  #include <util/hash_type.h>
      14 |  
      15 |  #include <algorithm>
      16 | +#include <span>
    


    theStack commented at 6:14 AM on July 3, 2026:

    in a4771f7651b97f28e29209b4a43794e073953d2e: nit: unused include, probably a leftover from an earlier version


    Eunovo commented at 11:26 AM on July 7, 2026:

    Done.

  55. in src/addresstype.h:169 in a4771f7651
     164 | +        unsigned int version,
     165 | +        const CPubKey& scan_pubkey,
     166 | +        const CPubKey& spend_pubkey,
     167 | +        std::vector<unsigned char> extra_data
     168 | +    ) : V0SilentPaymentsDestination(scan_pubkey, spend_pubkey),
     169 | +        m_version(version), m_extra_data(std::move(extra_data)) {}
    


    theStack commented at 6:17 AM on July 3, 2026:

    in a4771f7651b97f28e29209b4a43794e073953d2e: could assert here that version is in the range [1,30] (or throw if it isn't)


    Eunovo commented at 11:09 AM on July 7, 2026:

    Done.

  56. in src/addresstype.cpp:24 in a4771f7651
      19 |  typedef std::vector<unsigned char> valtype;
      20 |  
      21 | +V0SilentPaymentsDestination::V0SilentPaymentsDestination(const CPubKey& scan_pubkey, const CPubKey& spend_pubkey)
      22 | +    : m_scan_pubkey(scan_pubkey), m_spend_pubkey(spend_pubkey)
      23 | +{
      24 | +    if (!scan_pubkey.IsFullyValid() || !spend_pubkey.IsFullyValid()) {
    


    theStack commented at 6:27 AM on July 3, 2026:

    in a4771f7651b97f28e29209b4a43794e073953d2e: could also ensure that both of the passed in public keys are compressed (with corresponding uncompressed keys we would still derive the correct output scripts when sending, but the SP address encoding would be overlong and invalid, AFAICT).


    Eunovo commented at 11:10 AM on July 7, 2026:

    Done.

  57. in src/common/bip352.cpp:272 in 8de4f67123 outdated
     267 | +        return it->second.begin();
     268 | +    }
     269 | +    return nullptr;
     270 | +}
     271 | +
     272 | +std::pair<CPubKey, uint256> CreateLabel(const CKey& scan_key, const int m) {
    


    theStack commented at 7:11 AM on July 3, 2026:

    in 8de4f671236385b7ba9862ef02d21c775eb2a4a5: could use uint32_t as type for m here (as implied by the spec and also used in the secp API)


    Eunovo commented at 12:06 PM on July 7, 2026:

    Done.

  58. in src/common/bip352.h:156 in 8de4f67123 outdated
     151 | + * @param scan_key                                          The recipient's scan key.
     152 | + * @param prevouts_summary                                  The silent payments public data.
     153 | + * @param spend_pubkey                                      The recipient's spend public key.
     154 | + * @param output_pub_keys                                   The taproot output public keys.
     155 | + * @param labels                                            The recipient's labels.
     156 | + * @return std::<optional<std::vector<SilentPaymentsOutput>> The found outputs, nullopt if none found.
    


    theStack commented at 7:18 AM on July 3, 2026:

    in 8de4f671236385b7ba9862ef02d21c775eb2a4a5: here and in other doxygen headers in the same file: explicitly mentioning the return type is generally not necessary IMHO, as it's visible anyways two lines below


    Eunovo commented at 11:26 AM on July 7, 2026:

    I'll leave as-is since it's already specified.

  59. in src/common/bip352.h:83 in 8de4f67123
      78 | +/**
      79 | + * @brief Generate silent payments taproot destinations.
      80 | + *
      81 | + * Given a set of silent payments destinations, generate the requested number of outputs. If a silent payment
      82 | + * destination is repeated, this indicates multiple outputs are requested for the same recipient. The silent payment
      83 | + * desintaions are passed in map where the key indicates their desired position in the final tx.vout array.
    


    theStack commented at 7:32 AM on July 3, 2026:

    in 8de4f671236385b7ba9862ef02d21c775eb2a4a5:

     * destinations are passed in a map where the key indicates their desired position in the final tx.vout array.
    

    Eunovo commented at 11:26 AM on July 7, 2026:

    Done.

  60. in src/common/bip352.cpp:236 in 8de4f67123 outdated
     231 | +    );
     232 | +    if (!ret) return {};
     233 | +    return generated_outputs;
     234 | +}
     235 | +
     236 | +std::optional<std::map<size_t, WitnessV1Taproot>> GenerateSilentPaymentsTaprootDestinations(const std::map<size_t, V0SilentPaymentsDestination>& sp_dests, const std::vector<CKey>& plain_keys, const std::vector<KeyPair>& taproot_keys, const COutPoint& smallest_outpoint)
    


    theStack commented at 7:57 AM on July 3, 2026:

    in 8de4f671236385b7ba9862ef02d21c775eb2a4a5: I wonder if this can be simplified by using a vector instead of a map for both the destinations and the created outputs? The relevant grouping (per shared scan key) happens already inside the secp module, there is nothing special about repeated destinations that warrants a map imho


    Eunovo commented at 1:45 PM on July 7, 2026:

    The map is not used for grouping outputs; it is used to store the original indicies of destinations so they can be replaced with the taproot scripts later, see https://github.com/bitcoin/bitcoin/pull/35302/commits/676bb5e8be94303c1a67791bd5f16cd018023002#diff-6e06b309cd494ef5da4e78aa0929a980767edd12342137f268b9219167064d13R1504-R1524.


    theStack commented at 3:40 PM on July 25, 2026:

    Oh I see now, thinking that the map key for the destinations being a counter was a brain fart. As for using vectors instead of maps, I think this would still work if we only allow contiguous recipient indexes, see e.g. https://github.com/theStack/bitcoin/commit/7b4af2f4b589d134c99c85e7d6153f703eb2be60 (Not entirely sure if the index gaps are really needed, probably for wallet sending scenarios where SP outputs and regular outputs are mixed?).


    Eunovo commented at 11:55 AM on August 11, 2026:

    Not entirely sure if the index gaps are really needed, probably for wallet sending scenarios where SP outputs and regular outputs are mixed?

    Yes. We preserve the original indexes in a Map because the vector of recipients can contain both SP and non-SP destinations, and the SP destinations are not required to be contiguous.

  61. theStack commented at 8:14 AM on July 3, 2026: contributor

    Did another review round and left some more comments below, many of them being nitty (feel free to push back or ignore).

  62. Eunovo force-pushed on Jul 6, 2026
  63. Eunovo force-pushed on Jul 7, 2026
  64. sedited commented at 8:13 PM on July 23, 2026: contributor

    @Eunovo ping for rebase :)

  65. Eunovo commented at 8:45 PM on July 23, 2026: contributor

    @Eunovo ping for rebase :)

    Will rebase soon.

  66. DrahtBot added the label Needs rebase on Jul 23, 2026
  67. Eunovo force-pushed on Jul 23, 2026
  68. DrahtBot removed the label Needs rebase on Jul 23, 2026
  69. sedited requested review from theStack on Jul 24, 2026
  70. sedited requested review from w0xlt on Jul 24, 2026
  71. Eunovo force-pushed on Jul 24, 2026
  72. Eunovo commented at 10:53 AM on July 24, 2026: contributor

    Fixed the typos detected by Drahtbot

  73. in src/key_io.cpp:127 in 75950c7c4b
     121 | @@ -89,7 +122,9 @@ CTxDestination DecodeDestination(const std::string& str, const CChainParams& par
     122 |      error_str = "";
     123 |  
     124 |      // Note this will be false if it is a valid Bech32 address for a different network
     125 | -    bool is_bech32 = (ToLower(str.substr(0, params.Bech32HRP().size())) == params.Bech32HRP());
     126 | +    // BIP352 addresses are encoded using bech32m but with a higher character limit, so also check if it's a silent payments address
     127 | +    bool is_silent_payment = (ToLower(str.substr(0, params.SilentPaymentsHRP().size())) == params.SilentPaymentsHRP());
     128 | +    bool is_bech32 = is_silent_payment ? true : (ToLower(str.substr(0, params.Bech32HRP().size())) == params.Bech32HRP());
    


    theStack commented at 10:55 AM on July 25, 2026:

    in 75950c7c4b9e802cd251b5f827b2eb76ad796479: nit: slightly simpler:

        bool is_bech32 = is_silent_payment || (ToLower(str.substr(0, params.Bech32HRP().size())) == params.Bech32HRP());
    

    Eunovo commented at 9:42 AM on July 27, 2026:

    It is much better. Fixed.

  74. in src/kernel/chainparams.h:194 in 10240e6092
     190 | @@ -190,6 +191,7 @@ class CChainParams
     191 |      std::vector<std::string> vSeeds;
     192 |      std::vector<unsigned char> base58Prefixes[MAX_BASE58_TYPES];
     193 |      std::string bech32_hrp;
     194 | +    std::string silent_payment_hrp;
    


    theStack commented at 11:21 AM on July 25, 2026:

    in 10240e6092f77fda9ef6ccb52b9c3f5a7f345373: nit: for consistency, could also use the plural form here (related to the earlier comment #35301 (review))

        std::string silent_payments_hrp;
    

    Eunovo commented at 9:43 AM on July 27, 2026:

    Fixed.

  75. in src/test/key_io_tests.cpp:65 in 75950c7c4b
      63 | -            BOOST_CHECK_EQUAL(HexStr(script), HexStr(exp_payload));
      64 | +
      65 | +            // Payload check depends on address type
      66 | +            if (isSilentPayment && silentPaymentVersion == 0) {
      67 | +                const auto* sp = std::get_if<V0SilentPaymentsDestination>(&destination);
      68 | +                BOOST_CHECK_MESSAGE(sp != nullptr, "Not a V0SilentPaymentsDestination:" + strTest);
    


    theStack commented at 11:33 AM on July 25, 2026:

    in 75950c7c4b9e802cd251b5f827b2eb76ad796479: here and a few lines below: I wonder if we could use BOOST_REQUIRE_MESSAGE instead, as failing hard if something is wrong with the test vectors data (IIUC that's when the condition would be violated) seems reasonable? the if condition after could be removed then, simplifying the code


    Eunovo commented at 9:43 AM on July 27, 2026:

    The resulting code is much cleaner. Fixed!

  76. in src/addresstype.h:171 in 75950c7c4b
     166 | +        std::vector<unsigned char> extra_data
     167 | +    ) : V0SilentPaymentsDestination(scan_pubkey, spend_pubkey),
     168 | +        m_version(version), m_extra_data(std::move(extra_data)) {
     169 | +        // Version 0 address must be created as a V0SilentPaymentsDestination
     170 | +        // Only v1 through v30 are supported
     171 | +        Assert(version > 0 && version < 31);
    


    theStack commented at 11:34 AM on July 25, 2026:

    in 75950c7c4b9e802cd251b5f827b2eb76ad796479: yocto-nit: inclusive bounds are a tiny bit more readable imho

            Assert(version >= 1 && version <= 30);
    

    Eunovo commented at 9:43 AM on July 27, 2026:

    Done.

  77. in src/common/bip352.cpp:316 in d7f1998fa5
     311 | +{
     312 | +    CPubKey labeled_spend_pubkey = CreateLabeledSpendPubKey(recipient.GetSpendPubKey(), label);
     313 | +    return V0SilentPaymentsDestination{recipient.GetScanPubKey(), labeled_spend_pubkey};
     314 | +}
     315 | +
     316 | +std::optional<std::vector<SilentPaymentsOutput>> ScanForSilentPaymentsOutputs(
    


    theStack commented at 11:47 AM on July 25, 2026:

    in d7f1998fa52bb63621daa60a04707d2b75ecbfe2: nit: is the std::optional wrapping needed here for the scanning function (e.g. for a potential future error condition)? simply returning an empty std::vector in case no output is found or there are no (P2TR) outputs to scan for in the first place seems fine and simpler AFAICT.


    Eunovo commented at 9:44 AM on July 27, 2026:

    I agree with you. I removed the std::optional wrapper.

  78. in src/test/bip352_tests.cpp:181 in 90c2898fe2 outdated
     176 | +                const auto labeled_addr{bip352::GenerateSilentPaymentsLabeledAddress(sp_address, label_pubkey)};
     177 | +                // expected["addresses"] contains the base silent payments address (at index 0)
     178 | +                // followed by the labeled addresses
     179 | +                const auto decoded{DecodeDestination(expected["addresses"][i+1].get_str())};
     180 | +                const auto* sp = std::get_if<V0SilentPaymentsDestination>(&decoded);
     181 | +                BOOST_CHECK(labeled_addr == *sp);
    


    theStack commented at 12:03 PM on July 25, 2026:

    in 90c2898fe2206a33e71d854a8f93e2f5709e0688: should add a BOOST_REQUIRE(sp != nullptr) before dereferencing to avoid potential UB


    Eunovo commented at 9:44 AM on July 27, 2026:

    Done.

  79. theStack commented at 12:07 PM on July 25, 2026: contributor

    Left some nits below, planning to do a final review pass on the secp wrapper commit d7f1998fa52bb63621daa60a04707d2b75ecbfe2 soon.

  80. Eunovo force-pushed on Jul 27, 2026
  81. in src/common/bip352.h:74 in b8cb20ae59
      69 | + *
      70 | + * If the input is not eligible for silent payments, the input is skipped (indicated by returning a nullopt).
      71 | + *
      72 | + * @param txin                    The transaction input.
      73 | + * @param spk                     The scriptPubKey of the prevout.
      74 | + * @return std::optional<CPubKey> The public key, or nullopt if not found.
    


    theStack commented at 9:27 AM on July 30, 2026:

    in b8cb20ae596feada95331257fcf280cd26bb2ffb: nit:

     * [@return](/bitcoin-bitcoin/contributor/return/) std::optional<PubKey> The public key, or nullopt if not found.
    

    (or alternatively, could also remove the return type from the doxygen comment)


    Eunovo commented at 4:15 PM on August 10, 2026:

    Done.

  82. in src/common/bip352.h:104 in b8cb20ae59
      99 | + * Label public keys can be stored in a cache, mapping the public key to the label tweak. This cache
     100 | + * is used during scanning to determine if a label was used and if so to retrieve the label tweak.
     101 | + *
     102 | + * @param scan_key                 The recipient's scan_key, used to salt the hash
     103 | + * @param m                        An integer m (only use m = 0 for the change label)
     104 | + * @return std::<CPubKey, uint256> The label public key and label tweak.
    


    theStack commented at 9:41 AM on July 30, 2026:

    in b8cb20ae596feada95331257fcf280cd26bb2ffb:

     * [@return](/bitcoin-bitcoin/contributor/return/) std::pair<CPubKey, uint256> The label public key and label tweak.
    

    (or as above, remove the type)


    Eunovo commented at 4:15 PM on August 10, 2026:

    Done.

  83. in src/common/bip352.h:158 in b8cb20ae59 outdated
     153 | + * @param spend_pubkey                                      The recipient's spend public key.
     154 | + * @param output_pub_keys                                   The taproot output public keys.
     155 | + * @param labels                                            The recipient's labels.
     156 | + * @return std::vector<SilentPaymentsOutput>                The found outputs.
     157 | + */
     158 | +std::vector<SilentPaymentsOutput> ScanForSilentPaymentsOutputs(const CKey& scan_key, const PrevoutsSummary& prevouts_summary, const CPubKey& spend_pubkey, const std::vector<XOnlyPubKey>& output_pub_keys, const std::map<CPubKey, uint256>& labels);
    


    theStack commented at 9:55 AM on July 30, 2026:

    in b8cb20ae596feada95331257fcf280cd26bb2ffb: consistency nit: the function's parameter names don't match the ones in the implementation (spend_pubkey vs. recipient_spend_pubkey, output_pub_keys vs. tx_outputs); i'd prefer the shorter ones, but no strong opinion


    Eunovo commented at 5:20 PM on August 10, 2026:

    Fixed.

  84. in src/common/bip352.cpp:363 in b8cb20ae59
     358 | +        &spend_pubkey_obj,
     359 | +        LabelLookupCallback,
     360 | +        &labels
     361 | +    );
     362 | +    assert(ret);
     363 | +    if (n_found_outputs == 0) return {};
    


    theStack commented at 10:03 AM on July 30, 2026:

    in b8cb20ae596feada95331257fcf280cd26bb2ffb: nit: this line is not strictly needed I think


    Eunovo commented at 4:18 PM on August 10, 2026:

    Removed.

  85. in src/key_io.cpp:183 in dee330e6ad outdated
     179 | +            if (dec.encoding != bech32::Encoding::BECH32M) {
     180 | +                error_str = "Silent Payments address must use Bech32m checksum";
     181 | +                return CNoDestination();
     182 | +            }
     183 | +            if (!ConvertBits<5, 8, false>([&](unsigned char c) { data.push_back(c); }, dec.data.begin() + 1, dec.data.end())) {
     184 | +                return CNoDestination();
    


    theStack commented at 10:46 AM on July 30, 2026:

    in dee330e6adee94d5b2d19cc4214694c97af82b42: should set error_str here before returning a CNoDestination, to avoid triggering an "Internal bug detected" error in validateaddress. looking at what happens when the same error condition hits for regular (non-SP) bech32(m) addresses below, "Invalid padding in Silent payments address (Bech32m data section)" might be a possible error string.


    Eunovo commented at 5:19 PM on August 10, 2026:

    Done.

  86. in src/key_io.cpp:208 in dee330e6ad
     204 | +                } else {
     205 | +                    std::vector<unsigned char> extra_data{data.begin() + 2 * CPubKey::COMPRESSED_SIZE, data.end()};
     206 | +                    return UnknownSilentPaymentsDestination{version, scan_pubkey, spend_pubkey, std::move(extra_data)};
     207 | +                }
     208 | +            } catch (const std::invalid_argument&) {
     209 | +                error_str = strprintf("Invalid Silent payments address");
    


    theStack commented at 10:48 AM on July 30, 2026:

    in dee330e6adee94d5b2d19cc4214694c97af82b42: nit: as there are no format args, there is no need for strprintf

                    error_str = "Invalid Silent payments address";
    

    Eunovo commented at 4:19 PM on August 10, 2026:

    Removed.

  87. Eunovo force-pushed on Aug 10, 2026
  88. Eunovo force-pushed on Aug 10, 2026
  89. DrahtBot added the label CI failed on Aug 10, 2026
  90. DrahtBot commented at 5:20 PM on August 10, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task iwyu: https://github.com/bitcoin/bitcoin/actions/runs/31407834056/job/93518375736</sub> <sub>LLM reason (✨ experimental): CI failed because the IWYU (include-what-you-use) check detected and required include changes (generated diff) in src/common/bip352.{h,cpp}.</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>

  91. Eunovo commented at 5:22 PM on August 10, 2026: contributor

    Sorry for the late reply @theStack . I resolved your comments and fixed a few extra nits I noticed. I also added a new functional test to rpc_validateaddress.py

    You can review with git range-diff master..dde9ab6 master..ed66407

  92. crypto: add read-only method to KeyPair
    Add a method for passing a KeyPair object to secp256k1 functions expecting a secp256k1_keypair.
    This allows for passing a KeyPair directly to a secp256k1 function without needing to create a
    temporary secp256k1_keypair object.
    39ed111960
  93. Add "sp" HRP e7216cded2
  94. Eunovo force-pushed on Aug 11, 2026
  95. in src/common/bip352.cpp:203 in 4e7ac8f3e4 outdated
     198 | +    const std::vector<V0SilentPaymentsDestination>& recipients,
     199 | +    const std::vector<CKey>& plain_keys,
     200 | +    const std::vector<KeyPair>& taproot_keypairs,
     201 | +    const COutPoint& smallest_outpoint
     202 | +) {
     203 | +    bool ret;
    


    w0xlt commented at 7:35 PM on August 11, 2026:

    CreateOutputs can return an empty output vector when both input-key sets are empty, before calling secp. GenerateSilentPaymentsTaprootDestinations converts this to std::nullopt without the guard, secp’s illegal-argument callback can abort the process.

    diff --git a/src/common/bip352.cpp b/src/common/bip352.cpp
    index 2fe80869c8..c0e6b7e774 100644
    --- a/src/common/bip352.cpp
    +++ b/src/common/bip352.cpp
    @@ -200,6 +200,8 @@ std::vector<secp256k1_xonly_pubkey> CreateOutputs(
         const std::vector<KeyPair>& taproot_keypairs,
         const COutPoint& smallest_outpoint
     ) {
    +    if (plain_keys.empty() && taproot_keypairs.empty()) return {};
    +
         bool ret;
         std::vector<const secp256k1_keypair *> taproot_keypair_ptrs;
         std::vector<const unsigned char *> plain_key_ptrs;
    diff --git a/src/test/bip352_tests.cpp b/src/test/bip352_tests.cpp
    index c365540d66..487e30b2cd 100644
    --- a/src/test/bip352_tests.cpp
    +++ b/src/test/bip352_tests.cpp
    @@ -227,6 +227,19 @@ BOOST_AUTO_TEST_CASE(bip352_preserves_requested_output_indexes)
         BOOST_CHECK_EQUAL(generated->count(5), 1);
     }
     
    +BOOST_AUTO_TEST_CASE(bip352_sender_rejects_empty_input_key_set)
    +{
    +    CKey scan_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000002");
    +    CKey spend_key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000003");
    +    V0SilentPaymentsDestination sp_dest{scan_key.GetPubKey(), spend_key.GetPubKey()};
    +    std::map<size_t, V0SilentPaymentsDestination> sp_dests{{0, sp_dest}};
    +    COutPoint smallest_outpoint{Txid::FromHex("0000000000000000000000000000000000000000000000000000000000000001").value(), 0};
    +
    +    const auto generated = bip352::GenerateSilentPaymentsTaprootDestinations(sp_dests, {}, {}, smallest_outpoint);
    +
    +    BOOST_CHECK(!generated.has_value());
    +}
    +
     BOOST_AUTO_TEST_CASE(bip352_skips_transactions_spending_unknown_segwit_versions)
     {
         CKey key = ParseHexToCKey("0000000000000000000000000000000000000000000000000000000000000001");
    
    

    Eunovo commented at 10:43 AM on August 12, 2026:

    Done.

  96. in src/test/bip352_tests.cpp:196 in 4e7ac8f3e4
     191 | +                continue;
     192 | +            }
     193 | +            if (!expected["n_outputs"].isNull()) {
     194 | +                BOOST_CHECK(found_outputs.size() == (size_t)expected["n_outputs"].getInt<int>());
     195 | +            } else {
     196 | +                std::vector<XOnlyPubKey> expected_outputs;
    


    w0xlt commented at 7:52 PM on August 11, 2026:

    The receive-vector test currently checks only the matched output public key. Maybe it could check priv_key_tweak too?

    diff --git a/src/test/bip352_tests.cpp b/src/test/bip352_tests.cpp
    index c365540d66..e16a4c1f28 100644
    --- a/src/test/bip352_tests.cpp
    +++ b/src/test/bip352_tests.cpp
    @@ -193,15 +193,18 @@ BOOST_AUTO_TEST_CASE(bip352_send_and_receive_test_vectors)
                 if (!expected["n_outputs"].isNull()) {
                     BOOST_CHECK(found_outputs.size() == (size_t)expected["n_outputs"].getInt<int>());
                 } else {
    -                std::vector<XOnlyPubKey> expected_outputs;
    +                std::map<XOnlyPubKey, uint256> expected_outputs;
                     for (const auto& output : expected["outputs"].getValues()) {
    -                    std::string pubkey_hex = output["pub_key"].get_str();
    -                    expected_outputs.emplace_back(ParseHex(pubkey_hex));
    +                    expected_outputs.emplace(
    +                        XOnlyPubKey{ParseHex(output["pub_key"].get_str())},
    +                        uint256{ParseHex(output["priv_key_tweak"].get_str())});
                     }
                     BOOST_TEST_MESSAGE(found_outputs.size());
    -                BOOST_CHECK(found_outputs.size() == expected_outputs.size());
    +                BOOST_REQUIRE_EQUAL(found_outputs.size(), expected_outputs.size());
                     for (const auto& output : found_outputs) {
    -                    BOOST_CHECK(std::find(expected_outputs.begin(), expected_outputs.end(), output.output) != expected_outputs.end());
    +                    const auto expected_output = expected_outputs.find(output.output);
    +                    BOOST_REQUIRE(expected_output != expected_outputs.end());
    +                    BOOST_CHECK(output.tweak == expected_output->second);
                     }
                 }
             }
    

    Eunovo commented at 10:43 AM on August 12, 2026:

    Done.

  97. in src/test/key_io_tests.cpp:57 in 4e7ac8f3e4 outdated
      53 | @@ -52,12 +54,24 @@ BOOST_AUTO_TEST_CASE(key_io_valid_parse)
      54 |              // Private key must be invalid public key
      55 |              destination = DecodeDestination(exp_base58string);
      56 |              BOOST_CHECK_MESSAGE(!IsValidDestination(destination), "IsValid privkey as pubkey:" + strTest);
      57 | -        } else {
      58 | -            // Must be valid public key
      59 | +        } else if (!isSilentPayments) { // TODO remove if condition when silent payments sending is implemented
    


    w0xlt commented at 8:04 PM on August 11, 2026:

    The !isSilentPayments condition skips all parsing checks for the new vectors. Maybe let them run through the same branch and special-case only IsValidDestination ?

    diff --git a/src/test/key_io_tests.cpp b/src/test/key_io_tests.cpp
    index e365c2bd53..7609274848 100644
    --- a/src/test/key_io_tests.cpp
    +++ b/src/test/key_io_tests.cpp
    @@ -54,10 +54,14 @@ BOOST_AUTO_TEST_CASE(key_io_valid_parse)
                 // Private key must be invalid public key
                 destination = DecodeDestination(exp_base58string);
                 BOOST_CHECK_MESSAGE(!IsValidDestination(destination), "IsValid privkey as pubkey:" + strTest);
    -        } else if (!isSilentPayments) { // TODO remove if condition when silent payments sending is implemented
    -            // Must be a valid destination
    +        } else {
    +            // Must decode to the expected destination
                 destination = DecodeDestination(exp_base58string);
    -            BOOST_CHECK_MESSAGE(IsValidDestination(destination), "!IsValid:" + strTest);
    +            if (isSilentPayments) { // TODO remove special case when silent payments sending is implemented
    +                BOOST_CHECK_MESSAGE(!IsValidDestination(destination), "IsValid silent payments destination:" + strTest);
    +            } else {
    +                BOOST_CHECK_MESSAGE(IsValidDestination(destination), "!IsValid:" + strTest);
    +            }
     
                 // Payload check depends on address type
                 if (isSilentPayments && silentPaymentsVersion == 0) {
    @@ -74,6 +78,7 @@ BOOST_AUTO_TEST_CASE(key_io_valid_parse)
                 }
     
                 // Try flipped case version
    +            const CTxDestination expected_destination{destination};
                 for (char& c : exp_base58string) {
                     if (c >= 'a' && c <= 'z') {
                         c = (c - 'a') + 'A';
    @@ -82,7 +87,11 @@ BOOST_AUTO_TEST_CASE(key_io_valid_parse)
                     }
                 }
                 destination = DecodeDestination(exp_base58string);
    -            BOOST_CHECK_MESSAGE(IsValidDestination(destination) == try_case_flip, "!IsValid case flipped:" + strTest);
    +            if (isSilentPayments) {
    +                BOOST_CHECK_MESSAGE((destination == expected_destination) == try_case_flip, "case flipped mismatch:" + strTest);
    +            } else {
    +                BOOST_CHECK_MESSAGE(IsValidDestination(destination) == try_case_flip, "!IsValid case flipped:" + strTest);
    +            }
                 if (!isSilentPayments && IsValidDestination(destination)) {
                     CScript script = GetScriptForDestination(destination);
                     BOOST_CHECK_EQUAL(HexStr(script), HexStr(exp_payload));
    

    Eunovo commented at 10:01 AM on August 12, 2026:

    I'm not sure it's necessary to do this; the test-each-commit CI job runs these tests before the "disable sending to silent payments address" commit.

  98. in src/test/key_io_tests.cpp:56 in 4e7ac8f3e4 outdated
      53 | @@ -52,12 +54,24 @@ BOOST_AUTO_TEST_CASE(key_io_valid_parse)
      54 |              // Private key must be invalid public key
      55 |              destination = DecodeDestination(exp_base58string);
      56 |              BOOST_CHECK_MESSAGE(!IsValidDestination(destination), "IsValid privkey as pubkey:" + strTest);
    


    w0xlt commented at 8:24 PM on August 11, 2026:

    IsValidDestination currently returns false even for a successfully decoded silent-payment destination.

    Requiring CNoDestination directly verifies that the malformed string was actually rejected.

    diff --git a/src/test/key_io_tests.cpp b/src/test/key_io_tests.cpp
    index e365c2bd53..d521498de8 100644
    --- a/src/test/key_io_tests.cpp
    +++ b/src/test/key_io_tests.cpp
    @@ -167,7 +167,7 @@ BOOST_AUTO_TEST_CASE(key_io_invalid)
             for (const auto& chain : {ChainType::MAIN, ChainType::TESTNET, ChainType::SIGNET, ChainType::REGTEST}) {
                 SelectParams(chain);
                 destination = DecodeDestination(exp_base58string);
    -            BOOST_CHECK_MESSAGE(!IsValidDestination(destination), "IsValid pubkey in mainnet:" + strTest);
    +            BOOST_CHECK_MESSAGE(std::holds_alternative<CNoDestination>(destination), "Decoded invalid destination:" + strTest);
                 privkey = DecodeSecret(exp_base58string);
                 BOOST_CHECK_MESSAGE(!privkey.IsValid(), "IsValid privkey in mainnet:" + strTest);
             }
    

    Eunovo commented at 10:03 AM on August 12, 2026:

    Same as in #35301 (review). We specifically want to test that IsValidDestination returns false here.

  99. in src/common/bip352.cpp:8 in 4e7ac8f3e4 outdated
       0 | @@ -0,0 +1,389 @@
       1 | +// Copyright (c) 2023 The Bitcoin Core developers
       2 | +// Distributed under the MIT software license, see the accompanying
       3 | +// file COPYING or http://www.opensource.org/licenses/mit-license.php.
       4 | +
       5 | +#include <common/bip352.h>
       6 | +
       7 | +#include <addresstype.h>
       8 | +#include <coins.h>
    


    w0xlt commented at 8:32 PM on August 11, 2026:

    This will likely fix the CI error.

    diff --git a/src/common/bip352.h b/src/common/bip352.h
    index d3a266b999..98fecc3e0c 100644
    --- a/src/common/bip352.h
    +++ b/src/common/bip352.h
    @@ -6,7 +6,6 @@
     #define BITCOIN_COMMON_BIP352_H
     
     #include <addresstype.h>
    -#include <coins.h>
     #include <crypto/common.h>
     #include <primitives/transaction.h>
     #include <pubkey.h>
    @@ -26,12 +25,14 @@ struct secp256k1_silentpayments_prevouts_summary;
     class CKey;
     class CScript;
     class KeyPair;
    +class Coin;
     
     namespace bip352 {
     
     using PubKey = std::variant<CPubKey, XOnlyPubKey>;
     
     class PrevoutsSummaryImpl;
    +
     class PrevoutsSummary
     {
     private:
    diff --git a/src/test/bip352_tests.cpp b/src/test/bip352_tests.cpp
    index c365540d66..2822889d23 100644
    --- a/src/test/bip352_tests.cpp
    +++ b/src/test/bip352_tests.cpp
    @@ -1,6 +1,7 @@
     #include <common/bip352.h>
     #include <span.h>
     #include <addresstype.h>
    +#include <coins.h>
     #include <policy/policy.h>
     #include <script/solver.h>
     #include <test/data/bip352_send_and_receive_vectors.json.h>
    

    Eunovo commented at 10:44 AM on August 12, 2026:

    Fixed.

  100. Eunovo force-pushed on Aug 12, 2026
  101. DrahtBot removed the label CI failed on Aug 12, 2026
  102. in src/key_io.cpp:81 in ca0cbbe768
      76 | +        std::vector<unsigned char> data_in = {};
      77 | +        data_in.reserve(SILENT_PAYMENTS_V0_DATA_SIZE);
      78 | +        // Set 0 as the silent payments version
      79 | +        std::vector<unsigned char> data_out = {0};
      80 | +        // ConvertBits will expand each 8-bit byte into 5-bit chunks,
      81 | +        data_out.reserve(std::ceil(1 + ((double)SILENT_PAYMENTS_V0_DATA_SIZE * 8 / 5)));
    


    theStack commented at 10:31 PM on August 12, 2026:

    in ca0cbbe768a7f35f1c83d0dd2d52e119cadfe1d6: nit: seems unnecessary to involve floating-point arithmetic here, could just do

            data_out.reserve(1 + CeilDiv(SILENT_PAYMENTS_V0_DATA_SIZE * 8, 5u));
    

    (also for consistency with existing code, see WitnessUnknown's operator() function below)


    Eunovo commented at 1:09 AM on August 15, 2026:

    Fixed.

  103. in src/common/bip352.cpp:373 in 49ac46511d
     368 | +        prevouts_summary.Get(),
     369 | +        &spend_pubkey_obj,
     370 | +        LabelLookupCallback,
     371 | +        &labels
     372 | +    );
     373 | +    assert(ret);
    


    theStack commented at 11:20 PM on August 12, 2026:

    in 49ac46511d53fce503ed907e4ba49b7369b6ddb5: missed this in previous review rounds unfortunately: I think we should check the return value here rather than assert to avoid a potential crash. This should only ever happen for adversarially chosen spend public keys (calculated backwards such that P_k results in point at infinity for a certain scan secret key and transaction data), so it can't occur for honestly created wallets. Still worthwhile to avoid crashes that could be provoked by e.g. "example RPCs" or "example wallets" instructing users to try out SP scanning with a given adversarial scan/spend key material.


    Eunovo commented at 1:09 AM on August 15, 2026:

    Fixed.

  104. in test/functional/rpc_validateaddress.py:189 in 6a5fe78f39
     193 | +    # ),
     194 | +    # (
     195 | +    #     "sp1pq22l5s6l9460ww6t4tkzsy2a7zejurcmzz35pt0ffrzk5erlaykdcqugecjjnjqf7ggq39vl6wexjlm00n66z94v675n7wcux6d2krr68t02m0h0s8cwhy", # V1 Silent Payments address
     196 | +    #     None
     197 | +    # ),
     198 |  ]
    


    theStack commented at 11:26 PM on August 12, 2026:

    in 6a5fe78f39b3d8d5d72ad8818ac29270c8e1cb34: to have test coverage for the "... sending support is not yet implemented" error messages, one could move these test cases to the INVALID_DATA section rather than commenting them out. The TODO would then be "move back to VALID_DATA section" rather than "uncomment" (which could very easily be missed). E.g.

    diff --git a/test/functional/rpc_validateaddress.py b/test/functional/rpc_validateaddress.py
    index c24b881229..b4c003e341 100755
    --- a/test/functional/rpc_validateaddress.py
    +++ b/test/functional/rpc_validateaddress.py
    @@ -130,7 +130,18 @@ INVALID_DATA = [
             "sp1qqgqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqf26rn7",
             "Invalid Silent payments address",
             []
    -    )
    +    ),
    +    # TODO move to VALID_DATA when Silent payments sending is enabled
    +    (
    +        "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", # V0 Silent Payments address
    +        "This is a valid Silent Payments v0 address, but sending support is not yet implemented.",
    +        [],
    +    ),
    +    (
    +        "sp1pq22l5s6l9460ww6t4tkzsy2a7zejurcmzz35pt0ffrzk5erlaykdcqugecjjnjqf7ggq39vl6wexjlm00n66z94v675n7wcux6d2krr68t02m0h0s8cwhy", # V1 Silent Payments address
    +        "This is a valid Silent Payments v1 address, but sending support is not yet implemented.",
    +        [],
    +    ),
     ]
     VALID_DATA = [
         # BIP 350
    @@ -177,16 +188,6 @@ VALID_DATA = [
             "bc1pfeessrawgf",
             "51024e73",
         ),
    -    # Silent Payments
    -    # TODO uncomment when Silent payments sending is enabled
    -    # (
    -    #     "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", # V0 Silent Payments address
    -    #     None
    -    # ),
    -    # (
    -    #     "sp1pq22l5s6l9460ww6t4tkzsy2a7zejurcmzz35pt0ffrzk5erlaykdcqugecjjnjqf7ggq39vl6wexjlm00n66z94v675n7wcux6d2krr68t02m0h0s8cwhy", # V1 Silent Payments address
    -    #     None
    -    # ),
     ]
    
    

    Eunovo commented at 1:10 AM on August 15, 2026:

    Done.

  105. theStack commented at 11:39 PM on August 12, 2026: contributor

    LGTM, modulo one suggested corner-case fix in the SP scanning wrapper, and two nits, see below.

  106. josibake commented at 12:47 PM on August 13, 2026: member

    Concept ACK

    This is some great looking code! Who wrote take 1? 😉

    Overall, looks good, I'm spending a little extra time digging into the parts I'm less familiar with, namely changes in the libsecp256k1 API and subsequently changes in the wrapper code.

  107. Eunovo force-pushed on Aug 15, 2026
  108. Eunovo force-pushed on Aug 17, 2026
  109. Eunovo commented at 6:02 PM on August 17, 2026: contributor

    I added a SilentPaymentsLabel class that wraps secp256k1_silentpayments_label to replace CPubKey usage as label. Reviewers can check with git range-diff master..0bcb949 master..b905cea

  110. in src/common/bip352.cpp:252 in e12485bc8f outdated
     247 | +    ret = secp256k1_silentpayments_sender_create_outputs(GetSecp256k1SignContext(),
     248 | +        generated_output_ptrs.data(),
     249 | +        recipient_ptrs.data(), recipient_ptrs.size(),
     250 | +        smallest_outpoint_ser.data(),
     251 | +        taproot_keypair_ptrs.data(), taproot_keypair_ptrs.size(),
     252 | +        plain_key_ptrs.data(), plain_key_ptrs.size()
    


    josibake commented at 10:47 AM on August 19, 2026:

    In _create_outputs, we check that there are no null keys passed in:

    if (keypairs != NULL) {
            ARG_CHECK(n_keypairs > 0);
            for (i = 0; i < n_keypairs; i++) {
                ARG_CHECK(keypairs[i] != NULL);
            }
        } else {
            ARG_CHECK(n_keypairs == 0);
        }
    

    .. so a precondition that the caller must pass valid keys. But we allow KeyPair::date() to return a nullptr if it is invalid. I did some poking around at KeyPair and where its used and it really shouldn't have a null state at all. Rather, when we create one with CKey::ComputeKeyPair it should return an optional<kp>. But that's its own orthogonal refactor.

    Since today keypair and ckey can be in an empty/null state, I think we need to check here before calling _create_outputs. Something like this a few lines above where we create the key vectors:

    for (const auto& key : plain_keys) {
        if (!key.IsValid()) return {};
        plain_key_ptrs.push_back(UCharCast(key.begin()));
    }
    
    for (const auto& keypair : taproot_keypairs) {
        if (!keypair.IsValid()) return {};
        taproot_keypair_ptrs.push_back(reinterpret_cast<const secp256k1_keypair*>(keypair.data()));
    }
    

    This is the one that stood out to me (when I was reviewing the first commit and saw it returning a nullptr), but there might be other spots. The heuristic we can use to check is:

    1. Does the libsecp module ARG_CHECK or assert on scenario x (e.g. no null keys)
    2. Is scenario x allowed by the calling code (can my key being in a null state)

    Eunovo commented at 6:30 PM on August 24, 2026:

    Fixed.

  111. in src/common/bip352.cpp:70 in ee242399fc outdated
      65 | +    assert(ret);
      66 | +}
      67 | +
      68 | +SilentPaymentsLabel::SilentPaymentsLabel(const unsigned char* label) {
      69 | +    memcpy(m_vch, label, CPubKey::COMPRESSED_SIZE);
      70 | +}
    


    josibake commented at 11:05 AM on August 19, 2026:

    You mention in the commit message we don't validate the raw bytes to avoid the parsing cost (fair), but this makes me a bit queasy 😰 I don't have a concrete suggestion yet, but I wonder if we can lock this path down so that its only reachable when we know its being used in a callback which is presumed to reading from pre-validated trusted storage.

    On a different note, I would find this much easier to review if it were folded into the original common commit. It avoids reviewing lines that then get changed again in the next commit and keeps all the context in one place. Just a preference, tho.


    josibake commented at 11:07 AM on August 19, 2026:

    EDIT: I see the full picture now. I would suggest updating the commit message to be a bit more clear, and I think we could probably rename the function to be more explicit. This will likely trip up other reviewers, as well.


    Eunovo commented at 8:59 AM on August 20, 2026:

    On a different note, I would find this much easier to review if it were folded into the original common commit

    I kept it separate in case I need to ditch it.

    EDIT: I see the full picture now. I would suggest updating the commit message to be a bit more clear, and I think we could probably rename the function to be more explicit. This will likely trip up other reviewers, as well.

    I'm guessing you found the "private constructor used by friend pattern". That's what I came up with to restrict its usage. I will rename the function to be more explicit, or maybe I can even come up with something better.


    josibake commented at 10:02 AM on August 20, 2026:

    Its a good pattern! I also recently came across the passkey pattern , which aims to achieve a more granular version of the same thing: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/patterns/passkey.md

    Not sure which is the best fit here, but I'll take a look at both


    Eunovo commented at 6:30 PM on August 24, 2026:

    I ended up leaving it as-is because I can't implement the pattern without a private constructor anyway, so adding a private factory just created what seemed like an unnecessary step.

    Its a good pattern! I also recently came across the passkey pattern , which aims to achieve a more granular version of the same thing: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/patterns/passkey.md

    Seems overkill for this use case. I think the comment and assert is probably sufficient.

  112. in src/addresstype.h:155 in b0526622a1
     150 | +        if (a.m_spend_pubkey < b.m_spend_pubkey) return true;
     151 | +        return false;
     152 | +    }
     153 | +};
     154 | +
     155 | +struct UnknownSilentPaymentsDestination : public V0SilentPaymentsDestination
    


    josibake commented at 11:24 AM on August 19, 2026:

    I'm not sure inheriting from the a V0Base is what we want here. I haven't thought about this long, but something that would feel more natural to me would be along the lines of:

    struct SilentPaymentAddress {
    public:
        static std::optional<SilentPaymentAddress>
        Decode(std::string_view address, const CChainParams& params);
    
        uint8_t Version() const;
        const CPubKey& ScanKey() const;
        const CPubKey& SpendKey() const;
        std::span<const unsigned char> ExtensionData() const;
    
    private:
        SilentPaymentAddress(/* validated fields */);
    
        uint8_t m_version;
        CPubKey m_scan_key;
        CPubKey m_spend_key;
        std::vector<unsigned char> m_extension_data;
    };
    

    Beyond the unknown type, the more I looked at this commit and the special casing required to get this to integrate into tests etc, the more it made me feel like we are forcing a square peg into a round hole (my bad!). A silent payment address is strictly a wallet level construct. It has nothing to do with key_io, and only loosely with CTxDestinations.

    In places where we want both, such as fuzzing wallet RPCs , or RPCs that want to treat an SP address the same as an encoded scriptpubkey, we could and should create a sum type just for that case, e.g.

    using PaymentTarget =
        std::variant<CTxDestination, SilentPaymentAddress>;
    

    Curious to hear your thoughts, and more than happy to help write the commit. I strongly suspect it will simplify this a lot, or at least make it easier and more explicit to reason about.


    Eunovo commented at 6:32 PM on August 24, 2026:

    I'm not sure inheriting from the a V0Base is what we want here. I haven't thought about this long, but something that would feel more natural to me would be along the lines of:

    I have removed the UnknownSilentPaymentsDestination class and converted what was previously the "V0Base" to just SilentPaymentsDestination

  113. in src/key_io.cpp:117 in b0526622a1


    josibake commented at 11:30 AM on August 19, 2026:

    In the world I'm envisioning, we would completely remove silent payments from the DecodeDestination function, because this returns a CTxDestination. This means we will need to introduce a silent payments specific decoder in common, for wallets and RPCs. I think this is much better than trying to coerce a silent payment address to a CTxDestination (its not), or try to have DecodeDestination return sum types of actual destinations and wallet payment instructions.


    Eunovo commented at 9:13 AM on August 20, 2026:

    What are we trying to achieve by removing the silent payments destination from CTxDestination? silent payments destination is still a "destination", and CTxDestination represents a destination that we can send money to.

  114. josibake commented at 11:32 AM on August 19, 2026: member

    Mostly high level stuff , haven't dug deep into nitty gritty details yet

  115. Add Silent Payments address types
    Add SilentPaymentsDestination for v0 to v30; Bip352 reserves v31
    for a backwards incompatible change. v1 to v30 addresses are handled
    as specified in Bip352. The extra_data will be ignored during sending.
    
    Valid v0 and v1 addresses were added to key_io_valid.json for testing,
    and invalid v0 and a v31 address were added to key_io_invalid.json.
    
    SilentPaymentsDestination must not be formed from invalid public keys;
    the operations that must be performed to send to a SilentPaymentsDestination,
    require valid public keys. Hence, the SilentPaymentsDestination struct takes
    the following precautions to ensure that such invalid states are not possible:
    - SilentPaymentsDestination instances can only be constructed from a public static factory
      that returns nullopt if the parameters are invalid.
    - The public keys are private and const references can be retrieved using member functions.
    e1c97fc53e
  116. common: add bip352.{h,cpp} secp256k1 module
    Wrap the silentpayments module from libsecp256k1. This is placed in
    common as it is intended to be used by:
    
      * RPCs: for parsing addresses
      * Wallet: for sending, receiving, spending silent payments outputs
      * Node: for creating silent payments indexes for light clients
    f860cc8a08
  117. common: add SilentPaymentsLabel to replace CPubKey usage
    Create a wrapper class for secp256k1_silentpayments_label. An instance of
    SilentPaymentsLabel always represents a valid secp256k1_silentpayments_label
    except when initialized from raw bytes in LabelLookupCallback. The raw bytes
    are not validated because doing so will add an extra ecpubkey_parse operation
    to the label lookup. LabelLookupCallback only needs to do comparisons, so allow
    instantiation of SilentPaymentsLabel only for comparison.
    e2545215d2
  118. wallet: disable sending to silent payments address
    Have `IsValidDestination` return false for silent payments destinations
    and set an error string when decoding a silent payments address.
    
    This prevents anyone from sending to a silent payments address before
    sending is implemented in the wallet, but also allows the functions to
    be used in the unit testing famework.
    905381f79f
  119. tests: add BIP352 test vectors as unit tests
    Use the test vectors to test sending and receiving. A few cases are not
    covered here, namely anything that requires testing specific to the
    wallet. For example:
    
    * Taproot script path spending is not tested, as that is better tested in
      a wallets coin selection / signing logic
    * Re-computing outputs during RBF is not tested, as that is better
      tested in a wallets RBF logic
    12515f645e
  120. in src/common/bip352.cpp:77 in ee242399fc
      72 | +SilentPaymentsLabel SilentPaymentsLabel::FromBytes(const unsigned char* vch33)
      73 | +{
      74 | +    secp256k1_silentpayments_label label_obj;
      75 | +    if (!secp256k1_silentpayments_recipient_label_parse(secp256k1_context_static, &label_obj, vch33)) {
      76 | +        throw std::ios_base::failure("SilentPaymentsLabel: invalid label");
      77 | +    }
    


    josibake commented at 10:36 AM on August 20, 2026:

    A std::optional<label> would be more appropriate here, since it is expected that we will receive invalid bytes (not exceptional). [leaving as a note from our call]


    Eunovo commented at 6:27 PM on August 24, 2026:

    Done.

  121. Eunovo force-pushed on Aug 24, 2026
  122. in src/common/bip352.cpp:67 in 12515f645e
      62 | +    m_label = std::make_unique<secp256k1_silentpayments_label>(label);
      63 | +    int ret = secp256k1_silentpayments_recipient_label_serialize(secp256k1_context_static, m_vch, m_label.get());
      64 | +    assert(ret);
      65 | +}
      66 | +
      67 | +SilentPaymentsLabel::SilentPaymentsLabel(const unsigned char* label) {
    


    rustaceanrob commented at 10:08 AM on August 25, 2026:

    Given this size is known ahead of time, we can force the caller to use the correct number of bytes with std::span<const unsigned char, CPubKey::COMPRESSED_SIZE>. I think this was discussed earlier but I don't think there should be any performance cost here.

  123. in src/common/bip352.cpp:71 in 12515f645e
      66 | +
      67 | +SilentPaymentsLabel::SilentPaymentsLabel(const unsigned char* label) {
      68 | +    memcpy(m_vch, label, CPubKey::COMPRESSED_SIZE);
      69 | +}
      70 | +
      71 | +std::optional<SilentPaymentsLabel> SilentPaymentsLabel::FromBytes(const unsigned char* vch33)
    


    rustaceanrob commented at 10:08 AM on August 25, 2026:

    Same as here, can be std::span with 33 bytes.

  124. in src/common/bip352.cpp:90 in 12515f645e
      85 | +    if (label.m_label.get() != nullptr) {
      86 | +        m_label = std::make_unique<secp256k1_silentpayments_label>(*label.m_label.get());
      87 | +    }
      88 | +    memcpy(m_vch, label.m_vch, CPubKey::COMPRESSED_SIZE);
      89 | +}
      90 | +SilentPaymentsLabel& SilentPaymentsLabel::operator=(const SilentPaymentsLabel& label) {
    


    rustaceanrob commented at 10:21 AM on August 25, 2026:

    Something like this would also work:

    friend void swap(SilentPaymentsLabel& a, SilentPaymentsLabel& b) noexcept {
        std::swap(a.m_label, b.m_label);
        std::swap(a.m_vch, b.vch);
    }
    
    SilentPaymentsLabel& SilentPaymentsLabel::operator=(SilentPaymentsLabel other) noexcept {
        swap(*this, other);
        return *this;
    }
    

    That approach might have some performance costs but I think simplifies the code and unifies the copy and move assignment (can delete operator(SilentPaymentsLabel&&)


    Eunovo commented at 3:58 PM on August 26, 2026:

    It does look neat; however, I think it adds unnecessary costs.

  125. in src/common/bip352.cpp:256 in 12515f645e
     251 | +    const std::vector<SilentPaymentsDestination>& recipients,
     252 | +    const std::vector<CKey>& plain_keys,
     253 | +    const std::vector<KeyPair>& taproot_keypairs,
     254 | +    const COutPoint& smallest_outpoint
     255 | +) {
     256 | +    bool ret;
    


    rustaceanrob commented at 12:06 PM on August 25, 2026:

    nit: This ret value is used as the result for two different parse opts. These could be split into more explicit bool ec_scan_pub_ret and bool ec_spend_pub_ret. Similar nit for the bool in GenerateSilentPaymentsTaprootDestinations

  126. in src/common/bip352.cpp:250 in 12515f645e
     245 | +    if (pubkeys.size() + xonly_pubkeys.size() == 0) return std::nullopt;
     246 | +    auto smallest_outpoint = std::min_element(tx_outpoints.begin(), tx_outpoints.end(), bip352::BIP352Comparator());
     247 | +    return CreateInputPubkeysTweak(pubkeys, xonly_pubkeys, *smallest_outpoint);
     248 | +}
     249 | +
     250 | +std::vector<secp256k1_xonly_pubkey> CreateOutputs(
    


    rustaceanrob commented at 12:26 PM on August 25, 2026:

    IMO it would be useful to distinguish between a secp error and an empty output vector by changing the return type to optional<vector<>>

  127. in src/addresstype.h:145 in 12515f645e
     140 | +        const CPubKey& scan_pubkey,
     141 | +        const CPubKey& spend_pubkey,
     142 | +        const std::span<unsigned char>& extention_data = {}
     143 | +    );
     144 | +public:
     145 | +    static std::optional<SilentPaymentsDestination> From(
    


    rustaceanrob commented at 1:54 PM on August 25, 2026:

    Since this already uses a named constructor, I would introduce a FromV0 that has an implicit version:

    diff --git a/src/addresstype.cpp b/src/addresstype.cpp
    index 1a8f658c58..466e898607 100644
    -std::optional<SilentPaymentsDestination> SilentPaymentsDestination::From(
    +std::optional<SilentPaymentsDestination> SilentPaymentsDestination::FromV0(
    +    const CPubKey& scan_pubkey,
    +    const CPubKey& spend_pubkey)
    +{
    +    if (!scan_pubkey.IsFullyValid()  || !scan_pubkey.IsCompressed())  return std::nullopt;
    +    if (!spend_pubkey.IsFullyValid() || !spend_pubkey.IsCompressed()) return std::nullopt;
    +    return SilentPaymentsDestination(/*version=*/0, scan_pubkey, spend_pubkey, /*extension_data=*/{});
    +}
    +
    +std::optional<SilentPaymentsDestination> SilentPaymentsDestination::FromForwardCompatible(
         uint8_t version,
         const CPubKey& scan_pubkey,
         const CPubKey& spend_pubkey,
    -    const std::span<unsigned char>& extention_data
    -) {
    -    if (version >= 31) return std::nullopt;
    -    if (!scan_pubkey.IsFullyValid() || !scan_pubkey.IsCompressed())
    -        return std::nullopt;
    -    if (!spend_pubkey.IsFullyValid() || !spend_pubkey.IsCompressed())
    -        return std::nullopt;
    -    return SilentPaymentsDestination(version, scan_pubkey, spend_pubkey, extention_data);
    +    std::span<const unsigned char> extension_data)
    +{
    +    if (version < 1 || version > 30)                                  return std::nullopt;
    +    if (!scan_pubkey.IsFullyValid()  || !scan_pubkey.IsCompressed())  return std::nullopt;
    +    if (!spend_pubkey.IsFullyValid() || !spend_pubkey.IsCompressed()) return std::nullopt;
    +    if (extension_data.size() > MAX_EXTENSION_DATA_SIZE)              return std::nullopt;
    +    return SilentPaymentsDestination(version, scan_pubkey, spend_pubkey, extension_data);
     }
    
  128. in src/common/bip352.cpp:384 in 12515f645e
     379 | +std::vector<SilentPaymentsOutput> ScanForSilentPaymentsOutputs(
     380 | +    const CKey& scan_key,
     381 | +    const PrevoutsSummary& prevouts_summary,
     382 | +    const CPubKey& spend_pubkey,
     383 | +    const std::vector<XOnlyPubKey>& tx_outputs,
     384 | +    const std::map<SilentPaymentsLabel, uint256>& labels
    


    rustaceanrob commented at 2:07 PM on August 25, 2026:

    I would consider creating a newtype that forces the caller to provide the change label. A small wrapper that takes the scan key or change label as the only constructors would suffice:

    class SilentPaymentsLabelSet {
    private:
        std::map<SilentPaymentsLabel, uint256> m_labels;
        SilentPaymentsLabel m_change_label;
    
    public:
        explicit SilentPaymentsLabelSet(std::pair<SilentPaymentsLabel, uint256> change)
            : m_change_label(change.first)
        {
            m_labels.emplace(std::move(change.first), change.second);
        }
    
        explicit SilentPaymentsLabelSet(const CKey& scan_key)
            : SilentPaymentsLabelSet(CreateLabel(scan_key, 0)) {}
    
        void Add(std::pair<SilentPaymentsLabel, uint256> label)
        {
            m_labels.emplace(std::move(label.first), label.second);
        }
    
        const SilentPaymentsLabel& GetChangeLabel() const LIFETIMEBOUND { return m_change_label; }
        const std::map<SilentPaymentsLabel, uint256>& AsMap() const LIFETIMEBOUND { return m_labels; }
    };
    
  129. in src/common/bip352.h:60 in 12515f645e
      55 | +struct BIP352Comparator {
      56 | +    bool operator()(const COutPoint& a, const COutPoint& b) const {
      57 | +        // BIP352 defines the "smallest outpoint" based on a lexicographic
      58 | +        // sort of the outpoints, using the 36-byte serialization:
      59 | +        // <txid, 32-bytes little-endian>:<vout, 4-bytes little-endian>
      60 | +        if (a.hash != b.hash) {
    


    rustaceanrob commented at 2:15 PM on August 25, 2026:

    I think this can be condensed to:

    if (a.hash != b.hash) return a.hash < b.hash;
    return internal_bswap_32(a.n) < internal_bswap_32(b.n)
    

    using compat/byteswap

  130. rustaceanrob commented at 2:17 PM on August 25, 2026: member

    Left some review primarily on bip352.cpp. In addition to the suggested comments, I have some public API suggestions, feel free to take them or leave them:

    <details> <summary>Suggestions for public API</summary>

    diff --git a/src/common/bip352.cpp b/src/common/bip352.cpp
    index c8a59f03e2..5e0a82605f 100644
    --- a/src/common/bip352.cpp
    +++ b/src/common/bip352.cpp
    @@ -23,6 +23,7 @@
     #include <streams.h>
     #include <uint256.h>
     #include <util/check.h>
    +#include <util/expected.h>
    
     #include <algorithm>
     #include <optional>
    @@ -64,14 +65,14 @@ SilentPaymentsLabel::SilentPaymentsLabel(const secp256k1_silentpayments_label& l
         assert(ret);
     }
    
    -SilentPaymentsLabel::SilentPaymentsLabel(const unsigned char* label) {
    -    memcpy(m_vch, label, CPubKey::COMPRESSED_SIZE);
    +SilentPaymentsLabel::SilentPaymentsLabel(std::span<const unsigned char, CPubKey::COMPRESSED_SIZE> label) {
    +    std::memcpy(m_vch, label.data(), label.size());
     }
    
    -std::optional<SilentPaymentsLabel> SilentPaymentsLabel::FromBytes(const unsigned char* vch33)
    +std::optional<SilentPaymentsLabel> SilentPaymentsLabel::FromBytes(std::span<const unsigned char, CPubKey::COMPRESSED_SIZE> vch33)
     {
         secp256k1_silentpayments_label label_obj;
    -    if (!secp256k1_silentpayments_recipient_label_parse(secp256k1_context_static, &label_obj, vch33)) {
    +    if (!secp256k1_silentpayments_recipient_label_parse(secp256k1_context_static, &label_obj, vch33.data())) {
             return std::nullopt;
         }
         return SilentPaymentsLabel(label_obj);
    @@ -247,7 +248,7 @@ std::optional<PrevoutsSummary> GetSilentPaymentsPrevoutsSummary(const std::vecto
         return CreateInputPubkeysTweak(pubkeys, xonly_pubkeys, *smallest_outpoint);
     }
    
    -std::vector<secp256k1_xonly_pubkey> CreateOutputs(
    +std::optional<std::vector<secp256k1_xonly_pubkey>> CreateOutputs(
         const std::vector<SilentPaymentsDestination>& recipients,
         const std::vector<CKey>& plain_keys,
         const std::vector<KeyPair>& taproot_keypairs,
    @@ -284,11 +285,11 @@ std::vector<secp256k1_xonly_pubkey> CreateOutputs(
             generated_output_ptrs.push_back(&generated_outputs[i]);
         }
         for (const auto& key : plain_keys) {
    -        if (!key.IsValid()) return {};
    +        if (!Assume(key.IsValid())) return std::nullopt;
             plain_key_ptrs.push_back(UCharCast(key.begin()));
         }
         for (const auto& keypair : taproot_keypairs) {
    -        if (!keypair.IsValid()) return {};
    +        if (!Assume(keypair.IsValid())) return std::nullopt;
             taproot_keypair_ptrs.push_back(reinterpret_cast<const secp256k1_keypair*>(keypair.data()));
         }
    
    @@ -304,17 +305,17 @@ std::vector<secp256k1_xonly_pubkey> CreateOutputs(
             taproot_keypair_ptrs.data(), taproot_keypair_ptrs.size(),
             plain_key_ptrs.data(), plain_key_ptrs.size()
         );
    -    if (!ret) return {};
    +    if (!ret) return std::nullopt;
         return generated_outputs;
     }
    
    -std::optional<std::map<size_t, WitnessV1Taproot>> GenerateSilentPaymentsTaprootDestinations(const std::map<size_t, SilentPaymentsDestination>& sp_dests, const std::vector<C
    Key>& plain_keys, const std::vector<KeyPair>& taproot_keys, const COutPoint& smallest_outpoint)
    +util::Expected<std::map<size_t, WitnessV1Taproot>, GenerateOutputsError>
    +GenerateSilentPaymentsTaprootDestinations(const std::map<size_t, SilentPaymentsDestination>& sp_dests, const std::vector<CKey>& plain_keys, const std::vector<KeyPair>& tapr
    oot_keys, const COutPoint& smallest_outpoint)
     {
    -    if (sp_dests.empty()) return {};
    -    if (smallest_outpoint.IsNull()) return {};
    -    if (plain_keys.empty() && taproot_keys.empty()) return {};
    +    if (sp_dests.empty())                           return util::Unexpected{GenerateOutputsError::NoRecipients};
    +    if (smallest_outpoint.IsNull())                 return util::Unexpected{GenerateOutputsError::NullSmallestOutpoint};
    +    if (plain_keys.empty() && taproot_keys.empty()) return util::Unexpected{GenerateOutputsError::NoInputKeys};
    
    -    bool ret;
         std::map<size_t, WitnessV1Taproot> tr_dests;
         std::vector<SilentPaymentsDestination> recipients;
         recipients.reserve(sp_dests.size());
    @@ -322,14 +323,13 @@ std::optional<std::map<size_t, WitnessV1Taproot>> GenerateSilentPaymentsTaprootD
             tr_dests.emplace(i, WitnessV1Taproot());
             recipients.push_back(addr);
         }
    -    std::vector<secp256k1_xonly_pubkey> outputs = CreateOutputs(recipients, plain_keys, taproot_keys, smallest_outpoint);
    -    // This will fail if any input pubkey is null or
    -    // inputs were maliciously crafted to sum to zero
    -    if (outputs.empty()) return std::nullopt;
    +    auto outputs = CreateOutputs(recipients, plain_keys, taproot_keys, smallest_outpoint);
    +    if (!outputs) return util::Unexpected{GenerateOutputsError::Secp256k1Failure};
    +
         size_t output_i{0};
         for (const auto& [i, addr] : sp_dests) {
             unsigned char xonly_pubkey_bytes[32];
    -        ret = secp256k1_xonly_pubkey_serialize(secp256k1_context_static, xonly_pubkey_bytes, &outputs[output_i]);
    +        bool ret = secp256k1_xonly_pubkey_serialize(secp256k1_context_static, xonly_pubkey_bytes, &(*outputs)[output_i]);
             assert(ret);
             tr_dests[i] = WitnessV1Taproot{XOnlyPubKey{xonly_pubkey_bytes}};
             output_i++;
    @@ -339,7 +339,7 @@ std::optional<std::map<size_t, WitnessV1Taproot>> GenerateSilentPaymentsTaprootD
    
     const unsigned char* LabelLookupCallback(const unsigned char* key, const void* context) {
         auto label_context = static_cast<const std::map<SilentPaymentsLabel, uint256>*>(context);
    -    SilentPaymentsLabel label{key};
    +    SilentPaymentsLabel label{std::span<const unsigned char, CPubKey::COMPRESSED_SIZE>{key, CPubKey::COMPRESSED_SIZE}};
         auto it = label_context->find(label);
         if (it != label_context->end()) {
             return it->second.begin();
    diff --git a/src/common/bip352.h b/src/common/bip352.h
    index fa6213ede0..9b027b0d6d 100644
    --- a/src/common/bip352.h
    +++ b/src/common/bip352.h
    @@ -10,7 +10,9 @@
     #include <primitives/transaction.h>
     #include <pubkey.h>
     #include <uint256.h>
    +#include <util/expected.h>
    
    +#include <array>
     #include <compare>
     #include <cstdint>
     #include <cstring>
    @@ -76,11 +78,11 @@ private:
         //! LabelLookupCallback. It does not parse the label
         //! bytes to avoid EC-point parse. This is safe because
         //! the resulting object is only used for comparison.
    -    SilentPaymentsLabel(const unsigned char* label);
    +    explicit SilentPaymentsLabel(std::span<const unsigned char, CPubKey::COMPRESSED_SIZE> label);
    
         //! Parses raw bytes into a fully valid label
         //! returns std::nullopt if vch33 is not a validly-encoded label.
    -    static std::optional<SilentPaymentsLabel> FromBytes(const unsigned char* vch33);
    +    static std::optional<SilentPaymentsLabel> FromBytes(std::span<const unsigned char, CPubKey::COMPRESSED_SIZE> vch33);
    
     public:
         SilentPaymentsLabel(const secp256k1_silentpayments_label& label);
    @@ -113,9 +115,9 @@ public:
         template <typename Stream>
         static std::optional<SilentPaymentsLabel> Unserialize(Stream& s)
         {
    -        unsigned char vch[CPubKey::COMPRESSED_SIZE];
    -        s >> std::span{vch, CPubKey::COMPRESSED_SIZE};
    -        return FromBytes(vch);
    +        std::array<unsigned char, CPubKey::COMPRESSED_SIZE> vch;
    +        s >> std::span{vch};
    +        return FromBytes(std::span{vch});
         }
    
         const secp256k1_silentpayments_label* Get() const;
    @@ -141,6 +143,13 @@ struct SilentPaymentsOutput {
      */
     std::optional<PubKey> GetPubKeyFromInput(const CTxIn& txin, const CScript& spk);
    
    +enum class GenerateOutputsError {
    +    NoRecipients,           //!< sp_dests was empty
    +    NullSmallestOutpoint,   //!< no eligible inputs supplied
    +    NoInputKeys,            //!< both plain_keys and taproot_keys were empty
    +    Secp256k1Failure,       //!< inputs summed to zero, or another libsecp256k1 error
    +};
    +
     /**
      * [@brief](/bitcoin-bitcoin/contributor/brief/) Generate silent payments taproot destinations.
      *
    @@ -154,7 +163,8 @@ std::optional<PubKey> GetPubKeyFromInput(const CTxIn& txin, const CScript& spk);
      * [@param](/bitcoin-bitcoin/contributor/param/) smallest_outpoint                   The smallest_outpoint from the transaction inputs.
      * [@return](/bitcoin-bitcoin/contributor/return/) std::map<size_t, WitnessV1Taproot> The generated silent payments taproot destinations.
      */
    -std::optional<std::map<size_t, WitnessV1Taproot>> GenerateSilentPaymentsTaprootDestinations(const std::map<size_t, SilentPaymentsDestination>& sp_dests, const std::vector<C
    Key>& plain_keys, const std::vector<KeyPair>& taproot_keys, const COutPoint& smallest_outpoint);
    +util::Expected<std::map<size_t, WitnessV1Taproot>, GenerateOutputsError>
    +GenerateSilentPaymentsTaprootDestinations(const std::map<size_t, SilentPaymentsDestination>& sp_dests, const std::vector<CKey>& plain_keys, const std::vector<KeyPair>& tapr
    oot_keys, const COutPoint& smallest_outpoint);
    
     /**
      * [@brief](/bitcoin-bitcoin/contributor/brief/) Create a silent payments label pair.
    diff --git a/src/test/bip352_tests.cpp b/src/test/bip352_tests.cpp
    index eae2b724c5..8e9cf438e9 100644
    --- a/src/test/bip352_tests.cpp
    +++ b/src/test/bip352_tests.cpp
    @@ -91,7 +91,7 @@ BOOST_AUTO_TEST_CASE(bip352_send_and_receive_test_vectors)
                 }
                 auto sp_tr_dests = bip352::GenerateSilentPaymentsTaprootDestinations(sp_dests, keys, taproot_keys, *smallest_outpoint);
                 // This means the inputs summed to zero, which realistically would only happen maliciously. In this case, just move on
    -            if (!sp_tr_dests.has_value()) {
    +            if (!sp_tr_dests) {
                     // Check that we actually expect zero outputs to be generated for this test
                     BOOST_CHECK(expected["outputs"].getValues()[0].empty());
                     continue;
    @@ -222,7 +222,7 @@ BOOST_AUTO_TEST_CASE(bip352_preserves_requested_output_indexes)
    
         auto generated = bip352::GenerateSilentPaymentsTaprootDestinations(sp_dests, {sender_key}, {}, smallest_outpoint);
    
    -    BOOST_REQUIRE(generated.has_value());
    +    BOOST_REQUIRE(generated);
         BOOST_CHECK_EQUAL(generated->size(), sp_dests.size());
         BOOST_CHECK_EQUAL(generated->count(0), 0);
         BOOST_CHECK_EQUAL(generated->count(1), 0);
    @@ -240,7 +240,8 @@ BOOST_AUTO_TEST_CASE(bip352_sender_rejects_empty_input_key_set)
    
         const auto generated = bip352::GenerateSilentPaymentsTaprootDestinations(sp_dests, {}, {}, smallest_outpoint);
    
    -    BOOST_CHECK(!generated.has_value());
    +    BOOST_REQUIRE(!generated);
    +    BOOST_CHECK(generated.error() == bip352::GenerateOutputsError::NoInputKeys);
     }
    

    </details>

  131. bitcoin deleted a comment on Aug 25, 2026
  132. Eunovo commented at 12:58 PM on August 27, 2026: contributor

    While reasoning about the API changes @rustaceanrob suggested, I had a few new ideas, and I came up with a different API that has a new SilentPaymentsReceiver class that contains all the receiving functions. I pushed this new API to implement-bip352-all/bip352.h for reviewers to compare against the API on this branch.

    It offers a clear API that is difficult to misuse. The change label is automatically created and checked. Deriving a label and registering it for scanning are no longer separate steps. There is reduced need to thread key material by hand in different functions.

    cc: @josibake @theStack @rustaceanrob

  133. rustaceanrob commented at 2:43 PM on August 27, 2026: member

    . I pushed this new API to implement-bip352-all/bip352.h for reviewers to compare against the API on this branch.

    I like the changes here.

  134. theStack commented at 3:40 PM on August 31, 2026: contributor

    ... I came up with a different API that has a new SilentPaymentsReceiver class that contains all the receiving functions. I pushed this new API to implement-bip352-all/bip352.h for reviewers to compare against the API on this branch.

    Nice, looks like a reasonable API improvement, happy to re-review if you want to adopt it in this PR.


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-08-31 18:51 UTC

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