From fb105b7e5169dd02cc4d9b58213e8c7804423ca9 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:20:11 -0500 Subject: [PATCH] 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 --- bip-0375/validator/bip352_crypto.py | 51 ++++++++++++++++ bip-0375/validator/inputs.py | 47 ++++++++++++++- bip-0375/validator/validate_psbt.py | 92 ++++++++++++++++++++++++----- 3 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 bip-0375/validator/bip352_crypto.py diff --git a/bip-0375/validator/bip352_crypto.py b/bip-0375/validator/bip352_crypto.py new file mode 100644 index 00000000..55238336 --- /dev/null +++ b/bip-0375/validator/bip352_crypto.py @@ -0,0 +1,51 @@ +""" +Silent payment output script derivation +""" + +from typing import List + +from deps.bitcoin_test.messages import COutPoint +from secp256k1lab.secp256k1 import G, GE, Scalar +from secp256k1lab.ecdh import ecdh_compressed_in_raw_out +from secp256k1lab.util import tagged_hash + + +def compute_silent_payment_output_script( + outpoints: List[COutPoint], + summed_pubkey_bytes: bytes, + ecdh_share_bytes: bytes, + spend_pubkey_bytes: bytes, + k: int, +) -> bytes: + """Compute silent payment output script per BIP-352""" + input_hash_bytes = get_input_hash(outpoints, GE.from_bytes(summed_pubkey_bytes)) + + # Compute shared_secret = input_hash * ecdh_share + shared_secret_bytes = ecdh_compressed_in_raw_out( + input_hash_bytes, ecdh_share_bytes + ).to_bytes_compressed() + + # Compute t_k = hash_BIP0352/SharedSecret(shared_secret || k) + t_k = Scalar.from_bytes_checked( + tagged_hash("BIP0352/SharedSecret", shared_secret_bytes + ser_uint32(k)) + ) + + # Compute P_k = B_spend + t_k * G + B_spend = GE.from_bytes(spend_pubkey_bytes) + P_k = B_spend + t_k * G + + # Return P2TR script (x-only pubkey) + return bytes([0x51, 0x20]) + P_k.to_bytes_xonly() + + +def get_input_hash(outpoints: List[COutPoint], sum_input_pubkeys: GE) -> bytes: + """Compute input hash per BIP-352""" + lowest_outpoint = sorted(outpoints, key=lambda outpoint: outpoint.serialize())[0] + return tagged_hash( + "BIP0352/Inputs", + lowest_outpoint.serialize() + sum_input_pubkeys.to_bytes_compressed(), + ) + + +def ser_uint32(u: int) -> bytes: + return u.to_bytes(4, "big") diff --git a/bip-0375/validator/inputs.py b/bip-0375/validator/inputs.py index ec24cef9..d8fe3800 100644 --- a/bip-0375/validator/inputs.py +++ b/bip-0375/validator/inputs.py @@ -8,6 +8,7 @@ from typing import Optional, Tuple from deps.bitcoin_test.messages import CTransaction, CTxOut, from_binary from deps.bitcoin_test.psbt import ( + PSBT, PSBT_IN_BIP32_DERIVATION, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_OUTPUT_INDEX, @@ -17,7 +18,51 @@ from deps.bitcoin_test.psbt import ( ) from secp256k1lab.secp256k1 import GE -from .psbt_bip375 import BIP375PSBTMap +from .psbt_bip375 import BIP375PSBTMap, PSBT_GLOBAL_SP_ECDH_SHARE, PSBT_IN_SP_ECDH_SHARE + + +def collect_input_ecdh_and_pubkey( + psbt: PSBT, scan_key: bytes +) -> Tuple[Optional[bytes], Optional[bytes]]: + """ + Collect combined ECDH share and summed pubkey for a scan key. + + Checks global ECDH share first, falls back to per-input shares. + Returns (ecdh_share_bytes, summed_pubkey_bytes) or (None, None). + """ + # Check for global ECDH share + summed_pubkey = None + ecdh_share = psbt.g.get_by_key(PSBT_GLOBAL_SP_ECDH_SHARE, scan_key) + if ecdh_share: + summed_pubkey = None + for input_map in psbt.i: + pubkey = pubkey_from_eligible_input(input_map) + if pubkey is not None: + summed_pubkey = ( + pubkey if summed_pubkey is None else summed_pubkey + pubkey + ) + + if summed_pubkey: + return ecdh_share, summed_pubkey.to_bytes_compressed() + + # Check for per-input ECDH shares + combined_ecdh = None + for input_map in psbt.i: + input_ecdh = input_map.get_by_key(PSBT_IN_SP_ECDH_SHARE, scan_key) + if input_ecdh: + ecdh_point = GE.from_bytes(input_ecdh) + combined_ecdh = ( + ecdh_point if combined_ecdh is None else combined_ecdh + ecdh_point + ) + pubkey = pubkey_from_eligible_input(input_map) + if pubkey is not None: + summed_pubkey = ( + pubkey if summed_pubkey is None else summed_pubkey + pubkey + ) + + if combined_ecdh and summed_pubkey: + return combined_ecdh.to_bytes_compressed(), summed_pubkey.to_bytes_compressed() + return None, None def pubkey_from_eligible_input(input_map: BIP375PSBTMap) -> Optional[GE]: diff --git a/bip-0375/validator/validate_psbt.py b/bip-0375/validator/validate_psbt.py index 6ffb0b80..d6c10cbc 100644 --- a/bip-0375/validator/validate_psbt.py +++ b/bip-0375/validator/validate_psbt.py @@ -9,9 +9,12 @@ input eligibility, and output script correctness. import struct from typing import Tuple +from deps.bitcoin_test.messages import COutPoint from deps.bitcoin_test.psbt import ( PSBT, PSBT_GLOBAL_TX_MODIFIABLE, + PSBT_IN_OUTPUT_INDEX, + PSBT_IN_PREVIOUS_TXID, PSBT_IN_SIGHASH_TYPE, PSBT_IN_WITNESS_UTXO, PSBT_OUT_SCRIPT, @@ -19,7 +22,13 @@ from deps.bitcoin_test.psbt import ( from deps.dleq import dleq_verify_proof from secp256k1lab.secp256k1 import GE -from .inputs import is_input_eligible, parse_witness_utxo, pubkey_from_eligible_input +from .bip352_crypto import compute_silent_payment_output_script +from .inputs import ( + collect_input_ecdh_and_pubkey, + is_input_eligible, + parse_witness_utxo, + pubkey_from_eligible_input, +) from .psbt_bip375 import ( PSBT_GLOBAL_SP_ECDH_SHARE, PSBT_GLOBAL_SP_DLEQ, @@ -165,13 +174,9 @@ def validate_ecdh_coverage(psbt: PSBT) -> Tuple[bool, str]: if not dleq_proof: return False, "Global ECDH share missing DLEQ proof" - # Compute A_sum "input public keys" for global verification - A_sum = None - for input_map in psbt.i: - pubkey = pubkey_from_eligible_input(input_map) - if pubkey is not None: - A_sum = pubkey if A_sum is None else A_sum + pubkey - assert A_sum is not None, "No public keys found for inputs" + _, summed_pubkey_bytes = collect_input_ecdh_and_pubkey(psbt, scan_key) + assert summed_pubkey_bytes is not None, "No public keys found for inputs" + A_sum = GE.from_bytes(summed_pubkey_bytes) valid, msg = validate_dleq_proof(A_sum, scan_key, ecdh_share, dleq_proof) if not valid: @@ -182,11 +187,14 @@ def validate_ecdh_coverage(psbt: PSBT) -> Tuple[bool, str]: for i, input_map in enumerate(psbt.i): is_eligible, _ = is_input_eligible(input_map) ecdh_share = input_map.get_by_key(PSBT_IN_SP_ECDH_SHARE, scan_key) - if not is_eligible and ecdh_share: - return ( - False, - f"Input {i} has ECDH share but is ineligible for silent payments", - ) + # Disabled this check for now since it is not strictly forbidden by BIP-375 + if not is_eligible: + continue + # if not is_eligible and ecdh_share: + # return ( + # False, + # f"Input {i} has ECDH share but is ineligible for silent payments", + # ) if is_eligible and not ecdh_share: return ( False, @@ -285,4 +293,60 @@ def validate_input_eligibility(psbt: PSBT) -> Tuple[bool, str]: def validate_output_scripts(psbt: PSBT) -> Tuple[bool, str]: - return False, "Output scripts check not implemented yet" + """ + Validate computed output scripts match silent payment derivation + + Checks: + - For each SP output with PSBT_OUT_SCRIPT set, recomputes the expected P2TR + script from the ECDH share and input public keys and verifies it matches + - k values are tracked per scan key and incremented for each SP output sharing + the same scan key (outputs with different scan keys use independent k counters) + """ + # Build outpoints list + outpoints = [] + for input_map in psbt.i: + if PSBT_IN_PREVIOUS_TXID in input_map and PSBT_IN_OUTPUT_INDEX in input_map: + output_index_bytes = input_map.get(PSBT_IN_OUTPUT_INDEX) + txid_int = int.from_bytes(input_map[PSBT_IN_PREVIOUS_TXID], "little") + output_index = struct.unpack("