Skip to content

Conversation

josibake
Copy link
Member

This PR implements BIP352 - Silent payments. It is recommended to read through the BIP before reviewing this PR.

This is a continuation of the work in #1519 and only opened as a new PR due to the comment history on #1519 becoming quite long and difficult to sift through. It is recommended reviewers go through #1519 for background context, if interested.

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 6264c3d to 9e85256 Compare July 14, 2025 14:54
@josibake
Copy link
Member Author

Updated 6264c3d -> 9e85256 (2025_00 -> 2025_01, compare)

  • Added documentation for expectations around label_lookup pointer lifetimes (h/t @antonilol)
  • Update docs to accurately reflect that label_context is optional (h/t @antonilol)
  • Added a test case for passing a lookup callback with a null context (which required some small updates to the test label lookup function)

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 9e85256 to a4db279 Compare July 21, 2025 14:09
@real-or-random
Copy link
Contributor

Sorry, stopping CI here. We're about to make a release and need to the CI. :)

We'll restart the jobs here afterwards.

@josibake
Copy link
Member Author

Update 9e85256 -> a4db279 (2025_01 -> 2025_02, compare)

  • Update the constant time tests to cover the _recipient_created_shared_secret and _recipient_created_output_pubkey functions (h/t @theStack )
  • Remove no longer needed TODO comments and clarify why a constant time test without a label lookup function is sufficient for _recipient_scan_outputs

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from a4db279 to e35bede Compare July 22, 2025 10:27
@josibake
Copy link
Member Author

Rebased on top of 0.7.0 release 🎉 a4db279 -> e35bede (2025_02 -> 2025_02_rebase, compare)

@josibake
Copy link
Member Author

I did a deep dive on using (*arg)[size] in this PR and opened #1710 for discussion, since this is a broader topic than just this PR. The relevant changes for here and the downstream Bitcoin Core PRs are josibake@5a10880 and josibake/bitcoin@5835d98

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from e35bede to 1a84908 Compare July 24, 2025 12:18
@josibake
Copy link
Member Author

Updated e35bede -> 1a84908 (2025_02_rebase -> 2025_03, compare)

  • Added a test case for the _recipient_create_output_pubkey corner case (h/t @theStack)
  • Removed the VERIFY_CHECK in favour of returning an error

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 1a84908 to 2948a9b Compare July 25, 2025 09:16
@josibake
Copy link
Member Author

Update 1a84908 -> 2948a9b (2025_03 -> 2025_04, compare)

  • Fixed valgrind error in test
  • Update the example to use EXIT_SUCCESS/EXIT_FAILURE (h/t @theStack)
  • Clear shared secret variable consistently (and update comment) (h/t @theStack)
  • Add comment explaining why we declassify the pubkey sum (h/t @theStack)

Thanks for the thorough review, @theStack !

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 2948a9b to 64ecd6c Compare July 25, 2025 14:25
@josibake
Copy link
Member Author

Update 2948a9b -> 64ecd6c (2025_04 -> 2025_05, compare)

  • Remove no longer needed TODO comment regarding _cmov
  • Remove todo comment regarding input_hash, now that this is properly specified in the BIP

cc @jonasnick and @real-or-random regarding the use of a VERIFY_CHECK in favour of returning an error, when returning an error results in an untestable branch. I'm happy with the approach here were we use a VERIFY_CHECK for input_hash and t_k to check for an overflow of the curve order. However, given this is something we've discussed a few times in the post, would be great to hear your thoughts on this and I'm happy to defer to whatever you both think is best.

This should address all of the outstanding TODOs (at least the ones we left comments for 😅 )

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 64ecd6c to 3c4af8f Compare July 28, 2025 18:01
@josibake
Copy link
Member Author

Updated 64ecd6c -> 3c4af8f (2025_05 -> 2025_06, compare)

  • Updates the benchmarks per @theStack 's suggestion to have separate benchmarks for _full_scan and full_scan_with_labels
  • Leave a TODO comment for a follow-up to make the labels benchmark more representative of real world usage
  • Cleans up the benchmark arguments and formatting

Copy link
Contributor

