silentpayments: flush labels before direct match
What changed, and why it matters
This commit fixes a bug in Bitcoin's silent payments scanning code. When a wallet scans a transaction for outputs it can spend, a performance shortcut could accidentally skip over a labeled output and report a plain (unlabeled) version instead. The fix makes the scanner check pending labeled candidates before accepting a direct unlabeled match, restoring the intended 'first match wins' behavior and preserving label metadata.
Review whether any released versions or downstream consumers of this silent payments implementation shipped with the buggy scan order, and consider whether wallet users relying on labeled outputs need guidance. The fix itself should be merged and the new regression test run in CI.
Security signals we found
Incorrect output attribution in wallet scanning
Loss of label metadata for received funds
Regression test demonstrates labeled output precedence failure
Batch-inversion optimization changes effective scan order
Evidence from the diff
In secp256k1’s silentpayments module, secp256k1_silentpayments_recipient_scan_outputs uses batch inversion to test multiple label candidates at once. If a direct x-only match against the unlabeled spend pubkey is found, the loop previously broke immediately, discarding any pending label batch that might have matched an earlier transaction output. The patch flushes the pending label batch before accepting the direct match, so an earlier labeled k=0 output is reported with its label rather than a later unlabeled output. A regression test is added that creates two k=0 outputs (labeled first, unlabeled second) and asserts the scanner returns the labeled one.
Changed components
src/modules/silentpayments/main_impl.hsrc/modules/silentpayments/tests_impl.hInspect captured patch +74 / −0
diff --git a/src/modules/silentpayments/main_impl.h b/src/modules/silentpayments/main_impl.h
index 0a320be..d2a910e 100644
--- a/src/modules/silentpayments/main_impl.h
+++ b/src/modules/silentpayments/main_impl.h
@@ -713,6 +713,15 @@ int secp256k1_silentpayments_recipient_scan_outputs(
if (secp256k1_xonly_pubkey_cmp(ctx, &unlabeled_output_xonly, tx_outputs[j]) == 0) {
label_tweak = NULL;
found_idx = j;
+ /* An earlier label match takes precedence over this direct match. */
+ if (label_batch_idx > 0) {
+ int label_found_idx = secp256k1_silentpayments_check_label_batch(
+ &label_ge, &label_tweak, label_candidates_gej, label_batch_idx,
+ j - label_batch_idx, label_lookup, label_context);
+ if (label_found_idx != -1) {
+ found_idx = label_found_idx;
+ }
+ }
break;
}
diff --git a/src/modules/silentpayments/tests_impl.h b/src/modules/silentpayments/tests_impl.h
index 4cee6fd..a66e8f9 100644
--- a/src/modules/silentpayments/tests_impl.h
+++ b/src/modules/silentpayments/tests_impl.h
@@ -601,6 +601,70 @@ static void test_recipient_api(void) {
CHECK_ILLEGAL(CTX, secp256k1_silentpayments_recipient_scan_outputs(CTX, fp, &n_f, tp, 1, ALICE_SECKEY, &ps, &p, NULL, NULL));
}
+static void test_recipient_scan_label_precedes_direct_match(void) {
+ static const unsigned char sender_seckey[32] = { 1 };
+ static const unsigned char scan_seckey[32] = { 2 };
+ static const unsigned char spend_seckey[32] = { 3 };
+ secp256k1_pubkey sender_pubkey, scan_pubkey, unlabeled_spend_pubkey, labeled_spend_pubkey;
+ const secp256k1_pubkey *prevout_pubkeys[1];
+ const unsigned char *sender_seckeys[1];
+ secp256k1_silentpayments_prevouts_summary prevouts_summary;
+ secp256k1_silentpayments_label label;
+ secp256k1_silentpayments_recipient recipient;
+ const secp256k1_silentpayments_recipient *recipients[1];
+ secp256k1_xonly_pubkey labeled_output, direct_output;
+ secp256k1_xonly_pubkey *generated_outputs[1];
+ const secp256k1_xonly_pubkey *tx_outputs[2];
+ secp256k1_silentpayments_found_output found_output[2];
+ secp256k1_silentpayments_found_output *found_outputs[2];
+ struct labels_cache cache;
+ unsigned char found_label[33];
+ uint32_t n_found_outputs;
+
+ CHECK(secp256k1_ec_pubkey_create(CTX, &sender_pubkey, sender_seckey));
+ CHECK(secp256k1_ec_pubkey_create(CTX, &scan_pubkey, scan_seckey));
+ CHECK(secp256k1_ec_pubkey_create(CTX, &unlabeled_spend_pubkey, spend_seckey));
+ prevout_pubkeys[0] = &sender_pubkey;
+ sender_seckeys[0] = sender_seckey;
+ CHECK(secp256k1_silentpayments_recipient_prevouts_summary_create(
+ CTX, &prevouts_summary, SMALLEST_OUTPOINT, NULL, 0, prevout_pubkeys, 1));
+
+ memset(&cache, 0, sizeof(cache));
+ CHECK(secp256k1_silentpayments_recipient_label_create(
+ CTX, &label, cache.entries[0].label_tweak, scan_seckey, 1));
+ CHECK(secp256k1_silentpayments_recipient_label_serialize(CTX, cache.entries[0].label, &label));
+ cache.entries_used = 1;
+ CHECK(secp256k1_silentpayments_recipient_create_labeled_spend_pubkey(
+ CTX, &labeled_spend_pubkey, &unlabeled_spend_pubkey, &label));
+
+ recipient.scan_pubkey = scan_pubkey;
+ recipient.spend_pubkey = labeled_spend_pubkey;
+ recipient.index = 0;
+ recipients[0] = &recipient;
+ /* Produce two k = 0 outputs, with the labeled output first. */
+ generated_outputs[0] = &labeled_output;
+ CHECK(secp256k1_silentpayments_sender_create_outputs(
+ CTX, generated_outputs, recipients, 1, SMALLEST_OUTPOINT, NULL, 0, sender_seckeys, 1));
+ recipient.spend_pubkey = unlabeled_spend_pubkey;
+ generated_outputs[0] = &direct_output;
+ CHECK(secp256k1_silentpayments_sender_create_outputs(
+ CTX, generated_outputs, recipients, 1, SMALLEST_OUTPOINT, NULL, 0, sender_seckeys, 1));
+ CHECK(secp256k1_xonly_pubkey_cmp(CTX, &labeled_output, &direct_output) != 0);
+
+ tx_outputs[0] = &labeled_output;
+ tx_outputs[1] = &direct_output;
+ found_outputs[0] = &found_output[0];
+ found_outputs[1] = &found_output[1];
+ CHECK(secp256k1_silentpayments_recipient_scan_outputs(
+ CTX, found_outputs, &n_found_outputs, tx_outputs, 2, scan_seckey, &prevouts_summary,
+ &unlabeled_spend_pubkey, label_lookup, &cache));
+ CHECK(n_found_outputs == 1);
+ CHECK(secp256k1_xonly_pubkey_cmp(CTX, &found_output[0].output, &labeled_output) == 0);
+ CHECK(found_output[0].found_with_label);
+ CHECK(secp256k1_silentpayments_recipient_label_serialize(CTX, found_label, &found_output[0].label));
+ CHECK(secp256k1_memcmp_var(found_label, cache.entries[0].label, sizeof(found_label)) == 0);
+}
+
void run_silentpayments_test_vector_send(const struct bip352_test_vector *test) {
static secp256k1_silentpayments_recipient recipients[MAX_OUTPUTS_PER_TEST_CASE];
static const secp256k1_silentpayments_recipient *recipient_ptrs[MAX_OUTPUTS_PER_TEST_CASE];
@@ -861,6 +925,7 @@ static const struct tf_test_entry tests_silentpayments[] = {
CASE1(test_send_api),
CASE1(test_label_api),
CASE1(test_recipient_api),
+ CASE1(test_recipient_scan_label_precedes_direct_match),
CASE1(run_silentpayments_test_vectors),
CASE1(silentpayments_sha256_tag_test),
};
Why this scored 42/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.