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 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:

* 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."

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

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.


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. :) ~ edit: That's wrong, but here's a better rationale that it's okay to declassify the derived pubkeys: On the side of the sender, the sender can (and probably will) reveal them on the blockchain, so this is public data. On the side of the scanner, the code wants to branch when a payment is found: we can't meaningfully hold up constant-timeness and hide how many payments have been found etc.

Copy link
Contributor

Choose a reason for hiding this comment

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

That's wrong, but here's a better rationale that it's okay to declassify the derived pubkeys

That's an interesting observation. But, wouldn't this also justify declassifying the shared secret (and keep the current implementation)?

Copy link
Member Author

Choose a reason for hiding this comment

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

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

IIRC, using non-constant time multiplication with the scan key resulted in a ~ 9% - 15% speedup. Overall, I agree we should have a consistent strategy and document our choices. Curious to hear @real-or-random 's response and then happy to implement whatever makes the most sense here.

Copy link
Contributor

Choose a reason for hiding this comment

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

@jonasnick and I discussed this. Our conclusion is that we should also serialize the shared secret in constant time and only declassify hash(shared_secret || k). Leaking raw (=unhashed) ECDH shared secrets can, in theory, make Cheon's attack on ECDH possible. Not sure if it's really a concern here, but serializing in constant time is essentially for free, so let's just do it.

So we think that the general rules for secrets whose leakage would break indistinguishability should be as follows.
Perhaps these can be added as a file-level comment.

  • We always clear them from the stack because that's cheap.
  • We use constant-time operations when ECDH (input) secret keys are involved, because leaking the sender secret key is obviously bad, and leaking the entire scan key is also bad. But we declassify the hashed ECDH shared secret. As opposed to the scan key, this value allows the attacker to recognize a single incoming payment but not all incoming payments.

The declassify call should then get a code comment along these lines:
The only thing that the attacker can do with the hashed secret is derive the final pubkeys (TODO Is there a term that is more precise than "final pubkeys"). On the side of the sender, we assume that will reveal those on the blockchain anyway. On the side of the scanner, we assume that the caller wants to branch when a payment is found.

### 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

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

theStack and others added 12 commits October 2, 2025 14:27
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 in the next commit.

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.
Add function for creating k=0 outputs for multiple spend public keys.
These keys can then be checked for existance against the UTXO set/blockchain.

If a match is found, the client needs to download the full transaction and
rescan with `_scan_outputs`.
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 <[email protected]>
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 <[email protected]>
Co-authored-by: Sebastian Falbesoner <[email protected]>
Co-authored-by: Tim Ruffing <[email protected]>
Co-authored-by: Jonas Nick <[email protected]>
Co-authored-by: Sebastian Falbesoner <[email protected]>
Test midstate tags used in silent payments.
@josibake josibake force-pushed the bip352-silentpayments-module-2025 branch from c4942d3 to a0d2a33 Compare October 7, 2025 13:43
@josibake
Copy link
Member Author

josibake commented Oct 7, 2025

Updated ac4a726 -> a0d2a33 (2025_27 -> 2025_28, compare)

  • Makefile and typo fixes (h/t @stratospher)
  • examples/silentpayments.c rewrite
    • Various typo and wording fixes (h/t @jonasnick)
    • Demonstrates usage of the new _recipient_create_output_pubkeys function
    • Includes an address creation example, demonstrating usage of the label functions (h/t @jonasnick)
    • Various other simplifications
  • Move magic bytes out of header into implementation file (h/t @nymius)
  • Remove _recipient_{create_shared_secret, create_output_pubkey} functions in favour of a _recipient_create_output_pubkeys function (h/t @nymius)
    • This removes all references to k and provides a simpler API for light clients, without exposing internal details of the protocol
    • This allows for clients to check for a k=0 output, and then proceed to downloading the full transaction if a match is found
  • Various doc fixes in the header file (h/t @jonasnick , @nymius )
  • Update _prevouts_summary_{parse, serialize} functions to work with both 33 and 65 byte serializations
    • For indexes that are not storage constrained, being able to save the prevouts_summary object as a 65 byte uncompressed key saves the index from needing to calculate the y value when scanning, e.g., in an outsourced scanning use case
  • Update benchmarks, tests to use new _create_output_pubkeys function
  • Update test coverage
  • Fix the buffer overflow cases in the _scan_outputs function (h/t @jonasnick , @theStack )

This is quite an extensive push based on a lot of excellent review. Its likely I've missed some feedback and will be polishing over the next few days, but wanted to get the bulk of the changes up. Thanks again for all the continued review!

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.

All my previous concerns have been addressed. From a first read I extracted the some nits. Let me know if the following patch is easier to apply:

diff --git a/include/secp256k1_silentpayments.h b/include/secp256k1_silentpayments.h
index b8357b8..da40acf 100644
--- a/include/secp256k1_silentpayments.h
+++ b/include/secp256k1_silentpayments.h
@@ -187,7 +187,7 @@ SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_silentpayments_recipien
  *  `secp256k1_silentpayments_recipient_prevouts_summary_create`. Can be serialized with
  *  `secp256k1_silentpayments_recipient_prevouts_summary_serialize`. The serialization is
  *  intended for storing the object or sending the prevout summary data to light clients.
- *  The serialization is is parsed with
+ *  The serialization is parsed with
  *  `secp256k1_silentpayments_recipient_prevouts_summary_parse`.
  */
 typedef struct secp256k1_silentpayments_prevouts_summary {
@@ -250,7 +250,7 @@ SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_silentpayments_recipien
  *
  *  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 means a serialized
- *  prevouts_summary will not parse back to a the same prevouts_summary object (due to the EC multiplication).
+ *  prevouts_summary will not parse back to the same prevouts_summary object (due to the EC multiplication).
  *
  *  Returns: 1 always.
  *
@@ -386,7 +386,7 @@ SECP256K1_API SECP256K1_WARN_UNUSED_RESULT int secp256k1_silentpayments_recipien
 
 /** Create Silent Payment output public keys.
  *
- *  Given a scan key, a prevouts_summary, and array of recipient spend public keys,
+ *  Given a scan key, a prevouts_summary, and an array of recipient spend public keys,
  *  create the silent payments output public keys.
  *
  *  This function is used by the recipient when scanning for outputs without
diff --git a/src/modules/silentpayments/main_impl.h b/src/modules/silentpayments/main_impl.h
index c0f4822..52cfa1f 100644
--- a/src/modules/silentpayments/main_impl.h
+++ b/src/modules/silentpayments/main_impl.h
@@ -97,7 +97,7 @@ static void secp256k1_silentpayments_create_shared_secret(const secp256k1_contex
 
     secp256k1_ecmult_const(&ss_j, public_component, secret_component);
     secp256k1_ge_set_gej(&ss, &ss_j);
-    /* We declassify the shared secret group elemement because serializing a group element is a non-constant time operation. */
+    /* We declassify the shared secret group element because serializing a group element is a non-constant time operation. */
     secp256k1_declassify(ctx, &ss, sizeof(ss));
     /* This can only fail if the shared secret is the point at infinity, which should be
      * impossible at this point considering we have already validated the public key and

secp256k1_xonly_pubkey **outputs_xonly,
const unsigned char *scan_key32,
const secp256k1_silentpayments_prevouts_summary *prevouts_summary,
const secp256k1_pubkey **spend_pubkeys,
Copy link

@nymius nymius Oct 8, 2025

Choose a reason for hiding this comment

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

Any reason for this to not be const secp256k1_pubkey * const *spend_pubkeys? like _pubkeys in _silentpayments_recipient_prevouts_summary_create or _seckeys in _silentpayments_sender_create_outputs

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, outputs_xonly is the out-param where we will write the generated xonly public keys. This is the same as found_outputs in the scanning function and generated_outputs in the _create_outputs function.

Copy link

Choose a reason for hiding this comment

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

Are we referring to the same line? I was talking about line 421: const secp256k1_pubkey **spend_pubkeys.

Copy link

@nymius nymius Oct 8, 2025

Choose a reason for hiding this comment

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

Sorry, I missed a word in the original comment. I edited it. tldr:
Why not const secp256k1_pubkey **spend_pubkeys -> const secp256k1_pubkey * const *spend_pubkeys?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, gotcha! Sorry, I misunderstood your original comment. This should be const secp256k1_pubkey * const *spend_pubkeys, as you suggest.

@josibake
Copy link
Member Author

josibake commented Oct 8, 2025

Thanks for the review @nymius , let me know if you uncover anything else while working with the new API! Also, thanks for the patch; very easy to apply and much appreciated!

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.

Glad to see the updated API, I agree that the light client scanning interface makes much more sense this way, and that supporting prevouts summary (de)serialization in both compressed and uncompressed pubkey format is useful. Left some mostly nitty comments below, some of them are likely not related to the latest changes, but I just missed them in earlier review rounds. Haven't looked at tests and examples of the updated API functions yet.

/** Sort an array of silent payment recipients. This is used to group recipients by scan pubkey to
* ensure the correct values of k are used when creating multiple outputs for a recipient.
*
* Note: secp256k1_ec_pubkey_cmp uses heap sort, which is unstable. Developers cannot and should not
Copy link
Contributor

Choose a reason for hiding this comment

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

_ec_pubkey_cmp itself doesn't do any sorting, should that be

Suggested change
* Note: secp256k1_ec_pubkey_cmp uses heap sort, which is unstable. Developers cannot and should not
* Note: secp256k1_silentpayments_recipient_sort uses heap sort, which is unstable. Developers cannot and should not

instead? (Or maybe just "note that we use heap sort, ...")

size_t index;
} secp256k1_silentpayments_recipient;

/** Create Silent Payment outputs for recipient(s).
Copy link
Contributor

Choose a reason for hiding this comment

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

case-sensitivity-yocto-nit: noticed that sometimes in the API header the "Silent Payment" is used, sometimes "silent payment" is, each also in singular and plural, might be worth it to use only one variant for consistency.

VERIFY_CHECK(ctx != NULL);
ARG_CHECK(label != NULL);
ARG_CHECK(label_tweak32 != NULL);
ARG_CHECK(scan_key32 != NULL);
Copy link
Contributor

Choose a reason for hiding this comment

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

in _recipient_create_label: I think it would make sense to also error if the passed scan key is invalid, to prevent the user of creating unspendable labels

secp256k1_pubkey *label,
unsigned char *label_tweak32,
const unsigned char *scan_key32,
const uint32_t m
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 saying it's wrong, but it seems unnecessary and at least very unusual to use const for parameters that are passed by value (feel free to ignore if that was discussed before, I suspect there was a reason for introducing it as I can't remember seeing it from earlier review rounds)

Comment on lines +263 to +264
* flags: SECP256K1_EC_COMPRESSED if serialization should be in
* compressed format, otherwise SECP256K1_EC_UNCOMPRESSED.
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: seems that having only one of the size and flags parameters would be sufficient. I suspect it's done like that to be more in-line with existing API functions (thinking of _ec_pubkey_serialize)?

Comment on lines +281 to +282
* returned, it is set to a parsed version of input33.
* In: input33: pointer to a serialized silentpayments_prevouts_summary.
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
* returned, it is set to a parsed version of input33.
* In: input33: pointer to a serialized silentpayments_prevouts_summary.
* returned, it is set to a parsed version of input.
* In: input: pointer to a serialized silentpayments_prevouts_summary.

also, the description of the inputlen parameter is missing

Comment on lines +432 to +433
* serializing the resulting point as a compressed public key, if combined = false. If combined = true,
* the point is serialized back into a compressed 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.

needs update, now that serialization in both compressed and uncompressed public key format is possible

found_idx = 0;
n_found = 0;
k = 0;
for (i = 0; i <= n_tx_outputs; i++) {
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
for (i = 0; i <= n_tx_outputs; i++) {
for (i = 0; i < n_tx_outputs; i++) {

off-by-one, IIUC

Comment on lines +793 to +794
secp256k1_scalar_clear(&scan_key_scalar);
return secp256k1_silentpayments_create_output_pubkeys(ctx, outputs_xonly, shared_secret, spend_pubkeys, n_spend_pubkeys, 0);
Copy link
Contributor

Choose a reason for hiding this comment

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

in _recipient_create_output_pubkeys: should also clear out the shared_secret here

secp256k1_write_be32(k_serialized, k);
secp256k1_sha256_write(&hash, k_serialized, sizeof(k_serialized));
secp256k1_sha256_finalize(&hash, hash_ser);
/* Convert output_tweak to a scalar to ensure the value is less than the curve order.
Copy link
Contributor

Choose a reason for hiding this comment

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

nit, here and in some other similar comments before _scalar_set_b32 calls: could probably just drop the "to ensure the value is less than the curve order" part, as that sounds like that's the only reason we do the conversion (maybe it was in the past, when that function returned the result as byte-array, but I don't remember).

Copy link
Member

@furszy furszy left a comment

Choose a reason for hiding this comment

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

Left a little help for the new test framework changes (will need to rebase this PR as it conflicts with master). The changes are small, I think you’ll like what we came up with.

Comment on lines 266 to 269
void run_silentpayments_tests(void) {
test_recipient_sort();
test_send_api();
}
Copy link
Member

Choose a reason for hiding this comment

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

In 6fe3a7a:

This now should be:

static const struct tf_test_entry tests_silentpayment[] = {
    CASE1(test_recipient_sort),
    CASE1(test_send_api),
};

And also need to include the framework:

#include "../../unit_test.h"

Comment on lines +7817 to +7820
#ifdef ENABLE_MODULE_SILENTPAYMENTS
run_silentpayments_tests();
#endif

Copy link
Member

@furszy furszy Oct 15, 2025

Choose a reason for hiding this comment

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

In 6fe3a7a:

On the modules registry, this should be

#ifdef ENABLE_MODULE_SILENTPAYMENTS
    MAKE_TEST_MODULE(silentpayment),
#endif

Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

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

I noticed that scanning performance is quite poor in the worst case. Consider a maliciously crafted 1mB transaction where all outputs are SP outputs to the recipient, who uses labels. There are about 1mB/32B tx outputs. Thus the outer loop runs n_tx_outputs = 31250 times and the inner loop 1 + 2 + ... + (n_tx_outputs - 1) + n_tx_outputs = n_tx_outputs * (n_tx_outputs + 1)/2 times. Each inner loop calls ge_set_gej at most twice, which takes 2us on my machine. So it would take me 16 minutes to scan such a transaction. I believe if the BIP mandated that the transaction outputs need to be ordered by k (i.e., smaller k needs to have a lower output index), scanning could be implemented in linear time. But requiring such an order may put too many constraints on transaction creation.

EDIT: I previously assumed 4mB worth of outputs which is of course not possible. This would have led to 4.3 hours of scanning.

Comment on lines +547 to +550
ret &= secp256k1_eckey_pubkey_serialize(&ge, output, &size, compressed);
(void)ret;
return 1;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

ret is unused.

Comment on lines +197 to +203
ret = secp256k1_ec_pubkey_serialize(ctx,
bob_address[1],
&len,
&labeled_spend_pubkey,
SECP256K1_EC_COMPRESSED
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

ret is unchecked

Comment on lines +194 to +195
ret = secp256k1_silentpayments_recipient_create_labeled_spend_pubkey(ctx, &labeled_spend_pubkey, &unlabeled_spend_pubkey, &label);
assert(ret);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason we assert that a negligible event didn't happen but in the case of recipient_create_label we handle the negligible event?

Comment on lines +691 to +693
/* If not found, negate the tx_output and calculate second scan label candidate:
* label2 = -tx_output - generated_output
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

This is useful and we had a similar line for label1 (label1 = tx_output - generated_output). Did you remove that intentionally?

found_idx = 0;
n_found = 0;
k = 0;
for (i = 0; i <= n_tx_outputs; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We can remove i and use n_found in it's place. Or even going one step further, we can remove n_found and replace it with k unless you think it hurts readability. I think it helps with understanding what is going on.

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.

10 participants