From 8530b14f37c8db241889b470e667ea735605eb16 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:21:15 -0500 Subject: [PATCH 1/2] Implement test runner to evaluate bip 375 test vectors --- bip-0375/README.md | 24 ++ bip-0375/bip352_utils.py | 245 ++++++++++++ bip-0375/dleq_374.py | 148 +++++++ bip-0375/reference.py | 772 +++++++++++++++++++++++++++++++++++++ bip-0375/secp256k1_374.py | 360 +++++++++++++++++ bip-0375/test_vectors.json | 769 ++++++++++++++++++++++++++++++++++++ 6 files changed, 2318 insertions(+) create mode 100644 bip-0375/README.md create mode 100644 bip-0375/bip352_utils.py create mode 100755 bip-0375/dleq_374.py create mode 100644 bip-0375/reference.py create mode 100755 bip-0375/secp256k1_374.py create mode 100644 bip-0375/test_vectors.json diff --git a/bip-0375/README.md b/bip-0375/README.md new file mode 100644 index 0000000000..f15bb921a0 --- /dev/null +++ b/bip-0375/README.md @@ -0,0 +1,24 @@ +# BIP 375 Reference Implementation + +This directory contains the complete reference implementation for BIP 375: Sending Silent Payments with PSBTs. + +## **Core Files** + +### Reference Implementation + +- **`reference.py`** - Minimal standalone BIP 375 validator with integrated test runner (executable) + +### Dependencies (from BIP 374) + +- **`dleq_374.py`** - BIP 374 DLEQ proof implementation +- **`secp256k1_374.py`** - Secp256k1 implementation + +## **Usage** + +### Run Reference Implementation Tests + +```bash +python reference.py # Run all tests using test_vectors.json +python reference.py -f custom.json # Use custom test file +python reference.py -v # Verbose mode with detailed errors +``` \ No newline at end of file diff --git a/bip-0375/bip352_utils.py b/bip-0375/bip352_utils.py new file mode 100644 index 0000000000..178392ad5a --- /dev/null +++ b/bip-0375/bip352_utils.py @@ -0,0 +1,245 @@ +import hashlib +import struct +from io import BytesIO +from secp256k1_374 import GE, G +from typing import Union, List, Tuple + + +def from_hex(hex_string): + """Deserialize from a hex string representation (e.g. from RPC)""" + return BytesIO(bytes.fromhex(hex_string)) + + +def ser_uint32(u: int) -> bytes: + return u.to_bytes(4, "big") + + +def ser_uint256(u): + return u.to_bytes(32, "little") + + +def deser_uint256(f): + return int.from_bytes(f.read(32), "little") + + +def deser_txid(txid: str): + # recall that txids are serialized little-endian, but displayed big-endian + # this means when converting from a human readable hex txid, we need to first + # reverse it before deserializing it + dixt = "".join(map(str.__add__, txid[-2::-2], txid[-1::-2])) + return bytes.fromhex(dixt) + + +def deser_compact_size(f: BytesIO): + view = f.getbuffer() + nbytes = view.nbytes + view.release() + if nbytes == 0: + return 0 # end of stream + + nit = struct.unpack(" bool: + if len(spk) != 34: + return False + # OP_1 OP_PUSHBYTES_32 <32 bytes> + return (spk[0] == 0x51) & (spk[1] == 0x20) + + +def is_p2wpkh(spk: bytes) -> bool: + if len(spk) != 22: + return False + # OP_0 OP_PUSHBYTES_20 <20 bytes> + return (spk[0] == 0x00) & (spk[1] == 0x14) + + +def is_p2sh(spk: bytes) -> bool: + if len(spk) != 23: + return False + # OP_HASH160 OP_PUSHBYTES_20 <20 bytes> OP_EQUAL + return (spk[0] == 0xA9) & (spk[1] == 0x14) & (spk[-1] == 0x87) + + +def is_p2pkh(spk: bytes) -> bool: + if len(spk) != 25: + return False + # OP_DUP OP_HASH160 OP_PUSHBYTES_20 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG + return ( + (spk[0] == 0x76) + & (spk[1] == 0xA9) + & (spk[2] == 0x14) + & (spk[-2] == 0x88) + & (spk[-1] == 0xAC) + ) + + +def is_eligible_input_type(script_pubkey: bytes) -> bool: + """Check if scriptPubKey is an eligible input type for silent payments per BIP-352""" + return ( + is_p2pkh(script_pubkey) + or is_p2wpkh(script_pubkey) + or is_p2tr(script_pubkey) + or is_p2sh(script_pubkey) + ) + + +def parse_non_witness_utxo(non_witness_utxo: bytes, output_index: int) -> bytes: + """Extract scriptPubKey from NON_WITNESS_UTXO field""" + try: + offset = 0 + + # Skip version (4 bytes) + if len(non_witness_utxo) < 4: + return None + offset += 4 + + # Parse input count (compact size) + if offset >= len(non_witness_utxo): + return None + + input_count = non_witness_utxo[offset] + offset += 1 + if input_count >= 0xFD: + # Handle larger compact size (simplified - just skip) + if input_count == 0xFD: + offset += 2 + elif input_count == 0xFE: + offset += 4 + else: + offset += 8 + input_count = ( + struct.unpack("= len(non_witness_utxo): + return None + + # Skip scriptSig + script_len = non_witness_utxo[offset] + offset += 1 + if script_len >= 0xFD: + return None # Simplified - don't handle large scripts + offset += script_len + + # Skip sequence (4) + offset += 4 + if offset > len(non_witness_utxo): + return None + + # Parse output count + if offset >= len(non_witness_utxo): + return None + output_count = non_witness_utxo[offset] + offset += 1 + if output_count >= 0xFD: + if output_count == 0xFD: + output_count = struct.unpack( + "= len(non_witness_utxo): + return None + offset += 8 + + # Parse scriptPubKey length + if offset >= len(non_witness_utxo): + return None + script_len = non_witness_utxo[offset] + offset += 1 + if script_len >= 0xFD: + if script_len == 0xFD: + script_len = struct.unpack( + " len(non_witness_utxo): + return None + return non_witness_utxo[offset : offset + script_len] + + # Otherwise skip to next output + offset += script_len + if offset > len(non_witness_utxo): + return None + + return None + except Exception: + return None + + +def compute_bip352_output_script( + outpoints: List[Tuple[bytes, int]], + summed_pubkey_bytes: bytes, + ecdh_share_bytes: bytes, + spend_pubkey_bytes: bytes, + k: int = 0, +) -> bytes: + """Compute BIP-352 silent payment output script""" + # Find smallest outpoint lexicographically + serialized_outpoints = [txid + struct.pack(" bytes: + ss = sha256(tag.encode()).digest() + ss += ss + ss += data + return sha256(ss).digest() + + +def xor_bytes(lhs: bytes, rhs: bytes) -> bytes: + assert len(lhs) == len(rhs) + return bytes([lhs[i] ^ rhs[i] for i in range(len(lhs))]) + + +def dleq_challenge( + A: GE, B: GE, C: GE, R1: GE, R2: GE, m: bytes | None, G: GE, +) -> int: + if m is not None: + assert len(m) == 32 + m = bytes([]) if m is None else m + return int.from_bytes( + TaggedHash( + DLEQ_TAG_CHALLENGE, + A.to_bytes_compressed() + + B.to_bytes_compressed() + + C.to_bytes_compressed() + + G.to_bytes_compressed() + + R1.to_bytes_compressed() + + R2.to_bytes_compressed() + + m, + ), + "big", + ) + + +def dleq_generate_proof( + a: int, B: GE, r: bytes, G: GE = G, m: bytes | None = None +) -> bytes | None: + assert len(r) == 32 + if not (0 < a < GE.ORDER): + return None + if B.infinity: + return None + if m is not None: + assert len(m) == 32 + A = a * G + C = a * B + t = xor_bytes(a.to_bytes(32, "big"), TaggedHash(DLEQ_TAG_AUX, r)) + m_prime = bytes([]) if m is None else m + rand = TaggedHash( + DLEQ_TAG_NONCE, t + A.to_bytes_compressed() + C.to_bytes_compressed() + m_prime + ) + k = int.from_bytes(rand, "big") % GE.ORDER + if k == 0: + return None + R1 = k * G + R2 = k * B + e = dleq_challenge(A, B, C, R1, R2, m, G) + s = (k + e * a) % GE.ORDER + proof = e.to_bytes(32, "big") + s.to_bytes(32, "big") + if not dleq_verify_proof(A, B, C, proof, G=G, m=m): + return None + return proof + + +def dleq_verify_proof( + A: GE, B: GE, C: GE, proof: bytes, G: GE = G, m: bytes | None = None +) -> bool: + if A.infinity or B.infinity or C.infinity or G.infinity: + return False + assert len(proof) == 64 + e = int.from_bytes(proof[:32], "big") + s = int.from_bytes(proof[32:], "big") + if s >= GE.ORDER: + return False + R1 = s * G - e * A + if R1.infinity: + return False + R2 = s * B - e * C + if R2.infinity: + return False + if e != dleq_challenge(A, B, C, R1, R2, m, G): + return False + return True + + +class DLEQTests(unittest.TestCase): + def test_dleq(self): + seed = random.randrange(sys.maxsize) + random.seed(seed) + print(f"PRNG seed is: {seed}") + for _ in range(10): + # generate random keypairs for both parties + a = random.randrange(1, GE.ORDER) + A = a * G + b = random.randrange(1, GE.ORDER) + B = b * G + + # create shared secret + C = a * B + + # create dleq proof + rand_aux = random.randbytes(32) + proof = dleq_generate_proof(a, B, rand_aux) + self.assertTrue(proof is not None) + # verify dleq proof + success = dleq_verify_proof(A, B, C, proof) + self.assertTrue(success) + + # flip a random bit in the dleq proof and check that verification fails + for _ in range(5): + proof_damaged = list(proof) + proof_damaged[random.randrange(len(proof))] ^= 1 << ( + random.randrange(8) + ) + success = dleq_verify_proof(A, B, C, bytes(proof_damaged)) + self.assertFalse(success) + + # create the same dleq proof with a message + message = random.randbytes(32) + proof = dleq_generate_proof(a, B, rand_aux, m=message) + self.assertTrue(proof is not None) + # verify dleq proof with a message + success = dleq_verify_proof(A, B, C, proof, m=message) + self.assertTrue(success) + + # flip a random bit in the dleq proof and check that verification fails + for _ in range(5): + proof_damaged = list(proof) + proof_damaged[random.randrange(len(proof))] ^= 1 << ( + random.randrange(8) + ) + success = dleq_verify_proof(A, B, C, bytes(proof_damaged)) + self.assertFalse(success) diff --git a/bip-0375/reference.py b/bip-0375/reference.py new file mode 100644 index 0000000000..1c0e21eaac --- /dev/null +++ b/bip-0375/reference.py @@ -0,0 +1,772 @@ +#!/usr/bin/env python3 +""" +BIP 375: Sending Silent Payments with PSBTs - Reference Implementation + +This module implements the core functionality for creating and processing +PSBT v2 transactions with silent payment outputs as specified in BIP 375. + +Requires: +- BIP 352 (Silent Payments) +- BIP 370 (PSBT v2) +- BIP 374 (DLEQ Proofs) +""" + +import base64 +import struct +from typing import Dict, List, Tuple, Optional + +# External dependencies for cryptographic operations +from dleq_374 import dleq_verify_proof +from secp256k1_374 import GE +from bip352_utils import ( + compute_bip352_output_script, + is_p2wpkh, + is_p2sh, + is_eligible_input_type, + parse_non_witness_utxo, +) + + +# Minimal PSBT field type constants for reference implementation +class PSBTFieldType: + """Minimal BIP 375 field types needed for reference validator""" + + # Global fields (required for validation) + PSBT_GLOBAL_TX_VERSION = 0x02 + PSBT_GLOBAL_INPUT_COUNT = 0x04 + PSBT_GLOBAL_OUTPUT_COUNT = 0x05 + PSBT_GLOBAL_VERSION = 0xFB + PSBT_GLOBAL_SP_ECDH_SHARE = 0x07 + PSBT_GLOBAL_SP_DLEQ = 0x08 + + # Input fields (required for validation) + PSBT_IN_NON_WITNESS_UTXO = 0x00 + PSBT_IN_WITNESS_UTXO = 0x01 + PSBT_IN_PARTIAL_SIG = 0x02 + PSBT_IN_SIGHASH_TYPE = 0x03 + PSBT_IN_REDEEM_SCRIPT = 0x04 + PSBT_IN_BIP32_DERIVATION = 0x06 + PSBT_IN_PREVIOUS_TXID = 0x0E + PSBT_IN_OUTPUT_INDEX = 0x0F + PSBT_IN_TAP_INTERNAL_KEY = 0x17 + PSBT_IN_SP_ECDH_SHARE = 0x1D + PSBT_IN_SP_DLEQ = 0x1E + + # Output fields (required for validation) + PSBT_OUT_AMOUNT = 0x03 + PSBT_OUT_SCRIPT = 0x04 + PSBT_OUT_SP_V0_INFO = 0x09 + PSBT_OUT_SP_V0_LABEL = 0x0A + + +def validate_bip375_psbt( + psbt_data: bytes, input_keys: List[Dict] = None +) -> Tuple[bool, str]: + """Validate a PSBT according to BIP 375 rules""" + + # Basic PSBT structure validation + if len(psbt_data) < 5 or psbt_data[:5] != b"psbt\xff": + return False, "Invalid PSBT magic" + + # Parse PSBT fields + global_fields, input_maps, output_maps = parse_psbt_structure(psbt_data) + + # Check if silent payment outputs exist + # Either SP_V0_INFO or SP_V0_LABEL indicates silent payment intent + has_silent_outputs = any( + PSBTFieldType.PSBT_OUT_SP_V0_INFO in output_fields + or PSBTFieldType.PSBT_OUT_SP_V0_LABEL in output_fields + for output_fields in output_maps + ) + + if not has_silent_outputs: + # If no silent payment outputs, this is just a regular PSBT v2 + return True, "Valid PSBT v2 (no silent payments)" + + # Critical structural validation - SP_V0_INFO field sizes and PSBT_OUT_SCRIPT requirements + for i, output_fields in enumerate(output_maps): + # BIP375: Each output must have either PSBT_OUT_SCRIPT or PSBT_OUT_SP_V0_INFO (or both) + has_script = PSBTFieldType.PSBT_OUT_SCRIPT in output_fields + has_sp_info = PSBTFieldType.PSBT_OUT_SP_V0_INFO in output_fields + has_sp_label = PSBTFieldType.PSBT_OUT_SP_V0_LABEL in output_fields + + if not has_script and not has_sp_info: + return ( + False, + f"Output {i} must have either PSBT_OUT_SCRIPT or PSBT_OUT_SP_V0_INFO", + ) + + # PSBT_OUT_SP_V0_LABEL requires PSBT_OUT_SP_V0_INFO + if has_sp_label and not has_sp_info: + return ( + False, + f"Output {i} has PSBT_OUT_SP_V0_LABEL but missing PSBT_OUT_SP_V0_INFO", + ) + + if has_sp_info: + sp_info = output_fields[PSBTFieldType.PSBT_OUT_SP_V0_INFO] + if len(sp_info) != 66: # 33 + 33 bytes for scan_key + spend_key + return ( + False, + f"Output {i} SP_V0_INFO has wrong size ({len(sp_info)} bytes, expected 66)", + ) + + # ECDH shares must exist + has_global_ecdh = PSBTFieldType.PSBT_GLOBAL_SP_ECDH_SHARE in global_fields + has_input_ecdh = any( + PSBTFieldType.PSBT_IN_SP_ECDH_SHARE in input_fields + for input_fields in input_maps + ) + + if not has_global_ecdh and not has_input_ecdh: + return False, "Silent payment outputs present but no ECDH shares found" + + # Cannot have both global and per-input ECDH shares for same scan key + if has_global_ecdh and has_input_ecdh: + # Extract scan key from global ECDH share + global_ecdh_field = global_fields[PSBTFieldType.PSBT_GLOBAL_SP_ECDH_SHARE] + global_scan_key = global_ecdh_field["key"] + + # Check if any input has ECDH share for the same scan key + for i, input_fields in enumerate(input_maps): + if PSBTFieldType.PSBT_IN_SP_ECDH_SHARE in input_fields: + input_ecdh_field = input_fields[PSBTFieldType.PSBT_IN_SP_ECDH_SHARE] + input_scan_key = input_ecdh_field["key"] + if input_scan_key == global_scan_key: + return ( + False, + "Cannot have both global and per-input ECDH shares for same scan key", + ) + + # DLEQ proofs must exist for ECDH shares + if has_global_ecdh: + has_global_dleq = PSBTFieldType.PSBT_GLOBAL_SP_DLEQ in global_fields + if not has_global_dleq: + return False, "Global ECDH share present but missing DLEQ proof" + + if has_input_ecdh: + for i, input_fields in enumerate(input_maps): + if PSBTFieldType.PSBT_IN_SP_ECDH_SHARE in input_fields: + if PSBTFieldType.PSBT_IN_SP_DLEQ not in input_fields: + return False, f"Input {i} has ECDH share but missing DLEQ proof" + + # Verify DLEQ proofs + if has_global_ecdh: + if not validate_global_dleq_proof(global_fields, input_maps, input_keys): + return False, "Global DLEQ proof verification failed" + + if has_input_ecdh: + for i, input_fields in enumerate(input_maps): + if PSBTFieldType.PSBT_IN_SP_ECDH_SHARE in input_fields: + if not validate_input_dleq_proof(input_fields, input_keys, i): + return False, f"Input {i} DLEQ proof verification failed" + + # Segwit version restrictions + for i, input_fields in enumerate(input_maps): + if PSBTFieldType.PSBT_IN_WITNESS_UTXO in input_fields: + witness_utxo = input_fields[PSBTFieldType.PSBT_IN_WITNESS_UTXO] + if check_invalid_segwit_version(witness_utxo): + return False, f"Input {i} uses segwit version > 1 with silent payments" + + # Eligible input type requirement + # When silent payment outputs exist, ALL inputs must be eligible types + for i, input_fields in enumerate(input_maps): + script_pubkey = None + + # Try WITNESS_UTXO first (segwit inputs) + if PSBTFieldType.PSBT_IN_WITNESS_UTXO in input_fields: + witness_utxo = input_fields[PSBTFieldType.PSBT_IN_WITNESS_UTXO] + # Extract scriptPubKey from witness_utxo (skip 8-byte amount + 1-byte length) + if len(witness_utxo) >= 9: + script_len = witness_utxo[8] + script_pubkey = witness_utxo[9 : 9 + script_len] + + # Try NON_WITNESS_UTXO for legacy inputs (P2PKH, P2SH) + elif PSBTFieldType.PSBT_IN_NON_WITNESS_UTXO in input_fields: + non_witness_utxo = input_fields[PSBTFieldType.PSBT_IN_NON_WITNESS_UTXO] + # Get the output index from PSBT_IN_OUTPUT_INDEX field + if PSBTFieldType.PSBT_IN_OUTPUT_INDEX in input_fields: + output_index_bytes = input_fields[PSBTFieldType.PSBT_IN_OUTPUT_INDEX] + if len(output_index_bytes) == 4: + output_index = struct.unpack("= 4: + sighash_type = struct.unpack(" Tuple[Dict[int, bytes], List[Dict[int, bytes]], List[Dict[int, bytes]]]: + """Parse PSBT structure into global, input, and output field maps""" + + def parse_compact_size_uint(data: bytes, offset: int) -> Tuple[int, int]: + """Parse compact size uint and return (value, new_offset)""" + if offset >= len(data): + raise ValueError("Not enough data") + + first_byte = data[offset] + if first_byte < 0xFD: + return first_byte, offset + 1 + elif first_byte == 0xFD: + return struct.unpack(" Tuple[Dict[int, bytes], int]: + """Parse a PSBT section and return (field_map, new_offset)""" + fields = {} + + while offset < len(data): + # Read key length + key_len, offset = parse_compact_size_uint(data, offset) + if key_len == 0: # End of section + break + + # Read key data + if offset + key_len > len(data): + raise ValueError("Truncated key data") + key_data = data[offset : offset + key_len] + offset += key_len + + # Read value length + value_len, offset = parse_compact_size_uint(data, offset) + + # Read value data + if offset + value_len > len(data): + raise ValueError("Truncated value data") + value_data = data[offset : offset + value_len] + offset += value_len + + # Extract field type and handle key-value pairs + if key_data: + field_type = key_data[0] + key_content = key_data[1:] if len(key_data) > 1 else b"" + + # For BIP 375 and BIP-174 key-value fields, store both key and value + if field_type in [ + PSBTFieldType.PSBT_GLOBAL_SP_ECDH_SHARE, + PSBTFieldType.PSBT_GLOBAL_SP_DLEQ, + PSBTFieldType.PSBT_IN_SP_ECDH_SHARE, + PSBTFieldType.PSBT_IN_SP_DLEQ, + PSBTFieldType.PSBT_IN_BIP32_DERIVATION, + PSBTFieldType.PSBT_IN_PARTIAL_SIG, + ]: + fields[field_type] = {"key": key_content, "value": value_data} + else: + # For standard PSBT fields, just store value + fields[field_type] = value_data + + return fields, offset + + if len(psbt_data) < 5 or psbt_data[:5] != b"psbt\xff": + raise ValueError("Invalid PSBT magic") + + offset = 5 + + # Parse global section + global_fields, offset = parse_section(psbt_data, offset) + + # Determine number of inputs and outputs (standard PSBT fields) + num_inputs = ( + global_fields.get(PSBTFieldType.PSBT_GLOBAL_INPUT_COUNT, b"\x00")[0] + if PSBTFieldType.PSBT_GLOBAL_INPUT_COUNT in global_fields + else 1 + ) + num_outputs = ( + global_fields.get(PSBTFieldType.PSBT_GLOBAL_OUTPUT_COUNT, b"\x00")[0] + if PSBTFieldType.PSBT_GLOBAL_OUTPUT_COUNT in global_fields + else 1 + ) + + # Parse input sections + input_maps = [] + for _ in range(num_inputs): + input_fields, offset = parse_section(psbt_data, offset) + input_maps.append(input_fields) + + # Parse output sections + output_maps = [] + for _ in range(num_outputs): + output_fields, offset = parse_section(psbt_data, offset) + output_maps.append(output_fields) + + return global_fields, input_maps, output_maps + + +def extract_dleq_components( + dleq_field: Dict, ecdh_field: Dict +) -> Tuple[bytes, bytes, bytes]: + """Extract and validate DLEQ proof components from PSBT fields""" + + # Extract key and value components + proof = dleq_field["value"] + dleq_scan_key_bytes = dleq_field["key"] + ecdh_share_bytes = ecdh_field["value"] + ecdh_scan_key_bytes = ecdh_field["key"] + + # Validate proof length + if len(proof) != 64: + raise ValueError(f"Invalid DLEQ proof length: {len(proof)} bytes (expected 64)") + + # Validate BIP 375 key-value structure + if len(ecdh_scan_key_bytes) != 33: + raise ValueError( + f"Invalid ECDH scan key length: {len(ecdh_scan_key_bytes)} bytes (expected 33)" + ) + if len(ecdh_share_bytes) != 33: + raise ValueError( + f"Invalid ECDH share length: {len(ecdh_share_bytes)} bytes (expected 33)" + ) + if len(dleq_scan_key_bytes) != 33: + raise ValueError( + f"Invalid DLEQ scan key length: {len(dleq_scan_key_bytes)} bytes (expected 33)" + ) + + # Verify scan keys match between ECDH and DLEQ fields + if ecdh_scan_key_bytes != dleq_scan_key_bytes: + raise ValueError("Scan key mismatch between ECDH and DLEQ fields") + + return proof, ecdh_scan_key_bytes, ecdh_share_bytes + + +def validate_global_dleq_proof( + global_fields: Dict[int, bytes], + input_maps: List[Dict[int, bytes]] = None, + input_keys: List[Dict] = None, +) -> bool: + """Validate global DLEQ proof using BIP 374 implementation""" + + if PSBTFieldType.PSBT_GLOBAL_SP_DLEQ not in global_fields: + return False + if PSBTFieldType.PSBT_GLOBAL_SP_ECDH_SHARE not in global_fields: + return False + + # Extract and validate components + try: + proof, scan_key_bytes, ecdh_share_bytes = extract_dleq_components( + global_fields[PSBTFieldType.PSBT_GLOBAL_SP_DLEQ], + global_fields[PSBTFieldType.PSBT_GLOBAL_SP_ECDH_SHARE], + ) + except ValueError: + return False + + # Convert to GE points + B = GE.from_bytes(scan_key_bytes) # scan key + C = GE.from_bytes(ecdh_share_bytes) # ECDH result + + # For global ECDH shares, we need to combine all input public keys + # According to BIP 375: "Let A_n be the sum of the public keys A of all eligible inputs" + A_combined = None + + # Extract and combine public keys from PSBT fields (preferred, BIP-174 standard) + for input_fields in input_maps: + input_pubkey = get_pubkey_from_input(input_fields) + + if input_pubkey is not None: + if A_combined is None: + A_combined = input_pubkey + else: + A_combined = A_combined + input_pubkey + + if A_combined is None: + return False + + return dleq_verify_proof(A_combined, B, C, proof) + + +def validate_input_dleq_proof( + input_fields: Dict[int, bytes], + input_keys: List[Dict] = None, + input_index: int = None, +) -> bool: + """Validate input DLEQ proof using BIP 374 implementation""" + + if PSBTFieldType.PSBT_IN_SP_DLEQ not in input_fields: + return False + if PSBTFieldType.PSBT_IN_SP_ECDH_SHARE not in input_fields: + return False + + # Extract and validate components + try: + proof, scan_key_bytes, ecdh_share_bytes = extract_dleq_components( + input_fields[PSBTFieldType.PSBT_IN_SP_DLEQ], + input_fields[PSBTFieldType.PSBT_IN_SP_ECDH_SHARE], + ) + except ValueError: + return False + + # Convert to GE points + B = GE.from_bytes(scan_key_bytes) # scan key + C = GE.from_bytes(ecdh_share_bytes) # ECDH result + + # Extract input public key A from available sources + A = get_pubkey_from_input(input_fields) + + if A is None: + return False + + # Perform DLEQ verification + return dleq_verify_proof(A, B, C, proof) + + +def get_pubkey_from_input(input_fields: Dict[int, bytes]) -> Optional[GE]: + """Extract public key from PSBT input fields""" + # Try BIP32 derivation field (highest priority, BIP-174 standard) + if PSBTFieldType.PSBT_IN_BIP32_DERIVATION in input_fields: + derivation_data = input_fields[PSBTFieldType.PSBT_IN_BIP32_DERIVATION] + if isinstance(derivation_data, dict): + pubkey_candidate = derivation_data.get("key", b"") + if len(pubkey_candidate) == 33: + return GE.from_bytes(pubkey_candidate) + + return None + + +def check_invalid_segwit_version(witness_utxo: bytes) -> bool: + """Check if witness UTXO uses invalid segwit version for silent payments""" + + # Skip amount (8 bytes) and script length + if len(witness_utxo) < 9: + return False + + offset = 8 # Skip amount + script_len = witness_utxo[offset] + offset += 1 + + if offset + script_len > len(witness_utxo): + return False + + script = witness_utxo[offset : offset + script_len] + + # Check if it's segwit v2 or higher + if len(script) >= 2 and script[0] >= 0x52: # OP_2 or higher + return True + + return False + + +def run_test_case( + psbt_b64: str, + input_keys: List[Dict] = None, + expected_ecdh_shares: List[Dict] = None, +) -> Tuple[bool, str]: + """Run test case""" + + # Decode PSBT and run BIP 375 validation first + psbt_data = base64.b64decode(psbt_b64) + bip375_is_valid, bip375_error = validate_bip375_psbt(psbt_data, input_keys) + + # If BIP 375 validation fails, exit early + if not bip375_is_valid: + return False, bip375_error + + # Compute and verify BIP-352 output scripts + # This validates that output scripts match the silent payment derivation + if input_keys and expected_ecdh_shares: + global_fields, input_maps, output_maps = parse_psbt_structure(psbt_data) + + # Build outpoints list from PSBT inputs + outpoints = [] + for input_fields in input_maps: + if PSBTFieldType.PSBT_IN_PREVIOUS_TXID in input_fields: + txid = input_fields[PSBTFieldType.PSBT_IN_PREVIOUS_TXID] + output_index_bytes = input_fields.get(PSBTFieldType.PSBT_IN_OUTPUT_INDEX) + output_index = struct.unpack(" Dict: + """Load test vectors from JSON file""" + import json + import sys + + try: + with open(filename, "r") as f: + return json.load(f) + except FileNotFoundError: + print(f"Error: Test vector file '{filename}' not found") + sys.exit(1) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in test vector file: {e}") + sys.exit(1) + + +def parse_test(test_vector: Dict) -> tuple: + """Parse test vector""" + psbt_b64 = test_vector["psbt"] + input_keys = test_vector.get("input_keys", []) + expected_ecdh_shares = test_vector.get("expected_ecdh_shares", []) + + return psbt_b64, input_keys, expected_ecdh_shares + + +def run_tests(test_data: Dict, verbose: bool = False) -> None: + """Run all complete test cases""" + + print("BIP 375 Reference Implementation - Test Runner") + print("=" * 50) + print(f"Description: {test_data['description']}") + print(f"Version: {test_data['version']}") + print(f"Invalid test cases: {len(test_data['invalid'])}") + print(f"Valid test cases: {len(test_data['valid'])}") + + test_num = 1 + + # Run invalid test cases + print("\n=== Running Invalid Test Cases ===") + for test_case in test_data["invalid"]: + description = test_case["description"] + expected_error = test_case.get("comment", "unknown error") + + print(f"Test {test_num}: {description}") + + psbt_b64, input_keys, expected_ecdh_shares = parse_test(test_case) + + # Run the enhanced test case + is_valid, error_msg = run_test_case( + psbt_b64=psbt_b64, + input_keys=input_keys, + expected_ecdh_shares=expected_ecdh_shares, + ) + + assert not is_valid, error_msg + if verbose: + print(f" Comment: {expected_error}") + print(f" Details: {error_msg}") + test_num += 1 + + # Run valid test cases + print() + print("=== Running Valid Test Cases ===") + for test_case in test_data["valid"]: + description = test_case["description"] + + print(f"Test {test_num}: {description}") + if verbose: + print(f" Comment: {test_case.get('comment', '')}") + + psbt_b64, input_keys, expected_ecdh_shares = parse_test(test_case) + + # Run the enhanced test case + is_valid, error_msg = run_test_case( + psbt_b64=psbt_b64, + input_keys=input_keys, + expected_ecdh_shares=expected_ecdh_shares, + ) + + assert is_valid, error_msg + test_num += 1 + + print(f"\n✓ All {test_num - 1} tests passed") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="BIP 375 Reference Implementation - Test Runner", + ) + parser.add_argument( + "--test-file", + "-f", + default="test_vectors.json", + help="Test vector file to run (default: test_vectors.json)", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show detailed error messages and exception details", + ) + + args = parser.parse_args() + + # Load test vectors + test_data = load_test_vectors(args.test_file) + + # Run tests + run_tests(test_data, args.verbose) diff --git a/bip-0375/secp256k1_374.py b/bip-0375/secp256k1_374.py new file mode 100755 index 0000000000..b83d028f92 --- /dev/null +++ b/bip-0375/secp256k1_374.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2022-2023 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Test-only implementation of low-level secp256k1 field and group arithmetic + +It is designed for ease of understanding, not performance. + +WARNING: This code is slow and trivially vulnerable to side channel attacks. Do not use for +anything but tests. + +Exports: +* FE: class for secp256k1 field elements +* GE: class for secp256k1 group elements +* G: the secp256k1 generator point +""" + +import unittest +from hashlib import sha256 + +class FE: + """Objects of this class represent elements of the field GF(2**256 - 2**32 - 977). + + They are represented internally in numerator / denominator form, in order to delay inversions. + """ + + # The size of the field (also its modulus and characteristic). + SIZE = 2**256 - 2**32 - 977 + + def __init__(self, a=0, b=1): + """Initialize a field element a/b; both a and b can be ints or field elements.""" + if isinstance(a, FE): + num = a._num + den = a._den + else: + num = a % FE.SIZE + den = 1 + if isinstance(b, FE): + den = (den * b._num) % FE.SIZE + num = (num * b._den) % FE.SIZE + else: + den = (den * b) % FE.SIZE + assert den != 0 + if num == 0: + den = 1 + self._num = num + self._den = den + + def __add__(self, a): + """Compute the sum of two field elements (second may be int).""" + if isinstance(a, FE): + return FE(self._num * a._den + self._den * a._num, self._den * a._den) + return FE(self._num + self._den * a, self._den) + + def __radd__(self, a): + """Compute the sum of an integer and a field element.""" + return FE(a) + self + + def __sub__(self, a): + """Compute the difference of two field elements (second may be int).""" + if isinstance(a, FE): + return FE(self._num * a._den - self._den * a._num, self._den * a._den) + return FE(self._num - self._den * a, self._den) + + def __rsub__(self, a): + """Compute the difference of an integer and a field element.""" + return FE(a) - self + + def __mul__(self, a): + """Compute the product of two field elements (second may be int).""" + if isinstance(a, FE): + return FE(self._num * a._num, self._den * a._den) + return FE(self._num * a, self._den) + + def __rmul__(self, a): + """Compute the product of an integer with a field element.""" + return FE(a) * self + + def __truediv__(self, a): + """Compute the ratio of two field elements (second may be int).""" + return FE(self, a) + + def __pow__(self, a): + """Raise a field element to an integer power.""" + return FE(pow(self._num, a, FE.SIZE), pow(self._den, a, FE.SIZE)) + + def __neg__(self): + """Negate a field element.""" + return FE(-self._num, self._den) + + def __int__(self): + """Convert a field element to an integer in range 0..p-1. The result is cached.""" + if self._den != 1: + self._num = (self._num * pow(self._den, -1, FE.SIZE)) % FE.SIZE + self._den = 1 + return self._num + + def sqrt(self): + """Compute the square root of a field element if it exists (None otherwise). + + Due to the fact that our modulus is of the form (p % 4) == 3, the Tonelli-Shanks + algorithm (https://en.wikipedia.org/wiki/Tonelli-Shanks_algorithm) is simply + raising the argument to the power (p + 1) / 4. + + To see why: (p-1) % 2 = 0, so 2 divides the order of the multiplicative group, + and thus only half of the non-zero field elements are squares. An element a is + a (nonzero) square when Euler's criterion, a^((p-1)/2) = 1 (mod p), holds. We're + looking for x such that x^2 = a (mod p). Given a^((p-1)/2) = 1, that is equivalent + to x^2 = a^(1 + (p-1)/2) mod p. As (1 + (p-1)/2) is even, this is equivalent to + x = a^((1 + (p-1)/2)/2) mod p, or x = a^((p+1)/4) mod p.""" + v = int(self) + s = pow(v, (FE.SIZE + 1) // 4, FE.SIZE) + if s**2 % FE.SIZE == v: + return FE(s) + return None + + def is_square(self): + """Determine if this field element has a square root.""" + # A more efficient algorithm is possible here (Jacobi symbol). + return self.sqrt() is not None + + def is_even(self): + """Determine whether this field element, represented as integer in 0..p-1, is even.""" + return int(self) & 1 == 0 + + def __eq__(self, a): + """Check whether two field elements are equal (second may be an int).""" + if isinstance(a, FE): + return (self._num * a._den - self._den * a._num) % FE.SIZE == 0 + return (self._num - self._den * a) % FE.SIZE == 0 + + def to_bytes(self): + """Convert a field element to a 32-byte array (BE byte order).""" + return int(self).to_bytes(32, 'big') + + @staticmethod + def from_bytes(b): + """Convert a 32-byte array to a field element (BE byte order, no overflow allowed).""" + v = int.from_bytes(b, 'big') + if v >= FE.SIZE: + return None + return FE(v) + + def __str__(self): + """Convert this field element to a 64 character hex string.""" + return f"{int(self):064x}" + + def __repr__(self): + """Get a string representation of this field element.""" + return f"FE(0x{int(self):x})" + + +class GE: + """Objects of this class represent secp256k1 group elements (curve points or infinity) + + Normal points on the curve have fields: + * x: the x coordinate (a field element) + * y: the y coordinate (a field element, satisfying y^2 = x^3 + 7) + * infinity: False + + The point at infinity has field: + * infinity: True + """ + + # Order of the group (number of points on the curve, plus 1 for infinity) + ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + # Number of valid distinct x coordinates on the curve. + ORDER_HALF = ORDER // 2 + + def __init__(self, x=None, y=None): + """Initialize a group element with specified x and y coordinates, or infinity.""" + if x is None: + # Initialize as infinity. + assert y is None + self.infinity = True + else: + # Initialize as point on the curve (and check that it is). + fx = FE(x) + fy = FE(y) + assert fy**2 == fx**3 + 7 + self.infinity = False + self.x = fx + self.y = fy + + def __add__(self, a): + """Add two group elements together.""" + # Deal with infinity: a + infinity == infinity + a == a. + if self.infinity: + return a + if a.infinity: + return self + if self.x == a.x: + if self.y != a.y: + # A point added to its own negation is infinity. + assert self.y + a.y == 0 + return GE() + else: + # For identical inputs, use the tangent (doubling formula). + lam = (3 * self.x**2) / (2 * self.y) + else: + # For distinct inputs, use the line through both points (adding formula). + lam = (self.y - a.y) / (self.x - a.x) + # Determine point opposite to the intersection of that line with the curve. + x = lam**2 - (self.x + a.x) + y = lam * (self.x - x) - self.y + return GE(x, y) + + @staticmethod + def mul(*aps): + """Compute a (batch) scalar group element multiplication. + + GE.mul((a1, p1), (a2, p2), (a3, p3)) is identical to a1*p1 + a2*p2 + a3*p3, + but more efficient.""" + # Reduce all the scalars modulo order first (so we can deal with negatives etc). + naps = [(a % GE.ORDER, p) for a, p in aps] + # Start with point at infinity. + r = GE() + # Iterate over all bit positions, from high to low. + for i in range(255, -1, -1): + # Double what we have so far. + r = r + r + # Add then add the points for which the corresponding scalar bit is set. + for (a, p) in naps: + if (a >> i) & 1: + r += p + return r + + def __rmul__(self, a): + """Multiply an integer with a group element.""" + if self == G: + return FAST_G.mul(a) + return GE.mul((a, self)) + + def __neg__(self): + """Compute the negation of a group element.""" + if self.infinity: + return self + return GE(self.x, -self.y) + + def __sub__(self, a): + """Subtract a group element from another.""" + return self + (-a) + + def to_bytes_compressed(self): + """Convert a non-infinite group element to 33-byte compressed encoding.""" + assert not self.infinity + return bytes([3 - self.y.is_even()]) + self.x.to_bytes() + + def to_bytes_uncompressed(self): + """Convert a non-infinite group element to 65-byte uncompressed encoding.""" + assert not self.infinity + return b'\x04' + self.x.to_bytes() + self.y.to_bytes() + + def to_bytes_xonly(self): + """Convert (the x coordinate of) a non-infinite group element to 32-byte xonly encoding.""" + assert not self.infinity + return self.x.to_bytes() + + @staticmethod + def lift_x(x): + """Return group element with specified field element as x coordinate (and even y).""" + y = (FE(x)**3 + 7).sqrt() + if y is None: + return None + if not y.is_even(): + y = -y + return GE(x, y) + + @staticmethod + def from_bytes(b): + """Convert a compressed or uncompressed encoding to a group element.""" + assert len(b) in (33, 65) + if len(b) == 33: + if b[0] != 2 and b[0] != 3: + return None + x = FE.from_bytes(b[1:]) + if x is None: + return None + r = GE.lift_x(x) + if r is None: + return None + if b[0] == 3: + r = -r + return r + else: + if b[0] != 4: + return None + x = FE.from_bytes(b[1:33]) + y = FE.from_bytes(b[33:]) + if y**2 != x**3 + 7: + return None + return GE(x, y) + + @staticmethod + def from_bytes_xonly(b): + """Convert a point given in xonly encoding to a group element.""" + assert len(b) == 32 + x = FE.from_bytes(b) + if x is None: + return None + return GE.lift_x(x) + + @staticmethod + def is_valid_x(x): + """Determine whether the provided field element is a valid X coordinate.""" + return (FE(x)**3 + 7).is_square() + + def __str__(self): + """Convert this group element to a string.""" + if self.infinity: + return "(inf)" + return f"({self.x},{self.y})" + + def __repr__(self): + """Get a string representation for this group element.""" + if self.infinity: + return "GE()" + return f"GE(0x{int(self.x):x},0x{int(self.y):x})" + +# The secp256k1 generator point +G = GE.lift_x(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798) + + +class FastGEMul: + """Table for fast multiplication with a constant group element. + + Speed up scalar multiplication with a fixed point P by using a precomputed lookup table with + its powers of 2: + + table = [P, 2*P, 4*P, (2^3)*P, (2^4)*P, ..., (2^255)*P] + + During multiplication, the points corresponding to each bit set in the scalar are added up, + i.e. on average ~128 point additions take place. + """ + + def __init__(self, p): + self.table = [p] # table[i] = (2^i) * p + for _ in range(255): + p = p + p + self.table.append(p) + + def mul(self, a): + result = GE() + a = a % GE.ORDER + for bit in range(a.bit_length()): + if a & (1 << bit): + result += self.table[bit] + return result + +# Precomputed table with multiples of G for fast multiplication +FAST_G = FastGEMul(G) + +class TestFrameworkSecp256k1(unittest.TestCase): + def test_H(self): + H = sha256(G.to_bytes_uncompressed()).digest() + assert GE.lift_x(FE.from_bytes(H)) is not None + self.assertEqual(H.hex(), "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0") diff --git a/bip-0375/test_vectors.json b/bip-0375/test_vectors.json new file mode 100644 index 0000000000..6d15007200 --- /dev/null +++ b/bip-0375/test_vectors.json @@ -0,0 +1,769 @@ +{ + "description": "BIP 375 Test Vectors - All Scenarios", + "version": "1.0", + "format_notes": [ + "All keys are hex-encoded", + "PSBTs have all necessary fields", + "Test vectors are organized into 'invalid' and 'valid' arrays", + "Comment provides additional context for all test cases" + ], + "invalid": [ + { + "description": "Missing DLEQ proof for ECDH share", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCrtYht20vGCALx8ZiisSkDZZzJ7nPgIx1FVehBiNyWQAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/wEDBAEAAAAAAQMIGHMBAAAAAAABBCJRIBe7uqUS4DD7yj7lFPeyOAfhEYMaR0VEcJE0yfm/RrbEAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "abb5886ddb4bc60802f1f198a2b12903659cc9ee73e0231d4555e84188dc9640", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": null, + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "512017bbbaa512e030fbca3ee514f7b23807e111831a474544709134c9f9bf46b6c4", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "PSBT_IN_SP_ECDH_SHARE without corresponding PSBT_IN_SP_DLEQ" + }, + { + "description": "Invalid DLEQ proof", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiBJpgB4JAmRsNt6srOq9JqFCEYGmw7mPo8/4lJ2+/nPoAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIeAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QIHV1pvvebv/E/qjiAN4whI/xJQnstvRxMFrJvVYN5hmLW7l6QbgreDug7YKivXVjkDvnwa9fLacXS3HUTu7/mEBAwQBAAAAAAEDCBhzAQAAAAAAAQQiUSCy5HLRM5RYz18oPOTQydTDNj9R1Kn1KSQfhJ3O1Ft10AEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "49a60078240991b0db7ab2b3aaf49a850846069b0ee63e8f3fe25276fbf9cfa0", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "81d5d69bef79bbff13faa3880378c2123fc49427b2dbd1c4c16b26f5583798662d6ee5e906e0ade0ee83b60a8af5d58e40ef9f06bd7cb69c5d2dc7513bbbfe61", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120b2e472d1339458cf5f283ce4d0c9d4c3363f51d4a9f529241f849dced45b75d0", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "DLEQ proof verification failed" + }, + { + "description": "Non-SIGHASH_ALL signature with silent payments", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCfF8iU72tyS4+7lJj0TTc8KFCARRz/QDHgOTLIuPm2twEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAIAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EB82fZvBIlT42he3KPxdpkVJZi87KRvIU7SuM39pJ22oAePiUtm39mKZwbmhzrySfXfZOzJhETpE3KXfZb62CtDAAEDCBhzAQAAAAAAAQQiUSB0TgmVHxe1gkg8egwIwGJv6sNmaTyE+vOeA0thLejccQEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "9f17c894ef6b724b8fbb9498f44d373c285080451cff4031e03932c8b8f9b6b7", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "7cd9f66f048953e3685edca3f17699152598bceca46f214ed2b8cdfda49db6a0078f894b66dfd98a6706e6873af249f5df64ecc98444e91372977d96fad82b43", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120744e09951f17b582483c7a0c08c0626feac366693c84faf39e034b612de8dc71", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "Silent payment outputs require SIGHASH_ALL signatures only" + }, + { + "description": "Mixed segwit versions with silent payments", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCuv6p7LnQnsTi6F7UesBJ3TJOXBoHuPhb2Y3hjUklAJAEPBAAAAAABASughgEAAAAAACJSIIYiz2yIzTx5hSOFG+T1WYNgEYY+sDBYObtzXueh9KPpARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCBhzAQAAAAAAAQQiUSAhnr95bXw7ml6Di0tr4pd8dOuNav5vT1iABrPWw2HrfAEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "aebfaa7b2e7427b138ba17b51eb012774c93970681ee3e16f663786352494024", + "prevout_index": 0, + "prevout_scriptpubkey": "52208622cf6c88cd3c798523851be4f559836011863eb0305839bb735ee7a1f4a3e9", + "amount": 100000, + "witness_utxo": "a0860100000000002252208622cf6c88cd3c798523851be4f559836011863eb0305839bb735ee7a1f4a3e9", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120219ebf796d7c3b9a5e838b4b6be2977c74eb8d6afe6f4f588006b3d6c361eb7c", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "mixed_segwit" + }, + { + "description": "Silent payment outputs but no ECDH shares", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiAkKwMCMdEgYFJ2eRmpLxfpzUNydadt47rD+2VnyyekwwEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAAAQMIGHMBAAAAAAABBCJRIATj08VSBMBgg9/eWjaW0ZzOJ28erl5eCzDe6FqZrylWAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "242b030231d1206052767919a92f17e9cd437275a76de3bac3fb6567cb27a4c3", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "512004e3d3c55204c06083dfde5a3696d19cce276f1eae5e5e0b30dee85a99af2956", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "no_ecdh_shares" + }, + { + "description": "Global ECDH share without DLEQ proof", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/wABDiD2W3/BmfoPsrzNsfoGEte1D2K0+PqV1WXEoB9MWC6SpAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAAAQMIGHMBAAAAAAABBCJRIIEAAjnkcLtSN9n6vrAZ4nmPxFcueck5C10dXjYbt+AgAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "f65b7fc199fa0fb2bccdb1fa0612d7b50f62b4f8fa95d565c4a01f4c582e92a4", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": null, + "is_global": true, + "input_index": null + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "512081000239e470bb5237d9fabeb019e2798fc4572e79c9390b5d1d5e361bb7e020", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "missing_global_dleq" + }, + { + "description": "Wrong SP_V0_INFO field size", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiBhb07a0EqylXgp4bK5tBHX2y2Sc2xQAy4NalHiyZy13gEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCBhzAQAAAAAAAQQiUSCrH00eEXl4YXYOd4rbbMFm+Wq2HdGvh5nmUfQVMiHskwEJQQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "616f4edad04ab2957829e1b2b9b411d7db2d92736c50032e0d6a51e2c99cb5de", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120ab1f4d1e11797861760e778adb6cc166f96ab61dd1af8799e651f4153221ec93", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde4921144", + "sp_label": null + } + ], + "comment": "wrong_sp_info_size" + }, + { + "description": "Mixed eligible and ineligible input types", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAIAAAABBQQBAAAAAQYBAwABDiApw6Peut1f6dS0XD295x0CWioK+UM7tO+6i0Pv5za0oQEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIeAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gtsz2qm3pd5sGLvsbwVMcZaXnsq6QVaZiqwVAbw30R9QBDwQAAAAAARAE/v///wEAUwIAAAAB5D9MfaDZ74MIM0F6Sy1pXgWgdanUwFY3tfzq3xlflbIAAAAAAP////8B8EkCAAAAAAAXqRRjd6+3VLmKdhIZzxZ2/JLCN4RQtocAAAAAAQRHUiECjx0ILWAB+kuomaQD2vmx2wG+klwiUzr2MO5kk7C+n3khAzWJfsWucE/lXY6l1Gewrr2zeZvhDDYEcdm3icgRr947Uq4AAQMIkF8BAAAAAAABBCJRIOwCLPQ2taebtxfBsueBADxLQswPLMCEEteHBizKo0eDAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "29c3a3debadd5fe9d4b45c3dbde71d025a2a0af9433bb4efba8b43efe736b4a1", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + }, + { + "input_index": 1, + "private_key": "b0d095ddc9d046be37f1f083e2993b2c7ace820a0696c730d930a4eba61fc056", + "public_key": "028f1d082d6001fa4ba899a403daf9b1db01be925c22533af630ee6493b0be9f79", + "prevout_txid": "b6ccf6aa6de9779b062efb1bc1531c65a5e7b2ae9055a662ab05406f0df447d4", + "prevout_index": 0, + "prevout_scriptpubkey": "a9146377afb754b98a761219cf1676fc92c2378450b687", + "amount": 150000, + "witness_utxo": "0200000001e43f4c7da0d9ef830833417a4b2d695e05a075a9d4c05637b5fceadf195f95b20000000000ffffffff01f04902000000000017a9146377afb754b98a761219cf1676fc92c2378450b68700000000", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 90000, + "script": "5120ec022cf436b5a79bb717c1b2e781003c4b42cc0f2cc08412d787062ccaa34783", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "P2WPKH and P2SH multisig mixed - only P2WPKH is eligible" + }, + { + "description": "Wrong ECDH share size", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiC1ovhHRrNOgzq0ftE7S5hwtBwhRvo0a3NKnEftiiROwwEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CACVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5iwABAwgYcwEAAAAAAAEEIlEggDCSSC8mKdKdmFkQsILGjKIHwO1APY2HsBzp079x+AEBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "b5a2f84746b34e833ab47ed13b4b9870b41c2146fa346b734a9c47ed8a244ec3", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120803092482f2629d29d985910b082c68ca207c0ed403d8d87b01ce9d3bf71f801", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "ECDH share must be 33 bytes" + }, + { + "description": "Wrong DLEQ proof size", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiA78BpakVw0yvjzRht09tYBDcMoUSAE43p653aHic5dEwEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQMIGHMBAAAAAAABBCJRIO10/QbweN3s8W6gOiwTeFJjNgwtIQhX77ERqoAxaa37AQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "3bf01a5a915c34caf8f3461b74f6d6010dc328512004e37a7ae7768789ce5d13", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120ed74fd06f078ddecf16ea03a2c13785263360c2d210857efb111aa803169adfb", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "DLEQ proof must be 64 bytes" + }, + { + "description": "Label without SP_V0_INFO", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCYIQIrPQiMfZ8vxvALHE0JCEOOoHYl2kTD+2DBdIFEqgEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAABAwgYcwEAAAAAAAEEIlEgI8T3t3YqhXDgqZbvvMN5ruf8ijCuIW4yFjhEdJ4oNDsBCgQBAAAAAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "9821022b3d088c7d9f2fc6f00b1c4d0908438ea07625da44c3fb60c1748144aa", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [], + "expected_ecdh_shares": [], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "512023c4f7b7762a8570e0a996efbcc379aee7fc8a30ae216e32163844749e28343b", + "is_silent_payment": false, + "sp_info": null, + "sp_label": null + } + ], + "comment": "PSBT_OUT_SP_V0_LABEL requires PSBT_OUT_SP_V0_INFO" + }, + { + "description": "Address mismatch", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiBumss8ldbK7k1KciUCaeuPPQVy7U+E9Lf8M6mavj46BQEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCBhzAQAAAAAAAQQiUSDN452LBbSW+PGPILJ9CvmjIzMJ4EciGeCBP/O103h+KQEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "6e9acb3c95d6caee4d4a72250269eb8f3d0572ed4f84f4b7fc33a99abe3e3a05", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120cde39d8b05b496f8f18f20b27d0af9a3233309e0472219e0813ff3b5d3787e29", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "Output script doesn't match BIP-352 computed address" + }, + { + "description": "Both global and per-input ECDH shares", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gXPiMRKRM1SJjYb7RB1GUb5+HCVIpEeB3gV9ffweFr2kBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQABAwQBAAAAIh0C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPghAlUWTnkm1Q1SoJ/5kGR6XpXB2xv8aKYW+8LaITkn+Yv/Ih4C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPhAwdZ/OHiCv4F5FeoVgh1k1bODXB/AXFWM49gkkqF9kIwQSH27m93BCAyMLZwZcbJmCDxpBU4iGT8kQSisZnN+YAABAwgYcwEAAAAAAAEEIlEgS6fi6UvLAo37uSgZaLV+3Cn2cFex/LIX3bRQwxpRxwwBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "5cf88c44a44cd5226361bed10751946f9f8709522911e077815f5f7f0785af69", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": true, + "input_index": null + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "51204ba7e2e94bcb028dfbb9281968b57edc29f67057b1fcb217ddb450c31a51c70c", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "Cannot have both global and per-input ECDH shares for same scan key" + } + ], + "valid": [ + { + "description": "Single signer with global ECDH share", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gbprLPJXWyu5NSnIlAmnrjz0Fcu1PhPS3/DOpmr4+OgUBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQABAwQBAAAAAAEDCBhzAQAAAAAAAQQiUSCuGfvuJzChqVLX0lmMxwP93zuXKyUUix7Rp5roc51eBwEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA=", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "6e9acb3c95d6caee4d4a72250269eb8f3d0572ed4f84f4b7fc33a99abe3e3a05", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": true, + "input_index": null + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "5120ae19fbee2730a1a952d7d2598cc703fddf3b972b25148b1ed1a79ae8739d5e07", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "One entity controls all inputs, uses global approach for efficiency" + }, + { + "description": "Multi-party with per-input ECDH shares", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAIAAAABBQQBAAAAAQYBAwABDiBc+IxEpEzVImNhvtEHUZRvn4cJUikR4HeBX19/B4WvaQEPBAAAAAABAR9QwwAAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEOIBPS7rrZoQmRR0EMhqb+CyDunbdZ2kHP1O6Xks1QEjcVAQ8EAAAAAAEBH1DDAAAAAAAAFgAUQhxxWu35g68OO2dv98SU0QVPzboBEAT+////IgYCjx0ILWAB+kuomaQD2vmx2wG+klwiUzr2MO5kk7C+n3kAAQMEAQAAACIdAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQNb6DQQKcckf6K5ONXUuFs6afvZMXutha9leZJx3LQoASIeAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMD+LMxS37uSW9P/ZUt6ltjsDgMaZ9BBtPmcsOgRSeKHqCvT1RvA/3M6zn0yq2PWEhhlRXeAj7LH+wbSvgb/OrYAAQMIGHMBAAAAAAABBCJRIAvcah2ruHUXZ4wrX33N/yl23F0RNcTdk/ZnI+vvuY4lAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "5cf88c44a44cd5226361bed10751946f9f8709522911e077815f5f7f0785af69", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 50000, + "witness_utxo": "50c3000000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + }, + { + "input_index": 1, + "private_key": "b0d095ddc9d046be37f1f083e2993b2c7ace820a0696c730d930a4eba61fc056", + "public_key": "028f1d082d6001fa4ba899a403daf9b1db01be925c22533af630ee6493b0be9f79", + "prevout_txid": "13d2eebad9a1099147410c86a6fe0b20ee9db759da41cfd4ee9792cd50123715", + "prevout_index": 0, + "prevout_scriptpubkey": "0014421c715aedf983af0e3b676ff7c494d1054fcdba", + "amount": 50000, + "witness_utxo": "50c3000000000000160014421c715aedf983af0e3b676ff7c494d1054fcdba", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": false, + "input_index": 0 + }, + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "035be8341029c7247fa2b938d5d4b85b3a69fbd9317bad85af65799271dcb42801", + "dleq_proof": "c0fe2ccc52dfbb925bd3ff654b7a96d8ec0e031a67d041b4f99cb0e81149e287a82bd3d51bc0ff733ace7d32ab63d61218654577808fb2c7fb06d2be06ff3ab6", + "is_global": false, + "input_index": 1 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 95000, + "script": "51200bdc6a1dabb87517678c2b5f7dcdff2976dc5d1135c4dd93f66723ebefb98e25", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "Two signers each contribute ECDH shares for their respective inputs" + }, + { + "description": "Silent payment with change detection", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQCAAAAAQYBAwABDiAlbK6m2hWAb7hW7a50mI1EDHqxtcCGHsgR0ZCSdHudHAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NAAEDBAEAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8iHgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+EDB1n84eIK/gXkV6hWCHWTVs4NcH8BcVYzj2CSSoX2QjBBIfbub3cEIDIwtnBlxsmYIPGkFTiIZPyRBKKxmc35gAAEDCFDDAAAAAAAAAQQiUSBVuRZLw33Ib1uJNhaCqjCItbP6U9rbQ+lfG9ETm7HANQEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AP1JENIUgFlZrxF0fpqXFoYYs3ZM/FchDtCcjgi9SUyNwEKBAEAAAAAAQMIyK8AAAAAAAABBBYAFOPDEMwq86xuYsrkvSPj7lK54clZIgID01f3wHGPJHjj/Y+MzCcp3djAzK6x8CsYGm9E1DufjY0MAAAAAAAAAAAAAAABAA==", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "256caea6da15806fb856edae74988d440c7ab1b5c0861ec811d19092747b9d1c", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": 1 + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 50000, + "script": "512055b9164bc37dc86f5b89361682aa3088b5b3fa53dadb43e95f1bd1139bb1c035", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f803f524434852016566bc45d1fa6a5c5a1862cdd933f15c843b42723822f5253237", + "sp_label": 1 + }, + { + "output_index": 1, + "amount": 45000, + "script": "0014e3c310cc2af3ac6e62cae4bd23e3ee52b9e1c959", + "is_silent_payment": false, + "sp_info": null, + "sp_label": null + } + ], + "comment": "Uses PSBT_OUT_SP_V0_LABEL and BIP32 derivation for change identification" + }, + { + "description": "Multiple silent payment outputs to same scan key", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQCAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gLHvTL/FQccCuAyc4ZKFDbIpWITVp4RMtz46nPsjDMiIBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQABAwQBAAAAAAEDCECcAAAAAAAAAQQiUSD7K3E6/VK6JHGBuZhBqk8siFW6332s4usuMFXaE+4rjAEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwABAwjY1gAAAAAAAAEEIlEgWaCp4b2YmHQgE1U4YO0LRLcm+8Ug6cVxvEZJXyR59hgBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA", + "input_keys": [ + { + "input_index": 0, + "private_key": "e75f1990643539150761b0dc9eceb2ec0cf8636883eea2112d991dec3567a7b9", + "public_key": "03d357f7c0718f2478e3fd8f8ccc2729ddd8c0ccaeb1f02b181a6f44d43b9f8d8d", + "prevout_txid": "2c7bd32ff15071c0ae03273864a1436c8a56213569e1132dcf8ea73ec8c33222", + "prevout_index": 0, + "prevout_scriptpubkey": "0014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "amount": 100000, + "witness_utxo": "a086010000000000160014f8daf76ad803b0efa95d6cff8aee0891d3af1c8c", + "sequence": 4294967295 + } + ], + "scan_keys": [ + { + "scan_pubkey": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "spend_pubkey": "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "label": null + } + ], + "expected_ecdh_shares": [ + { + "scan_key": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8", + "ecdh_result": "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff", + "dleq_proof": "c1d67f387882bf817915ea15821d64d5b3835c1fc05c558ce3d82492a17d908c10487dbb9bddc1080c8c2d9c1971b266083c69054e22193f244128ac66737e60", + "is_global": false, + "input_index": 0 + } + ], + "expected_outputs": [ + { + "output_index": 0, + "amount": 40000, + "script": "5120fb2b713afd52ba247181b99841aa4f2c8855badf7dace2eb2e3055da13ee2b8c", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + }, + { + "output_index": 1, + "amount": 55000, + "script": "512059a0a9e1bd9898742013553860ed0b44b726fbc520e9c571bc46495f2479f618", + "is_silent_payment": true, + "sp_info": "02d029ff96de2cbcf782be4359c48620ea92bcdd6bef032b95158b91a1693fb4f8024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b", + "sp_label": null + } + ], + "comment": "Two outputs to same silent payment address, different k values" + } + ] +} \ No newline at end of file From 0018e4fc0f7c199f10c62168a638e07fbf9ae49a Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:26:17 -0500 Subject: [PATCH 2/2] Add tests generation material --- bip-0375/README.md | 24 +- bip-0375/tests/psbt_sp/__init__.py | 113 ++ bip-0375/tests/psbt_sp/bip352_crypto.py | 144 +++ bip-0375/tests/psbt_sp/constants.py | 85 ++ bip-0375/tests/psbt_sp/crypto.py | 435 +++++++ bip-0375/tests/psbt_sp/psbt.py | 1511 ++++++++++++++++++++++ bip-0375/tests/psbt_sp/psbt_io.py | 78 ++ bip-0375/tests/psbt_sp/psbt_utils.py | 206 +++ bip-0375/tests/psbt_sp/roles.py | 1117 +++++++++++++++++ bip-0375/tests/psbt_sp/serialization.py | 238 ++++ bip-0375/tests/test_generator.py | 1521 +++++++++++++++++++++++ 11 files changed, 5471 insertions(+), 1 deletion(-) create mode 100644 bip-0375/tests/psbt_sp/__init__.py create mode 100644 bip-0375/tests/psbt_sp/bip352_crypto.py create mode 100644 bip-0375/tests/psbt_sp/constants.py create mode 100644 bip-0375/tests/psbt_sp/crypto.py create mode 100644 bip-0375/tests/psbt_sp/psbt.py create mode 100644 bip-0375/tests/psbt_sp/psbt_io.py create mode 100644 bip-0375/tests/psbt_sp/psbt_utils.py create mode 100644 bip-0375/tests/psbt_sp/roles.py create mode 100644 bip-0375/tests/psbt_sp/serialization.py create mode 100644 bip-0375/tests/test_generator.py diff --git a/bip-0375/README.md b/bip-0375/README.md index f15bb921a0..564f0c66b6 100644 --- a/bip-0375/README.md +++ b/bip-0375/README.md @@ -8,11 +8,25 @@ This directory contains the complete reference implementation for BIP 375: Sendi - **`reference.py`** - Minimal standalone BIP 375 validator with integrated test runner (executable) +### PSBTv2 Library for Silent Payments + +- **`tests/psbt_sp/`** - Complete PSBT v2 package for Silent Payments + - Full role-based implementation (Creator, Constructor, Updater, Signer, Input Finalizer, Extractor) + - Serialization, crypto utilities, and BIP 352 integration + - Used to generate test vectors + ### Dependencies (from BIP 374) - **`dleq_374.py`** - BIP 374 DLEQ proof implementation - **`secp256k1_374.py`** - Secp256k1 implementation +## **Testing** + +### Test Infrastructure + +- **`test_vectors.json`** - Test vectors with full cryptographic material (13 invalid + 4 valid) +- **`tests/test_generator.py`** - Deterministic test vector generator producing`test_vectors.json` + ## **Usage** ### Run Reference Implementation Tests @@ -21,4 +35,12 @@ This directory contains the complete reference implementation for BIP 375: Sendi python reference.py # Run all tests using test_vectors.json python reference.py -f custom.json # Use custom test file python reference.py -v # Verbose mode with detailed errors -``` \ No newline at end of file +``` + +### Generate Test Vectors + +```bash +python tests/test_generator.py # Creates test_vectors.json in bip-0375 root directory +``` + +**Note:** Demo implementations can be found in [bip375-examples](https://github.com/macgyver13/bip375-examples/) diff --git a/bip-0375/tests/psbt_sp/__init__.py b/bip-0375/tests/psbt_sp/__init__.py new file mode 100644 index 0000000000..6b6a617398 --- /dev/null +++ b/bip-0375/tests/psbt_sp/__init__.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +PSBT Silent Payment (psbt_sp) Package + +Clean Python package for BIP 375 - Sending Silent Payments with PSBTs +Provides PSBT v2 implementation with silent payment extensions. +""" + +# Constants +from .constants import PSBTFieldType + +# Core PSBT functionality +from .psbt import SilentPaymentPSBT, SilentPaymentAddress, ECDHShare, validate_psbt_silent_payments + +# Cryptographic utilities +from .crypto import Wallet + +# Serialization utilities (for advanced usage) +from .serialization import PSBTField, PSBTv2, compact_size_uint, create_witness_utxo, parse_psbt_bytes + +# BIP 352 cryptographic functions +from .bip352_crypto import ( + compute_label_tweak, + compute_shared_secret_tweak, + apply_label_to_spend_key, + derive_silent_payment_output_pubkey, + pubkey_to_p2wpkh_script +) + +# PSBT utilities +from .psbt_utils import ( + extract_input_pubkey, + extract_combined_input_pubkeys, + check_ecdh_coverage, + extract_scan_keys_from_outputs +) + +# File I/O +from .psbt_io import save_psbt_to_file, load_psbt_from_file + +# Role-based classes +from .roles import ( + PSBTCreator, + PSBTConstructor, + PSBTUpdater, + PSBTSigner, + PSBTInputFinalizer, + PSBTExtractor +) + +# Convenience re-exports of extractor methods +extract_transaction = PSBTExtractor.extract_transaction +save_transaction = PSBTExtractor.save_transaction + +__version__ = "1.0.0" +__author__ = "BIP 375 Implementation" +__description__ = "PSBT v2 with BIP 375 Silent Payment extensions" + +# Public API - what gets imported with "from psbt_sp import *" +__all__ = [ + # Constants + "PSBTFieldType", + + # Core classes + "SilentPaymentPSBT", + "SilentPaymentAddress", + "ECDHShare", + "Wallet", + + # Role-based classes + "PSBTCreator", + "PSBTConstructor", + "PSBTUpdater", + "PSBTSigner", + "PSBTInputFinalizer", + "PSBTExtractor", + + # Serialization classes + "PSBTField", + "PSBTv2", + + # BIP 352 crypto functions + "compute_label_tweak", + "compute_shared_secret_tweak", + "apply_label_to_spend_key", + "derive_silent_payment_output_pubkey", + "pubkey_to_p2wpkh_script", + + # PSBT utility functions + "extract_input_pubkey", + "extract_combined_input_pubkeys", + "check_ecdh_coverage", + "extract_scan_keys_from_outputs", + + # Transaction functions + "extract_transaction", + "save_transaction", + + # File I/O functions + "save_psbt_to_file", + "load_psbt_from_file", + + # Other utilities + "compact_size_uint", + "create_witness_utxo", + "parse_psbt_bytes", + "validate_psbt_silent_payments", + + # Package metadata + "__version__", + "__author__", + "__description__" +] \ No newline at end of file diff --git a/bip-0375/tests/psbt_sp/bip352_crypto.py b/bip-0375/tests/psbt_sp/bip352_crypto.py new file mode 100644 index 0000000000..72531c2bdf --- /dev/null +++ b/bip-0375/tests/psbt_sp/bip352_crypto.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +BIP 352 Silent Payments Cryptographic Utilities + +Pure cryptographic functions for BIP 352 silent payments protocol. +These functions are independent of PSBT and can be used by wallets, +receivers, and other silent payment implementations. +""" + +import hashlib +import struct +from secp256k1_374 import GE, G + + +def compute_label_tweak(scan_privkey_bytes: bytes, label: int) -> int: + """ + Compute BIP 352 label tweak for modifying spend key + + Formula: hash_BIP0352/Label(ser_256(b_scan) || ser_32(m)) + + Args: + scan_privkey_bytes: Scan private key (32 bytes) + label: Label integer (0 for change, > 0 for other purposes) + + Returns: + Scalar for point multiplication to modify spend key + """ + # BIP 352: ser_256(b_scan) || ser_32(m) + label_bytes = struct.pack(' int: + """ + Compute BIP 352 shared secret tweak for output derivation + + Formula: t_k = hash_BIP0352/SharedSecret(ecdh_shared_secret || ser_32(k)) + + Args: + ecdh_shared_secret_bytes: ECDH shared secret point (33 bytes compressed) + k: Output index for this scan key (default 0) + + Returns: + Scalar tweak for deriving output public key + """ + k_bytes = k.to_bytes(4, 'big') # 4 bytes big-endian + + # Tagged hash: BIP0352/SharedSecret + tag_data = b"BIP0352/SharedSecret" + tag_hash = hashlib.sha256(tag_data).digest() + + tagged_input = tag_hash + tag_hash + ecdh_shared_secret_bytes + k_bytes + tweak_hash = hashlib.sha256(tagged_input).digest() + tweak_int = int.from_bytes(tweak_hash, 'big') % GE.ORDER + + return tweak_int + + +def apply_label_to_spend_key(spend_key_point: GE, scan_privkey_bytes: bytes, label: int) -> GE: + """ + Apply BIP 352 label to spend public key + + Formula: B_m = B_spend + hash_BIP0352/Label(b_scan || m) * G + + Args: + spend_key_point: Base spend public key + scan_privkey_bytes: Scan private key (32 bytes) + label: Label integer + + Returns: + Modified spend public key B_m + """ + label_tweak = compute_label_tweak(scan_privkey_bytes, label) + label_tweak_point = label_tweak * G + return spend_key_point + label_tweak_point + + +def derive_silent_payment_output_pubkey( + spend_key_point: GE, + ecdh_shared_secret_bytes: bytes, + k: int = 0 +) -> GE: + """ + Derive final output public key for silent payment + + Formula: P_k = B_m + t_k * G + where t_k = hash_BIP0352/SharedSecret(ecdh_shared_secret || ser_32(k)) + + Args: + spend_key_point: Spend public key (possibly label-modified B_m) + ecdh_shared_secret_bytes: ECDH shared secret (33 bytes compressed) + k: Output index for this scan key + + Returns: + Final output public key P_k + """ + tweak_int = compute_shared_secret_tweak(ecdh_shared_secret_bytes, k) + tweak_point = tweak_int * G + return spend_key_point + tweak_point + + +def pubkey_to_p2wpkh_script(pubkey_point: GE) -> bytes: + """ + Convert public key to P2WPKH script + + Args: + pubkey_point: Public key point + + Returns: + P2WPKH script: OP_0 <20-byte-pubkey-hash> + """ + pubkey_bytes = pubkey_point.to_bytes_compressed() + pubkey_hash = hashlib.new('ripemd160', hashlib.sha256(pubkey_bytes).digest()).digest() + return b'\x00\x14' + pubkey_hash # OP_0 + 20 bytes + + +def pubkey_to_p2tr_script(pubkey_point: GE) -> bytes: + """ + Convert public key to P2TR (Taproot) script + + BIP 352 requires silent payment outputs to use P2TR (Taproot). + + Formula: OP_1 <32-byte-x-only-pubkey> + + Args: + pubkey_point: Public key point + + Returns: + P2TR script: OP_1 (0x51) + 32 bytes (x-only public key) + """ + # Get x-only public key (32 bytes) - BIP 340/341 + pubkey_bytes = pubkey_point.to_bytes_compressed() + x_only = pubkey_bytes[1:] # Remove first byte (02/03 parity), keep 32-byte x coordinate + return b'\x51\x20' + x_only # OP_1 (0x51) + PUSH_32 (0x20) + 32 bytes diff --git a/bip-0375/tests/psbt_sp/constants.py b/bip-0375/tests/psbt_sp/constants.py new file mode 100644 index 0000000000..d302b14e04 --- /dev/null +++ b/bip-0375/tests/psbt_sp/constants.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +BIP 174/370/375 PSBT Field Type Constants + +This module defines all PSBT field type constants used across BIP 174 (PSBTv1), +BIP 370 (PSBTv2), and BIP 375 (Silent Payments with PSBT). +""" + + +class PSBTFieldType: + """PSBT Field Type Constants for BIP 174/370/375""" + + # ======================================================================= + # GLOBAL FIELDS (PSBT-wide fields) + # ======================================================================= + + # Standard PSBT v2 global fields + PSBT_GLOBAL_UNSIGNED_TX = 0x00 + PSBT_GLOBAL_XPUB = 0x01 + PSBT_GLOBAL_TX_VERSION = 0x02 + PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03 + PSBT_GLOBAL_INPUT_COUNT = 0x04 + PSBT_GLOBAL_OUTPUT_COUNT = 0x05 + PSBT_GLOBAL_TX_MODIFIABLE = 0x06 + PSBT_GLOBAL_VERSION = 0xfb + PSBT_GLOBAL_PROPRIETARY = 0xfc + + # BIP 375 Silent Payment global fields + PSBT_GLOBAL_SP_ECDH_SHARE = 0x07 + PSBT_GLOBAL_SP_DLEQ = 0x08 + + # ======================================================================= + # INPUT FIELDS (Per-input fields) + # ======================================================================= + + # Standard PSBT v2 input fields + PSBT_IN_NON_WITNESS_UTXO = 0x00 + PSBT_IN_WITNESS_UTXO = 0x01 + PSBT_IN_PARTIAL_SIG = 0x02 + PSBT_IN_SIGHASH_TYPE = 0x03 + PSBT_IN_REDEEM_SCRIPT = 0x04 + PSBT_IN_WITNESS_SCRIPT = 0x05 + PSBT_IN_BIP32_DERIVATION = 0x06 + PSBT_IN_FINAL_SCRIPTSIG = 0x07 + PSBT_IN_FINAL_SCRIPTWITNESS = 0x08 + PSBT_IN_POR_COMMITMENT = 0x09 + PSBT_IN_RIPEMD160 = 0x0a + PSBT_IN_SHA256 = 0x0b + PSBT_IN_HASH160 = 0x0c + PSBT_IN_HASH256 = 0x0d + PSBT_IN_PREVIOUS_TXID = 0x0e + PSBT_IN_OUTPUT_INDEX = 0x0f + PSBT_IN_SEQUENCE = 0x10 + PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11 + PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12 + PSBT_IN_TAP_KEY_SIG = 0x13 + PSBT_IN_TAP_SCRIPT_SIG = 0x14 + PSBT_IN_TAP_LEAF_SCRIPT = 0x15 + PSBT_IN_TAP_BIP32_DERIVATION = 0x16 + PSBT_IN_TAP_INTERNAL_KEY = 0x17 + PSBT_IN_TAP_MERKLE_ROOT = 0x18 + PSBT_IN_PROPRIETARY = 0xfc + + # BIP 375 Silent Payment input fields + PSBT_IN_SP_ECDH_SHARE = 0x1d + PSBT_IN_SP_DLEQ = 0x1e + + # ======================================================================= + # OUTPUT FIELDS (Per-output fields) + # ======================================================================= + + # Standard PSBT v2 output fields + PSBT_OUT_REDEEM_SCRIPT = 0x00 + PSBT_OUT_WITNESS_SCRIPT = 0x01 + PSBT_OUT_BIP32_DERIVATION = 0x02 + PSBT_OUT_AMOUNT = 0x03 + PSBT_OUT_SCRIPT = 0x04 + PSBT_OUT_TAP_INTERNAL_KEY = 0x05 + PSBT_OUT_TAP_TREE = 0x06 + PSBT_OUT_TAP_BIP32_DERIVATION = 0x07 + PSBT_OUT_PROPRIETARY = 0xfc + + # BIP 375 Silent Payment output fields + PSBT_OUT_SP_V0_INFO = 0x09 + PSBT_OUT_SP_V0_LABEL = 0x0a diff --git a/bip-0375/tests/psbt_sp/crypto.py b/bip-0375/tests/psbt_sp/crypto.py new file mode 100644 index 0000000000..767f3aa7f3 --- /dev/null +++ b/bip-0375/tests/psbt_sp/crypto.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +""" +Deterministic cryptographic utilities for BIP 375 + +Test-only implementation of low-level secp256k1 field and group arithmetic + +It is designed for ease of understanding, not performance. + +WARNING: This code is slow and trivially vulnerable to side channel attacks. Do not use for +anything but tests. + +""" + +from dataclasses import dataclass +import hashlib +import hmac +import struct +from typing import Tuple, Optional +from secp256k1_374 import GE, G + + +class PrivateKey(int): + """Private key that inherits from int with convenient format methods""" + + def __new__(cls, value: int): + return super().__new__(cls, value) + + @property + def bytes(self) -> bytes: + """Get as 32-byte big-endian bytes""" + return super().to_bytes(32, 'big') + + @property + def hex(self) -> str: + """Get as hex string""" + return self.bytes.hex() + + def __mul__(self, other): + """Allow multiplication (for ECDH) - return GE point when multiplied with PublicKey""" + result = super().__mul__(other) + # Return PrivateKey if multiplying with another int, otherwise return the result + return PrivateKey(result) if isinstance(other, int) else result + + def __repr__(self): + """String representation""" + return f"PrivateKey({int(self)})" + + +class PublicKey(GE): + """Public key that inherits from GE with convenient format methods""" + + def __new__(cls, point: GE): + # Create a new instance bypassing __init__ + obj = object.__new__(cls) + # Directly copy all attributes from the source point + if hasattr(point, 'infinity'): + obj.infinity = point.infinity + if hasattr(point, 'x'): + obj.x = point.x + if hasattr(point, 'y'): + obj.y = point.y + return obj + + def __init__(self, point: GE): + # Override __init__ to do nothing since we handle everything in __new__ + pass + + @property + def bytes(self) -> bytes: + """Get as compressed bytes (33 bytes)""" + return self.to_bytes_compressed() + + @property + def bytes_uncompressed(self) -> bytes: + """Get as uncompressed bytes (65 bytes)""" + return self.to_bytes_uncompressed() + + @property + def bytes_xonly(self) -> bytes: + """Get as x-only bytes (32 bytes)""" + return self.to_bytes_xonly() + + @property + def hex(self) -> str: + """Get as compressed hex string""" + return self.bytes.hex() + + @property + def hex_uncompressed(self) -> str: + """Get as uncompressed hex string""" + return self.bytes_uncompressed.hex() + + @property + def hex_xonly(self) -> str: + """Get as x-only hex string""" + return self.bytes_xonly.hex() + + def __add__(self, other): + """Override addition - prioritize EC point addition, allow concatenation for bytes""" + if isinstance(other, (PublicKey, GE)): + # This is elliptic curve point addition + result = super().__add__(other) + return PublicKey(result) + elif isinstance(other, bytes): + # Concatenate with raw bytes + return self.bytes + other + else: + # Try elliptic curve addition as fallback + result = super().__add__(other) + return PublicKey(result) + + def __sub__(self, other): + """Override subtraction to return PublicKey""" + result = super().__sub__(other) + return PublicKey(result) if not result.infinity else PublicKey(result) + + def __mul__(self, other): + """Override multiplication to return PublicKey when multiplied by int""" + if isinstance(other, int): + # This is scalar multiplication: PublicKey * int + result = super().__mul__(other) + return PublicKey(result) + else: + # Not supported - let Python handle the error + return NotImplemented + + def __rmul__(self, other): + """Override right multiplication to return PublicKey when int * PublicKey""" + if isinstance(other, int): + # This is scalar multiplication: int * PublicKey + result = super().__rmul__(other) + return PublicKey(result) + else: + # Let the other object handle the multiplication + return NotImplemented + + def __neg__(self): + """Override negation to return PublicKey""" + result = super().__neg__() + return PublicKey(result) + + def __len__(self): + """Return length of bytes""" + return len(self.bytes) + + def __repr__(self): + """String representation""" + if self.infinity: + return "PublicKey(infinity)" + else: + return f"PublicKey({self.hex})" + + +class Wallet: + """Deterministic wallet for generating silent payment keys""" + + def __init__(self, seed: str = "bip375_complete_seed"): + self.seed = seed + self.scan_priv, self.scan_pub = self.create_key_pair("scan", 0) + self.spend_priv, self.spend_pub = self.create_key_pair("spend", 0) + self.input_keys = [] + + def deterministic_private_key(self, purpose: str, index: int = 0) -> int: + """Generate deterministic private key from seed""" + data = f"{self.seed}_{purpose}_{index}".encode() + hash_result = hashlib.sha256(data).digest() + # Ensure it's in valid range for secp256k1 + return int.from_bytes(hash_result, 'big') % GE.ORDER + + def create_key_pair(self, purpose: str, index: int = 0) -> Tuple[PrivateKey, PublicKey]: + """Create deterministic key pair""" + private_int = self.deterministic_private_key(purpose, index) + public_point = private_int * G + return PrivateKey(private_int), PublicKey(public_point) + + def input_key_pair(self, index: int = 0) -> Tuple[PrivateKey, PublicKey]: + """Generate input key pair for specific index""" + # Create all missing keys up to and including the requested index + while len(self.input_keys) <= index: + self.input_keys.append(self.create_key_pair("input", len(self.input_keys))) + return self.input_keys[index] + + @staticmethod + def random_bytes(salt: int = 0) -> bytes: + hash_result = hashlib.sha256(f"{salt}".encode()).digest() + return (int.from_bytes(hash_result, 'big') % GE.ORDER).to_bytes(32) + +@dataclass +class UTXO: + """Represents an unspent transaction output with spending information""" + txid: str # 32-byte transaction ID (hex) + vout: int # Output index + amount: int # Value in satoshis + script_pubkey: str # Spending conditions (hex) + private_key: Optional[PrivateKey] = None # Key to spend this UTXO + sequence: int = 0xfffffffe # Optional sequence number + + @property + def txid_bytes(self) -> bytes: + """Get txid as bytes""" + return bytes.fromhex(self.txid) + + @property + def script_pubkey_bytes(self) -> bytes: + """Get script_pubkey as bytes""" + return bytes.fromhex(self.script_pubkey) + + +# Bitcoin Transaction Signing Functions +# ===================================== + +def deterministic_nonce(private_key: int, message_hash: bytes) -> int: + """ + Generate deterministic nonce k for ECDSA signing (RFC 6979 style) + + Args: + private_key: Private key as integer + message_hash: 32-byte hash to sign + + Returns: + Deterministic nonce k in range [1, GE.ORDER-1] + """ + if len(message_hash) != 32: + raise ValueError("Message hash must be 32 bytes") + + private_key_bytes = private_key.to_bytes(32, 'big') + + # RFC 6979 simplified implementation + v = b'\x01' * 32 + k = b'\x00' * 32 + + # Step 1: K = HMAC_K(V || 0x00 || private_key || message_hash) + k = hmac.new(k, v + b'\x00' + private_key_bytes + message_hash, hashlib.sha256).digest() + + # Step 2: V = HMAC_K(V) + v = hmac.new(k, v, hashlib.sha256).digest() + + # Step 3: K = HMAC_K(V || 0x01 || private_key || message_hash) + k = hmac.new(k, v + b'\x01' + private_key_bytes + message_hash, hashlib.sha256).digest() + + # Step 4: V = HMAC_K(V) + v = hmac.new(k, v, hashlib.sha256).digest() + + # Generate candidate k values until we find a valid one + while True: + v = hmac.new(k, v, hashlib.sha256).digest() + candidate_k = int.from_bytes(v, 'big') + + # k must be in range [1, GE.ORDER-1] + if 1 <= candidate_k < GE.ORDER: + return candidate_k + + # If candidate is invalid, update K and V and try again + k = hmac.new(k, v + b'\x00', hashlib.sha256).digest() + v = hmac.new(k, v, hashlib.sha256).digest() + + +def ecdsa_sign(private_key: int, message_hash: bytes) -> Tuple[int, int]: + """ + Sign a message hash using ECDSA + + Args: + private_key: Private key as integer + message_hash: 32-byte hash to sign + + Returns: + (r, s) signature components as integers + """ + if not (1 <= private_key < GE.ORDER): + raise ValueError("Private key must be in range [1, n-1]") + + if len(message_hash) != 32: + raise ValueError("Message hash must be 32 bytes") + + z = int.from_bytes(message_hash, 'big') + + while True: + # Generate deterministic nonce + k = deterministic_nonce(private_key, message_hash) + + # Calculate R = k * G + R = k * G + if R.infinity: + continue # Retry with different k + + r = int(R.x) % GE.ORDER + if r == 0: + continue # Retry with different k + + # Calculate s = k^(-1) * (z + r * private_key) mod n + k_inv = pow(k, -1, GE.ORDER) # Modular inverse + s = (k_inv * (z + r * private_key)) % GE.ORDER + + if s == 0: + continue # Retry with different k + + # Use low-S form (BIP 62) + if s > GE.ORDER // 2: + s = GE.ORDER - s + + return (r, s) + + +def der_encode_signature(r: int, s: int) -> bytes: + """ + DER encode ECDSA signature for Bitcoin transactions + + Args: + r: r component of signature + s: s component of signature + + Returns: + DER-encoded signature bytes + """ + def encode_integer(value: int) -> bytes: + # Convert to minimal bytes representation + byte_length = (value.bit_length() + 7) // 8 + if byte_length == 0: + byte_length = 1 + value_bytes = value.to_bytes(byte_length, 'big') + + # Add padding byte if high bit is set (to keep it positive) + if value_bytes[0] & 0x80: + value_bytes = b'\x00' + value_bytes + + return b'\x02' + bytes([len(value_bytes)]) + value_bytes + + r_encoded = encode_integer(r) + s_encoded = encode_integer(s) + + # Construct full DER sequence + content = r_encoded + s_encoded + return b'\x30' + bytes([len(content)]) + content + + +def sighash_all(transaction_data: dict, input_index: int, script_code: bytes, amount: int) -> bytes: + """ + Compute SIGHASH_ALL hash for P2WPKH input signing + + Args: + transaction_data: Dict with 'inputs' and 'outputs' lists + input_index: Index of input being signed + script_code: Script code for the input (P2WPKH: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG) + amount: Amount of the UTXO being spent + + Returns: + 32-byte SIGHASH_ALL hash + """ + # Simplified SIGHASH_ALL for P2WPKH (BIP 143) + inputs = transaction_data['inputs'] + outputs = transaction_data['outputs'] + + # Version (4 bytes, little-endian) + version = struct.pack(' bytes: + """ + Sign a P2WPKH input with SIGHASH_ALL + + Args: + private_key: Private key as integer + transaction_data: Transaction data dict + input_index: Index of input to sign + pubkey_hash: 20-byte hash160 of public key + amount: Amount of UTXO being spent + + Returns: + Complete signature with SIGHASH_ALL byte appended + """ + # P2WPKH script code: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG + script_code = b'\x76\xa9\x14' + pubkey_hash + b'\x88\xac' + + # Compute SIGHASH_ALL hash + sighash = sighash_all(transaction_data, input_index, script_code, amount) + + # Sign the hash + r, s = ecdsa_sign(private_key, sighash) + + # DER encode and append SIGHASH_ALL byte + der_sig = der_encode_signature(r, s) + return der_sig + b'\x01' # SIGHASH_ALL \ No newline at end of file diff --git a/bip-0375/tests/psbt_sp/psbt.py b/bip-0375/tests/psbt_sp/psbt.py new file mode 100644 index 0000000000..e8bf77edf2 --- /dev/null +++ b/bip-0375/tests/psbt_sp/psbt.py @@ -0,0 +1,1511 @@ +#!/usr/bin/env python3 +""" +BIP 375 SilentPaymentPSBT Class + +Main implementation of PSBT v2 class with silent payment extensions. +""" + +from dataclasses import dataclass +from typing import Dict, List, Tuple, Optional +import struct + +from .constants import PSBTFieldType +from secp256k1_374 import GE +from .serialization import PSBTField +from .crypto import PublicKey, UTXO +from .roles import PSBTConstructor, PSBTCreator, PSBTSigner, PSBTInputFinalizer +from dleq_374 import dleq_verify_proof +import hashlib + + +@dataclass +class SilentPaymentAddress: + """Silent payment address with scan and spend keys""" + scan_key: PublicKey # 33 bytes compressed public key + spend_key: PublicKey # 33 bytes compressed public key + label: Optional[int] = None + + +@dataclass +class ECDHShare: + """ECDH share for a specific scan key""" + scan_key: bytes # 33 bytes + share: bytes # 33 bytes (point) + dleq_proof: Optional[bytes] = None # 64 bytes + + +class SilentPaymentPSBT: + """ + PSBT v2 with BIP 375 silent payment extensions + + Methods organized by BIP 174/370/375 roles: + - Creator/Constructor: Build PSBT structure + - Signer: ECDH shares, DLEQ proofs, signatures + - Input Finalizer: Compute output scripts + - Transaction Extractor: Build final transaction + """ + + # ============================================================================ + # region INITIALIZATION + # ============================================================================ + + def __init__(self): + self.global_fields: List[PSBTField] = [] + self.input_maps: List[List[PSBTField]] = [] + self.output_maps: List[List[PSBTField]] = [] + + # endregion + + # ============================================================================ + # region CREATOR/CONSTRUCTOR ROLE - Build PSBT Structure + # ============================================================================ + + def add_base_fields(self, num_inputs: int, num_outputs: int) -> None: + """ + Creator role: Add required PSBT v2 global fields + + Args: + num_inputs: Number of transaction inputs + num_outputs: Total number of outputs (regular + silent payment) + """ + # Delegate to PSBTCreator role + self.global_fields, self.input_maps, self.output_maps = PSBTCreator.create_base_psbt(num_inputs, num_outputs) + + def add_inputs_outputs(self, inputs: List, outputs: List[dict]) -> None: + """ + Constructor role: Add input and output information to PSBT + + Args: + inputs: List of input objects (UTXO dataclass) or dictionaries with txid, vout, amount, script_pubkey, etc. + outputs: List of output dictionaries, can be regular outputs or silent payment addresses + + Raises: + ValueError: If BIP 375 Segwit version restrictions are violated + """ + # Delegate to PSBTConstructor role + PSBTConstructor.add_inputs(self.input_maps, inputs) + PSBTConstructor.add_outputs(self.output_maps, outputs) + + # BIP 375: Validate Segwit version restrictions + # Cannot mix inputs spending Segwit v>1 with silent payment outputs + PSBTConstructor._check_segwit_version_restrictions(self.input_maps, self.output_maps) + + def create_silent_payment_psbt(self, inputs: List, outputs: List[dict]) -> 'SilentPaymentPSBT': + """ + Create a PSBT v2 with silent payment extensions + + Args: + inputs: List of input objects (UTXO dataclass) or dictionaries with txid, vout, amount, script_pubkey + outputs: List of output dictionaries (regular outputs + silent payment addresses) + + Returns: + Configured SilentPaymentPSBT ready for ECDH share computation + """ + num_inputs = len(inputs) + num_outputs = len(outputs) + + # Creator role: Set up PSBT v2 base structure + self.add_base_fields(num_inputs, num_outputs) + + # Constructor role: Add transaction input/output information + self.add_inputs_outputs(inputs, outputs) + + return self + + # Test Generator helper functions + def add_global_field(self, field_type: int, key_data: bytes, value_data: bytes): + """Add a global field""" + self.global_fields.append(PSBTField(field_type, key_data, value_data)) + + def add_input_field(self, input_index: int, field_type: int, key_data: bytes, value_data: bytes): + """Add a field to specific input""" + # Extend input_maps if needed + while len(self.input_maps) <= input_index: + self.input_maps.append([]) + + self.input_maps[input_index].append(PSBTField(field_type, key_data, value_data)) + + def add_output_field(self, output_index: int, field_type: int, key_data: bytes, value_data: bytes): + """Add a field to specific output""" + # Extend output_maps if needed + while len(self.output_maps) <= output_index: + self.output_maps.append([]) + + self.output_maps[output_index].append(PSBTField(field_type, key_data, value_data)) + + # endregion + + # ============================================================================ + # region UPDATER ROLE - Add BIP32 Derivation Information + # ============================================================================ + + def updater_role( + self, + inputs: List, + derivation_paths: Optional[List[Dict]] = None, + change_indices: Optional[List[int]] = None, + change_derivation_info: Optional[Dict[int, Dict]] = None + ) -> bool: + """ + Updater role: Add BIP32 derivation information to PSBT + + This role is essential for hardware wallet compatibility. It adds PSBT_IN_BIP32_DERIVATION + fields that allow hardware wallets to: + 1. Extract public keys without needing private keys in the PSBT + 2. Match public keys to their internal key derivation + 3. Derive the correct private keys from their master seed + + Args: + inputs: List of UTXO objects + derivation_paths: Optional list of input derivation info (see PSBTUpdater.add_input_bip32_derivation) + change_indices: Optional list of output indices that are change + change_derivation_info: Optional dict of output derivation info (see PSBTUpdater.add_output_bip32_derivation) + + Returns: + True if successful + + Example (Privacy mode - recommended for hardware wallets): + ```python + # Hardware wallet coordinator knows public keys but not derivation paths + derivation_paths = [ + {"pubkey": hw_pubkey_0}, # Privacy mode - no path revealed + {"pubkey": hw_pubkey_1}, + ] + psbt.updater_role(inputs, derivation_paths) + ``` + + Example (Full derivation mode - for watch-only wallets): + ```python + derivation_paths = [ + { + "pubkey": pubkey_bytes, + "master_fingerprint": b'\\x12\\x34\\x56\\x78', + "path": [0x80000054, 0x80000000, 0x80000000, 0, 0] # m/84'/0'/0'/0/0 + }, + ] + psbt.updater_role(inputs, derivation_paths) + ``` + """ + from .roles import PSBTUpdater + + # Add input BIP32 derivation + input_fields_added = PSBTUpdater.add_input_bip32_derivation( + self.input_maps, + inputs, + derivation_paths + ) + + print(f" UPDATER: Added PSBT_IN_BIP32_DERIVATION for {input_fields_added} input(s)") + + # Add output BIP32 derivation for change outputs if provided + if change_indices and change_derivation_info: + output_fields_added = PSBTUpdater.add_output_bip32_derivation( + self.output_maps, + change_indices, + change_derivation_info + ) + if output_fields_added > 0: + print(f" UPDATER: Added PSBT_OUT_BIP32_DERIVATION for {output_fields_added} output(s)") + + return True + + # endregion + + # ============================================================================ + # region SERIALIZATION - Encode/Decode PSBT + # ============================================================================ + + def serialize_section(self, fields: List[PSBTField]) -> bytes: + """Serialize a section (global, input, or output)""" + result = b'' + for field in fields: + result += field.serialize() + # End with separator (empty key) + result += b'\x00' + return result + + def serialize(self) -> bytes: + """Serialize entire PSBT to bytes""" + result = b'psbt\xff' # PSBT magic + + # Global section + result += self.serialize_section(self.global_fields) + + # Input sections + for input_fields in self.input_maps: + result += self.serialize_section(input_fields) + + # Output sections + for output_fields in self.output_maps: + result += self.serialize_section(output_fields) + + return result + + def encode(self) -> str: + import base64 + return base64.b64encode(self.serialize()).decode() + + def pretty_print(self) -> str: + """Return a human-readable description of the PSBT""" + lines = ["PSBT v2 with Silent Payment Extensions", "=" * 50] + + # Global fields + lines.append("Global Fields:") + for field in self.global_fields: + field_name = self._get_field_name(field.field_type, "global", strip_prefix=True) + lines.append(f" {field_name}: {field.value_data.hex()}") + + # Input fields + for i, input_fields in enumerate(self.input_maps): + lines.append(f"\nInput {i}:") + for field in input_fields: + field_name = self._get_field_name(field.field_type, "in", strip_prefix=True) + lines.append(f" {field_name}: {field.value_data.hex()}") + + # Output fields + for i, output_fields in enumerate(self.output_maps): + lines.append(f"\nOutput {i}:") + for field in output_fields: + field_name = self._get_field_name(field.field_type, "out", strip_prefix=True) + if field.field_type == PSBTFieldType.PSBT_OUT_SP_V0_INFO: + # Pretty print silent payment info + if len(field.value_data) == 66: # 33 + 33 bytes + scan_key = field.value_data[:33].hex() + spend_key = field.value_data[33:].hex() + lines.append(f" {field_name}:") + lines.append(f" Scan Key: {scan_key}") + lines.append(f" Spend Key: {spend_key}") + else: + lines.append(f" {field_name}: {field.value_data.hex()}") + else: + lines.append(f" {field_name}: {field.value_data.hex()}") + + return "\n".join(lines) + + def to_json(self) -> dict: + """ + Return a JSON-serializable dict representation of the PSBT + + Returns structured data with global fields, inputs, and outputs. + This is derived from the PSBT and should only be used for human + inspection, not as a source of truth for programmatic operations. + + Returns: + dict with 'global', 'inputs', 'outputs' sections + """ + result = { + 'global': [], + 'inputs': [], + 'outputs': [] + } + + # Global fields + for field in self.global_fields: + field_name = self._get_field_name(field.field_type, "global") + field_data = { + 'field': field_name, + 'type': field.field_type, + 'value_hex': field.value_data.hex() + } + + # Add human-readable values for common fields + if field.field_type == PSBTFieldType.PSBT_GLOBAL_TX_VERSION: + field_data['value'] = struct.unpack(' 0 else 0 + elif field.field_type == PSBTFieldType.PSBT_GLOBAL_OUTPUT_COUNT: + field_data['value'] = field.value_data[0] if len(field.value_data) > 0 else 0 + elif field.field_type == PSBTFieldType.PSBT_GLOBAL_TX_MODIFIABLE: + flags = field.value_data[0] if len(field.value_data) > 0 else 0 + field_data['value'] = { + 'raw': flags, + 'inputs_modifiable': bool(flags & 0x01), + 'outputs_modifiable': bool(flags & 0x02) + } + + result['global'].append(field_data) + + # Input fields + for i, input_fields in enumerate(self.input_maps): + input_data = {'index': i, 'fields': []} + + for field in input_fields: + field_name = self._get_field_name(field.field_type, "in") + field_info = { + 'field': field_name, + 'type': field.field_type, + 'value_hex': field.value_data.hex() + } + + # Add human-readable values for common fields + if field.field_type == PSBTFieldType.PSBT_IN_PREVIOUS_TXID: + field_info['value'] = field.value_data.hex() + elif field.field_type == PSBTFieldType.PSBT_IN_OUTPUT_INDEX: + field_info['value'] = struct.unpack(' str: + """ + Get human-readable name for field type with section context + + Args: + field_type: PSBT field type integer + section: Section name ('global', 'in', or 'out') + strip_prefix: If True, strip 'PSBT_GLOBAL_', 'PSBT_IN_', 'PSBT_OUT_' prefix + If False (default), return full name like 'PSBT_GLOBAL_TX_VERSION' + + Returns: + Field name string (full or stripped based on strip_prefix) + """ + # Search only within the appropriate section to handle duplicate values + section_prefix = f"PSBT_{section.upper()}_" + + for attr_name in dir(PSBTFieldType): + if attr_name.startswith(section_prefix): + attr_value = getattr(PSBTFieldType, attr_name) + if isinstance(attr_value, int) and attr_value == field_type: + # Return name with or without the section prefix + if strip_prefix: + return attr_name[len(section_prefix):] + else: + return attr_name + + # Unknown field type, return hex representation + return f"UNKNOWN_{field_type:02x}" + + # endregion + + # ============================================================================ + # region SIGNER ROLE - ECDH Shares & Signatures + # ============================================================================ + + def add_ecdh_shares(self, inputs: List[UTXO], scan_keys: List[PublicKey], use_global = True) -> None: + """ + Add ECDH shares and DLEQ proofs to the PSBT for given UTXOs and scan keys + + Args: + inputs: List of UTXO objects, some may have private_key = None + scan_keys: List of scan keys (PublicKey objects) + use_global: If True, use global ECDH approach; if False, use per-input approach + """ + # Delegate to PSBTSigner role + PSBTSigner.add_ecdh_shares( + global_fields=self.global_fields, + input_maps=self.input_maps, + inputs=inputs, + scan_keys=scan_keys, + use_global=use_global + ) + + def sign_inputs(self, inputs: List[UTXO]) -> bool: + """ + Sign transaction inputs using private keys from UTXOs (SIGNER ROLE) + + Args: + inputs: List of UTXO objects with private keys for signing + + Returns: + True if signing successful, raises exception if validation fails + """ + # Pre-signing validation + is_valid, errors = validate_psbt_silent_payments(self) + if not is_valid: + raise ValueError(f"PSBT validation failed before signing: {errors}") + + # Delegate to PSBTSigner role + signatures_added = PSBTSigner.sign_inputs( + input_maps=self.input_maps, + output_maps=self.output_maps, + inputs=inputs + ) + + if signatures_added == 0: + raise ValueError("No inputs were signed successfully") + + print(f" Successfully signed {signatures_added} input(s)") + return True + + # endregion + + # ============================================================================ + # region VERIFICATION - DLEQ Proofs + # ============================================================================ + + def verify_dleq_proofs(self, inputs: List[UTXO] = None) -> bool: + """ + Verify all DLEQ proofs in the PSBT + + Returns: + True if all proofs are valid, False otherwise + """ + + # Check for global DLEQ proofs + global_ecdh_fields = {} + global_dleq_fields = {} + + for field in self.global_fields: + if field.field_type == PSBTFieldType.PSBT_GLOBAL_SP_ECDH_SHARE: + scan_key = field.key_data + global_ecdh_fields[scan_key] = field.value_data + elif field.field_type == PSBTFieldType.PSBT_GLOBAL_SP_DLEQ: + scan_key = field.key_data + global_dleq_fields[scan_key] = field.value_data + + # Verify global DLEQ proofs + for scan_key in global_ecdh_fields: + if scan_key not in global_dleq_fields: + print(f"❌ Global ECDH share missing DLEQ proof for scan key {scan_key.hex()}") + return False + + ecdh_share_bytes = global_ecdh_fields[scan_key] + dleq_proof = global_dleq_fields[scan_key] + + if len(dleq_proof) != 64: + print(f"❌ Invalid global DLEQ proof length: {len(dleq_proof)} bytes") + return False + + # Combine all input public keys for global verification + A_combined = self._extract_combined_input_pubkeys(inputs) + if A_combined is None: + print("❌ Could not extract input public keys for global DLEQ verification") + return False + + B_scan = GE.from_bytes(scan_key) # scan key + C = GE.from_bytes(ecdh_share_bytes) # ECDH result + # Verify DLEQ proof + if not dleq_verify_proof(A_combined, B_scan, C, dleq_proof): + print(f"❌ Global DLEQ proof verification failed for scan key {scan_key.hex()}") + return False + + print(f" Global DLEQ proof verified for scan key {scan_key.hex()}") + + # Check for per-input DLEQ proofs + for input_index, input_fields in enumerate(self.input_maps): + input_ecdh_fields = {} + input_dleq_fields = {} + + for field in input_fields: + if field.field_type == PSBTFieldType.PSBT_IN_SP_ECDH_SHARE: + scan_key = field.key_data + input_ecdh_fields[scan_key] = field.value_data + elif field.field_type == PSBTFieldType.PSBT_IN_SP_DLEQ: + scan_key = field.key_data + input_dleq_fields[scan_key] = field.value_data + + # Verify per-input DLEQ proofs + for scan_key in input_ecdh_fields: + if scan_key not in input_dleq_fields: + print(f"❌ Input {input_index} ECDH share missing DLEQ proof for scan key {scan_key.hex()}") + return False + + ecdh_share_bytes = input_ecdh_fields[scan_key] + dleq_proof = input_dleq_fields[scan_key] + + if len(dleq_proof) != 64: + print(f"❌ Invalid input {input_index} DLEQ proof length: {len(dleq_proof)} bytes") + return False + + # Convert to GE points + B = GE.from_bytes(scan_key) # scan key + C = GE.from_bytes(ecdh_share_bytes) # ECDH result + + # Extract input public key for this specific input + A = self._extract_input_pubkey(input_index, inputs) + if A is None: + print(f"❌ Could not extract public key for input {input_index}") + return False + + # Verify DLEQ proof + if not dleq_verify_proof(A, B, C, dleq_proof): + print(f"❌ Input {input_index} DLEQ proof verification failed for scan key {scan_key.hex()}") + return False + + print(f" Input {input_index} DLEQ proof verified for scan key {scan_key.hex()}") + + if not global_ecdh_fields and not any( + any(field.field_type == PSBTFieldType.PSBT_IN_SP_ECDH_SHARE for field in input_fields) + for input_fields in self.input_maps + ): + print("⚠️ No ECDH shares found in PSBT - no DLEQ proofs to verify") + return True + + print(" All DLEQ proofs verified successfully") + return True + + def _extract_combined_input_pubkeys(self, inputs: List[UTXO] = None) -> Optional[GE]: + """ + Extract and combine all input public keys for global DLEQ verification + + Note: + This method delegates to the standalone extract_combined_input_pubkeys() function + in psbt_utils.py, automatically providing the PSBT field data. + """ + from .psbt_utils import extract_combined_input_pubkeys as _extract_combined + return _extract_combined(self.input_maps, inputs) + + def _extract_input_pubkey(self, input_index: int, inputs: List[UTXO] = None) -> Optional[GE]: + """ + Extract public key for a specific input from PSBT fields + + Args: + input_index: Index of the input + inputs: Optional list of UTXO objects (for fallback extraction from private key) + + Note: + This method delegates to the standalone extract_input_pubkey() function + in psbt_utils.py, automatically providing the PSBT field data. + """ + if input_index >= len(self.input_maps): + return None + + from .psbt_utils import extract_input_pubkey as _extract_pubkey + return _extract_pubkey(self.input_maps[input_index], inputs, input_index) + + # endregion + + # ============================================================================ + # region INPUT FINALIZER ROLE - Compute Output Scripts + # ============================================================================ + + def set_inputs_outputs_non_modifiable(self) -> None: + """ + Set PSBT_GLOBAL_TX_MODIFIABLE flags to 0x00 (neither inputs nor outputs modifiable) + + This method implements the BIP 375 requirement: + "If the Signer sets any missing PSBT_OUT_SCRIPTs, it must set the + Inputs Modifiable and Outputs Modifiable flags to False." + """ + # Find and update existing TX_MODIFIABLE field, or add if missing + tx_modifiable_updated = False + + for field in self.global_fields: + if field.field_type == PSBTFieldType.PSBT_GLOBAL_TX_MODIFIABLE: + # Update existing field to 0x00 (neither inputs nor outputs modifiable) + field.value_data = struct.pack(' int: + """ + Compute BIP 352 label tweak for modifying spend key + + Formula: hash_BIP0352/Label(ser_256(b_scan) || ser_32(m)) + + Args: + scan_privkey_bytes: Scan private key (32 bytes) + label: Label integer (0 for change, > 0 for other purposes) + + Returns: + Scalar for point multiplication to modify spend key + """ + # BIP 352: ser_256(b_scan) || ser_32(m) + label_bytes = struct.pack(' None: + """ + Compute output scripts for all silent payment addresses (INPUT FINALIZER ROLE) + Uses BIP 352 protocol with ECDH shares from PSBT + + Args: + scan_privkeys: Optional dict mapping scan_key_bytes -> scan_privkey_bytes + Required for computing label tweaks for change outputs + """ + # Pre-computation validation + is_valid, errors = validate_psbt_silent_payments(self) + if not is_valid: + raise ValueError(f"PSBT validation failed before computing output scripts: {errors}") + + # Delegate to PSBTInputFinalizer role + scripts_computed = PSBTInputFinalizer.compute_output_scripts( + global_fields=self.global_fields, + input_maps=self.input_maps, + output_maps=self.output_maps, + scan_privkeys=scan_privkeys + ) + + if scripts_computed == 0: + raise ValueError("No silent payment outputs found to compute") + + print(f" Successfully computed {scripts_computed} output script(s)") + + # endregion + + # ============================================================================ + # region COMPLETE ROLE WORKFLOWS - Multi-Step Operations + # ============================================================================ + + def signer_role(self, inputs: List[UTXO], scan_keys: List[PublicKey] = None) -> bool: + """ + Complete BIP 375 SIGNER role implementation + + For each output with PSBT_OUT_SP_V0_INFO set, the Signer should: + 1. Compute and set an ECDH share and DLEQ proof for each input it has the private key for, + or set a global ECDH share and DLEQ proof if it has private keys for all eligible inputs + 2. Verify the DLEQ proofs for all inputs it does not have the private keys for, + or the global DLEQ proof if it is set + 3. If all eligible inputs have an ECDH share or the global ECDH share is set, + compute and set the PSBT_OUT_SCRIPT + 4. If the Signer sets any missing PSBT_OUT_SCRIPTs, it must set the Inputs Modifiable + and Outputs Modifiable flags to False + 5. If any output does not have PSBT_OUT_SCRIPT set, the Signer must not yet add a signature + + Args: + inputs: List of UTXO objects with private keys for signing + scan_keys: List of scan keys to compute ECDH shares for (auto-extracted if None) + + Returns: + True if SIGNER role completed successfully, False otherwise + """ + + # Step 1: Check if we have any silent payment outputs + has_silent_outputs = any( + any(field.field_type == PSBTFieldType.PSBT_OUT_SP_V0_INFO for field in output_fields) + for output_fields in self.output_maps + ) + + if not has_silent_outputs: + print("⚠️ No silent payment outputs found - proceeding with regular signing") + return self.sign_inputs(inputs) + + # Step 2: Extract scan keys from silent payment outputs if not provided + if scan_keys is None: + scan_keys = [] + for output_fields in self.output_maps: + for field in output_fields: + if field.field_type == PSBTFieldType.PSBT_OUT_SP_V0_INFO: + if len(field.value_data) == 66: # 33 + 33 bytes + scan_key_bytes = field.value_data[:33] + scan_key = PublicKey(GE.from_bytes(scan_key_bytes)) + if scan_key not in scan_keys: + scan_keys.append(scan_key) + print(f"📋 Found {len(scan_keys)} unique scan key(s) in silent payment outputs") + + if not scan_keys: + print("❌ Could not extract scan keys from silent payment outputs") + return False + + print("4.1. Computing ECDH shares and DLEQ proofs for controlled inputs...") + spendable_inputs = [(i, utxo) for i, utxo in enumerate(inputs) if utxo.private_key is not None] + + if not spendable_inputs: + print("❌ No spendable inputs found (no private keys provided)") + return False + + # Use global ECDH approach (single entity controls all inputs) + try: + self.add_ecdh_shares(inputs, scan_keys, use_global=True) + print(" ECDH shares and DLEQ proofs computed") + except Exception as e: + print(f"❌ Failed to compute ECDH shares: {e}") + return False + + print("4.2. Verifying all DLEQ proofs...") + if not self.verify_dleq_proofs(inputs): + print("❌ DLEQ proof verification failed") + return False + + print("4.3. Checking ECDH share coverage...") + # TODO: For now, assume global ECDH covers all inputs (single signer scenario) + has_complete_ecdh_coverage = True + + if not has_complete_ecdh_coverage: + print("❌ Incomplete ECDH share coverage - cannot compute output scripts yet") + return False + + print("4.4. Computing silent payment output scripts...") + try: + self.compute_output_scripts() + except Exception as e: + print(f"❌ Failed to compute output scripts: {e}") + return False + + print("4.5. Verifying all outputs have scripts before signing...") + for i, output_fields in enumerate(self.output_maps): + has_script = any(field.field_type == PSBTFieldType.PSBT_OUT_SCRIPT for field in output_fields) + if not has_script: + print(f"❌ Output {i} missing script - cannot sign yet") + return False + + print("4.6. Adding signatures to inputs...") + success = self.sign_inputs(inputs) + if not success: + print("❌ Signing failed") + return False + return True + + def signer_role_partial(self, inputs: List[UTXO], controlled_input_indices: List[int], + scan_keys: List[PublicKey] = None, scan_privkeys: dict = None) -> bool: + """ + Partial SIGNER role implementation for multi-signer workflows + + This is the key method for multi-party silent payment collaboration. + Each signer only adds ECDH shares for inputs they control and verifies + DLEQ proofs from other signers. + + Args: + inputs: List of UTXO objects (may contain private keys only for controlled inputs) + controlled_input_indices: List of input indices this signer controls + scan_keys: List of scan keys to compute ECDH shares for (auto-extracted if None) + scan_privkeys: Optional dict mapping scan_key_bytes -> scan_privkey_bytes + Required for computing label tweaks for change outputs + + Returns: + True if partial SIGNER role completed successfully, False otherwise + """ + print(f" SIGNER (partial): Processing {len(controlled_input_indices)} controlled input(s)") + + # Step 0: Check if PSBT is still modifiable + is_modifiable = self._check_psbt_modifiable() + if not is_modifiable: + print("❌ PSBT is no longer modifiable (transaction already finalized)") + print(" Cannot add ECDH shares or signatures to a finalized PSBT") + print(" This usually means Charlie has already completed the workflow") + return False + + # Step 1: Check if we have any silent payment outputs + has_silent_outputs = any( + any(field.field_type == PSBTFieldType.PSBT_OUT_SP_V0_INFO for field in output_fields) + for output_fields in self.output_maps + ) + + if not has_silent_outputs: + print("⚠️ No silent payment outputs found - proceeding with regular signing") + return self._sign_controlled_inputs(inputs, controlled_input_indices) + + # Step 2: Extract scan keys from silent payment outputs if not provided + if scan_keys is None: + scan_keys = [] + for output_fields in self.output_maps: + for field in output_fields: + if field.field_type == PSBTFieldType.PSBT_OUT_SP_V0_INFO: + if len(field.value_data) == 66: # 33 + 33 bytes + scan_key_bytes = field.value_data[:33] + scan_key = PublicKey(GE.from_bytes(scan_key_bytes)) + if scan_key not in scan_keys: + scan_keys.append(scan_key) + print(f" Found {len(scan_keys)} unique scan key(s)") + + if not scan_keys: + print("❌ Could not extract scan keys from silent payment outputs") + return False + + # Step 3: Verify existing DLEQ proofs from other signers + print(" Verifying existing DLEQ proofs from other signers...") + if not self._verify_existing_dleq_proofs(inputs, controlled_input_indices): + print("❌ DLEQ proof verification failed") + return False + + # Step 4: Add ECDH shares for controlled inputs only + print(f" Computing ECDH shares for controlled inputs {controlled_input_indices}...") + try: + self._add_partial_ecdh_shares(inputs, controlled_input_indices, scan_keys) + print(" ECDH shares and DLEQ proofs computed for controlled inputs") + except Exception as e: + print(f"❌ Failed to compute ECDH shares: {e}") + return False + + # Step 5: Check if we now have complete ECDH coverage + is_complete, inputs_with_ecdh = self.check_ecdh_coverage() + print(f" ECDH coverage: {len(inputs_with_ecdh)}/{len(self.input_maps)} inputs covered") + + if is_complete: + print(" Complete ECDH coverage achieved! Computing output scripts...") + try: + self.compute_output_scripts(scan_privkeys=scan_privkeys) + print(" Output scripts computed successfully") + except Exception as e: + print(f"❌ Failed to compute output scripts: {e}") + return False + + # Step 6: Check if we can sign controlled inputs + # For multi-signer workflow: sign if ALL outputs have scripts (regardless of ECDH coverage) + print(" Checking if we can sign controlled inputs...") + all_outputs_have_scripts = all( + any(field.field_type == PSBTFieldType.PSBT_OUT_SCRIPT for field in output_fields) + for output_fields in self.output_maps + ) + + if all_outputs_have_scripts: + print(f" All outputs have scripts - signing controlled inputs {controlled_input_indices}...") + success = self._sign_controlled_inputs(inputs, controlled_input_indices) + if not success: + print("❌ Signing failed") + return False + print(" Signatures added successfully") + else: + print("⚠️ Some outputs missing scripts - cannot sign yet") + # For multi-signer: still sign inputs even without complete coverage + # This allows incremental signing as each party processes their inputs + if not is_complete: + print(f" Signing controlled inputs {controlled_input_indices} for partial workflow...") + success = self._sign_controlled_inputs(inputs, controlled_input_indices) + if not success: + print("❌ Partial signing failed") + return False + print(" Partial signatures added successfully") + + print(" SIGNER (partial): Completed successfully") + return True + + def _check_psbt_modifiable(self) -> bool: + """ + Check if the PSBT is still modifiable based on PSBT_GLOBAL_TX_MODIFIABLE flags + + Returns: + True if PSBT can be modified, False if finalized + """ + # Check for TX_MODIFIABLE field + for field in self.global_fields: + if field.field_type == PSBTFieldType.PSBT_GLOBAL_TX_MODIFIABLE: + if len(field.value_data) >= 1: + modifiable_flags = field.value_data[0] + # 0x03 = both inputs and outputs modifiable + # 0x02 = only outputs modifiable + # 0x01 = only inputs modifiable + # 0x00 = neither inputs nor outputs modifiable (finalized) + return modifiable_flags != 0x00 + + # If no TX_MODIFIABLE field found, assume modifiable (default state) + return True + + def _verify_existing_dleq_proofs(self, inputs: List[UTXO], controlled_input_indices: List[int]) -> bool: + """ + Verify DLEQ proofs from other signers (for inputs we don't control) + """ + # Get list of inputs we don't control + uncontrolled_indices = [i for i in range(len(self.input_maps)) if i not in controlled_input_indices] + + if not uncontrolled_indices: + print(" No other signers' proofs to verify") + return True + + # Check for global DLEQ proofs first + for field in self.global_fields: + if field.field_type == PSBTFieldType.PSBT_GLOBAL_SP_DLEQ: + print(" Found global DLEQ proof - verifying...") + # For now, assume global proofs are valid if structurally correct + # In full implementation, would need to verify against combined pubkeys + if len(field.value_data) == 64: + print(" Global DLEQ proof verification passed") + return True + else: + print("❌ Invalid global DLEQ proof length") + return False + + # Check per-input DLEQ proofs for uncontrolled inputs + verified_count = 0 + for input_index in uncontrolled_indices: + if input_index < len(self.input_maps): + input_fields = self.input_maps[input_index] + has_ecdh = any(field.field_type == PSBTFieldType.PSBT_IN_SP_ECDH_SHARE for field in input_fields) + has_dleq = any(field.field_type == PSBTFieldType.PSBT_IN_SP_DLEQ for field in input_fields) + + if has_ecdh and has_dleq: + # Verify the DLEQ proof cryptographically + if self._verify_input_dleq_proof(input_index, inputs): + verified_count += 1 + print(f" Input {input_index} DLEQ proof verification passed") + else: + print(f"❌ Input {input_index} DLEQ proof verification failed") + return False + elif has_ecdh: + print(f"❌ Input {input_index} has ECDH share but missing DLEQ proof") + return False + + print(f" Verified {verified_count} DLEQ proof(s) from other signers") + return True + + def _verify_input_dleq_proof(self, input_index: int, inputs: List[UTXO]) -> bool: + """ + Cryptographically verify DLEQ proof for a specific input + + Args: + input_index: Index of input to verify + inputs: List of UTXO inputs + + Returns: + bool: True if verification succeeds, False otherwise + """ + from .psbt_utils import extract_input_pubkey + + if input_index >= len(self.input_maps): + return False + + input_fields = self.input_maps[input_index] + input_field_dict = {field.field_type: field for field in input_fields} + + # Check if DLEQ proof and ECDH share exist + if (PSBTFieldType.PSBT_IN_SP_DLEQ not in input_field_dict or + PSBTFieldType.PSBT_IN_SP_ECDH_SHARE not in input_field_dict): + return False + + try: + # Extract DLEQ proof and ECDH share + dleq_field = input_field_dict[PSBTFieldType.PSBT_IN_SP_DLEQ] + ecdh_field = input_field_dict[PSBTFieldType.PSBT_IN_SP_ECDH_SHARE] + + # Parse scan key from DLEQ field key_data + scan_key_bytes = dleq_field.key_data + if len(scan_key_bytes) != 33: + return False + + # Parse points from bytes + scan_key_point = GE.from_bytes(scan_key_bytes) + ecdh_result_point = GE.from_bytes(ecdh_field.value_data) + + # Get input public key from PSBT fields using utility function + input_public_key_point = extract_input_pubkey( + input_fields=input_fields, + inputs=inputs, + input_index=input_index + ) + + if input_public_key_point is None: + return False + + # Verify DLEQ proof: dleq_verify_proof(A, B, C, proof) + # A = input_public_key, B = scan_key, C = ecdh_result + proof_verified = dleq_verify_proof( + input_public_key_point, # A (input pubkey) + scan_key_point, # B (scan key) + ecdh_result_point, # C (ECDH result) + dleq_field.value_data # proof + ) + + return proof_verified + + except Exception: + return False + + def _add_partial_ecdh_shares(self, inputs: List[UTXO], controlled_input_indices: List[int], + scan_keys: List[PublicKey]) -> None: + """ + Add ECDH shares for controlled inputs only (per-input approach) + + Delegates to PSBTSigner.add_ecdh_shares_for_inputs() + """ + PSBTSigner.add_ecdh_shares_for_inputs( + input_maps=self.input_maps, + inputs=inputs, + input_indices=controlled_input_indices, + scan_keys=scan_keys + ) + + for input_index in controlled_input_indices: + for scan_key in scan_keys: + print(f" Added ECDH share for input {input_index}, scan key {scan_key.bytes.hex()}") + + def _sign_controlled_inputs(self, inputs: List[UTXO], controlled_input_indices: List[int]) -> bool: + """ + Sign only the controlled inputs + + Delegates to PSBTSigner.sign_specific_inputs() + """ + if not controlled_input_indices: + print(" No controlled inputs to sign") + return True + + try: + signatures_added = PSBTSigner.sign_specific_inputs( + input_maps=self.input_maps, + output_maps=self.output_maps, + inputs=inputs, + input_indices=controlled_input_indices + ) + + if signatures_added == 0: + print("❌ No inputs were signed successfully") + return False + + print(f" Successfully signed {signatures_added} input(s)") + return True + + except Exception as e: + print(f"❌ Failed to sign controlled inputs: {e}") + return False + + # endregion + + # ============================================================================ + # region TRANSACTION EXTRACTOR ROLE - Build Final Transaction + # ============================================================================ + + def extract_transaction(self) -> bytes: + """ + Extract the final Bitcoin transaction from the completed PSBT (TRANSACTION EXTRACTOR ROLE) + + Returns: + Serialized transaction bytes + + Note: + This method delegates to the standalone extract_transaction() function + in transaction.py, automatically providing the PSBT field data. + """ + # Validation before extraction + is_valid, errors = validate_psbt_silent_payments(self) + if not is_valid: + raise ValueError(f"PSBT validation failed before extraction: {errors}") + + # Delegate to PSBTExtractor role + from .roles import PSBTExtractor + return PSBTExtractor.extract_transaction(self.global_fields, self.input_maps, self.output_maps) + + # endregion + + # ============================================================================ + # region FILE I/O - Save/Load PSBTs + # ============================================================================ + + def save_psbt_to_file(self, filename: str, metadata: Optional[Dict] = None) -> None: + """ + Save PSBT to JSON file with metadata for multi-signer workflows + + Args: + filename: File path to save to + metadata: Optional metadata dict with step info, completed_by, etc. + + Note: + This method delegates to the standalone save_psbt_to_file() function + in psbt_io.py, automatically providing psbt and psbt_json. + """ + from .psbt_io import save_psbt_to_file as _save_psbt_to_file + + _save_psbt_to_file( + psbt=self.encode(), + filename=filename, + metadata=metadata, + psbt_json=self.to_json() + ) + + @classmethod + def load_psbt_from_file(cls, filename: str) -> Tuple['SilentPaymentPSBT', Dict]: + """ + Load PSBT from JSON file with metadata + + Args: + filename: File path to load from + + Returns: + Tuple of (SilentPaymentPSBT instance, metadata dict) + + Note: + This method delegates to the standalone load_psbt_from_file() function + in psbt_io.py and wraps the result in a SilentPaymentPSBT instance. + """ + from .psbt_io import load_psbt_from_file as _load_psbt_from_file + + # Load raw PSBT fields + global_fields, input_maps, output_maps, metadata = _load_psbt_from_file(filename) + + # Wrap in SilentPaymentPSBT instance + psbt = cls() + psbt.global_fields = global_fields + psbt.input_maps = input_maps + psbt.output_maps = output_maps + + return psbt, metadata + + @classmethod + def from_base64(cls, psbt_base64: str) -> 'SilentPaymentPSBT': + """ + Create SilentPaymentPSBT from base64-encoded PSBT string + + Args: + psbt_base64: Base64-encoded PSBT string + + Returns: + SilentPaymentPSBT instance + + Raises: + ValueError: If PSBT data is invalid + + Note: + This is a factory method that decodes and parses the PSBT in one step. + """ + import base64 + from .serialization import parse_psbt_bytes + + # Decode and parse + psbt_data = base64.b64decode(psbt_base64) + global_fields, input_maps, output_maps = parse_psbt_bytes(psbt_data) + + # Wrap in instance + psbt = cls() + psbt.global_fields = global_fields + psbt.input_maps = input_maps + psbt.output_maps = output_maps + + return psbt + + # endregion + + # ============================================================================ + # region UTILITY METHODS - Helpers & Queries + # ============================================================================ + + def check_ecdh_coverage(self) -> Tuple[bool, List[int]]: + """ + Check which inputs have ECDH shares and if coverage is complete + + Returns: + Tuple of (is_complete, list_of_input_indices_with_ecdh) + + Note: + This method delegates to the standalone check_ecdh_coverage() function + in psbt_utils.py, automatically providing the PSBT field data. + """ + from .psbt_utils import check_ecdh_coverage as _check_ecdh_coverage + return _check_ecdh_coverage(self.global_fields, self.input_maps) + + def can_compute_output_scripts(self) -> bool: + """ + Check if we can compute output scripts (have complete ECDH coverage) + + Returns: + True if ready to compute output scripts, False otherwise + """ + is_complete, _ = self.check_ecdh_coverage() + return is_complete + + def get_inputs_with_ecdh_shares(self) -> List[int]: + """ + Get list of input indices that have ECDH shares + + Returns: + List of input indices with ECDH shares + """ + _, inputs_with_ecdh = self.check_ecdh_coverage() + return inputs_with_ecdh + + def compute_unique_id(self) -> bytes: + """ + Compute unique identifier for this PSBT per BIP375 + + Per BIP375: For silent payment capable PSBTs, outputs with PSBT_OUT_SP_V0_INFO + use that field (not PSBT_OUT_SCRIPT) for unique identification to prevent malleability. + + Returns: + 32-byte SHA256 hash of the unsigned transaction (unique identifier) + + Note: + This method delegates to the standalone compute_psbt_unique_id() function. + """ + return compute_psbt_unique_id(self) + + # endregion + + +# ============================================================================ +# region STANDALONE VALIDATION FUNCTION +# ============================================================================ + +def validate_psbt_silent_payments(psbt: SilentPaymentPSBT) -> Tuple[bool, List[str]]: + """ + Validate a PSBT with silent payments according to BIP 375 rules + + Args: + psbt: PSBT to validate + + Returns: + (is_valid, list_of_errors) + """ + + errors = [] + + # Validate global fields + has_psbt_version = False + has_tx_version = False + has_input_count = False + has_output_count = False + + for field in psbt.global_fields: + if field.field_type == PSBTFieldType.PSBT_GLOBAL_VERSION: + has_psbt_version = True + psbt_version = struct.unpack('= 4: + sighash_type = struct.unpack(' bytes: + """ + Compute unique identifier for a silent payment capable PSBT per BIP375 + + Per BIP375: "Silent payment capable PSBTs can be uniquely identified the same way as PSBTv2s, + except when including silent payment outputs. If an output contains the PSBT_OUT_SP_V0_INFO + field, it must use that field instead of PSBT_OUT_SCRIPT as the output script when creating + the unsigned transaction used for unique identification." + + The PSBT_OUT_SP_V0_INFO field contains 66 bytes (33 byte scan key + 33 byte spend key). + When computing the unique ID, this 66-byte value is used directly as the output script. + + Args: + psbt: The PSBT to compute identifier for + + Returns: + 32-byte SHA256 hash of the unsigned transaction (unique identifier) + """ + import hashlib + + # Build unsigned transaction for identification + tx_data = b'' + + # Extract version from PSBT_GLOBAL_TX_VERSION + version = 2 # Default + for field in psbt.global_fields: + if field.field_type == PSBTFieldType.PSBT_GLOBAL_TX_VERSION: + version = struct.unpack(' None: + """ + Save PSBT to JSON file with metadata for multi-signer workflows + + Args: + psbt: Base64-encoded PSBT (source of truth) + filename: File path to save to + metadata: Optional metadata dict with step info, completed_by, etc. + psbt_json: Optional JSON representation from psbt.to_json() for human inspection + (derived data, not used programmatically) + + Note: + The psbt_json parameter should be derived from psbt.to_json() and is included + only for human readability. All programmatic operations should use psbt. + """ + # Create default metadata if none provided + if metadata is None: + metadata = {} + + # Add timestamp + metadata['timestamp'] = datetime.datetime.utcnow().isoformat() + 'Z' + + # Prepare JSON data + json_data = { + 'psbt': psbt, + 'metadata': metadata + } + + # Add human-readable PSBT representation if provided + if psbt_json: + json_data['psbt_json'] = psbt_json + + # Write to file + with open(filename, 'w') as f: + json.dump(json_data, f, indent=2) + + +def load_psbt_from_file(filename: str) -> Tuple[List[PSBTField], List[List[PSBTField]], List[List[PSBTField]], Dict]: + """ + Load PSBT from JSON file with metadata + + Args: + filename: File path to load from + + Returns: + Tuple of (global_fields, input_maps, output_maps, metadata) + """ + with open(filename, 'r') as f: + json_data = json.load(f) + + # Decode PSBT from base64 + psbt_data = base64.b64decode(json_data['psbt']) + + # Parse PSBT structure + global_fields, input_maps, output_maps = parse_psbt_bytes(psbt_data) + + metadata = json_data.get('metadata', {}) + + return global_fields, input_maps, output_maps, metadata diff --git a/bip-0375/tests/psbt_sp/psbt_utils.py b/bip-0375/tests/psbt_sp/psbt_utils.py new file mode 100644 index 0000000000..48adaa42c8 --- /dev/null +++ b/bip-0375/tests/psbt_sp/psbt_utils.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +PSBT Utility Functions + +Helper functions for extracting and processing PSBT data. +""" + +from typing import List, Optional +from .constants import PSBTFieldType +from secp256k1_374 import GE, G, FE +from .serialization import PSBTField +from .crypto import UTXO + +# TODO: Add explicit is_p2wpkh(), is_p2pkh(), is_p2sh_p2wpkh() helpers +# TODO: Add extract other input types + +def is_taproot_output(script_pubkey: bytes) -> bool: + """ + Check if script_pubkey is a Taproot (Segwit v1) output + + Taproot format: 0x5120 || 32-byte x-only pubkey + + Args: + script_pubkey: The scriptPubKey bytes to check + + Returns: + True if this is a Taproot output, False otherwise + """ + return len(script_pubkey) == 34 and script_pubkey[0] == 0x51 and script_pubkey[1] == 0x20 + + +def extract_taproot_pubkey(input_fields: List[PSBTField]) -> Optional[GE]: + """ + Extract Taproot internal public key from PSBT input fields. + + Looks for PSBT_IN_TAP_INTERNAL_KEY field and lifts the x-only key to a full point. + + Args: + input_fields: List of PSBT fields for the input + + Returns: + Lifted public key point, or None if field not found + """ + for field in input_fields: + if field.field_type == PSBTFieldType.PSBT_IN_TAP_INTERNAL_KEY: + if len(field.value_data) == 32: + # Lift x-only key to full point (assumes even y-coordinate per BIP340) + x_coord = int.from_bytes(field.value_data, 'big') + # Validate x-coordinate is in valid range (must be < field prime) + if x_coord >= FE.SIZE: + return None + try: + return GE.lift_x(x_coord) + except Exception: + return None + return None + + +def extract_input_pubkey(input_fields: List[PSBTField], inputs: List[UTXO] = None, input_index: int = None) -> Optional[GE]: + """ + Extract public key for a specific input from PSBT fields + + Priority order (per BIP174 best practices): + 1. PSBT_IN_BIP32_DERIVATION (preferred - standard way, hardware wallet compatible) + 2. PSBT_IN_TAP_INTERNAL_KEY (for Taproot inputs) + 3. PSBT_IN_PARTIAL_SIG (public key is the key field) + 4. Derive from private key (fallback for reference implementation) + + Args: + input_fields: List of PSBT fields for the input + inputs: Optional list of UTXO objects (for fallback extraction from private key) + input_index: Optional input index (required if using inputs for fallback) + + Returns: + Public key point, or None if not found + """ + # Method 1: Extract from BIP32 derivation field (HIGHEST PRIORITY) + # This is the standard BIP174 way and supports hardware wallets + for field in input_fields: + if field.field_type == PSBTFieldType.PSBT_IN_BIP32_DERIVATION: + try: + # BIP32 derivation format: key is 33-byte compressed pubkey + # value is <4-byte fingerprint><32-bit path elements> or empty for privacy + if len(field.key_data) == 33: + return GE.from_bytes(field.key_data) + except Exception: + continue + + # Method 2: Extract from Taproot internal key (for Taproot inputs) + # This handles key path spending for Taproot (Segwit v1) + taproot_pubkey = extract_taproot_pubkey(input_fields) + if taproot_pubkey is not None: + return taproot_pubkey + + # Method 3: Extract from partial signature field + # Public key is in the key field of PSBT_IN_PARTIAL_SIG + for field in input_fields: + if field.field_type == PSBTFieldType.PSBT_IN_PARTIAL_SIG: + try: + if len(field.key_data) == 33: + return GE.from_bytes(field.key_data) + except Exception: + continue + + # Method 3: Derive from private key (FALLBACK - reference implementation only) + # This should NOT be used in production hardware wallet flows + if inputs and input_index is not None and input_index < len(inputs): + utxo = inputs[input_index] + if hasattr(utxo, 'private_key') and utxo.private_key is not None: + try: + input_private_key_int = int(utxo.private_key) + input_public_key_point = input_private_key_int * G + return input_public_key_point + except Exception: + pass + + return None + + +def extract_combined_input_pubkeys(input_maps: List[List[PSBTField]], inputs: List[UTXO] = None) -> Optional[GE]: + """ + Extract and combine all input public keys for global DLEQ verification + + Args: + input_maps: List of input field lists + inputs: Optional list of UTXO objects (for fallback extraction) + + Returns: + Combined public key point (sum of all input pubkeys), or None if extraction fails + """ + A_combined = None + + for input_index, input_fields in enumerate(input_maps): + pubkey = extract_input_pubkey(input_fields, inputs, input_index) + + if pubkey is None: + return None + + if A_combined is None: + A_combined = pubkey + else: + A_combined = A_combined + pubkey + + return A_combined + + +def check_ecdh_coverage(global_fields: List[PSBTField], input_maps: List[List[PSBTField]]) -> tuple[bool, List[int]]: + """ + Check which inputs have ECDH shares and if coverage is complete + + Args: + global_fields: List of global PSBT fields + input_maps: List of input field lists + + Returns: + Tuple of (is_complete, list_of_input_indices_with_ecdh) + """ + inputs_with_ecdh = [] + + # Check for global ECDH shares (covers all inputs if present) + has_global_ecdh = any( + field.field_type == PSBTFieldType.PSBT_GLOBAL_SP_ECDH_SHARE + for field in global_fields + ) + + if has_global_ecdh: + # Global ECDH covers all inputs + inputs_with_ecdh = list(range(len(input_maps))) + is_complete = True + else: + # Check per-input ECDH shares + for i, input_fields in enumerate(input_maps): + has_input_ecdh = any( + field.field_type == PSBTFieldType.PSBT_IN_SP_ECDH_SHARE + for field in input_fields + ) + if has_input_ecdh: + inputs_with_ecdh.append(i) + + # Complete if all inputs have ECDH shares + is_complete = len(inputs_with_ecdh) == len(input_maps) + + return is_complete, inputs_with_ecdh + + +def extract_scan_keys_from_outputs(output_maps: List[List[PSBTField]]) -> List[bytes]: + """ + Extract unique scan keys from silent payment outputs + + Args: + output_maps: List of output field lists + + Returns: + List of unique scan key bytes (33 bytes each) + """ + scan_keys = [] + + for output_fields in output_maps: + for field in output_fields: + if field.field_type == PSBTFieldType.PSBT_OUT_SP_V0_INFO: + if len(field.value_data) == 66: # 33 + 33 bytes + scan_key_bytes = field.value_data[:33] + if scan_key_bytes not in scan_keys: + scan_keys.append(scan_key_bytes) + + return scan_keys diff --git a/bip-0375/tests/psbt_sp/roles.py b/bip-0375/tests/psbt_sp/roles.py new file mode 100644 index 0000000000..0ee498ad5e --- /dev/null +++ b/bip-0375/tests/psbt_sp/roles.py @@ -0,0 +1,1117 @@ +#!/usr/bin/env python3 +""" +BIP 174/370/375 PSBT Role-Based Classes + +Implements the distinct roles defined in Bitcoin PSBT specifications: +- Creator: Creates the initial PSBT structure +- Constructor: Adds inputs and outputs +- Updater: Adds BIP32 derivation info for hardware wallet compatibility +- Signer: Computes ECDH shares, generates DLEQ proofs, and signs inputs +- Input Finalizer: Computes final output scripts for silent payments +- Extractor: Extracts final Bitcoin transaction from completed PSBT +""" + +import struct +import hashlib +from typing import List, Dict, Optional, Tuple +from .constants import PSBTFieldType +from secp256k1_374 import GE, G +from .serialization import PSBTField +from .crypto import Wallet, PublicKey, UTXO, sign_p2wpkh_input +from .bip352_crypto import ( + apply_label_to_spend_key, + derive_silent_payment_output_pubkey, + pubkey_to_p2tr_script +) +from dleq_374 import dleq_generate_proof + + +class PSBTCreator: + """ + Creator Role: Initializes PSBT with base fields + + Responsibilities: + - Create PSBT v2 global fields (version, input/output counts, modifiable flags) + """ + + @staticmethod + def create_base_psbt(num_inputs: int, num_outputs: int) -> Tuple[List[PSBTField], List[List[PSBTField]], List[List[PSBTField]]]: + """ + Create PSBT v2 base structure with required global fields + + Args: + num_inputs: Number of transaction inputs + num_outputs: Total number of outputs (regular + silent payment) + + Returns: + Tuple of (global_fields, empty input_maps, empty output_maps) + """ + global_fields = [] + + # PSBT v2 requires these global fields + global_fields.append(PSBTField(PSBTFieldType.PSBT_GLOBAL_VERSION, b'', struct.pack(' Optional[int]: + """ + Extract segwit version from script_pubkey + + Args: + script_pubkey: Script public key bytes + + Returns: + Segwit version (0, 1, 2, ..., 16) or None if not segwit + """ + if len(script_pubkey) < 2: + return None + + # Segwit scriptPubkey format: + # Version byte: OP_0 (0x00) or OP_1..OP_16 (0x51..0x60) + version_byte = script_pubkey[0] + + if version_byte == 0x00: + # Segwit v0 (P2WPKH or P2WSH) + return 0 + elif 0x51 <= version_byte <= 0x60: + # Segwit v1-v16 (Taproot = v1) + return version_byte - 0x50 + else: + # Not segwit (P2PKH, P2SH, etc.) + return None + + @staticmethod + def _check_segwit_version_restrictions( + input_maps: List[List[PSBTField]], + output_maps: List[List[PSBTField]] + ) -> None: + """ + Validate BIP 375 Segwit version restrictions + + BIP 375: Cannot mix inputs spending Segwit v>1 with silent payment outputs + + Args: + input_maps: List of input field lists + output_maps: List of output field lists + + Raises: + ValueError: If Segwit version restrictions are violated + """ + # Check if any output is a silent payment + has_silent_payment_output = False + for output_idx, output_fields in enumerate(output_maps): + for field in output_fields: + if field.field_type == PSBTFieldType.PSBT_OUT_SP_V0_INFO: + has_silent_payment_output = True + break + if has_silent_payment_output: + break + + if not has_silent_payment_output: + # No silent payment outputs, no restriction applies + return + + # Check each input for Segwit version > 1 + for input_idx, input_fields in enumerate(input_maps): + for field in input_fields: + if field.field_type == PSBTFieldType.PSBT_IN_WITNESS_UTXO: + # Parse witness UTXO to extract script_pubkey + if len(field.value_data) < 9: + continue + + # Format: <8-byte amount> <1-byte script length>