Silent Payments: Implement bip352 (take 2) #35301

pull Eunovo wants to merge 6 commits into bitcoin:master from Eunovo:implement-bip352 changing 22 files +6938 −16
  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

    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:

    • #35861 (Testnet 5 (BIP95) by fjahr)
    • #35793 (Implement BIP 54 (Consensus Cleanup) without mainnet activation by darosior)
    • #35642 (headersync: do parameter search at runtime by sipa)
    • #27260 (Enhanced error messages for invalid network prefix during address parsing. by portlandhodl)

    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{{}, GetScriptForDestination(WitnessV0KeyHash{pubkey})}, 0, false} in src/test/bip352_tests.cpp
    • Coin{CTxOut{{}, GetScriptForDestination(WitnessUnknown{2, std::vector<unsigned char>(32, 1)})}, 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-10 17:19:53</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?).

  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. 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.
    6b423d2049
  88. Add "sp" HRP d020c7c8c0
  89. Eunovo force-pushed on Aug 10, 2026
  90. Add Silent Payments address types
    Add V0SilentPaymentsDestination and UnknownSilentPaymentsDestination
    for v1 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.
    
    V0SilentPaymentsDestination must not be formed from invalid public keys;
    the operations that must be performed to send to a V0SilentPaymentsDestination,
    require valid public keys. Hence, the V0SilentPaymentsDestination struct takes
    the following precautions to ensure that such invalid states are not possible:
    - The constructor will throw when provided with invalid public keys
    - The public keys are private and const references can be retrieved using member functions.
    5bf7eb69dd
  91. 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
    bdce163af5
  92. 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.
    06f90dcb62
  93. 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
    
    The unit tests are written in such a way that adding new test cases is
    as easy as updating the JSON file
    ed66407d72
  94. Eunovo force-pushed on Aug 10, 2026
  95. DrahtBot added the label CI failed on Aug 10, 2026
  96. 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>

  97. 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 upstream/master..dde9ab6 upstream/master..ed66407


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

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