Add “silentpayments” module implementing BIP352 (take 5, using “LabelSet” scanning approach) #1792

pull theStack wants to merge 11 commits into bitcoin-core:master from theStack:silentpayments_module_fullnode_only-LabelSet-scan changing 23 files +8127 −35
  1. theStack commented at 3:24 am on December 29, 2025: contributor

    Summary

    This PR implements BIP352 with scanning limited to full-nodes. Compared to #1765, an alternative scanning method called the “LabelSet approach” (initially [proposed by @w0xlt](/bitcoin-core-secp256k1/1765/#issuecomment-3592403663)) is used, with the primary goal of fixing the worst-case scanning attack, discovered and discussed at length in previous takes 3 and 4. In constrast to the previously used “BIP approach”, the scanning cost depends primarily on the number of labels (something the user can control and the module can set a limit on), rather than the number of transaction outputs (limited only by Bitcoin’s consensus rules, i.e. the block weight limit). See https://gist.github.com/theStack/25c77747838610931e8bbeb9d76faf78 for a high-level comparison between the two approaches with benchmarks, and a Python implementation based on secp256k1lab.

    Pros and cons

    Other than mitigating adversarial scanning scenarios, the LabelSet approach offers additional advantages:

    • simpler API: passing an explicit list of labels to scan for is much simpler than having to maintain a label cache data structure and provide a callback function; passing the transaction outputs as raw 32-byte x-only pubkeys is also simpler for the user, as no prior secp256k1_xonly_pubkey_parse calls are needed anymore (see #1765 (comment) for more details about API and implementation between the two approaches)
    • faster also for the common case (i.e. no match), if the number of labels used is reasonably small, as recent benchmarks indicate (in theory, the LabelSet approach should be faster whenever the number of labels L is smaller than twice the number of the scanned tx’s taproot outputs N, i.e. if L < 2 * N).
    • simpler implementation: scanning resembles the flow on the sender side (i.e. start with unlabeled public key, end with the taproot output), and there is no need to handle the missing y-parity information in x-only public keys (by trying both)

    The main drawback of the LabelSet approach is that every additional label increases the scanning cost by at least one EC point addition. As a result, it is definitely not suitable for use cases that require a large number of labels.

    Limiting the number of labels

    To be on the safe side and still avoid the worst-case scanning attack, it was suggested to limit the number of supported labels in the module. Currently, this limit is not enforced in code; it only appears as a WARNING in the API header documentation, with the recommendation to not use more than 20 (that’s the number where the worst-case takes about a second on my machine, see benchmarks below). It is unclear whether this is sufficient. We may want to enforce a hard limit instead, or introduce a configurable limit (for example at compile time or via an API call). This would better communicate that using values outside the recommended range is done at the user’s own risk.

    For a minimalist solution, one could in theory support only a single label for now. That’s of course very restrictive, but would still comply with the minimum BIP requirements (see https://github.com/bitcoin/bips/blob/fc00f51c229088c447b3694cca9bf14ace0e1a96/bip-0352.mediawiki?plain=1#L131). The advantage would be that it would result in the simplest possible API and implementation, and expectations would be very clearly expressed to the user, if the parameter might be even called e.g. change_label.

    Benchmarks

    Benchmarks have been updated to show average scenarios over a N/L matrix (N…number of tx outputs, L… number of labels) as well as the worst case scenario:

     0$ ./build/bin/bench silentpayments
     1Benchmark                               ,    Min(us)    ,    Avg(us)    ,    Max(us)
     2
     3silentpayments_scan_nomatch_N=10_L=0    ,    41.1       ,    41.1       ,    41.2
     4silentpayments_scan_nomatch_N=10_L=1    ,    42.1       ,    42.1       ,    42.2
     5silentpayments_scan_nomatch_N=10_L=2    ,    43.1       ,    43.2       ,    43.2
     6silentpayments_scan_nomatch_N=10_L=5    ,    46.1       ,    46.2       ,    46.2
     7silentpayments_scan_nomatch_N=10_L=10   ,    51.6       ,    51.6       ,    51.7
     8silentpayments_scan_nomatch_N=10_L=20   ,    62.6       ,    62.7       ,    62.7
     9silentpayments_scan_nomatch_N=10_L=50   ,    96.4       ,    96.5       ,    96.6
    10silentpayments_scan_nomatch_N=10_L=100  ,   153.0       ,   153.0       ,   153.0
    11
    12silentpayments_scan_nomatch_N=100_L=0   ,    43.5       ,    43.5       ,    43.5
    13silentpayments_scan_nomatch_N=100_L=1   ,    44.4       ,    44.5       ,    44.5
    14silentpayments_scan_nomatch_N=100_L=2   ,    45.5       ,    45.5       ,    45.5
    15silentpayments_scan_nomatch_N=100_L=5   ,    48.5       ,    48.5       ,    48.6
    16silentpayments_scan_nomatch_N=100_L=10  ,    53.9       ,    54.0       ,    54.0
    17silentpayments_scan_nomatch_N=100_L=20  ,    65.0       ,    65.0       ,    65.1
    18silentpayments_scan_nomatch_N=100_L=50  ,    99.0       ,    99.0       ,    99.1
    19silentpayments_scan_nomatch_N=100_L=100 ,   156.0       ,   156.0       ,   156.0
    20
    21silentpayments_scan_nomatch_N=2325_L=0  ,   220.0       ,   221.0       ,   222.0
    22silentpayments_scan_nomatch_N=2325_L=1  ,   221.0       ,   222.0       ,   223.0
    23silentpayments_scan_nomatch_N=2325_L=2  ,   222.0       ,   223.0       ,   223.0
    24silentpayments_scan_nomatch_N=2325_L=5  ,   226.0       ,   227.0       ,   228.0
    25silentpayments_scan_nomatch_N=2325_L=10 ,   232.0       ,   233.0       ,   234.0
    26silentpayments_scan_nomatch_N=2325_L=20 ,   243.0       ,   244.0       ,   245.0
    27silentpayments_scan_nomatch_N=2325_L=50 ,   279.0       ,   280.0       ,   280.0
    28silentpayments_scan_nomatch_N=2325_L=100,   339.0       ,   339.0       ,   340.0
    29
    30silentpayments_scan_worstcase_L=1       , 416567.0       , 416567.0       , 416567.0
    31silentpayments_scan_worstcase_L=2       , 444503.0       , 444503.0       , 444503.0
    32silentpayments_scan_worstcase_L=5       , 531165.0       , 531165.0       , 531165.0
    33silentpayments_scan_worstcase_L=10      , 674923.0       , 674923.0       , 674923.0
    34silentpayments_scan_worstcase_L=20      , 961857.0       , 961857.0       , 961857.0
    35silentpayments_scan_worstcase_L=50      , 1820950.0       , 1820950.0       , 1820950.0
    36silentpayments_scan_worstcase_L=100     , 3249931.0       , 3249931.0       , 3249931.0
    

    History and details about the “BIP approach”

    Prior takes of the “silentpayments” module (PRs #1471, #1519, #1698, #1765) implemented scanning for labels as suggested in BIP352 (the “BIP approach”): for each taproot output, subtract the unlabeled output candidate and check if the result is in a pre-calculated label cache. This method has the useful property of being performance-independent of the number of labels to scan for, as the lookups in the label cache are constant and fast, if implemented in a proper efficient data structure like a hash map (even hundreds of thousands of labels can be checked without a noticeable loss in performance, see the showcase in https://groups.google.com/g/bitcoindev/c/bP6ktUyCOJI/m/HGCF9YxNAQAJ).

    One drawback of the BIP approach is that it scales poorly for pathological transactions containing a large number of outputs, all corresponding to Silent Payments recipients sharing the same scan key. In the worst-case scenario of a single, very large transaction filling an entire block (note that such a transaction would be non-standard, but still consensus-valid), scanning could take several minutes for the targeted recipient (see #1698#pullrequestreview-3341766084). Proposed mitigations in takes 3 and 4 that were based on the BIP approach turned out to be insufficient to fix the problem, and it is currently believed that this is an inherent problem that could only be solved by a protocol change (by e.g. limiting the Silent Payments eligible transactions to a smaller number of outputs).

    Therefore, a different way of scanning has been proposed that works in the opposite direction: iterate over a list of explicitly passed labels, add the unlabeled output candidate and check if the result is in the list of taproot outputs (via binary search to be efficient), i.e. the “LabelSet approach”.

    https://www.youtube.com/watch?v=Hm-q80gA7NI

  2. build: add skeleton for new silentpayments (BIP352) module 4f9f2c65d3
  3. silentpayments: sending
    Add a routine for the entire sending flow which takes a set of private keys,
    the smallest outpoint, and list of recipients and returns a list of
    x-only public keys by performing the following steps:
    
    1. Sum up the private keys
    2. Calculate the input_hash
    3. For each recipient group:
        3a. Calculate a shared secret
        3b. Create the requested number of outputs
    
    This function assumes a single sender context in that it requires the
    sender to have access to all of the private keys. In the future, this
    API may be expanded to allow for a multiple senders or for a single
    sender who does not have access to all private keys at any given time,
    but for now these modes are considered out of scope / unsafe.
    
    Internal to the library, add:
    
    1. A function for creating shared secrets (i.e., a*B or b*A)
    2. A function for generating the "SharedSecret" tagged hash
    3. A function for creating a single output public key
    78b259fc43
  4. silentpayments: recipient label support
    Add function for creating a label tweak. This requires a tagged hash
    function for labels. This function is used by the receiver for creating
    labels to be used for a) creating labeled addresses and b) to populate
    a labels cache when scanning.
    
    Add function for creating a labeled spend pubkey. This involves taking
    a label tweak, turning it into a public key and adding it to the spend
    public key. This function is used by the receiver to create a labeled
    silent payment address.
    
    Add tests for the label API.
    5dac264854
  5. fjahr commented at 1:51 pm on December 29, 2025: contributor

    Concept ACK per my rationale in the take 4 PR

    To be on the safe side and still avoid the worst-case scanning attack, it was suggested to limit the number of supported labels in the module. Currently, this limit is not enforced in code; it only appears as a WARNING in the API header documentation, with the recommendation to not use more than 20 (that’s the number where the worst-case takes about a second on my machine, see benchmarks below). It is unclear whether this is sufficient. We may want to enforce a hard limit instead, or introduce a configurable limit (for example at compile time or via an API call).

    I think it’s good to add this documentation and warn of the performance penalties with a specific number as a reference is good but I still think we should probably add a hard limit too which can be higher. I was thinking of the order of 1000 without having run any actual benchmarks. From a high level perspective I think such a number implies that the user probably doesn’t generate new labels manually and uses the static sp addresses in different places (the typical usage pattern we mostly expect I think). Instead such a user may have some kind of automation in place that churns out new sp addresses with new labels and that the number of labels may potentially grow unbounded. The goal would be that such a user runs into this limit rather than that their servers are brought to their knees unexpectedly, which should be the better outcome in my mind. But curious what others think.

  6. theStack force-pushed on Jan 1, 2026
  7. theStack commented at 3:21 pm on January 1, 2026: contributor

    @fjahr: Sounds reasonable. I’ve added a hard limit on the number of labels as suggested, defined by a public API constant SECP256K1_SILENTPAYMENTS_MAX_LABELS, currently set to 500 [1] (very arbitrary, no idea if 15 seconds is still okay or not, but more than that seems definitely too much in my opinion). The n_label_entries parameter for the scan function is ARG_CHECKed against that, i.e. any values exceeding that are treated as illegal.

    Some optional ideas on top that came to my mind:

    • we could wrap the limit #define in a conditional #ifndef block in order to allow the user to override the limit at compile-time; if that’s the case, one could emit a warning in the #else block via #warning to notify the user that this is outside of the recommended range and could lead to very high worst-case scanning times (EDIT: I guess this doesn’t work in general, considering that it would need a recompilation of the silentpayments module for the user-defined limit to take effect).
    • we could limit the number of labels already at label creation time, in the sense that only values in the range of $0..MAX-1$ are allowed to be passed as $m$ parameter to _label_create. This would allow to limit labels as early as possible (it’s kind of bad if one can create labeled addresses, but then not be able to scan for them), but on the other hand that assumes that labels are only ever created in a contiguous range starting from zero. The currently proposed sp() output descriptor format allows to specify arbitrary ranges, see https://github.com/bitcoin/bips/pull/2047#issuecomment-3685534829. Considering that, limiting the $m$ values to one range seems a bit too restrictive.

    [1] worst-case benchmarks on my machine:

     0$ ./build/bin/bench silentpayments_scan_worstcase
     1Benchmark                               ,    Min(us)    ,    Avg(us)    ,    Max(us)
     2
     3silentpayments_scan_worstcase_L=1       , 415553.0       , 415553.0       , 415553.0
     4silentpayments_scan_worstcase_L=2       , 444280.0       , 444280.0       , 444280.0
     5silentpayments_scan_worstcase_L=5       , 529870.0       , 529870.0       , 529870.0
     6silentpayments_scan_worstcase_L=10      , 672437.0       , 672437.0       , 672437.0
     7silentpayments_scan_worstcase_L=20      , 956493.0       , 956493.0       , 956493.0
     8silentpayments_scan_worstcase_L=50      , 1808294.0       , 1808294.0       , 1808294.0
     9silentpayments_scan_worstcase_L=100     , 3224289.0       , 3224289.0       , 3224289.0
    10silentpayments_scan_worstcase_L=200     , 6058590.0       , 6058590.0       , 6058590.0
    11silentpayments_scan_worstcase_L=500     , 14562453.0       , 14562453.0       , 14562453.0
    
  8. silentpayments: receiving
    Add routine for scanning a transaction and returning the necessary
    spending data for any found outputs. This function works with labels via
    expliticly passing in a list of (label, label_tweak) entries and
    needs access to the transaction outputs.
    Requiring access to the transaction outputs is not suitable for light
    clients, but light client support is provided in a future release.
    
    Add an opaque data type for passing around the prevout public key sum
    and the input hash tweak (input_hash). This data is passed to the scanner
    before the ECDH step as two separate elements so that the scanner can
    multiply the scan_key * input_hash before doing ECDH.
    
    Finally, add test coverage for the receiving API.
    aad3573a9a
  9. silentpayments: add examples/silentpayments.c
    Demonstrate sending and scanning on full nodes.
    dbf3cafc22
  10. silentpayments: add benchmarks for scanning
    Add benchmarks for a full transaction scan, over a L/N matrix
    (L/N, L...number of labels, N...number of tx outputs) for the
    common case and worst-case.
    
    Only benchmarks for scanning are added as this is the most
    performance critical portion of the protocol.
    
    Co-authored-by: Sebastian Falbesoner <91535+thestack@users.noreply.github.com>
    c0426e9430
  11. tests: add BIP-352 test vectors
    Add the BIP-352 test vectors. The vectors are generated with a Python script
    that converts the .json file from the BIP to C code:
    
    $ ./tools/tests_silentpayments_generate.py test_vectors.json > ./src/modules/silentpayments/vectors.h
    
    Co-authored-by: Ron <4712150+macgyver13@users.noreply.github.com>
    Co-authored-by: Sebastian Falbesoner <91535+thestack@users.noreply.github.com>
    Co-authored-by: Tim Ruffing <1071625+real-or-random@users.noreply.github.com>
    957fb354e0
  12. tests: add constant time tests
    Co-authored-by: Jonas Nick <2582071+jonasnick@users.noreply.github.com>
    Co-authored-by: Sebastian Falbesoner <91535+thestack@users.noreply.github.com>
    bb7cccd9dd
  13. tests: add sha256 tag test
    Test midstate tags used in silent payments.
    0ce93f4acd
  14. ci: enable silentpayments module 8e74236aa8
  15. docs: update README c2061dfd5f
  16. theStack force-pushed on Jan 1, 2026
  17. w0xlt commented at 11:44 am on January 2, 2026: none

    Approach ACK. Will review in detail soon.

    For the reasons outlined in the “Pros and cons” section of the PR description, particularly the simpler API, improved common-case performance for small label sets and mitigation of the worst-case scanning attack—the LabelSet approach seems like the right default choice.

    Given that most users are expected to have a relatively small number of labels (likely well within the SECP256K1_SILENTPAYMENTS_MAX_LABELS bound), this should cover the typical use case nicely.

    If there’s sufficient demand, a hybrid approach could always be explored in a future iteration to accommodate more specialized scenarios.

  18. Sjors commented at 6:15 am on January 5, 2026: member

    It might be good to have a separate Github issue to discuss this.

    I think it’s fine to initially go for the LabelSet approach, since many current implementations are mobile, which means they benefit from the safety this provides and won’t run into the label limit anytime soon.

    To reduce review burden on this PR, we can add the hybrid approach in a followup, where we pick the BIP mechanism based on e.g. the 8 * n_tx_outputs < n_labels + 2 heuristic (https://github.com/w0xlt/secp256k1/commit/6c76c7c5fa30c5fa7676511de22c47953ee76bd1#diff-4d053be8d1f6d948b412f26ae89711a9dcd2a2683da581cb52b9f6757480361b).

    I do think that hybrid approach is useful. Someone who goes through the trouble of automating label generation might happily add some CPU cores if lets them avoid the complexity of splitting their users across multiple watch keys. Especially if the cost of an attack far outweighs the cost to deal with it.

    We could document the hardware required to guarantee that a worst case block takes less than 10 minutes to process. For the hybrid approach that’s just a single number IIUC. For the LabelSet approach it’s a function of L. With that in place a warning should be enough IMO, for L > ? based on lower end hardware (mobile) and e.g. a 10 second worst case.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin-core/secp256k1. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-01-07 22:15 UTC

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