Skip to content

Commit 6eb01f0

Browse files
authored
Merge pull request #2106 from theStack/bip352_limit_max-k-PR
BIP-352: introduce per-group recipient limit K_max (=2323)
2 parents 9fb88a1 + b4bc0a8 commit 6eb01f0

File tree

3 files changed

+2444
-7
lines changed

3 files changed

+2444
-7
lines changed

bip-0352.mediawiki

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ After the inputs have been selected, the sender can create one or more outputs f
301301
* Let ''input_hash = hash<sub>BIP0352/Inputs</sub>(outpoint<sub>L</sub> || A)'', where ''outpoint<sub>L</sub>'' is the smallest ''outpoint'' lexicographically used in the transaction<ref name="why_smallest_outpoint"></ref> and ''A = a·G''
302302
** If ''input_hash'' is not a valid scalar, i.e., if ''input_hash = 0'' or ''input_hash'' is larger or equal to the secp256k1 group order, fail
303303
* Group receiver silent payment addresses by ''B<sub>scan</sub>'' (e.g. each group consists of one ''B<sub>scan</sub>'' and one or more ''B<sub>m</sub>'')
304+
* If any of the groups exceed the limit of ''K<sub>max</sub>'' (=2323) silent payment addresses, fail.<ref name="why_limit_k">'''Why is the size of groups (i.e. silent payment addresses sharing the same scan public key) limited by ''K<sub>max</sub>''?''' An adversary could construct a block filled with a single transaction consisting of N=23255 outputs (that's the theoretical maximum under current consensus rules, w.r.t. the block weight limit) that all target the same entity, consisting of one large group. Without a limit on the group size, scanning such a block with the algorithm described in this document would have a complexity of ''O(N<sup>2</sup>)'' for that entity, taking several minutes on modern systems. By capping the group size at ''K<sub>max</sub>'', we reduce the inner loop iterations to ''K<sub>max</sub>'', thereby decreasing the worst-case block scanning complexity to ''O(N·K<sub>max</sub>)''. This cuts down the scanning cost to the order of tens of seconds. The chosen value of ''K<sub>max</sub>'' = 2323 represents the maximum number of P2TR outputs that can fit into a 100kvB transaction, meaning a transaction that adheres to the current standardness rules is guaranteed to be within the limit. This ensures flexibility and also mitigates potential fingerprinting issues.</ref>
305+
304306
* For each group:
305307
** Let ''ecdh_shared_secret = input_hash·a·B<sub>scan</sub>''
306308
** Let ''k = 0''
@@ -340,6 +342,7 @@ If each of the checks in ''[[#scanning-silent-payment-eligible-transactions|Scan
340342
* Check for outputs:
341343
** Let ''outputs_to_check'' be the taproot output keys from all taproot outputs in the transaction (spent and unspent).
342344
** Starting with ''k = 0'':
345+
*** If ''k == K<sub>max</sub>'' (=2323), stop scanning.<ref name="why_limit_k"></ref>
343346
*** Let ''t<sub>k</sub> = hash<sub>BIP0352/SharedSecret</sub>(ser<sub>P</sub>(ecdh_shared_secret) || ser<sub>32</sub>(k))''
344347
**** If ''t<sub>k</sub>'' is not a valid scalar, i.e., if ''t<sub>k</sub> = 0'' or ''t<sub>k</sub>'' is larger or equal to the secp256k1 group order, fail
345348
*** Compute ''P<sub>k</sub> = B<sub>spend</sub> + t<sub>k</sub>·G''
@@ -390,7 +393,15 @@ A [[bip-0352/send_and_receive_test_vectors.json|collection of test vectors in JS
390393
{
391394
"given": {
392395
"vin": [<array of vin objects with an added field for the private key. These objects are structured to match the `vin` output field from `getrawtransaction verbosity=2`>],
393-
"recipients": [<array of strings, where each string is a bech32m encoding representing a silent payment address>]
396+
"recipients": [<array of recipient objects, consisting of the address and its contained scan/spend public keys each>
397+
{
398+
"address": <bech32m encoding representing a silent payment address>,
399+
"scan_pub_key": <hex encoded scan public key>,
400+
"spend_pub_key": <hex encoded spend public key>,
401+
"count": <optional integer for specifying the same recipient repeatedly (1 by default)>
402+
},
403+
...
404+
]
394405
},
395406
"expected": {
396407
"outputs": [<array of strings, where each string is a hex encoding of 32-byte X-only public key; contains all possible output sets, test must match a subset of size `n_outputs`>],
@@ -411,15 +422,15 @@ A [[bip-0352/send_and_receive_test_vectors.json|collection of test vectors in JS
411422
},
412423
"expected": {
413424
"addresses": [<array of bech32m strings, one for the silent payment address and each labeled address (if used)>],
414-
"outputs": [<array of outputs with tweak and signature; contains all possible output sets, tester must match a subset of size `n_outputs`>
425+
"outputs": [<optional array of outputs with tweak and signature, tester must match this set (alternatively, "n_outputs" can be specified)>
415426
{
416427
"priv_key_tweak": <hex encoded private key tweak data>,
417428
"pub_key": <hex encoded X-only public key>,
418429
"signature": <hex encoded signature for the output (produced with spend_priv_key + priv_key_tweak)>
419430
},
420431
...
421432
],
422-
"n_outputs": <integer for the exact number of expected outputs>
433+
"n_outputs": <optional integer for the number of expected found outputs (alternative to "outputs")>
423434
}
424435
}
425436
@@ -489,6 +500,8 @@ The <code>MAJOR</code> version is incremented if changes to the BIP are introduc
489500
The <code>MINOR</code> version is incremented whenever the inputs or the output of an algorithm changes in a backward-compatible way or new backward-compatible functionality is added.
490501
The <code>PATCH</code> version is incremented for other changes that are noteworthy (bug fixes, test vectors, important clarifications, etc.).
491502

503+
* '''1.1.0''' (2026-03-02):
504+
** Introduce per-group recipient limit ''K<sub>max</sub>'' to mitigate quadratic scanning behavior for adversarial transactions.<ref name="why_limit_k"></ref>
492505
* '''1.0.2''' (2025-07-25):
493506
** Clarify how to handle the improbable corner case where the output of SHA256 is equal to 0 or greater than or equal to the secp256k1 curve order.
494507
* '''1.0.1''' (2024-06-22):

bip-0352/reference.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
)
2727

2828

29+
K_max = 2323 # per-group recipient limit
30+
31+
2932
def get_pubkey_from_input(vin: VinInfo) -> ECPubKey:
3033
if is_p2pkh(vin.prevout):
3134
# skip the first 3 op_codes and grab the 20 byte hash
@@ -144,6 +147,10 @@ def create_outputs(input_priv_keys: List[Tuple[ECKey, bool]], outpoints: List[CO
144147
else:
145148
silent_payment_groups[B_scan] = [B_m]
146149

150+
# Fail if per-group recipient limit (K_max) is exceeded
151+
if any([len(group) > K_max for group in silent_payment_groups.values()]):
152+
return []
153+
147154
outputs = []
148155
for B_scan, B_m_values in silent_payment_groups.items():
149156
ecdh_shared_secret = input_hash * a_sum * B_scan
@@ -175,6 +182,8 @@ def scanning(b_scan: ECKey, B_spend: ECPubKey, A_sum: ECPubKey, input_hash: byte
175182
k = 0
176183
wallet = []
177184
while True:
185+
if k == K_max: # Don't look further than the per-group recipient limit (K_max)
186+
break
178187
t_k = TaggedHash("BIP0352/SharedSecret", ecdh_shared_secret.get_bytes(False) + ser_uint32(k))
179188
P_k = B_spend + t_k * G
180189
for output in outputs_to_check:
@@ -259,7 +268,11 @@ def scanning(b_scan: ECKey, B_spend: ECPubKey, A_sum: ECPubKey, input_hash: byte
259268
sending_outputs = []
260269
if (len(input_pub_keys) > 0):
261270
outpoints = [vin.outpoint for vin in vins]
262-
sending_outputs = create_outputs(input_priv_keys, outpoints, given["recipients"], expected=expected, hrp="sp")
271+
recipients = [] # expand given recipient entries to full list
272+
for recipient_entry in given["recipients"]:
273+
count = recipient_entry.get("count", 1)
274+
recipients.extend([recipient_entry] * count)
275+
sending_outputs = create_outputs(input_priv_keys, outpoints, recipients, expected=expected, hrp="sp")
263276

264277
# Note: order doesn't matter for creating/finding the outputs. However, different orderings of the recipient addresses
265278
# will produce different generated outputs if sending to multiple silent payment addresses belonging to the
@@ -354,9 +367,14 @@ def scanning(b_scan: ECKey, B_spend: ECPubKey, A_sum: ECPubKey, input_hash: byte
354367
# same sender but with different labels. Because of this, expected["outputs"] contains all possible valid output sets,
355368
# based on all possible permutations of recipient address orderings. Must match exactly one of the possible found output
356369
# sets in expected["outputs"]
357-
generated_set = {frozenset(d.items()) for d in add_to_wallet}
358-
expected_set = {frozenset(d.items()) for d in expected["outputs"]}
359-
assert generated_set == expected_set, "Receive test failed"
370+
if "outputs" in expected: # detailed check against expected outputs
371+
generated_set = {frozenset(d.items()) for d in add_to_wallet}
372+
expected_set = {frozenset(d.items()) for d in expected["outputs"]}
373+
assert generated_set == expected_set, "Receive test failed"
374+
elif "n_outputs" in expected: # only check the number of found outputs
375+
assert len(add_to_wallet) == expected["n_outputs"], "Receive test failed"
376+
else:
377+
assert False, "either 'outputs' or 'n_outputs' must be specified in 'expected' field of receiving test vector"
360378

361379

362380
print("All tests passed")

0 commit comments

Comments
 (0)