Skip to content

Commit e9f045c

Browse files
committed
BIP-375: add output scripts validation
Add support for computing bip352 output scripts Extract ECDH shares and public key from PSBT and aggregate both if necessary Refactor validate_ecdh_coverage to use collect_input_ecdh_and_pubkey
1 parent 10459d1 commit e9f045c

3 files changed

Lines changed: 175 additions & 15 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
Silent payment output script derivation
3+
"""
4+
5+
from typing import List
6+
7+
from deps.bitcoin_test.messages import COutPoint
8+
from secp256k1lab.secp256k1 import G, GE, Scalar
9+
from secp256k1lab.ecdh import ecdh_compressed_in_raw_out
10+
from secp256k1lab.util import tagged_hash
11+
12+
13+
def compute_silent_payment_output_script(
14+
outpoints: List[COutPoint],
15+
summed_pubkey_bytes: bytes,
16+
ecdh_share_bytes: bytes,
17+
spend_pubkey_bytes: bytes,
18+
k: int,
19+
) -> bytes:
20+
"""Compute silent payment output script per BIP-352"""
21+
input_hash_bytes = get_input_hash(outpoints, GE.from_bytes(summed_pubkey_bytes))
22+
23+
# Compute shared_secret = input_hash * ecdh_share
24+
shared_secret_bytes = ecdh_compressed_in_raw_out(
25+
input_hash_bytes, ecdh_share_bytes
26+
).to_bytes_compressed()
27+
28+
# Compute t_k = hash_BIP0352/SharedSecret(shared_secret || k)
29+
t_k = Scalar.from_bytes_checked(
30+
tagged_hash("BIP0352/SharedSecret", shared_secret_bytes + ser_uint32(k))
31+
)
32+
33+
# Compute P_k = B_spend + t_k * G
34+
B_spend = GE.from_bytes(spend_pubkey_bytes)
35+
P_k = B_spend + t_k * G
36+
37+
# Return P2TR script (x-only pubkey)
38+
return bytes([0x51, 0x20]) + P_k.to_bytes_xonly()
39+
40+
41+
def get_input_hash(outpoints: List[COutPoint], sum_input_pubkeys: GE) -> bytes:
42+
"""Compute input hash per BIP-352"""
43+
lowest_outpoint = sorted(outpoints, key=lambda outpoint: outpoint.serialize())[0]
44+
return tagged_hash(
45+
"BIP0352/Inputs",
46+
lowest_outpoint.serialize() + sum_input_pubkeys.to_bytes_compressed(),
47+
)
48+
49+
50+
def ser_uint32(u: int) -> bytes:
51+
return u.to_bytes(4, "big")

bip-0375/validator/inputs.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from deps.bitcoin_test.messages import CTransaction, CTxOut, from_binary
1010
from deps.bitcoin_test.psbt import (
11+
PSBT,
1112
PSBT_IN_BIP32_DERIVATION,
1213
PSBT_IN_NON_WITNESS_UTXO,
1314
PSBT_IN_OUTPUT_INDEX,
@@ -17,7 +18,51 @@
1718
)
1819
from secp256k1lab.secp256k1 import GE
1920

20-
from .psbt_bip375 import BIP375PSBTMap
21+
from .psbt_bip375 import BIP375PSBTMap, PSBT_GLOBAL_SP_ECDH_SHARE, PSBT_IN_SP_ECDH_SHARE
22+
23+
24+
def collect_input_ecdh_and_pubkey(
25+
psbt: PSBT, scan_key: bytes
26+
) -> Tuple[Optional[bytes], Optional[bytes]]:
27+
"""
28+
Collect combined ECDH share and summed pubkey for a scan key.
29+
30+
Checks global ECDH share first, falls back to per-input shares.
31+
Returns (ecdh_share_bytes, summed_pubkey_bytes) or (None, None).
32+
"""
33+
# Check for global ECDH share
34+
summed_pubkey = None
35+
ecdh_share = psbt.g.get_by_key(PSBT_GLOBAL_SP_ECDH_SHARE, scan_key)
36+
if ecdh_share:
37+
summed_pubkey = None
38+
for input_map in psbt.i:
39+
pubkey = pubkey_from_eligible_input(input_map)
40+
if pubkey is not None:
41+
summed_pubkey = (
42+
pubkey if summed_pubkey is None else summed_pubkey + pubkey
43+
)
44+
45+
if summed_pubkey:
46+
return ecdh_share, summed_pubkey.to_bytes_compressed()
47+
48+
# Check for per-input ECDH shares
49+
combined_ecdh = None
50+
for input_map in psbt.i:
51+
input_ecdh = input_map.get_by_key(PSBT_IN_SP_ECDH_SHARE, scan_key)
52+
if input_ecdh:
53+
ecdh_point = GE.from_bytes(input_ecdh)
54+
combined_ecdh = (
55+
ecdh_point if combined_ecdh is None else combined_ecdh + ecdh_point
56+
)
57+
pubkey = pubkey_from_eligible_input(input_map)
58+
if pubkey is not None:
59+
summed_pubkey = (
60+
pubkey if summed_pubkey is None else summed_pubkey + pubkey
61+
)
62+
63+
if combined_ecdh and summed_pubkey:
64+
return combined_ecdh.to_bytes_compressed(), summed_pubkey.to_bytes_compressed()
65+
return None, None
2166

2267

2368
def pubkey_from_eligible_input(input_map: BIP375PSBTMap) -> Optional[GE]:

bip-0375/validator/validate_psbt.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,26 @@
99
import struct
1010
from typing import Tuple
1111

12+
from deps.bitcoin_test.messages import COutPoint
1213
from deps.bitcoin_test.psbt import (
1314
PSBT,
1415
PSBT_GLOBAL_TX_MODIFIABLE,
16+
PSBT_IN_OUTPUT_INDEX,
17+
PSBT_IN_PREVIOUS_TXID,
1518
PSBT_IN_SIGHASH_TYPE,
1619
PSBT_IN_WITNESS_UTXO,
1720
PSBT_OUT_SCRIPT,
1821
)
1922
from deps.dleq import dleq_verify_proof
2023
from secp256k1lab.secp256k1 import GE
2124

22-
from .inputs import is_input_eligible, parse_witness_utxo, pubkey_from_eligible_input
25+
from .bip352_crypto import compute_silent_payment_output_script
26+
from .inputs import (
27+
collect_input_ecdh_and_pubkey,
28+
is_input_eligible,
29+
parse_witness_utxo,
30+
pubkey_from_eligible_input,
31+
)
2332
from .psbt_bip375 import (
2433
PSBT_GLOBAL_SP_ECDH_SHARE,
2534
PSBT_GLOBAL_SP_DLEQ,
@@ -165,13 +174,9 @@ def validate_ecdh_coverage(psbt: PSBT) -> Tuple[bool, str]:
165174
if not dleq_proof:
166175
return False, "Global ECDH share missing DLEQ proof"
167176

168-
# Compute A_sum "input public keys" for global verification
169-
A_sum = None
170-
for input_map in psbt.i:
171-
pubkey = pubkey_from_eligible_input(input_map)
172-
if pubkey is not None:
173-
A_sum = pubkey if A_sum is None else A_sum + pubkey
174-
assert A_sum is not None, "No public keys found for inputs"
177+
_, summed_pubkey_bytes = collect_input_ecdh_and_pubkey(psbt, scan_key)
178+
assert summed_pubkey_bytes is not None, "No public keys found for inputs"
179+
A_sum = GE.from_bytes(summed_pubkey_bytes)
175180

176181
valid, msg = validate_dleq_proof(A_sum, scan_key, ecdh_share, dleq_proof)
177182
if not valid:
@@ -182,11 +187,14 @@ def validate_ecdh_coverage(psbt: PSBT) -> Tuple[bool, str]:
182187
for i, input_map in enumerate(psbt.i):
183188
is_eligible, _ = is_input_eligible(input_map)
184189
ecdh_share = input_map.get_by_key(PSBT_IN_SP_ECDH_SHARE, scan_key)
185-
if not is_eligible and ecdh_share:
186-
return (
187-
False,
188-
f"Input {i} has ECDH share but is ineligible for silent payments",
189-
)
190+
# Disabled this check for now since it is not strictly forbidden by BIP-375
191+
if not is_eligible:
192+
continue
193+
# if not is_eligible and ecdh_share:
194+
# return (
195+
# False,
196+
# f"Input {i} has ECDH share but is ineligible for silent payments",
197+
# )
190198
if is_eligible and not ecdh_share:
191199
return (
192200
False,
@@ -285,4 +293,60 @@ def validate_input_eligibility(psbt: PSBT) -> Tuple[bool, str]:
285293

286294

287295
def validate_output_scripts(psbt: PSBT) -> Tuple[bool, str]:
288-
return False, "Output scripts check not implemented yet"
296+
"""
297+
Validate computed output scripts match silent payment derivation
298+
299+
Checks:
300+
- For each SP output with PSBT_OUT_SCRIPT set, recomputes the expected P2TR
301+
script from the ECDH share and input public keys and verifies it matches
302+
- k values are tracked per scan key and incremented for each SP output sharing
303+
the same scan key (outputs with different scan keys use independent k counters)
304+
"""
305+
# Build outpoints list
306+
outpoints = []
307+
for input_map in psbt.i:
308+
if PSBT_IN_PREVIOUS_TXID in input_map and PSBT_IN_OUTPUT_INDEX in input_map:
309+
output_index_bytes = input_map.get(PSBT_IN_OUTPUT_INDEX)
310+
txid_int = int.from_bytes(input_map[PSBT_IN_PREVIOUS_TXID], "little")
311+
output_index = struct.unpack("<I", output_index_bytes)[0]
312+
outpoints.append(COutPoint(txid_int, output_index))
313+
314+
# Track k values per scan key
315+
scan_key_k_values = {}
316+
317+
# Validate each SP output
318+
for output_idx, output_map in enumerate(psbt.o):
319+
if PSBT_OUT_SP_V0_INFO not in output_map:
320+
continue # Skip non-SP outputs
321+
322+
sp_info = output_map[PSBT_OUT_SP_V0_INFO]
323+
scan_pubkey_bytes = sp_info[:33]
324+
spend_pubkey_bytes = sp_info[33:]
325+
326+
k = scan_key_k_values.get(scan_pubkey_bytes, 0)
327+
328+
# Get ECDH share and summed pubkey
329+
ecdh_share_bytes, summed_pubkey_bytes = collect_input_ecdh_and_pubkey(
330+
psbt, scan_pubkey_bytes
331+
)
332+
333+
if ecdh_share_bytes and summed_pubkey_bytes and outpoints:
334+
computed_script = compute_silent_payment_output_script(
335+
outpoints, summed_pubkey_bytes, ecdh_share_bytes, spend_pubkey_bytes, k
336+
)
337+
338+
if PSBT_OUT_SCRIPT in output_map:
339+
actual_script = output_map[PSBT_OUT_SCRIPT]
340+
if actual_script != computed_script:
341+
return (
342+
False,
343+
f"Output {output_idx} script doesn't match silent payments derivation",
344+
)
345+
346+
scan_key_k_values[scan_pubkey_bytes] = k + 1
347+
elif PSBT_OUT_SCRIPT in output_map:
348+
return (
349+
False,
350+
f"Output {output_idx} has PSBT_OUT_SCRIPT but missing ECDH share or input pubkeys",
351+
)
352+
return True, None

0 commit comments

Comments
 (0)