diff --git a/bip-0375/test_runner.py b/bip-0375/test_runner.py new file mode 100644 index 00000000..c4fb03ac --- /dev/null +++ b/bip-0375/test_runner.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Process test vectors JSON file and run validation checks""" + +import argparse +import json +from pathlib import Path +import sys +from typing import Tuple + +project_root = Path(__file__).parent +deps_dir = project_root / "deps" +secp256k1lab_dir = deps_dir / "secp256k1lab" / "src" +for path in [str(deps_dir), str(secp256k1lab_dir)]: + if path not in sys.path: + sys.path.insert(0, path) + +from validator.psbt_bip375 import BIP375PSBT +from validator.validate_psbt import ( + validate_psbt_structure, + validate_ecdh_coverage, + validate_input_eligibility, + validate_output_scripts, +) + +CHECK_FUNCTIONS = { + "psbt_structure": validate_psbt_structure, + "ecdh_coverage": validate_ecdh_coverage, + "input_eligibility": validate_input_eligibility, + "output_scripts": validate_output_scripts, +} + + +def validate_bip375_psbt( + psbt_data: str, checks: list[str], debug: bool = False +) -> Tuple[bool, str]: + """Performs sequential validation of a PSBT against BIP-375 rules""" + psbt = BIP375PSBT.from_base64(psbt_data) + + if checks is None: + checks = [ + "psbt_structure", + "ecdh_coverage", + "input_eligibility", + "output_scripts", + ] + + for check_name in checks: + if check_name not in CHECK_FUNCTIONS: + return False, f"Unknown check: {check_name}" + + check_fn = CHECK_FUNCTIONS[check_name] + + is_valid, msg = check_fn(psbt) + if debug: + msg = f"{check_name.upper()}: {msg}" if msg else msg + + if not is_valid: + return False, msg + + return True, "All checks passed" + + +def load_test_vectors(filename: str) -> dict: + """Load test vectors from JSON file""" + 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 run_validation_tests(test_data: dict, verbosity: int = 0) -> tuple[int, int]: + """Run validation checks for each test vector""" + passed = 0 + failed = 0 + + # Process invalid PSBTs (should fail validation) + invalid_tests = test_data.get("invalid", []) + print(f"Invalid PSBTs: {len(invalid_tests)}") + for test_vector in invalid_tests: + is_valid, result = validate_bip375_psbt( + test_vector["psbt"], test_vector.get("checks"), debug=verbosity >= 2 + ) + print(f"{test_vector['description']}") + if not is_valid: + passed += 1 + if verbosity >= 1: + print(f" {result}") + else: + failed += 1 + if result: + print(f" ERROR: {result}") + + # Process valid PSBTs (should pass validation) + valid_tests = test_data.get("valid", []) + print("") + print(f"Valid PSBTs: {len(valid_tests)}") + for test_vector in valid_tests: + is_valid, result = validate_bip375_psbt( + test_vector["psbt"], test_vector.get("checks"), debug=verbosity >= 2 + ) + + print(f"{test_vector['description']}") + if is_valid: + passed += 1 + if verbosity >= 1: + print(f" {result}") + else: + failed += 1 + if result: + print(f" ERROR: {result}") + + return passed, failed + + +def main(): + parser = argparse.ArgumentParser( + description="Silent Payments PSBT Validator", + ) + parser.add_argument( + "--test-file", + "-f", + default=str(project_root / "bip375_test_vectors.json"), + help="Test vector file to run (default: bip375_test_vectors.json)", + ) + parser.add_argument( + "-v", + dest="verbosity", + action="count", + default=0, + help="Verbosity level: -v shows pass/fail details, -vv enables debug output", + ) + + args = parser.parse_args() + + test_data = load_test_vectors(args.test_file) + + print(f"Description: {test_data.get('description', 'N/A')}") + print(f"Version: {test_data.get('version', 'N/A')}") + print() + + passed, failed = run_validation_tests(test_data, args.verbosity) + + print() + print(f"Summary: {passed} passed, {failed} failed") + + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/bip-0375/validator/validate_psbt.py b/bip-0375/validator/validate_psbt.py new file mode 100644 index 00000000..64152e0b --- /dev/null +++ b/bip-0375/validator/validate_psbt.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Validates PSBTs according to BIP-375 rules + +Provides independent checks for PSBT structure, ECDH share coverage, +input eligibility, and output script correctness. +""" + +from typing import Tuple + +from deps.bitcoin_test.psbt import ( + PSBT, + PSBT_GLOBAL_TX_MODIFIABLE, + PSBT_OUT_SCRIPT, +) + +from .psbt_bip375 import ( + PSBT_GLOBAL_SP_ECDH_SHARE, + PSBT_GLOBAL_SP_DLEQ, + PSBT_IN_SP_ECDH_SHARE, + PSBT_IN_SP_DLEQ, + PSBT_OUT_SP_V0_INFO, + PSBT_OUT_SP_V0_LABEL, +) + + +def validate_psbt_structure(psbt: PSBT) -> Tuple[bool, str]: + """ + Validate PSBT structure requirements + + Checks: + - Each output must have PSBT_OUT_SCRIPT or PSBT_OUT_SP_V0_INFO + - PSBT_OUT_SP_V0_LABEL requires PSBT_OUT_SP_V0_INFO + - SP_V0_INFO must be 66 bytes (33-byte scan key + 33-byte spend key) + - ECDH shares must be 33 bytes + - DLEQ proofs must be 64 bytes + - TX_MODIFIABLE is zero when PSBT_OUT_SCRIPT set for SP output + """ + # Check output requirements + for i, output_map in enumerate(psbt.o): + has_script = ( + PSBT_OUT_SCRIPT in output_map and len(output_map[PSBT_OUT_SCRIPT]) > 0 + ) + has_sp_info = PSBT_OUT_SP_V0_INFO in output_map + has_sp_label = PSBT_OUT_SP_V0_LABEL in output_map + + # Output must have script or SP info + 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", + ) + + # SP label requires SP 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", + ) + + # Validate SP_V0_INFO field length + if has_sp_info: + sp_info = output_map[PSBT_OUT_SP_V0_INFO] + if len(sp_info) != 66: + return ( + False, + f"Output {i} SP_V0_INFO has wrong length ({len(sp_info)} bytes, expected 66)", + ) + + # Validate ECDH share lengths (global and per-input) + global_ecdh_shares = psbt.g.get_all_by_type(PSBT_GLOBAL_SP_ECDH_SHARE) + for _, ecdh_share in global_ecdh_shares: + if len(ecdh_share) != 33: + return ( + False, + f"Global ECDH share has wrong length ({len(ecdh_share)} bytes, expected 33)", + ) + + for i, input_map in enumerate(psbt.i): + input_ecdh_shares = input_map.get_all_by_type(PSBT_IN_SP_ECDH_SHARE) + for _, ecdh_share in input_ecdh_shares: + if len(ecdh_share) != 33: + return ( + False, + f"Input {i} ECDH share has wrong length ({len(ecdh_share)} bytes, expected 33)", + ) + + # Validate DLEQ proof lengths (global and per-input) + global_dleq_proofs = psbt.g.get_all_by_type(PSBT_GLOBAL_SP_DLEQ) + for _, dleq_proof in global_dleq_proofs: + if len(dleq_proof) != 64: + return ( + False, + f"Global DLEQ proof has wrong length ({len(dleq_proof)} bytes, expected 64)", + ) + + for i, input_map in enumerate(psbt.i): + input_dleq_proofs = input_map.get_all_by_type(PSBT_IN_SP_DLEQ) + for _, dleq_proof in input_dleq_proofs: + if len(dleq_proof) != 64: + return ( + False, + f"Input {i} DLEQ proof has wrong length ({len(dleq_proof)} bytes, expected 64)", + ) + + # Check TX_MODIFIABLE flag when PSBT_OUT_SCRIPT is set + for output_map in psbt.o: + if PSBT_OUT_SP_V0_INFO in output_map and PSBT_OUT_SCRIPT in output_map: + if len(output_map.get(PSBT_OUT_SCRIPT, b"")) > 0: + if psbt.g.get(PSBT_GLOBAL_TX_MODIFIABLE) != b"\x00": + return ( + False, + "PSBT_OUT_SCRIPT set for silent payments output but PSBT_GLOBAL_TX_MODIFIABLE not zeroed", + ) + return True, None + + +def validate_ecdh_coverage(psbt: PSBT) -> Tuple[bool, str]: + return False, "ECDH coverage check not implemented yet" + + +def validate_input_eligibility(psbt: PSBT) -> Tuple[bool, str]: + return False, "Input eligibility check not implemented yet" + + +def validate_output_scripts(psbt: PSBT) -> Tuple[bool, str]: + return False, "Output scripts check not implemented yet"