@theStack theStack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the CI commit, could add the silent payments module also to the native macOS arm64 job (as done for musig recently in #1699), e.g.

diff
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8ee13ce..f612a84 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -583,13 +583,13 @@ jobs:
       fail-fast: false
       matrix:
         env_vars:
-          - { WIDEMUL: 'int64',  RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes' }
+          - { WIDEMUL: 'int64',  RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes', SILENTPAYMENTS: 'yes' }
           - { WIDEMUL: 'int128_struct', ECMULTGENPRECISION: 2, ECMULTWINDOW: 4 }
-          - { WIDEMUL: 'int128',                  ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes' }
+          - { WIDEMUL: 'int128',                  ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes', SILENTPAYMENTS: 'yes' }
           - { WIDEMUL: 'int128', RECOVERY: 'yes' }
-          - { WIDEMUL: 'int128', RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes' }
-          - { WIDEMUL: 'int128', RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes', CC: 'gcc' }
-          - { WIDEMUL: 'int128', RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes', CPPFLAGS: '-DVERIFY' }
+          - { WIDEMUL: 'int128', RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes', SILENTPAYMENTS: 'yes' }
+          - { WIDEMUL: 'int128', RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes', SILENTPAYMENTS: 'yes', CC: 'gcc' }
+          - { WIDEMUL: 'int128', RECOVERY: 'yes', ECDH: 'yes', EXTRAKEYS: 'yes', SCHNORRSIG: 'yes', MUSIG: 'yes', ELLSWIFT: 'yes', SILENTPAYMENTS: 'yes', CPPFLAGS: '-DVERIFY' }
           - BUILD: 'distcheck'
 
     steps:
(untested)

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 4428fd3 to 5fe0d3e Compare September 22, 2025 10:53
@josibake
Copy link
Member Author

Updated 4428fd3 -> 5fe0d3e (2025_24 -> 2025_25, compare)

  • Added comment regarding unstable sort (h/t @Sjors)
  • Added documentation to the header regarding using all generated outputs (h/t @Sjors)
  • s/greater than curve order/greater than or equal to the curve order/ (h/t @theStack)
  • Added a test case for a maliciously created label tweak

Thanks @Sjors for catching the missed comments; I've added both back in.

@theStack thanks for the untestable branch checker! After adding the last edge case test, I also re-ran gcovr and confirmed only the documented untestable branches are the ones not covered in the coverage report.

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 5fe0d3e to 1a21cf1 Compare September 22, 2025 11:30
@josibake
Copy link
Member Author

josibake commented Sep 22, 2025

Updated 5fe0d3e -> 1a21cf1 (2025_25 -> 2025_26, compare)

  • Update example to no longer assert on functions that can return errors (h/t @jonasnick)
  • Update send API docs to document all possible failure cases

I believe this push addresses all of the remaining outstanding feedback, except for #1698 (comment). It is my belief that the check for the secret key sum being zero is sufficient, but waiting on @jonasnick to confirm. If there is any other outstanding feedback I have missed, please let me know!

@josibake
Copy link
Member Author

The UBSan failure seems to be happening due to the newly introduced test case; will troubleshoot in the morning. Just wanted to mention that its caused by the tests, in case anyone is willing to review the other changes but dissuaded by the red CI 😅

@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from 1a21cf1 to ac4a726 Compare September 23, 2025 08:25
@josibake
Copy link
Member Author

Updated 1a21cf1 -> ac4a726 (2025_26 -> 2025_27, compare)

  • Fix UBsan error in newly introduced test

Copy link
Contributor

@theStack theStack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left two more comments only on the API docs, with one small potential secret key verification proposal for the labels creation function. Planning to take a final look at the tests and the example tomorrow.

Comment on lines 339 to 340
* 0 if any combination of the shared secret, label and spend public keys
* sum to zero.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "output_tweak invalid" case is currently missing here, documented on the sender side (and for _recipient_create_output_pubkey) as "if hash(shared_secret || k) is an invalid scalar". The other cases could be mentioned explicitly for consistency with other API headers, as it's (at least for me) not clear what "any combination" exactly means, e.g. if that also includes the sum of all three of the mentioned elements or not. IIUC the two other cases are:

  • output tweak is the negation of spend secret key
  • label tweak is the negation of output tweak

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think something like this reads much better:

if hash(shared_secret || k) is an invalid scalar or the negation of
either the spend secret key or the label tweak, or if the arguments
are invalid.

and then per your later comment, we just add "if the arguments are invalid" to the rest of the API docs. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me 👍

* used instead.
*
* Returns: 1 if shared secret creation was successful.
* 0 if the recipient scan key is invalid.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed only now that this is currently the only function that explicitly mentions failure if a secret key input is invalid. Other functions that get (plain) secret keys as inputs are:

  • _sender_create_outputs
  • _recipient_create_label (this one currently doesn't check if the scan key is valid internally, I guess it should though to be on the safe side, preventing users from creating unspendable labels?)
  • _recipient_scan_outputs

Not sure if it's better to always mention failure for invalid secret key inputs explicitly or never in the API docs; maybe something generic like "if the arguments are invalid" could be used to keep it simple, as e.g. done in the musig module.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding "if the arguments are invalid" uniformly is a good approach.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, recipient_create_labeled_spend_pubkey is the only function that explicitly mentions failure if a public key is invalid.

josibake and others added 10 commits September 24, 2025 16:37
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
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.
Add routine for scanning a transaction and returning the necessary
spending data for any found outputs. This function works with labels via
a lookup callback and requires access to the transaction outputs.
Requiring access to the transaction outputs is not suitable for light
clients, but light client support is enabled by exposing the
`_create_shared_secret` and `_create_output_pubkey` functions in the
API. This means the light client will need to manage their own scanning
state, so wherever possible it is preferrable to use the
`_recipient_scan_ouputs` function.

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.

Add functions for deserializing / serializing a prevouts_summary object to
and from a public key. When serializing a prevouts_summary object, the
input_hash is multplied into the prevout public key sum. This is so the
object can be stored as public key for wallet rescanning later, or to send
to light clients. For the light client, a `_parse` function is added which
parses the compressed public key serialization into a `prevouts_summary`
object.

Finally, add test coverage for the receiving API.
Demonstrate sending, scanning, and light client scanning.
Add a benchmark for a full transaction scan and for scanning a single
output. 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>
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>
Co-authored-by: Jonas Nick <2582071+jonasnick@users.noreply.github.com>
Co-authored-by: Sebastian Falbesoner <91535+thestack@users.noreply.github.com>
Test midstate tags used in silent payments.
Comment on lines +648 to +650
* Note: we can only hit this branch if tx_output != output_xonly. Thus,
* we can add tx_output_gej + output_negated_ge without needing to check
* whether or not the result is the point at infinity.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: potentially confusing because we can always add without needing to check whether or not the result is the point at infinity. This misses the serialization context. Why not move this note to below serialize and use the same phrasing as in the label2 case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, the label2 case wording also is much more clear.

hash->bytes = 64;
}

static int secp256k1_silentpayments_calculate_input_hash_scalar(secp256k1_scalar *input_hash_scalar, const unsigned char *outpoint_smallest36, secp256k1_ge *pubkey_sum) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed the seckey_sum != 0 check. nit: I would still document the requirement above calculate_input_hash_scalar. Just something like "pubkey_sum must not be the point at infinity.".

real-or-random added a commit that referenced this pull request Sep 24, 2025
4d90585 docs: Improve API docs of _context_set_illegal_callback (Tim Ruffing)
895f53d docs: Clarify that callback can be called more than once (Tim Ruffing)

Pull request description:

  The tests in #1698 reminded me that some functions, e.g., `secp256k1_ec_pubkey_cmp`, may call the illegal callback more than once (see #1390 (comment) for more context). This PR clarifies the API docs to state explicitly that this is possible.

  This is the simplest solution. Any production code should crash anyway if it encounters a callback. And in debug code or in our test code, it doesn't really matter whether you see an error message once or twice.

  The alternative is to provide a guarantee that the callback is called only once. But that would make our code more complex for no good reason.

  The second commit fixes a few typos, wording, and consistency.

ACKs for top commit:
  stratospher:
    ACK 4d90585.
  theStack:
    re-ACK 4d90585

Tree-SHA512: 97c31d68851e845b21e9ec2530432603917c019580feba98b62014b538f61be94ba963bf619217720d8f7331ac830e97e62c76c02e7297d3cf73dd085e6f4ca2
@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from ac4a726 to c4942d3 Compare September 25, 2025 12:32
Copy link

@nymius nymius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm focusing on the integration on silent payments with BDK. Have been working the last couple of months rolling my own crypto to implement things on top of it. I've shaped off many of the changes required by BDK to work with BIP 352. Now I'm looking to start reimplementing this changes on top of more solid bases, that' s what I've started looking at this PR, interested in the bindings for rust-secp256k1, and probably rewiring the project I've been working on to that library.

Looking at the headers file (include/secp256k1_silentpayments.h), and after discussing with Josie, we come up with the following points:

* any further elliptic-curve operations from the wallet.
*/

static const unsigned char secp256k1_silentpayments_prevouts_summary_magic[4] = { 0xa7, 0x1c, 0xd3, 0x5e };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secp256k1_silentpayments_prevouts_summary_magic isn't mentioned in any documentation string, nor relevant for the API users. Shouldn't be placed in the implementation file?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!

* payment addresses. The same recipient can be
* passed multiple times to create multiple outputs
* for the same recipient.
* n_recipients: the number of recipients. This is equal to the
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the secp256k1_silentpayments_sender_create_outputs function docs, I have to re-read multiple times the n_recipients doc, because it says the number of recipients, I quote:

"...is equal to the total number of outputs to be generated as each recipient may passed multiple times to generate multiple outputs for the same recipient."

This make me question if the docs where mentioning this again because there was a difference between the number of recipients and the recipients length.
Like: if the n_recipients is equal to the total number of outputs, and docs are making this statement, then there is another statement that doesn't hold for the n_recipients. The most probable other statement for me was: n_recipients is not equal to recipients's length. Convoluted, but any "maybe though" may be enough.

Then I had to re-read again. What I mean here is the redundancy may cause issues here instead of clarify the meaning, and maybe, n_recipients: the number of recipients. is enough.

Copy link
Member Author

@josibake josibake Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I think explaining twice here does indeed cause some confusion. It should also be fairly obvious to the caller that n_recipients is meant to represent the size of the recipients array (where duplicates are allowed).

EDIT: perhaps the size of the recipients array? This feels less ambiguous to me.

* for the same recipient.
* n_recipients: the number of recipients. This is equal to the
* total number of outputs to be generated as each
* recipient may passed multiple times to generate
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
* recipient may passed multiple times to generate
* recipient may be passed multiple times to generate

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following your earlier suggestion, I removed the redundant explanation in favour of "the size of the recipients array."

* Out: label: pointer to the resulting label public key
* label_tweak32: pointer to the 32 byte label tweak
* In: scan_key32: pointer to the recipient's 32 byte scan key
* m: label integer (0 is used for change outputs)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In secp256k1_silentpayments_recipient_create_label docs, m is said to be a label integer. I know the intention is to not induce the user to think that arbitrary strings can be used as labels.

So I guess label integer is better than integer label (a label that happens to be an integer). But at the same time, the API uses label to refer to the public key produced by tweaking the spend public key with the label_tweak32. Maybe m: the integer from which the label is derived or something along this line is better.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps m: integer representing the m-th label ?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't use representing here, because it's hiding the fact that the integer is being used to produce the label.

* In: prevouts_summary: pointer to an initialized silentpayments_prevouts_summary
* object
*/
SECP256K1_API int secp256k1_silentpayments_recipient_prevouts_summary_serialize(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secp256k1_silentpayments_recipient_prevouts_summary_serialize performs evaluation and serialization atomically.
Now it only returns a 33 bytes compressed prevout summary. This is useful to retrieve the prevout summary from tweak servers to light clients in a bandwidth conscious. However, it is also useful to have the 64 uncompressed serialization for large clients doing scanning in behalf of users, as the computation of the square root is costly and performance is key for these clients, which will be probably serving multiple users at the same time.

A flag parameter which takes SECP256K1_EC_COMPRESSED or SECP256K1_EC_UNCOMPRESSED would be useful here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point. Without this option, a callers only other option would be to store the full 101 byte object, or try to copy out only the uncompressed public key bytes by accessing the internals. Both of these are sub-optimal so I think adding the flag makes sense.

* incremented for each additional output created
* or after each output found when scanning)
*/
SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_silentpayments_recipient_create_output_pubkey(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secp256k1_silentpayments_recipient_create_output_pubkey has a k parameter. I'm questioning if this parameter is useful at all. The scenarios that come to my mind where it may be considered are:

  • large servers: the server has the tweaks, the script pubkeys and the scanning secret key together with the spending public key and uses this function to derive all script pubkeys for a transaction and find matches against the script pubkey database. This would be like using the inefficient lookup every time. The server would have to derive ahead of time all possible script pubkeys for the bare spend key, plus any additional label, times k ranging from 0 to the length of the vout.
    Additionally, as the function doesn't produce tweaks, it would have to also use secp256k1_silentpayments_recipient_scan_outputs on the transaction to get the tweaks, as without them, outputs cannot be spent.
    Fixing k to 0 would make an improvement here, because the only match should be against the bare keys or the labeled ones, and all possible remaining ones would be scanned with secp256k1_silentpayments_recipient_scan_outputs, using the lookup table. In order to do this the full tx should be obtained too for each match. That reduces the multiplicative factor of k.

  • light clients: for these kind of clients, local label scanning is out of discussion because the data burden is very high. The envisioned use for these clients is more of the style of compact block filter scanning (not necessarily using Golomb filters). The client would be connected to tweak provider, and use this function in combination with the tweaks to create an oracle with a binary output answering if a blockchain chunk contains any transaction relevant for the client.
    If the answer is yes, then it will request the full blockchain chunk and extract the relevant outputs together with the tweaks using secp256k1_silentpayments_recipient_scan_outputs.
    Here, again, the k wouldn't change from 0 as the output of the oracle is binary, and the full transaction has to be scanned anyways to get the tweaks.
    One may also argue that at least the change output script should be generated too, but that's not the case because change outputs are always generated from transactions spending outputs already known by the client, so it's more efficient to look up for spent outputs rather than created outputs in this case.
    This holds even in the case of wallet recovery, because this is performed in order, and the creation of outputs always precedes its destruction, so not a problem.

If k is hidden from the API, and made an internal parameter, the underlying flow above will still be working and without incurring in any performance downgrade to the previous state.

At the same time, k is only used to get $t_k$ which is the tweak added to the spending public key to get the final script pubkey, in combination with the shared secret obtained presumably with secp256k1_silentpayments_recipient_create_shared_secret (already recommended for light clients for this use case in the docs). If this is the only use case for secp256k1_silentpayments_recipient_create_shared_secret, the removal of the k parameter would allow the merge of both functions into one, just taking spend public key, scan secret key, and the prevout_summary, and returning the x-only public key of the first possible derived silent payment output from that prevout summary, which together with the tweak server, would form the oracle for the client.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, as the function doesn't produce tweaks, it would have to also use secp256k1_silentpayments_recipient_scan_outputs

Great catch. As implemented, the _create_output_pubkey function is useless on its own for a light client. We could keep k and add an out param for the tweak, but this feels like the wrong direction. I much prefer what you described where we can instead remove k, and potentially even get rid of the recipient_created_shared_secret function.

One may also argue that at least the change output script should be generated too, but that's not the case because change outputs are always generated from transactions spending outputs already known by the client, so it's more efficient to look up for spent outputs rather than created outputs in this case.
This holds even in the case of wallet recovery, because this is performed in order, and the creation of outputs always precedes its destruction, so not a problem.

I don't think we can assume the client will always recover by processing blocks in order. Also, imagine a client that wants to quickly recover its unspent balance and doesn't care about the transaction history, e.g., recovering from only the UTXO set. In this case, I think we do need a way for a light client to easily check for both the unlabeled spend public key and the change spend public key. We could do this by:

  • s/create_output_pubkey/create_output_pubkeys/, where now the caller passes in a list of spend public keys and gets in return a k=0 output for each spend public key
  • Remove the _recipient_created_shared_secret function

This would allow a light client to check for at a minimum the k=0 output for both the spend public key and change public key. If a match is found, the caller would then request the transaction (or the block containing the transaction), and then run the _scan_outputs function on the transaction. This would result in a simpler and less footgunny API, while still providing enough flexibility in that we are not assuming all light clients would behave the same way.

@nymius
Copy link

nymius commented Sep 25, 2025

Hope to have documented the the thought chain well. Here is a TLDR of the proposed changes:

  • move secp256k1_silentpayments_prevouts_summary_magic to the implementation file, src/modules/silentpayments/main_impl.h.
  • n_recipients docs in secp256k1_silentpayments_sender_create_outputs may be improved.
  • m parameter description in secp256k1_silentpayments_recipient_create_label may have a better description.
  • adding a new flag parameter secp256k1_silentpayments_recipient_prevouts_summary_serialize to allow compressed and uncompressed serialization.
  • refactor secp256k1_silentpayments_recipient_create_output_pubkey to only take ctx, spend_pubkey, scan_key and prevout_summary to not expose k parameter.
  • remove secp256k1_silentpayments_recipient_create_shared_secret function if the above point is done.

*/
secp256k1_scalar_set_b32(input_hash_scalar, input_hash, &overflow);
ret &= !secp256k1_scalar_is_zero(input_hash_scalar);
return !!ret & !overflow;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why !!ret? I don't think we use this pattern anywhere except for the return value of user-provided functions. (same in secp256k1_silentpayments_create_output_tweak)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was following the ecdh/elligator swift modules, but you are correct: this pattern is only used when evaluating the output of a user provided function, which is not the case here. In our case, we know the return values will be {0, 1}, so ret & !overflow is sufficient.

* used instead.
*
* Returns: 1 if shared secret creation was successful.
* 0 if the recipient scan key is invalid.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, recipient_create_labeled_spend_pubkey is the only function that explicitly mentions failure if a public key is invalid.

Comment on lines +185 to +192
* `secp256k1_silentpayments_recipient_prevouts_summary_create`. Can be serialized as a
* compressed public key using
* `secp256k1_silentpayments_recipient_prevouts_summary_serialize`. The serialization is
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* `secp256k1_silentpayments_recipient_prevouts_summary_create`. Can be serialized as a
* compressed public key using
* `secp256k1_silentpayments_recipient_prevouts_summary_serialize`. The serialization is
* `secp256k1_silentpayments_recipient_prevouts_summary_create`. Can be serialized with
* `secp256k1_silentpayments_recipient_prevouts_summary_serialize`. The serialization is

nit: Unless I'm missing something, mentioning the compressed public key seems unnecessary?

Comment on lines 270 to 271
* 0 if the sequence is invalid (e.g., does not represent a valid
* public key).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* 0 if the sequence is invalid (e.g., does not represent a valid
* public key).
* 0 if the sequence is invalid.

* size of the found outputs array. This number
* represents the number of outputs found while
* scanning (0 if none are found)
* In: tx_outputs: pointer to the tx's x-only public key outputs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* In: tx_outputs: pointer to the tx's x-only public key outputs
* In: tx_outputs: pointer to the transaction's x-only public key outputs

nit: this would be the only time we mention "tx" in the text.

Comment on lines +345 to +347
* 0 if hash(shared_secret || k) is an invalid scalar or the negation of
* either the spend secret key or the label tweak, or if the arguments
* are invalid.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can remove the tweak = 0 failure branch as I suggested in another comment, then it seems like this function can never fail for a legitimately created silent payments transaction.

Suggested change
* 0 if hash(shared_secret || k) is an invalid scalar or the negation of
* either the spend secret key or the label tweak, or if the arguments
* are invalid.
* 0 if the transaction is not a silent payments transaction or if the recipient's scan key is invalid.

Comment on lines +428 to +432
/* Since we've already validated the prevouts data, this shouldn't fail, but
* better to be careful here since we are scanning data that could have been
* maliciously created.
*/
printf("Something went wrong while scanning this transaction, skipping.\n");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be simpler to follow if we removed the comment (but maybe I'm missing something).

Suggested change
/* Since we've already validated the prevouts data, this shouldn't fail, but
* better to be careful here since we are scanning data that could have been
* maliciously created.
*/
printf("Something went wrong while scanning this transaction, skipping.\n");
printf("This transaction is not valid for silent payments, skipping.\n");

Comment on lines +426 to +427
* 0 if hash(shared secret || k) results in an invalid scalar,
* or if the arguments are invalid.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can never happen for a legitimate silent payments transaction, so maybe we can just say

Suggested change
* 0 if hash(shared secret || k) results in an invalid scalar,
* or if the arguments are invalid.
* 0 if the transaction is not a silent payments transaction.

Comment on lines +542 to +545
/* Since we've already validated the prevouts data, the only reason this could fail
* is if we input a bad scan key or bad spend public key, which should never happen
* because this is data under our control.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The create_shared_secret doc doesn't mention the bad spend public key, so wouldn't it be sufficient to say something like

Suggested change
/* Since we've already validated the prevouts data, the only reason this could fail
* is if we input a bad scan key or bad spend public key, which should never happen
* because this is data under our control.
*/
/* This only fails if the scan key is invalid. The recipient, Carol,
* created the scan key herself, so this must always succeed. */

Comment on lines +560 to +565
/* We can only fail here if the particular set of inputs results in the
* output of a hash function resulting in an invalid scalar. This is
* statistically improbable for an honest execution, but if we were to
* get to this point that means this is not a silent payments transaction
* (or if it is, the sender was not following the protocol).
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the output of the hash function resulting in an invalid scalar impossible (instead of improbable) for an honest execution? If so (unless I'm missing something), it seems like we can remove this comment. It seems sufficient for the reader to know that "This transaction is not valid for silent payments", which is what we print below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't the output of the hash function resulting in an invalid scalar impossible

Its not impossible for the sender to hit this case when creating the output, but indeed, since we specify that the sender should fail if this happens it would be impossible for the recipient to hit this case.


secp256k1_ecmult_const(&ss_j, public_component, secret_component);
secp256k1_ge_set_gej(&ss, &ss_j);
secp256k1_declassify(ctx, &ss, sizeof(ss));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we declassify the shared secret? Isn't this secret data? If we do declassify the secret, I think a comment is needed here to justify this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, we declassify because serializing a group element is a non-constant time operation. So I think it's appropriate to declassify here (with a comment).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secp256k1_eckey_pubkey_serialize is not constant time, but it's possible to serialize in constant time:

    secp256k1_fe_normalize(&ss.x);
    secp256k1_fe_normalize(&ss.y);
    secp256k1_fe_get_b32(&shared_secret33[1], &ss.x);
    shared_secret33[0] = 2 | secp256k1_fe_is_odd(&ss.y);

This has some cost: secp256k1_fe_normalize instead of secp256k1_fe_normalize_var and we have to replace ecmult in pubkey_tweak_add with ecmult_const. Given that the library is not constant time with respect to the tweaks used for BIP 32 derivation, not being constant time with respect to the shared secret would be consistent. If we're not protecting privacy against side channels, we could also not be constant time with respect to the scan key, but I don't see how this would result in a speedup, so it's probably worthwhile to keep that. But ideally we would document these choices.

Copy link
Contributor

@real-or-random real-or-random Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your code suggestion is similar to what the ECDH module does:

/* Compute a hash of the point */
secp256k1_fe_normalize(&pt.x);
secp256k1_fe_normalize(&pt.y);
secp256k1_fe_get_b32(x, &pt.x);
secp256k1_fe_get_b32(y, &pt.y);
ret = hashfp(output, x, y, data);

Of course, we could call the ECDH module here and pass a custom hash function. This sounds clean (as clean as your suggestion), but as you say that wouldn't give us much because the tweak functions are still variable time. The tweak functions are generic, and only the caller knows whether the data is secret or not. :/ I'd like the idea of making the tweak functions constant-time for that reason.

and we have to replace ecmult in pubkey_tweak_add with ecmult_const.

In this case, we could replace it with the constant-time ecmult_gen instead. And it turns out that our current ecmult_gen is faster than ecmult for generator multiplications. (cc @sipa who pointed that out recently). This could be done in a separate PR.

The only drawback is that this doesn't work in pubkey_tweak_mul. Here we'd need to use ecmult_const which is indeed slower. (We could still offer a pubkey_tweak_mul_var).

I think the root of the problem is that we use the same type for public group elements and private group elements. Most group elements are public (pubkeys), but some are not (ECDH shared secret). The same is true for scalars. Some are secret, some are not. We could have different types for them, and this would make things clearer. Of course, this will be a huge change.


That the handling of secret data is a bit inconsistent had also occurred to me previously (only later after my last review round) but I forgot to point it out. We now have some code comments like this:

/* Leaking this value would break indistinguishability of the transaction, so clear it. */
secp256k1_memclear_explicit(&shared_secret, sizeof(shared_secret));

(see https://github.com/bitcoin-core/secp256k1/pull/1698/files/c4942d3646a58bb9fd8a51e60defe1571f8ee595#diff-4d053be8d1f6d948b412f26ae89711a9dcd2a2683da581cb52b9f6757480361bR298-R299)

So we clear stack values in the case of the shared secret and other data secret with respect to unlinkability. This is fine because it's cheap to do, but I agree we should document these choices.

I think what we should do is roughly this:

  • We always clear unlinkability secrets from the stack because that's cheap.
  • We use constant-time operations when the scan key is involved or data derived from it (or equivalently, secret keys on the sender side), but we declassify the final derived pubkeys, i.e., pubkeys to send coins to (as derived by the sender) and candidate pubkeys to receive on (as derived by the scanner).

The last thing sounds like reasonable middle ground to me. We don't want full protection here. Making scanning constant-time means you need to read the entire blockchain before you can return. :)

}
}
if (found) {
found_outputs[n_found]->output = *tx_outputs[found_idx];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes assumes that there are no two iterations of the while loop that will find the same tx_output. Otherwise, this would result in a buffer overflow. I can see three scenarios where that assumption is broken

  1. There's a collision in create_output_tweak that result in the same tweaks for different values of k. That is very unlikely but I think we want to protect against BOs even in that case.
  2. The user-provided label_lookup function is broken. For example, if label_lookup always returns the label_tweak 0 for any input, then every while loop iteration will "find" the first tx_output, leading to a buffer overflow (and an infinite loop). The same result happens with a label_lookup function where the adversary has the ability to insert their own chosen labels. I don't think the scanning function should trust the user's lookup function that much.
  3. A transaction with UINT32_MAX + 1 matching outputs is given. In every iteration k will be incremented, and provided to create_output_tweak which converts it to a uint32_t. When k = UINT32_MAX + 1, the same output_tweak will be computed as in a previous iteration, resulting in a duplicate match.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking out loud: to avoid both the potential buffer overflow and infinite loop without introducing new branches, would it be an option to simply limit the (outer) while loop to a maximum of n_tx_outputs iterations? That assumes that a transaction with n outputs never contains Silent Payments outputs that were created with values k >= n, which I think holds, if the sender includes all generated outputs in the transaction (as suggested in the sender API docs).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch! I will need to think a bit longer on how to handle these cases elegantly, but one thought for 3) is we could add an ARG_CHECK to make sure this function is not called with n_tx_outputs > UINT32_MAX. This is a reasonable check for Bitcoin (given todays constraints), but the downside is these constraints could change in the future and it assumes this library is only used on Bitcoin, and not another chain or a side chain with different constraints. Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be an option to simply limit the (outer) while loop to a maximum of n_tx_outputs iterations

That seems to address the first two conditions for the bufer overflow.

an ARG_CHECK to make sure this function is not called with n_tx_outputs > UINT32_MAX

Possible, but since this is a deviation from the spec (strictly speaking), seems a bit necessary. Maybe we can just return 0 if k reaches UINT32_MAX in create_outputs and scan?

### Pregenerated test vectors
### (see the comments in the previous section for detailed rationale)
TESTVECTORS = src/wycheproof/ecdsa_secp256k1_sha256_bitcoin_test.h
TESTVECTORS += src/modules/silentpayments/vectors.h
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

60f209c: we could wrap this in if ENABLE_MODULE_SILENTPAYMENTS as well. (similar to ECDH test vectors)

* 2. Two outputs for Carol
*
* To create multiple outputs for Carol, Alice simply passes Carol's
* silent payment address mutltiple times.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0e94f2d: typo nit: multiple

if (!secp256k1_eckey_pubkey_parse(&pk, input33, inputlen)) {
return 0;
}
/* A serialized prevouts_summary will always have have the input_hash multiplied in, so we set combined = true.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

da3120e: typo nit: have have

* returned, it is set to a parsed version of input33.
* In: input33: pointer to a serialized silentpayments_prevouts_summary.
*/
SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_silentpayments_recipient_prevouts_summary_parse(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've started building some bindings on top of PrevoutSummary, and I've got a panic because I was trying to assert a prevout summary was equal to its serialized and parsed (again) version. I've just assumed $\text{serialize} = \text{parse}^{-1}$. Dumb assumption (parse would be finding the inverse of a EC multiplication), but natural in other contexts. This doc in secp256k1_silentpayments_recipient_prevouts_summary_serialize may be enough:

Serializing a prevouts_summary object created with _recipent_prevouts_summary_create will result in
an EC multiplication. This allows for a more compact serialization.

but also commenting here and in secp256k1_silentpayments_recipient_prevouts_summary_serialize the assumption I made does not hold wouldn't hurt and it wouldn't go further than mentioning the EC multiplication above in terms of internals leakage.

secp256k1_memclear_explicit(&shared_secret, sizeof(shared_secret));
return 0;
}
VERIFY_CHECK(k < SIZE_MAX);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this VERIFY_CHECK have an effect? k appears to be <= n_recipients which is a size_t.

* Returning an error here results in an untestable branch in the code, but we do this anyways to ensure strict compliance with BIP0352.
*/
if (!secp256k1_silentpayments_create_output_tweak(&output_tweak_scalar, shared_secret, k)) {
secp256k1_scalar_clear(&scan_key_scalar);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: not sure if you've considered this, but you could also clear scan_key_scalar right after it is used for the last time. Then we'd have fewer lines for clearing and there wouldn't be the possibility of forgetting to clear when the function fails. There are other secret values in this file that could follow the same pattern.

}
}
if (found) {
found_outputs[n_found]->output = *tx_outputs[found_idx];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be an option to simply limit the (outer) while loop to a maximum of n_tx_outputs iterations

That seems to address the first two conditions for the bufer overflow.

an ARG_CHECK to make sure this function is not called with n_tx_outputs > UINT32_MAX

Possible, but since this is a deviation from the spec (strictly speaking), seems a bit necessary. Maybe we can just return 0 if k reaches UINT32_MAX in create_outputs and scan?

@josibake
Copy link
Member Author

josibake commented Oct 1, 2025

Thanks all for the ongoing review! I'm working on implementing the feedback so far and expect to have an updated branch by Friday, at the latest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants