silentpayments: respect per-group recipients protocol limit (K_max=2323)
What changed, and why it matters
This commit fixes the libsecp256k1 Silent Payments implementation so it respects the protocol-defined maximum of 2,323 recipients that share the same scan public key. Previously, the code had a TODO warning about unbounded scanning that could lead to quadratic runtime and possible missed outputs. Now, sending fails if any group exceeds the limit, and scanning stops at the limit. It also documents a recommendation to shuffle transaction outputs to improve worst-case performance.
Review downstream callers to ensure they handle the new sender failure mode and consider shuffling tx_outputs before scanning as recommended. No immediate emergency response is indicated; this is a correctness/performance hardening change.
Security signals we found
Unbounded loop bounded to protocol limit to prevent quadratic scanning cost
Sender-side enforcement prevents creating outputs recipients may not find
New public constant documents protocol limit (BIP-352 K_max=2323)
Memory clearing of shared secret on new early-exit path
API documentation updated to describe limit and recommend input shuffling
Evidence from the diff
The patch enforces BIP-352’s per-group recipient limit (K_max=2323) in the Silent Payments module. In secp256k1_silentpayments_sender_create_outputs, it now returns failure before generating an output whose index k would exceed SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT. In secp256k1_silentpayments_recipient_scan_outputs, it bounds the scanning loop to min(n_tx_outputs, K_max) to avoid quadratic work and to match what senders are now allowed to produce. The public header documents the new constant and the failure/scanning behavior, and adds a note advising callers to shuffle tx_outputs. Tests verify success at the limit, failure above it, and mixed-group scenarios.
Changed components
include/secp256k1_silentpayments.hsrc/modules/silentpayments/main_impl.hsrc/modules/silentpayments/tests_impl.hsecp256k1_silentpayments_sender_create_outputssecp256k1_silentpayments_recipient_scan_outputsInspect captured patch +85 / −5
diff --git a/include/secp256k1_silentpayments.h b/include/secp256k1_silentpayments.h
index b2f1ce0..d9b3eae 100644
--- a/include/secp256k1_silentpayments.h
+++ b/include/secp256k1_silentpayments.h
@@ -27,6 +27,10 @@ extern "C" {
* any further elliptic-curve operations from the wallet.
*/
+/* Maximum number of Silent Payments recipients per group (i.e.
+ * recipients sharing the same scan public key) as per BIP-352 */
+#define SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT 2323
+
/** The data from a single recipient address
*
* This struct serves as an input argument to `silentpayments_sender_create_outputs`.
@@ -78,6 +82,8 @@ typedef struct secp256k1_silentpayments_recipient {
* keys is uniformly random and independent of all other keys)
* - A hash output is not a valid scalar (negligible probability
* per hash evaluation)
+ * - Any group (i.e. recipients sharing the same scan public key) exceeds
+ * the protocol limit SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT
*
* Args: ctx: pointer to a context object
* (not secp256k1_context_static).
@@ -338,6 +344,13 @@ typedef struct secp256k1_silentpayments_found_output {
* For creating the label cache, `secp256k1_silentpayments_recipient_label_create`
* and `secp256k1_silentpayments_recipient_label_serialize` can be used.
*
+ * Note:
+ * Scanning is bounded by SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT and may
+ * miss outputs if a transaction contains more outputs for a single scan public
+ * key group than this limit.
+ * It's recommended to shuffle `tx_outputs` before calling this function to ensure
+ * runtime is not affected by the ordering of the outputs.
+ *
* Returns: 1 if output scanning was successful.
* 0 if the transaction is not a Silent Payments transaction,
* or if the arguments are invalid.
@@ -349,7 +362,8 @@ typedef struct secp256k1_silentpayments_found_output {
* n_found_outputs: pointer to an integer indicating the final
* size of the found outputs array. This number
* represents the number of outputs found while
- * scanning (0 if none are found).
+ * scanning (0 if none are found). Can't be larger than
+ * SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT.
* In: tx_outputs: pointer to the transaction's x-only public key outputs
* n_tx_outputs: the size of the tx_outputs array.
* scan_key32: pointer to the recipient's 32 byte scan key. The scan
diff --git a/src/modules/silentpayments/main_impl.h b/src/modules/silentpayments/main_impl.h
index a4eb344..53929f4 100644
--- a/src/modules/silentpayments/main_impl.h
+++ b/src/modules/silentpayments/main_impl.h
@@ -315,6 +315,13 @@ int secp256k1_silentpayments_sender_create_outputs(
secp256k1_silentpayments_create_shared_secret(shared_secret, &pk, &seckey_sum_scalar);
k = 0;
}
+ /* If creating another output for the current recipient group exceeded the
+ * protocol limit, fail, as the recipient wouldn't be guaranteed to find it. */
+ if (k >= SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT) {
+ secp256k1_scalar_clear(&seckey_sum_scalar);
+ secp256k1_memclear_explicit(&shared_secret, sizeof(shared_secret));
+ return 0;
+ }
if (!secp256k1_silentpayments_create_output_pubkey(ctx, generated_outputs[recipients[i]->index], shared_secret, &recipients[i]->spend_pubkey, k)) {
secp256k1_scalar_clear(&seckey_sum_scalar);
secp256k1_memclear_explicit(&shared_secret, sizeof(shared_secret));
@@ -322,7 +329,6 @@ int secp256k1_silentpayments_sender_create_outputs(
}
current_scan_pubkey = recipients[i]->scan_pubkey;
k++;
- /* TODO: limit k, in order to avoid quadratic scaling issue for scanning */
}
secp256k1_scalar_clear(&seckey_sum_scalar);
secp256k1_memclear_explicit(&shared_secret, sizeof(shared_secret));
@@ -569,7 +575,7 @@ int secp256k1_silentpayments_recipient_scan_outputs(
secp256k1_scalar scan_key_scalar;
secp256k1_ge unlabeled_spend_pubkey_ge, prevouts_pubkey_sum_ge, tx_output_ge;
unsigned char shared_secret[33];
- uint32_t k;
+ uint32_t k, k_max;
size_t i;
int found_idx, combined, valid_scan_key, ret;
@@ -623,9 +629,11 @@ int secp256k1_silentpayments_recipient_scan_outputs(
secp256k1_scalar_clear(&scan_key_scalar);
found_idx = -1;
- /* TODO: limit number of k iterations, in order to avoid quadratic scaling issue */
+ /* Don't look further than the per-group recipient limit, in order to avoid quadratic scaling issues. */
+ k_max = (n_tx_outputs < SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT) ?
+ n_tx_outputs : SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT;
/* TODO: improve scanning performance by performing batch inversion for label scanning */
- for (k = 0; k < n_tx_outputs; k++) {
+ for (k = 0; k < k_max; k++) {
secp256k1_scalar t_k_scalar;
secp256k1_xonly_pubkey unlabeled_output_xonly;
secp256k1_ge unlabeled_output_ge = unlabeled_spend_pubkey_ge;
diff --git a/src/modules/silentpayments/tests_impl.h b/src/modules/silentpayments/tests_impl.h
index c880460..ac6cdf4 100644
--- a/src/modules/silentpayments/tests_impl.h
+++ b/src/modules/silentpayments/tests_impl.h
@@ -309,6 +309,64 @@ static void test_send_api(void) {
}
CHECK(secp256k1_silentpayments_sender_create_outputs(CTX, op, rp, 2, SMALLEST_OUTPOINT, NULL, 0, p, 1) == 0);
}
+
+ /* check that sending API respects the per-group recipient limit (K_max) */
+ {
+ const size_t total_recipients = 10 * SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT;
+ secp256k1_silentpayments_recipient *recipients = checked_malloc(&CTX->error_callback, sizeof(*recipients) * total_recipients);
+ const secp256k1_silentpayments_recipient **recipients_ptrs = checked_malloc(&CTX->error_callback, sizeof(*recipients_ptrs) * total_recipients);
+ secp256k1_xonly_pubkey *outputs = checked_malloc(&CTX->error_callback, sizeof(*outputs) * total_recipients);
+ secp256k1_xonly_pubkey **outputs_ptrs = checked_malloc(&CTX->error_callback, sizeof(*outputs_ptrs) * total_recipients);
+ size_t test_num_recipients;
+
+ for (i = 0; i < total_recipients; i++) {
+ /* use the same scan/spend pubkey for every recipient initially; the scan pubkeys
+ * will change later on for each test case to modify the group sizes, while the
+ * spend pubkeys will remain unchanged, as they are not relevant for the scenarios */
+ recipients[i].scan_pubkey = r[1].scan_pubkey;
+ recipients[i].spend_pubkey = r[1].spend_pubkey;
+ recipients[i].index = i;
+ recipients_ptrs[i] = &recipients[i];
+ outputs_ptrs[i] = &outputs[i];
+ }
+
+ /* one group with the number of recipients being just on the limit => succeeds */
+ test_num_recipients = SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT;
+ CHECK(secp256k1_silentpayments_sender_create_outputs(CTX, outputs_ptrs, recipients_ptrs,
+ test_num_recipients, SMALLEST_OUTPOINT, NULL, 0, p, 1) == 1);
+
+ /* one group with the number of recipients exceeding the limit => fails */
+ test_num_recipients = SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT + 1;
+ CHECK(secp256k1_silentpayments_sender_create_outputs(CTX, outputs_ptrs, recipients_ptrs,
+ test_num_recipients, SMALLEST_OUTPOINT, NULL, 0, p, 1) == 0);
+
+ /* multiple groups with each being just on the limit => succeeds */
+ for (i = 0; i < total_recipients; i++) {
+ /* create recipient blocks of K_max size, each with different tweak values */
+ uint32_t tweak_value = i / SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT;
+ unsigned char tweak[32] = {0};
+ secp256k1_write_be32(&tweak[28], tweak_value);
+ CHECK(secp256k1_ec_pubkey_tweak_add(CTX, &recipients[i].scan_pubkey, tweak) == 1);
+ }
+ test_num_recipients = total_recipients;
+ CHECK(secp256k1_silentpayments_sender_create_outputs(CTX, outputs_ptrs, recipients_ptrs,
+ test_num_recipients, SMALLEST_OUTPOINT, NULL, 0, p, 1) == 1);
+
+ /* multiple groups, one of them exceeding the limit => fails */
+ for (i = 0; i < total_recipients; i++) { /* restore original order first */
+ recipients_ptrs[i] = &recipients[i];
+ }
+ recipients[SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT].scan_pubkey =
+ recipients[SECP256K1_SILENTPAYMENTS_RECIPIENT_GROUP_LIMIT-1].scan_pubkey;
+ test_num_recipients = total_recipients;
+ CHECK(secp256k1_silentpayments_sender_create_outputs(CTX, outputs_ptrs, recipients_ptrs,
+ test_num_recipients, SMALLEST_OUTPOINT, NULL, 0, p, 1) == 0);
+
+ free(outputs_ptrs);
+ free(outputs);
+ free(recipients_ptrs);
+ free(recipients);
+ }
}
static void test_label_api(void) {
Why this scored 38/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.