1
0
mirror of https://github.com/bitcoin/bips.git synced 2026-04-20 16:28:39 +00:00

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
This commit is contained in:
macgyver13
2026-02-23 10:20:11 -05:00
parent ab30224051
commit fb105b7e51
3 changed files with 175 additions and 15 deletions

View File

@@ -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")

View File

@@ -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]:

View File

@@ -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("<I", output_index_bytes)[0]
outpoints.append(COutPoint(txid_int, output_index))
# Track k values per scan key
scan_key_k_values = {}
# Validate each SP output
for output_idx, output_map in enumerate(psbt.o):
if PSBT_OUT_SP_V0_INFO not in output_map:
continue # Skip non-SP outputs
sp_info = output_map[PSBT_OUT_SP_V0_INFO]
scan_pubkey_bytes = sp_info[:33]
spend_pubkey_bytes = sp_info[33:]
k = scan_key_k_values.get(scan_pubkey_bytes, 0)
# Get ECDH share and summed pubkey
ecdh_share_bytes, summed_pubkey_bytes = collect_input_ecdh_and_pubkey(
psbt, scan_pubkey_bytes
)
if ecdh_share_bytes and summed_pubkey_bytes and outpoints:
computed_script = compute_silent_payment_output_script(
outpoints, summed_pubkey_bytes, ecdh_share_bytes, spend_pubkey_bytes, k
)
if PSBT_OUT_SCRIPT in output_map:
actual_script = output_map[PSBT_OUT_SCRIPT]
if actual_script != computed_script:
return (
False,
f"Output {output_idx} script doesn't match silent payments derivation",
)
scan_key_k_values[scan_pubkey_bytes] = k + 1
elif PSBT_OUT_SCRIPT in output_map:
return (
False,
f"Output {output_idx} has PSBT_OUT_SCRIPT but missing ECDH share or input pubkeys",
)
return True, None