From ad4b24588485ea959155a6487488f980b5195ab0 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 26 Aug 2024 08:47:38 -0500 Subject: [PATCH 01/61] Initial BIP-352 support --- src/embit/bip352.py | 25 +++++++++++++++++++++ tests/tests/test_bip352.py | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/embit/bip352.py create mode 100644 tests/tests/test_bip352.py diff --git a/src/embit/bip352.py b/src/embit/bip352.py new file mode 100644 index 00000000..0ed009d1 --- /dev/null +++ b/src/embit/bip352.py @@ -0,0 +1,25 @@ +""" +BIP-352: Silent Payments +see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki + +TODO: +* Add support for SP labels. +* Implement deriving a destination addr for a given output and recipient SP address. +* Implement check to determine if a given output is an SP output for a given SP address. +* Implement signing SP spends (once psbt format is settled). +""" + +from embit import bech32, ec + + +def generate_silent_payment_address(B_scan: ec.PublicKey, B_m: ec.PublicKey, network: str = "main", version: int = 0) -> str: + """ + Adapted from https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py + + Generates the recipient's reusable silent payment address for a given: + * scanning pubkey `B_scan` + * spending pubkey `B_m` + """ + data = bech32.convertbits(B_scan.sec() + B_m.sec(), 8, 5) + hrp = "sp" if network == "main" else "tsp" + return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data) diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py new file mode 100644 index 00000000..e0d73ce5 --- /dev/null +++ b/tests/tests/test_bip352.py @@ -0,0 +1,45 @@ +""" +BIP-352 test vectors: +https://github.com/bitcoin/bips/blob/master/bip-0352/send_and_receive_test_vectors.json +""" + +from binascii import unhexlify +from unittest import TestCase +from embit import bip352 +from embit.ec import PrivateKey +from embit.networks import NETWORKS + + +BASIC_TEST_VECTORS = [ + { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c", + "sp_address": "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + }, + { + "spend_priv_key": "0000000000000000000000000000000000000000000000000000000000000001", + "scan_priv_key": "0000000000000000000000000000000000000000000000000000000000000002", + "sp_address": "sp1qqtrqglu5g8kh6mfsg4qxa9wq0nv9cauwfwxw70984wkqnw2uwz0w2qnehen8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq6lqj3s" + } +] + + +class BIP352Test(TestCase): + def test_generate_silent_payment_address(self): + """ Should generate the expected silent payment address """ + for test_vector in BASIC_TEST_VECTORS: + spend_priv_key = PrivateKey(unhexlify(test_vector["spend_priv_key"])) + scan_priv_key = PrivateKey(unhexlify(test_vector["scan_priv_key"])) + sp_address = bip352.generate_silent_payment_address(scan_priv_key.get_public_key(), spend_priv_key.get_public_key()) + assert sp_address == test_vector["sp_address"] + + + def test_generate_silent_payment_address_for_network(self): + """ Test network silent payment addrs should start with "tsp" """ + test_networks = [k for k in NETWORKS.keys() if k != "main"] + scan_pubkey = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["spend_priv_key"])).get_public_key() + spend_pubkey = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"])).get_public_key() + + for network in test_networks: + payment_addr = bip352.generate_silent_payment_address(scan_pubkey, spend_pubkey, network=network) + assert payment_addr.startswith("tsp") From 575e80b6f2b8cac989bfd7733f69f3cd46cc69af Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 31 Dec 2024 13:38:35 -0600 Subject: [PATCH 02/61] Add labeled SP addrs --- src/embit/bip352.py | 36 +++++++++++++++++++++++++-------- tests/tests/test_bip352.py | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/embit/bip352.py b/src/embit/bip352.py index 0ed009d1..0a33bef2 100644 --- a/src/embit/bip352.py +++ b/src/embit/bip352.py @@ -3,23 +3,43 @@ see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki TODO: -* Add support for SP labels. * Implement deriving a destination addr for a given output and recipient SP address. * Implement check to determine if a given output is an SP output for a given SP address. * Implement signing SP spends (once psbt format is settled). """ - from embit import bech32, ec +from embit.util import secp256k1 +from embit.hashes import tagged_hash + -def generate_silent_payment_address(B_scan: ec.PublicKey, B_m: ec.PublicKey, network: str = "main", version: int = 0) -> str: +def generate_silent_payment_address(B_scan: ec.PublicKey, B_spend: ec.PublicKey, network: str = "main", version: int = 0) -> str: """ Adapted from https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py - - Generates the recipient's reusable silent payment address for a given: - * scanning pubkey `B_scan` - * spending pubkey `B_m` """ - data = bech32.convertbits(B_scan.sec() + B_m.sec(), 8, 5) + data = bech32.convertbits(B_scan.sec() + B_spend.sec(), 8, 5) hrp = "sp" if network == "main" else "tsp" return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data) + + + +def generate_labeled_silent_payment_address(b_scan: ec.PrivateKey, B_spend: ec.PublicKey, label, network: str = "main", version: int = 0) -> str: + """ + The spending key is tweaked with the label to generate a labeled silent payment address. + see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#address-encoding + + `label` must be an int, str, or bytes. + """ + if isinstance(label, int): + label_bytes = label.to_bytes(4, "big") + elif isinstance(label, str): + label_bytes = label.encode() + elif isinstance(label, bytes): + label_bytes = label + else: + raise Exception("Label must be an int, str, or bytes.") + + tweak = tagged_hash("BIP0352/Label", b_scan.secret + label_bytes) + label_pubkey = ec.PublicKey(secp256k1.ec_pubkey_add(secp256k1.ec_pubkey_parse(B_spend.sec()), tweak)) + + return generate_silent_payment_address(b_scan.get_public_key(), label_pubkey, network=network, version=version) diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py index e0d73ce5..f613a199 100644 --- a/tests/tests/test_bip352.py +++ b/tests/tests/test_bip352.py @@ -5,6 +5,8 @@ from binascii import unhexlify from unittest import TestCase + +import pytest from embit import bip352 from embit.ec import PrivateKey from embit.networks import NETWORKS @@ -24,6 +26,22 @@ ] +LABEL_TEST_VECTORS = { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c", + "labels": [ + 2, + 3, + 1001337 + ], + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5" + ] +} + + class BIP352Test(TestCase): def test_generate_silent_payment_address(self): """ Should generate the expected silent payment address """ @@ -43,3 +61,26 @@ def test_generate_silent_payment_address_for_network(self): for network in test_networks: payment_addr = bip352.generate_silent_payment_address(scan_pubkey, spend_pubkey, network=network) assert payment_addr.startswith("tsp") + + + def test_generate_labeled_silent_payment_address(self): + """ Should generate the expected labeled silent payment addresses """ + spend_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["spend_priv_key"])) + scan_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["scan_priv_key"])) + for label, address in zip(LABEL_TEST_VECTORS["labels"], LABEL_TEST_VECTORS["addresses"]): + sp_address = bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label) + assert sp_address == address + + # Label may also be a string, but the bip does not provide any test vectors + bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label="tenant 6102") + + # Label may also be passed in as bytes + bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label="I am bytes".encode()) + + with pytest.raises(Exception): + # Label is required + bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key()) + + with pytest.raises(Exception): + # Label must be an int, str, or bytes + bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label=1.0) From d91a969dca91fffd4135a935446fcebb6b22b7af Mon Sep 17 00:00:00 2001 From: odudex Date: Fri, 29 May 2026 09:04:13 -0300 Subject: [PATCH 03/61] bip352: restrict labels to 32-bit ints, fix formatting Reject non-int and reserved/out-of-range labels (m=0 is change), drop the non-standard str/bytes label support, correct swapped scan/spend keys in the network test, and apply black formatting. --- src/embit/bip352.py | 39 +++++++++++------- tests/tests/test_bip352.py | 81 ++++++++++++++++++++++---------------- 2 files changed, 72 insertions(+), 48 deletions(-) diff --git a/src/embit/bip352.py b/src/embit/bip352.py index 0a33bef2..95b1007c 100644 --- a/src/embit/bip352.py +++ b/src/embit/bip352.py @@ -7,13 +7,15 @@ * Implement check to determine if a given output is an SP output for a given SP address. * Implement signing SP spends (once psbt format is settled). """ + from embit import bech32, ec from embit.util import secp256k1 from embit.hashes import tagged_hash - -def generate_silent_payment_address(B_scan: ec.PublicKey, B_spend: ec.PublicKey, network: str = "main", version: int = 0) -> str: +def generate_silent_payment_address( + B_scan: ec.PublicKey, B_spend: ec.PublicKey, network: str = "main", version: int = 0 +) -> str: """ Adapted from https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py """ @@ -22,24 +24,31 @@ def generate_silent_payment_address(B_scan: ec.PublicKey, B_spend: ec.PublicKey, return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data) - -def generate_labeled_silent_payment_address(b_scan: ec.PrivateKey, B_spend: ec.PublicKey, label, network: str = "main", version: int = 0) -> str: +def generate_labeled_silent_payment_address( + b_scan: ec.PrivateKey, + B_spend: ec.PublicKey, + label: int, + network: str = "main", + version: int = 0, +) -> str: """ The spending key is tweaked with the label to generate a labeled silent payment address. see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#address-encoding - `label` must be an int, str, or bytes. + `label` must be a 32-bit unsigned integer `m`. `m = 0` is reserved for change + outputs and cannot be used here. """ - if isinstance(label, int): - label_bytes = label.to_bytes(4, "big") - elif isinstance(label, str): - label_bytes = label.encode() - elif isinstance(label, bytes): - label_bytes = label - else: - raise Exception("Label must be an int, str, or bytes.") + if not isinstance(label, int) or isinstance(label, bool): + raise TypeError("Label must be an int.") + if not 1 <= label <= 0xFFFFFFFF: + raise ValueError("Label must be a 32-bit unsigned integer in [1, 2**32 - 1].") + label_bytes = label.to_bytes(4, "big") tweak = tagged_hash("BIP0352/Label", b_scan.secret + label_bytes) - label_pubkey = ec.PublicKey(secp256k1.ec_pubkey_add(secp256k1.ec_pubkey_parse(B_spend.sec()), tweak)) + label_pubkey = ec.PublicKey( + secp256k1.ec_pubkey_add(secp256k1.ec_pubkey_parse(B_spend.sec()), tweak) + ) - return generate_silent_payment_address(b_scan.get_public_key(), label_pubkey, network=network, version=version) + return generate_silent_payment_address( + b_scan.get_public_key(), label_pubkey, network=network, version=version + ) diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py index f613a199..2883e141 100644 --- a/tests/tests/test_bip352.py +++ b/tests/tests/test_bip352.py @@ -11,76 +11,91 @@ from embit.ec import PrivateKey from embit.networks import NETWORKS - BASIC_TEST_VECTORS = [ { "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c", - "sp_address": "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + "sp_address": "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", }, { "spend_priv_key": "0000000000000000000000000000000000000000000000000000000000000001", "scan_priv_key": "0000000000000000000000000000000000000000000000000000000000000002", - "sp_address": "sp1qqtrqglu5g8kh6mfsg4qxa9wq0nv9cauwfwxw70984wkqnw2uwz0w2qnehen8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq6lqj3s" - } + "sp_address": "sp1qqtrqglu5g8kh6mfsg4qxa9wq0nv9cauwfwxw70984wkqnw2uwz0w2qnehen8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq6lqj3s", + }, ] LABEL_TEST_VECTORS = { "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c", - "labels": [ - 2, - 3, - 1001337 - ], + "labels": [2, 3, 1001337], "addresses": [ "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq", "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n", - "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5" - ] + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5", + ], } class BIP352Test(TestCase): def test_generate_silent_payment_address(self): - """ Should generate the expected silent payment address """ + """Should generate the expected silent payment address""" for test_vector in BASIC_TEST_VECTORS: spend_priv_key = PrivateKey(unhexlify(test_vector["spend_priv_key"])) scan_priv_key = PrivateKey(unhexlify(test_vector["scan_priv_key"])) - sp_address = bip352.generate_silent_payment_address(scan_priv_key.get_public_key(), spend_priv_key.get_public_key()) + sp_address = bip352.generate_silent_payment_address( + scan_priv_key.get_public_key(), spend_priv_key.get_public_key() + ) assert sp_address == test_vector["sp_address"] - def test_generate_silent_payment_address_for_network(self): - """ Test network silent payment addrs should start with "tsp" """ + """Test network silent payment addrs should start with "tsp" """ test_networks = [k for k in NETWORKS.keys() if k != "main"] - scan_pubkey = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["spend_priv_key"])).get_public_key() - spend_pubkey = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"])).get_public_key() + scan_pubkey = PrivateKey( + unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"]) + ).get_public_key() + spend_pubkey = PrivateKey( + unhexlify(BASIC_TEST_VECTORS[0]["spend_priv_key"]) + ).get_public_key() for network in test_networks: - payment_addr = bip352.generate_silent_payment_address(scan_pubkey, spend_pubkey, network=network) + payment_addr = bip352.generate_silent_payment_address( + scan_pubkey, spend_pubkey, network=network + ) assert payment_addr.startswith("tsp") - def test_generate_labeled_silent_payment_address(self): - """ Should generate the expected labeled silent payment addresses """ + """Should generate the expected labeled silent payment addresses""" spend_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["spend_priv_key"])) scan_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["scan_priv_key"])) - for label, address in zip(LABEL_TEST_VECTORS["labels"], LABEL_TEST_VECTORS["addresses"]): - sp_address = bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label) + for label, address in zip( + LABEL_TEST_VECTORS["labels"], LABEL_TEST_VECTORS["addresses"] + ): + sp_address = bip352.generate_labeled_silent_payment_address( + scan_priv_key, spend_priv_key.get_public_key(), label + ) assert sp_address == address - # Label may also be a string, but the bip does not provide any test vectors - bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label="tenant 6102") - - # Label may also be passed in as bytes - bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label="I am bytes".encode()) + def test_generate_labeled_silent_payment_address_invalid_label(self): + """Labels must be 32-bit unsigned ints in [1, 2**32 - 1]""" + spend_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["spend_priv_key"])) + scan_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["scan_priv_key"])) + spend_pubkey = spend_priv_key.get_public_key() - with pytest.raises(Exception): + with pytest.raises(TypeError): # Label is required - bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key()) - - with pytest.raises(Exception): - # Label must be an int, str, or bytes - bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_priv_key.get_public_key(), label=1.0) + bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_pubkey) + + for bad_label in ["tenant 6102", b"I am bytes", 1.0, True]: + with pytest.raises(TypeError): + # Label must be an int (and not a bool) + bip352.generate_labeled_silent_payment_address( + scan_priv_key, spend_pubkey, label=bad_label + ) + + for bad_label in [0, -1, 0x100000000]: + with pytest.raises(ValueError): + # m = 0 is reserved for change; values must fit in 32 bits + bip352.generate_labeled_silent_payment_address( + scan_priv_key, spend_pubkey, label=bad_label + ) From 78ce0cb1fd32fee57d5996e5d493ba8e2f779bd6 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Fri, 11 Jul 2025 18:45:22 +0530 Subject: [PATCH 04/61] chore: make bech32 flexible for large payloads(sp) --- src/embit/bech32.py | 38 ++++++++++++++------- tests/tests/test_bech32.py | 67 +++++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/embit/bech32.py b/src/embit/bech32.py index 24ee7fa7..23e2e594 100644 --- a/src/embit/bech32.py +++ b/src/embit/bech32.py @@ -33,6 +33,12 @@ class Encoding: BECH32M = 2 +class Bech32DecodeError(Exception): + """Exception raised for errors during Bech32 decoding.""" + + pass + + def bech32_polymod(values): """Internal function that computes the Bech32 checksum.""" generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] @@ -77,21 +83,28 @@ def bech32_encode(encoding, hrp, data): def bech32_decode(bech): """Validate a Bech32/Bech32m string, and determine HRP and data.""" - if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or ( - bech.lower() != bech and bech.upper() != bech - ): - return (None, None, None) + if any(ord(x) < 33 or ord(x) > 126 for x in bech): + raise Bech32DecodeError("Invalid character in input") + if bech.lower() != bech and bech.upper() != bech: + raise Bech32DecodeError("Mixed case strings not allowed") bech = bech.lower() pos = bech.rfind("1") - if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: - return (None, None, None) - if not all(x in CHARSET for x in bech[pos + 1 :]): - return (None, None, None) + if pos < 1: + raise Bech32DecodeError("Separator '1' not found or misplaced") + if pos > 83: + raise Bech32DecodeError("HRP too long (max 83 characters)") + if pos + 7 > len(bech): + raise Bech32DecodeError("Data part too short") + if len(bech) > 118: + raise Bech32DecodeError("String too long for SP address") hrp = bech[:pos] - data = [CHARSET.find(x) for x in bech[pos + 1 :]] + data_part = bech[pos + 1 :] + if not all(x in CHARSET for x in data_part): + raise Bech32DecodeError("Data part contains invalid characters") + data = [CHARSET.find(x) for x in data_part] encoding = bech32_verify_checksum(hrp, data) if encoding is None: - return (None, None, None) + raise Bech32DecodeError("Checksum verification failed") return (encoding, hrp, data[:-6]) @@ -120,7 +133,10 @@ def convertbits(data, frombits, tobits, pad=True): def decode(hrp, addr): """Decode a segwit address.""" - encoding, hrpgot, data = bech32_decode(addr) + try: + encoding, hrpgot, data = bech32_decode(addr) + except Bech32DecodeError: + return (None, None) if hrpgot != hrp: return (None, None) decoded = convertbits(data[1:], 5, 8, False) diff --git a/tests/tests/test_bech32.py b/tests/tests/test_bech32.py index 7d71fb17..295e3e90 100644 --- a/tests/tests/test_bech32.py +++ b/tests/tests/test_bech32.py @@ -99,9 +99,11 @@ def segwit_scriptpubkey(witver, witprog): ("bc", 16, 41), ] +SILENT_PAYMENTS_ADDRESS = "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + class Bech32Test(TestCase): - """Unit test class for segwit addressess.""" + """Unit test class for segwit addresses.""" def test_valid_checksum(self): """Test checksum creation and validation.""" @@ -110,14 +112,14 @@ def test_valid_checksum(self): self.assertIsNotNone(hrp) pos = test.rfind("1") test = test[: pos + 1] + chr(ord(test[pos + 1]) ^ 1) + test[pos + 2 :] - enc, hrp, _ = segwit_addr.bech32_decode(test) - self.assertIsNone(hrp) + with self.assertRaises(segwit_addr.Bech32DecodeError): + segwit_addr.bech32_decode(test) def test_invalid_checksum(self): """Test validation of invalid checksums.""" for test in INVALID_CHECKSUM: - enc, hrp, _ = segwit_addr.bech32_decode(test) - self.assertIsNone(hrp) + with self.assertRaises(segwit_addr.Bech32DecodeError): + segwit_addr.bech32_decode(test) def test_valid_address(self): """Test whether valid addresses decode to the correct output.""" @@ -143,3 +145,58 @@ def test_invalid_address_enc(self): for hrp, version, length in INVALID_ADDRESS_ENC: code = segwit_addr.encode(hrp, version, [0] * length) self.assertIsNone(code) + + def test_silent_payments_address(self): + """Test decoding of Silent Payments address (Bech32m format).""" + encoding, hrp, data = segwit_addr.bech32_decode(SILENT_PAYMENTS_ADDRESS) + self.assertIsNotNone(hrp) + self.assertEqual(hrp, "sp") + self.assertEqual(encoding, segwit_addr.Encoding.BECH32M) + + def test_bech32_decode_exceptions(self): + """Test exception cases for bech32_decode.""" + with self.assertRaises(segwit_addr.Bech32DecodeError) as context: + segwit_addr.bech32_decode("bc1qw!@#$%") + self.assertIn("Data part contains invalid characters", str(context.exception)) + + with self.assertRaises(segwit_addr.Bech32DecodeError) as context: + segwit_addr.bech32_decode("BC1qwgHUDx") + self.assertIn("Mixed case", str(context.exception)) + + with self.assertRaises(segwit_addr.Bech32DecodeError) as context: + segwit_addr.bech32_decode("nonseparatoraddress") + self.assertIn("Separator '1' not found", str(context.exception)) + + with self.assertRaises(segwit_addr.Bech32DecodeError) as context: + segwit_addr.bech32_decode("bc1short") + self.assertIn("Data part too short", str(context.exception)) + + with self.assertRaises(segwit_addr.Bech32DecodeError) as context: + segwit_addr.bech32_decode("bc1qinvalidchar") + self.assertIn("Data part contains invalid characters", str(context.exception)) + + with self.assertRaises(segwit_addr.Bech32DecodeError) as context: + segwit_addr.bech32_decode("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5") + self.assertIn("Checksum verification failed", str(context.exception)) + + +class Bech32RoundTripTest(TestCase): + """Test round-trip encoding/decoding for various payload sizes.""" + + def test_round_trip(self): + """Test round-trip encoding/decoding for various payload sizes.""" + test_cases = [ + ("test", []), # empty payload + ("ln", [0] * 100), # medium + ("hrp-with-dashes", list(range(31))), # full charset + ] + + for hrp, payload in test_cases: + for encoding in [segwit_addr.Encoding.BECH32, segwit_addr.Encoding.BECH32M]: + encoded = segwit_addr.bech32_encode(encoding, hrp, payload) + decoded_enc, decoded_hrp, decoded_data = segwit_addr.bech32_decode( + encoded + ) + + self.assertEqual(decoded_enc, encoding) + self.assertEqual(decoded_hrp, hrp.lower()) From 1fa298f139c396f8731501ea13d9b437985433e8 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Fri, 11 Jul 2025 18:52:29 +0530 Subject: [PATCH 05/61] chore: replace None w error msg --- src/embit/bech32.py | 40 ++++++++++++++++++++++++-------------- tests/tests/test_bech32.py | 12 ++++++------ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/embit/bech32.py b/src/embit/bech32.py index 23e2e594..2f7e15bd 100644 --- a/src/embit/bech32.py +++ b/src/embit/bech32.py @@ -117,7 +117,7 @@ def convertbits(data, frombits, tobits, pad=True): max_acc = (1 << (frombits + tobits - 1)) - 1 for value in data: if value < 0 or (value >> frombits): - return None + raise Bech32DecodeError("Invalid input value for bit conversion") acc = ((acc << frombits) | value) & max_acc bits += frombits while bits >= tobits: @@ -127,36 +127,46 @@ def convertbits(data, frombits, tobits, pad=True): if bits: ret.append((acc << (tobits - bits)) & maxv) elif bits >= frombits or ((acc << (tobits - bits)) & maxv): - return None + raise Bech32DecodeError("Invalid padding in bit conversion") return ret def decode(hrp, addr): """Decode a segwit address.""" - try: - encoding, hrpgot, data = bech32_decode(addr) - except Bech32DecodeError: - return (None, None) + encoding, hrpgot, data = bech32_decode(addr) if hrpgot != hrp: - return (None, None) + raise Bech32DecodeError(f"HRP mismatch: expected {hrp}, got {hrpgot}") decoded = convertbits(data[1:], 5, 8, False) - if decoded is None or len(decoded) < 2 or len(decoded) > 40: - return (None, None) + if len(decoded) < 2 or len(decoded) > 40: + raise Bech32DecodeError("Invalid witness program length") if data[0] > 16: - return (None, None) + raise Bech32DecodeError("Invalid witness version") if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: - return (None, None) + raise Bech32DecodeError("Invalid witness program length for version 0") if (data[0] == 0 and encoding != Encoding.BECH32) or ( data[0] != 0 and encoding != Encoding.BECH32M ): - return (None, None) + raise Bech32DecodeError("Invalid encoding for witness version") return (data[0], decoded) def encode(hrp, witver, witprog): """Encode a segwit address.""" + if witver < 0 or witver > 16: + raise Bech32DecodeError("Invalid witness version") + if len(witprog) < 2 or len(witprog) > 40: + raise Bech32DecodeError("Invalid witness program length") + if witver == 0 and len(witprog) != 20 and len(witprog) != 32: + raise Bech32DecodeError("Invalid witness program length for version 0") + encoding = Encoding.BECH32 if witver == 0 else Encoding.BECH32M - ret = bech32_encode(encoding, hrp, [witver] + convertbits(witprog, 8, 5)) - if decode(hrp, ret) == (None, None): - return None + converted = convertbits(witprog, 8, 5) + ret = bech32_encode(encoding, hrp, [witver] + converted) + + # Verify the encoding worked correctly + try: + decode(hrp, ret) + except Bech32DecodeError: + raise Bech32DecodeError("Failed to encode valid segwit address") + return ret diff --git a/tests/tests/test_bech32.py b/tests/tests/test_bech32.py index 295e3e90..f9aecea7 100644 --- a/tests/tests/test_bech32.py +++ b/tests/tests/test_bech32.py @@ -135,16 +135,16 @@ def test_valid_address(self): def test_invalid_address(self): """Test whether invalid addresses fail to decode.""" for test in INVALID_ADDRESS: - witver, _ = segwit_addr.decode("bc", test) - self.assertIsNone(witver) - witver, _ = segwit_addr.decode("tb", test) - self.assertIsNone(witver) + with self.assertRaises(segwit_addr.Bech32DecodeError): + segwit_addr.decode("bc", test) + with self.assertRaises(segwit_addr.Bech32DecodeError): + segwit_addr.decode("tb", test) def test_invalid_address_enc(self): """Test whether address encoding fails on invalid input.""" for hrp, version, length in INVALID_ADDRESS_ENC: - code = segwit_addr.encode(hrp, version, [0] * length) - self.assertIsNone(code) + with self.assertRaises(segwit_addr.Bech32DecodeError): + segwit_addr.encode(hrp, version, [0] * length) def test_silent_payments_address(self): """Test decoding of Silent Payments address (Bech32m format).""" From c1f360881f3df834b07cac095397801191f4f7fd Mon Sep 17 00:00:00 2001 From: notTanveer Date: Tue, 15 Jul 2025 11:06:54 +0530 Subject: [PATCH 06/61] sp checks --- src/embit/bech32.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/embit/bech32.py b/src/embit/bech32.py index 2f7e15bd..178aa33f 100644 --- a/src/embit/bech32.py +++ b/src/embit/bech32.py @@ -34,8 +34,6 @@ class Encoding: class Bech32DecodeError(Exception): - """Exception raised for errors during Bech32 decoding.""" - pass @@ -137,14 +135,20 @@ def decode(hrp, addr): if hrpgot != hrp: raise Bech32DecodeError(f"HRP mismatch: expected {hrp}, got {hrpgot}") decoded = convertbits(data[1:], 5, 8, False) - if len(decoded) < 2 or len(decoded) > 40: - raise Bech32DecodeError("Invalid witness program length") + if len(decoded) < 2 or len(decoded) > 66: + raise Bech32DecodeError(f"Invalid witness program length") if data[0] > 16: raise Bech32DecodeError("Invalid witness version") - if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: + if ( + hrp not in ["sp", "tsp"] + and data[0] == 0 + and len(decoded) != 20 + and len(decoded) != 32 + ): raise Bech32DecodeError("Invalid witness program length for version 0") - if (data[0] == 0 and encoding != Encoding.BECH32) or ( - data[0] != 0 and encoding != Encoding.BECH32M + if hrp not in ["sp", "tsp"] and ( + (data[0] == 0 and encoding != Encoding.BECH32) + or (data[0] != 0 and encoding != Encoding.BECH32M) ): raise Bech32DecodeError("Invalid encoding for witness version") return (data[0], decoded) @@ -160,10 +164,8 @@ def encode(hrp, witver, witprog): raise Bech32DecodeError("Invalid witness program length for version 0") encoding = Encoding.BECH32 if witver == 0 else Encoding.BECH32M - converted = convertbits(witprog, 8, 5) - ret = bech32_encode(encoding, hrp, [witver] + converted) + ret = bech32_encode(encoding, hrp, [witver] + convertbits(witprog, 8, 5)) - # Verify the encoding worked correctly try: decode(hrp, ret) except Bech32DecodeError: From 8356d27db88864e8a2cb579a1b4105fe951c4b94 Mon Sep 17 00:00:00 2001 From: odudex Date: Fri, 29 May 2026 09:38:00 -0300 Subject: [PATCH 07/61] bech32: make Bech32DecodeError an EmbitError and tighten validation - Subclass EmbitError so existing except EmbitError callers still catch malformed-address errors (e.g. script.address_to_scriptpubkey). - Restore the BIP-141 2-40 byte witness program limit in decode() and drop the sp/tsp special-casing; SP addresses are not witness programs. - Replace the 118 length cap with BIP-352's recommended 1023 so valid higher-version silent payment addresses decode; cite BIP-173/BIP-352. - Drop the no-placeholder f-string and the encode() try/except that masked the original Bech32DecodeError. --- src/embit/bech32.py | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/embit/bech32.py b/src/embit/bech32.py index 178aa33f..b6766c52 100644 --- a/src/embit/bech32.py +++ b/src/embit/bech32.py @@ -19,7 +19,9 @@ # THE SOFTWARE. """Reference implementation for Bech32 and segwit addresses.""" + from .misc import const +from .base import EmbitError CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" BECH32_CONST = const(1) @@ -33,7 +35,7 @@ class Encoding: BECH32M = 2 -class Bech32DecodeError(Exception): +class Bech32DecodeError(EmbitError): pass @@ -89,12 +91,16 @@ def bech32_decode(bech): pos = bech.rfind("1") if pos < 1: raise Bech32DecodeError("Separator '1' not found or misplaced") + # BIP-173: the HRP must contain 1 to 83 characters. if pos > 83: raise Bech32DecodeError("HRP too long (max 83 characters)") if pos + 7 > len(bech): raise Bech32DecodeError("Data part too short") - if len(bech) > 118: - raise Bech32DecodeError("String too long for SP address") + # BIP-173 caps Bech32 strings at 90 chars, but BIP-352 silent payment + # addresses are longer (>=117) and recommend a 1023-char limit to leave + # room for future versions. + if len(bech) > 1023: + raise Bech32DecodeError("String too long (max 1023 characters)") hrp = bech[:pos] data_part = bech[pos + 1 :] if not all(x in CHARSET for x in data_part): @@ -130,25 +136,24 @@ def convertbits(data, frombits, tobits, pad=True): def decode(hrp, addr): - """Decode a segwit address.""" + """Decode a segwit address. + + Silent payment (sp/tsp) addresses are not witness programs and must not be + decoded here; use bech32_decode + convertbits for those. + """ encoding, hrpgot, data = bech32_decode(addr) if hrpgot != hrp: - raise Bech32DecodeError(f"HRP mismatch: expected {hrp}, got {hrpgot}") + raise Bech32DecodeError("HRP mismatch: expected {}, got {}".format(hrp, hrpgot)) decoded = convertbits(data[1:], 5, 8, False) - if len(decoded) < 2 or len(decoded) > 66: - raise Bech32DecodeError(f"Invalid witness program length") + # BIP-141: a witness program is 2 to 40 bytes. + if len(decoded) < 2 or len(decoded) > 40: + raise Bech32DecodeError("Invalid witness program length") if data[0] > 16: raise Bech32DecodeError("Invalid witness version") - if ( - hrp not in ["sp", "tsp"] - and data[0] == 0 - and len(decoded) != 20 - and len(decoded) != 32 - ): + if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: raise Bech32DecodeError("Invalid witness program length for version 0") - if hrp not in ["sp", "tsp"] and ( - (data[0] == 0 and encoding != Encoding.BECH32) - or (data[0] != 0 and encoding != Encoding.BECH32M) + if (data[0] == 0 and encoding != Encoding.BECH32) or ( + data[0] != 0 and encoding != Encoding.BECH32M ): raise Bech32DecodeError("Invalid encoding for witness version") return (data[0], decoded) @@ -166,9 +171,8 @@ def encode(hrp, witver, witprog): encoding = Encoding.BECH32 if witver == 0 else Encoding.BECH32M ret = bech32_encode(encoding, hrp, [witver] + convertbits(witprog, 8, 5)) - try: - decode(hrp, ret) - except Bech32DecodeError: - raise Bech32DecodeError("Failed to encode valid segwit address") + # Sanity check: the result must round-trip. Any failure propagates with its + # original, descriptive Bech32DecodeError. + decode(hrp, ret) return ret From 77cf14add89a851549eed46136343e8a090096ff Mon Sep 17 00:00:00 2001 From: notTanveer Date: Thu, 21 May 2026 13:52:04 +0530 Subject: [PATCH 08/61] feat: silent payment send support --- src/embit/bip352.py | 54 ------- src/embit/silent_payments/__init__.py | 0 src/embit/silent_payments/bip352.py | 204 ++++++++++++++++++++++++++ src/embit/transaction.py | 11 ++ src/embit/util/key.py | 2 + tests/tests/test_bip352.py | 32 ++-- 6 files changed, 235 insertions(+), 68 deletions(-) delete mode 100644 src/embit/bip352.py create mode 100644 src/embit/silent_payments/__init__.py create mode 100644 src/embit/silent_payments/bip352.py diff --git a/src/embit/bip352.py b/src/embit/bip352.py deleted file mode 100644 index 95b1007c..00000000 --- a/src/embit/bip352.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -BIP-352: Silent Payments -see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki - -TODO: -* Implement deriving a destination addr for a given output and recipient SP address. -* Implement check to determine if a given output is an SP output for a given SP address. -* Implement signing SP spends (once psbt format is settled). -""" - -from embit import bech32, ec -from embit.util import secp256k1 -from embit.hashes import tagged_hash - - -def generate_silent_payment_address( - B_scan: ec.PublicKey, B_spend: ec.PublicKey, network: str = "main", version: int = 0 -) -> str: - """ - Adapted from https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py - """ - data = bech32.convertbits(B_scan.sec() + B_spend.sec(), 8, 5) - hrp = "sp" if network == "main" else "tsp" - return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data) - - -def generate_labeled_silent_payment_address( - b_scan: ec.PrivateKey, - B_spend: ec.PublicKey, - label: int, - network: str = "main", - version: int = 0, -) -> str: - """ - The spending key is tweaked with the label to generate a labeled silent payment address. - see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#address-encoding - - `label` must be a 32-bit unsigned integer `m`. `m = 0` is reserved for change - outputs and cannot be used here. - """ - if not isinstance(label, int) or isinstance(label, bool): - raise TypeError("Label must be an int.") - if not 1 <= label <= 0xFFFFFFFF: - raise ValueError("Label must be a 32-bit unsigned integer in [1, 2**32 - 1].") - - label_bytes = label.to_bytes(4, "big") - tweak = tagged_hash("BIP0352/Label", b_scan.secret + label_bytes) - label_pubkey = ec.PublicKey( - secp256k1.ec_pubkey_add(secp256k1.ec_pubkey_parse(B_spend.sec()), tweak) - ) - - return generate_silent_payment_address( - b_scan.get_public_key(), label_pubkey, network=network, version=version - ) diff --git a/src/embit/silent_payments/__init__.py b/src/embit/silent_payments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py new file mode 100644 index 00000000..d8548ca8 --- /dev/null +++ b/src/embit/silent_payments/bip352.py @@ -0,0 +1,204 @@ +""" +BIP-352: Silent Payments +see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki +""" + +from collections import Counter, defaultdict +from typing import Tuple, List, Dict + +from .. import bech32, ec +from ..util import secp256k1 +from ..hashes import tagged_hash +from ..util.key import SECP256K1_ORDER +from ..transaction import COutPoint +from ..util.secp256k1 import ( + ec_pubkey_create, + ec_pubkey_serialize, + ec_pubkey_parse, + ec_pubkey_tweak_mul, + ec_pubkey_tweak_add, + ec_seckey_verify, + ec_privkey_negate, +) +from binascii import hexlify + + +def generate_silent_payment_address( + scan_privkey: ec.PrivateKey, + spend_pubkey: ec.PublicKey, + label=None, + network: str = "main", + version: int = 0, +) -> str: + """ + Adapted from https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py + + Generates the recipient's reusable silent payment address for a given: + * scan private key + * spend public key + * optional label for labeled addresses. It must be a 32-bit unsigned + integer `m`; `m = 0` is reserved for change and cannot be used here. + """ + scan_pubkey = scan_privkey.get_public_key() + if label is not None: + # Labels are 32-bit unsigned ints; m = 0 is reserved for change. See + # https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#address-encoding + if not isinstance(label, int) or isinstance(label, bool): + raise TypeError("Label must be an int.") + if not 1 <= label <= 0xFFFFFFFF: + raise ValueError( + "Label must be a 32-bit unsigned integer in [1, 2**32 - 1]." + ) + label_bytes = label.to_bytes(4, "big") + tweak = tagged_hash("BIP0352/Label", scan_privkey.secret + label_bytes) + spend_pubkey = ec.PublicKey( + secp256k1.ec_pubkey_add( + secp256k1.ec_pubkey_parse(spend_pubkey.sec()), tweak + ) + ) + + data = bech32.convertbits(scan_pubkey.sec() + spend_pubkey.sec(), 8, 5) + hrp = "sp" if network == "main" else "tsp" + return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data) + + +# TODO: use the bech32 decode function once the flexible bech32 PR is in +def decode_silent_payment_address(address: str) -> Tuple[ec.PublicKey, ec.PublicKey]: + """ + Decode a silent payment address and return the scan and spend public keys. + Silent payment addresses can be longer than 90 characters, so we need custom decoding. + """ + if address.startswith("sp1"): + hrp = "sp" + elif address.startswith("tsp1"): + hrp = "tsp" + else: + raise ValueError("Invalid silent payment address: unknown HRP") + + # custom bech32 to bypass the 90-character limit + if (any(ord(x) < 33 or ord(x) > 126 for x in address)) or ( + address.lower() != address and address.upper() != address + ): + raise ValueError("Invalid silent payment address: invalid characters") + + address = address.lower() + pos = address.rfind("1") + if pos < 1 or pos + 7 > len(address): + raise ValueError("Invalid silent payment address: invalid format") + + if not all(x in bech32.CHARSET for x in address[pos + 1 :]): + raise ValueError( + "Invalid silent payment address: invalid characters in data part" + ) + + hrpgot = address[:pos] + data = [bech32.CHARSET.find(x) for x in address[pos + 1 :]] + + if hrpgot != hrp: + raise ValueError("Invalid silent payment address: HRP mismatch") + + encoding = bech32.bech32_verify_checksum(hrpgot, data) + if encoding is None: + raise ValueError("Invalid silent payment address: checksum verification failed") + + if encoding != bech32.Encoding.BECH32M: + raise ValueError("Invalid silent payment address: must use bech32m encoding") + + data = data[:-6] + + if data[0] != 0: + raise ValueError( + f"Invalid silent payment address: unsupported version {data[0]}" + ) + + decoded = bech32.convertbits(data[1:], 5, 8, False) + if decoded is None: + raise ValueError("Invalid silent payment address: conversion failed") + + try: + B_scan = ec.PublicKey.parse(bytes(decoded[:33])) + B_spend = ec.PublicKey.parse(bytes(decoded[33:])) + except Exception as e: + raise ValueError(f"Invalid silent payment address: invalid public keys - {e}") + + return B_scan, B_spend + + +def get_input_hash(outpoints: List["COutPoint"], sum_pubkey_bytes: bytes) -> bytes: + lowest_outpoint = sorted(outpoints, key=lambda o: o.serialize())[0] + preimage = lowest_outpoint.serialize() + sum_pubkey_bytes + return tagged_hash("BIP0352/Inputs", preimage) + + +def create_outputs( + input_privkeys: List[Tuple[bytes, bool]], + outpoints: List["COutPoint"], + recipients: List[str], +) -> Dict[str, List[str]]: + """ + Creates silent payment outputs for given recipients. + + Args: + input_privkeys: List of (private_key_bytes, is_xonly) tuples + outpoints: List of transaction outpoints + recipients: List of silent payment addresses (strings) - duplicates are allowed + + Returns: + Dictionary mapping each unique recipient address to list of output hex strings + """ + if not input_privkeys: + return {} + + signing_keys = [] + for sec, is_xonly in input_privkeys: + if not ec_seckey_verify(sec): + raise ValueError("Invalid private key") + + if is_xonly: + pub = ec_pubkey_create(sec) + ser = ec_pubkey_serialize(pub) + if ser[0] == 0x03: + sec = ec_privkey_negate(sec) + signing_keys.append(int.from_bytes(sec, "big")) + + a_sum = sum(signing_keys) % SECP256K1_ORDER + if a_sum == 0: + return {} + + a_sum_bytes = a_sum.to_bytes(32, "big") + A = ec_pubkey_create(a_sum_bytes) + + input_hash = get_input_hash(outpoints, ec_pubkey_serialize(A)) + + recipient_counts = Counter(recipients) + + groups: Dict[ec.PublicKey, List[Tuple[ec.PublicKey, str, int]]] = defaultdict(list) + for addr, count in recipient_counts.items(): + B_scan, B_spend = decode_silent_payment_address(addr) + groups[B_scan].append((B_spend, addr, count)) + + result: Dict[str, List[str]] = {addr: [] for addr in recipient_counts.keys()} + scalar = (int.from_bytes(input_hash, "big") * a_sum) % SECP256K1_ORDER + scalar_bytes = scalar.to_bytes(32, "big") + + for B_scan, B_spend_list in groups.items(): + ecdh_point = ec_pubkey_parse(B_scan.sec()) + ec_pubkey_tweak_mul(ecdh_point, scalar_bytes) + xonly_shared_secret = ec_pubkey_serialize(ecdh_point) + + k = 0 + for B_spend, addr, count in B_spend_list: + for _ in range(count): + t_k = tagged_hash( + "BIP0352/SharedSecret", + xonly_shared_secret + k.to_bytes(4, "big"), + ) + + P_k = ec_pubkey_parse(B_spend.sec()) + ec_pubkey_tweak_add(P_k, t_k) + + xonly = ec_pubkey_serialize(P_k)[1:33] + result[addr].append(hexlify(xonly).decode()) + k += 1 + + return result diff --git a/src/embit/transaction.py b/src/embit/transaction.py index 6eec9b87..1919a9d0 100644 --- a/src/embit/transaction.py +++ b/src/embit/transaction.py @@ -4,6 +4,7 @@ from .base import EmbitBase, EmbitError from .script import Script, Witness from .misc import const +from typing import NamedTuple class TransactionError(EmbitError): @@ -398,3 +399,13 @@ def read_from(cls, stream): value = int.from_bytes(stream.read(8), "little") script_pubkey = Script.read_from(stream) return cls(value, script_pubkey) + + +class COutPoint(NamedTuple): + txid: bytes # endianness same as hex string displayed; reverse of tx serialization order + out_idx: int + + def serialize(self) -> bytes: + return self.txid[::-1] + int.to_bytes( + self.out_idx, length=4, byteorder="little", signed=False + ) diff --git a/src/embit/util/key.py b/src/embit/util/key.py index f573ec5b..0d3c3833 100644 --- a/src/embit/util/key.py +++ b/src/embit/util/key.py @@ -2,6 +2,7 @@ Copy-paste from key.py in bitcoin test_framework. This is a fallback option if the library can't do ctypes bindings to secp256k1 library. """ + import random import hmac import hashlib @@ -291,6 +292,7 @@ def set(self, data): self.valid = False else: self.valid = False + return self @property def is_compressed(self): diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py index 2883e141..49ee8e31 100644 --- a/tests/tests/test_bip352.py +++ b/tests/tests/test_bip352.py @@ -7,7 +7,7 @@ from unittest import TestCase import pytest -from embit import bip352 +from embit.silent_payments import bip352 from embit.ec import PrivateKey from embit.networks import NETWORKS @@ -44,23 +44,21 @@ def test_generate_silent_payment_address(self): spend_priv_key = PrivateKey(unhexlify(test_vector["spend_priv_key"])) scan_priv_key = PrivateKey(unhexlify(test_vector["scan_priv_key"])) sp_address = bip352.generate_silent_payment_address( - scan_priv_key.get_public_key(), spend_priv_key.get_public_key() + scan_priv_key, spend_priv_key.get_public_key() ) assert sp_address == test_vector["sp_address"] def test_generate_silent_payment_address_for_network(self): """Test network silent payment addrs should start with "tsp" """ test_networks = [k for k in NETWORKS.keys() if k != "main"] - scan_pubkey = PrivateKey( - unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"]) - ).get_public_key() + scan_priv_key = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"])) spend_pubkey = PrivateKey( unhexlify(BASIC_TEST_VECTORS[0]["spend_priv_key"]) ).get_public_key() for network in test_networks: payment_addr = bip352.generate_silent_payment_address( - scan_pubkey, spend_pubkey, network=network + scan_priv_key, spend_pubkey, network=network ) assert payment_addr.startswith("tsp") @@ -71,8 +69,8 @@ def test_generate_labeled_silent_payment_address(self): for label, address in zip( LABEL_TEST_VECTORS["labels"], LABEL_TEST_VECTORS["addresses"] ): - sp_address = bip352.generate_labeled_silent_payment_address( - scan_priv_key, spend_priv_key.get_public_key(), label + sp_address = bip352.generate_silent_payment_address( + scan_priv_key, spend_priv_key.get_public_key(), label=label ) assert sp_address == address @@ -82,20 +80,26 @@ def test_generate_labeled_silent_payment_address_invalid_label(self): scan_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["scan_priv_key"])) spend_pubkey = spend_priv_key.get_public_key() - with pytest.raises(TypeError): - # Label is required - bip352.generate_labeled_silent_payment_address(scan_priv_key, spend_pubkey) - for bad_label in ["tenant 6102", b"I am bytes", 1.0, True]: with pytest.raises(TypeError): # Label must be an int (and not a bool) - bip352.generate_labeled_silent_payment_address( + bip352.generate_silent_payment_address( scan_priv_key, spend_pubkey, label=bad_label ) for bad_label in [0, -1, 0x100000000]: with pytest.raises(ValueError): # m = 0 is reserved for change; values must fit in 32 bits - bip352.generate_labeled_silent_payment_address( + bip352.generate_silent_payment_address( scan_priv_key, spend_pubkey, label=bad_label ) + + def test_decode_silent_payment_address_round_trip(self): + """A generated address should decode back to its scan/spend pubkeys""" + spend_priv_key = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["spend_priv_key"])) + scan_priv_key = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"])) + address = BASIC_TEST_VECTORS[0]["sp_address"] + + B_scan, B_spend = bip352.decode_silent_payment_address(address) + assert B_scan.sec() == scan_priv_key.get_public_key().sec() + assert B_spend.sec() == spend_priv_key.get_public_key().sec() From 735eef5ad82cc3e9be6a6738fbf5df97e4bf4ac0 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Thu, 21 May 2026 13:55:17 +0530 Subject: [PATCH 09/61] test(sp): outputs and BIP-352 test vectors --- .../data/send_and_receive_test_vectors.json | 2760 +++++++++++++++++ tests/tests/test_bip352.py | 210 +- 2 files changed, 2931 insertions(+), 39 deletions(-) create mode 100644 tests/tests/data/send_and_receive_test_vectors.json diff --git a/tests/tests/data/send_and_receive_test_vectors.json b/tests/tests/data/send_and_receive_test_vectors.json new file mode 100644 index 00000000..264f7bec --- /dev/null +++ b/tests/tests/data/send_and_receive_test_vectors.json @@ -0,0 +1,2760 @@ +[ + { + "comment": "Simple send: two inputs", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + }, + "private_key": "93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + } + } + ], + "outputs": [ + "3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "f438b40179a3c4262de12986c0e6cce0634007cdc79c1dcd3e20b9ebc2e7eef6", + "pub_key": "3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1", + "signature": "74f85b856337fbe837643b86f462118159f93ac4acc2671522f27e8f67b079959195ccc7a5dbee396d2909f5d680d6e30cda7359aa2755822509b70d6b0687a1" + } + ] + } + } + ] + }, + { + "comment": "Simple send: two inputs, order reversed", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + }, + "private_key": "93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + } + } + ], + "outputs": [ + "3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "f438b40179a3c4262de12986c0e6cce0634007cdc79c1dcd3e20b9ebc2e7eef6", + "pub_key": "3e9fce73d4e77a4809908e3c3a2e54ee147b9312dc5044a193d1fc85de46e3c1", + "signature": "74f85b856337fbe837643b86f462118159f93ac4acc2671522f27e8f67b079959195ccc7a5dbee396d2909f5d680d6e30cda7359aa2755822509b70d6b0687a1" + } + ] + } + } + ] + }, + { + "comment": "Simple send: two inputs from the same transaction", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 3, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 7, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + }, + "private_key": "93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "79e71baa2ba3fc66396de3a04f168c7bf24d6870ec88ca877754790c1db357b6" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 3, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 7, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + } + } + ], + "outputs": [ + "79e71baa2ba3fc66396de3a04f168c7bf24d6870ec88ca877754790c1db357b6" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "4851455bfbe1ab4f80156570aa45063201aa5c9e1b1dcd29f0f8c33d10bf77ae", + "pub_key": "79e71baa2ba3fc66396de3a04f168c7bf24d6870ec88ca877754790c1db357b6", + "signature": "10332eea808b6a13f70059a8a73195808db782012907f5ba32b6eae66a2f66b4f65147e2b968a1678c5f73d57d5d195dbaf667b606ff80c8490eac1f3b710657" + } + ] + } + } + ] + }, + { + "comment": "Simple send: two inputs from the same transaction, order reversed", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 7, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 3, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + }, + "private_key": "93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "f4c2da807f89cb1501f1a77322a895acfb93c28e08ed2724d2beb8e44539ba38" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 7, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 3, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + } + } + ], + "outputs": [ + "f4c2da807f89cb1501f1a77322a895acfb93c28e08ed2724d2beb8e44539ba38" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "ab0c9b87181bf527879f48db9f14a02233619b986f8e8f2d5d408ce68a709f51", + "pub_key": "f4c2da807f89cb1501f1a77322a895acfb93c28e08ed2724d2beb8e44539ba38", + "signature": "398a9790865791a9db41a8015afad3a47d60fec5086c50557806a49a1bc038808632b8fe679a7bb65fc6b455be994502eed849f1da3729cd948fc7be73d67295" + } + ] + } + } + ] + }, + { + "comment": "Outpoint ordering byte-lexicographically vs. vout-integer", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 1, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 256, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + }, + "private_key": "93f5ed907ad5b2bdbbdcb5d9116ebc0a4e1f92f910d5260237fa45a9408aad16" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "a85ef8701394b517a4b35217c4bd37ac01ebeed4b008f8d0879f9e09ba95319c" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 1, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 256, + "scriptSig": "48304602210086783ded73e961037e77d49d9deee4edc2b23136e9728d56e4491c80015c3a63022100fda4c0f21ea18de29edbce57f7134d613e044ee150a89e2e64700de2d4e83d4e2103bd85685d03d111699b15d046319febe77f8de5286e9e512703cdee1bf3be3792", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914d9317c66f54ff0a152ec50b1d19c25be50c8e15988ac" + } + } + } + ], + "outputs": [ + "a85ef8701394b517a4b35217c4bd37ac01ebeed4b008f8d0879f9e09ba95319c" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "c8ac0292997b5bca98b3ebd99a57e253071137550f270452cd3df8a3e2266d36", + "pub_key": "a85ef8701394b517a4b35217c4bd37ac01ebeed4b008f8d0879f9e09ba95319c", + "signature": "c036ee38bfe46aba03234339ae7219b31b824b52ef9d5ce05810a0d6f62330dedc2b55652578aa5bdabf930fae941acd839d5a66f8fce7caa9710ccb446bddd1" + } + ] + } + } + ] + }, + { + "comment": "Single recipient: multiple UTXOs from the same public key", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "548ae55c8eec1e736e8d3e520f011f1f42a56d166116ad210b3937599f87f566" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + } + ], + "outputs": [ + "548ae55c8eec1e736e8d3e520f011f1f42a56d166116ad210b3937599f87f566" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "f032695e2636619efa523fffaa9ef93c8802299181fd0461913c1b8daf9784cd", + "pub_key": "548ae55c8eec1e736e8d3e520f011f1f42a56d166116ad210b3937599f87f566", + "signature": "f238386c5d5e5444f8d2c75aabbcb28c346f208c76f60823f5de3b67b79e0ec72ea5de2d7caec314e0971d3454f122dda342b3eede01b3857e83654e36b25f76" + } + ] + } + } + ] + }, + { + "comment": "Single recipient: taproot only inputs with even y-values", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140bd1e708f92dbeaf24a6b8dd22e59c6274355424d62baea976b449e220fd75b13578e262ab11b7aa58e037f0c6b0519b66803b7d9decaa1906dedebfb531c56c1", + "prevout": { + "scriptPubKey": { + "hex": "5120782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338" + } + }, + "private_key": "fc8716a97a48ba9a05a98ae47b5cd201a25a7fd5d8b73c203c5f7b6b6b3b6ad7" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "de88bea8e7ffc9ce1af30d1132f910323c505185aec8eae361670421e749a1fb" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140bd1e708f92dbeaf24a6b8dd22e59c6274355424d62baea976b449e220fd75b13578e262ab11b7aa58e037f0c6b0519b66803b7d9decaa1906dedebfb531c56c1", + "prevout": { + "scriptPubKey": { + "hex": "5120782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338" + } + } + } + ], + "outputs": [ + "de88bea8e7ffc9ce1af30d1132f910323c505185aec8eae361670421e749a1fb" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "3fb9ce5ce1746ced103c8ed254e81f6690764637ddbc876ec1f9b3ddab776b03", + "pub_key": "de88bea8e7ffc9ce1af30d1132f910323c505185aec8eae361670421e749a1fb", + "signature": "c5acd25a8f021a4192f93bc34403fd8b76484613466336fb259c72d04c169824f2690ca34e96cee86b69f376c8377003268fda56feeb1b873e5783d7e19bcca5" + } + ] + } + } + ] + }, + { + "comment": "Single recipient: taproot only with mixed even/odd y-values", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "", + "txinwitness": "01400a4d0dca6293f40499394d7eefe14a1de11e0e3454f51de2e802592abf5ee549042a1b1a8fb2e149ee9dd3f086c1b69b2f182565ab6ecf599b1ec9ebadfda6c5", + "prevout": { + "scriptPubKey": { + "hex": "51208c8d23d4764feffcd5e72e380802540fa0f88e3d62ad5e0b47955f74d7b283c4" + } + }, + "private_key": "1d37787c2b7116ee983e9f9c13269df29091b391c04db94239e0d2bc2182c3bf" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "77cab7dd12b10259ee82c6ea4b509774e33e7078e7138f568092241bf26b99f1" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "", + "txinwitness": "01400a4d0dca6293f40499394d7eefe14a1de11e0e3454f51de2e802592abf5ee549042a1b1a8fb2e149ee9dd3f086c1b69b2f182565ab6ecf599b1ec9ebadfda6c5", + "prevout": { + "scriptPubKey": { + "hex": "51208c8d23d4764feffcd5e72e380802540fa0f88e3d62ad5e0b47955f74d7b283c4" + } + } + } + ], + "outputs": [ + "77cab7dd12b10259ee82c6ea4b509774e33e7078e7138f568092241bf26b99f1" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "f5382508609771068ed079b24e1f72e4a17ee6d1c979066bf1d4e2a5676f09d4", + "pub_key": "77cab7dd12b10259ee82c6ea4b509774e33e7078e7138f568092241bf26b99f1", + "signature": "ff65833b8fd1ed3ef9d0443b4f702b45a3f2dd457ba247687e8207745c3be9d2bdad0ab3f07118f8b2efc6a04b95f7b3e218daf8a64137ec91bd2fc67fc137a5" + } + ] + } + } + ] + }, + { + "comment": "Single recipient: taproot input with even y-value and non-taproot input", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "463044021f24e010c6e475814740ba24c8cf9362c4db1276b7f46a7b1e63473159a80ec30221008198e8ece7b7f88e6c6cc6bb8c86f9f00b7458222a8c91addf6e1577bcf7697e2103e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9148cbc7dfe44f1579bff3340bbef1eddeaeb1fc97788ac" + } + }, + "private_key": "8d4751f6e8a3586880fb66c19ae277969bd5aa06f61c4ee2f1e2486efdf666d3" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "30523cca96b2a9ae3c98beb5e60f7d190ec5bc79b2d11a0b2d4d09a608c448f0" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "463044021f24e010c6e475814740ba24c8cf9362c4db1276b7f46a7b1e63473159a80ec30221008198e8ece7b7f88e6c6cc6bb8c86f9f00b7458222a8c91addf6e1577bcf7697e2103e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9148cbc7dfe44f1579bff3340bbef1eddeaeb1fc97788ac" + } + } + } + ], + "outputs": [ + "30523cca96b2a9ae3c98beb5e60f7d190ec5bc79b2d11a0b2d4d09a608c448f0" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "b40017865c79b1fcbed68896791be93186d08f47e416b289b8c063777e14e8df", + "pub_key": "30523cca96b2a9ae3c98beb5e60f7d190ec5bc79b2d11a0b2d4d09a608c448f0", + "signature": "d1edeea28cf1033bcb3d89376cabaaaa2886cbd8fda112b5c61cc90a4e7f1878bdd62180b07d1dfc8ffee1863c525a0c7b5bcd413183282cfda756cb65787266" + } + ] + } + } + ] + }, + { + "comment": "Single recipient: taproot input with odd y-value and non-taproot input", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "01400a4d0dca6293f40499394d7eefe14a1de11e0e3454f51de2e802592abf5ee549042a1b1a8fb2e149ee9dd3f086c1b69b2f182565ab6ecf599b1ec9ebadfda6c5", + "prevout": { + "scriptPubKey": { + "hex": "51208c8d23d4764feffcd5e72e380802540fa0f88e3d62ad5e0b47955f74d7b283c4" + } + }, + "private_key": "1d37787c2b7116ee983e9f9c13269df29091b391c04db94239e0d2bc2182c3bf" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "463044021f24e010c6e475814740ba24c8cf9362c4db1276b7f46a7b1e63473159a80ec30221008198e8ece7b7f88e6c6cc6bb8c86f9f00b7458222a8c91addf6e1577bcf7697e2103e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9148cbc7dfe44f1579bff3340bbef1eddeaeb1fc97788ac" + } + }, + "private_key": "8d4751f6e8a3586880fb66c19ae277969bd5aa06f61c4ee2f1e2486efdf666d3" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "359358f59ee9e9eec3f00bdf4882570fd5c182e451aa2650b788544aff012a3a" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "01400a4d0dca6293f40499394d7eefe14a1de11e0e3454f51de2e802592abf5ee549042a1b1a8fb2e149ee9dd3f086c1b69b2f182565ab6ecf599b1ec9ebadfda6c5", + "prevout": { + "scriptPubKey": { + "hex": "51208c8d23d4764feffcd5e72e380802540fa0f88e3d62ad5e0b47955f74d7b283c4" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "463044021f24e010c6e475814740ba24c8cf9362c4db1276b7f46a7b1e63473159a80ec30221008198e8ece7b7f88e6c6cc6bb8c86f9f00b7458222a8c91addf6e1577bcf7697e2103e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9148cbc7dfe44f1579bff3340bbef1eddeaeb1fc97788ac" + } + } + } + ], + "outputs": [ + "359358f59ee9e9eec3f00bdf4882570fd5c182e451aa2650b788544aff012a3a" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "a2f9dd05d1d398347c885d9c61a64d18a264de6d49cea4326bafc2791d627fa7", + "pub_key": "359358f59ee9e9eec3f00bdf4882570fd5c182e451aa2650b788544aff012a3a", + "signature": "96038ad233d8befe342573a6e54828d863471fb2afbad575cc65271a2a649480ea14912b6abbd3fbf92efc1928c036f6e3eef927105af4ec1dd57cb909f360b8" + } + ] + } + } + ] + }, + { + "comment": "Multiple outputs: multiple outputs, same recipient", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "d97e442d110c0bdd31161a7bb6e7862e038d02a09b1484dfbb463f2e0f7c9230", + "pub_key": "e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca", + "signature": "29bd25d0f808d7fcd2aa6d5ed206053899198397506c301b218a9e47a3d7070af03e903ff718978d50d1b6b9af8cc0e313d84eda5d5b1e8e85e5516d630bbeb9" + }, + { + "priv_key_tweak": "33ce085c3c11eaad13694aae3c20301a6c83382ec89a7cde96c6799e2f88805a", + "pub_key": "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac", + "signature": "335667ca6cae7a26438f5cfdd73b3d48fa832fa9768521d7d5445f22c203ab0d74ed85088f27d29959ba627a4509996676f47df8ff284d292567b1beef0e3912" + } + ] + } + } + ] + }, + { + "comment": "Multiple outputs: multiple outputs, multiple recipients", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgrz6j0lcqnc04vxccydl0kpsj4frfje0ktmgcl2t346hkw30226xqupawdf48k8882j0strrvcmgg2kdawz53a54dd376ngdhak364hzcmynqtn", + "sp1qqgrz6j0lcqnc04vxccydl0kpsj4frfje0ktmgcl2t346hkw30226xqupawdf48k8882j0strrvcmgg2kdawz53a54dd376ngdhak364hzcmynqtn" + ] + }, + "expected": { + "outputs": [ + [ + "2e847bb01d1b491da512ddd760b8509617ee38057003d6115d00ba562451323a", + "841792c33c9dc6193e76744134125d40add8f2f4a96475f28ba150be032d64e8", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "2e847bb01d1b491da512ddd760b8509617ee38057003d6115d00ba562451323a", + "841792c33c9dc6193e76744134125d40add8f2f4a96475f28ba150be032d64e8", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ], + "key_material": { + "spend_priv_key": "9902c3c56e84002a7cd410113a9ab21d142be7f53cf5200720bb01314c5eb920", + "scan_priv_key": "060b751d7892149006ed7b98606955a29fe284a1e900070c0971f5fb93dbf422" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgrz6j0lcqnc04vxccydl0kpsj4frfje0ktmgcl2t346hkw30226xqupawdf48k8882j0strrvcmgg2kdawz53a54dd376ngdhak364hzcmynqtn" + ], + "outputs": [ + { + "priv_key_tweak": "72cd082cccb633bf85240a83494b32dc943a4d05647a6686d23ad4ca59c0ebe4", + "pub_key": "2e847bb01d1b491da512ddd760b8509617ee38057003d6115d00ba562451323a", + "signature": "38745f3d9f5eef0b1cfb17ca314efa8c521efab28a23aa20ec5e3abb561d42804d539906dce60c4ee7977966184e6f2cab1faa0e5377ceb7148ec5218b4e7878" + }, + { + "priv_key_tweak": "2f17ea873a0047fc01ba8010fef0969e76d0e4283f600d48f735098b1fee6eb9", + "pub_key": "841792c33c9dc6193e76744134125d40add8f2f4a96475f28ba150be032d64e8", + "signature": "c26f4e3cf371b90b840f48ea0e761b5ec31883ed55719f9ef06a90e282d85f565790ab780a3f491bc2668cc64e944dca849d1022a878cdadb8d168b8da4a6da3" + } + ] + } + } + ] + }, + { + "comment": "Receiving with labels: label with even parity", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq" + ] + }, + "expected": { + "outputs": [ + [ + "d014d4860f67d607d60b1af70e0ee236b99658b61bb769832acbbe87c374439a" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "d014d4860f67d607d60b1af70e0ee236b99658b61bb769832acbbe87c374439a" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [ + 2, + 3, + 1001337 + ] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5" + ], + "outputs": [ + { + "priv_key_tweak": "51d4e9d0d482b5700109b4b2e16ff508269b03d800192a043d61dca4a0a72a52", + "pub_key": "d014d4860f67d607d60b1af70e0ee236b99658b61bb769832acbbe87c374439a", + "signature": "c30fa63bad6f0a317f39a773a5cbf0b0f8193c71dfebba05ee6ae4ed28e3775e6e04c3ea70a83703bb888122855dc894cab61692e7fd10c9b3494d479a60785e" + } + ] + } + } + ] + }, + { + "comment": "Receiving with labels: label with odd parity", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n" + ] + }, + "expected": { + "outputs": [ + [ + "67626aebb3c4307cf0f6c39ca23247598fabf675ab783292eb2f81ae75ad1f8c" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "67626aebb3c4307cf0f6c39ca23247598fabf675ab783292eb2f81ae75ad1f8c" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [ + 2, + 3, + 1001337 + ] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5" + ], + "outputs": [ + { + "priv_key_tweak": "6024ae214876356b8d917716e7707d267ae16a0fdb07de2a786b74a7bbcddead", + "pub_key": "67626aebb3c4307cf0f6c39ca23247598fabf675ab783292eb2f81ae75ad1f8c", + "signature": "a86d554d0d6b7aa0907155f7e0b47f0182752472fffaeddd68da90e99b9402f166fd9b33039c302c7115098d971c1399e67c19e9e4de180b10ea0b9d6f0db832" + } + ] + } + } + ] + }, + { + "comment": "Receiving with labels: large label integer", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5" + ] + }, + "expected": { + "outputs": [ + [ + "7efa60ce78ac343df8a013a2027c6c5ef29f9502edcbd769d2c21717fecc5951" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "7efa60ce78ac343df8a013a2027c6c5ef29f9502edcbd769d2c21717fecc5951" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [ + 2, + 3, + 1001337 + ] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5" + ], + "outputs": [ + { + "priv_key_tweak": "e336b92330c33030285ce42e4115ad92d5197913c88e06b9072b4a9b47c664a2", + "pub_key": "7efa60ce78ac343df8a013a2027c6c5ef29f9502edcbd769d2c21717fecc5951", + "signature": "c9e80dd3bdd25ca2d352ce77510f1aed37ba3509dc8cc0677f2d7c2dd04090707950ce9dd6c83d2a428063063aff5c04f1744e334f661f2fc01b4ef80b50f739" + } + ] + } + } + ] + }, + { + "comment": "Multiple outputs with labels: un-labeled and labeled address; same recipient", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqaxww2fnhrx05cghth75n0qcj59e3e2anscr0q9wyknjxtxycg07y3pevyj", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ], + [ + "83dc944e61603137294829aed56c74c9b087d80f2c021b98a7fae5799000696c", + "e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [ + 1 + ] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqaxww2fnhrx05cghth75n0qcj59e3e2anscr0q9wyknjxtxycg07y3pevyj" + ], + "outputs": [ + { + "priv_key_tweak": "43100f89f1a6bf10081c92b473ffc57ceac7dbed600b6aba9bb3976f17dbb914", + "pub_key": "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "signature": "15c92509b67a6c211ebb4a51b7528d0666e6720de2343b2e92cfb97942ca14693c1f1fdc8451acfdb2644039f8f5c76114807fdc3d3a002d8a46afab6756bd75" + }, + { + "priv_key_tweak": "33ce085c3c11eaad13694aae3c20301a6c83382ec89a7cde96c6799e2f88805a", + "pub_key": "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac", + "signature": "335667ca6cae7a26438f5cfdd73b3d48fa832fa9768521d7d5445f22c203ab0d74ed85088f27d29959ba627a4509996676f47df8ff284d292567b1beef0e3912" + } + ] + } + } + ] + }, + { + "comment": "Multiple outputs with labels: multiple outputs for labeled address; same recipient", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqaxww2fnhrx05cghth75n0qcj59e3e2anscr0q9wyknjxtxycg07y3pevyj", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqaxww2fnhrx05cghth75n0qcj59e3e2anscr0q9wyknjxtxycg07y3pevyj" + ] + }, + "expected": { + "outputs": [ + [ + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "83dc944e61603137294829aed56c74c9b087d80f2c021b98a7fae5799000696c" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "83dc944e61603137294829aed56c74c9b087d80f2c021b98a7fae5799000696c" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [ + 1 + ] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqaxww2fnhrx05cghth75n0qcj59e3e2anscr0q9wyknjxtxycg07y3pevyj" + ], + "outputs": [ + { + "priv_key_tweak": "43100f89f1a6bf10081c92b473ffc57ceac7dbed600b6aba9bb3976f17dbb914", + "pub_key": "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "signature": "15c92509b67a6c211ebb4a51b7528d0666e6720de2343b2e92cfb97942ca14693c1f1fdc8451acfdb2644039f8f5c76114807fdc3d3a002d8a46afab6756bd75" + }, + { + "priv_key_tweak": "9d5fd3b91cac9ddfea6fc2e6f9386f680e6cee623cda02f53706306c081de87f", + "pub_key": "83dc944e61603137294829aed56c74c9b087d80f2c021b98a7fae5799000696c", + "signature": "db0dfacc98b6a6fcc67cc4631f080b1ca38c60d8c397f2f19843f8f95ec91594b24e47c5bd39480a861c1209f7e3145c440371f9191fb96e324690101eac8e8e" + } + ] + } + } + ] + }, + { + "comment": "Multiple outputs with labels: un-labeled, labeled, and multiple outputs for labeled address; same recipients", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqaxww2fnhrx05cghth75n0qcj59e3e2anscr0q9wyknjxtxycg07y3pevyj", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjyh2ju7hd5gj57jg5r9lev3pckk4n2shtzaq34467erzzdfajfggty6aa5", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjyh2ju7hd5gj57jg5r9lev3pckk4n2shtzaq34467erzzdfajfggty6aa5" + ] + }, + "expected": { + "outputs": [ + [ + "006a02c308ccdbf3ac49f0638f6de128f875db5a213095cf112b3b77722472ae", + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701", + "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa" + ], + [ + "006a02c308ccdbf3ac49f0638f6de128f875db5a213095cf112b3b77722472ae", + "3edf1ff6657c6e69568811bd726a7a7f480493aa42161acfe8dd4f44521f99ed", + "7ee1543ed5d123ffa66fbebc128c020173eb490d5fa2ba306e0c9573a77db8f3", + "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa" + ], + [ + "006a02c308ccdbf3ac49f0638f6de128f875db5a213095cf112b3b77722472ae", + "7ee1543ed5d123ffa66fbebc128c020173eb490d5fa2ba306e0c9573a77db8f3", + "83dc944e61603137294829aed56c74c9b087d80f2c021b98a7fae5799000696c", + "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701" + ], + [ + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "3c54444944d176437644378c23efb999ab6ab1cacdfe1dc1537b607e3df330e2", + "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa", + "f4569fc5f69c10f0082cfbb8e072e6266ec55f69fba8cffca4cbb4c144b7e59b" + ], + [ + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac", + "f4569fc5f69c10f0082cfbb8e072e6266ec55f69fba8cffca4cbb4c144b7e59b" + ], + [ + "3c54444944d176437644378c23efb999ab6ab1cacdfe1dc1537b607e3df330e2", + "602e10e6944107c9b48bd885b493676578c935723287e0ab2f8b7f136862568e", + "7ee1543ed5d123ffa66fbebc128c020173eb490d5fa2ba306e0c9573a77db8f3", + "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa" + ], + [ + "3c54444944d176437644378c23efb999ab6ab1cacdfe1dc1537b607e3df330e2", + "7ee1543ed5d123ffa66fbebc128c020173eb490d5fa2ba306e0c9573a77db8f3", + "83dc944e61603137294829aed56c74c9b087d80f2c021b98a7fae5799000696c", + "f4569fc5f69c10f0082cfbb8e072e6266ec55f69fba8cffca4cbb4c144b7e59b" + ], + [ + "3edf1ff6657c6e69568811bd726a7a7f480493aa42161acfe8dd4f44521f99ed", + "7ee1543ed5d123ffa66fbebc128c020173eb490d5fa2ba306e0c9573a77db8f3", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac", + "f4569fc5f69c10f0082cfbb8e072e6266ec55f69fba8cffca4cbb4c144b7e59b" + ], + [ + "3edf1ff6657c6e69568811bd726a7a7f480493aa42161acfe8dd4f44521f99ed", + "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa", + "e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca", + "f4569fc5f69c10f0082cfbb8e072e6266ec55f69fba8cffca4cbb4c144b7e59b" + ], + [ + "602e10e6944107c9b48bd885b493676578c935723287e0ab2f8b7f136862568e", + "7ee1543ed5d123ffa66fbebc128c020173eb490d5fa2ba306e0c9573a77db8f3", + "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ], + [ + "602e10e6944107c9b48bd885b493676578c935723287e0ab2f8b7f136862568e", + "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701", + "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa", + "e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca" + ], + [ + "83dc944e61603137294829aed56c74c9b087d80f2c021b98a7fae5799000696c", + "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701", + "e976a58fbd38aeb4e6093d4df02e9c1de0c4513ae0c588cef68cda5b2f8834ca", + "f4569fc5f69c10f0082cfbb8e072e6266ec55f69fba8cffca4cbb4c144b7e59b" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "006a02c308ccdbf3ac49f0638f6de128f875db5a213095cf112b3b77722472ae", + "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701", + "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [ + 1, + 1337 + ] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqaxww2fnhrx05cghth75n0qcj59e3e2anscr0q9wyknjxtxycg07y3pevyj", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjyh2ju7hd5gj57jg5r9lev3pckk4n2shtzaq34467erzzdfajfggty6aa5" + ], + "outputs": [ + { + "priv_key_tweak": "4e3352fbe0505c25e718d96007c259ef08db34f8c844e4ff742d9855ff03805a", + "pub_key": "006a02c308ccdbf3ac49f0638f6de128f875db5a213095cf112b3b77722472ae", + "signature": "6eeae1ea9eb826e3d0e812f65937100e0836ea188c04f36fabc4981eda29de8d3d3529390a0a8b3d830f7bca4f5eae5994b9788ddaf05ad259ffe26d86144b4b" + }, + { + "priv_key_tweak": "43100f89f1a6bf10081c92b473ffc57ceac7dbed600b6aba9bb3976f17dbb914", + "pub_key": "39f42624d5c32a77fda80ff0acee269afec601d3791803e80252ae04e4ffcf4c", + "signature": "15c92509b67a6c211ebb4a51b7528d0666e6720de2343b2e92cfb97942ca14693c1f1fdc8451acfdb2644039f8f5c76114807fdc3d3a002d8a46afab6756bd75" + }, + { + "priv_key_tweak": "bf709f98d4418f8a67e738154ae48818dad44689cd37fbc070891a396dd1c633", + "pub_key": "ae1a780c04237bd577283c3ddb2e499767c3214160d5a6b0767e6b8c278bd701", + "signature": "42a19fd8a63dde1824966a95d65a28203e631e49bf96ca5dae1b390e7a0ace2cc8709c9b0c5715047032f57f536a3c80273cbecf4c05be0b5456c183fa122c06" + }, + { + "priv_key_tweak": "736f05e4e3072c3b8656bedef2e9bf54cbcaa2b6fe5320d3e86f5b96874dda71", + "pub_key": "ca64abe1e0f737823fb9a94f597eed418fb2df77b1317e26b881a14bb594faaa", + "signature": "2e61bb3d79418ecf55f68847cf121bfc12d397b39d1da8643246b2f0a9b96c3daa4bfe9651beb5c9ce20e1f29282c4566400a4b45ee6657ec3b18fdc554da0b4" + } + ] + } + } + ] + }, + { + "comment": "Single recipient: use silent payments for sender change", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + "sp1qqw6vczcfpdh5nf5y2ky99kmqae0tr30hgdfg88parz50cp80wd2wqqlv6saelkk5snl4wfutyxrchpzzwm8rjp3z6q7apna59z9huq4x754e5atr" + ] + }, + "expected": { + "outputs": [ + [ + "be368e28979d950245d742891ae6064020ba548c1e2e65a639a8bb0675d95cff", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "be368e28979d950245d742891ae6064020ba548c1e2e65a639a8bb0675d95cff", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ], + "key_material": { + "spend_priv_key": "b8f87388cbb41934c50daca018901b00070a5ff6cc25a7e9e716a9d5b9e4d664", + "scan_priv_key": "11b7a82e06ca2648d5fded2366478078ec4fc9dc1d8ff487518226f229d768fd" + }, + "labels": [ + 0 + ] + }, + "expected": { + "addresses": [ + "sp1qqw6vczcfpdh5nf5y2ky99kmqae0tr30hgdfg88parz50cp80wd2wqqauj52ymtc4xdkmx3tgyhrsemg2g3303xk2gtzfy8h8ejet8fz8jcw23zua", + "sp1qqw6vczcfpdh5nf5y2ky99kmqae0tr30hgdfg88parz50cp80wd2wqqlv6saelkk5snl4wfutyxrchpzzwm8rjp3z6q7apna59z9huq4x754e5atr" + ], + "outputs": [ + { + "priv_key_tweak": "80cd767ed20bd0bb7d8ea5e803f8c381293a62e8a073cf46fb0081da46e64e1f", + "pub_key": "be368e28979d950245d742891ae6064020ba548c1e2e65a639a8bb0675d95cff", + "signature": "7fbd5074cf1377273155eefafc7c330cb61b31da252f22206ac27530d2b2567040d9af7808342ed4a09598c26d8307446e4ed77079e6a2e61fea736e44da5f5a" + } + ] + } + }, + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "be368e28979d950245d742891ae6064020ba548c1e2e65a639a8bb0675d95cff", + "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "33ce085c3c11eaad13694aae3c20301a6c83382ec89a7cde96c6799e2f88805a", + "pub_key": "f207162b1a7abc51c42017bef055e9ec1efc3d3567cb720357e2b84325db33ac", + "signature": "335667ca6cae7a26438f5cfdd73b3d48fa832fa9768521d7d5445f22c203ab0d74ed85088f27d29959ba627a4509996676f47df8ff284d292567b1beef0e3912" + } + ] + } + } + ] + }, + { + "comment": "Single recipient: taproot input with NUMS point", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0440c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b22205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5ac21c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac00150", + "prevout": { + "scriptPubKey": { + "hex": "5120da6f0595ecb302bbe73e2f221f05ab10f336b06817d36fd28fc6691725ddaa85" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140bd1e708f92dbeaf24a6b8dd22e59c6274355424d62baea976b449e220fd75b13578e262ab11b7aa58e037f0c6b0519b66803b7d9decaa1906dedebfb531c56c1", + "prevout": { + "scriptPubKey": { + "hex": "5120782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338" + } + }, + "private_key": "fc8716a97a48ba9a05a98ae47b5cd201a25a7fd5d8b73c203c5f7b6b6b3b6ad7" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 1, + "scriptSig": "", + "txinwitness": "0340268d31a9276f6380107d5321cafa6d9e8e5ea39204318fdc8206b31507c891c3bbcea3c99e2208d73bd127a8e8c5f1e45a54f1bd217205414ddb566ab7eda0092220e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85dac21c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + "prevout": { + "scriptPubKey": { + "hex": "51200a3c9365ceb131f89b0a4feb6896ebd67bb15a98c31eaa3da143bb955a0f3fcb" + } + }, + "private_key": "8d4751f6e8a3586880fb66c19ae277969bd5aa06f61c4ee2f1e2486efdf666d3" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "79e79897c52935bfd97fc6e076a6431a0c7543ca8c31e0fc3cf719bb572c842d" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0440c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b22205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5ac21c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac00150", + "prevout": { + "scriptPubKey": { + "hex": "5120da6f0595ecb302bbe73e2f221f05ab10f336b06817d36fd28fc6691725ddaa85" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140bd1e708f92dbeaf24a6b8dd22e59c6274355424d62baea976b449e220fd75b13578e262ab11b7aa58e037f0c6b0519b66803b7d9decaa1906dedebfb531c56c1", + "prevout": { + "scriptPubKey": { + "hex": "5120782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 1, + "scriptSig": "", + "txinwitness": "0340268d31a9276f6380107d5321cafa6d9e8e5ea39204318fdc8206b31507c891c3bbcea3c99e2208d73bd127a8e8c5f1e45a54f1bd217205414ddb566ab7eda0092220e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85dac21c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + "prevout": { + "scriptPubKey": { + "hex": "51200a3c9365ceb131f89b0a4feb6896ebd67bb15a98c31eaa3da143bb955a0f3fcb" + } + } + } + ], + "outputs": [ + "79e79897c52935bfd97fc6e076a6431a0c7543ca8c31e0fc3cf719bb572c842d" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "3ddec3232609d348d6b8b53123b4f40f6d4f5398ca586f087b0416ec3b851496", + "pub_key": "79e79897c52935bfd97fc6e076a6431a0c7543ca8c31e0fc3cf719bb572c842d", + "signature": "d7d06e3afb68363031e4eb18035c46ceae41bdbebe7888a4754bc9848c596436869aeaecff0527649a1f458b71c9ceecec10b535c09d01d720229aa228547706" + } + ] + } + } + ] + }, + { + "comment": "Pubkey extraction from malleated p2pkh", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 1, + "scriptSig": "0075473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 2, + "scriptSig": "5163473045022100e7d26e77290b37128f5215ade25b9b908ce87cc9a4d498908b5bb8fd6daa1b8d022002568c3a8226f4f0436510283052bfb780b76f3fe4aa60c4c5eb118e43b187372102e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d67483046022100c0d3c851d3bd562ae93d56bcefd735ea57c027af46145a4d5e9cac113bfeb0c2022100ee5b2239af199fa9b7aa1d98da83a29d0a2cf1e4f29e2f37134ce386d51c544c2102ad0f26ddc7b3fcc340155963b3051b85289c1869612ecb290184ac952e2864ec68", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914c82c5ec473cbc6c86e5ef410e36f9495adcf979988ac" + } + }, + "private_key": "72b8ae09175ca7977f04993e651d88681ed932dfb92c5158cdf0161dd23fda6e" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "4612cdbf845c66c7511d70aab4d9aed11e49e48cdb8d799d787101cdd0d53e4f" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 1, + "scriptSig": "0075473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 2, + "scriptSig": "5163473045022100e7d26e77290b37128f5215ade25b9b908ce87cc9a4d498908b5bb8fd6daa1b8d022002568c3a8226f4f0436510283052bfb780b76f3fe4aa60c4c5eb118e43b187372102e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d67483046022100c0d3c851d3bd562ae93d56bcefd735ea57c027af46145a4d5e9cac113bfeb0c2022100ee5b2239af199fa9b7aa1d98da83a29d0a2cf1e4f29e2f37134ce386d51c544c2102ad0f26ddc7b3fcc340155963b3051b85289c1869612ecb290184ac952e2864ec68", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914c82c5ec473cbc6c86e5ef410e36f9495adcf979988ac" + } + } + } + ], + "outputs": [ + "4612cdbf845c66c7511d70aab4d9aed11e49e48cdb8d799d787101cdd0d53e4f" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "10bde9781def20d7701e7603ef1b1e5e71c67bae7154818814e3c81ef5b1a3d3", + "pub_key": "4612cdbf845c66c7511d70aab4d9aed11e49e48cdb8d799d787101cdd0d53e4f", + "signature": "6137969f810e9e8ef6c9755010e808f5dd1aed705882e44d7f0ae64eb0c509ec8b62a0671bee0d5914ac27d2c463443e28e999d82dc3d3a4919f093872d947bb" + } + ] + } + } + ] + }, + { + "comment": "P2PKH and P2WPKH Uncompressed Keys are skipped", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b974104782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233387c5343bf58e23269e903335b958a12182f9849297321e8d710e49a8727129cab", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9144b92ac4ac6fe6212393894addda332f2e47a315688ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 1, + "scriptSig": "", + "txinwitness": "02473045022100e7d26e77290b37128f5215ade25b9b908ce87cc9a4d498908b5bb8fd6daa1b8d022002568c3a8226f4f0436510283052bfb780b76f3fe4aa60c4c5eb118e43b187374104e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d6fe8190e189be57d0d5bcd17dbcbcd04c9b4a1c5f605b10d5c90abfcc0d12884", + "prevout": { + "scriptPubKey": { + "hex": "00140423f731a07491364e8dce98b7c00bda63336950" + } + }, + "private_key": "72b8ae09175ca7977f04993e651d88681ed932dfb92c5158cdf0161dd23fda6e" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "67fee277da9e8542b5d2e6f32d660a9bbd3f0e107c2d53638ab1d869088882d6" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a91419c2f3ae0ca3b642bd3e49598b8da89f50c1416188ac" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b974104782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233387c5343bf58e23269e903335b958a12182f9849297321e8d710e49a8727129cab", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9144b92ac4ac6fe6212393894addda332f2e47a315688ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 1, + "scriptSig": "", + "txinwitness": "02473045022100e7d26e77290b37128f5215ade25b9b908ce87cc9a4d498908b5bb8fd6daa1b8d022002568c3a8226f4f0436510283052bfb780b76f3fe4aa60c4c5eb118e43b187374104e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d6fe8190e189be57d0d5bcd17dbcbcd04c9b4a1c5f605b10d5c90abfcc0d12884", + "prevout": { + "scriptPubKey": { + "hex": "00140423f731a07491364e8dce98b7c00bda63336950" + } + } + } + ], + "outputs": [ + "67fee277da9e8542b5d2e6f32d660a9bbd3f0e107c2d53638ab1d869088882d6" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "688fa3aeb97d2a46ae87b03591921c2eaf4b505eb0ddca2733c94701e01060cf", + "pub_key": "67fee277da9e8542b5d2e6f32d660a9bbd3f0e107c2d53638ab1d869088882d6", + "signature": "72e7ad573ac23255d4651d5b0326a200496588acb7a4894b22092236d5eda6a0a9a4d8429b022c2219081fefce5b33795cae488d10f5ea9438849ed8353624f2" + } + ] + } + } + ] + }, + { + "comment": "Skip invalid P2SH inputs", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "16001419c2f3ae0ca3b642bd3e49598b8da89f50c14161", + "txinwitness": "02483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "prevout": { + "scriptPubKey": { + "hex": "a9148629db5007d5fcfbdbb466637af09daf9125969387" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 1, + "scriptSig": "1600144b92ac4ac6fe6212393894addda332f2e47a3156", + "txinwitness": "02473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b974104782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233387c5343bf58e23269e903335b958a12182f9849297321e8d710e49a8727129cab", + "prevout": { + "scriptPubKey": { + "hex": "a9146c9bf136fbb7305fd99d771a95127fcf87dedd0d87" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 2, + "scriptSig": "00493046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d601483045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b97014c695221025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be52103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233382102e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d53ae", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "a9141044ddc6cea09e4ac40fbec2ba34ad62de6db25b87" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [ + "67fee277da9e8542b5d2e6f32d660a9bbd3f0e107c2d53638ab1d869088882d6" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "16001419c2f3ae0ca3b642bd3e49598b8da89f50c14161", + "txinwitness": "02483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d621025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5", + "prevout": { + "scriptPubKey": { + "hex": "a9148629db5007d5fcfbdbb466637af09daf9125969387" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 1, + "scriptSig": "1600144b92ac4ac6fe6212393894addda332f2e47a3156", + "txinwitness": "02473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b974104782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233387c5343bf58e23269e903335b958a12182f9849297321e8d710e49a8727129cab", + "prevout": { + "scriptPubKey": { + "hex": "a9146c9bf136fbb7305fd99d771a95127fcf87dedd0d87" + } + } + }, + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 2, + "scriptSig": "00493046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d601483045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b97014c695221025a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be52103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233382102e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d53ae", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "a9141044ddc6cea09e4ac40fbec2ba34ad62de6db25b87" + } + } + } + ], + "outputs": [ + "67fee277da9e8542b5d2e6f32d660a9bbd3f0e107c2d53638ab1d869088882d6" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [ + { + "priv_key_tweak": "688fa3aeb97d2a46ae87b03591921c2eaf4b505eb0ddca2733c94701e01060cf", + "pub_key": "67fee277da9e8542b5d2e6f32d660a9bbd3f0e107c2d53638ab1d869088882d6", + "signature": "72e7ad573ac23255d4651d5b0326a200496588acb7a4894b22092236d5eda6a0a9a4d8429b022c2219081fefce5b33795cae488d10f5ea9438849ed8353624f2" + } + ] + } + } + ] + }, + { + "comment": "Recipient ignores unrelated outputs", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgrz6j0lcqnc04vxccydl0kpsj4frfje0ktmgcl2t346hkw30226xqupawdf48k8882j0strrvcmgg2kdawz53a54dd376ngdhak364hzcmynqtn" + ] + }, + "expected": { + "outputs": [ + [ + "841792c33c9dc6193e76744134125d40add8f2f4a96475f28ba150be032d64e8" + ] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "", + "txinwitness": "0140c459b671370d12cfb5acee76da7e3ba7cc29b0b4653e3af8388591082660137d087fdc8e89a612cd5d15be0febe61fc7cdcf3161a26e599a4514aa5c3e86f47b", + "prevout": { + "scriptPubKey": { + "hex": "51205a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b972103782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9147cdd63cc408564188e8e472640e921c7c90e651d88ac" + } + } + } + ], + "outputs": [ + "841792c33c9dc6193e76744134125d40add8f2f4a96475f28ba150be032d64e8", + "782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [] + } + } + ] + }, + { + "comment": "No valid inputs, sender generates no outputs", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d641045a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5c61836c9b1688ba431f7ea3039742251f62f0dca3da1bee58a47fa9b456c2d52", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914460e8b41545d2dbe7e0671f0f573e2232814260a88ac" + } + }, + "private_key": "eadc78165ff1f8ea94ad7cfdc54990738a4c53f6e0507b42154201b8e5dff3b1" + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b974104782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233387c5343bf58e23269e903335b958a12182f9849297321e8d710e49a8727129cab", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9144b92ac4ac6fe6212393894addda332f2e47a315688ac" + } + }, + "private_key": "0378e95685b74565fa56751b84a32dfd18545d10d691641b8372e32164fad66a" + } + ], + "recipients": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ] + }, + "expected": { + "outputs": [ + [] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "vout": 0, + "scriptSig": "483046022100ad79e6801dd9a8727f342f31c71c4912866f59dc6e7981878e92c5844a0ce929022100fb0d2393e813968648b9753b7e9871d90ab3d815ebf91820d704b19f4ed224d641045a1e61f898173040e20616d43e9f496fba90338a39faa1ed98fcbaeee4dd9be5c61836c9b1688ba431f7ea3039742251f62f0dca3da1bee58a47fa9b456c2d52", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a914460e8b41545d2dbe7e0671f0f573e2232814260a88ac" + } + } + }, + { + "txid": "a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d", + "vout": 0, + "scriptSig": "473045022100a8c61b2d470e393279d1ba54f254b7c237de299580b7fa01ffcc940442ecec4502201afba952f4e4661c40acde7acc0341589031ba103a307b886eb867b23b850b974104782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c3799373233387c5343bf58e23269e903335b958a12182f9849297321e8d710e49a8727129cab", + "txinwitness": "", + "prevout": { + "scriptPubKey": { + "hex": "76a9144b92ac4ac6fe6212393894addda332f2e47a315688ac" + } + } + } + ], + "outputs": [ + "782eeb913431ca6e9b8c2fd80a5f72ed2024ef72a3c6fb10263c379937323338", + "e0ec4f64b3fa2e463ccfcf4e856e37d5e1e20275bc89ec1def9eb098eff1f85d" + ], + "key_material": { + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ], + "outputs": [] + } + } + ] + }, + { + "comment": "Input keys sum up to zero / point at infinity: sending fails, receiver skips tx", + "sending": [ + { + "given": { + "vin": [ + { + "txid": "3a286147b25e16ae80aff406f2673c6e565418c40f45c071245cdebc8a94174e", + "vout": 0, + "scriptSig": "", + "txinwitness": "024730440220085003179ce1a3a88ce0069aa6ea045e140761ab88c22a26ae2a8cfe983a6e4602204a8a39940f0735c8a4424270ac8da65240c261ab3fda9272f6d6efbf9cfea366012102557ef3e55b0a52489b4454c1169e06bdea43687a69c1f190eb50781644ab6975", + "prevout": { + "scriptPubKey": { + "hex": "00149d9e24f9fab4e35bf1a6df4b46cb533296ac0792" + } + }, + "private_key": "a6df6a0bb448992a301df4258e06a89fe7cf7146f59ac3bd5ff26083acb22ceb" + }, + { + "txid": "3a286147b25e16ae80aff406f2673c6e565418c40f45c071245cdebc8a94174e", + "vout": 1, + "scriptSig": "", + "txinwitness": "0247304402204586a68e1d97dd3c6928e3622799859f8c3b20c3c670cf654cc905c9be29fdb7022043fbcde1689f3f4045e8816caf6163624bd19e62e4565bc99f95c533e599782c012103557ef3e55b0a52489b4454c1169e06bdea43687a69c1f190eb50781644ab6975", + "prevout": { + "scriptPubKey": { + "hex": "00149860538b5575962776ed0814ae222c7d60c72d7b" + } + }, + "private_key": "592095f44bb766d5cfe20bda71f9575ed2df6b9fb9addc7e5fdffe0923841456" + } + ], + "recipients": [ + "sp1qqtrqglu5g8kh6mfsg4qxa9wq0nv9cauwfwxw70984wkqnw2uwz0w2qnehen8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq6lqj3s" + ] + }, + "expected": { + "outputs": [ + [] + ] + } + } + ], + "receiving": [ + { + "given": { + "vin": [ + { + "txid": "3a286147b25e16ae80aff406f2673c6e565418c40f45c071245cdebc8a94174e", + "vout": 0, + "scriptSig": "", + "txinwitness": "024730440220085003179ce1a3a88ce0069aa6ea045e140761ab88c22a26ae2a8cfe983a6e4602204a8a39940f0735c8a4424270ac8da65240c261ab3fda9272f6d6efbf9cfea366012102557ef3e55b0a52489b4454c1169e06bdea43687a69c1f190eb50781644ab6975", + "prevout": { + "scriptPubKey": { + "hex": "00149d9e24f9fab4e35bf1a6df4b46cb533296ac0792" + } + } + }, + { + "txid": "3a286147b25e16ae80aff406f2673c6e565418c40f45c071245cdebc8a94174e", + "vout": 1, + "scriptSig": "", + "txinwitness": "0247304402204586a68e1d97dd3c6928e3622799859f8c3b20c3c670cf654cc905c9be29fdb7022043fbcde1689f3f4045e8816caf6163624bd19e62e4565bc99f95c533e599782c012103557ef3e55b0a52489b4454c1169e06bdea43687a69c1f190eb50781644ab6975", + "prevout": { + "scriptPubKey": { + "hex": "00149860538b5575962776ed0814ae222c7d60c72d7b" + } + } + } + ], + "outputs": [ + "0000000000000000000000000000000000000000000000000000000000000000" + ], + "key_material": { + "spend_priv_key": "0000000000000000000000000000000000000000000000000000000000000001", + "scan_priv_key": "0000000000000000000000000000000000000000000000000000000000000002" + }, + "labels": [] + }, + "expected": { + "addresses": [ + "sp1qqtrqglu5g8kh6mfsg4qxa9wq0nv9cauwfwxw70984wkqnw2uwz0w2qnehen8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq6lqj3s" + ], + "outputs": [] + } + } + ] + } +] diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py index 49ee8e31..b4da4091 100644 --- a/tests/tests/test_bip352.py +++ b/tests/tests/test_bip352.py @@ -9,7 +9,87 @@ import pytest from embit.silent_payments import bip352 from embit.ec import PrivateKey -from embit.networks import NETWORKS +import os +import json +from embit import hashes +from embit.script import Script, Witness +from embit.transaction import COutPoint +from embit.util.key import ECPubKey +from embit.ec import NUMS_PUBKEY + + +def get_input_pubkey(prevout_script, script_sig=None, witness=None) -> ECPubKey: + """Extract and validate the input pubkey for a prevout, by script type. + + Test helper for BIP-352 send vectors. Returns an ECPubKey with .valid=False + when no suitable compressed pubkey can be determined. + """ + spk = ( + prevout_script if isinstance(prevout_script, Script) else Script(prevout_script) + ) + + if isinstance(script_sig, str): + try: + ss = bytes.fromhex(script_sig) + except Exception: + ss = b"" + elif isinstance(script_sig, bytes): + ss = script_sig + else: + ss = b"" + + if isinstance(witness, Witness): + wstack = witness.items + elif isinstance(witness, list): + wstack = witness + else: + wstack = [] + + script_type = spk.script_type() + + def _compressed(pubkey_bytes): + pubkey = ECPubKey().set(pubkey_bytes) + return pubkey if (pubkey.valid and pubkey.is_compressed) else None + + if script_type == "p2pkh": + spk_hash = spk.data[3:23] + for i in range(len(ss), 32, -1): + if i >= 33: + pubkey_bytes = ss[i - 33 : i] + if ( + pubkey_bytes[0] in (0x02, 0x03) + and hashes.hash160(pubkey_bytes) == spk_hash + ): + pubkey = _compressed(pubkey_bytes) + if pubkey: + return pubkey + return ECPubKey() + + if script_type in ("p2sh", "p2wpkh"): + if wstack and (script_type == "p2wpkh" or len(ss) > 1): + pubkey = _compressed(wstack[-1]) + if pubkey: + return pubkey + return ECPubKey() + + if script_type == "p2tr": + if wstack: + # strip annex if present (last element starting with 0x50) + if len(wstack) > 1 and wstack[-1][:1] == b"\x50": + wstack = wstack[:-1] + # Script-path spend with NUMS internal key: not key-spendable + if len(wstack) > 1: + control_block = wstack[-1] + if len(control_block) >= 33 and control_block[1:33] == NUMS_PUBKEY.xonly(): + return ECPubKey() + # Key-path spend: reconstruct even-y compressed SEC from x-only + if len(spk.data) >= 34: + pubkey = ECPubKey().set(b"\x02" + spk.data[2:34]) + if pubkey.valid: + return pubkey + + return ECPubKey() + BASIC_TEST_VECTORS = [ { @@ -37,6 +117,9 @@ } +INVALID_LABEL_TEST_VECTORS = ["not an int", 99999999999999999999999999, -15, 1.0] + + class BIP352Test(TestCase): def test_generate_silent_payment_address(self): """Should generate the expected silent payment address""" @@ -48,20 +131,6 @@ def test_generate_silent_payment_address(self): ) assert sp_address == test_vector["sp_address"] - def test_generate_silent_payment_address_for_network(self): - """Test network silent payment addrs should start with "tsp" """ - test_networks = [k for k in NETWORKS.keys() if k != "main"] - scan_priv_key = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"])) - spend_pubkey = PrivateKey( - unhexlify(BASIC_TEST_VECTORS[0]["spend_priv_key"]) - ).get_public_key() - - for network in test_networks: - payment_addr = bip352.generate_silent_payment_address( - scan_priv_key, spend_pubkey, network=network - ) - assert payment_addr.startswith("tsp") - def test_generate_labeled_silent_payment_address(self): """Should generate the expected labeled silent payment addresses""" spend_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["spend_priv_key"])) @@ -70,36 +139,99 @@ def test_generate_labeled_silent_payment_address(self): LABEL_TEST_VECTORS["labels"], LABEL_TEST_VECTORS["addresses"] ): sp_address = bip352.generate_silent_payment_address( - scan_priv_key, spend_priv_key.get_public_key(), label=label + scan_priv_key, spend_priv_key.get_public_key(), label ) assert sp_address == address - def test_generate_labeled_silent_payment_address_invalid_label(self): - """Labels must be 32-bit unsigned ints in [1, 2**32 - 1]""" - spend_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["spend_priv_key"])) - scan_priv_key = PrivateKey(unhexlify(LABEL_TEST_VECTORS["scan_priv_key"])) - spend_pubkey = spend_priv_key.get_public_key() - - for bad_label in ["tenant 6102", b"I am bytes", 1.0, True]: - with pytest.raises(TypeError): - # Label must be an int (and not a bool) + with pytest.raises(Exception): + for label in INVALID_LABEL_TEST_VECTORS: bip352.generate_silent_payment_address( - scan_priv_key, spend_pubkey, label=bad_label + scan_priv_key, spend_priv_key.get_public_key(), label ) - for bad_label in [0, -1, 0x100000000]: - with pytest.raises(ValueError): - # m = 0 is reserved for change; values must fit in 32 bits - bip352.generate_silent_payment_address( - scan_priv_key, spend_pubkey, label=bad_label + def test_decode_silent_payment_address(self): + """Should decode the silent payment address and return the expected keys""" + for test_vector in BASIC_TEST_VECTORS: + scan_priv_key = PrivateKey(unhexlify(test_vector["scan_priv_key"])) + spend_priv_key = PrivateKey(unhexlify(test_vector["spend_priv_key"])) + B_scan, B_spend = bip352.decode_silent_payment_address( + test_vector["sp_address"] + ) + + assert B_scan == scan_priv_key.get_public_key() + assert B_spend == spend_priv_key.get_public_key() + + with pytest.raises(ValueError): + # Invalid HRP + bip352.decode_silent_payment_address( + "st1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv" + ) + + with pytest.raises(ValueError): + # Invalid encoding + bip352.decode_silent_payment_address( + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwvm" + ) + + def test_create_silent_payments_outputs(self): + """Test silent payment output generation using test vectors""" + __location__ = os.path.realpath( + os.path.join(os.getcwd(), os.path.dirname(__file__)) + ) + with open( + os.path.join(__location__, "data/send_and_receive_test_vectors.json"), "r" + ) as f: + SEND_AND_RECEIVE_TEST_VECTORS = json.load(f) + + from io import BytesIO + + for case in SEND_AND_RECEIVE_TEST_VECTORS: + for sending_test in case["sending"]: + given = sending_test["given"] + expected = sending_test["expected"] + + outpoints: list[COutPoint] = [] + input_privkeys: list[tuple] = [] + + for txin in given["vin"]: + outpoints.append( + COutPoint(txid=unhexlify(txin["txid"]), out_idx=txin["vout"]) + ) + + spk_hex = txin["prevout"]["scriptPubKey"]["hex"] + spk = Script(unhexlify(spk_hex)) + + wit_hex = txin.get("txinwitness", "") or "" + witness = None + if wit_hex: + try: + witness = Witness.read_from(BytesIO(bytes.fromhex(wit_hex))) + except Exception: + witness = None + + pub = get_input_pubkey(spk, txin.get("scriptSig", ""), witness) + if not getattr(pub, "valid", False): + continue + + is_xonly = spk.script_type() == "p2tr" + input_privkeys.append((unhexlify(txin["private_key"]), is_xonly)) + + outputs_map = bip352.create_outputs( + input_privkeys=input_privkeys, + outpoints=outpoints, + recipients=given["recipients"], ) - def test_decode_silent_payment_address_round_trip(self): - """A generated address should decode back to its scan/spend pubkeys""" - spend_priv_key = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["spend_priv_key"])) - scan_priv_key = PrivateKey(unhexlify(BASIC_TEST_VECTORS[0]["scan_priv_key"])) - address = BASIC_TEST_VECTORS[0]["sp_address"] + expected_outputs = expected["outputs"] - B_scan, B_spend = bip352.decode_silent_payment_address(address) - assert B_scan.sec() == scan_priv_key.get_public_key().sec() - assert B_spend.sec() == spend_priv_key.get_public_key().sec() + actual_outputs = [] + for recipient, outputs in outputs_map.items(): + actual_outputs.extend(outputs) + + self.assertTrue( + any( + set(actual_outputs) == set(expected_set) + for expected_set in expected_outputs + ), + f"Actual outputs {set(actual_outputs)} did not match any expected set {expected_outputs}", + ) From a7ebf3f2078363a954f2712aacc360d3037d9269 Mon Sep 17 00:00:00 2001 From: odudex Date: Fri, 29 May 2026 10:19:46 -0300 Subject: [PATCH 10/61] sp: make silent payment code MicroPython-safe Drop the typing and collections imports (Counter/defaultdict, Tuple/List/Dict) and convert COutPoint from typing.NamedTuple to a plain class with positional int.to_bytes. Replace remaining f-strings with str.format and remove the now unused COutPoint import from bip352. --- src/embit/silent_payments/bip352.py | 30 +++++++++++++---------------- src/embit/transaction.py | 13 ++++++------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index d8548ca8..635b1330 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -3,14 +3,10 @@ see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki """ -from collections import Counter, defaultdict -from typing import Tuple, List, Dict - from .. import bech32, ec from ..util import secp256k1 from ..hashes import tagged_hash from ..util.key import SECP256K1_ORDER -from ..transaction import COutPoint from ..util.secp256k1 import ( ec_pubkey_create, ec_pubkey_serialize, @@ -63,7 +59,7 @@ def generate_silent_payment_address( # TODO: use the bech32 decode function once the flexible bech32 PR is in -def decode_silent_payment_address(address: str) -> Tuple[ec.PublicKey, ec.PublicKey]: +def decode_silent_payment_address(address: str): """ Decode a silent payment address and return the scan and spend public keys. Silent payment addresses can be longer than 90 characters, so we need custom decoding. @@ -108,7 +104,7 @@ def decode_silent_payment_address(address: str) -> Tuple[ec.PublicKey, ec.Public if data[0] != 0: raise ValueError( - f"Invalid silent payment address: unsupported version {data[0]}" + "Invalid silent payment address: unsupported version {}".format(data[0]) ) decoded = bech32.convertbits(data[1:], 5, 8, False) @@ -119,22 +115,20 @@ def decode_silent_payment_address(address: str) -> Tuple[ec.PublicKey, ec.Public B_scan = ec.PublicKey.parse(bytes(decoded[:33])) B_spend = ec.PublicKey.parse(bytes(decoded[33:])) except Exception as e: - raise ValueError(f"Invalid silent payment address: invalid public keys - {e}") + raise ValueError( + "Invalid silent payment address: invalid public keys - {}".format(e) + ) return B_scan, B_spend -def get_input_hash(outpoints: List["COutPoint"], sum_pubkey_bytes: bytes) -> bytes: +def get_input_hash(outpoints, sum_pubkey_bytes: bytes) -> bytes: lowest_outpoint = sorted(outpoints, key=lambda o: o.serialize())[0] preimage = lowest_outpoint.serialize() + sum_pubkey_bytes return tagged_hash("BIP0352/Inputs", preimage) -def create_outputs( - input_privkeys: List[Tuple[bytes, bool]], - outpoints: List["COutPoint"], - recipients: List[str], -) -> Dict[str, List[str]]: +def create_outputs(input_privkeys, outpoints, recipients): """ Creates silent payment outputs for given recipients. @@ -170,14 +164,16 @@ def create_outputs( input_hash = get_input_hash(outpoints, ec_pubkey_serialize(A)) - recipient_counts = Counter(recipients) + recipient_counts = {} + for addr in recipients: + recipient_counts[addr] = recipient_counts.get(addr, 0) + 1 - groups: Dict[ec.PublicKey, List[Tuple[ec.PublicKey, str, int]]] = defaultdict(list) + groups = {} for addr, count in recipient_counts.items(): B_scan, B_spend = decode_silent_payment_address(addr) - groups[B_scan].append((B_spend, addr, count)) + groups.setdefault(B_scan, []).append((B_spend, addr, count)) - result: Dict[str, List[str]] = {addr: [] for addr in recipient_counts.keys()} + result = {addr: [] for addr in recipient_counts.keys()} scalar = (int.from_bytes(input_hash, "big") * a_sum) % SECP256K1_ORDER scalar_bytes = scalar.to_bytes(32, "big") diff --git a/src/embit/transaction.py b/src/embit/transaction.py index 1919a9d0..fb64d8c7 100644 --- a/src/embit/transaction.py +++ b/src/embit/transaction.py @@ -4,7 +4,6 @@ from .base import EmbitBase, EmbitError from .script import Script, Witness from .misc import const -from typing import NamedTuple class TransactionError(EmbitError): @@ -401,11 +400,11 @@ def read_from(cls, stream): return cls(value, script_pubkey) -class COutPoint(NamedTuple): - txid: bytes # endianness same as hex string displayed; reverse of tx serialization order - out_idx: int +class COutPoint: + def __init__(self, txid: bytes, out_idx: int): + # txid endianness same as hex string displayed; reverse of tx serialization order + self.txid = txid + self.out_idx = out_idx def serialize(self) -> bytes: - return self.txid[::-1] + int.to_bytes( - self.out_idx, length=4, byteorder="little", signed=False - ) + return self.txid[::-1] + self.out_idx.to_bytes(4, "little") From de23863b5d163ccc9da1de11d95cae85f704c41d Mon Sep 17 00:00:00 2001 From: notTanveer Date: Tue, 26 May 2026 01:53:39 +0530 Subject: [PATCH 11/61] feat(BIP-392): sp descriptors --- src/embit/bech32.py | 21 +++ src/embit/descriptor/__init__.py | 1 + src/embit/descriptor/descriptor.py | 8 + src/embit/descriptor/sp.py | 286 +++++++++++++++++++++++++++++ tests/tests/test_sp_descriptor.py | 277 ++++++++++++++++++++++++++++ 5 files changed, 593 insertions(+) create mode 100644 src/embit/descriptor/sp.py create mode 100644 tests/tests/test_sp_descriptor.py diff --git a/src/embit/bech32.py b/src/embit/bech32.py index b6766c52..b230f180 100644 --- a/src/embit/bech32.py +++ b/src/embit/bech32.py @@ -112,6 +112,27 @@ def bech32_decode(bech): return (encoding, hrp, data[:-6]) +# TODO: remove this once flexible bech32 is in +def bech32_decode_long(bech): + """Like bech32_decode but without the 90-character length limit.""" + if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or ( + bech.lower() != bech and bech.upper() != bech + ): + return (None, None, None) + bech = bech.lower() + pos = bech.rfind("1") + if pos < 1 or pos + 7 > len(bech): + return (None, None, None) + if not all(x in CHARSET for x in bech[pos + 1 :]): + return (None, None, None) + hrp = bech[:pos] + data = [CHARSET.find(x) for x in bech[pos + 1 :]] + encoding = bech32_verify_checksum(hrp, data) + if encoding is None: + return (None, None, None) + return (encoding, hrp, data[:-6]) + + def convertbits(data, frombits, tobits, pad=True): """General power-of-2 base conversion.""" acc = 0 diff --git a/src/embit/descriptor/__init__.py b/src/embit/descriptor/__init__.py index 600296d3..00298b68 100644 --- a/src/embit/descriptor/__init__.py +++ b/src/embit/descriptor/__init__.py @@ -1,3 +1,4 @@ from . import miniscript from .descriptor import Descriptor from .arguments import Key +from .sp import SilentPaymentDescriptor diff --git a/src/embit/descriptor/descriptor.py b/src/embit/descriptor/descriptor.py index fd3c3057..c0737e74 100644 --- a/src/embit/descriptor/descriptor.py +++ b/src/embit/descriptor/descriptor.py @@ -6,6 +6,7 @@ from .miniscript import Miniscript, Multi, Sortedmulti from .arguments import Key from .taptree import TapTree +from .sp import SilentPaymentDescriptor class Descriptor(DescriptorBase): @@ -302,6 +303,13 @@ def read_from(cls, s): is_miniscript = True taproot = False taptree = TapTree() + if start.startswith(b"sp("): + s.seek(-5, 1) + sp_desc = SilentPaymentDescriptor.read_from(s) + end = s.read(1) + if end != b")": + raise DescriptorError("Expected closing ) for sp()") + return sp_desc if start.startswith(b"tr("): taproot = True s.seek(-5, 1) diff --git a/src/embit/descriptor/sp.py b/src/embit/descriptor/sp.py new file mode 100644 index 00000000..94c6c823 --- /dev/null +++ b/src/embit/descriptor/sp.py @@ -0,0 +1,286 @@ +from io import BytesIO +from .. import bech32, ec +from ..misc import read_until +from .base import DescriptorBase +from .errors import DescriptorError +from .arguments import KeyOrigin, Key + +SPSCAN_HRPS = {"spscan": "main", "tspscan": "test"} +SPSPEND_HRPS = {"spspend": "main", "tspspend": "test"} +SP_KEY_HRPS = {**SPSCAN_HRPS, **SPSPEND_HRPS} + + +def _bech32m_decode_sp_key(encoded): + encoding, hrp, data = bech32.bech32_decode_long(encoded) + if encoding is None: + raise DescriptorError("Invalid bech32m encoding in SP key: %s" % encoded) + if encoding != bech32.Encoding.BECH32M: + raise DescriptorError("SP key must use bech32m encoding") + if hrp not in SP_KEY_HRPS: + raise DescriptorError("Unknown SP key HRP: %s" % hrp) + if len(data) < 1: + raise DescriptorError("SP key data too short") + version = data[0] + if version != 0: + raise DescriptorError("Unsupported SP key version: %d" % version) + payload = bech32.convertbits(data[1:], 5, 8, False) + if payload is None: + raise DescriptorError("Invalid SP key payload encoding") + return hrp, bytes(payload) + + +def _bech32m_encode_sp_key(hrp, payload): + data = bech32.convertbits(payload, 8, 5) + return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [0] + data) + + +class SPScanKey: + """spscan key expression: encodes scan_privkey + spend_pubkey.""" + + def __init__(self, scan_privkey, spend_pubkey, origin=None, network="main"): + if not isinstance(scan_privkey, ec.PrivateKey): + raise DescriptorError("SPScanKey scan key must be a PrivateKey") + if not isinstance(spend_pubkey, ec.PublicKey): + raise DescriptorError("SPScanKey spend key must be a PublicKey") + self.scan_privkey = scan_privkey + self.spend_pubkey = spend_pubkey + self.origin = origin + self.network = network + + @property + def is_watch_only(self): + return True + + @classmethod + def decode(cls, encoded, origin=None): + hrp, payload = _bech32m_decode_sp_key(encoded) + if hrp not in SPSCAN_HRPS: + raise DescriptorError("Expected spscan HRP, got: %s" % hrp) + if len(payload) != 65: + raise DescriptorError( + "spscan payload must be 65 bytes (32 privkey + 33 pubkey), got %d" + % len(payload) + ) + scan_privkey = ec.PrivateKey(payload[:32]) + spend_pubkey = ec.PublicKey.parse(payload[32:65]) + network = SPSCAN_HRPS[hrp] + return cls(scan_privkey, spend_pubkey, origin, network) + + def encode(self): + hrp = "tspscan" if self.network == "test" else "spscan" + payload = self.scan_privkey.secret + self.spend_pubkey.sec() + return _bech32m_encode_sp_key(hrp, payload) + + def __str__(self): + prefix = "[%s]" % self.origin if self.origin else "" + return prefix + self.encode() + + +class SPSpendKey: + """spspend key expression: encodes scan_privkey + spend_privkey.""" + + def __init__(self, scan_privkey, spend_privkey, origin=None, network="main"): + if not isinstance(scan_privkey, ec.PrivateKey): + raise DescriptorError("SPSpendKey scan key must be a PrivateKey") + if not isinstance(spend_privkey, ec.PrivateKey): + raise DescriptorError("SPSpendKey spend key must be a PrivateKey") + self.scan_privkey = scan_privkey + self.spend_privkey = spend_privkey + self.origin = origin + self.network = network + + @property + def spend_pubkey(self): + return self.spend_privkey.get_public_key() + + @property + def is_watch_only(self): + return False + + @classmethod + def decode(cls, encoded, origin=None): + hrp, payload = _bech32m_decode_sp_key(encoded) + if hrp not in SPSPEND_HRPS: + raise DescriptorError("Expected spspend HRP, got: %s" % hrp) + if len(payload) != 64: + raise DescriptorError( + "spspend payload must be 64 bytes (32 + 32), got %d" % len(payload) + ) + scan_privkey = ec.PrivateKey(payload[:32]) + spend_privkey = ec.PrivateKey(payload[32:64]) + network = SPSPEND_HRPS[hrp] + return cls(scan_privkey, spend_privkey, origin, network) + + def encode(self): + hrp = "tspspend" if self.network == "test" else "spspend" + payload = self.scan_privkey.secret + self.spend_privkey.secret + return _bech32m_encode_sp_key(hrp, payload) + + def __str__(self): + prefix = "[%s]" % self.origin if self.origin else "" + return prefix + self.encode() + + +def _read_sp_key_expression(s): + """Read an spscan/spspend expression or a standard Key from stream.""" + first = s.read(1) + origin = None + if first == b"[": + prefix, char = read_until(s, b"]") + if char != b"]": + raise DescriptorError("Invalid key - missing ]") + origin = KeyOrigin.from_string(prefix.decode()) + else: + s.seek(-1, 1) + + pos_before = s.tell() + token, char = read_until(s, b",)") + if char is not None: + s.seek(-1, 1) + token_str = token.decode() + + lower = token_str.lower() + for hrp in SPSCAN_HRPS: + if lower.startswith(hrp + "1"): + return SPScanKey.decode(token_str, origin), char + for hrp in SPSPEND_HRPS: + if lower.startswith(hrp + "1"): + return SPSpendKey.decode(token_str, origin), char + + s.seek(pos_before) + if origin: + origin_str = "[%s]" % origin + combined = BytesIO(origin_str.encode() + s.read()) + key = Key.read_from(combined) + else: + key = Key.read_from(s) + return key, None + + +class SilentPaymentDescriptor(DescriptorBase): + """BIP-392 sp() descriptor for Silent Payments.""" + + def __init__(self, sp_key=None, scan_key=None, spend_key=None): + if sp_key is not None: + if not isinstance(sp_key, (SPScanKey, SPSpendKey)): + raise DescriptorError( + "Single-arg sp() requires an spscan or spspend key expression" + ) + self.sp_key = sp_key + self.scan_key = None + self.spend_key = None + elif scan_key is not None: + if not _is_private_key(scan_key): + raise DescriptorError("Two-arg sp(): scan key must be private") + self.sp_key = None + self.scan_key = scan_key + self.spend_key = spend_key + else: + raise DescriptorError("sp() requires at least one argument") + + @property + def is_single_arg(self): + return self.sp_key is not None + + @property + def is_watch_only(self): + if self.sp_key is not None: + return self.sp_key.is_watch_only + return not _is_private_key(self.spend_key) + + @property + def keys(self): + if self.sp_key is not None: + return [self.sp_key] + return [self.scan_key, self.spend_key] + + def get_scan_privkey(self): + if self.sp_key is not None: + return self.sp_key.scan_privkey + k = self.scan_key + if isinstance(k, Key): + return k.private_key + return None + + def get_spend_pubkey(self): + if self.sp_key is not None: + return self.sp_key.spend_pubkey + k = self.spend_key + if isinstance(k, Key): + return k.get_public_key() + return None + + @classmethod + def from_string(cls, desc): + if "#" in desc: + desc = desc.split("#")[0] + s = BytesIO(desc.encode()) + start = s.read(3) + if start != b"sp(": + raise DescriptorError("Expected sp( prefix, got: %s" % start.decode()) + res = cls._read_args(s) + end = s.read(1) + if end != b")": + raise DescriptorError("Expected closing ) for sp()") + left = s.read() + if len(left) > 0: + raise DescriptorError("Unexpected characters after sp(): %r" % left) + return res + + @classmethod + def read_from(cls, s): + return cls._read_args(s) + + @classmethod + def _read_args(cls, s): + first_arg, sep = _read_sp_key_expression(s) + + if isinstance(first_arg, (SPScanKey, SPSpendKey)): + c = s.read(1) + if c == b")": + s.seek(-1, 1) + return cls(sp_key=first_arg) + raise DescriptorError( + "spscan/spspend key must be the only argument to sp()" + ) + + c = s.read(1) + if c != b",": + raise DescriptorError( + "Single-arg sp() requires spscan or spspend key expression, " + "got a standard key" + ) + + scan_key = first_arg + if not _is_private_key(scan_key): + raise DescriptorError("Two-arg sp(): scan key must be private") + if isinstance(scan_key.key, ec.PrivateKey) and not scan_key.key.compressed: + raise DescriptorError("Uncompressed keys are not allowed in sp()") + + spend_arg, _ = _read_sp_key_expression(s) + if isinstance(spend_arg, (SPScanKey, SPSpendKey)): + raise DescriptorError( + "Two-arg sp() cannot use spscan/spspend key expressions" + ) + if isinstance(spend_arg, Key) and isinstance(spend_arg.key, ec.PrivateKey): + if not spend_arg.key.compressed: + raise DescriptorError("Uncompressed keys are not allowed in sp()") + + return cls(scan_key=scan_key, spend_key=spend_arg) + + def to_string(self): + if self.sp_key is not None: + return "sp(%s)" % self.sp_key + return "sp(%s,%s)" % (self.scan_key, self.spend_key) + + def __str__(self): + return self.to_string() + + def __repr__(self): + return self.to_string() + + +def _is_private_key(key): + if isinstance(key, Key): + return key.is_private + return False diff --git a/tests/tests/test_sp_descriptor.py b/tests/tests/test_sp_descriptor.py new file mode 100644 index 00000000..bd48f613 --- /dev/null +++ b/tests/tests/test_sp_descriptor.py @@ -0,0 +1,277 @@ +from unittest import TestCase +from embit.descriptor import SilentPaymentDescriptor +from embit.descriptor.sp import SPScanKey, SPSpendKey +from embit.descriptor.arguments import KeyOrigin +from embit.descriptor.errors import DescriptorError +from embit import bip32, bip39, ec +from binascii import unhexlify + +# TODO: add more test vectors +VECTORS = [ + { + "mnemonic": "initial tilt corn easily leave weather strategy return topple gesture sad day", + "coin_type": 1, + "spscan": "tspscan1q09zrmaz09cdzs5jxm552qpv3f2gxd9vxhs0yady09jdd6aqt5e7s9fue8565hmue30u47mvc6rqwwwh0zw6ptjtqzwq7kr6h27sa09f5g6x977", + "spspend": "tspspend1q09zrmaz09cdzs5jxm552qpv3f2gxd9vxhs0yady09jdd6aqt5e772yf8kwpa7shfhuw9esasvgn8lh7e6ufea60fpvfx9dk7m3klg6sa90au8", + }, + { + "mnemonic": "tongue vanish post gentle fever figure kangaroo select infant blur phrase relief", + "coin_type": 0, + "spscan": "spscan1qnd95fpg2587jn73qg98pq8uk20y09v5c20u0e4kynsc4m2qmkrrs9cahrrlzln5nreangzkja2mj8pnwrfwudqws4vl3at3zyw2tslxtryq7pn", + "spspend": "spspend1qnd95fpg2587jn73qg98pq8uk20y09v5c20u0e4kynsc4m2qmkrrmd9tyggwt47773rhumkklet4g2us7c3x0gul65za8fg32nansdesukdqr2", + }, + { + "mnemonic": "index today witness obscure ugly curtain symbol pumpkin pelican child maple struggle arctic water tiny pizza harbor below violin eight tennis frost clown hood", + "coin_type": 1, + "spscan": "tspscan1q0z4tkwaar4ww77qgesalgzw0c40q89zh7p7hmp3qn73yrdw9jpvs9yjn9d7puunttpfjuale84erzh2z636fqgy63gp7m52v5hcnmmrrlrxnur", + "spspend": "tspspend1q0z4tkwaar4ww77qgesalgzw0c40q89zh7p7hmp3qn73yrdw9jpvnadsvv7qqcd8ytmdtzn6r5ywvccgzpw2386spvymglmszzep0svg39qpev", + }, + { + "mnemonic": "fold cotton pipe robust eagle rabbit coach average orient utility minor absurd fine claim artist rabbit kingdom original lobster cruise march city vibrant resemble", + "coin_type": 0, + "spscan": "spscan1q79q4zljllyehszny72w5zfptzpxnp96esg0n2fwecgzd2v7fr6fsy54qq8jfr6mm3pgrze8hln43my7epsfkg98wtl77ch6r5lz6pedd2jcnxk", + "spspend": "spspend1q79q4zljllyehszny72w5zfptzpxnp96esg0n2fwecgzd2v7fr6fs8y6dg3fu9jp5rhrycnuhtd555t6904x4xs7cklka8z5tk5p9xwq82pf6k", + }, +] + + +def _derive_sp_keys(mnemonic, coin_type): + seed = bip39.mnemonic_to_seed(mnemonic) + master = bip32.HDKey.from_seed(seed) + scan_priv = master.derive("m/352h/%dh/0h/1h/0" % coin_type).key + spend_priv = master.derive("m/352h/%dh/0h/0h/0" % coin_type).key + return scan_priv, spend_priv + + +class TestMnemonicVectors(TestCase): + def _keys(self, v): + network = "test" if v["coin_type"] == 1 else "main" + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + return network, scan_priv, spend_priv, spend_priv.get_public_key() + + def test_spscan_encoding(self): + for v in VECTORS: + net, scan_priv, _, spend_pub = self._keys(v) + self.assertEqual( + SPScanKey(scan_priv, spend_pub, network=net).encode(), v["spscan"] + ) + + def test_spspend_encoding(self): + for v in VECTORS: + net, scan_priv, spend_priv, _ = self._keys(v) + self.assertEqual( + SPSpendKey(scan_priv, spend_priv, network=net).encode(), v["spspend"] + ) + + def test_descriptor_roundtrip_spscan(self): + for v in VECTORS: + net, scan_priv, _, spend_pub = self._keys(v) + desc_str = "sp(%s)" % SPScanKey(scan_priv, spend_pub, network=net).encode() + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertEqual(str(desc), desc_str) + self.assertTrue(desc.is_watch_only) + + def test_descriptor_roundtrip_spspend(self): + for v in VECTORS: + net, scan_priv, spend_priv, _ = self._keys(v) + desc_str = ( + "sp(%s)" % SPSpendKey(scan_priv, spend_priv, network=net).encode() + ) + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertEqual(str(desc), desc_str) + self.assertFalse(desc.is_watch_only) + + def test_keys_extractable_from_descriptor(self): + for v in VECTORS: + net, scan_priv, _, spend_pub = self._keys(v) + spscan = SPScanKey(scan_priv, spend_pub, network=net) + desc = SilentPaymentDescriptor.from_string("sp(%s)" % spscan.encode()) + self.assertEqual(desc.get_scan_privkey().secret, scan_priv.secret) + self.assertEqual(desc.get_spend_pubkey().sec(), spend_pub.sec()) + + +class TestKeyOrigin(TestCase): + """Key origin prefix [fingerprint/path] is parsed and preserved in SP descriptors.""" + + def test_spscan_with_origin_roundtrip(self): + for v in VECTORS: + net = "test" if v["coin_type"] == 1 else "main" + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + spend_pub = spend_priv.get_public_key() + origin = KeyOrigin.from_string("deadbeef/352h/%dh/0h" % v["coin_type"]) + spscan = SPScanKey(scan_priv, spend_pub, origin=origin, network=net) + desc_str = "sp(%s)" % str(spscan) + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertEqual(str(desc), desc_str) + self.assertTrue(desc.is_watch_only) + + def test_spspend_with_origin_roundtrip(self): + for v in VECTORS: + net = "test" if v["coin_type"] == 1 else "main" + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + origin = KeyOrigin.from_string("cafebabe/352h/%dh/0h" % v["coin_type"]) + spspend = SPSpendKey(scan_priv, spend_priv, origin=origin, network=net) + desc_str = "sp(%s)" % str(spspend) + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertEqual(str(desc), desc_str) + self.assertFalse(desc.is_watch_only) + + def test_parsed_key_retains_origin_fingerprint(self): + v = VECTORS[0] + net = "test" + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + spend_pub = spend_priv.get_public_key() + origin = KeyOrigin.from_string("deadbeef/352h/1h/0h") + spscan = SPScanKey(scan_priv, spend_pub, origin=origin, network=net) + desc = SilentPaymentDescriptor.from_string("sp(%s)" % str(spscan)) + self.assertIsNotNone(desc.sp_key.origin) + self.assertEqual(desc.sp_key.origin.fingerprint, unhexlify("deadbeef")) + + def test_origin_does_not_affect_key_content(self): + """Origin prefix doesn't change the encoded key bytes.""" + v = VECTORS[1] + net = "main" + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + spend_pub = spend_priv.get_public_key() + origin = KeyOrigin.from_string("cafebabe/352h/0h/0h") + spscan = SPScanKey(scan_priv, spend_pub, origin=origin, network=net) + desc = SilentPaymentDescriptor.from_string("sp(%s)" % str(spscan)) + self.assertEqual(desc.get_scan_privkey().secret, scan_priv.secret) + self.assertEqual(desc.get_spend_pubkey().sec(), spend_pub.sec()) + + +class TestTwoArgDescriptor(TestCase): + """sp(scan_key, spend_key) two-argument descriptor form.""" + + def _leaf_hdkeys(self, v): + seed = bip39.mnemonic_to_seed(v["mnemonic"]) + master = bip32.HDKey.from_seed(seed) + ct = v["coin_type"] + scan_hd = master.derive("m/352h/%dh/0h/1h/0" % ct) + spend_hd_priv = master.derive("m/352h/%dh/0h/0h/0" % ct) + spend_hd_pub = spend_hd_priv.to_public() + return scan_hd, spend_hd_priv, spend_hd_pub + + def test_xprv_xpub_is_watch_only(self): + for v in VECTORS: + scan_hd, _, spend_hd_pub = self._leaf_hdkeys(v) + desc_str = "sp(%s,%s)" % (scan_hd.to_base58(), spend_hd_pub.to_base58()) + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertFalse(desc.is_single_arg) + self.assertTrue(desc.is_watch_only) + + def test_xprv_xprv_is_not_watch_only(self): + for v in VECTORS: + scan_hd, spend_hd_priv, _ = self._leaf_hdkeys(v) + desc_str = "sp(%s,%s)" % (scan_hd.to_base58(), spend_hd_priv.to_base58()) + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertFalse(desc.is_single_arg) + self.assertFalse(desc.is_watch_only) + + def test_two_arg_roundtrip(self): + for v in VECTORS: + scan_hd, _, spend_hd_pub = self._leaf_hdkeys(v) + desc_str = "sp(%s,%s)" % (scan_hd.to_base58(), spend_hd_pub.to_base58()) + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertEqual(str(desc), desc_str) + + def test_two_arg_keys_match_direct_derivation(self): + """Two-arg form with leaf-level keys produces the same scan/spend keys as direct derivation.""" + for v in VECTORS: + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + spend_pub = spend_priv.get_public_key() + scan_hd, _, spend_hd_pub = self._leaf_hdkeys(v) + desc_str = "sp(%s,%s)" % (scan_hd.to_base58(), spend_hd_pub.to_base58()) + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertEqual(desc.get_scan_privkey().secret, scan_priv.secret) + self.assertEqual(desc.get_spend_pubkey().sec(), spend_pub.sec()) + + +class TestChecksumHandling(TestCase): + """Descriptor #checksum suffix is stripped during parsing.""" + + def test_checksum_suffix_stripped(self): + for v in VECTORS: + net = "test" if v["coin_type"] == 1 else "main" + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + spend_pub = spend_priv.get_public_key() + desc_str = "sp(%s)" % SPScanKey(scan_priv, spend_pub, network=net).encode() + desc = SilentPaymentDescriptor.from_string(desc_str + "#aaaaaaaa") + self.assertEqual(str(desc), desc_str) + + def test_no_checksum_parses_normally(self): + v = VECTORS[0] + net = "test" + scan_priv, spend_priv = _derive_sp_keys(v["mnemonic"], v["coin_type"]) + spend_pub = spend_priv.get_public_key() + desc_str = "sp(%s)" % SPScanKey(scan_priv, spend_pub, network=net).encode() + desc = SilentPaymentDescriptor.from_string(desc_str) + self.assertTrue(desc.is_watch_only) + self.assertTrue(desc.is_single_arg) + + +class TestInvalidDescriptor(TestCase): + """Invalid sp() descriptors raise DescriptorError.""" + + def _scan_priv(self): + return ec.PrivateKey(bytes([0x01] * 32)) + + def _spend_pub(self): + return ec.PrivateKey(bytes([0x02] * 32)).get_public_key() + + def test_empty_sp(self): + self.assertRaises(Exception, SilentPaymentDescriptor.from_string, "sp()") + + def test_bare_xpub_single_arg(self): + """Single-arg sp() with a plain xpub (not spscan/spspend) is rejected.""" + seed = bytes(range(16)) + master = bip32.HDKey.from_seed(seed) + xpub = master.to_public().to_base58() + self.assertRaises( + DescriptorError, SilentPaymentDescriptor.from_string, "sp(%s)" % xpub + ) + + def test_xpub_xpub_scan_key_rejected(self): + """Two-arg sp(xpub, xpub) is rejected — scan key must be private.""" + seed = bip39.mnemonic_to_seed(VECTORS[0]["mnemonic"]) + master = bip32.HDKey.from_seed(seed) + scan_xpub = master.derive("m/352h/1h/0h/1h/0").to_public().to_base58() + spend_xpub = master.derive("m/352h/1h/0h/0h/0").to_public().to_base58() + self.assertRaises( + DescriptorError, + SilentPaymentDescriptor.from_string, + "sp(%s,%s)" % (scan_xpub, spend_xpub), + ) + + def test_hex_pubkey_scan_key_rejected(self): + """Two-arg sp(pubkey_hex, pubkey_hex) is rejected — scan key must be private.""" + scan_pub = self._scan_priv().get_public_key() + spend_pub = self._spend_pub() + desc_str = "sp(%s,%s)" % (scan_pub.sec().hex(), spend_pub.sec().hex()) + self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + + def test_spscan_in_second_position_rejected(self): + """Two-arg sp(wif, spscan1...) is rejected — second arg cannot be an spscan key.""" + scan_priv = self._scan_priv() + spscan = SPScanKey(scan_priv, self._spend_pub()) + desc_str = "sp(%s,%s)" % (scan_priv.wif(), spscan.encode()) + self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + + def test_two_spscan_args_rejected(self): + """sp(spscan1..., spscan1...) is rejected — spscan must be the only argument.""" + spscan = SPScanKey(self._scan_priv(), self._spend_pub()) + desc_str = "sp(%s,%s)" % (spscan.encode(), spscan.encode()) + self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + + def test_uncompressed_scan_key_rejected(self): + """Uncompressed private key as scan arg is rejected.""" + scan_priv = ec.PrivateKey(bytes([0x01] * 32), compressed=False) + spend_pub = self._spend_pub() + desc_str = "sp(%s,%s)" % (scan_priv.wif(), spend_pub.sec().hex()) + self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + + def test_trailing_junk_rejected(self): + """Characters after the closing ) are rejected.""" + spscan = SPScanKey(self._scan_priv(), self._spend_pub()) + desc_str = "sp(%s)junk" % spscan.encode() + self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) From b1917711d1e75d2689b07d2c4f470119b4dda7c5 Mon Sep 17 00:00:00 2001 From: odudex Date: Fri, 29 May 2026 10:34:28 -0300 Subject: [PATCH 12/61] sp descriptors: use flexible bech32, robust parsing, clearer errors - Remove bech32_decode_long; the flexible bech32_decode (this branch) already handles long strings, so decode SP keys and silent payment addresses through it, converting Bech32DecodeError to the caller's error type. - Drop the now-dead 'is None' guards (convertbits raises instead of returning None) and wrap conversion to keep DescriptorError/ValueError consistent. - Seek to an absolute offset after 'sp(' so parsing is robust to a short read. - Raise a clear DescriptorError for unsupported address/derive/script_pubkey on SilentPaymentDescriptor, and cover the Descriptor.from_string('sp(...)') path. --- src/embit/bech32.py | 21 ----------------- src/embit/descriptor/descriptor.py | 4 +++- src/embit/descriptor/sp.py | 30 ++++++++++++++++++++----- src/embit/silent_payments/bip352.py | 34 ++++++---------------------- tests/tests/test_sp_descriptor.py | 35 ++++++++++++++++++++++++----- 5 files changed, 63 insertions(+), 61 deletions(-) diff --git a/src/embit/bech32.py b/src/embit/bech32.py index b230f180..b6766c52 100644 --- a/src/embit/bech32.py +++ b/src/embit/bech32.py @@ -112,27 +112,6 @@ def bech32_decode(bech): return (encoding, hrp, data[:-6]) -# TODO: remove this once flexible bech32 is in -def bech32_decode_long(bech): - """Like bech32_decode but without the 90-character length limit.""" - if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or ( - bech.lower() != bech and bech.upper() != bech - ): - return (None, None, None) - bech = bech.lower() - pos = bech.rfind("1") - if pos < 1 or pos + 7 > len(bech): - return (None, None, None) - if not all(x in CHARSET for x in bech[pos + 1 :]): - return (None, None, None) - hrp = bech[:pos] - data = [CHARSET.find(x) for x in bech[pos + 1 :]] - encoding = bech32_verify_checksum(hrp, data) - if encoding is None: - return (None, None, None) - return (encoding, hrp, data[:-6]) - - def convertbits(data, frombits, tobits, pad=True): """General power-of-2 base conversion.""" acc = 0 diff --git a/src/embit/descriptor/descriptor.py b/src/embit/descriptor/descriptor.py index c0737e74..fe8b576e 100644 --- a/src/embit/descriptor/descriptor.py +++ b/src/embit/descriptor/descriptor.py @@ -296,6 +296,7 @@ def from_string(cls, desc): @classmethod def read_from(cls, s): # starts with sh(wsh()), sh() or wsh() + start_pos = s.tell() start = s.read(8) sh = False wsh = False @@ -304,7 +305,8 @@ def read_from(cls, s): taproot = False taptree = TapTree() if start.startswith(b"sp("): - s.seek(-5, 1) + # position right after "sp(" (absolute, robust to a short read(8)) + s.seek(start_pos + 3) sp_desc = SilentPaymentDescriptor.read_from(s) end = s.read(1) if end != b")": diff --git a/src/embit/descriptor/sp.py b/src/embit/descriptor/sp.py index 94c6c823..3d3963ed 100644 --- a/src/embit/descriptor/sp.py +++ b/src/embit/descriptor/sp.py @@ -7,13 +7,15 @@ SPSCAN_HRPS = {"spscan": "main", "tspscan": "test"} SPSPEND_HRPS = {"spspend": "main", "tspspend": "test"} -SP_KEY_HRPS = {**SPSCAN_HRPS, **SPSPEND_HRPS} +SP_KEY_HRPS = SPSCAN_HRPS.copy() +SP_KEY_HRPS.update(SPSPEND_HRPS) def _bech32m_decode_sp_key(encoded): - encoding, hrp, data = bech32.bech32_decode_long(encoded) - if encoding is None: - raise DescriptorError("Invalid bech32m encoding in SP key: %s" % encoded) + try: + encoding, hrp, data = bech32.bech32_decode(encoded) + except bech32.Bech32DecodeError as e: + raise DescriptorError("Invalid bech32m encoding in SP key: %s" % e) if encoding != bech32.Encoding.BECH32M: raise DescriptorError("SP key must use bech32m encoding") if hrp not in SP_KEY_HRPS: @@ -23,8 +25,9 @@ def _bech32m_decode_sp_key(encoded): version = data[0] if version != 0: raise DescriptorError("Unsupported SP key version: %d" % version) - payload = bech32.convertbits(data[1:], 5, 8, False) - if payload is None: + try: + payload = bech32.convertbits(data[1:], 5, 8, False) + except bech32.Bech32DecodeError: raise DescriptorError("Invalid SP key payload encoding") return hrp, bytes(payload) @@ -268,6 +271,21 @@ def _read_args(cls, s): return cls(scan_key=scan_key, spend_key=spend_arg) + def derive(self, *args, **kwargs): + raise DescriptorError( + "sp() descriptors do not support derive(); see BIP-352 for output derivation" + ) + + def script_pubkey(self, *args, **kwargs): + raise DescriptorError( + "sp() descriptors have no fixed script_pubkey(); outputs are derived per BIP-352" + ) + + def address(self, *args, **kwargs): + raise DescriptorError( + "sp() descriptors have no address(); use BIP-352 silent payment address generation" + ) + def to_string(self): if self.sp_key is not None: return "sp(%s)" % self.sp_key diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 635b1330..3688db8e 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -62,7 +62,6 @@ def generate_silent_payment_address( def decode_silent_payment_address(address: str): """ Decode a silent payment address and return the scan and spend public keys. - Silent payment addresses can be longer than 90 characters, so we need custom decoding. """ if address.startswith("sp1"): hrp = "sp" @@ -71,44 +70,25 @@ def decode_silent_payment_address(address: str): else: raise ValueError("Invalid silent payment address: unknown HRP") - # custom bech32 to bypass the 90-character limit - if (any(ord(x) < 33 or ord(x) > 126 for x in address)) or ( - address.lower() != address and address.upper() != address - ): - raise ValueError("Invalid silent payment address: invalid characters") - - address = address.lower() - pos = address.rfind("1") - if pos < 1 or pos + 7 > len(address): - raise ValueError("Invalid silent payment address: invalid format") - - if not all(x in bech32.CHARSET for x in address[pos + 1 :]): - raise ValueError( - "Invalid silent payment address: invalid characters in data part" - ) - - hrpgot = address[:pos] - data = [bech32.CHARSET.find(x) for x in address[pos + 1 :]] + try: + encoding, hrpgot, data = bech32.bech32_decode(address) + except bech32.Bech32DecodeError as e: + raise ValueError("Invalid silent payment address: {}".format(e)) if hrpgot != hrp: raise ValueError("Invalid silent payment address: HRP mismatch") - encoding = bech32.bech32_verify_checksum(hrpgot, data) - if encoding is None: - raise ValueError("Invalid silent payment address: checksum verification failed") - if encoding != bech32.Encoding.BECH32M: raise ValueError("Invalid silent payment address: must use bech32m encoding") - data = data[:-6] - if data[0] != 0: raise ValueError( "Invalid silent payment address: unsupported version {}".format(data[0]) ) - decoded = bech32.convertbits(data[1:], 5, 8, False) - if decoded is None: + try: + decoded = bech32.convertbits(data[1:], 5, 8, False) + except bech32.Bech32DecodeError: raise ValueError("Invalid silent payment address: conversion failed") try: diff --git a/tests/tests/test_sp_descriptor.py b/tests/tests/test_sp_descriptor.py index bd48f613..39a9c5f1 100644 --- a/tests/tests/test_sp_descriptor.py +++ b/tests/tests/test_sp_descriptor.py @@ -1,5 +1,5 @@ from unittest import TestCase -from embit.descriptor import SilentPaymentDescriptor +from embit.descriptor import Descriptor, SilentPaymentDescriptor from embit.descriptor.sp import SPScanKey, SPSpendKey from embit.descriptor.arguments import KeyOrigin from embit.descriptor.errors import DescriptorError @@ -89,6 +89,19 @@ def test_keys_extractable_from_descriptor(self): self.assertEqual(desc.get_scan_privkey().secret, scan_priv.secret) self.assertEqual(desc.get_spend_pubkey().sec(), spend_pub.sec()) + def test_descriptor_from_string_dispatches_to_sp(self): + """Descriptor.from_string('sp(...)') returns a SilentPaymentDescriptor.""" + for v in VECTORS: + net, scan_priv, _, spend_pub = self._keys(v) + desc_str = "sp(%s)" % SPScanKey(scan_priv, spend_pub, network=net).encode() + desc = Descriptor.from_string(desc_str) + self.assertIsInstance(desc, SilentPaymentDescriptor) + self.assertEqual(str(desc), desc_str) + # Unsupported standard descriptor operations raise a clear error + self.assertRaises(DescriptorError, desc.address) + self.assertRaises(DescriptorError, desc.derive, 0) + self.assertRaises(DescriptorError, desc.script_pubkey) + class TestKeyOrigin(TestCase): """Key origin prefix [fingerprint/path] is parsed and preserved in SP descriptors.""" @@ -248,30 +261,40 @@ def test_hex_pubkey_scan_key_rejected(self): scan_pub = self._scan_priv().get_public_key() spend_pub = self._spend_pub() desc_str = "sp(%s,%s)" % (scan_pub.sec().hex(), spend_pub.sec().hex()) - self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + self.assertRaises( + DescriptorError, SilentPaymentDescriptor.from_string, desc_str + ) def test_spscan_in_second_position_rejected(self): """Two-arg sp(wif, spscan1...) is rejected — second arg cannot be an spscan key.""" scan_priv = self._scan_priv() spscan = SPScanKey(scan_priv, self._spend_pub()) desc_str = "sp(%s,%s)" % (scan_priv.wif(), spscan.encode()) - self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + self.assertRaises( + DescriptorError, SilentPaymentDescriptor.from_string, desc_str + ) def test_two_spscan_args_rejected(self): """sp(spscan1..., spscan1...) is rejected — spscan must be the only argument.""" spscan = SPScanKey(self._scan_priv(), self._spend_pub()) desc_str = "sp(%s,%s)" % (spscan.encode(), spscan.encode()) - self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + self.assertRaises( + DescriptorError, SilentPaymentDescriptor.from_string, desc_str + ) def test_uncompressed_scan_key_rejected(self): """Uncompressed private key as scan arg is rejected.""" scan_priv = ec.PrivateKey(bytes([0x01] * 32), compressed=False) spend_pub = self._spend_pub() desc_str = "sp(%s,%s)" % (scan_priv.wif(), spend_pub.sec().hex()) - self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + self.assertRaises( + DescriptorError, SilentPaymentDescriptor.from_string, desc_str + ) def test_trailing_junk_rejected(self): """Characters after the closing ) are rejected.""" spscan = SPScanKey(self._scan_priv(), self._spend_pub()) desc_str = "sp(%s)junk" % spscan.encode() - self.assertRaises(DescriptorError, SilentPaymentDescriptor.from_string, desc_str) + self.assertRaises( + DescriptorError, SilentPaymentDescriptor.from_string, desc_str + ) From c8cb4e41a26eb29960bc15f61a419cf9d3138368 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 13 May 2026 10:05:59 -0500 Subject: [PATCH 13/61] secp256k1: implement ec_pubkey_tweak_mul and ec_pubkey_combine python fallbacks --- src/embit/util/py_secp256k1.py | 56 +++---- tests/tests/test_bindings.py | 259 ++++++++++++++++++++++++++++++++- 2 files changed, 287 insertions(+), 28 deletions(-) diff --git a/src/embit/util/py_secp256k1.py b/src/embit/util/py_secp256k1.py index 0f1f8e1d..543bc226 100644 --- a/src/embit/util/py_secp256k1.py +++ b/src/embit/util/py_secp256k1.py @@ -242,33 +242,35 @@ def ec_pubkey_add(pub, tweak, context=None): return Q[0].to_bytes(32, "little") + Q[1].to_bytes(32, "little") -# def ec_privkey_tweak_mul(secret, tweak, context=None): -# if len(secret)!=32 or len(tweak)!=32: -# raise ValueError("Secret and tweak should both be 32 bytes long") -# s = int.from_bytes(secret, 'big') -# t = int.from_bytes(tweak, 'big') -# if t > _key.SECP256K1_ORDER or s > _key.SECP256K1_ORDER: -# raise ValueError("Failed to tweak the secret") -# r = pow(s, t, _key.SECP256K1_ORDER) -# res = r.to_bytes(32, 'big') -# for i in range(len(secret)): -# secret[i] = res[i] - -# def ec_pubkey_tweak_mul(pub, tweak, context=None): -# if len(pub)!=64: -# raise ValueError("Public key should be 64 bytes long") -# if len(tweak)!=32: -# raise ValueError("Tweak should be 32 bytes long") -# if _secp.secp256k1_ec_pubkey_tweak_mul(context, pub, tweak) == 0: -# raise ValueError("Failed to tweak the public key") - -# def ec_pubkey_combine(*args, context=None): -# pub = bytes(64) -# pubkeys = (c_char_p * len(args))(*args) -# r = _secp.secp256k1_ec_pubkey_combine(context, pub, pubkeys, len(args)) -# if r == 0: -# raise ValueError("Failed to negate pubkey") -# return pub +def ec_pubkey_tweak_mul(pub, tweak, context=None): + if len(pub) != 64: + raise ValueError("Public key should be 64 bytes long") + if len(tweak) != 32: + raise ValueError("Tweak should be 32 bytes long") + t = int.from_bytes(tweak, "big") + if t == 0 or t >= _key.SECP256K1_ORDER: + raise ValueError("Failed to tweak the public key") + pubkey = _pubkey_parse(pub) + Q = _key.SECP256K1.affine(_key.SECP256K1.mul([(pubkey.p, t)])) + if Q is None: + raise ValueError("Failed to tweak the public key") + res = Q[0].to_bytes(32, "little") + Q[1].to_bytes(32, "little") + for i in range(len(pub)): + pub[i] = res[i] + + +def ec_pubkey_combine(*args, context=None): + if len(args) == 0: + raise ValueError("At least one pubkey is required") + acc = (0, 1, 0) + for pub in args: + if len(pub) != 64: + raise ValueError("Public key should be 64 bytes long") + acc = _key.SECP256K1.add(acc, _pubkey_parse(pub).p) + Q = _key.SECP256K1.affine(acc) + if Q is None: + raise ValueError("Failed to combine pubkeys") + return Q[0].to_bytes(32, "little") + Q[1].to_bytes(32, "little") # schnorrsig diff --git a/tests/tests/test_bindings.py b/tests/tests/test_bindings.py index d0cbc519..4182e0fb 100644 --- a/tests/tests/test_bindings.py +++ b/tests/tests/test_bindings.py @@ -1,4 +1,4 @@ -from binascii import hexlify +from binascii import hexlify, unhexlify from unittest import TestCase, skipUnless from embit.util import py_secp256k1 @@ -113,6 +113,29 @@ def test_cross(self): ctypes_secp256k1.ec_pubkey_add(pub1, b"9" * 32), ) + # ec_pubkey_tweak_mul mutates in place. The ctypes binding's c_char_p + # argtype requires `bytes`; the py_secp256k1 implementation requires a + # mutable `bytearray`. Pass each the type it expects, then compare. + tweak = b"9" * 32 + pub_py = bytearray(pub1) + pub_c = bytes(pub1) # mutated in place via the CPython ctypes quirk + py_secp256k1.ec_pubkey_tweak_mul(pub_py, tweak) + ctypes_secp256k1.ec_pubkey_tweak_mul(pub_c, tweak) + self.assertEqual(bytes(pub_py), pub_c) + + # ec_pubkey_combine takes varargs and returns a new pubkey. + pub_other = py_secp256k1.ec_pubkey_create(b"7" * 32) + self.assertEqual( + py_secp256k1.ec_pubkey_combine(pub1, pub_other), + ctypes_secp256k1.ec_pubkey_combine(pub1, pub_other), + ) + # Three-arg combine. + pub_third = py_secp256k1.ec_pubkey_create(b"3" * 32) + self.assertEqual( + py_secp256k1.ec_pubkey_combine(pub1, pub_other, pub_third), + ctypes_secp256k1.ec_pubkey_combine(pub1, pub_other, pub_third), + ) + @skipUnless( _SCHNORR_AVAILABLE, "ctypes backend missing schnorr symbols; skipping parity checks", @@ -170,3 +193,237 @@ def test_recovery(self): pub = ctypes_secp256k1.ecdsa_recover(sig + bytes([i]), msg) pub2 = py_secp256k1.ecdsa_recover(sig + bytes([i]), msg) self.assertEqual(pub, pub2) + + +# Curve order N. Used to construct overflow/zero tweaks. +_SECP256K1_ORDER = ( + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +) + + +def _tweak_mul_result_x(pubkey_compressed_hex, tweak_hex): + """Helper: parse a compressed pubkey, multiply by tweak, return x-coord hex.""" + pub = bytearray(py_secp256k1.ec_pubkey_parse(unhexlify(pubkey_compressed_hex))) + py_secp256k1.ec_pubkey_tweak_mul(pub, unhexlify(tweak_hex)) + return py_secp256k1.ec_pubkey_serialize(bytes(pub))[1:33].hex() + + +class PubkeyTweakMulTest(TestCase): + """Standalone tests for py_secp256k1.ec_pubkey_tweak_mul. + + Runs without libsecp256k1 — covers correctness via algebraic identities + and canonical BIP-47 spec vectors. + + Spec reference: + secp256k1_ec_pubkey_tweak_mul (libsecp256k1 public API) + https://github.com/bitcoin-core/secp256k1/blob/b11340b3ce2afac1b6ffda4ce5828c30621d2917/include/secp256k1.h#L830-L846 + + Tweak validity is defined by secp256k1_ec_seckey_verify (tweak must be a + 32-byte big-endian integer in the range [1, N-1] where N is the curve order): + https://github.com/bitcoin-core/secp256k1/blob/b11340b3ce2afac1b6ffda4ce5828c30621d2917/include/secp256k1.h#L721-L729 + """ + + def test_identity_tweak_one_unchanged(self): + """tweak_mul(P, 1) == P — multiplicative identity of the scalar group.""" + secret = b"2" * 32 + pub = py_secp256k1.ec_pubkey_create(secret) + ba = bytearray(pub) + py_secp256k1.ec_pubkey_tweak_mul(ba, (1).to_bytes(32, "big")) + self.assertEqual(bytes(ba), pub) + + def test_generator_times_k_equals_pubkey_create_k(self): + """tweak_mul(G, k) == ec_pubkey_create(k) for valid scalar k. + + ec_pubkey_create(k) is defined as k*G (G = curve generator); so + tweak_mul applied to G must yield the same point. + """ + for k in (1, 2, 3, 12345, 0xDEADBEEF, _SECP256K1_ORDER - 1): + with self.subTest(k=k): + G = py_secp256k1.ec_pubkey_create((1).to_bytes(32, "big")) + ba = bytearray(G) + py_secp256k1.ec_pubkey_tweak_mul(ba, k.to_bytes(32, "big")) + self.assertEqual( + bytes(ba), py_secp256k1.ec_pubkey_create(k.to_bytes(32, "big")) + ) + + def test_double_tweak_composes(self): + """tweak_mul(tweak_mul(P, a), b) == tweak_mul(P, a*b mod N). + + Associativity of scalar multiplication on the curve. + """ + secret = b"5" * 32 + P = py_secp256k1.ec_pubkey_create(secret) + a = int.from_bytes(b"\x03" * 32, "big") + b = int.from_bytes(b"\x07" * 32, "big") + + # Sequential a then b. + ba = bytearray(P) + py_secp256k1.ec_pubkey_tweak_mul(ba, a.to_bytes(32, "big")) + py_secp256k1.ec_pubkey_tweak_mul(ba, b.to_bytes(32, "big")) + + # Single combined scalar. + bc = bytearray(P) + ab = (a * b) % _SECP256K1_ORDER + py_secp256k1.ec_pubkey_tweak_mul(bc, ab.to_bytes(32, "big")) + + self.assertEqual(bytes(ba), bytes(bc)) + + def test_rejects_zero_tweak(self): + """tweak == 0 must fail: 0*P = point-at-infinity (not a valid pubkey). + + Matches libsecp256k1 behavior: tweak is rejected by seckey_verify, so + tweak_mul returns 0 (we raise ValueError). See upstream test at + https://github.com/bitcoin-core/secp256k1/blob/b11340b3ce2afac1b6ffda4ce5828c30621d2917/src/tests.c#L6325 + """ + pub = py_secp256k1.ec_pubkey_create(b"4" * 32) + ba = bytearray(pub) + with self.assertRaises(ValueError): + py_secp256k1.ec_pubkey_tweak_mul(ba, b"\x00" * 32) + + def test_rejects_overflow_tweak(self): + """tweak >= curve order N must fail (no canonical representative). + + See upstream test at + https://github.com/bitcoin-core/secp256k1/blob/b11340b3ce2afac1b6ffda4ce5828c30621d2917/src/tests.c#L6357 + """ + pub = py_secp256k1.ec_pubkey_create(b"4" * 32) + for t in (_SECP256K1_ORDER, _SECP256K1_ORDER + 1, 2**256 - 1): + with self.subTest(t=t): + ba = bytearray(pub) + with self.assertRaises(ValueError): + py_secp256k1.ec_pubkey_tweak_mul(ba, t.to_bytes(32, "big")) + + def test_rejects_wrong_pubkey_length(self): + with self.assertRaises(ValueError): + py_secp256k1.ec_pubkey_tweak_mul(bytearray(63), b"\x01" * 32) + + def test_rejects_wrong_tweak_length(self): + pub = py_secp256k1.ec_pubkey_create(b"4" * 32) + with self.assertRaises(ValueError): + py_secp256k1.ec_pubkey_tweak_mul(bytearray(pub), b"\x01" * 31) + + def test_bip47_spec_vectors(self): + """Canonical BIP-47 ECDH vectors: x-coord of (Alice's a0 * Bob's Bi). + + BIP-47 (Reusable Payment Codes) defines the shared secret as a*B where + a is the payer's private key and B is the recipient's derived pubkey. + These vectors are the X coordinate of that curve point — exactly what + ec_pubkey_tweak_mul computes. + + Spec: https://github.com/bitcoin/bips/blob/master/bip-0047.mediawiki + Test vectors: https://gist.github.com/SamouraiDev/6aad669604c5930864bd + """ + a0 = unhexlify( + "8d6a8ecd8ee5e0042ad0cb56e3a971c760b5145c3917a8e7beaf0ed92d7a520c" + ) + vectors = [ + ( + "024ce8e3b04ea205ff49f529950616c3db615b1e37753858cc60c1ce64d17e2ad8", + "f5bb84706ee366052471e6139e6a9a969d586e5fe6471a9b96c3d8caefe86fef", + ), + ( + "03e092e58581cf950ff9c8fc64395471733e13f97dedac0044ebd7d60ccc1eea4d", + "adfb9b18ee1c4460852806a8780802096d67a8c1766222598dc801076beb0b4d", + ), + ( + "029b5f290ef2f98a0462ec691f5cc3ae939325f7577fcaf06cfc3b8fc249402156", + "79e860c3eb885723bb5a1d54e5cecb7df5dc33b1d56802906762622fa3c18ee5", + ), + ( + "02094be7e0eef614056dd7c8958ffa7c6628c1dab6706f2f9f45b5cbd14811de44", + "d8339a01189872988ed4bd5954518485edebf52762bf698b75800ac38e32816d", + ), + ( + "031054b95b9bc5d2a62a79a58ecfe3af000595963ddc419c26dab75ee62e613842", + "14c687bc1a01eb31e867e529fee73dd7540c51b9ff98f763adf1fc2f43f98e83", + ), + ( + "03dac6d8f74cacc7630106a1cfd68026c095d3d572f3ea088d9a078958f8593572", + "725a8e3e4f74a50ee901af6444fb035cb8841e0f022da2201b65bc138c6066a2", + ), + ( + "02396351f38e5e46d9a270ad8ee221f250eb35a575e98805e94d11f45d763c4651", + "521bf140ed6fb5f1493a5164aafbd36d8a9e67696e7feb306611634f53aa9d1f", + ), + ( + "039d46e873827767565141574aecde8fb3b0b4250db9668c73ac742f8b72bca0d0", + "5f5ecc738095a6fb1ea47acda4996f1206d3b30448f233ef6ed27baf77e81e46", + ), + ( + "038921acc0665fd4717eb87f81404b96f8cba66761c847ebea086703a6ae7b05bd", + "1e794128ac4c9837d7c3696bbc169a8ace40567dc262974206fcf581d56defb4", + ), + ( + "03d51a06c6b48f067ff144d5acdfbe046efa2e83515012cf4990a89341c1440289", + "fe36c27c62c99605d6cd7b63bf8d9fe85d753592b14744efca8be20a4d767c37", + ), + ] + for i, (B_hex, expected_x_hex) in enumerate(vectors): + with self.subTest(i=i): + self.assertEqual( + _tweak_mul_result_x(B_hex, a0.hex()), expected_x_hex + ) + + +class PubkeyCombineTest(TestCase): + """Standalone tests for py_secp256k1.ec_pubkey_combine. + + Spec reference: + secp256k1_ec_pubkey_combine (libsecp256k1 public API) + https://github.com/bitcoin-core/secp256k1/blob/b11340b3ce2afac1b6ffda4ce5828c30621d2917/include/secp256k1.h#L884-L898 + """ + + def test_combine_single_pubkey_unchanged(self): + """combine(P) == P — combining one pubkey is a no-op.""" + pub = py_secp256k1.ec_pubkey_create(b"4" * 32) + self.assertEqual(py_secp256k1.ec_pubkey_combine(pub), pub) + + def test_combine_p_plus_p_equals_tweak_mul_by_two(self): + """combine(P, P) == tweak_mul(P, 2) — point doubling. + + P + P on the curve equals 2*P. Mirrors the 'Tweak mul * 2 = 1+1' + identity in bitcoin-core/secp256k1's test suite: + https://github.com/bitcoin-core/secp256k1/blob/b11340b3ce2afac1b6ffda4ce5828c30621d2917/src/tests.c#L6383-L6386 + """ + P = py_secp256k1.ec_pubkey_create(b"9" * 32) + combined = py_secp256k1.ec_pubkey_combine(P, P) + doubled = bytearray(P) + py_secp256k1.ec_pubkey_tweak_mul(doubled, (2).to_bytes(32, "big")) + self.assertEqual(combined, bytes(doubled)) + + def test_combine_three_pubkeys_associates(self): + """combine(A, B, C) == combine(combine(A, B), C). + + Point addition on an elliptic curve is associative. + """ + A = py_secp256k1.ec_pubkey_create(b"1" * 32) + B = py_secp256k1.ec_pubkey_create(b"2" * 32) + C = py_secp256k1.ec_pubkey_create(b"3" * 32) + self.assertEqual( + py_secp256k1.ec_pubkey_combine(A, B, C), + py_secp256k1.ec_pubkey_combine( + py_secp256k1.ec_pubkey_combine(A, B), C + ), + ) + + def test_combine_rejects_pubkey_and_negation(self): + """combine(P, -P) sums to the point at infinity — must fail. + + Per the libsecp256k1 API contract, 'the sum of the public keys is not + valid' returns 0 (we raise ValueError). The point at infinity is not a + valid pubkey representation. + """ + P = py_secp256k1.ec_pubkey_create(b"5" * 32) + neg_P = py_secp256k1.ec_pubkey_negate(P) + with self.assertRaises(ValueError): + py_secp256k1.ec_pubkey_combine(P, neg_P) + + def test_combine_rejects_no_args(self): + """API contract: n must be at least 1 (see header doc above).""" + with self.assertRaises(ValueError): + py_secp256k1.ec_pubkey_combine() + + def test_combine_rejects_wrong_length(self): + pub = py_secp256k1.ec_pubkey_create(b"6" * 32) + with self.assertRaises(ValueError): + py_secp256k1.ec_pubkey_combine(pub, b"\x00" * 63) From 6f1be8329d9944e99a49c00ed88c9952ab2543a3 Mon Sep 17 00:00:00 2001 From: Mike Tolkachev Date: Fri, 15 May 2026 13:53:26 -0300 Subject: [PATCH 14/61] secp256k1: fix mutable key in tweak functions and add ec_privkey_tweak_mul --- src/embit/liquid/descriptor.py | 15 +++-- src/embit/liquid/pset.py | 2 +- src/embit/liquid/transaction.py | 2 +- src/embit/util/ctypes_secp256k1.py | 63 ++++++++++++++---- src/embit/util/py_secp256k1.py | 44 ++++++++++++- tests/tests/test_bindings.py | 101 ++++++++++++++++++++++++++++- tests/tests/test_liquid.py | 66 ++++++++++++++----- 7 files changed, 253 insertions(+), 40 deletions(-) diff --git a/src/embit/liquid/descriptor.py b/src/embit/liquid/descriptor.py index 23829315..cefeebe5 100644 --- a/src/embit/liquid/descriptor.py +++ b/src/embit/liquid/descriptor.py @@ -225,12 +225,15 @@ def sec(self): def musig_combine_privs(privs, sort=True): keys = [ - [b"" + prv, secp256k1.ec_pubkey_serialize(secp256k1.ec_pubkey_create(prv))] + [ + bytearray(prv), + secp256k1.ec_pubkey_serialize(secp256k1.ec_pubkey_create(prv)), + ] for prv in privs ] for karr in keys: if karr[1][0] == 0x03: - secp256k1.ec_privkey_negate(karr[0]) + karr[0] = bytearray(secp256k1.ec_privkey_negate(karr[0])) # x only karr[1] = karr[1][1:] if sort: @@ -238,7 +241,7 @@ def musig_combine_privs(privs, sort=True): secs = [k[1] for k in keys] ll = sha256(b"".join(secs)) coefs = [ - tagged_hash("MuSig coefficient", ll + i.to_bytes(4, "little")) + bytearray(tagged_hash("MuSig coefficient", ll + i.to_bytes(4, "little"))) for i in range(len(keys)) ] # tweak them all @@ -249,15 +252,15 @@ def musig_combine_privs(privs, sort=True): s = secp256k1.ec_privkey_add(s, c) pub = secp256k1.ec_pubkey_create(s) if secp256k1.ec_pubkey_serialize(pub)[0] == 0x03: - secp256k1.ec_privkey_negate(s) + s = secp256k1.ec_privkey_negate(s) return s def musig_combine_pubs(pubs, sort=True): - keys = [[pub, secp256k1.ec_pubkey_serialize(pub)] for pub in pubs] + keys = [[bytearray(pub), secp256k1.ec_pubkey_serialize(pub)] for pub in pubs] for karr in keys: if karr[1][0] == 0x03: - secp256k1.ec_pubkey_negate(karr[0]) + karr[0] = bytearray(secp256k1.ec_pubkey_negate(karr[0])) # x only karr[1] = karr[1][1:] if sort: diff --git a/src/embit/liquid/pset.py b/src/embit/liquid/pset.py index 290c59ac..dc97b2ba 100644 --- a/src/embit/liquid/pset.py +++ b/src/embit/liquid/pset.py @@ -370,7 +370,7 @@ def reblind(self, nonce, blinding_pubkey=None, extra_message=b""): blinding_pubkey = blinding_pubkey or self.blinding_pubkey if not blinding_pubkey: raise PSBTError("Blinding pubkey required") - pub = secp256k1.ec_pubkey_parse(blinding_pubkey) + pub = bytearray(secp256k1.ec_pubkey_parse(blinding_pubkey)) self.ecdh_pubkey = ec.PrivateKey(nonce).sec() secp256k1.ec_pubkey_tweak_mul(pub, nonce) sec = secp256k1.ec_pubkey_serialize(pub) diff --git a/src/embit/liquid/transaction.py b/src/embit/liquid/transaction.py index ca7af0a5..43c0b6ef 100644 --- a/src/embit/liquid/transaction.py +++ b/src/embit/liquid/transaction.py @@ -82,7 +82,7 @@ def unblind( raise TransactionError("Invalid value commitment length") if len(asset_commitment) != 33: raise TransactionError("Invalid asset commitment length") - pub = secp256k1.ec_pubkey_parse(pubkey) + pub = bytearray(secp256k1.ec_pubkey_parse(pubkey)) secp256k1.ec_pubkey_tweak_mul(pub, blinding_key) sec = secp256k1.ec_pubkey_serialize(pub) nonce = hashlib.sha256(hashlib.sha256(sec).digest()).digest() diff --git a/src/embit/util/ctypes_secp256k1.py b/src/embit/util/ctypes_secp256k1.py index 5ce2b6c3..cddb3971 100644 --- a/src/embit/util/ctypes_secp256k1.py +++ b/src/embit/util/ctypes_secp256k1.py @@ -42,7 +42,19 @@ def _copy(a: bytes) -> bytes: """Ugly copy that works everywhere incl micropython""" if len(a) == 0: return b"" - return a[:1] + a[1:] + return bytes(a[:1] + a[1:]) + + +def _readable_buffer(a): + if isinstance(a, bytearray): + return bytes(a) + return a + + +def _writable_buffer(a): + if isinstance(a, bytearray): + return (c_char * len(a)).from_buffer(a) + return a def _platform_binary_ext(): @@ -562,7 +574,7 @@ def _init(flags=(CONTEXT_SIGN | CONTEXT_VERIFY)): def context_randomize(seed, context=_secp.ctx): if len(seed) != 32: raise ValueError("Seed should be 32 bytes long") - if _secp.secp256k1_context_randomize(context, seed) == 0: + if _secp.secp256k1_context_randomize(context, _readable_buffer(seed)) == 0: raise RuntimeError("Failed to randomize context") @@ -571,7 +583,7 @@ def ec_pubkey_create(secret, context=_secp.ctx): if len(secret) != 32: raise ValueError("Private key should be 32 bytes long") pub = bytes(64) - r = _secp.secp256k1_ec_pubkey_create(context, pub, secret) + r = _secp.secp256k1_ec_pubkey_create(context, pub, _readable_buffer(secret)) if r == 0: raise ValueError("Invalid private key") return pub @@ -588,7 +600,9 @@ def ec_pubkey_parse(sec, context=_secp.ctx): if sec[0] != 0x04: raise ValueError("Uncompressed pubkey should start with 0x04") pub = bytes(64) - r = _secp.secp256k1_ec_pubkey_parse(context, pub, sec, len(sec)) + r = _secp.secp256k1_ec_pubkey_parse( + context, pub, _readable_buffer(sec), len(sec) + ) if r == 0: raise ValueError("Failed parsing public key") return pub @@ -602,7 +616,9 @@ def ec_pubkey_serialize(pubkey, flag=EC_COMPRESSED, context=_secp.ctx): raise ValueError("Invalid flag") sec = bytes(33) if (flag == EC_COMPRESSED) else bytes(65) sz = c_size_t(len(sec)) - r = _secp.secp256k1_ec_pubkey_serialize(context, sec, byref(sz), pubkey, flag) + r = _secp.secp256k1_ec_pubkey_serialize( + context, sec, byref(sz), _readable_buffer(pubkey), flag + ) if r == 0: raise ValueError("Failed to serialize pubkey") return sec @@ -693,7 +709,7 @@ def ecdsa_sign(msg, secret, nonce_function=None, extra_data=None, context=_secp. def ec_seckey_verify(secret, context=_secp.ctx): if len(secret) != 32: raise ValueError("Secret should be 32 bytes long") - return bool(_secp.secp256k1_ec_seckey_verify(context, secret)) + return bool(_secp.secp256k1_ec_seckey_verify(context, _readable_buffer(secret))) @locked @@ -720,7 +736,12 @@ def ec_pubkey_negate(pubkey, context=_secp.ctx): def ec_privkey_tweak_add(secret, tweak, context=_secp.ctx): if len(secret) != 32 or len(tweak) != 32: raise ValueError("Secret and tweak should both be 32 bytes long") - if _secp.secp256k1_ec_privkey_tweak_add(context, secret, tweak) == 0: + if ( + _secp.secp256k1_ec_privkey_tweak_add( + context, _writable_buffer(secret), _readable_buffer(tweak) + ) + == 0 + ): raise ValueError("Failed to tweak the secret") return None @@ -731,7 +752,12 @@ def ec_pubkey_tweak_add(pub, tweak, context=_secp.ctx): raise ValueError("Public key should be 64 bytes long") if len(tweak) != 32: raise ValueError("Tweak should be 32 bytes long") - if _secp.secp256k1_ec_pubkey_tweak_add(context, pub, tweak) == 0: + if ( + _secp.secp256k1_ec_pubkey_tweak_add( + context, _writable_buffer(pub), _readable_buffer(tweak) + ) + == 0 + ): raise ValueError("Failed to tweak the public key") return None @@ -755,7 +781,7 @@ def ec_pubkey_add(pub, tweak, context=_secp.ctx): if len(tweak) != 32: raise ValueError("Tweak should be 32 bytes long") p = _copy(pub) - if _secp.secp256k1_ec_pubkey_tweak_add(context, p, tweak) == 0: + if _secp.secp256k1_ec_pubkey_tweak_add(context, p, _readable_buffer(tweak)) == 0: raise ValueError("Failed to tweak the public key") return p @@ -764,7 +790,12 @@ def ec_pubkey_add(pub, tweak, context=_secp.ctx): def ec_privkey_tweak_mul(secret, tweak, context=_secp.ctx): if len(secret) != 32 or len(tweak) != 32: raise ValueError("Secret and tweak should both be 32 bytes long") - if _secp.secp256k1_ec_privkey_tweak_mul(context, secret, tweak) == 0: + if ( + _secp.secp256k1_ec_privkey_tweak_mul( + context, _writable_buffer(secret), _readable_buffer(tweak) + ) + == 0 + ): raise ValueError("Failed to tweak the secret") @@ -774,15 +805,21 @@ def ec_pubkey_tweak_mul(pub, tweak, context=_secp.ctx): raise ValueError("Public key should be 64 bytes long") if len(tweak) != 32: raise ValueError("Tweak should be 32 bytes long") - if _secp.secp256k1_ec_pubkey_tweak_mul(context, pub, tweak) == 0: + if ( + _secp.secp256k1_ec_pubkey_tweak_mul( + context, _writable_buffer(pub), _readable_buffer(tweak) + ) + == 0 + ): raise ValueError("Failed to tweak the public key") @locked def ec_pubkey_combine(*args, context=_secp.ctx): pub = bytes(64) - pubkeys = (c_char_p * len(args))(*args) - r = _secp.secp256k1_ec_pubkey_combine(context, pub, pubkeys, len(args)) + readable_args = [_readable_buffer(arg) for arg in args] + pubkeys = (c_char_p * len(readable_args))(*readable_args) + r = _secp.secp256k1_ec_pubkey_combine(context, pub, pubkeys, len(readable_args)) if r == 0: raise ValueError("Failed to combine pubkeys") return pub diff --git a/src/embit/util/py_secp256k1.py b/src/embit/util/py_secp256k1.py index 543bc226..ba52a98d 100644 --- a/src/embit/util/py_secp256k1.py +++ b/src/embit/util/py_secp256k1.py @@ -205,18 +205,52 @@ def ec_pubkey_negate(pubkey, context=None): return ec_pubkey_parse(bytes([0x05 - sec[0]]) + sec[1:]) -def ec_privkey_tweak_add(secret, tweak, context=None): +def _ensure_mutable_key(key): + if not isinstance(key, bytearray): + raise TypeError("Tweaked key must be a bytearray") + + +def ec_privkey_tweak_add( + secret: bytearray, tweak: bytes | bytearray, context: object | None = None +) -> None: + """Add tweak to secret in place.""" + _ensure_mutable_key(secret) res = ec_privkey_add(secret, tweak) for i in range(len(secret)): secret[i] = res[i] -def ec_pubkey_tweak_add(pub, tweak, context=None): +def ec_pubkey_tweak_add( + pub: bytearray, tweak: bytes | bytearray, context: object | None = None +) -> None: + """Add tweak to public key in place.""" + _ensure_mutable_key(pub) res = ec_pubkey_add(pub, tweak) for i in range(len(pub)): pub[i] = res[i] +def ec_privkey_tweak_mul( + secret: bytearray, tweak: bytes | bytearray, context: object | None = None +) -> None: + """Multiply secret by tweak in place.""" + _ensure_mutable_key(secret) + if len(secret) != 32 or len(tweak) != 32: + raise ValueError("Secret and tweak should both be 32 bytes long") + s = int.from_bytes(secret, "big") + t = int.from_bytes(tweak, "big") + if ( + s == 0 + or s >= _key.SECP256K1_ORDER + or t == 0 + or t >= _key.SECP256K1_ORDER + ): + raise ValueError("Failed to tweak the secret") + res = ((s * t) % _key.SECP256K1_ORDER).to_bytes(32, "big") + for i in range(len(secret)): + secret[i] = res[i] + + def ec_privkey_add(secret, tweak, context=None): if len(secret) != 32 or len(tweak) != 32: raise ValueError("Secret and tweak should both be 32 bytes long") @@ -242,7 +276,11 @@ def ec_pubkey_add(pub, tweak, context=None): return Q[0].to_bytes(32, "little") + Q[1].to_bytes(32, "little") -def ec_pubkey_tweak_mul(pub, tweak, context=None): +def ec_pubkey_tweak_mul( + pub: bytearray, tweak: bytes | bytearray, context: object | None = None +) -> None: + """Multiply public key by tweak in place.""" + _ensure_mutable_key(pub) if len(pub) != 64: raise ValueError("Public key should be 64 bytes long") if len(tweak) != 32: diff --git a/tests/tests/test_bindings.py b/tests/tests/test_bindings.py index 4182e0fb..52227e2a 100644 --- a/tests/tests/test_bindings.py +++ b/tests/tests/test_bindings.py @@ -118,10 +118,22 @@ def test_cross(self): # mutable `bytearray`. Pass each the type it expects, then compare. tweak = b"9" * 32 pub_py = bytearray(pub1) - pub_c = bytes(pub1) # mutated in place via the CPython ctypes quirk + pub_c = ctypes_secp256k1._copy(pub1) # mutated via the CPython ctypes quirk py_secp256k1.ec_pubkey_tweak_mul(pub_py, tweak) ctypes_secp256k1.ec_pubkey_tweak_mul(pub_c, tweak) self.assertEqual(bytes(pub_py), pub_c) + pub_c_mutable = bytearray(pub1) + ctypes_secp256k1.ec_pubkey_tweak_mul(pub_c_mutable, bytearray(tweak)) + self.assertEqual(bytes(pub_py), bytes(pub_c_mutable)) + + secret_py = bytearray(secret) + secret_c = ctypes_secp256k1._copy(secret) + py_secp256k1.ec_privkey_tweak_mul(secret_py, tweak) + ctypes_secp256k1.ec_privkey_tweak_mul(secret_c, tweak) + self.assertEqual(bytes(secret_py), secret_c) + secret_c_mutable = bytearray(secret) + ctypes_secp256k1.ec_privkey_tweak_mul(secret_c_mutable, bytearray(tweak)) + self.assertEqual(bytes(secret_py), bytes(secret_c_mutable)) # ec_pubkey_combine takes varargs and returns a new pubkey. pub_other = py_secp256k1.ec_pubkey_create(b"7" * 32) @@ -129,6 +141,10 @@ def test_cross(self): py_secp256k1.ec_pubkey_combine(pub1, pub_other), ctypes_secp256k1.ec_pubkey_combine(pub1, pub_other), ) + self.assertEqual( + ctypes_secp256k1.ec_pubkey_combine(pub1, pub_other), + ctypes_secp256k1.ec_pubkey_combine(bytearray(pub1), bytearray(pub_other)), + ) # Three-arg combine. pub_third = py_secp256k1.ec_pubkey_create(b"3" * 32) self.assertEqual( @@ -208,6 +224,89 @@ def _tweak_mul_result_x(pubkey_compressed_hex, tweak_hex): return py_secp256k1.ec_pubkey_serialize(bytes(pub))[1:33].hex() +class PrivkeyTweakMulTest(TestCase): + """Standalone tests for py_secp256k1.ec_privkey_tweak_mul.""" + + def test_identity_tweak_one_unchanged(self): + secret = bytearray(b"2" * 32) + expected = bytes(secret) + py_secp256k1.ec_privkey_tweak_mul(secret, (1).to_bytes(32, "big")) + self.assertEqual(bytes(secret), expected) + + def test_multiplies_scalar_mod_order(self): + for secret_int, tweak_int in ( + (1, 2), + (2, 3), + (12345, 0xDEADBEEF), + (_SECP256K1_ORDER - 1, _SECP256K1_ORDER - 1), + ): + with self.subTest(secret_int=secret_int, tweak_int=tweak_int): + secret = bytearray(secret_int.to_bytes(32, "big")) + tweak = tweak_int.to_bytes(32, "big") + py_secp256k1.ec_privkey_tweak_mul(secret, tweak) + expected = (secret_int * tweak_int) % _SECP256K1_ORDER + self.assertEqual(bytes(secret), expected.to_bytes(32, "big")) + + def test_public_key_matches_tweaked_secret(self): + secret = bytearray(b"5" * 32) + tweak = b"9" * 32 + pub = bytearray(py_secp256k1.ec_pubkey_create(secret)) + + py_secp256k1.ec_privkey_tweak_mul(secret, tweak) + py_secp256k1.ec_pubkey_tweak_mul(pub, tweak) + + self.assertEqual(bytes(pub), py_secp256k1.ec_pubkey_create(secret)) + + def test_rejects_zero_secret(self): + with self.assertRaises(ValueError): + py_secp256k1.ec_privkey_tweak_mul(bytearray(32), b"\x01" * 32) + + def test_rejects_zero_tweak(self): + with self.assertRaises(ValueError): + py_secp256k1.ec_privkey_tweak_mul(bytearray(b"4" * 32), b"\x00" * 32) + + def test_rejects_overflow_secret_or_tweak(self): + for secret_int, tweak_int in ( + (_SECP256K1_ORDER, 1), + (_SECP256K1_ORDER + 1, 1), + (1, _SECP256K1_ORDER), + (1, _SECP256K1_ORDER + 1), + (2**256 - 1, 1), + (1, 2**256 - 1), + ): + with self.subTest(secret_int=secret_int, tweak_int=tweak_int): + secret = bytearray(secret_int.to_bytes(32, "big")) + tweak = tweak_int.to_bytes(32, "big") + with self.assertRaises(ValueError): + py_secp256k1.ec_privkey_tweak_mul(secret, tweak) + + def test_rejects_wrong_lengths(self): + with self.assertRaises(ValueError): + py_secp256k1.ec_privkey_tweak_mul(bytearray(31), b"\x01" * 32) + with self.assertRaises(ValueError): + py_secp256k1.ec_privkey_tweak_mul(bytearray(b"4" * 32), b"\x01" * 31) + + +class TweakMutationTypeTest(TestCase): + def test_rejects_immutable_private_key_for_add(self): + with self.assertRaisesRegex(TypeError, "bytearray"): + py_secp256k1.ec_privkey_tweak_add(b"2" * 32, b"\x01" * 32) + + def test_rejects_immutable_public_key_for_add(self): + pub = py_secp256k1.ec_pubkey_create(b"2" * 32) + with self.assertRaisesRegex(TypeError, "bytearray"): + py_secp256k1.ec_pubkey_tweak_add(pub, b"\x01" * 32) + + def test_rejects_immutable_private_key_for_mul(self): + with self.assertRaisesRegex(TypeError, "bytearray"): + py_secp256k1.ec_privkey_tweak_mul(b"2" * 32, b"\x01" * 32) + + def test_rejects_immutable_public_key_for_mul(self): + pub = py_secp256k1.ec_pubkey_create(b"2" * 32) + with self.assertRaisesRegex(TypeError, "bytearray"): + py_secp256k1.ec_pubkey_tweak_mul(pub, b"\x01" * 32) + + class PubkeyTweakMulTest(TestCase): """Standalone tests for py_secp256k1.ec_pubkey_tweak_mul. diff --git a/tests/tests/test_liquid.py b/tests/tests/test_liquid.py index cc8a290b..e9d9730a 100644 --- a/tests/tests/test_liquid.py +++ b/tests/tests/test_liquid.py @@ -12,27 +12,42 @@ from embit.ec import PrivateKey from embit.hashes import tagged_hash -_LIQUID_REQUIRED_FUNCS = [ - "ec_pubkey_tweak_mul", - "ec_privkey_tweak_mul", +_LIQUID_ZKP_REQUIRED_FUNCS = [ "generator_generate_blinded", + "generator_parse", + "generator_serialize", + "pedersen_blind_generator_blind_sum", "pedersen_commit", + "pedersen_commitment_parse", + "pedersen_commitment_serialize", "rangeproof_rewind", + "rangeproof_sign", "surjectionproof_generate", + "surjectionproof_initialize", + "surjectionproof_serialize", ] -_LIQUID_AVAILABLE = all(hasattr(secp256k1, func) for func in _LIQUID_REQUIRED_FUNCS) -if not _LIQUID_AVAILABLE: +_LIQUID_MUSIG_REQUIRED_FUNCS = [ + "ec_pubkey_tweak_mul", + "ec_privkey_tweak_mul", + "ec_pubkey_combine", +] +_LIQUID_ZKP_AVAILABLE = all( + hasattr(secp256k1, func) for func in _LIQUID_ZKP_REQUIRED_FUNCS +) +_LIQUID_MUSIG_AVAILABLE = all( + hasattr(secp256k1, func) for func in _LIQUID_MUSIG_REQUIRED_FUNCS +) +if not _LIQUID_ZKP_AVAILABLE: print( - "[tests] Liquid/ZKP backend unavailable; " - "skipping liquid tests and using pure-Python fallback." + "[tests] Liquid/ZKP backend unavailable; skipping ZKP-heavy liquid tests." ) -@skipUnless( - _LIQUID_AVAILABLE, - "Liquid/ZKP backend unavailable; pure-Python fallback in use", -) class LiquidTest(TestCase): + @skipUnless( + _LIQUID_ZKP_AVAILABLE, + "Liquid/ZKP backend unavailable; pure-Python fallback in use", + ) def test_value_commitment(self): # scalars in little endian vbf = bytes( @@ -124,6 +139,10 @@ def test_slip77(self): ), ) + @skipUnless( + _LIQUID_ZKP_AVAILABLE, + "Liquid/ZKP backend unavailable; pure-Python fallback in use", + ) def test_rangeproof(self): tx = "02000000010161ec81269a870c234cf5bc940e6a501a59170634dc40dec64a2113bc0f18b5510100000000fdffffff030bbfaa67f3a1d4d17804c5f1661ac538f179e34030ca3314f2c932828646493ed5081ca5ecd48661c55e40da8e680810671ceb4e0052046289db8a61c6921bd0d818035165006aab5ad15093372f2268a0f17a174a225f77cb8c79cb06066499f5ec0c17a914c5cf7922d5c4882f3718de54977f086199e3ca33870a3f985d1e2c7302f4ed33b265eb4e50361c1a36f42994fae73f19b399df81e9de09369e66ab256f85390aa0d7826a27b28c63002046a4941863d41c88ba73db003b021b86ddebdf2783d477db0f829fdc327bc2a9882e7697c4a3e28b9b151e53e13717a9143171392675316d931905b590cc08d44653e71610870118696cf23e3209a6e84479d01f13fe79c901356ae53907cc882cffd3e26401230100000000000000f900006e00000000000247304402207c660c76a672313c9b0e85f725558c6a9376a51da1978d5c52b1e28f514a2e1e022029d20e7975499a6c70edddf76ab805f795a69e24239f5d784f5e189fb4e04dee012102f88d70eb972f0034548fab6cc583dfb35330766d92fb9793e998e4ea19e725af0043010001d24bc22e3ba61c698f9127a79e6642f94bdf11c51ffcb0a15fc379a77d69e4a27eee88c76e70879b4c723c8bcc47255a0e2241aaca0d4d620cb1972200f87011fd4e106033000000000000000160aa54000e33153b17f39866cbbd8f443a587c3bf8873523f8f85133b7e1dae762dc34417c89dc2067da545a49972b8deb67cddce8160b031f3e44c3bac86d02fe73399ca7843336d4c9a4c38c15c644320ebd5ca7d3526ced8886a9bded6c7834b5a727d8c1fba64738c5b5c4ccd3d745e52abed218f7439b9476afc38ca1fb49ec2691ce085b317a23338062fdbd7f93bd9261fad4aa25604423dc71f5e593d1bcc396fdb940cd159d7edaaafaa62ce1a01af097d26d2f0ccefd6ac59284676b8c2113e6bfc4420c362f7882d6af18c50f9aab55ecd113348f358c9fe00c0f79efc53eb21393c4064d55a3707a1bc942afec4480dbb11b0fa0e48fbaf5c17378263415588511097d299d88f76912a186e81eca3ae429e43c6a50ccc6ccaccf02cc01cac81048611fd5efb4b59c14f841ae973b03cf484dfc2d1922161ef52dc4bb65845b8edd16cfe9a9509249d06661906275495a351966180aecbd7f68503daa6379825fc6f40860e7e7487bd602a93d5c10267bc77af9230fa659fea8a75fdbbd4b3e4aec8ce852393a99ebf5695abb443959bdcec8a36ec0ce46226ff745a3c9650a2808841413b3eba91db4f66c978930d3413951d2e6a2e77561c95cb91755e7a115f5d6ea4bcf4d9672752782d8281b941a12c0d2bd205628771506bf88c445836f654e46cea98c550f98144617e493e85aa6aebade127a6b50a11c4760f74eb9ffe4226d3e36461006eaeb83d9a03e3d26adfde5e4c7e5ee7ab348d3a1dcbefafd58b56ed7fd30afebc524fc737d87fcd334c3ad9db437d8f310a0306538c1e5207565d705475ec470308cd7c6fa8e0ad59d12ba30f75bc3428eef91aec871b761afd91e31b92dcf1260a50371b7f020aaf1e767a4aaac8925e31842c57a8cb56e66da2dc3d88040a52859698f6e0dbb2c84e674c4de09eac9abf260163f2f9ff18631d477dccb009e1ede337e1165691e4f5fc4bf3d09a966834c76f9971941dcb242d28b2cd819d7584ece74d21550d9db13e539ac473622915611222109c8b87204d4ffbfadf16e063132990c295a42d40a2d01ad6332d5a34a028f1a7b2839f29ab6e19b24dc24a02d3a103f23e3031dc4c8a4886941e51feede21b60cfdd54e8db129408e5c575578b3b8b193d138dee87eb5406a44fe8b0b37e16cb3f260ff02e0f375ec6028429ca0cb60722ccfac66f87aee14e16e3ff54abe7eb3796c83846259200340080ef1f80dec2540ba4d77290bcad05794f8253681fcadbafbd701059b460e41bdbdaffc7f5993c45fc90b10ca1d3b63be3b3819ef235cf75da209c23d6562d228311293d7c4db6f9a3ec20989a02e66529a8e2d9316899b3bd2af2931670e2a6209a4ffaab5e8e05f9412f58c087e99161ad57057f86ac0775434b5a6a224c27ad3f3e6e6ea4190a23b50d47c37096612c29fb02a3108bb5c903a979c36d0e7c7341a0dffed887803a3a78bfe1b5cc2891dcd0e3f01c66ac975e45e1307320b63095583c4df9794fafb5d13ad22a039029c5cc686f2782fbd1cf83f7a916bd74abcc2e6e6b56b22b168ee3570c8b8b418303906a663ef8fc92dc5e665daab345d9c05887ad88c1ff7b39f128087ad74f2a8e52bbfc049bab0425e480f9af1b999374e32afb2ae809ca602bc50ae47afa517c5b14cfbb567600d808e1048ea6b4d68ae2dcc19c6eb2be08f2c1b8c8d428335dd9151caf77151c4b2886f20d4e2c568e6c1f6e70bc2ec97323a7cb9404ff113246b45b72a3a9ca6975ef981fbfec6f4e1bda1424cf8fb4d270f4c29fc2ddb6c38b662524876d81afe14a80e9e3d54fbaf54b8ab41e0efdb01db7615098f9ea1a68298bd9acf61cd5eb66b7a171921342c9b9ee32495ce480fdd0dd58dcc7970edde2b29787b5f4c82888855a425393227a069f32b6f9db0e2ab8ad084b048dac5ee2fd4acb53f5338a96da898c239e92f77543ac93c90462e72f7673fe2ddb379d121a47f07c0a5b2453606001125ab626991c3436e7d9ed78e479248bf54dc502d243267a587216ad364740a9cd87b181febaeafb613cdac74fb3a199f1d854a8965d6d93a508592c5dac86e8db5592e980bca3ec3e18897d254c376d78a2fcfff4a16cfc1116f5517a8cea53f400b597fbc264174e7467219a07c90a4a9dae471de17e14a088134001a3b486d6a4a10f95a75686e1c9bf4e35290e5b73e61064ca460e5674e0dd5092978f3c264f0036049f2979429fbf8e616d90343ec4dca6ed464dde7b2dec5637ebb9dd4550dd137d9b6b39cd7dbe64863d5b100da80c8226261a68b5f06413656afb165e5730f2b3677d35b0fddb3e1f6ec35b99a9900738aa4f7bd3a71916088415a17bdd5b752535642b9c5fdd5db3196d1dae707ecbb14c6d5266f6438eff62adeb101d43404a792ee5396f6e23c5611b3daf1176c0eed3c82d0d5c381d7f6069e1b5034cc666fd42ae87fe5a62e7c322efdf52d1c20fcdccdc425b23e41820eb74f567a6b2d2cb2acc73848df30f835502da8e149c86c729d0ec2f16bca6ab8e3226654055682a487f97c1d4cbc4af1ac81872ff2f4631ef5bf0c3b050151a8d99905e03f4da42458b9b4fd3700aa24bf884a50e3b79690bba922192a6ed272e18556f3c5822d9c357ac4de38ef695b9c4128bb3ebdaa721a84d1d0a5bf761e41020bc36605be79c472d247c8cdb3c3c97aed81e637030bc8173d85467201326836a52fc4dc5f7be5c57eb1f30c455b597e713b54f09e42ad91c5543f942f4d21471b8493bbfcf7ca6812757b9d4a92578f0848149b8f5db4f19f7743d3c3dcce24239a8f17332c5c82cc670b8880709af331317bcd6b3e9696f4b739dd0bb133b699cf473594ba4eb20d746df7f5e1a815083eaef02c829f08993a580fc9ef5fe261b638579fd92c24311753c7d15d9294d9503b83e3ce25300d1ed41417c74347db0cdbc0b6320c320b8084ca6f124b5ca83f51b2ebe118fd17f4ef7ff777486fe138eb90147698d8abda55351cb7aada68ef5d0eaf32f2c506a4cd51eadbc34c876b4ded119fa5df951bae43d26ccaf90e934145f929735684bd2665a4e6d4917736df100688d6d225e2667d0555ab684c374799c5468ca28b7e7f653a33340bc78d1dca7fcc4cf365dfa87401ec87e8a9cf472f60e7bc3b6949b8adaf7a2ce5079ad8dff9fcd3f8284a016b7386e085894801e8603e77a394e6e93c899cfba1e89adcc177ba306ee82775eedb84b00ee92987a56b8386a007802383566133dde589c87f09a071f2c110090cdec84a92f133ed08d4b8073293c307f21461d52ad15043ad4535af4c9576c3288e83f1302bec91777087c9c2b37fdd87a78cb4083ed87fb24069e9aa459170deb30774369b3233d23ee4b42700b0869fadafdecf2b884fbbe7b870729625c4eed3a74fb5a0560871faf45327b4327cf03608561d5bb3a65007d594a08212ca992d0fb84258168a4b2c882442775e8ecb3b614d0a3aee0909a0a92d94856ee79f5e37ad53fce688597f218cbfcbb76febba01a411481b252ca3bc44e80ad0552c27be7f0bb8c9cbbad08ea1dfbf1201f9307be5d6ae7392c8a625de6f255af593929ef917d78132002d2260cfef45952a0181c8c5e47be8c9ee88e9ee176d6428e8de818d046f06cc3c359cdfffb1be8804580acc3e8748aeb4d0c2fecb50f7922259a2c52685b24ef63b04e9393e4e5af7c1c46e441fe36b3be8f9d3ee273ac43b296750199a3c0b4fd89380098598fd6b6202b4056ca9a6e6d03eb51451a4361f422a0b45c9875c064e9185a7e188ba13198dff9cb4605195cbea27e117e58e926176b8dd6863ebd4cbff7c7e472b7a38fa1727419130d1bfde56fe11557c5e3606e6991a1c242db3230aab1eaf7b463eef9cd6357b4060f663b3017c0ba4d0be2d22a709b76f167b5015a2769dbe9901d18abc0c2c285200b756057f9b2af9a7e83532f13f6c8be7fd40411bd4ab80fd0f11fe1538baa87e3b2083cab8fd660c0f1c90b4bcbba9ed5b784657b13f5b254ad8caf455bd4eb9260f580538fa69dde93e0a61e057d7903247af859e63dca5fbadedb1eb36f5722f8fb6e1a9cafaa204848b29826b89b556a450191ab5108273fb0e103a01bdfc673d7cd8160d90d2430a23945ebd319357ef09287bcdfce4f32cb5c043d690ad20c0d1177334a8792b2518c17874efb3f0afab5b7756dec945c0bf86d667c0a78f2aefebb285ed019ae6eace0a3be26459dbd6f2e9d1290f7befea161287676dad9430667789e69c090b2ad7a0dcf729d29bb6e350af5c03d6cec3231bd90e44da255baaaac80771d421386a1867ed584db55520178c490223976e3f8f5a6bee8f96c689c270f8f7ddb336c8e5fc49265a1e7642632f6a65ade6a4724a606a9a4955bbf8c9c95adf99228249ba8b76136e3cb2e82b33554f0a34ec48d1318ed85b3c13264abb13eddba21494a606787fcaf46d323204338fdce4323343f494431f58f2d6432c38a57dd83a8ab9f8cc46f2ce4bf8daa0a802eeb53990e6916b22b8719dd596316019f4e181f407626a4638990a3379470815361b2fa45564fb9aea96c00937f0d31286975c8c26e95adcf9e26df2524295d6b75b772ec95caa0535076ea85c1819b601de3f4e9c9e1962b470e147c8f22e0d1c6f7482f63fbc8039f4c9a879a7247e686decf9b36cb15a9afeb077cf20a398a814724ce5a5f53ded62a8baa322f2414bea00161b1023ec070aef656fbe21350876cff4e96e58128838b934d95cb9dcf741772efc455e89279005c09caabe1f3aa70ca4ab8ba92b45ba4d7829ecf311916cc540e7e96ef928930da15917f2e13d78fed992b052545876d638b3898c36795a61b78a2db7ce635a9e2621ef9cc6e9acb7b7985269ec8ff2cd76dc94f47106c3ee5132c84629c54be23f270b886744e452ce9b2ecd2c5fcdf1738cda54808d5201bc7985e519b49bcbd16577ca887a9943f600d4119a8be587a9ebaf91683d0f8ae711800d8605674e82b8594fdf988ae887f3cef270d6b1e2df21b7ab23562144a858f71361424dbf36a980d20e630215172aa17d5d16af93354020e7f6f1e74a42ed96edae4f03d420c9f1b389dd12cf0709b762333b684c7e76fc1d29d831da2bf3a2137bd7a5f7a74f94b2f54eef2dcbfc14276abfbbb2417d91299d39743d6de831860666ac704974b9cc2c3489c5111c07b92b8aba3853e0119dfb79e33a32dabb696d3acefa722e03a9d1429d1296c2bbe3e38847eb4d5a57a27c63bedb17fbdee19537a242d81c7ccbc0d6660e7a3270f27810f3c5035f687b8915e7cd7afe5b008bc97eae792bdca46fb08fccd5455846a0af1ce7ea838b948b3dc962a3dc389e244d3e25c95c0b88c637bdca7edd674507677a8fefd1fda1d1319a8bbd322783c918a447e369d1838af0b248f8f255145e285cdd84f2278b95bc8330599029c188a88f16a9fd7e0c0c0ee52110f6811c293e1ecf463cd092c985b12b55f5b9d128bfe7eac91302be9b48cf1221bef73fda345781372be992d390c1f86c7a8a14d4620cc7f3c0f7cfab196fa4b7796f9dfd819d757dd02fdf43c3bb9a938f16b99da8cb55f26f2d51ba8d24362527899a1d1d51d8ae0258d8d5b5c5221d2f008404425f5ac5c7787dff20ddf95e9ec0c446c955e1a19be50bed0d3f4f344b9f8813fe5d25bacbec36dd7be1b5795a8e125a8e1e26434ec51fc79c357925d1ef2a6a254c4ef829566f5a784718bd746b6c0c0cfb49d96edaa995bb07ad16e37a62535e8ba50fefd0c5e05e907fa77b72743c3479b1a1960b60ff197d0f31940e0eb62c386f86302a0d0559cd0381711ff421330d3010497259755fce9dd4b1d459fab672430100013f32023c28e566200d19bf60f3594dd68631e53ab701d81c7e352146d2463041a38e457d30ab5e6d81a423058bfa6963c228fa6c2249f20a625e7668d77c17e8fd4e10603300000000000000010b87a8005cd141bb0e23431c240f0d9ba6abfbb207095a386fe307e8b9a0e4ca5b640e5698b810ab3fdd54af982953691caab9475378b8742ce4ef1782e2e297da7470f4bcad95f0b9dd5b67cd42d514586a06fc5852c0d2f79e78651e567f8136ca40e5cb5d2f16ba3fd25623e455704cbdaa9a12e1cc236e6e075ff9d0d205f054359baca2c17065a5795af277c80c0d949fabe8d843a81a88006e6921d352eb3bf73d928fb9f68c46b303a8d46ffdd01ed987b64ba80f9897d659e2773c20599917b9877c0a453500252abe335d322083c5e00b092e2b981ed56380083b9d6e71b6460002edcc846e0f7bd4a8f2f81dc7e1f057c4259f9487ecadac14d665270d29cce513f838f0a8bd977d7698ac33e9fc5defea8832a1d93ec5cbcadd8754154c7ab6dd8d8c668a78ea73ba95994b9bc3896c065e61bf0326920535574140066d6c0d14cacf3833c54e3ca490cc071ed0f1f95c7daa99c0141b25dda44432392d8c307fb7f15792b688af9cc6994e2c7bf96c8737cdcfd3de7296260b43aff50896ea5069c254b9eae4fa00af02162307b826fc3f6a0df021303858d9f28f2f44debddbb9fdfcee7817c046063870fd66cc367efcaaf2ab22d364faecb912300d4eef688a20b41ffeb4ac37729604716c63123f812c3a9bf0f97e1b7caa044bc4d5b9e856d03af435e8ff8876437a17390d804d2c94b9ea9243deb63740c8587daba9022de2f55a2bb489140f2c626292f322d661e9be2d5f15b9a086030c19a89e54bd09d8943182241aaeafb0ddb0ec4828fb0de3e1713148aa715f9d597cdef52a3b6c1f850f037862dd86a15f15765b47aa9156c9bba604f51aedfe1d6e8a6d5f5ca9397e3850c5e27ab94334eef8641fc81568e75ee0af53cc3e9d60bc1b5a6e917681c85b38e85aeab517f1c8a4a536801298943210ed216bd221b2005a70a0f8fb2bfa38e88a169e28794ec6de4bd25bb56a8eaf45453f957d4beba02e7e6074da354b899c74fe815b6bd8e1aa6681b6824e97d79a016ee510e7dc594a7a1b3f2ecd77e774bd10266b087e31c54d5b12037997cd4e7b2decded0e206788911110d9a35db605a7b55b6e46a9b0490c95efc67c47699dc1e838e0df2cdbc6f3f22ec815ba2f1221bb7d65408479b41b0f140cf46d3594ab2f98261ba6608f5c48707809c0399b3b32d3a117ec82647398388fc2e1f734d2d07dc9d33f802597e0edac665dde52e906c2c7b7eafca54a57464ea4870e1251e862524c144c48d8bd5eaba3b22d9f4d55c81463a061672293049d1c09602e0141d97a2f0b844ceb32755062640b507603f7105382502a6342b87c52db21b9433d3574914baa7fe65a2db8debd24f955242a8fd966006d8bdb63f9db75b5f221a39ed5cdb616488974c13d7969aca573fa9c2a5ff13e3c1eb301561eaae3c8e15cea416aa9ddef3698be52bb5bda033af6988adf3e731ee62208af238cf3e2db2fbf50a9a86dd9eaa99faad62d27373e988c69c1b8d6676c4a7c4bafb168fda7c0c2803b2d4a03e8b7f04a0a7e2e82798052c373e34c873bbe98b538da9d515b43f12c3a3a33e590ec92221ccf2946dcacb42a95e7ab0821fc44af1d486573f73f045e0367f5bc82c841bb7ab132b6363931145395440aacafa7b93d5820a30862c3f37cefc2cf7f3657a03a7a35191529b95b80c55610c62aac5755815d3e290e5306ba4ff3baaac06ce90fffbcbe87ad41e48a4166aa8293bee0c77d5e994fc3cd12a4ad013b173949d753e1c2d96844dc1b37bbd3c0fc281d6e9b5ac66fb083acea67cc69ec553c074543f55851a334be29d793bcb73db56f72ae1893602037a0ffde4359a23dc26c469f3418523d8788fd25050add339311161dc6496735c3ed3a565504ec576bd64f4a51e42a60cbca11558533a8f67f5511fb4a16bad299851312bb3cd653572ad6b87e690df5dd6b9848db96df64dc4b9b4d30d824576aba02e252a8323ca083e73be4440b560fad4e835a9378cee2d96bde213a4552f54b9b7428c9661844a14a4d07717ffd379df959cf72fa04952ccf0b15c8ae5e736dd355e851d4199c316a77f800a9fa841a07cb64c0605ffc31484cf5f381538465d3d5e2f2edefb97045c17dd1d5641a18a27392550a476976cea2640c3c3f6ba9766b605934bb9b18169966c5104d1f6938235c6e78305a94ff182f80636ccb1195a020f89b703810d674b0d8e1bcb44dcfae32c89c35b57a407f2265aebb80c1543c04893ef903fa315d0be2a29611c772b276630e5355067657447a0a8c36c1363da0cb86a360b3345860e9ceee8b6ee1f846858d7ac281c4f3d77698b27a697875e3d4cb7352221785cc2ed959d8cd0317e20c45f92670d6ba8893206ac51da9ccd52e8405154d68fa3e025083adbfdfd917c2d186c0114480355bee202949a4b6ffb66e74d1b0b395e161aa591eb9eabf98194b0622276219cb4804263cbe1d942c862ca551a12ee008ddda041a913780cf5309a1520fce9b6d82f1aef28bff1ba04d2734ce07588fe26ef45e5e96e1c1036a6c671143416d1643786cb4231e291770586ebf32df774233edcc414fa6e3ceb12d14e4350d883432db129359501d8b73ff7a0b33de48c5227bc49ef75016c18539e986f7b1343979b71cb9a44f3bccf132b8d2276ab273f1626d9a0bf52ae8f8e73100b15517aa6ad21ae3c853ae9ae3fe0741aa803a3d9825b903a15b6972c512fa1466bb68ff5e38096a94b1c3fd5b91c8aeed69937ec4ef8f994ebc89e8bf5b56028d66bbedf2b22d780e9375f26ec707cfb0389d9e2787c6aabdf05473566c73e105dbd78940bdbf6c47d811009ffe4ff175778e4fbbbd757c57150103c58d4e3a7bd8f7212bea642d3668bcc6f67462b3f5ca46e5a91162748fb1efdf8d2250c9458c8857273736622b440cdf5c6ccaae15f49c71c18aee816da67e4a9eaf50b7980a252b96a9dfa94fb759756bf97e9f5e498688e5f38add25b2958bc38c98eaccce8f44e9a2a63aea30de18ba47109240f03e0620f26d16a6a47f2d4ffdc6d12529e3c7d0e7eec5bc0a24badf57d5746d45b5519869c5ca287945ab81086301c87c1f7fda5392597a3a6ac6e7a8606149d8fe6bc2a8aee2ea3f13abd52da3af3a8003b9842d9d64ddd183c03739c031d068eac37d03c92bc7977d6f35c4668320c8f2a523869f380cd1b47a8e2db7edc31e0f1e27a25ae378ffe780f776e4a7695bf8ca7a61d73ce143078bf7d1d84cd96872d06707ba3249ae257f54ff0ef40eb271cdde69392e559b4988ae30ea17e2805faea5f9b127be42e29e6c803dd561adda18f7b73f50a1b948131cdf8ce1a3434e2d8a7b0d1c6ecbdc1a1db24bd25df35cad84a25202815c5e6a688b8b37ec6a2ad2748a5d0ea2a5a0cad14a6d509dd08fdad62e7b10829103c7e4550910bc3b88669c7eeeb8f10dc0abf64b6c911ab00014909083c978939f745e162d027c01a489d6777fa95da337c24a9640e5aaeb0a9ac3458955d601c208bb68bfc71737e29c5f7181ceca530255d73d558099ed0445ac6e12ce8f89ed770eee9f88a2c991ef606c8ce2237dd9f4b9ca32512ec24b9cf2ca4d000f7d167acb949c6121dcc30c0bb976190191eeb98a753231e8268cfeb0ae862e6e0d6d918d6452e88b7be6192bf47d0e9b6a27093f00f3d1835bab2f98585202e30798aedafc233c39ddcefb981cef4fa5de9e558b42a38b64d8dbfb033f1d81bce7e15658dd7e93d15904c19b3cc98a8e32b825c8e63ac0a19e4456cf0ae6c8bdc45daaea5922d98eab2ad7c335cca067c73fb5c01fe788e8fb3ab24b9f1bad7f0c3251134b3b81c6374ce0df9538d551254d7e05fe35e19b020d504a53d839f0e4f6b1c31da8165e0120dec87edac833807a0c613968fc955d9355d45f47a8c2d5d33c98810890cadfc2f626691d11a1540e1b3222a9d9d93ecdbca0eda8adf9203de044570b55b87b25a1443a04640a79d65eda2ff58a30ee4a90e5725f26b23a7c375c3c113d6f0e5cf93c5e1005e184b078a97ca838d79793d988c2f089ef5960ee234f7ce95c93c482c826df9b8a981dd246d5cbed48e9e0f8ce251edd536c978c2a34f28cf24ba67006ec6f026e314b3257cd367c656c7fec4011ef5f52561900641a323879a4705fb4756bccdb5fde8d750ecfb20f9f3e0555c767df65ff851ab0b77edbd28dcdbfce8bfe271ca79e3d845bc4fd713bef06e1835285c030c877becb78976ac521e71bf3816eb5f9c987f0290675f8f7332424dfba5dd962979dca0ca11bc734b8da81739ec13113ca8a06a8cac5823685f26e45acaef53f9d18f1c251daa084f95caf0f8c0dcac6f5a7c3ff166cc73f42e5045597d13927012cb0ada24a10338fef868a93dbd00366d7d02722f3ebaa6307ada7789852554af7cae4d9e8ab44c723ed248f3a577432ae7c943a943354a46bb4cb407e034b2fdbdc57aed0b6eb91bf438fed03cfe6d9416de10c8b8b009a5ae99f7fc9c715839127dbb800224d8f14480d1bdbebbc49aec17a0522dbf7abd077ec3bc6e2317194d91e01bb99d1d6dc690a5fbc01643f7fac1dd6275a967b13a3b1eb0aaf169ae4a1121430235defdaee37cd0a5456a7c46753790805360ad7c55078bf415f67629341603213cf4934bdad52081e1a3598619646ed2a80c0f1bdbf04348cb34c5622a01194eeeef0334872dc500566407a3628fd72648e143141946c379db7ac35a078a6cda18b67bfd66bc8c59293023a781e01ac9bf5af4dc830dedae04514a346041348a7a3be16a93b2de6d193a2d95024287677d4f6524e3d245d91884ce4ce837ee48036a28665cf1cecf047097b7e811b8b94b4eb98e8d40bc1a04103df9d68a2ee817fd32a01e30fcaa804c84d9893fcfe9843bfdbc78a2d2b0bc13d4311221ab69f5810ca5dbcdc16974e11a183cea17f45345f4290a7924f079fda182bf0889f6d2598a2a074e181f3d5afe44064716921024bc2100eb74fba49d93983a6adc3619ff524ec69fce6814b18e745d9a9ec748c605ab88402ed7e1b1ca3b16745bf6bcef16d41ca462f53ce4f61a4dc0a3ddd97542790de36fb71ca4f22a875c71b9d2bb734262d6b64dad68b3e74b3ccfc991a16e4c0d82000333adb601a92c505f3777bc24f481a5d9718f967c7da5be14e6afd56c53503b82803dfdc081e1818c96818560e3f1b2623ceec30732da696a1ceacf7560cb8366340085284a7635d350a3ff2e4030f670d359225529a9d99523e48d40dd33143dadbf6909ef72490599100c8d31b768352998df8e511c6b0f308376d8325faf65eafd9bf4c7a0473a7e90190b4730bc4e99e8369ef1bd085db8891dd456ba073b289420f231250c0ece8bf751f3d24b8df030ca37f77db88b9b60f3dd595e2ae08dbd167ca48044e08471cf2ae4785d34f737b269beeaaf665b438ef1648efc5bb983f61f60c8a08a2d65490fd8fd261deccbd4b1ec99e12bf84937242f566438d38a2185995048028eee40c4d1f7aba3f1554ad1986a5af53b725462fc71b412b79ef515ff796ef1a5f917a8bb5875173ccace1408814af623d32bb69241743587d870ca95c3cdd5f38c2efb93b5031ebf262c6638bd01803a3e3b7cae967384a79780fd7542aaf9575565635e0a9b623018afa5b14b49335177bbbe4b556c0b6fe544fc6edc6dda333979c8659325bca20c9f161b2a32dfdd081a9ecca17c816e1ab7f6c79d15b5d3c19a9357f3d5f2891af8f902df414eef6da24f8d3f1ee6c6f69e1af4a449a3c218af53b688f58b1dd7991cae5269ca0de00ab57adacc3cc9429ba7a065c8ae8d47c02746e1a070d44ff18dcd3f97ffc4f2466072c3f8c1a0ebe0b057090000" raw = unhexlify(tx) @@ -153,10 +172,6 @@ def test_descriptors(self): % multi, "blinded(xprvA18YC5Aog5LxHgMrSv5t9QaHyfh5DU8Pr8zFTP5QhJSTjdg3mSpEyxLZfNQaEc8sALUtsHeDJYsp8YnobhjJT9D7JADoEV4wXiMuNMYDLZ2/<0;1>/*,%s)" % multi, - "blinded(musig(xprvA18YC5Aog5LxHgMrSv5t9QaHyfh5DU8Pr8zFTP5QhJSTjdg3mSpEyxLZfNQaEc8sALUtsHeDJYsp8YnobhjJT9D7JADoEV4wXiMuNMYDLZ2/<0;1>/*,xprv9ybbsYg8NKhDxDrSdmWPWih2AVjyDYxvTYvjaqNLmSpQcaLhmXeXUcHDEK99MiPDJwteBF2EzZkhfwwQDycrTgdxWGAgyWVpVJxrgZF5eCT/<0;1>/*),%s)" - % multi, - "blinded(musig(xpub6E7tbahhWSuFWASKYwctWYX2XhXZcvrFDMurFmV2FdyScS1CJz8VXkf3WchmYnBmC8uMVgENPLYd8uWjXYjxFFwFXD6unhFXs6VBjHTAb9e/<0;1>/*,xprv9ybbsYg8NKhDxDrSdmWPWih2AVjyDYxvTYvjaqNLmSpQcaLhmXeXUcHDEK99MiPDJwteBF2EzZkhfwwQDycrTgdxWGAgyWVpVJxrgZF5eCT/<0;1>/*),%s)" - % multi, ] for d in descs: desc = LDescriptor.from_string(d) @@ -174,6 +189,27 @@ def test_descriptors(self): with self.assertRaises((ValueError, RuntimeError, DescriptorError)): LDescriptor.from_string(d) + @skipUnless( + _LIQUID_MUSIG_AVAILABLE, + "Liquid MuSig backend unavailable; pure-Python fallback missing MuSig helpers", + ) + def test_musig_descriptors(self): + multi = "wsh(sortedmulti(1,[12345678/44h/12]xpub6BwcvdstHTJtLpp1WxUiQCYERWSB66XY5JrCpw71GAJxcJ6s2AiUoEK4Nzt6UDaTmanUiSe6TY2RoFturKNLXeWBhwBF6WBNghr8cr7qnjk/<0;1>/*,[abcdef12/84h/22h]xpub6F6wWxm8F64iBHNhyaoh3QKCuuMUY5pfPPr1H1WuZXUXeXtZ21qjFN5ykaqnLL1jtPEFB9d94CyZrcYWKVdSiJKQ6mLGEB5sfrGFBpg6wgA/<0;1>/*))" + descs = [ + "blinded(musig(xprvA18YC5Aog5LxHgMrSv5t9QaHyfh5DU8Pr8zFTP5QhJSTjdg3mSpEyxLZfNQaEc8sALUtsHeDJYsp8YnobhjJT9D7JADoEV4wXiMuNMYDLZ2/<0;1>/*,xprv9ybbsYg8NKhDxDrSdmWPWih2AVjyDYxvTYvjaqNLmSpQcaLhmXeXUcHDEK99MiPDJwteBF2EzZkhfwwQDycrTgdxWGAgyWVpVJxrgZF5eCT/<0;1>/*),%s)" + % multi, + "blinded(musig(xpub6E7tbahhWSuFWASKYwctWYX2XhXZcvrFDMurFmV2FdyScS1CJz8VXkf3WchmYnBmC8uMVgENPLYd8uWjXYjxFFwFXD6unhFXs6VBjHTAb9e/<0;1>/*,xprv9ybbsYg8NKhDxDrSdmWPWih2AVjyDYxvTYvjaqNLmSpQcaLhmXeXUcHDEK99MiPDJwteBF2EzZkhfwwQDycrTgdxWGAgyWVpVJxrgZF5eCT/<0;1>/*),%s)" + % multi, + ] + for d in descs: + desc = LDescriptor.from_string(d) + self.assertEqual(d, str(desc)) + self.assertIsNotNone(desc.derive(0).address()) + + @skipUnless( + _LIQUID_ZKP_AVAILABLE, + "Liquid/ZKP backend unavailable; pure-Python fallback in use", + ) def test_blind_unblind(self): # mnemonic = "abandon "*11 + "about" mbkey = PrivateKey.from_string( From 585c52dc5f0fefda7b417fea5180ad1d13ce1fcc Mon Sep 17 00:00:00 2001 From: odudex Date: Fri, 29 May 2026 11:15:03 -0300 Subject: [PATCH 15/61] bip352: pass bytearray to in-place pubkey tweaks in create_outputs The new pure-Python secp256k1 tweak functions require a bytearray (they mutate in place via index assignment); ec_pubkey_parse returns bytes, which only worked with the ctypes backend. Wrap parsed points in bytearray so create_outputs works on both backends (fixes the pure-Python CI path). --- src/embit/silent_payments/bip352.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 3688db8e..66c2fc27 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -158,7 +158,9 @@ def create_outputs(input_privkeys, outpoints, recipients): scalar_bytes = scalar.to_bytes(32, "big") for B_scan, B_spend_list in groups.items(): - ecdh_point = ec_pubkey_parse(B_scan.sec()) + # bytearray: tweak_* mutate the point in place (required by the pure-Python + # secp256k1 backend, and works with the ctypes one too) + ecdh_point = bytearray(ec_pubkey_parse(B_scan.sec())) ec_pubkey_tweak_mul(ecdh_point, scalar_bytes) xonly_shared_secret = ec_pubkey_serialize(ecdh_point) @@ -170,7 +172,7 @@ def create_outputs(input_privkeys, outpoints, recipients): xonly_shared_secret + k.to_bytes(4, "big"), ) - P_k = ec_pubkey_parse(B_spend.sec()) + P_k = bytearray(ec_pubkey_parse(B_spend.sec())) ec_pubkey_tweak_add(P_k, t_k) xonly = ec_pubkey_serialize(P_k)[1:33] From 874c727577cdc6c9f5b4a51bb5cbf3a5cba22c8a Mon Sep 17 00:00:00 2001 From: notTanveer Date: Mon, 11 May 2026 15:47:53 +0530 Subject: [PATCH 16/61] psbt: add PSBTv2 per-input locktime fields and version-aware scope parsing - Add LOCKTIME_THRESHOLD constant (500000000) for locktime type discrimination - Add required_time_locktime and required_height_locktime fields to InputScope - Propagate those fields in InputScope.update() - Parse PSBT_IN_REQUIRED_TIME_LOCKTIME (0x11) and PSBT_IN_REQUIRED_HEIGHT_LOCKTIME (0x12) in InputScope.read_value(), with range validation - Serialize 0x11 and 0x12 in InputScope.write_to() under the version==2 block - Add version=None parameter to InputScope.read_value() and OutputScope.read_value() so callers can signal the PSBT version being parsed - Add InputScope.read_from() and OutputScope.read_from() classmethods that accept and forward the version parameter (supersede PSBTScope base classmethod for v2) - Propagate version in LInputScope.read_value() and LOutputScope.read_value() (pset.py) so the Liquid subclasses stay compatible - Pass version=self.version when PSBTView calls read_from() for input/output scopes so PSBTv2 scopes are parsed with the correct version context --- src/embit/liquid/pset.py | 8 ++-- src/embit/psbt.py | 89 ++++++++++++++++++++++++++++++++++++++-- src/embit/psbtview.py | 8 +++- 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/embit/liquid/pset.py b/src/embit/liquid/pset.py index dc97b2ba..62025b94 100644 --- a/src/embit/liquid/pset.py +++ b/src/embit/liquid/pset.py @@ -140,10 +140,10 @@ def blinded_vin(self): witness=TxInWitness(self.issue_rangeproof, self.token_rangeproof), ) - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): # standard bitcoin stuff if (b"\xfc\x08elements" not in k) and (b"\xfc\x04pset" not in k): - super().read_value(stream, k) + super().read_value(stream, k, version=version) elif k == b"\xfc\x04pset\x0e": # range proof is very large, # so we don't load it if compress flag is set. @@ -386,9 +386,9 @@ def reblind(self, nonce, blinding_pubkey=None, extra_message=b""): secp256k1.generator_parse(self.asset_commitment), ) - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): if (b"\xfc\x08elements" not in k) and (b"\xfc\x04pset" not in k): - super().read_value(stream, k) + super().read_value(stream, k, version=version) # range proof and surjection proof are very large, # so we don't load them if compress flag is set. elif k in [b"\xfc\x08elements\x04", b"\xfc\x04pset\x04"]: diff --git a/src/embit/psbt.py b/src/embit/psbt.py index d20c88bb..306dab09 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -12,6 +12,9 @@ from io import BytesIO +LOCKTIME_THRESHOLD = 500000000 + + class PSBTError(EmbitError): pass @@ -89,7 +92,7 @@ def parse_unknowns(self): s.seek(0) self.read_value(s, k) - def read_value(self, stream, key, *args, **kwargs): + def read_value(self, stream, key, version=None): # separator if len(key) == 0: return @@ -148,6 +151,8 @@ def __init__(self, unknown: dict = {}, vin=None, compress=CompressMode.KEEP_ALL) self.final_scriptsig = None self.final_scriptwitness = None + self.required_time_locktime = None + self.required_height_locktime = None self.parse_unknowns() def clear_metadata(self, compress=CompressMode.CLEAR_ALL): @@ -193,6 +198,16 @@ def update(self, other): self.taproot_scripts.update(other.taproot_scripts) self.final_scriptsig = other.final_scriptsig or self.final_scriptsig self.final_scriptwitness = other.final_scriptwitness or self.final_scriptwitness + self.required_time_locktime = ( + other.required_time_locktime + if other.required_time_locktime is not None + else self.required_time_locktime + ) + self.required_height_locktime = ( + other.required_height_locktime + if other.required_height_locktime is not None + else self.required_height_locktime + ) @property def vin(self): @@ -245,7 +260,7 @@ def verify(self, ignore_missing=False): raise PSBTError("Missing non_witness_utxo") return False - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): # separator if len(k) == 0: return @@ -345,6 +360,7 @@ def read_value(self, stream, k): else: raise PSBTError("Duplicated final scriptwitness") + # PSBTv2 fields elif k == b"\x0e": self.txid = bytes(reversed(v)) elif k == b"\x0f": @@ -352,6 +368,36 @@ def read_value(self, stream, k): elif k == b"\x10": self.sequence = int.from_bytes(v, "little") + # PSBT_IN_REQUIRED_TIME_LOCKTIME + elif k[0] == 0x11: + if version != 2: + raise PSBTError("PSBT_IN_REQUIRED_TIME_LOCKTIME not allowed in PSBTv0") + if len(k) != 1: + raise PSBTError("Invalid required time locktime key") + if self.required_time_locktime is not None: + raise PSBTError("Duplicated required time locktime") + locktime = int.from_bytes(v, "little") + if locktime < LOCKTIME_THRESHOLD: + raise PSBTError(f"Time-based locktime must be >= {LOCKTIME_THRESHOLD}") + self.required_time_locktime = locktime + + # PSBT_IN_REQUIRED_HEIGHT_LOCKTIME + elif k[0] == 0x12: + if version != 2: + raise PSBTError( + "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME not allowed in PSBTv0" + ) + if len(k) != 1: + raise PSBTError("Invalid required height locktime key") + if self.required_height_locktime is not None: + raise PSBTError("Duplicated required height locktime") + locktime = int.from_bytes(v, "little") + if locktime >= LOCKTIME_THRESHOLD or locktime == 0: + raise PSBTError( + f"Height-based locktime must be > 0 and < {LOCKTIME_THRESHOLD}" + ) + self.required_height_locktime = locktime + # PSBT_IN_TAP_KEY_SIG elif k[0] == 0x13: # read the taproot key sig @@ -444,6 +490,20 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: r += ser_string(stream, b"\x10") r += ser_string(stream, self.sequence.to_bytes(4, "little")) + # Add required time locktime if present + if self.required_time_locktime is not None: + r += ser_string(stream, b"\x11") + r += ser_string( + stream, self.required_time_locktime.to_bytes(4, "little") + ) + + # Add required height locktime if present + if self.required_height_locktime is not None: + r += ser_string(stream, b"\x12") + r += ser_string( + stream, self.required_height_locktime.to_bytes(4, "little") + ) + # PSBT_IN_TAP_KEY_SIG if self.taproot_key_sig is not None: r += ser_string(stream, b"\x13") @@ -489,6 +549,17 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: r += stream.write(b"\x00") return r + @classmethod + def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vin=None, version=None): + res = cls({}, vin=vin, compress=compress) + while True: + key = read_string(stream) + # separator + if len(key) == 0: + break + res.read_value(stream, key, version=version) + return res + class OutputScope(PSBTScope): def __init__(self, unknown: dict = {}, vout=None, compress=CompressMode.KEEP_ALL): @@ -531,7 +602,7 @@ def update(self, other): def vout(self): return TransactionOutput(self.value, self.script_pubkey) - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): # separator if len(k) == 0: return @@ -562,6 +633,7 @@ def read_value(self, stream, k): else: self.bip32_derivations[pub] = DerivationPath.parse(v) + # PSBTv2 fields elif k == b"\x03": self.value = int.from_bytes(v, "little") elif k == b"\x04": @@ -633,6 +705,17 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: r += stream.write(b"\x00") return r + @classmethod + def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vout=None, version=None): + res = cls({}, vout=vout, compress=compress) + while True: + key = read_string(stream) + # separator + if len(key) == 0: + break + res.read_value(stream, key, version=version) + return res + class PSBT(EmbitBase): MAGIC = b"psbt\xff" diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index c66bcb5b..75b35e28 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -313,7 +313,9 @@ def input(self, i, compress=None): raise PSBTError("Invalid input index") vin = self.tx.vin(i) if self.tx else None self.seek_to_scope(i) - return self.PSBTIN_CLS.read_from(self.stream, vin=vin, compress=compress) + return self.PSBTIN_CLS.read_from( + self.stream, vin=vin, compress=compress, version=self.version + ) def output(self, i, compress=None): """Reads, parses and returns PSBT OutputScope #i""" @@ -323,7 +325,9 @@ def output(self, i, compress=None): raise PSBTError("Invalid output index") vout = self.tx.vout(i) if self.tx else None self.seek_to_scope(self.num_inputs + i) - return self.PSBTOUT_CLS.read_from(self.stream, vout=vout, compress=compress) + return self.PSBTOUT_CLS.read_from( + self.stream, vout=vout, compress=compress, version=self.version + ) # compress is not used here, but may be used by subclasses (liquid) def vin(self, i, compress=None): From 11eab7a0ec35f314889f4ca10f26145750b7dc27 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Mon, 11 May 2026 15:56:31 +0530 Subject: [PATCH 17/61] psbt: implement PSBTv2 global parsing and serialization - Add TxModifiable class with INPUTS/OUTPUTS/SIGHASH_SINGLE bit flags - Add PSBT._validate_v2_output() classmethod to check required PSBTv2 output fields; PSET overrides it to allow value_commitment in place of value - Add tx_modifiable_flags field to PSBT.__init__() (None for v0/unset) - Update comment on self.version to note '2 for v2' - Add PSBT.determine_locktime(): implements BIP-370 locktime selection algorithm (height vs time preference, max-of-minimums) - Update PSBT.tx property to call determine_locktime() for PSBTv2 - Update PSBT.write_to(): serialize PSBT_GLOBAL_TX_MODIFIABLE (0x06) when set - Rewrite PSBT.read_from(): collect all global KVs into OrderedDict before branching on PSBTv2 vs PSBTv0; detect version from 0xfb; reject unsigned tx in PSBTv2 and reject v2-only keys in PSBTv0; validate required fields after parsing; call read_from with version=version on all scopes - Rewrite PSBT.parse_unknowns(): strip 0xfb/0x06; guard 0x02/0x03/0x04/0x05 behind version==2; track _raw_input_count_from_global and _raw_output_count_from_global for PSBTv2 scope parsing - Add version guards to InputScope.read_value() for 0x0e/0x0f/0x10: PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX, and PSBT_IN_SEQUENCE are rejected when version != 2 - Add version guards to OutputScope.read_value() for 0x03/0x04: PSBT_OUT_AMOUNT and PSBT_OUT_SCRIPT are rejected when version != 2 --- src/embit/liquid/pset.py | 12 ++ src/embit/psbt.py | 404 +++++++++++++++++++++++++++++++++------ 2 files changed, 353 insertions(+), 63 deletions(-) diff --git a/src/embit/liquid/pset.py b/src/embit/liquid/pset.py index 62025b94..e3cfa3a0 100644 --- a/src/embit/liquid/pset.py +++ b/src/embit/liquid/pset.py @@ -503,6 +503,18 @@ class PSET(PSBT): PSBTOUT_CLS = LOutputScope TX_CLS = LTransaction + @classmethod + def _validate_v2_output(cls, out, i): + """PSET allows value_commitment as an alternative to value (blinded outputs).""" + if out.value is None and not getattr(out, "value_commitment", None): + raise PSBTError( + f"PSBTv2 output {i} missing required PSBT_OUT_AMOUNT (0x03)" + ) + if out.script_pubkey is None: + raise PSBTError( + f"PSBTv2 output {i} missing required PSBT_OUT_SCRIPT (0x04)" + ) + def unblind(self, blinding_key): for inp in self.inputs: inp.unblind(blinding_key) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 306dab09..cc25f093 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -272,9 +272,17 @@ def read_value(self, stream, k, version=None): raise PSBTError("Duplicated utxo value") else: l = compact.read_from(stream) + # For PSBTv2, PSBT_IN_OUTPUT_INDEX may follow PSBT_IN_NON_WITNESS_UTXO; + # use the pre-scanned vout (set by read_from) when the field hasn't + # been parsed yet so the OOM protection is key-order independent. + effective_vout = ( + self.vout + if self.vout is not None + else getattr(self, "_prescan_vout", None) + ) # we verified and saved utxo - if self.compress and self.txid and self.vout is not None: - txout, txhash = self.TX_CLS.read_vout(stream, self.vout) + if self.compress and self.txid and effective_vout is not None: + txout, txhash = self.TX_CLS.read_vout(stream, effective_vout) self._txhash = txhash self._utxo = txout else: @@ -362,35 +370,53 @@ def read_value(self, stream, k, version=None): # PSBTv2 fields elif k == b"\x0e": + if version != 2: + raise PSBTError("PSBT_IN_PREVIOUS_TXID not allowed in PSBTv0") + if len(v) != 32: + raise PSBTError("PSBT_IN_PREVIOUS_TXID must be 32 bytes") + if self.txid is not None: + raise PSBTError("Duplicated PSBT_IN_PREVIOUS_TXID") self.txid = bytes(reversed(v)) elif k == b"\x0f": + if version != 2: + raise PSBTError("PSBT_IN_OUTPUT_INDEX not allowed in PSBTv0") + if len(v) != 4: + raise PSBTError("PSBT_IN_OUTPUT_INDEX must be 4 bytes") + if self.vout is not None: + raise PSBTError("Duplicated PSBT_IN_OUTPUT_INDEX") self.vout = int.from_bytes(v, "little") elif k == b"\x10": + if version != 2: + raise PSBTError("PSBT_IN_SEQUENCE not allowed in PSBTv0") + if len(v) != 4: + raise PSBTError("PSBT_IN_SEQUENCE must be 4 bytes") + if self.sequence is not None: + raise PSBTError("Duplicated PSBT_IN_SEQUENCE") self.sequence = int.from_bytes(v, "little") # PSBT_IN_REQUIRED_TIME_LOCKTIME - elif k[0] == 0x11: + elif k == b"\x11": if version != 2: raise PSBTError("PSBT_IN_REQUIRED_TIME_LOCKTIME not allowed in PSBTv0") - if len(k) != 1: - raise PSBTError("Invalid required time locktime key") if self.required_time_locktime is not None: raise PSBTError("Duplicated required time locktime") + if len(v) != 4: + raise PSBTError("PSBT_IN_REQUIRED_TIME_LOCKTIME must be 4 bytes") locktime = int.from_bytes(v, "little") if locktime < LOCKTIME_THRESHOLD: raise PSBTError(f"Time-based locktime must be >= {LOCKTIME_THRESHOLD}") self.required_time_locktime = locktime # PSBT_IN_REQUIRED_HEIGHT_LOCKTIME - elif k[0] == 0x12: + elif k == b"\x12": if version != 2: raise PSBTError( "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME not allowed in PSBTv0" ) - if len(k) != 1: - raise PSBTError("Invalid required height locktime key") if self.required_height_locktime is not None: raise PSBTError("Duplicated required height locktime") + if len(v) != 4: + raise PSBTError("PSBT_IN_REQUIRED_HEIGHT_LOCKTIME must be 4 bytes") locktime = int.from_bytes(v, "little") if locktime >= LOCKTIME_THRESHOLD or locktime == 0: raise PSBTError( @@ -552,12 +578,31 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: @classmethod def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vin=None, version=None): res = cls({}, vin=vin, compress=compress) + # For PSBTv2 with compress mode, pre-scan for PSBT_IN_OUTPUT_INDEX so the + # non-witness UTXO memory optimisation works regardless of key order. + # (BIP370 does not guarantee that 0x0f appears before 0x00.) + res._prescan_vout = None + if version == 2 and compress and res.vout is None: + try: + start_pos = stream.tell() + while True: + k = read_string(stream) + if len(k) == 0: + break + v = read_string(stream) + if k == b"\x0f" and len(v) == 4: + res._prescan_vout = int.from_bytes(v, "little") + break + stream.seek(start_pos) + except (AttributeError, OSError): + pass # stream not seekable; OOM protection may be unavailable while True: key = read_string(stream) # separator if len(key) == 0: break res.read_value(stream, key, version=version) + del res._prescan_vout return res @@ -635,8 +680,18 @@ def read_value(self, stream, k, version=None): # PSBTv2 fields elif k == b"\x03": - self.value = int.from_bytes(v, "little") + if version != 2: + raise PSBTError("PSBT_OUT_AMOUNT not allowed in PSBTv0") + if len(v) != 8: + raise PSBTError("PSBT_OUT_AMOUNT must be 8 bytes") + if self.value is not None: + raise PSBTError("Duplicated PSBT_OUT_AMOUNT") + self.value = int.from_bytes(v, "little", signed=True) elif k == b"\x04": + if version != 2: + raise PSBTError("PSBT_OUT_SCRIPT not allowed in PSBTv0") + if self.script_pubkey is not None: + raise PSBTError("Duplicated PSBT_OUT_SCRIPT") self.script_pubkey = Script(v) # PSBT_OUT_TAP_INTERNAL_KEY @@ -675,7 +730,8 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: if version == 2: if self.value is not None: r += ser_string(stream, b"\x03") - r += ser_string(stream, self.value.to_bytes(8, "little")) + # BIP370: PSBT_OUT_AMOUNT is a signed 64-bit integer + r += ser_string(stream, self.value.to_bytes(8, "little", signed=True)) if self.script_pubkey is not None: r += ser_string(stream, b"\x04") r += self.script_pubkey.write_to(stream) @@ -717,6 +773,12 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vout=None, version=No return res +class TxModifiable: + INPUTS = 0b00000001 + OUTPUTS = 0b00000010 + SIGHASH_SINGLE = 0b00000100 + + class PSBT(EmbitBase): MAGIC = b"psbt\xff" # for subclasses @@ -724,17 +786,33 @@ class PSBT(EmbitBase): PSBTOUT_CLS = OutputScope TX_CLS = Transaction - def __init__(self, tx=None, unknown={}, version=None): - self.version = version # None for v0 + @classmethod + def _validate_v2_output(cls, out, i): + """Check that a PSBTv2 output has all required fields. + Subclasses (e.g. PSET) may override this to accept alternative fields.""" + if out.value is None: + raise PSBTError( + f"PSBTv2 output {i} missing required PSBT_OUT_AMOUNT (0x03)" + ) + if out.script_pubkey is None: + raise PSBTError( + f"PSBTv2 output {i} missing required PSBT_OUT_SCRIPT (0x04)" + ) + + def __init__(self, tx=None, unknown=None, version=None): + self.version = version # None for v0, 2 for v2 self.inputs = [] self.outputs = [] self.tx_version = None self.locktime = None + self.tx_modifiable_flags = None + self._raw_input_count_from_global = None + self._raw_output_count_from_global = None if tx is not None: self.parse_tx(tx) - self.unknown = unknown + self.unknown = {} if unknown is None else unknown self.xpubs = OrderedDict() self.parse_unknowns() @@ -744,11 +822,89 @@ def parse_tx(self, tx): self.inputs = [self.PSBTIN_CLS(vin=vin) for vin in tx.vin] self.outputs = [self.PSBTOUT_CLS(vout=vout) for vout in tx.vout] + @staticmethod + def _classify_locktimes(inputs): + """Classify inputs by locktime requirement type. + + Returns a tuple of (height_locktimes, time_locktimes, inputs_with_requirements) + where the lists contain the respective locktime values and the count is the + number of inputs that express at least one locktime requirement. + """ + height_locktimes = [] + time_locktimes = [] + inputs_with_requirements = 0 + for inp in inputs: + has_requirement = False + if inp.required_height_locktime is not None: + height_locktimes.append(inp.required_height_locktime) + has_requirement = True + if inp.required_time_locktime is not None: + time_locktimes.append(inp.required_time_locktime) + has_requirement = True + if has_requirement: + inputs_with_requirements += 1 + return height_locktimes, time_locktimes, inputs_with_requirements + + def determine_locktime(self): + """ + Determines the appropriate locktime according to PSBTv2 rules. + Returns the locktime that should be used for the transaction. + """ + if self.version != 2: + return self.locktime or 0 + + height_locktimes, time_locktimes, inputs_with_locktime_requirements = ( + self._classify_locktimes(self.inputs) + ) + + # If no inputs have locktime requirements, use fallback + if inputs_with_locktime_requirements == 0: + return self.locktime or 0 + + # height preferred, if ALL inputs with requirements support it + if len(height_locktimes) == inputs_with_locktime_requirements: + return max(height_locktimes) + + # fall back to time if ALL inputs with requirements support it + if len(time_locktimes) == inputs_with_locktime_requirements: + return max(time_locktimes) + + raise PSBTError( + "Cannot determine locktime: inputs have conflicting height and time locktime requirements" + ) + + def _validate_locktime_compatibility(self, inputs): + """Verify that the given input list has mutually compatible locktime requirements. + + Raises PSBTError when no single locktime type (height or time) is supported + by every input that expresses a requirement. + """ + height_locktimes, time_locktimes, inputs_with_requirements = ( + self._classify_locktimes(inputs) + ) + if inputs_with_requirements == 0: + return + if ( + len(height_locktimes) == inputs_with_requirements + or len(time_locktimes) == inputs_with_requirements + ): + return + raise PSBTError( + "Input locktime requirements are incompatible with existing inputs" + ) + @property def tx(self): + if self.version == 2: + locktime = self.determine_locktime() + else: + locktime = self.locktime or 0 + + if self.version == 2 and self.tx_version is None: + raise PSBTError("PSBTv2 is missing required PSBT_GLOBAL_TX_VERSION") return self.TX_CLS( version=self.tx_version or 2, - locktime=self.locktime or 0, + locktime=locktime, vin=[inp.vin for inp in self.inputs], vout=[out.vout for out in self.outputs], ) @@ -801,9 +957,13 @@ def write_to(self, stream) -> int: r += ser_string(stream, self.xpubs[xpub].serialize()) if self.version == 2: - if self.tx_version is not None: - r += ser_string(stream, b"\x02") - r += ser_string(stream, self.tx_version.to_bytes(4, "little")) + if self.tx_version is None: + raise PSBTError( + "Cannot serialize PSBTv2: missing required PSBT_GLOBAL_TX_VERSION" + ) + r += ser_string(stream, b"\x02") + # BIP370: PSBT_GLOBAL_TX_VERSION is a signed int32 + r += ser_string(stream, self.tx_version.to_bytes(4, "little", signed=True)) if self.locktime is not None: r += ser_string(stream, b"\x03") r += ser_string(stream, self.locktime.to_bytes(4, "little")) @@ -811,6 +971,9 @@ def write_to(self, stream) -> int: r += ser_string(stream, compact.to_bytes(len(self.inputs))) r += ser_string(stream, b"\x05") r += ser_string(stream, compact.to_bytes(len(self.outputs))) + if self.tx_modifiable_flags is not None: + r += ser_string(stream, b"\x06") + r += ser_string(stream, bytes([self.tx_modifiable_flags])) r += ser_string(stream, b"\xfb") r += ser_string(stream, self.version.to_bytes(4, "little")) # unknown @@ -855,72 +1018,187 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL): without storing them in memory and save the utxo internally for signing. This helps against out-of-memory errors. """ - tx = None - unknown = {} - version = None - # check magic if stream.read(len(cls.MAGIC)) != cls.MAGIC: raise PSBTError("Invalid PSBT magic") + + global_kvs = OrderedDict() while True: key = read_string(stream) - # separator - if len(key) == 0: + if len(key) == 0: # Separator break value = read_string(stream) - # tx - if key == b"\x00": - if tx is None: - tx = cls.TX_CLS.parse(value) - else: + if key in global_kvs: + raise PSBTError(f"Duplicated global key: {hexlify(key).decode()}") + global_kvs[key] = value + + # Determine PSBT version from PSBT_GLOBAL_VERSION (0xfb) + version = None + if b"\xfb" in global_kvs: + if len(global_kvs[b"\xfb"]) != 4: + raise PSBTError("PSBT_GLOBAL_VERSION must be 4 bytes") + parsed_version = int.from_bytes(global_kvs[b"\xfb"], "little") + if parsed_version == 2: + version = 2 + elif parsed_version == 0: + version = ( + None # explicit PSBT_GLOBAL_VERSION=0 is valid for v0 per BIP174 + ) + else: + raise PSBTError( + f"Unsupported PSBT_GLOBAL_VERSION value: {parsed_version}" + ) + + if version == 2: # PSBTv2 + if b"\x00" in global_kvs: # PSBT_GLOBAL_UNSIGNED_TX + raise PSBTError("PSBT_GLOBAL_UNSIGNED_TX is not allowed in PSBTv2") + # Pass all global KVs to unknown; __init__ calls parse_unknowns. + psbt = cls(tx=None, unknown=global_kvs, version=version) + + # Validate that input/output counts were processed by parse_unknowns + if psbt._raw_input_count_from_global is None: + raise PSBTError( + "PSBTv2 missing or invalid PSBT_GLOBAL_INPUT_COUNT (0x04)" + ) + if psbt._raw_output_count_from_global is None: + raise PSBTError( + "PSBTv2 missing or invalid PSBT_GLOBAL_OUTPUT_COUNT (0x05)" + ) + if psbt.tx_version is None: + raise PSBTError("PSBTv2 missing required PSBT_GLOBAL_TX_VERSION (0x02)") + else: # PSBTv0 (version is None or 0) + if b"\x00" not in global_kvs: + raise PSBTError("PSBT_GLOBAL_UNSIGNED_TX (0x00) is required for PSBTv0") + + # Check for forbidden v2-only global keys in v0 context. + # PSBT_GLOBAL_VERSION (0xfb) with value 0 is explicitly permitted by BIP174 + # and was already validated above, so it is excluded from this set. + v2_only_global_keys = {b"\x02", b"\x03", b"\x04", b"\x05", b"\x06"} + for k_v2_only in v2_only_global_keys: + if k_v2_only in global_kvs: raise PSBTError( - "Failed to parse PSBT - duplicated transaction field" + f"Global key {hexlify(k_v2_only).decode()} is not allowed in PSBTv0" ) - elif key == b"\xfb": - version = int.from_bytes(value, "little") - else: - if key in unknown: - raise PSBTError("Duplicated key") - unknown[key] = value - - if tx and version == 2: - raise PSBTError("Global TX field is not allowed in PSBTv2") - psbt = cls(tx, unknown, version=version) - # input scopes - for i, vin in enumerate(psbt.tx.vin): - psbt.inputs[i] = cls.PSBTIN_CLS.read_from( - stream, compress=compress, vin=vin - ) - # output scopes - for i, vout in enumerate(psbt.tx.vout): - psbt.outputs[i] = cls.PSBTOUT_CLS.read_from( - stream, compress=compress, vout=vout - ) + + tx_bytes = global_kvs.pop(b"\x00") # Remove so it's not in unknown + tx_for_v0 = cls.TX_CLS.parse(tx_bytes) + psbt = cls(tx=tx_for_v0, unknown=global_kvs, version=version) + + if version == 2: + num_inputs = psbt._raw_input_count_from_global + num_outputs = psbt._raw_output_count_from_global + + parsed_inputs = [] + for _ in range(num_inputs): + parsed_inputs.append( + cls.PSBTIN_CLS.read_from( + stream, + compress=compress, + vin=None, + version=version, + ) + ) + psbt.inputs = parsed_inputs + + for i, inp in enumerate(psbt.inputs): + if inp.txid is None: + raise PSBTError( + f"PSBTv2 input {i} missing required PSBT_IN_PREVIOUS_TXID (0x0e)" + ) + if inp.vout is None: + raise PSBTError( + f"PSBTv2 input {i} missing required PSBT_IN_OUTPUT_INDEX (0x0f)" + ) + + parsed_outputs = [] + for _ in range(num_outputs): + parsed_outputs.append( + cls.PSBTOUT_CLS.read_from( + stream, + compress=compress, + vout=None, + version=version, + ) + ) + psbt.outputs = parsed_outputs + + for i, out in enumerate(psbt.outputs): + cls._validate_v2_output(out, i) + else: + temp_inputs = [] + for i in range(len(psbt.inputs)): + temp_inputs.append( + cls.PSBTIN_CLS.read_from( + stream, + compress=compress, + vin=psbt.inputs[i].vin, + version=version, + ) + ) + psbt.inputs = temp_inputs + + temp_outputs = [] + for i in range(len(psbt.outputs)): + temp_outputs.append( + cls.PSBTOUT_CLS.read_from( + stream, + compress=compress, + vout=psbt.outputs[i].vout, + version=version, + ) + ) + psbt.outputs = temp_outputs + return psbt def parse_unknowns(self): + # Handle PSBT_GLOBAL_VERSION first + if b"\xfb" in self.unknown: + self.unknown.pop(b"\xfb", None) + + if b"\x06" in self.unknown: + flags_bytes = self.unknown.pop(b"\x06") + if len(flags_bytes) != 1: + raise PSBTError("PSBT_GLOBAL_TX_MODIFIABLE must be 1 byte") + self.tx_modifiable_flags = flags_bytes[0] + for k in list(self.unknown): # xpub field if k[0] == 0x01: xpub = bip32.HDKey.parse(k[1:]) self.xpubs[xpub] = DerivationPath.parse(self.unknown.pop(k)) elif k == b"\x02": - self.tx_version = int.from_bytes(self.unknown.pop(k), "little") + if self.version == 2: + v = self.unknown.pop(k) + if len(v) != 4: + raise PSBTError("PSBT_GLOBAL_TX_VERSION must be 4 bytes") + # BIP370: PSBT_GLOBAL_TX_VERSION is a signed int32 + self.tx_version = int.from_bytes(v, "little", signed=True) elif k == b"\x03": - self.locktime = int.from_bytes(self.unknown.pop(k), "little") + if self.version == 2: + v = self.unknown.pop(k) + if len(v) != 4: + raise PSBTError("PSBT_GLOBAL_FALLBACK_LOCKTIME must be 4 bytes") + self.locktime = int.from_bytes(v, "little") elif k == b"\x04": - if len(self.inputs) > 0: - raise PSBTError("Inputs already initialized") - self.inputs = [ - self.PSBTIN_CLS() - for _ in range(compact.from_bytes(self.unknown.pop(k))) - ] + if self.version == 2: + if self._raw_input_count_from_global is not None: + self.unknown.pop(k, None) + continue + self._raw_input_count_from_global = compact.from_bytes( + self.unknown.pop(k) + ) + # Store count only; do not pre-allocate objects to avoid + # attacker-controlled memory exhaustion. elif k == b"\x05": - if len(self.outputs) > 0: - raise PSBTError("Outputs already initialized") - self.outputs = [ - self.PSBTOUT_CLS() - for _ in range(compact.from_bytes(self.unknown.pop(k))) - ] + if self.version == 2: + if self._raw_output_count_from_global is not None: + self.unknown.pop(k, None) + continue + self._raw_output_count_from_global = compact.from_bytes( + self.unknown.pop(k) + ) + # Store count only; do not pre-allocate objects to avoid + # attacker-controlled memory exhaustion. def sighash(self, i, sighash=SIGHASH.ALL, **kwargs): inp = self.inputs[i] From e8a272ad09a22a184c68e819d54cc3e9c6650161 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Mon, 11 May 2026 15:58:06 +0530 Subject: [PATCH 18/61] psbt: add GLOBAL_TX_MODIFIABLE API, add_input/add_output, update sign_with - Update sign_with(): after each legacy/segwit signature, update tx_modifiable_flags per BIP-370: clear INPUTS bit if not ANYONECANPAY, clear OUTPUTS bit if not NONE, set SIGHASH_SINGLE bit if sighash type is SINGLE - Add get_tx_modifiable() / set_tx_modifiable(flags): getter/setter for PSBT_GLOBAL_TX_MODIFIABLE; setter raises PSBTError on PSBTv0 - Add is_inputs_modifiable(): True if version!=2, tx_modifiable_flags is None, or INPUTS bit is set - Add is_outputs_modifiable(): True if version!=2, tx_modifiable_flags is None, or OUTPUTS bit is set - Add has_sighash_single(): True only if version==2, tx_modifiable_flags is not None, and SIGHASH_SINGLE bit is set - Add add_input(input_scope): appends an InputScope, gated on is_inputs_modifiable(); updates _raw_input_count_from_global for PSBTv2 - Add add_output(output_scope): appends an OutputScope, gated on is_outputs_modifiable(); updates _raw_output_count_from_global for PSBTv2 --- src/embit/psbt.py | 117 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index cc25f093..5332d536 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -11,7 +11,6 @@ from binascii import b2a_base64, a2b_base64, hexlify, unhexlify from io import BytesIO - LOCKTIME_THRESHOLD = 500000000 @@ -1357,7 +1356,7 @@ def sign_with(self, root, sighash=SIGHASH.DEFAULT) -> int: if fingerprint: # if taproot derivations are present add them for pub in inp.taproot_bip32_derivations: - (_leafs, derivation) = inp.taproot_bip32_derivations[pub] + _leafs, derivation = inp.taproot_bip32_derivations[pub] if derivation.fingerprint == fingerprint: # Add only if not already present if (pub, derivation) not in bip32_derivations: @@ -1395,7 +1394,7 @@ def sign_with(self, root, sighash=SIGHASH.DEFAULT) -> int: if inp.is_taproot: # try to sign with individual private key (WIF) # or with root without derivations - counter += self.sign_input_with_tapkey( + tap_sigs = self.sign_input_with_tapkey( root, i, inp, @@ -1403,12 +1402,15 @@ def sign_with(self, root, sighash=SIGHASH.DEFAULT) -> int: ) # sign with all derived keys for prv, pub in derived_keypairs: - counter += self.sign_input_with_tapkey( + tap_sigs += self.sign_input_with_tapkey( prv, i, inp, sighash=inp_sighash, ) + if tap_sigs > 0: + counter += tap_sigs + self._update_tx_modifiable(inp_sighash) continue # hash can be reused @@ -1418,13 +1420,116 @@ def sign_with(self, root, sighash=SIGHASH.DEFAULT) -> int: # check if root itself is included in the script if sec in sc.data or pkh in sc.data: sig = root.sign(h) - # sig plus sighash flag inp.partial_sigs[rootpub] = sig.serialize() + bytes([inp_sighash]) counter += 1 + self._update_tx_modifiable(inp_sighash) for prv, pub in derived_keypairs: sig = prv.sign(h) - # sig plus sighash flag inp.partial_sigs[pub] = sig.serialize() + bytes([inp_sighash]) counter += 1 + self._update_tx_modifiable(inp_sighash) + return counter + + def _update_tx_modifiable(self, inp_sighash: int) -> None: + """ + Update PSBT_GLOBAL_TX_MODIFIABLE after a signature is added. + BIP-370 Signer role: signer must update this field to reflect + the constraints imposed by the sighash type used. + """ + if self.version != 2: + return + + is_anyonecanpay = bool(inp_sighash & SIGHASH.ANYONECANPAY) + sighash_type = inp_sighash & 0x1F + + if self.tx_modifiable_flags is None: + self.tx_modifiable_flags = 0 + + if not is_anyonecanpay: + self.tx_modifiable_flags &= ~TxModifiable.INPUTS + if sighash_type != SIGHASH.NONE: + self.tx_modifiable_flags &= ~TxModifiable.OUTPUTS + if sighash_type == SIGHASH.SINGLE: + self.tx_modifiable_flags |= TxModifiable.SIGHASH_SINGLE + + def get_tx_modifiable(self): + return self.tx_modifiable_flags + + def set_tx_modifiable(self, flags): + if self.version != 2: + raise PSBTError("GLOBAL_TX_MODIFIABLE only supported in PSBTv2") + self.tx_modifiable_flags = flags + + def is_inputs_modifiable(self): + if self.version != 2: + return True + if self.tx_modifiable_flags is None: + return False + return bool(self.tx_modifiable_flags & TxModifiable.INPUTS) + + def is_outputs_modifiable(self): + if self.version != 2: + return True + if self.tx_modifiable_flags is None: + return False + return bool(self.tx_modifiable_flags & TxModifiable.OUTPUTS) + + def has_sighash_single(self): + if self.version != 2 or self.tx_modifiable_flags is None: + return False + return bool(self.tx_modifiable_flags & TxModifiable.SIGHASH_SINGLE) + + @classmethod + def create_v2(cls, tx_version=2, fallback_locktime=None, tx_modifiable=None): + """Creator role convenience factory: initialise an empty PSBTv2. + + BIP-370 Creator role: set PSBT_GLOBAL_VERSION=2, PSBT_GLOBAL_TX_VERSION, + and optionally PSBT_GLOBAL_FALLBACK_LOCKTIME and PSBT_GLOBAL_TX_MODIFIABLE + so the Constructor role can immediately begin adding inputs and outputs. + """ + if tx_modifiable is None: + tx_modifiable = TxModifiable.INPUTS | TxModifiable.OUTPUTS + psbt = cls(tx=None, version=2) + psbt.tx_version = tx_version + psbt.locktime = fallback_locktime + psbt.tx_modifiable_flags = tx_modifiable + return psbt + + def add_input(self, input_scope): + if not self.is_inputs_modifiable(): + raise PSBTError("Inputs are not modifiable") + if self.version == 2: + # BIP370 Constructor role: validate required fields + if input_scope.txid is None: + raise PSBTError("PSBTv2 input must have PSBT_IN_PREVIOUS_TXID") + if input_scope.vout is None: + raise PSBTError("PSBTv2 input must have PSBT_IN_OUTPUT_INDEX") + # Validate locktime compatibility before mutating state + if ( + input_scope.required_height_locktime is not None + or input_scope.required_time_locktime is not None + ): + self._validate_locktime_compatibility(self.inputs + [input_scope]) + # SIGHASH_SINGLE: each added input must have a paired output + if self.has_sighash_single() and len(self.inputs) >= len(self.outputs): + raise PSBTError( + "Cannot add input without a corresponding output when SIGHASH_SINGLE is set" + ) + self.inputs.append(input_scope) + if self.version == 2: + self._raw_input_count_from_global = len(self.inputs) + + def add_output(self, output_scope): + if not self.is_outputs_modifiable(): + raise PSBTError("Outputs are not modifiable") + if self.version == 2: + # BIP370 Constructor role: validate required fields + if output_scope.value is None: + raise PSBTError("PSBTv2 output must have PSBT_OUT_AMOUNT") + if output_scope.script_pubkey is None: + raise PSBTError("PSBTv2 output must have PSBT_OUT_SCRIPT") + self.outputs.append(output_scope) + if self.version == 2: + self._raw_output_count_from_global = len(self.outputs) From 8699ecfa7e337921552d15d79c0c011fd662cb6c Mon Sep 17 00:00:00 2001 From: notTanveer Date: Mon, 11 May 2026 16:01:24 +0530 Subject: [PATCH 19/61] tests: add BIP-370 PSBTv2 test vectors Add test_psbtV2.py with 48 tests covering PSBTv2 parsing, serialization, locktime selection, TxModifiable flags, and add_input/add_output API. --- tests/tests/test_psbtV2.py | 583 +++++++++++++++++++++++++++++++++++++ 1 file changed, 583 insertions(+) create mode 100644 tests/tests/test_psbtV2.py diff --git a/tests/tests/test_psbtV2.py b/tests/tests/test_psbtV2.py new file mode 100644 index 00000000..a296299f --- /dev/null +++ b/tests/tests/test_psbtV2.py @@ -0,0 +1,583 @@ +""" +BIP 370 Test Vectors for PSBTv2 +https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#user-content-Test_Vectors +""" + +import pytest +from binascii import unhexlify +from io import BytesIO +from embit.psbt import PSBT, PSBTError, TxModifiable, InputScope, OutputScope +from embit.psbtview import PSBTView +from embit.script import Script +from embit.transaction import SIGHASH + + +class TestPSBTVectors: + """Test vectors for PSBT parsing and validation""" + + def test_invalid_psbtv0_with_version_field(self): + """PSBTv0 but with PSBT_GLOBAL_VERSION set to 2 should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_tx_version(self): + """PSBTv0 but with PSBT_GLOBAL_TX_VERSION should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001020402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_fallback_locktime(self): + """PSBTv0 but with PSBT_GLOBAL_FALLBACK_LOCKTIME should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001030402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_input_count(self): + """PSBTv0 but with PSBT_GLOBAL_INPUT_COUNT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001040102000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_count(self): + """PSBTv0 but with PSBT_GLOBAL_OUTPUT_COUNT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001050102000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_tx_modifiable(self): + """PSBTv0 but with PSBT_GLOBAL_TX_MODIFIABLE should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001060100000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_previous_txid(self): + """PSBTv0 but with PSBT_IN_PREVIOUS_TXID should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc800220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_index(self): + """PSBTv0 but with PSBT_IN_OUTPUT_INDEX should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_sequence(self): + """PSBTv0 but with PSBT_IN_SEQUENCE should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27011004ffffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_required_time_locktime(self): + """PSBTv0 but with PSBT_IN_REQUIRED_TIME_LOCKTIME should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a270111048c8dc46200220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_required_height_locktime(self): + """PSBTv0 but with PSBT_IN_REQUIRED_HEIGHT_LOCKTIME should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a270112041027000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_amount(self): + """PSBTv0 but with PSBT_OUT_AMOUNT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f00000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_script(self): + """PSBTv0 but with PSBT_OUT_SCRIPT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000104160014a07dac8ab6ca942d379ed795f835ba71c9cc6885002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_global_unsigned_tx(self): + """PSBTv2 with PSBT_GLOBAL_UNSIGNED_TX should be invalid""" + hex_data = "70736274ff0100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e00000000010204020000000103040000000001040101010501020106010701fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff0111048c8dc4620112041027000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_input_count(self): + """PSBTv2 missing PSBT_GLOBAL_INPUT_COUNT should be invalid""" + hex_data = "70736274ff01020402000000010304000000000105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_output_count(self): + """PSBTv2 missing PSBT_GLOBAL_OUTPUT_COUNT should be invalid""" + hex_data = "70736274ff01020402000000010304000000000104010101fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_tx_version(self): + """PSBTv2 missing PSBT_GLOBAL_TX_VERSION should be invalid""" + hex_data = "70736274ff010401010105010201fb040200000000010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_previous_txid(self): + """PSBTv2 input missing PSBT_IN_PREVIOUS_TXID should be invalid""" + hex_data = "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_output_index(self): + """PSBTv2 input missing PSBT_IN_OUTPUT_INDEX should be invalid""" + hex_data = "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_output_amount(self): + """PSBTv2 output missing PSBT_OUT_AMOUNT should be invalid""" + hex_data = "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_output_script(self): + """PSBTv2 output missing PSBT_OUT_SCRIPT should be invalid""" + hex_data = "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f0000000000220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_invalid_time_locktime(self): + """PSBTv2 with PSBT_IN_REQUIRED_TIME_LOCKTIME less than 500000000 should be invalid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011104ff64cd1d00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_invalid_height_locktime(self): + """PSBTv2 with PSBT_IN_REQUIRED_HEIGHT_LOCKTIME >= 500000000 should be invalid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f04000000000112040065cd1d00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_height_locktime_zero(self): + """PSBTv2 with PSBT_IN_REQUIRED_HEIGHT_LOCKTIME == 0 should be invalid""" + hex_data = "70736274ff010204020000000103040000000001040101010501020106010701fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff0111048c8dc4620112040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + # Valid PSBTs + def test_valid_psbtv2_minimal(self): + """1 input, 2 output PSBTv2, required fields only should be valid""" + hex_data = "70736274ff01020402000000010401010105010201fb040200000000010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + + def test_valid_psbtv2_updated(self): + """1 input, 2 output updated PSBTv2 should be valid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + + def test_valid_psbtv2_with_sequence(self): + """1 input, 2 output PSBTv2 with sequence should be valid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert psbt.inputs[0].sequence == 0xFFFFFFFE + + def test_valid_psbtv2_with_sequence_and_locktime(self): + """PSBTv2 with all possible fields should be valid, including sequence""" + hex_data = "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff0111048c8dc4620112041027000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert psbt.inputs[0].sequence == 0xFFFFFFFE + assert psbt.inputs[0].required_time_locktime == 1657048460 + assert psbt.inputs[0].required_height_locktime == 10000 + + def test_inputs_modifiable_flag(self): + """Inputs Modifiable Flag (bit 0) only""" + hex_data = "70736274ff0102040200000001040101010501020106010101fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x01 + assert psbt.is_inputs_modifiable() + assert not psbt.is_outputs_modifiable() + assert not psbt.has_sighash_single() + + def test_outputs_modifiable_flag(self): + """Outputs Modifiable Flag (bit 1) only""" + hex_data = "70736274ff0102040200000001040101010501020106010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x02 + assert not psbt.is_inputs_modifiable() + assert psbt.is_outputs_modifiable() + assert not psbt.has_sighash_single() + + def test_sighash_single_flag(self): + """Has SIGHASH_SINGLE Flag (bit 2) only""" + hex_data = "70736274ff0102040200000001040101010501020106010401fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x04 + assert not psbt.is_inputs_modifiable() + assert not psbt.is_outputs_modifiable() + assert psbt.has_sighash_single() + + def test_undefined_flag_bit3(self): + """Undefined flag (bit 3) — parser must not reject it""" + hex_data = "70736274ff0102040200000001040101010501020106010801fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x08 + # bits 0/1/2 are all clear + assert not psbt.is_inputs_modifiable() + assert not psbt.is_outputs_modifiable() + assert not psbt.has_sighash_single() + + def test_inputs_and_outputs_modifiable(self): + """Inputs Modifiable (bit 0) + Outputs Modifiable (bit 1)""" + hex_data = "70736274ff0102040200000001040101010501020106010301fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x03 + assert psbt.is_inputs_modifiable() + assert psbt.is_outputs_modifiable() + assert not psbt.has_sighash_single() + + def test_inputs_modifiable_and_sighash_single(self): + """Inputs Modifiable (bit 0) + Has SIGHASH_SINGLE (bit 2)""" + hex_data = "70736274ff0102040200000001040101010501020106010501fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x05 + assert psbt.is_inputs_modifiable() + assert not psbt.is_outputs_modifiable() + assert psbt.has_sighash_single() + + def test_outputs_modifiable_and_sighash_single(self): + """Outputs Modifiable (bit 1) + Has SIGHASH_SINGLE (bit 2)""" + hex_data = "70736274ff0102040200000001040101010501020106010601fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x06 + assert not psbt.is_inputs_modifiable() + assert psbt.is_outputs_modifiable() + assert psbt.has_sighash_single() + + def test_all_defined_flags(self): + """All defined flags set: Inputs (bit 0) + Outputs (bit 1) + SIGHASH_SINGLE (bit 2)""" + hex_data = "70736274ff0102040200000001040101010501020106010701fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0x07 + assert psbt.is_inputs_modifiable() + assert psbt.is_outputs_modifiable() + assert psbt.has_sighash_single() + + def test_all_possible_flags(self): + """All 8 bits set (0xFF) — defined + undefined flags""" + hex_data = "70736274ff010204020000000104010101050102010601ff01fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + assert psbt.tx_modifiable_flags == 0xFF + assert psbt.is_inputs_modifiable() + assert psbt.is_outputs_modifiable() + assert psbt.has_sighash_single() + # undefined bits preserved as-is + assert psbt.tx_modifiable_flags & ~( + TxModifiable.INPUTS | TxModifiable.OUTPUTS | TxModifiable.SIGHASH_SINGLE + ) + + def test_valid_psbtv2_with_all_fields(self): + """PSBTv2 with all possible fields should be valid""" + hex_data = "70736274ff010204020000000103040000000001040101010501020106010701fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff0111048c8dc4620112041027000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert psbt.inputs[0].required_time_locktime == 1657048460 + assert psbt.inputs[0].required_height_locktime == 10000 + + +class TestPSBTLocktime: + """Test locktime determination algorithm for PSBTv2""" + + def test_locktime_no_locktimes_specified(self): + """No locktimes specified should return 0""" + hex_data = "70736274ff01020402000000010401010105010201fb040200000000010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 0 + + def test_locktime_fallback_zero(self): + """Fallback locktime of 0 should return 0""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f040100000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f0400000000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 0 + + def test_locktime_height_10000(self): + """Height locktime of 10000 should return 10000""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f0400000000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 10000 + + def test_locktime_max_height(self): + """Multiple height locktimes should return maximum""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f040000000001120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert len(psbt.inputs) == 2 + assert psbt.determine_locktime() == 10000 # max(10000, 9000) + + def test_locktime_height_and_time(self): + """1 input with Height and other with height and time locktimes should return max height value""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc46201120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert len(psbt.inputs) == 2 + assert psbt.determine_locktime() == 10000 + + def test_locktime_height_preferred_over_time(self): + """Height locktime should be preferred when both present (BIP-370: height preferred on tie)""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000111048b8dc4620112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc46201120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert len(psbt.inputs) == 2 + assert psbt.determine_locktime() == 10000 # Height takes priority over time + + def test_locktime_time_only(self): + """Time locktime only should return time value""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000111048b8dc46200010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc46201120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert len(psbt.inputs) == 2 + assert psbt.determine_locktime() == 1657048460 + + def test_locktime_max_time(self): + """Multiple time locktimes should return maximum""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f040100000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc462000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert len(psbt.inputs) == 2 + assert psbt.determine_locktime() == 1657048460 + + def test_locktime_time_wins_when_not_all_support_height(self): + """Input 1 has height 10000 and time 1657048459, Input 2 has time 1657048460 only should return max time 1657048460""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000111048b8dc4620112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc462000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert len(psbt.inputs) == 2 + assert psbt.determine_locktime() == 1657048460 + + def test_locktime_uncomputable(self): + """Input with height only and input with time only should raise PSBTError""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc462000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert len(psbt.inputs) == 2 + with pytest.raises(PSBTError): + psbt.determine_locktime() + + +class TestPSBTv2Constructor: + """Test BIP-370 Creator / Constructor role: create_v2(), add_input(), add_output()""" + + def test_create_v2_defaults(self): + """create_v2() produces a version-2 PSBT with sensible defaults""" + psbt = PSBT.create_v2() + assert psbt.version == 2 + assert psbt.tx_version == 2 + assert psbt.locktime is None + assert psbt.tx_modifiable_flags == TxModifiable.INPUTS | TxModifiable.OUTPUTS + assert len(psbt.inputs) == 0 + assert len(psbt.outputs) == 0 + + def test_create_v2_custom_params(self): + """create_v2() accepts custom tx_version, fallback_locktime and tx_modifiable""" + psbt = PSBT.create_v2( + tx_version=1, + fallback_locktime=600000, + tx_modifiable=TxModifiable.INPUTS, + ) + assert psbt.tx_version == 1 + assert psbt.locktime == 600000 + assert psbt.tx_modifiable_flags == TxModifiable.INPUTS + + def test_add_input_appends_and_updates_count(self): + """add_input() appends the scope and increments the global input count""" + psbt = PSBT.create_v2() + inp = InputScope() + inp.txid = bytes(32) + inp.vout = 0 + psbt.add_input(inp) + assert len(psbt.inputs) == 1 + assert psbt._raw_input_count_from_global == 1 + + def test_add_input_requires_inputs_modifiable(self): + """add_input() raises when INPUTS bit is clear""" + psbt = PSBT.create_v2(tx_modifiable=TxModifiable.OUTPUTS) + inp = InputScope() + inp.txid = bytes(32) + inp.vout = 0 + with pytest.raises(PSBTError): + psbt.add_input(inp) + + def test_add_input_requires_txid(self): + """add_input() raises when PSBT_IN_PREVIOUS_TXID is absent""" + psbt = PSBT.create_v2() + inp = InputScope() + inp.vout = 0 + with pytest.raises(PSBTError): + psbt.add_input(inp) + + def test_add_input_requires_vout(self): + """add_input() raises when PSBT_IN_OUTPUT_INDEX is absent""" + psbt = PSBT.create_v2() + inp = InputScope() + inp.txid = bytes(32) + with pytest.raises(PSBTError): + psbt.add_input(inp) + + def test_add_output_appends_and_updates_count(self): + """add_output() appends the scope and increments the global output count""" + psbt = PSBT.create_v2() + out = OutputScope() + out.value = 100000 + out.script_pubkey = Script(b"\x00\x14" + bytes(20)) + psbt.add_output(out) + assert len(psbt.outputs) == 1 + assert psbt._raw_output_count_from_global == 1 + + def test_add_output_requires_outputs_modifiable(self): + """add_output() raises when OUTPUTS bit is clear""" + psbt = PSBT.create_v2(tx_modifiable=TxModifiable.INPUTS) + out = OutputScope() + out.value = 100000 + out.script_pubkey = Script(b"\x00\x14" + bytes(20)) + with pytest.raises(PSBTError): + psbt.add_output(out) + + def test_add_output_requires_amount(self): + """add_output() raises when PSBT_OUT_AMOUNT is absent""" + psbt = PSBT.create_v2() + out = OutputScope() + out.script_pubkey = Script(b"\x00\x14" + bytes(20)) + with pytest.raises(PSBTError): + psbt.add_output(out) + + def test_add_output_requires_script(self): + """add_output() raises when PSBT_OUT_SCRIPT is absent""" + psbt = PSBT.create_v2() + out = OutputScope() + out.value = 100000 + with pytest.raises(PSBTError): + psbt.add_output(out) + + +class TestTxModifiableUpdate: + """Test BIP-370 Signer role: _update_tx_modifiable() correctly restricts future modifications""" + + def test_sighash_all_clears_inputs_and_outputs(self): + """SIGHASH_ALL (not ANYONECANPAY) must clear both INPUTS and OUTPUTS bits""" + psbt = PSBT.create_v2() + psbt.tx_modifiable_flags = TxModifiable.INPUTS | TxModifiable.OUTPUTS + psbt._update_tx_modifiable(SIGHASH.ALL) + assert not psbt.is_inputs_modifiable() + assert not psbt.is_outputs_modifiable() + assert not psbt.has_sighash_single() + + def test_sighash_none_clears_inputs_preserves_outputs(self): + """SIGHASH_NONE clears INPUTS bit but must not touch the OUTPUTS bit""" + psbt = PSBT.create_v2() + psbt.tx_modifiable_flags = TxModifiable.INPUTS | TxModifiable.OUTPUTS + psbt._update_tx_modifiable(SIGHASH.NONE) + assert not psbt.is_inputs_modifiable() + assert psbt.is_outputs_modifiable() + + def test_sighash_single_sets_flag_and_clears_inputs(self): + """SIGHASH_SINGLE sets SIGHASH_SINGLE flag and clears INPUTS and OUTPUTS bits""" + psbt = PSBT.create_v2() + psbt.tx_modifiable_flags = TxModifiable.INPUTS | TxModifiable.OUTPUTS + psbt._update_tx_modifiable(SIGHASH.SINGLE) + assert not psbt.is_inputs_modifiable() + assert not psbt.is_outputs_modifiable() + assert psbt.has_sighash_single() + + def test_sighash_all_anyonecanpay_preserves_inputs_clears_outputs(self): + """SIGHASH_ALL|ANYONECANPAY preserves INPUTS bit and clears OUTPUTS bit""" + psbt = PSBT.create_v2() + psbt.tx_modifiable_flags = TxModifiable.INPUTS | TxModifiable.OUTPUTS + psbt._update_tx_modifiable(SIGHASH.ALL | SIGHASH.ANYONECANPAY) + assert psbt.is_inputs_modifiable() + assert not psbt.is_outputs_modifiable() + + def test_update_creates_flags_when_absent(self): + """_update_tx_modifiable initialises the field to 0 when it was absent""" + psbt = PSBT.create_v2() + psbt.tx_modifiable_flags = None + psbt._update_tx_modifiable(SIGHASH.ALL) + assert psbt.tx_modifiable_flags == 0 + + def test_no_op_for_psbtv0(self): + """_update_tx_modifiable is a no-op for PSBTv0 (PSBT_GLOBAL_TX_MODIFIABLE is v2-only)""" + psbt = PSBT.create_v2() + psbt.version = None # downgrade to v0 + psbt.tx_modifiable_flags = TxModifiable.INPUTS | TxModifiable.OUTPUTS + psbt._update_tx_modifiable(SIGHASH.ALL) + # must remain untouched because version != 2 + assert psbt.tx_modifiable_flags == TxModifiable.INPUTS | TxModifiable.OUTPUTS From c870c8a2a61f532e7164f1c5f9baf3ba1b1d16d8 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Wed, 13 May 2026 23:26:53 +0530 Subject: [PATCH 20/61] psbtview: add PSBTv2 support --- src/embit/psbtview.py | 204 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 180 insertions(+), 24 deletions(-) diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index 75b35e28..99a536f5 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -13,6 +13,7 @@ Makes sense to run gc.collect() after processing of each scope to free memory. """ + # TODO: refactor, a lot of code is duplicated here from transaction.py from collections import OrderedDict import hashlib @@ -26,6 +27,8 @@ CompressMode, InputScope, OutputScope, + TxModifiable, + LOCKTIME_THRESHOLD, read_string, ser_string, skip_string, @@ -180,6 +183,7 @@ def __init__( version=None, tx_offset=None, compress=CompressMode.KEEP_ALL, + global_kvs=None, ): if version != 2 and tx_offset is None: raise PSBTError("Global tx is not found, but PSBT version is %d" % version) @@ -196,8 +200,35 @@ def __init__( self.compress = compress self._tx_version = self.tx.version if self.tx else None self._locktime = self.tx.locktime if self.tx else None + # For PSBTv2: dict of all global key→value pairs (excluding the \x00 global tx). + # This is the single source of truth for every global field, including + # PSBT_GLOBAL_TX_MODIFIABLE (\x06). None for PSBTv0 where the global + # scope is streamed verbatim. + self._global_kvs = global_kvs self.clear_cache() + @property + def tx_modifiable_flags(self): + """PSBT_GLOBAL_TX_MODIFIABLE byte (PSBTv2 only). None when absent or for PSBTv0.""" + if self._global_kvs is None: + return None + v = self._global_kvs.get(b"\x06") + if v is None: + return None + if len(v) != 1: + raise PSBTError("Invalid PSBT_GLOBAL_TX_MODIFIABLE length") + return int.from_bytes(v, "little") + + @tx_modifiable_flags.setter + def tx_modifiable_flags(self, value): + """Setting to None removes the field; ignored for PSBTv0.""" + if self._global_kvs is None: + return + if value is None: + self._global_kvs.pop(b"\x06", None) + else: + self._global_kvs[b"\x06"] = bytes([value]) + def clear_cache(self): # cache for digests self._hash_prevouts = None @@ -222,6 +253,9 @@ def view(cls, stream, offset=None, compress=CompressMode.KEEP_ALL): num_inputs = None num_outputs = None tx_offset = None + # Collect all non-global-tx key-value pairs for PSBTv2 global scope rewriting. + # The global scope is small, so materialising it avoids byte-level injection. + global_kvs = {} while True: # read key and update cursor key = read_string(stream) @@ -229,19 +263,10 @@ def view(cls, stream, offset=None, compress=CompressMode.KEEP_ALL): # separator if len(key) == 0: break - if key in [b"\xfb", b"\x04", b"\x05"]: - value = read_string(stream) - cur += len(value) + len(compact.to_bytes(len(value))) - if key == b"\xfb": - version = int.from_bytes(value, "little") - elif key == b"\x04": - num_inputs = compact.from_bytes(value) - elif key == b"\x05": - num_outputs = compact.from_bytes(value) - elif key == b"\x00": - # we found global transaction - if version == 2: - raise PSBTError("Global transaction with version 2 PSBT") + if key == b"\x00": + # we found global transaction; defer version==2 check until after the loop + # so that PSBT_GLOBAL_UNSIGNED_TX is rejected even when it appears before + # PSBT_GLOBAL_VERSION (key order is not guaranteed). if (num_inputs is not None) or (num_outputs is not None): raise PSBTError("Invalid global transaction") tx_len = compact.read_from(stream) @@ -254,8 +279,21 @@ def view(cls, stream, offset=None, compress=CompressMode.KEEP_ALL): stream.seek(tx_offset + tx_len) cur += tx_len else: - cur += skip_string(stream) + value = read_string(stream) + cur += len(value) + len(compact.to_bytes(len(value))) + if key in global_kvs: + raise PSBTError("Duplicate global key: %s" % key) + global_kvs[key] = value + if key == b"\xfb": + version = int.from_bytes(value, "little") + elif key == b"\x04": + num_inputs = compact.from_bytes(value) + elif key == b"\x05": + num_outputs = compact.from_bytes(value) first_scope = cur + # PSBTv2 must not have a global unsigned transaction, regardless of key order + if tx_offset is not None and version == 2: + raise PSBTError("Global transaction with version 2 PSBT") if None in [version or tx_offset, num_inputs, num_outputs]: raise PSBTError("Missing something important in PSBT") return cls( @@ -267,6 +305,7 @@ def view(cls, stream, offset=None, compress=CompressMode.KEEP_ALL): version, tx_offset, compress, + global_kvs=global_kvs if version == 2 else None, ) def _skip_scope(self): @@ -345,7 +384,7 @@ def vin(self, i, compress=None): vout = int.from_bytes(v, "little") self.seek_to_scope(i) - v = self.get_value(b"\x10", from_current=True) or b"\xFF\xFF\xFF\xFF" + v = self.get_value(b"\x10", from_current=True) or b"\xff\xff\xff\xff" sequence = int.from_bytes(v, "little") return TransactionInput(txid, vout, sequence=sequence) @@ -370,15 +409,92 @@ def vout(self, i, compress=None): @property def locktime(self): if self._locktime is None: - v = self.get_value(b"\x03") - self._locktime = int.from_bytes(v, "little") if v is not None else 0 + if self.version == 2: + self._locktime = self._determine_locktime_v2() + else: + v = self.get_value(b"\x03") + self._locktime = int.from_bytes(v, "little") if v is not None else 0 return self._locktime + def _determine_locktime_v2(self): + """BIP370 locktime determination for PSBTv2. + + Derives the transaction locktime from per-input required locktime fields. + Falls back to PSBT_GLOBAL_FALLBACK_LOCKTIME (or 0) when no input imposes + a requirement. + """ + v = self.get_value(b"\x03") + if v is not None: + if len(v) != 4: + raise PSBTError("PSBT_GLOBAL_FALLBACK_LOCKTIME must be 4 bytes") + fallback = int.from_bytes(v, "little") + else: + fallback = 0 + + height_locktimes = [] + time_locktimes = [] + inputs_with_requirements = 0 + + for i in range(self.num_inputs): + self.seek_to_scope(i) + v_height = self.get_value(b"\x12", from_current=True) + self.seek_to_scope(i) + v_time = self.get_value(b"\x11", from_current=True) + + has_requirement = False + if v_height is not None: + if len(v_height) != 4: + raise PSBTError("PSBT_IN_REQUIRED_HEIGHT_LOCKTIME must be 4 bytes") + height_locktime = int.from_bytes(v_height, "little") + if height_locktime == 0 or height_locktime >= LOCKTIME_THRESHOLD: + raise PSBTError( + f"Height-based locktime must be > 0 and < {LOCKTIME_THRESHOLD}" + ) + height_locktimes.append(height_locktime) + has_requirement = True + if v_time is not None: + if len(v_time) != 4: + raise PSBTError("PSBT_IN_REQUIRED_TIME_LOCKTIME must be 4 bytes") + time_locktime = int.from_bytes(v_time, "little") + if time_locktime < LOCKTIME_THRESHOLD: + raise PSBTError( + f"Time-based locktime must be >= {LOCKTIME_THRESHOLD}" + ) + time_locktimes.append(time_locktime) + has_requirement = True + if has_requirement: + inputs_with_requirements += 1 + + if inputs_with_requirements == 0: + return fallback + + inputs_supporting_height = len(height_locktimes) + inputs_supporting_time = len(time_locktimes) + + # Prefer height-based locktime if every input with requirements supports it + if inputs_supporting_height == inputs_with_requirements: + return max(height_locktimes) + # Fall back to time-based locktime if every input with requirements supports it + if inputs_supporting_time == inputs_with_requirements: + return max(time_locktimes) + + raise PSBTError( + "Cannot determine locktime: inputs have conflicting height and time locktime requirements" + ) + @property def tx_version(self): if self._tx_version is None: v = self.get_value(b"\x02") - self._tx_version = int.from_bytes(v, "little") if v is not None else 0 + if v is None: + if self.version == 2: + raise PSBTError("Missing PSBT_GLOBAL_TX_VERSION in PSBTv2") + self._tx_version = 0 + else: + if len(v) != 4: + raise PSBTError("Invalid PSBT_GLOBAL_TX_VERSION length") + # BIP370: PSBT_GLOBAL_TX_VERSION is a signed int32 + self._tx_version = int.from_bytes(v, "little", signed=True) return self._tx_version def seek_to_value(self, key_start, from_current=False): @@ -468,7 +584,7 @@ def sighash_taproot( sh, anyonecanpay = SIGHASH.check(sighash) h = hashes.tagged_hash_init("TapSighash", b"\x00") h.update(bytes([sighash])) - h.update(self.tx_version.to_bytes(4, "little")) + h.update(self.tx_version.to_bytes(4, "little", signed=True)) h.update(self.locktime.to_bytes(4, "little")) if not anyonecanpay: h.update(self.hash_prevouts()) @@ -516,7 +632,7 @@ def sighash_segwit(self, input_index, script_pubkey, value, sighash=SIGHASH.ALL) inp = self.vin(input_index) zero = b"\x00" * 32 # for sighashes h = hashlib.sha256() - h.update(self.tx_version.to_bytes(4, "little")) + h.update(self.tx_version.to_bytes(4, "little", signed=True)) if anyonecanpay: h.update(zero) else: @@ -556,7 +672,7 @@ def sighash_legacy(self, input_index, script_pubkey, sighash=SIGHASH.ALL): return b"\x00" * 31 + b"\x01" h = hashlib.sha256() - h.update(self.tx_version.to_bytes(4, "little")) + h.update(self.tx_version.to_bytes(4, "little", signed=True)) # ANYONECANPAY - only one input is serialized if anyonecanpay: h.update(compact.to_bytes(1)) @@ -690,6 +806,23 @@ def sign_input_with_tapkey( counter += 1 return counter + def _update_tx_modifiable(self, inp_sighash: int) -> None: + if self.version != 2: + return + + is_anyonecanpay = bool(inp_sighash & SIGHASH.ANYONECANPAY) + sighash_type = inp_sighash & 0x1F + + if self.tx_modifiable_flags is None: + self.tx_modifiable_flags = 0 + + if not is_anyonecanpay: + self.tx_modifiable_flags &= ~TxModifiable.INPUTS + if sighash_type != SIGHASH.NONE: + self.tx_modifiable_flags &= ~TxModifiable.OUTPUTS + if sighash_type == SIGHASH.SINGLE: + self.tx_modifiable_flags |= TxModifiable.SIGHASH_SINGLE + def sign_input( self, i, root, sig_stream, sighash=SIGHASH.DEFAULT, extra_scope_data=None ) -> int: @@ -753,7 +886,7 @@ def sign_input( if fingerprint: # if taproot derivations are present add them for pub in inp.taproot_bip32_derivations: - (_leafs, derivation) = inp.taproot_bip32_derivations[pub] + _leafs, derivation = inp.taproot_bip32_derivations[pub] if derivation.fingerprint == fingerprint: # Add only if not already present if (pub, derivation) not in bip32_derivations: @@ -813,6 +946,8 @@ def sign_input( for pub, leaf in inp.taproot_sigs: ser_string(sig_stream, b"\x14" + pub.xonly() + leaf) ser_string(sig_stream, inp.taproot_sigs[(pub, leaf)]) + if counter > 0: + self._update_tx_modifiable(inp_sighash) return counter h = self.sighash(i, sighash=inp_sighash, input_scope=inp) @@ -824,11 +959,13 @@ def sign_input( # sig plus sighash flag inp.partial_sigs[rootpub] = sig.serialize() + bytes([inp_sighash]) counter += 1 + self._update_tx_modifiable(inp_sighash) for prv, pub in derived_keypairs: sig = prv.sign(h) # sig plus sighash flag inp.partial_sigs[pub] = sig.serialize() + bytes([inp_sighash]) counter += 1 + self._update_tx_modifiable(inp_sighash) for pub in inp.partial_sigs: ser_string(sig_stream, b"\x02" + pub.serialize()) ser_string(sig_stream, inp.partial_sigs[pub]) @@ -856,6 +993,11 @@ def sign_with(self, root, sig_stream, sighash=SIGHASH.DEFAULT) -> int: counter += self.sign_input(i, root, sig_stream, sighash=sighash) # add separator sig_stream.write(b"\x00") + # BIP-370: signer must set PSBT_GLOBAL_TX_MODIFIABLE for PSBTv2. + # Default to 0 (nothing modifiable) when no signing occurred and the + # field was absent — avoids leaving the field missing after a sign pass. + if self.version == 2 and self.tx_modifiable_flags is None: + self.tx_modifiable_flags = 0 return counter def write_to( @@ -879,8 +1021,22 @@ def write_to( compress = self.compress # first we write global scope - self.stream.seek(self.offset) - res = read_write(self.stream, writable_stream, self.first_scope - self.offset) + if self._global_kvs is not None: + # PSBTv2: reconstruct global scope from the materialised key-value dict. + # This sidesteps byte-level injection and gives deterministic key ordering. + writable_stream.write(self.MAGIC) + res = len(self.MAGIC) + for k in sorted(self._global_kvs.keys()): + res += ser_string(writable_stream, k) + res += ser_string(writable_stream, self._global_kvs[k]) + writable_stream.write(b"\x00") # global scope separator + res += 1 + else: + # PSBTv0: stream global scope directly from source (includes global tx). + self.stream.seek(self.offset) + res = read_write( + self.stream, writable_stream, self.first_scope - self.offset + ) # write all inputs for i in range(self.num_inputs): From b4da2cdd3405e59e0193cda30c929ccad10e926d Mon Sep 17 00:00:00 2001 From: odudex Date: Thu, 14 May 2026 10:21:51 -0300 Subject: [PATCH 21/61] fix: tighten PSBTv2 review follow-ups - reject BIP370-only globals in PSBTView v0 parsing - preserve signed and zero tx versions when synthesizing transactions - serialize transaction versions as signed int32 - keep PSBTv2 compressed non-witness UTXO parsing key-order independent - add regression coverage for the follow-up findings --- src/embit/psbt.py | 24 +++++++----- src/embit/psbtview.py | 13 +++++++ src/embit/transaction.py | 12 +++--- tests/tests/test_psbtV2.py | 77 +++++++++++++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 5332d536..7c4afa31 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -270,7 +270,7 @@ def read_value(self, stream, k, version=None): elif self.non_witness_utxo is not None: raise PSBTError("Duplicated utxo value") else: - l = compact.read_from(stream) + compact.read_from(stream) # For PSBTv2, PSBT_IN_OUTPUT_INDEX may follow PSBT_IN_NON_WITNESS_UTXO; # use the pre-scanned vout (set by read_from) when the field hasn't # been parsed yet so the OOM protection is key-order independent. @@ -280,7 +280,7 @@ def read_value(self, stream, k, version=None): else getattr(self, "_prescan_vout", None) ) # we verified and saved utxo - if self.compress and self.txid and effective_vout is not None: + if self.compress and effective_vout is not None: txout, txhash = self.TX_CLS.read_vout(stream, effective_vout) self._txhash = txhash self._utxo = txout @@ -577,9 +577,9 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: @classmethod def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vin=None, version=None): res = cls({}, vin=vin, compress=compress) - # For PSBTv2 with compress mode, pre-scan for PSBT_IN_OUTPUT_INDEX so the - # non-witness UTXO memory optimisation works regardless of key order. - # (BIP370 does not guarantee that 0x0f appears before 0x00.) + # PSBT maps are unordered, so PSBTv2 input maps may provide the output + # index after the non-witness UTXO. Peek only at compact-sized keys and + # skip values to keep compressed parsing independent of key order. res._prescan_vout = None if version == 2 and compress and res.vout is None: try: @@ -588,10 +588,15 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vin=None, version=Non k = read_string(stream) if len(k) == 0: break - v = read_string(stream) - if k == b"\x0f" and len(v) == 4: - res._prescan_vout = int.from_bytes(v, "little") + if k == b"\x0f": + v = read_string(stream) + if len(v) == 4: + res._prescan_vout = int.from_bytes(v, "little") + else: + # Let the real parse raise the precise validation error. + pass break + skip_string(stream) stream.seek(start_pos) except (AttributeError, OSError): pass # stream not seekable; OOM protection may be unavailable @@ -901,8 +906,9 @@ def tx(self): if self.version == 2 and self.tx_version is None: raise PSBTError("PSBTv2 is missing required PSBT_GLOBAL_TX_VERSION") + tx_version = self.tx_version if self.tx_version is not None else 2 return self.TX_CLS( - version=self.tx_version or 2, + version=tx_version, locktime=locktime, vin=[inp.vin for inp in self.inputs], vout=[out.vout for out in self.outputs], diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index 99a536f5..03b93bff 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -16,6 +16,7 @@ # TODO: refactor, a lot of code is duplicated here from transaction.py from collections import OrderedDict +from binascii import hexlify import hashlib from . import compact from . import ec @@ -285,15 +286,27 @@ def view(cls, stream, offset=None, compress=CompressMode.KEEP_ALL): raise PSBTError("Duplicate global key: %s" % key) global_kvs[key] = value if key == b"\xfb": + if len(value) != 4: + raise PSBTError("PSBT_GLOBAL_VERSION must be 4 bytes") version = int.from_bytes(value, "little") elif key == b"\x04": num_inputs = compact.from_bytes(value) elif key == b"\x05": num_outputs = compact.from_bytes(value) first_scope = cur + if version not in (None, 0, 2): + raise PSBTError("Unsupported PSBT_GLOBAL_VERSION value: %d" % version) + if version == 0: + version = None # PSBTv2 must not have a global unsigned transaction, regardless of key order if tx_offset is not None and version == 2: raise PSBTError("Global transaction with version 2 PSBT") + if version != 2: + for k in {b"\x02", b"\x03", b"\x04", b"\x05", b"\x06"}: + if k in global_kvs: + raise PSBTError( + "Global key %s is not allowed in PSBTv0" % hexlify(k).decode() + ) if None in [version or tx_offset, num_inputs, num_outputs]: raise PSBTError("Missing something important in PSBT") return cls( diff --git a/src/embit/transaction.py b/src/embit/transaction.py index fb64d8c7..c1b98548 100644 --- a/src/embit/transaction.py +++ b/src/embit/transaction.py @@ -76,7 +76,7 @@ def is_segwit(self): def write_to(self, stream): """Returns the byte serialization of the transaction""" - res = stream.write(self.version.to_bytes(4, "little")) + res = stream.write(self.version.to_bytes(4, "little", signed=True)) if self.is_segwit: res += stream.write(b"\x00\x01") # segwit marker and flag res += stream.write(compact.to_bytes(len(self.vin))) @@ -93,7 +93,7 @@ def write_to(self, stream): def hash(self): h = hashlib.sha256() - h.update(self.version.to_bytes(4, "little")) + h.update(self.version.to_bytes(4, "little", signed=True)) h.update(compact.to_bytes(len(self.vin))) for inp in self.vin: h.update(inp.serialize()) @@ -144,7 +144,7 @@ def read_vout(cls, stream, idx): @classmethod def read_from(cls, stream): - ver = int.from_bytes(stream.read(4), "little") + ver = int.from_bytes(stream.read(4), "little", signed=True) num_vin = compact.read_from(stream) # if num_vin is zero it is a segwit transaction is_segwit = num_vin == 0 @@ -221,7 +221,7 @@ def sighash_taproot( sh, anyonecanpay = SIGHASH.check(sighash) h = hashes.tagged_hash_init("TapSighash", b"\x00") h.update(bytes([sighash])) - h.update(self.version.to_bytes(4, "little")) + h.update(self.version.to_bytes(4, "little", signed=True)) h.update(self.locktime.to_bytes(4, "little")) if not anyonecanpay: h.update(self.hash_prevouts()) @@ -268,7 +268,7 @@ def sighash_segwit(self, input_index, script_pubkey, value, sighash=SIGHASH.ALL) inp = self.vin[input_index] zero = b"\x00" * 32 # for sighashes h = hashlib.sha256() - h.update(self.version.to_bytes(4, "little")) + h.update(self.version.to_bytes(4, "little", signed=True)) if anyonecanpay: h.update(zero) else: @@ -307,7 +307,7 @@ def sighash_legacy(self, input_index, script_pubkey, sighash=SIGHASH.ALL): return b"\x00" * 31 + b"\x01" h = hashlib.sha256() - h.update(self.version.to_bytes(4, "little")) + h.update(self.version.to_bytes(4, "little", signed=True)) # ANYONECANPAY - only one input is serialized if anyonecanpay: h.update(compact.to_bytes(1)) diff --git a/tests/tests/test_psbtV2.py b/tests/tests/test_psbtV2.py index a296299f..2830728e 100644 --- a/tests/tests/test_psbtV2.py +++ b/tests/tests/test_psbtV2.py @@ -6,10 +6,17 @@ import pytest from binascii import unhexlify from io import BytesIO -from embit.psbt import PSBT, PSBTError, TxModifiable, InputScope, OutputScope +from embit.psbt import ( + PSBT, + PSBTError, + TxModifiable, + InputScope, + OutputScope, + CompressMode, +) from embit.psbtview import PSBTView from embit.script import Script -from embit.transaction import SIGHASH +from embit.transaction import SIGHASH, Transaction, TransactionInput, TransactionOutput class TestPSBTVectors: @@ -581,3 +588,69 @@ def test_no_op_for_psbtv0(self): psbt._update_tx_modifiable(SIGHASH.ALL) # must remain untouched because version != 2 assert psbt.tx_modifiable_flags == TxModifiable.INPUTS | TxModifiable.OUTPUTS + + +class TestPSBTViewV2Validation: + def test_psbtview_rejects_v2_only_global_in_v0(self): + """PSBTView should enforce the same v0/v2 global separation as PSBT""" + psbt = PSBT(Transaction()) + raw = psbt.serialize() + stream = BytesIO(raw) + stream.read(len(PSBT.MAGIC)) + while True: + pos = stream.tell() + key_len = stream.read(1)[0] + if key_len == 0: + separator = pos + break + stream.seek(key_len, 1) + value_len = stream.read(1)[0] + stream.seek(value_len, 1) + + tx_version_kv = b"\x01\x02\x04\x02\x00\x00\x00" + invalid = raw[:separator] + tx_version_kv + raw[separator:] + with pytest.raises(PSBTError): + PSBTView.view(BytesIO(invalid)) + + +class TestPSBTv2TransactionVersion: + def test_psbt_tx_preserves_zero_and_negative_versions(self): + """PSBT_GLOBAL_TX_VERSION is signed and zero must not fall back to 2""" + psbt = PSBT.create_v2(tx_version=0) + inp = InputScope() + inp.txid = bytes(32) + inp.vout = 0 + psbt.add_input(inp) + out = OutputScope() + out.value = 1 + out.script_pubkey = Script(b"") + psbt.add_output(out) + assert psbt.tx.serialize()[:4] == b"\x00\x00\x00\x00" + + psbt.tx_version = -1 + assert psbt.tx.serialize()[:4] == b"\xff\xff\xff\xff" + + +class TestPSBTv2Compression: + def test_psbtv2_compress_non_witness_utxo_is_key_order_independent(self): + """PSBTv2 compress should not load the full prev tx when 0x00 precedes 0x0f""" + prev = Transaction( + vin=[TransactionInput(bytes([1]) * 32, 0)], + vout=[TransactionOutput(1234, Script(b"\x51"))], + ) + psbt = PSBT.create_v2() + inp = InputScope() + inp.txid = prev.txid() + inp.vout = 0 + # InputScope serializes non_witness_utxo before PSBTv2 prevout fields. + inp.non_witness_utxo = prev + psbt.add_input(inp) + out = OutputScope() + out.value = 1000 + out.script_pubkey = Script(b"\x51") + psbt.add_output(out) + + parsed = PSBT.parse(psbt.serialize(), compress=CompressMode.PARTIAL) + assert parsed.inputs[0].non_witness_utxo is None + assert parsed.inputs[0]._utxo.value == 1234 + assert parsed.verify() From 2bc81470dfc53e22e799694025d698726cb6a91f Mon Sep 17 00:00:00 2001 From: odudex Date: Thu, 14 May 2026 10:47:02 -0300 Subject: [PATCH 22/61] fix: restore old MicroPython-compatible PSBT errors Replace new PSBTv2/PSET f-strings with old-style formatting so the branch remains parseable on older MicroPython versions. --- src/embit/liquid/pset.py | 4 ++-- src/embit/psbt.py | 26 +++++++++++++++++--------- src/embit/psbtview.py | 5 +++-- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/embit/liquid/pset.py b/src/embit/liquid/pset.py index e3cfa3a0..cf4634c5 100644 --- a/src/embit/liquid/pset.py +++ b/src/embit/liquid/pset.py @@ -508,11 +508,11 @@ def _validate_v2_output(cls, out, i): """PSET allows value_commitment as an alternative to value (blinded outputs).""" if out.value is None and not getattr(out, "value_commitment", None): raise PSBTError( - f"PSBTv2 output {i} missing required PSBT_OUT_AMOUNT (0x03)" + "PSBTv2 output %d missing required PSBT_OUT_AMOUNT (0x03)" % i ) if out.script_pubkey is None: raise PSBTError( - f"PSBTv2 output {i} missing required PSBT_OUT_SCRIPT (0x04)" + "PSBTv2 output %d missing required PSBT_OUT_SCRIPT (0x04)" % i ) def unblind(self, blinding_key): diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 7c4afa31..7306c283 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -403,7 +403,9 @@ def read_value(self, stream, k, version=None): raise PSBTError("PSBT_IN_REQUIRED_TIME_LOCKTIME must be 4 bytes") locktime = int.from_bytes(v, "little") if locktime < LOCKTIME_THRESHOLD: - raise PSBTError(f"Time-based locktime must be >= {LOCKTIME_THRESHOLD}") + raise PSBTError( + "Time-based locktime must be >= %d" % LOCKTIME_THRESHOLD + ) self.required_time_locktime = locktime # PSBT_IN_REQUIRED_HEIGHT_LOCKTIME @@ -419,7 +421,8 @@ def read_value(self, stream, k, version=None): locktime = int.from_bytes(v, "little") if locktime >= LOCKTIME_THRESHOLD or locktime == 0: raise PSBTError( - f"Height-based locktime must be > 0 and < {LOCKTIME_THRESHOLD}" + "Height-based locktime must be > 0 and < %d" + % LOCKTIME_THRESHOLD ) self.required_height_locktime = locktime @@ -796,11 +799,11 @@ def _validate_v2_output(cls, out, i): Subclasses (e.g. PSET) may override this to accept alternative fields.""" if out.value is None: raise PSBTError( - f"PSBTv2 output {i} missing required PSBT_OUT_AMOUNT (0x03)" + "PSBTv2 output %d missing required PSBT_OUT_AMOUNT (0x03)" % i ) if out.script_pubkey is None: raise PSBTError( - f"PSBTv2 output {i} missing required PSBT_OUT_SCRIPT (0x04)" + "PSBTv2 output %d missing required PSBT_OUT_SCRIPT (0x04)" % i ) def __init__(self, tx=None, unknown=None, version=None): @@ -1033,7 +1036,9 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL): break value = read_string(stream) if key in global_kvs: - raise PSBTError(f"Duplicated global key: {hexlify(key).decode()}") + raise PSBTError( + "Duplicated global key: %s" % hexlify(key).decode() + ) global_kvs[key] = value # Determine PSBT version from PSBT_GLOBAL_VERSION (0xfb) @@ -1050,7 +1055,7 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL): ) else: raise PSBTError( - f"Unsupported PSBT_GLOBAL_VERSION value: {parsed_version}" + "Unsupported PSBT_GLOBAL_VERSION value: %d" % parsed_version ) if version == 2: # PSBTv2 @@ -1081,7 +1086,8 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL): for k_v2_only in v2_only_global_keys: if k_v2_only in global_kvs: raise PSBTError( - f"Global key {hexlify(k_v2_only).decode()} is not allowed in PSBTv0" + "Global key %s is not allowed in PSBTv0" + % hexlify(k_v2_only).decode() ) tx_bytes = global_kvs.pop(b"\x00") # Remove so it's not in unknown @@ -1107,11 +1113,13 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL): for i, inp in enumerate(psbt.inputs): if inp.txid is None: raise PSBTError( - f"PSBTv2 input {i} missing required PSBT_IN_PREVIOUS_TXID (0x0e)" + "PSBTv2 input %d missing required " + "PSBT_IN_PREVIOUS_TXID (0x0e)" % i ) if inp.vout is None: raise PSBTError( - f"PSBTv2 input {i} missing required PSBT_IN_OUTPUT_INDEX (0x0f)" + "PSBTv2 input %d missing required " + "PSBT_IN_OUTPUT_INDEX (0x0f)" % i ) parsed_outputs = [] diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index 03b93bff..ccc9b865 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -461,7 +461,8 @@ def _determine_locktime_v2(self): height_locktime = int.from_bytes(v_height, "little") if height_locktime == 0 or height_locktime >= LOCKTIME_THRESHOLD: raise PSBTError( - f"Height-based locktime must be > 0 and < {LOCKTIME_THRESHOLD}" + "Height-based locktime must be > 0 and < %d" + % LOCKTIME_THRESHOLD ) height_locktimes.append(height_locktime) has_requirement = True @@ -471,7 +472,7 @@ def _determine_locktime_v2(self): time_locktime = int.from_bytes(v_time, "little") if time_locktime < LOCKTIME_THRESHOLD: raise PSBTError( - f"Time-based locktime must be >= {LOCKTIME_THRESHOLD}" + "Time-based locktime must be >= %d" % LOCKTIME_THRESHOLD ) time_locktimes.append(time_locktime) has_requirement = True From 4aca48bf891d6473046796e1d769a0174caf037f Mon Sep 17 00:00:00 2001 From: odudex Date: Thu, 14 May 2026 11:36:05 -0300 Subject: [PATCH 23/61] fix(psbt): avoid signed byte conversion kwargs Replace signed=True usage in BIP370 signed integer parsing and serialization with helper functions compatible with older MicroPython byte conversion APIs. This keeps PSBTv2 transaction versions and output amounts spec-compliant without changing regular keyword-argument call sites. --- src/embit/psbt.py | 17 ++++++++++++----- src/embit/psbtview.py | 12 +++++++----- src/embit/transaction.py | 29 +++++++++++++++++++++++------ 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 7306c283..71eff73c 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -1,5 +1,12 @@ from collections import OrderedDict -from .transaction import Transaction, TransactionOutput, TransactionInput, SIGHASH +from .transaction import ( + Transaction, + TransactionOutput, + TransactionInput, + SIGHASH, + _signed_from_bytes, + _signed_to_bytes, +) from . import compact from . import bip32 from . import ec @@ -693,7 +700,7 @@ def read_value(self, stream, k, version=None): raise PSBTError("PSBT_OUT_AMOUNT must be 8 bytes") if self.value is not None: raise PSBTError("Duplicated PSBT_OUT_AMOUNT") - self.value = int.from_bytes(v, "little", signed=True) + self.value = _signed_from_bytes(v) elif k == b"\x04": if version != 2: raise PSBTError("PSBT_OUT_SCRIPT not allowed in PSBTv0") @@ -738,7 +745,7 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: if self.value is not None: r += ser_string(stream, b"\x03") # BIP370: PSBT_OUT_AMOUNT is a signed 64-bit integer - r += ser_string(stream, self.value.to_bytes(8, "little", signed=True)) + r += ser_string(stream, _signed_to_bytes(self.value, 8)) if self.script_pubkey is not None: r += ser_string(stream, b"\x04") r += self.script_pubkey.write_to(stream) @@ -971,7 +978,7 @@ def write_to(self, stream) -> int: ) r += ser_string(stream, b"\x02") # BIP370: PSBT_GLOBAL_TX_VERSION is a signed int32 - r += ser_string(stream, self.tx_version.to_bytes(4, "little", signed=True)) + r += ser_string(stream, _signed_to_bytes(self.tx_version, 4)) if self.locktime is not None: r += ser_string(stream, b"\x03") r += ser_string(stream, self.locktime.to_bytes(4, "little")) @@ -1185,7 +1192,7 @@ def parse_unknowns(self): if len(v) != 4: raise PSBTError("PSBT_GLOBAL_TX_VERSION must be 4 bytes") # BIP370: PSBT_GLOBAL_TX_VERSION is a signed int32 - self.tx_version = int.from_bytes(v, "little", signed=True) + self.tx_version = _signed_from_bytes(v) elif k == b"\x03": if self.version == 2: v = self.unknown.pop(k) diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index ccc9b865..967249ed 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -40,6 +40,8 @@ SIGHASH, hash_amounts, hash_script_pubkeys, + _signed_from_bytes, + _signed_to_bytes, ) @@ -411,7 +413,7 @@ def vout(self, i, compress=None): self.seek_to_scope(self.num_inputs + i) v = self.get_value(b"\x03", from_current=True) - value = int.from_bytes(v, "little") + value = _signed_from_bytes(v) self.seek_to_scope(self.num_inputs + i) v = self.get_value(b"\x04", from_current=True) @@ -508,7 +510,7 @@ def tx_version(self): if len(v) != 4: raise PSBTError("Invalid PSBT_GLOBAL_TX_VERSION length") # BIP370: PSBT_GLOBAL_TX_VERSION is a signed int32 - self._tx_version = int.from_bytes(v, "little", signed=True) + self._tx_version = _signed_from_bytes(v) return self._tx_version def seek_to_value(self, key_start, from_current=False): @@ -598,7 +600,7 @@ def sighash_taproot( sh, anyonecanpay = SIGHASH.check(sighash) h = hashes.tagged_hash_init("TapSighash", b"\x00") h.update(bytes([sighash])) - h.update(self.tx_version.to_bytes(4, "little", signed=True)) + h.update(_signed_to_bytes(self.tx_version, 4)) h.update(self.locktime.to_bytes(4, "little")) if not anyonecanpay: h.update(self.hash_prevouts()) @@ -646,7 +648,7 @@ def sighash_segwit(self, input_index, script_pubkey, value, sighash=SIGHASH.ALL) inp = self.vin(input_index) zero = b"\x00" * 32 # for sighashes h = hashlib.sha256() - h.update(self.tx_version.to_bytes(4, "little", signed=True)) + h.update(_signed_to_bytes(self.tx_version, 4)) if anyonecanpay: h.update(zero) else: @@ -686,7 +688,7 @@ def sighash_legacy(self, input_index, script_pubkey, sighash=SIGHASH.ALL): return b"\x00" * 31 + b"\x01" h = hashlib.sha256() - h.update(self.tx_version.to_bytes(4, "little", signed=True)) + h.update(_signed_to_bytes(self.tx_version, 4)) # ANYONECANPAY - only one input is serialized if anyonecanpay: h.update(compact.to_bytes(1)) diff --git a/src/embit/transaction.py b/src/embit/transaction.py index c1b98548..dac876fa 100644 --- a/src/embit/transaction.py +++ b/src/embit/transaction.py @@ -33,6 +33,23 @@ def check(cls, sighash: int): # util functions +def _signed_from_bytes(b): + res = int.from_bytes(b, "little") + bits = 8 * len(b) + if bits and res >= (1 << (bits - 1)): + res -= 1 << bits + return res + + +def _signed_to_bytes(i, length): + bits = 8 * length + if i < -(1 << (bits - 1)) or i >= (1 << (bits - 1)): + raise OverflowError("int too big to convert") + if i < 0: + i += 1 << bits + return i.to_bytes(length, "little") + + def hash_amounts(amounts): h = hashlib.sha256() for a in amounts: @@ -76,7 +93,7 @@ def is_segwit(self): def write_to(self, stream): """Returns the byte serialization of the transaction""" - res = stream.write(self.version.to_bytes(4, "little", signed=True)) + res = stream.write(_signed_to_bytes(self.version, 4)) if self.is_segwit: res += stream.write(b"\x00\x01") # segwit marker and flag res += stream.write(compact.to_bytes(len(self.vin))) @@ -93,7 +110,7 @@ def write_to(self, stream): def hash(self): h = hashlib.sha256() - h.update(self.version.to_bytes(4, "little", signed=True)) + h.update(_signed_to_bytes(self.version, 4)) h.update(compact.to_bytes(len(self.vin))) for inp in self.vin: h.update(inp.serialize()) @@ -144,7 +161,7 @@ def read_vout(cls, stream, idx): @classmethod def read_from(cls, stream): - ver = int.from_bytes(stream.read(4), "little", signed=True) + ver = _signed_from_bytes(stream.read(4)) num_vin = compact.read_from(stream) # if num_vin is zero it is a segwit transaction is_segwit = num_vin == 0 @@ -221,7 +238,7 @@ def sighash_taproot( sh, anyonecanpay = SIGHASH.check(sighash) h = hashes.tagged_hash_init("TapSighash", b"\x00") h.update(bytes([sighash])) - h.update(self.version.to_bytes(4, "little", signed=True)) + h.update(_signed_to_bytes(self.version, 4)) h.update(self.locktime.to_bytes(4, "little")) if not anyonecanpay: h.update(self.hash_prevouts()) @@ -268,7 +285,7 @@ def sighash_segwit(self, input_index, script_pubkey, value, sighash=SIGHASH.ALL) inp = self.vin[input_index] zero = b"\x00" * 32 # for sighashes h = hashlib.sha256() - h.update(self.version.to_bytes(4, "little", signed=True)) + h.update(_signed_to_bytes(self.version, 4)) if anyonecanpay: h.update(zero) else: @@ -307,7 +324,7 @@ def sighash_legacy(self, input_index, script_pubkey, sighash=SIGHASH.ALL): return b"\x00" * 31 + b"\x01" h = hashlib.sha256() - h.update(self.version.to_bytes(4, "little", signed=True)) + h.update(_signed_to_bytes(self.version, 4)) # ANYONECANPAY - only one input is serialized if anyonecanpay: h.update(compact.to_bytes(1)) From 658d06ff98de53ace1c182603cedbb4793b3f6f9 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:13:10 -0300 Subject: [PATCH 24/61] fix(psbtview): read global tx version as signed int32 GlobalTransactionView.version read the version unsigned while the sighash paths re-encode it via _signed_to_bytes, raising OverflowError for v0 txs whose version has the high bit set. Match Transaction.read_from. --- src/embit/psbtview.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index 967249ed..ad37a7f6 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -94,7 +94,8 @@ def __init__(self, stream, offset): def version(self): if self._version is None: self.stream.seek(self.offset) - self._version = int.from_bytes(self.stream.read(4), "little") + # tx version is a signed int32, matching Transaction.read_from + self._version = _signed_from_bytes(self.stream.read(4)) return self._version @property From 2c680737db8efb0c68cb95e1f14e558e52c89e0c Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:15:11 -0300 Subject: [PATCH 25/61] fix(psbt): don't reject valid appends under SIGHASH_SINGLE The count-based guard in add_input rejected legitimate end-appends. BIP370 only requires preserving input/output pairing for SIGHASH_SINGLE-signed inputs; appending never reindexes existing pairs, so it is always safe. Now symmetric with add_output. --- src/embit/psbt.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 71eff73c..17788e29 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -1533,11 +1533,9 @@ def add_input(self, input_scope): or input_scope.required_time_locktime is not None ): self._validate_locktime_compatibility(self.inputs + [input_scope]) - # SIGHASH_SINGLE: each added input must have a paired output - if self.has_sighash_single() and len(self.inputs) >= len(self.outputs): - raise PSBTError( - "Cannot add input without a corresponding output when SIGHASH_SINGLE is set" - ) + # BIP370: when SIGHASH_SINGLE signatures are present the input/output + # pairing must be preserved. This append-only API only adds to the end, + # which never reindexes existing pairs, so the addition is always safe. self.inputs.append(input_scope) if self.version == 2: self._raw_input_count_from_global = len(self.inputs) From 6e4265449ea4146f3b55f52ef17541d86571cb94 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:15:54 -0300 Subject: [PATCH 26/61] fix(psbtview): pass version when parsing extra scope streams write_to merged extra_input/output_streams with default v0 parsing, which would misclassify the version-sensitive PSBTv2 fields (0x0e-0x12, 0x03-0x04). Parse them with the view's own version. --- src/embit/psbtview.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index ad37a7f6..99716fbb 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -1060,7 +1060,7 @@ def write_to( inp = self.input(i) # add extra data from extra input streams for s in extra_input_streams: - extra = InputScope.read_from(s) + extra = InputScope.read_from(s, version=self.version) inp.update(extra) if compress: inp.clear_metadata(compress=compress) @@ -1071,7 +1071,7 @@ def write_to( out = self.output(i) # add extra data from extra input streams for s in extra_output_streams: - extra = OutputScope.read_from(s) + extra = OutputScope.read_from(s, version=self.version) out.update(extra) if compress: out.clear_metadata(compress=compress) From b09c135f1a0e16826fa732d588933ab7e6f456f0 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:16:37 -0300 Subject: [PATCH 27/61] fix(psbt): preserve an explicit nSequence of 0 in vin The 'or 0xFFFFFFFF' fallback rewrote a stored sequence of 0 (valid, enables nLockTime) to 0xFFFFFFFF, altering the assembled transaction and its sighashes. PSBTv2 parses PSBT_IN_SEQUENCE explicitly, making this reachable. --- src/embit/psbt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 17788e29..7c0a83a4 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -218,7 +218,9 @@ def update(self, other): @property def vin(self): return TransactionInput( - self.txid, self.vout, sequence=(self.sequence or 0xFFFFFFFF) + self.txid, + self.vout, + sequence=(self.sequence if self.sequence is not None else 0xFFFFFFFF), ) @property From 3bbb2d2c23134f3c82b66c4acae4c02fc3a752e4 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:17:28 -0300 Subject: [PATCH 28/61] fix(psbt): don't clobber taproot_internal_key on combine InputScope.update and OutputScope.update overwrote taproot_internal_key unconditionally, wiping an existing key when combining with a scope that lacks one. Guard like the neighboring fields. --- src/embit/psbt.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 7c0a83a4..5d7b742c 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -197,7 +197,9 @@ def update(self, other): self.witness_script = other.witness_script or self.witness_script self.bip32_derivations.update(other.bip32_derivations) self.taproot_bip32_derivations.update(other.taproot_bip32_derivations) - self.taproot_internal_key = other.taproot_internal_key + self.taproot_internal_key = ( + other.taproot_internal_key or self.taproot_internal_key + ) self.taproot_merkle_root = other.taproot_merkle_root or self.taproot_merkle_root self.taproot_key_sig = other.taproot_key_sig or self.taproot_key_sig self.taproot_sigs.update(other.taproot_sigs) @@ -657,7 +659,9 @@ def update(self, other): self.witness_script = other.witness_script or self.witness_script self.bip32_derivations.update(other.bip32_derivations) self.taproot_bip32_derivations.update(other.taproot_bip32_derivations) - self.taproot_internal_key = other.taproot_internal_key + self.taproot_internal_key = ( + other.taproot_internal_key or self.taproot_internal_key + ) @property def vout(self): From 8baf31f9858375d06eaf814283c8d6039acbace6 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Fri, 15 May 2026 17:18:25 +0530 Subject: [PATCH 29/61] feat(BIP-374): dleq proofs --- src/embit/silent_payments/dleq.py | 222 ++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/embit/silent_payments/dleq.py diff --git a/src/embit/silent_payments/dleq.py b/src/embit/silent_payments/dleq.py new file mode 100644 index 00000000..be96f249 --- /dev/null +++ b/src/embit/silent_payments/dleq.py @@ -0,0 +1,222 @@ +""" +BIP-374 DLEQ (Discrete Log Equality Proofs). + +Given points A = a*G and C = a*B, proves knowledge of scalar a +across both bases (G and B) without revealing a. + +Spec: https://github.com/bitcoin/bips/blob/master/bip-0374.mediawiki +Reference: https://github.com/bitcoin/bips/blob/master/bip-0374/reference.py +""" + +from .. import hashes +from ..util.key import SECP256K1_ORDER, SECP256K1_G +from ..util.secp256k1 import ( + ec_pubkey_create, + ec_pubkey_parse, + ec_pubkey_serialize, + ec_pubkey_tweak_mul, + ec_pubkey_combine, + EC_COMPRESSED, +) + +_G_COMPRESSED = b"\x02" + SECP256K1_G[0].to_bytes(32, "big") +DLEQ_TAG_AUX = "BIP0374/aux" +DLEQ_TAG_NONCE = "BIP0374/nonce" +DLEQ_TAG_CHALLENGE = "BIP0374/challenge" + + +class DLEQError(Exception): + """Raised when DLEQ proof generation fails due to invalid inputs.""" + + +def _scalar_mult_G(scalar_bytes, G_sec): + """Return scalar * G_sec as an opaque pubkey handle. + + Uses ec_pubkey_create for the standard secp256k1 generator (fast path) + and parse + tweak_mul for an arbitrary base point. + Raises ValueError / OverflowError on invalid inputs. + """ + if G_sec == _G_COMPRESSED: + return ec_pubkey_create(scalar_bytes) + pub = ec_pubkey_parse(G_sec) + ec_pubkey_tweak_mul(pub, scalar_bytes) + return pub + + +def generate_dleq_proof(a_bytes, B_sec, r=None, m=None, G=None): + """ + Generate a 64-byte DLEQ proof (BIP-374 GenerateProof). + + Proves log_G(A) == log_B(C) without revealing scalar a, + where A = a*G and C = a*B. + + Args: + a_bytes: 32-byte private key scalar. + B_sec: 33-byte compressed pubkey (alternative base point B). + r: 32-byte auxiliary randomness. Must be fresh per proof; reusing r + with the same (a, B) and no m can leak a. When m is provided, + it is mixed into nonce generation, so that leak does not occur + if the reused r is paired with different messages, but fresh r + is still required. + G: Optional 33-byte compressed base point. Defaults to secp256k1 G. + BIP-374 footnote 2 allows any curve point as the generator so + the algorithm can be used with alternate bases on secp256k1. + + Returns: + 64-byte proof: bytes(32, e) || bytes(32, s). + + Raises: + DLEQError: on invalid inputs or internal self-verification failure. + """ + if len(a_bytes) != 32: + raise DLEQError("a_bytes must be 32 bytes") + if len(B_sec) != 33: + raise DLEQError("B_sec must be a 33-byte compressed pubkey") + if m is not None and len(m) != 32: + raise DLEQError("m must be exactly 32 bytes per BIP-374") + if G is not None and len(G) != 33: + raise DLEQError("G must be a 33-byte compressed pubkey") + + G_sec = _G_COMPRESSED if G is None else G + + a_int = int.from_bytes(a_bytes, "big") + if a_int == 0 or a_int >= SECP256K1_ORDER: + raise DLEQError("a is out of range [1, n-1]") + + try: + A_internal = _scalar_mult_G(a_bytes, G_sec) # A = a*G + except (ValueError, OverflowError): + raise DLEQError("Invalid private key or G point") + A_compressed = ec_pubkey_serialize(A_internal, EC_COMPRESSED) + + # C = a*B; ec_pubkey_parse rejects the point at infinity (spec is_infinite(B)). + try: + C_internal = ec_pubkey_parse(B_sec) + ec_pubkey_tweak_mul(C_internal, a_bytes) + except (ValueError, OverflowError): + raise DLEQError("Invalid B_sec") + C_compressed = ec_pubkey_serialize(C_internal, EC_COMPRESSED) + + if r is None: + raise DLEQError( + "r must be provided: pass 32 bytes of fresh auxiliary randomness" + ) + if len(r) != 32: + raise DLEQError("r must be 32 bytes") + + aux_hash = hashes.tagged_hash(DLEQ_TAG_AUX, r) + t_int = a_int ^ int.from_bytes(aux_hash, "big") + t = t_int.to_bytes(32, "big") + + m_prime = m if m is not None else b"" + + # k = H_nonce(t || cbytes(A) || cbytes(C) || m') mod n + rand = hashes.tagged_hash(DLEQ_TAG_NONCE, t + A_compressed + C_compressed + m_prime) + k = int.from_bytes(rand, "big") % SECP256K1_ORDER + if k == 0: + raise DLEQError("Derived nonce k is zero") + + k_bytes = k.to_bytes(32, "big") + + R1_internal = _scalar_mult_G(k_bytes, G_sec) # R1 = k*G + R1_compressed = ec_pubkey_serialize(R1_internal, EC_COMPRESSED) + + R2_internal = ec_pubkey_parse(B_sec) # R2 = k*B (fresh buffer) + ec_pubkey_tweak_mul(R2_internal, k_bytes) + R2_compressed = ec_pubkey_serialize(R2_internal, EC_COMPRESSED) + + # e = int(H_challenge(A || B || C || G || R1 || R2 || m')) + e_hash = hashes.tagged_hash( + DLEQ_TAG_CHALLENGE, + A_compressed + + B_sec + + C_compressed + + G_sec + + R1_compressed + + R2_compressed + + m_prime, + ) + e = int.from_bytes(e_hash, "big") + + s = (k + e * a_int) % SECP256K1_ORDER + + proof = e.to_bytes(32, "big") + s.to_bytes(32, "big") + + # self-verify before returning (as required by spec) + if not verify_dleq_proof(A_compressed, B_sec, C_compressed, proof, m, G=G): + raise DLEQError("Self-verification of generated proof failed") + + return proof + + +def verify_dleq_proof(A_sec, B_sec, C_sec, proof, m=None, G=None): + """ + Verify a 64-byte DLEQ proof (BIP-374 VerifyProof). + + Returns True if the proof is valid, False otherwise. + Never raises exceptions — all failures return False. + + Args: + A_sec: 33-byte compressed pubkey (a*G). + B_sec: 33-byte compressed pubkey (alternative base point B). + C_sec: 33-byte compressed pubkey (a*B, the ECDH result). + proof: 64-byte proof bytes. + m: Optional 32-byte message that was bound during generation. + G: 33-byte compressed base point. Defaults to secp256k1 G. + Must match the G used during proof generation. + """ + if len(proof) != 64: + return False + if m is not None and len(m) != 32: + return False + + G_sec = _G_COMPRESSED if G is None else G + + try: + e = int.from_bytes(proof[:32], "big") + s = int.from_bytes(proof[32:], "big") + + if s >= SECP256K1_ORDER: + return False + + neg_e = (-e) % SECP256K1_ORDER + if neg_e == 0: + return False + + m_prime = m if m is not None else b"" + s_bytes = s.to_bytes(32, "big") + neg_e_bytes = neg_e.to_bytes(32, "big") + + # ec_pubkey_parse rejects the point at infinity (spec is_infinite checks + # for A, B, C). ec_pubkey_combine raises ValueError when R1 or R2 would + # be the point at infinity (spec is_infinite(R1) / is_infinite(R2)). + # ec_pubkey_tweak_mul mutates in-place; each point gets a fresh buffer. + + # R1 = s*G + (-e)*A + sG_internal = _scalar_mult_G(s_bytes, G_sec) + neg_eA_internal = ec_pubkey_parse(A_sec) + ec_pubkey_tweak_mul(neg_eA_internal, neg_e_bytes) + R1_internal = ec_pubkey_combine(sG_internal, neg_eA_internal) + R1_compressed = ec_pubkey_serialize(R1_internal, EC_COMPRESSED) + + # R2 = s*B + (-e)*C + sB_internal = ec_pubkey_parse(B_sec) + ec_pubkey_tweak_mul(sB_internal, s_bytes) + neg_eC_internal = ec_pubkey_parse(C_sec) + ec_pubkey_tweak_mul(neg_eC_internal, neg_e_bytes) + R2_internal = ec_pubkey_combine(sB_internal, neg_eC_internal) + R2_compressed = ec_pubkey_serialize(R2_internal, EC_COMPRESSED) + + # Recompute e' = int(H_challenge(A || B || C || G || R1 || R2 || m')) + e_check = int.from_bytes( + hashes.tagged_hash( + DLEQ_TAG_CHALLENGE, + A_sec + B_sec + C_sec + G_sec + R1_compressed + R2_compressed + m_prime, + ), + "big", + ) + + return e == e_check + + except (ValueError, OverflowError): + return False From b6b5a01a70a579318c064b951ea64a02f38a1c11 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Fri, 15 May 2026 17:24:30 +0530 Subject: [PATCH 30/61] test: dleq proofs added dleq test vectors: * https://github.com/bitcoin/bips/blob/master/bip-0374/test_vectors_generate_proof.csv * https://github.com/bitcoin/bips/blob/master/bip-0374/test_vectors_verify_proof.csv --- .../data/test_vectors_generate_proof.csv | 12 +++ .../tests/data/test_vectors_verify_proof.csv | 16 ++++ tests/tests/test_dleq.py | 77 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 tests/tests/data/test_vectors_generate_proof.csv create mode 100644 tests/tests/data/test_vectors_verify_proof.csv create mode 100644 tests/tests/test_dleq.py diff --git a/tests/tests/data/test_vectors_generate_proof.csv b/tests/tests/data/test_vectors_generate_proof.csv new file mode 100644 index 00000000..f913508d --- /dev/null +++ b/tests/tests/data/test_vectors_generate_proof.csv @@ -0,0 +1,12 @@ +index,point_G,scalar_a,point_B,auxrand_r,message,result_proof,comment +0,02cef38f55e78b321a1f785cb1c6e33dfcef9784c18bdc4e279801c449ccdfb88e,07ff93d43f1012a5d4a44aba55240212ed39c87b3344e46757d99f24177fc576,02dad4b35c2379ba8334c9a5dda8f6e6d5cd575a7cc9d3ca4faaac51839daaa30f,cb979b0fc8ccc7f237751e719d992fcc324b6500af33999cd54a3e5c05fb1ea4,efb07d4b382d3da1079fbf24df623ba6c2e4c764993bbfa6dd7a4fe4aaf33859,7e7e934169e0bf4706e6b29e5a621c7fe199a524744a25af80071e111c0e2e94118e730d8add118dd2ee4f7d1cc183e1b87168362d1a6f85c16d8671a3fc7a8a,Success case 1 +1,02464e351831efedb755223cabbf664f10564b4742c725c023034bc928ed339e0e,f4e9172285393c6ada994c811b3e50fc47e96421ea7e54f4a4e459528d4cf562,03fe589b0fa23f060f6d4d1e76b9b19d5bb3db0e56d39a4303913de0e706463008,75f12482b9209dae12230ea1f8bf69723a1b447d361db8f510dd9ab33556fd4c,76184ce9eea5b339ebf5304b57452c1ada1466610f0a58574d6c496798cee04b,6b4521a8363a7ebc5d95ac6ec6b64db81fcf21795187d7c4600c42b73fb4fb9870ab8d106c0fd2d292c1710e10437b20575ddb3cb32eb77a5618d94ddba600f2,Success case 2 +2,0222db2054fef98344352a13bc0304a71da7b5e9a2f7fd1f3c9f3519a3d9377fb7,589476913e763b60d5c2a5bfb39230ec669caac1b44312e9bcd2d3f4473abfef,03bc7a19970c812118f74ba659b491e00dade6096ff62d1afe032a92b8671498ed,4da1c4c4b0f9db4eb6b2e5cb648d7e8a0aa35aa5c4ec4d07f096e0e03deca366,66503623468a78cfcef47888c85e0010ecd897f441d263448bfc7a89b882ab20,12aa2aa469b3c037871a09d18ab18d3840219b1ed169f6ef9deae6d927949884a459705ae89a57522224ce3482dee00a41ba511188ae60efdeb736223eb66e7b,Success case 3 +3,03dfa65bd3711eba75fa1996a0c1d95a4419bd835304152d9aa6efa590670f2af6,24d0ed3fc189eb1b64e5dc9dd4af0f3c8c143b0c79cb5fcca0dfa08a11cc60a1,03b51081323d38fb0b75f0c1ec6755fdb79c239c327ca11269fe68ba8a878b704e,31a68d6db27f6404bbceff646ff1b26a34704a0105a36c5a845d0257cea19c9b,f2996b3766d123a949e65541baf1d89d446360d05af51bd93f0445d8c472c952,7907653d29c5722ae44510e7f2839f253450aefc833b7e0a3b38384032f847f2cf41136b2fe6a558ad125287d20c0117f2a30c4ac0c4cebbcfa1dd3a69d84200,Success case 4 +4,02b15de5a3aefcfe2473916c76e619b5800ac7250ef93a9e6e0dd1505104fc58e7,73fffa796edb72d111b5e0bbda1608f098ac98120796f971b438691e1bfb7b96,03a4692be176ff89a972de9cc407083096847b950d1cae72b947665a3d5f4c2f01,1cdfb4d7cce5e50783299896a471a44e6aa2c5e2100d6c37987c6b40503c6162,0ceb45f560f2cf6b76a139ffe2c47c5ca6d26d6a3a210e59f197413bbec040b4,02277ca5a7acfe8ae13c2db4a8f74489d0ba100ed8b082381ddb6522c4510718ab88b8dbbd785c388ade79586cf6416f3c47a79670af84abccc788a5d9f2e327,Success case 5 +5,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,c08ca8e0bb59769fc6a4e078456284e00ea34f65add988c246e1bba85824ccdc,034bccb1c570ac1f3bc42d61fe35de605b99626501ccb20297e1acbbf2d7152aa1,c8d7056abd4726eb5a0f198740af14d6c1f0c16e5d7a37eaec621b661e669ac4,,503562d36910cd2d61a4d07c8ff680265c713e63dde0dcb88e6ea3c58597bdc05b86db9af95eccc475ce2177f941c118fefed20227d4ce8ce9557cb008758de6,Success case 6 +6,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,8e641ba6bf7f64eec76005a29585a5035376375f33e331215aedfe03b8e80e7a,0231c64e3efa506fdad6aad0f6084d5f6739de7f448d7e66f9d22f842638f41d60,02a7b2e2f5a5e9b1078dbb160502a32491fe80a091e91dd92cf77b0b7d90970f,35841ca532846e1cdd23a3d107824343584f88eff580929469865eae8355ee3c,5c7b27a33210750e9de8679d9f43497cf9f12ac642cde0a1fc26443aa2fc89bf71aabf7bac89f5d8a96cbe86daba155fa74d6f3e111136179e53b04eb6d7807f,Success case 7 +7,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,cfb9a7ecc49bea4f2e2ee34c38a6f48b5cd5bd06f4e4d4ffb45905b3d26db842,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,d38466b77484154a3fcb3151094c1c8a845c73a3c036b3a8ebffd8ef62c9047f,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,Success case 8 +8,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,0000000000000000000000000000000000000000000000000000000000000000,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,d38466b77484154a3fcb3151094c1c8a845c73a3c036b3a8ebffd8ef62c9047f,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,INVALID,Failure case (a=0) +9,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,d38466b77484154a3fcb3151094c1c8a845c73a3c036b3a8ebffd8ef62c9047f,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,INVALID,Failure case (a=N [group order]) +10,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,cfb9a7ecc49bea4f2e2ee34c38a6f48b5cd5bd06f4e4d4ffb45905b3d26db842,INFINITY,d38466b77484154a3fcb3151094c1c8a845c73a3c036b3a8ebffd8ef62c9047f,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,INVALID,Failure case (B is point at infinity) diff --git a/tests/tests/data/test_vectors_verify_proof.csv b/tests/tests/data/test_vectors_verify_proof.csv new file mode 100644 index 00000000..8076e813 --- /dev/null +++ b/tests/tests/data/test_vectors_verify_proof.csv @@ -0,0 +1,16 @@ +index,point_G,point_A,point_B,point_C,proof,message,result_success,comment +0,02cef38f55e78b321a1f785cb1c6e33dfcef9784c18bdc4e279801c449ccdfb88e,02b540b22c2c5ef0dc886abdaad27498453d893265560bc08a187319af6f845f58,02dad4b35c2379ba8334c9a5dda8f6e6d5cd575a7cc9d3ca4faaac51839daaa30f,03fefe00951dcd0ef10b12523393c2b8113119de4fdeeab320694e96bdccd2775b,7e7e934169e0bf4706e6b29e5a621c7fe199a524744a25af80071e111c0e2e94118e730d8add118dd2ee4f7d1cc183e1b87168362d1a6f85c16d8671a3fc7a8a,efb07d4b382d3da1079fbf24df623ba6c2e4c764993bbfa6dd7a4fe4aaf33859,TRUE,Success case 1 +1,02464e351831efedb755223cabbf664f10564b4742c725c023034bc928ed339e0e,032baaf1b10845a51b551196984a91efe2adf9d41b92bec3927218e6e4ca344002,03fe589b0fa23f060f6d4d1e76b9b19d5bb3db0e56d39a4303913de0e706463008,031f59aa1df22190e00380d8c5941adf899f596593765a1251005fd24f2bf7c884,6b4521a8363a7ebc5d95ac6ec6b64db81fcf21795187d7c4600c42b73fb4fb9870ab8d106c0fd2d292c1710e10437b20575ddb3cb32eb77a5618d94ddba600f2,76184ce9eea5b339ebf5304b57452c1ada1466610f0a58574d6c496798cee04b,TRUE,Success case 2 +2,0222db2054fef98344352a13bc0304a71da7b5e9a2f7fd1f3c9f3519a3d9377fb7,026aa26fcd626f8f55295859e9f8dd1f103149dd64d77c2bbba1bcf33bb37ebaa2,03bc7a19970c812118f74ba659b491e00dade6096ff62d1afe032a92b8671498ed,035628d1a69910daef614c7cae68d71ece55c5908af2360629e25c1b7de21eeb4b,12aa2aa469b3c037871a09d18ab18d3840219b1ed169f6ef9deae6d927949884a459705ae89a57522224ce3482dee00a41ba511188ae60efdeb736223eb66e7b,66503623468a78cfcef47888c85e0010ecd897f441d263448bfc7a89b882ab20,TRUE,Success case 3 +3,03dfa65bd3711eba75fa1996a0c1d95a4419bd835304152d9aa6efa590670f2af6,031bf61ba89009ee1266c9003a72e8e07d77877678ccda7f15325aadcd64ed186b,03b51081323d38fb0b75f0c1ec6755fdb79c239c327ca11269fe68ba8a878b704e,02d1b1f37a80217ba73785babfa63251052775f9d3ca65060054033288b7a3f66b,7907653d29c5722ae44510e7f2839f253450aefc833b7e0a3b38384032f847f2cf41136b2fe6a558ad125287d20c0117f2a30c4ac0c4cebbcfa1dd3a69d84200,f2996b3766d123a949e65541baf1d89d446360d05af51bd93f0445d8c472c952,TRUE,Success case 4 +4,02b15de5a3aefcfe2473916c76e619b5800ac7250ef93a9e6e0dd1505104fc58e7,0296c8e00dda60bb5565b77371ff913091978646b58ccf218bc591f68a75232e6e,03a4692be176ff89a972de9cc407083096847b950d1cae72b947665a3d5f4c2f01,03a7a7f9527fd387c2b2ce0c76669d646c78a3b470a4b34d3a2dabafc8505ef472,02277ca5a7acfe8ae13c2db4a8f74489d0ba100ed8b082381ddb6522c4510718ab88b8dbbd785c388ade79586cf6416f3c47a79670af84abccc788a5d9f2e327,0ceb45f560f2cf6b76a139ffe2c47c5ca6d26d6a3a210e59f197413bbec040b4,TRUE,Success case 5 +5,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,02637b2c3ea8ca80b9caecc50f4134c86ae9cf7a269133e7afc71f30e3a3cda60c,034bccb1c570ac1f3bc42d61fe35de605b99626501ccb20297e1acbbf2d7152aa1,0285b826c8dd175805901906b6c9b4140a30cbcc94c6e7dcf36476038bf90d4718,503562d36910cd2d61a4d07c8ff680265c713e63dde0dcb88e6ea3c58597bdc05b86db9af95eccc475ce2177f941c118fefed20227d4ce8ce9557cb008758de6,,TRUE,Success case 6 +6,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,02983a72b4cb44d4322641a7b2001900cd6ae0908a610546c73ed126accdba0514,0231c64e3efa506fdad6aad0f6084d5f6739de7f448d7e66f9d22f842638f41d60,03af1bc14b384eda28398df6a7900e567c5b6f6613cafce5027b98be015286f71b,5c7b27a33210750e9de8679d9f43497cf9f12ac642cde0a1fc26443aa2fc89bf71aabf7bac89f5d8a96cbe86daba155fa74d6f3e111136179e53b04eb6d7807f,35841ca532846e1cdd23a3d107824343584f88eff580929469865eae8355ee3c,TRUE,Success case 7 +7,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,TRUE,Success case 8 +8,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,FALSE,Swapped points case 1 +9,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,FALSE,Swapped points case 2 +10,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,FALSE,Swapped points case 3 +11,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,FALSE,Swapped points case 4 +12,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,FALSE,Swapped points case 5 +13,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,78a5544afa75bf152653fe55fb76926f2f65131ff090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb2d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,FALSE,Tampered proof (random bit-flip) +14,0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,03611410561c35dae13135e4ad8094baac9bbcf2f4e18498181a8ff8a6d43be9d9,021cb81121a00f89769903305a367ad3cc02d5b402b12c026e06ac94bde28cd608,03d9a98624c0c74fc7eebd39ed84175f80d03c774908e75ca737a0745d1c64e20a,78a5544afa75bf152653fe55fb76926f2f65131bf090972a0b0b37d310c28a6bde0e7bfacc10ac12d36f55316ba134b6ba0b844a65ae05cad53c0b296c6639bb,22616bb5fb6d7c68270f305122f2a09e833239c4b1c9a04e285119fb606ac794,FALSE,Tampered message (random bit-flip) diff --git a/tests/tests/test_dleq.py b/tests/tests/test_dleq.py new file mode 100644 index 00000000..f96a7afa --- /dev/null +++ b/tests/tests/test_dleq.py @@ -0,0 +1,77 @@ +import csv +from pathlib import Path +from unittest import TestCase +from binascii import unhexlify + +from embit.silent_payments.dleq import generate_dleq_proof, verify_dleq_proof, DLEQError + +_DATA_DIR = Path(__file__).parent / "data" +_GENERATE_CSV = _DATA_DIR / "test_vectors_generate_proof.csv" +_VERIFY_CSV = _DATA_DIR / "test_vectors_verify_proof.csv" +_FAKE_INFINITY = b"\x02" + b"\x00" * 32 # x=0 is not on secp256k1 + + +class TestBIP374GenerateProofVectors(TestCase): + """Official BIP-374 generate-proof test vectors.""" + + def test_vectors(self): + with open(_GENERATE_CSV, newline="") as fh: + for row in csv.DictReader(fh): + idx = row["index"] + G_hex = row["point_G"] + a_hex = row["scalar_a"] + B_hex = row["point_B"] + r_hex = row["auxrand_r"] + msg_hex = row["message"] + result = row["result_proof"] + comment = row["comment"] + + with self.subTest(index=idx, comment=comment): + G_bytes = unhexlify(G_hex) + + a_bytes = unhexlify(a_hex) + r_bytes = unhexlify(r_hex) + m = unhexlify(msg_hex) if msg_hex else None + B_sec = _FAKE_INFINITY if B_hex == "INFINITY" else unhexlify(B_hex) + + if result == "INVALID": + with self.assertRaises(DLEQError): + generate_dleq_proof( + a_bytes, B_sec, r=r_bytes, m=m, G=G_bytes + ) + else: + proof = generate_dleq_proof( + a_bytes, B_sec, r=r_bytes, m=m, G=G_bytes + ) + self.assertEqual(proof, unhexlify(result)) + + +class TestBIP374VerifyProofVectors(TestCase): + """Official BIP-374 verify-proof test vectors.""" + + def test_vectors(self): + with open(_VERIFY_CSV, newline="") as fh: + for row in csv.DictReader(fh): + idx = row["index"] + G_hex = row["point_G"] + A_hex = row["point_A"] + B_hex = row["point_B"] + C_hex = row["point_C"] + proof_hex = row["proof"] + msg_hex = row["message"] + expected = row["result_success"] == "TRUE" + comment = row["comment"] + + with self.subTest(index=idx, comment=comment): + G_bytes = unhexlify(G_hex) + + A_sec = unhexlify(A_hex) + B_sec = unhexlify(B_hex) + C_sec = unhexlify(C_hex) + proof = unhexlify(proof_hex) + m = unhexlify(msg_hex) if msg_hex else None + + result = verify_dleq_proof( + A_sec, B_sec, C_sec, proof, m=m, G=G_bytes + ) + self.assertEqual(result, expected) From d914f2f180571287b78b4f689aa562671a7a63de Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:31:48 -0300 Subject: [PATCH 31/61] fix(dleq): wrap parsed points in bytearray for in-place tweaks ec_pubkey_tweak_mul mutates the key in place and the pure-Python secp256k1 backend rejects immutable bytes, so DLEQ raised TypeError there (and on MicroPython). ec_pubkey_parse returns bytes; wrap the results in bytearray at every tweak_mul site, matching the bip352 create_outputs fix. --- src/embit/silent_payments/dleq.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/embit/silent_payments/dleq.py b/src/embit/silent_payments/dleq.py index be96f249..cc6e1c04 100644 --- a/src/embit/silent_payments/dleq.py +++ b/src/embit/silent_payments/dleq.py @@ -38,7 +38,9 @@ def _scalar_mult_G(scalar_bytes, G_sec): """ if G_sec == _G_COMPRESSED: return ec_pubkey_create(scalar_bytes) - pub = ec_pubkey_parse(G_sec) + # bytearray: tweak_mul mutates the point in place (required by the pure-Python + # secp256k1 backend, which rejects immutable bytes; works with ctypes too) + pub = bytearray(ec_pubkey_parse(G_sec)) ec_pubkey_tweak_mul(pub, scalar_bytes) return pub @@ -91,7 +93,7 @@ def generate_dleq_proof(a_bytes, B_sec, r=None, m=None, G=None): # C = a*B; ec_pubkey_parse rejects the point at infinity (spec is_infinite(B)). try: - C_internal = ec_pubkey_parse(B_sec) + C_internal = bytearray(ec_pubkey_parse(B_sec)) ec_pubkey_tweak_mul(C_internal, a_bytes) except (ValueError, OverflowError): raise DLEQError("Invalid B_sec") @@ -121,7 +123,7 @@ def generate_dleq_proof(a_bytes, B_sec, r=None, m=None, G=None): R1_internal = _scalar_mult_G(k_bytes, G_sec) # R1 = k*G R1_compressed = ec_pubkey_serialize(R1_internal, EC_COMPRESSED) - R2_internal = ec_pubkey_parse(B_sec) # R2 = k*B (fresh buffer) + R2_internal = bytearray(ec_pubkey_parse(B_sec)) # R2 = k*B (fresh buffer) ec_pubkey_tweak_mul(R2_internal, k_bytes) R2_compressed = ec_pubkey_serialize(R2_internal, EC_COMPRESSED) @@ -194,15 +196,15 @@ def verify_dleq_proof(A_sec, B_sec, C_sec, proof, m=None, G=None): # R1 = s*G + (-e)*A sG_internal = _scalar_mult_G(s_bytes, G_sec) - neg_eA_internal = ec_pubkey_parse(A_sec) + neg_eA_internal = bytearray(ec_pubkey_parse(A_sec)) ec_pubkey_tweak_mul(neg_eA_internal, neg_e_bytes) R1_internal = ec_pubkey_combine(sG_internal, neg_eA_internal) R1_compressed = ec_pubkey_serialize(R1_internal, EC_COMPRESSED) # R2 = s*B + (-e)*C - sB_internal = ec_pubkey_parse(B_sec) + sB_internal = bytearray(ec_pubkey_parse(B_sec)) ec_pubkey_tweak_mul(sB_internal, s_bytes) - neg_eC_internal = ec_pubkey_parse(C_sec) + neg_eC_internal = bytearray(ec_pubkey_parse(C_sec)) ec_pubkey_tweak_mul(neg_eC_internal, neg_e_bytes) R2_internal = ec_pubkey_combine(sB_internal, neg_eC_internal) R2_compressed = ec_pubkey_serialize(R2_internal, EC_COMPRESSED) From 1f74ac07536f4f4018f50472e1858d1336c03b17 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:34:48 -0300 Subject: [PATCH 32/61] test(dleq): run BIP-374 vectors on the pure-Python backend Factor the vector runners into helpers. The existing classes keep running on the active backend (the C/ctypes bindings when libsecp256k1 is present); add classes that rebind the dleq module's secp256k1 functions to the pure-Python backend. The ctypes backend returns mutable buffers, so it could not catch the missing bytearray wrap; this exercises the MicroPython path too. --- tests/tests/test_dleq.py | 157 +++++++++++++++++++++++++-------------- 1 file changed, 101 insertions(+), 56 deletions(-) diff --git a/tests/tests/test_dleq.py b/tests/tests/test_dleq.py index f96a7afa..9b8c2033 100644 --- a/tests/tests/test_dleq.py +++ b/tests/tests/test_dleq.py @@ -3,75 +3,120 @@ from unittest import TestCase from binascii import unhexlify -from embit.silent_payments.dleq import generate_dleq_proof, verify_dleq_proof, DLEQError +import embit.silent_payments.dleq as dleq +from embit.silent_payments.dleq import DLEQError +from embit.util import py_secp256k1 _DATA_DIR = Path(__file__).parent / "data" _GENERATE_CSV = _DATA_DIR / "test_vectors_generate_proof.csv" _VERIFY_CSV = _DATA_DIR / "test_vectors_verify_proof.csv" _FAKE_INFINITY = b"\x02" + b"\x00" * 32 # x=0 is not on secp256k1 +# secp256k1 bindings the dleq module resolves at import time. Rebinding these on +# the module forces the proof code through a specific backend. +_SECP_BINDINGS = ( + "ec_pubkey_create", + "ec_pubkey_parse", + "ec_pubkey_serialize", + "ec_pubkey_tweak_mul", + "ec_pubkey_combine", + "EC_COMPRESSED", +) -class TestBIP374GenerateProofVectors(TestCase): - """Official BIP-374 generate-proof test vectors.""" - def test_vectors(self): - with open(_GENERATE_CSV, newline="") as fh: - for row in csv.DictReader(fh): - idx = row["index"] - G_hex = row["point_G"] - a_hex = row["scalar_a"] +def _check_generate_vectors(test): + """Run the official BIP-374 generate-proof vectors against dleq.""" + with open(_GENERATE_CSV, newline="") as fh: + for row in csv.DictReader(fh): + idx = row["index"] + comment = row["comment"] + result = row["result_proof"] + with test.subTest(index=idx, comment=comment): + G_bytes = unhexlify(row["point_G"]) + a_bytes = unhexlify(row["scalar_a"]) + r_bytes = unhexlify(row["auxrand_r"]) + m = unhexlify(row["message"]) if row["message"] else None B_hex = row["point_B"] - r_hex = row["auxrand_r"] - msg_hex = row["message"] - result = row["result_proof"] - comment = row["comment"] - - with self.subTest(index=idx, comment=comment): - G_bytes = unhexlify(G_hex) - - a_bytes = unhexlify(a_hex) - r_bytes = unhexlify(r_hex) - m = unhexlify(msg_hex) if msg_hex else None - B_sec = _FAKE_INFINITY if B_hex == "INFINITY" else unhexlify(B_hex) - - if result == "INVALID": - with self.assertRaises(DLEQError): - generate_dleq_proof( - a_bytes, B_sec, r=r_bytes, m=m, G=G_bytes - ) - else: - proof = generate_dleq_proof( + B_sec = _FAKE_INFINITY if B_hex == "INFINITY" else unhexlify(B_hex) + + if result == "INVALID": + with test.assertRaises(DLEQError): + dleq.generate_dleq_proof( a_bytes, B_sec, r=r_bytes, m=m, G=G_bytes ) - self.assertEqual(proof, unhexlify(result)) + else: + proof = dleq.generate_dleq_proof( + a_bytes, B_sec, r=r_bytes, m=m, G=G_bytes + ) + test.assertEqual(proof, unhexlify(result)) + + +def _check_verify_vectors(test): + """Run the official BIP-374 verify-proof vectors against dleq.""" + with open(_VERIFY_CSV, newline="") as fh: + for row in csv.DictReader(fh): + idx = row["index"] + comment = row["comment"] + expected = row["result_success"] == "TRUE" + with test.subTest(index=idx, comment=comment): + G_bytes = unhexlify(row["point_G"]) + A_sec = unhexlify(row["point_A"]) + B_sec = unhexlify(row["point_B"]) + C_sec = unhexlify(row["point_C"]) + proof = unhexlify(row["proof"]) + m = unhexlify(row["message"]) if row["message"] else None + + result = dleq.verify_dleq_proof( + A_sec, B_sec, C_sec, proof, m=m, G=G_bytes + ) + test.assertEqual(result, expected) + + +class TestBIP374GenerateProofVectors(TestCase): + """Official BIP-374 generate-proof vectors (active backend).""" + + def test_vectors(self): + _check_generate_vectors(self) class TestBIP374VerifyProofVectors(TestCase): - """Official BIP-374 verify-proof test vectors.""" + """Official BIP-374 verify-proof vectors (active backend).""" def test_vectors(self): - with open(_VERIFY_CSV, newline="") as fh: - for row in csv.DictReader(fh): - idx = row["index"] - G_hex = row["point_G"] - A_hex = row["point_A"] - B_hex = row["point_B"] - C_hex = row["point_C"] - proof_hex = row["proof"] - msg_hex = row["message"] - expected = row["result_success"] == "TRUE" - comment = row["comment"] - - with self.subTest(index=idx, comment=comment): - G_bytes = unhexlify(G_hex) - - A_sec = unhexlify(A_hex) - B_sec = unhexlify(B_hex) - C_sec = unhexlify(C_hex) - proof = unhexlify(proof_hex) - m = unhexlify(msg_hex) if msg_hex else None - - result = verify_dleq_proof( - A_sec, B_sec, C_sec, proof, m=m, G=G_bytes - ) - self.assertEqual(result, expected) + _check_verify_vectors(self) + + +class _PurePythonBackendMixin: + """Force the dleq module onto the pure-Python secp256k1 backend. + + The ctypes backend returns mutable buffers from ec_pubkey_parse, so a missing + bytearray wrap would still pass there; the pure-Python backend rejects + immutable bytes. Running the vectors on it guards the MicroPython path. + """ + + def setUp(self): + super().setUp() + self._saved_bindings = { + name: getattr(dleq, name) for name in _SECP_BINDINGS + } + for name in _SECP_BINDINGS: + setattr(dleq, name, getattr(py_secp256k1, name)) + + def tearDown(self): + for name, value in self._saved_bindings.items(): + setattr(dleq, name, value) + super().tearDown() + + +class TestBIP374GenerateProofVectorsPurePython(_PurePythonBackendMixin, TestCase): + """Official BIP-374 generate-proof vectors on the pure-Python backend.""" + + def test_vectors(self): + _check_generate_vectors(self) + + +class TestBIP374VerifyProofVectorsPurePython(_PurePythonBackendMixin, TestCase): + """Official BIP-374 verify-proof vectors on the pure-Python backend.""" + + def test_vectors(self): + _check_verify_vectors(self) From 0a1b31e14871f01b0ff4a5dd84a2d10b354f290e Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 17:36:23 -0300 Subject: [PATCH 33/61] fix(dleq): match BIP-374 reference on zero-scalar verify terms verify_dleq_proof returned False whenever (-e) mod n == 0, but the reference treats a zero-scalar multiplication as the point at infinity and only fails if R1/R2 itself is infinite. Model point_mul/point_add via _mul_point and _add_points (None == infinity) and drop the early bail-out. --- src/embit/silent_payments/dleq.py | 50 +++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/embit/silent_payments/dleq.py b/src/embit/silent_payments/dleq.py index cc6e1c04..4b7134b7 100644 --- a/src/embit/silent_payments/dleq.py +++ b/src/embit/silent_payments/dleq.py @@ -45,6 +45,30 @@ def _scalar_mult_G(scalar_bytes, G_sec): return pub +def _mul_point(base_sec, scalar): + """Return scalar * base_sec, or None (point at infinity) when scalar == 0. + + Mirrors the BIP-374 reference point_mul, where a zero scalar yields the + identity element. The caller passes scalar already reduced to [0, n). + """ + if scalar == 0: + return None + return _scalar_mult_G(scalar.to_bytes(32, "big"), base_sec) + + +def _add_points(p1, p2): + """Return p1 + p2, treating None as the point at infinity. + + ec_pubkey_combine raises ValueError when the sum is the point at infinity, + which callers convert to a verification failure (spec is_infinite check). + """ + if p1 is None: + return p2 + if p2 is None: + return p1 + return ec_pubkey_combine(p1, p2) + + def generate_dleq_proof(a_bytes, B_sec, r=None, m=None, G=None): """ Generate a 64-byte DLEQ proof (BIP-374 GenerateProof). @@ -182,31 +206,25 @@ def verify_dleq_proof(A_sec, B_sec, C_sec, proof, m=None, G=None): return False neg_e = (-e) % SECP256K1_ORDER - if neg_e == 0: - return False m_prime = m if m is not None else b"" - s_bytes = s.to_bytes(32, "big") - neg_e_bytes = neg_e.to_bytes(32, "big") # ec_pubkey_parse rejects the point at infinity (spec is_infinite checks - # for A, B, C). ec_pubkey_combine raises ValueError when R1 or R2 would - # be the point at infinity (spec is_infinite(R1) / is_infinite(R2)). - # ec_pubkey_tweak_mul mutates in-place; each point gets a fresh buffer. + # for A, B, C). A zero scalar (s or -e) yields the point at infinity, which + # _mul_point/_add_points carry through as None, matching the reference's + # point_mul/point_add instead of bailing out early. R1/R2 being the point + # at infinity (None, or a ValueError from ec_pubkey_combine) is a failure. # R1 = s*G + (-e)*A - sG_internal = _scalar_mult_G(s_bytes, G_sec) - neg_eA_internal = bytearray(ec_pubkey_parse(A_sec)) - ec_pubkey_tweak_mul(neg_eA_internal, neg_e_bytes) - R1_internal = ec_pubkey_combine(sG_internal, neg_eA_internal) + R1_internal = _add_points(_mul_point(G_sec, s), _mul_point(A_sec, neg_e)) + if R1_internal is None: + return False R1_compressed = ec_pubkey_serialize(R1_internal, EC_COMPRESSED) # R2 = s*B + (-e)*C - sB_internal = bytearray(ec_pubkey_parse(B_sec)) - ec_pubkey_tweak_mul(sB_internal, s_bytes) - neg_eC_internal = bytearray(ec_pubkey_parse(C_sec)) - ec_pubkey_tweak_mul(neg_eC_internal, neg_e_bytes) - R2_internal = ec_pubkey_combine(sB_internal, neg_eC_internal) + R2_internal = _add_points(_mul_point(B_sec, s), _mul_point(C_sec, neg_e)) + if R2_internal is None: + return False R2_compressed = ec_pubkey_serialize(R2_internal, EC_COMPRESSED) # Recompute e' = int(H_challenge(A || B || C || G || R1 || R2 || m')) From 25b49c2425879329829793ef2eb1a31e0e036370 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Sun, 31 May 2026 02:17:05 +0530 Subject: [PATCH 34/61] feat: add SP PSBT support (375 & 376) --- .gitignore | 3 + src/embit/ec.py | 12 + src/embit/psbt.py | 12 +- src/embit/silent_payments/__init__.py | 42 + src/embit/silent_payments/bip352.py | 58 + src/embit/silent_payments/ecdh.py | 196 +++ src/embit/silent_payments/fields.py | 44 + src/embit/silent_payments/psbt.py | 460 ++++++ src/embit/silent_payments/validator.py | 429 +++++ tests/tests/data/bip375_test_vectors.json | 1759 +++++++++++++++++++++ tests/tests/test_psbt_signer_sp.py | 342 ++++ 11 files changed, 3352 insertions(+), 5 deletions(-) create mode 100644 src/embit/silent_payments/ecdh.py create mode 100644 src/embit/silent_payments/fields.py create mode 100644 src/embit/silent_payments/psbt.py create mode 100644 src/embit/silent_payments/validator.py create mode 100644 tests/tests/data/bip375_test_vectors.json create mode 100644 tests/tests/test_psbt_signer_sp.py diff --git a/.gitignore b/.gitignore index a2e7c4ef..2a23cb29 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ settings.json src/embit/util/prebuilt/*.so src/embit/util/prebuilt/*.dylib src/embit/util/prebuilt/*.dll + +.claude/ +CLAUDE.md \ No newline at end of file diff --git a/src/embit/ec.py b/src/embit/ec.py index b36d861d..23b5f957 100644 --- a/src/embit/ec.py +++ b/src/embit/ec.py @@ -180,6 +180,18 @@ def taproot_tweak(self, h=b""): pk = PrivateKey(secp256k1.ec_privkey_negate(res)) return pk + def sp_spend_tweak(self, tweak: bytes) -> "PrivateKey": + """BIP-352 spend key: add the per-output tweak t_k to the spend secret. + + Returns a PrivateKey whose pubkey equals the silent-payment output key + P_k = B_spend + t_k*G. No TapTweak and no parity negation are applied + here; Schnorr signing handles output-key y-parity. + """ + if len(tweak) != 32 or not secp256k1.ec_seckey_verify(tweak): + raise EmbitError("Invalid silent payment tweak") + res = secp256k1.ec_privkey_add(self._secret, tweak) + return PrivateKey(res) + @classmethod def from_wif(cls, s): """Import private key from Wallet Import Format string.""" diff --git a/src/embit/psbt.py b/src/embit/psbt.py index 5d7b742c..c60b5d05 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -432,8 +432,7 @@ def read_value(self, stream, k, version=None): locktime = int.from_bytes(v, "little") if locktime >= LOCKTIME_THRESHOLD or locktime == 0: raise PSBTError( - "Height-based locktime must be > 0 and < %d" - % LOCKTIME_THRESHOLD + "Height-based locktime must be > 0 and < %d" % LOCKTIME_THRESHOLD ) self.required_height_locktime = locktime @@ -963,6 +962,9 @@ def fee(self): fee -= sum([out.value for out in self.tx.vout]) return fee + def _write_extra_globals(self, stream) -> int: + return 0 + def write_to(self, stream) -> int: # magic bytes r = stream.write(self.MAGIC) @@ -997,6 +999,8 @@ def write_to(self, stream) -> int: r += ser_string(stream, bytes([self.tx_modifiable_flags])) r += ser_string(stream, b"\xfb") r += ser_string(stream, self.version.to_bytes(4, "little")) + + r += self._write_extra_globals(stream) # unknown for key in self.unknown: r += ser_string(stream, key) @@ -1049,9 +1053,7 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL): break value = read_string(stream) if key in global_kvs: - raise PSBTError( - "Duplicated global key: %s" % hexlify(key).decode() - ) + raise PSBTError("Duplicated global key: %s" % hexlify(key).decode()) global_kvs[key] = value # Determine PSBT version from PSBT_GLOBAL_VERSION (0xfb) diff --git a/src/embit/silent_payments/__init__.py b/src/embit/silent_payments/__init__.py index e69de29b..5d4c589d 100644 --- a/src/embit/silent_payments/__init__.py +++ b/src/embit/silent_payments/__init__.py @@ -0,0 +1,42 @@ +from . import bip352 +from . import dleq +from .fields import ( + SPFieldError, + SPValidationError, + SilentPaymentData, +) +from .ecdh import ( + compute_ecdh_share, + compute_global_ecdh_share, + compute_dleq_proof, + compute_global_dleq_proof, + get_eligible_inputs, +) +from .bip352 import create_outputs +from .validator import BIP375Validator, validate_bip375_psbt +from .psbt import ( + SPInputScope, + SPOutputScope, + SilentPaymentsPSBT, + finalize_sp_spends, +) + +__all__ = [ + "bip352", + "dleq", + "SPFieldError", + "SPValidationError", + "SilentPaymentData", + "compute_ecdh_share", + "compute_global_ecdh_share", + "compute_dleq_proof", + "compute_global_dleq_proof", + "get_eligible_inputs", + "create_outputs", + "BIP375Validator", + "validate_bip375_psbt", + "SPInputScope", + "SPOutputScope", + "SilentPaymentsPSBT", + "finalize_sp_spends", +] diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 66c2fc27..ba26e391 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -15,6 +15,7 @@ ec_pubkey_tweak_add, ec_seckey_verify, ec_privkey_negate, + EC_COMPRESSED, ) from binascii import hexlify @@ -180,3 +181,60 @@ def create_outputs(input_privkeys, outpoints, recipients): k += 1 return result + + +def derive_silent_payment_outputs(ecdh_share, recipients, shared_secret=None): + """ + Derive silent payment outputs for recipients from a precomputed ECDH share. + + Unlike create_outputs (which derives the share from input private keys), + this takes the ECDH shared-secret point directly, as used by the BIP-375 + PSBT flow where shares are carried in the PSBT. + + Args: + ecdh_share: The ECDH shared secret point C (33 bytes) + recipients: List of (scan_key, spend_key, label) tuples + shared_secret: Precomputed xonly shared secret (for efficiency) + + Returns: + Dict mapping recipient index to output pubkey xonly (32 bytes each) + """ + if not recipients: + return {} + + # Use precomputed or default to the full 33-byte compressed point (BIP-352 §1) + if shared_secret is None: + shared_secret = ecdh_share + + result = {} + + for k, (scan_key, spend_key, label) in enumerate(recipients): + # For labeled recipients, tweak the spend key + if label is not None and label != 0: + if isinstance(label, int): + label_bytes = label.to_bytes(4, "big") + else: + label_bytes = label + + tweak = tagged_hash("BIP0352/Label", scan_key.sec() + label_bytes) + spend_internal = bytearray(ec_pubkey_parse(spend_key.sec())) + ec_pubkey_tweak_add(spend_internal, tweak) + tweaked_spend = ec_pubkey_serialize(spend_internal, EC_COMPRESSED) + else: + tweaked_spend = spend_key.sec() + + # Compute t_k + t_k = tagged_hash( + "BIP0352/SharedSecret", + shared_secret + k.to_bytes(4, "big"), + ) + + # P_k = B_spend + t_k + p_k_internal = bytearray(ec_pubkey_parse(tweaked_spend)) + ec_pubkey_tweak_add(p_k_internal, t_k) + p_k = ec_pubkey_serialize(p_k_internal, EC_COMPRESSED) + + # Store as p2tr output (extract xonly) + result[k] = p_k[1:33] + + return result diff --git a/src/embit/silent_payments/ecdh.py b/src/embit/silent_payments/ecdh.py new file mode 100644 index 00000000..62814aba --- /dev/null +++ b/src/embit/silent_payments/ecdh.py @@ -0,0 +1,196 @@ +""" +BIP-375 ECDH share and DLEQ proof computation, plus input eligibility. +""" + +import os + +from .. import ec +from . import dleq +from ..util.key import SECP256K1_ORDER +from ..util.secp256k1 import ( + ec_pubkey_parse, + ec_pubkey_serialize, + ec_pubkey_tweak_mul, + EC_COMPRESSED, + ec_seckey_verify, +) +from .fields import SPFieldError, SPValidationError + + +def compute_ecdh_share(private_key: bytes, scan_key: ec.PublicKey) -> bytes: + """ + Compute ECDH share for a single private key. + + Args: + private_key: 32-byte private key + scan_key: The scan key to compute share with + + Returns: + 33-byte ECDH share (a·B_scan) compressed + """ + if len(private_key) != 32: + raise SPFieldError("Private key must be 32 bytes") + + # Compute a·B_scan + b_internal = bytearray(ec_pubkey_parse(scan_key.sec())) + ec_pubkey_tweak_mul(b_internal, private_key) + return ec_pubkey_serialize(b_internal, EC_COMPRESSED) + + +def compute_global_ecdh_share(private_keys, scan_key: ec.PublicKey): + """ + Compute global ECDH share from multiple private keys. + + Args: + private_keys: List of 32-byte private keys (all eligible inputs) + scan_key: The scan key to compute share with + + Returns: + 33-byte ECDH share (a_sum·B_scan) compressed, or None if a_sum=0 + """ + if not private_keys: + return None + + # Verify all private keys + for priv in private_keys: + if not ec_seckey_verify(priv): + raise SPFieldError("Invalid private key") + + # Sum all private keys mod n + a_sum = sum(int.from_bytes(priv, "big") for priv in private_keys) % SECP256K1_ORDER + if a_sum == 0: + return None + + a_sum_bytes = a_sum.to_bytes(32, "big") + + # Compute a_sum·B_scan + b_internal = bytearray(ec_pubkey_parse(scan_key.sec())) + ec_pubkey_tweak_mul(b_internal, a_sum_bytes) + return ec_pubkey_serialize(b_internal, EC_COMPRESSED) + + +def compute_dleq_proof( + private_key: bytes, + scan_key: ec.PublicKey, + ecdh_share: bytes, + aux_rand=None, +) -> bytes: + """ + Generate DLEQ proof for an input's ECDH share. + + Args: + private_key: 32-byte private key (a) + scan_key: The scan key (B_scan) + ecdh_share: The ECDH share (a·B_scan) - used for self-verification + aux_rand: 32-byte auxiliary randomness. When None, fresh bytes are + generated via os.urandom; pass explicit bytes for + deterministic behaviour or hardware wallet use. + + Returns: + 64-byte DLEQ proof + """ + r = aux_rand if aux_rand is not None else os.urandom(32) + try: + return dleq.generate_dleq_proof(private_key, scan_key.sec(), r=r) + except dleq.DLEQError as e: + raise SPFieldError("Failed to generate DLEQ proof: {}".format(e)) + + +def compute_global_dleq_proof( + private_keys, + scan_key: ec.PublicKey, + global_share: bytes, + aux_rand=None, +) -> bytes: + """ + Generate DLEQ proof for global ECDH share. + + Args: + private_keys: List of 32-byte private keys (all eligible inputs) + scan_key: The scan key (B_scan) + global_share: The global ECDH share (a_sum·B_scan) + aux_rand: 32-byte auxiliary randomness. When None, fresh bytes are + generated via os.urandom. + + Returns: + 64-byte DLEQ proof + """ + # Sum all private keys mod n + a_sum = sum(int.from_bytes(priv, "big") for priv in private_keys) % SECP256K1_ORDER + if a_sum == 0: + raise SPFieldError("Cannot generate proof for zero sum") + + a_sum_bytes = a_sum.to_bytes(32, "big") + r = aux_rand if aux_rand is not None else os.urandom(32) + + try: + return dleq.generate_dleq_proof(a_sum_bytes, scan_key.sec(), r=r) + except dleq.DLEQError as e: + raise SPFieldError("Failed to generate global DLEQ proof: {}".format(e)) + + +def pubkey_hash_from_script(script, redeem_script=None): + """Return the 20-byte HASH160(pubkey) committed by a single-key script. + + Handles the SP-eligible script types (P2WPKH, P2PKH, P2SH-P2WPKH); returns + None for any other type. This is the single source of truth for matching a + pubkey to an input script, used by both the signer and the validator. + """ + if script is None: + return None + script_type = script.script_type() + if script_type == "p2wpkh": + return bytes(script.data[2:22]) + if script_type == "p2pkh": + return bytes(script.data[3:23]) + if ( + script_type == "p2sh" + and redeem_script is not None + and redeem_script.script_type() == "p2wpkh" + ): + return bytes(redeem_script.data[2:22]) + return None + + +def get_eligible_inputs(inputs, has_sp_outputs: bool = False): + """ + Get list of eligible input indices for SP computation. + + An input is eligible if: + 1. Its previous output is P2WPKH, P2PKH, or P2SH-P2WPKH + 2. When SP outputs are present, no Segwit version > 1 inputs are allowed + + Args: + inputs: List of PSBT input scopes + has_sp_outputs: Whether the PSBT has any SP outputs + + Returns: + List of eligible input indices + """ + eligible = [] + + for i, inp in enumerate(inputs): + script = inp.script_pubkey + if script is None: + continue + + script_type = script.script_type() + + # With SP outputs, reject Segwit v>1 + if has_sp_outputs and script_type == "p2tr": + raise SPValidationError( + "Input {} uses Segwit version > 1 (P2TR) with SP outputs".format(i) + ) + + # Eligible: P2PKH, P2WPKH, P2SH-P2WPKH + if script_type in {"p2pkh", "p2wpkh", "p2sh"}: + # For P2SH, check if it wraps P2WPKH + if script_type == "p2sh" and inp.redeem_script: + redeem_type = inp.redeem_script.script_type() + if redeem_type == "p2wpkh": + # For P2SH-wrapped, we need the public key from redeem script + eligible.append(i) + elif script_type != "p2sh": + eligible.append(i) + + return eligible diff --git a/src/embit/silent_payments/fields.py b/src/embit/silent_payments/fields.py new file mode 100644 index 00000000..a340d04a --- /dev/null +++ b/src/embit/silent_payments/fields.py @@ -0,0 +1,44 @@ +""" +BIP-375 Silent Payment shared types: exceptions and the SP output data class. + +This is the dependency-root leaf of the silent_payments subpackage — the +exception types and SilentPaymentData are imported by ecdh, psbt, and +validator, so they live here to keep those modules free of import cycles. +""" + +from .. import ec +from ..base import EmbitError + + +class SPFieldError(EmbitError): + pass + + +class SPValidationError(EmbitError): + pass + + +class SilentPaymentData: + """Represents PSBT_OUT_SP_V0_INFO field (scan key + spend key).""" + + def __init__(self, scan_key: ec.PublicKey, spend_key: ec.PublicKey): + self.scan_key = scan_key + self.spend_key = spend_key + + def serialize(self) -> bytes: + """Serialize as 33-byte scan key + 33-byte spend key.""" + return self.scan_key.sec() + self.spend_key.sec() + + @classmethod + def parse(cls, data: bytes) -> "SilentPaymentData": + """Parse from 66 bytes (33-byte scan key + 33-byte spend key).""" + if len(data) != 66: + raise SPFieldError( + "PSBT_OUT_SP_V0_INFO must be 66 bytes, got {}".format(len(data)) + ) + try: + scan_key = ec.PublicKey.parse(data[:33]) + spend_key = ec.PublicKey.parse(data[33:66]) + return cls(scan_key, spend_key) + except Exception as e: + raise SPFieldError("Invalid SP data: {}".format(e)) diff --git a/src/embit/silent_payments/psbt.py b/src/embit/silent_payments/psbt.py new file mode 100644 index 00000000..6665ab7e --- /dev/null +++ b/src/embit/silent_payments/psbt.py @@ -0,0 +1,460 @@ +""" +SP-aware PSBT scopes and subclass. + +Sections: + 1. SPInputScope / SPOutputScope — per-input/output BIP-375 field handlers + 2. SilentPaymentsPSBT — PSBT subclass with global SP fields and sign_with() SP hook + 3. finalize_sp_spends — BIP-376 finalizer for SP spend inputs +""" + +import io +from collections import OrderedDict + +from .. import ec, hashes +from ..base import EmbitError +from ..script import Witness +from ..psbt import ( + PSBT, + DerivationPath, + InputScope, + OutputScope, + PSBTError, + SIGHASH, + read_string, + ser_string, +) +from .ecdh import ( + compute_ecdh_share, + compute_dleq_proof, + get_eligible_inputs, + pubkey_hash_from_script, +) +from .fields import SilentPaymentData, SPFieldError, SPValidationError + +# ── scopes ──────────────────────────────────────────────────────────────────── + + +class SPInputScope(InputScope): + def __init__(self, *args, **kwargs): + self.sp_ecdh_shares = OrderedDict() # scan_key -> ecdh_share (33 bytes) + self.sp_dleq_proofs = OrderedDict() # scan_key -> dleq_proof (64 bytes) + self.sp_spend_bip32_derivations = OrderedDict() # pub_bytes -> DerivationPath + self.sp_tweak = None # 32 bytes or None + super().__init__(*args, **kwargs) + + def clear_metadata(self, *args, **kwargs): + super().clear_metadata(*args, **kwargs) + self.sp_ecdh_shares = OrderedDict() + self.sp_dleq_proofs = OrderedDict() + self.sp_spend_bip32_derivations = OrderedDict() + self.sp_tweak = None + + def update(self, other): + super().update(other) + if isinstance(other, SPInputScope): + self.sp_ecdh_shares.update(other.sp_ecdh_shares) + self.sp_dleq_proofs.update(other.sp_dleq_proofs) + self.sp_spend_bip32_derivations.update(other.sp_spend_bip32_derivations) + self.sp_tweak = other.sp_tweak or self.sp_tweak + + def read_value(self, stream, k, version=None): + if k[0] == 0x1D: # PSBT_IN_SP_ECDH_SHARE (BIP-375) + v = read_string(stream) + if version != 2: + raise PSBTError("PSBT_IN_SP_ECDH_SHARE not allowed in PSBTv0") + if len(k) != 34: + raise PSBTError("Invalid PSBT_IN_SP_ECDH_SHARE key length") + if len(v) != 33: + raise PSBTError("PSBT_IN_SP_ECDH_SHARE value must be 33 bytes") + scan_key = k[1:] + if scan_key in self.sp_ecdh_shares: + raise PSBTError("Duplicated PSBT_IN_SP_ECDH_SHARE for scan key") + self.sp_ecdh_shares[scan_key] = v + elif k[0] == 0x1E: # PSBT_IN_SP_DLEQ (BIP-375) + v = read_string(stream) + if version != 2: + raise PSBTError("PSBT_IN_SP_DLEQ not allowed in PSBTv0") + if len(k) != 34: + raise PSBTError("Invalid PSBT_IN_SP_DLEQ key length") + if len(v) != 64: + raise PSBTError("PSBT_IN_SP_DLEQ value must be 64 bytes") + scan_key = k[1:] + if scan_key in self.sp_dleq_proofs: + raise PSBTError("Duplicated PSBT_IN_SP_DLEQ for scan key") + self.sp_dleq_proofs[scan_key] = v + elif k[0] == 0x1F: # PSBT_IN_SP_SPEND_BIP32_DERIVATION (BIP-376) + v = read_string(stream) + if version != 2: + raise PSBTError( + "PSBT_IN_SP_SPEND_BIP32_DERIVATION not allowed in PSBTv0" + ) + if len(k) != 34: + raise PSBTError("Invalid PSBT_IN_SP_SPEND_BIP32_DERIVATION key length") + pub_bytes = k[1:] + if pub_bytes in self.sp_spend_bip32_derivations: + raise PSBTError( + "Duplicated PSBT_IN_SP_SPEND_BIP32_DERIVATION for pubkey" + ) + self.sp_spend_bip32_derivations[pub_bytes] = DerivationPath.read_from( + io.BytesIO(v) + ) + elif k == b"\x20": # PSBT_IN_SP_TWEAK (BIP-376) + v = read_string(stream) + if version != 2: + raise PSBTError("PSBT_IN_SP_TWEAK not allowed in PSBTv0") + if len(v) != 32: + raise PSBTError("PSBT_IN_SP_TWEAK value must be 32 bytes") + if self.sp_tweak is not None: + raise PSBTError("Duplicated PSBT_IN_SP_TWEAK") + self.sp_tweak = v + else: + super().read_value(stream, k, version=version) + + def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: + r = super().write_to(stream, skip_separator=True, version=version, **kwargs) + if version == 2: + for scan_key in self.sp_ecdh_shares: + r += ser_string(stream, b"\x1d" + scan_key) + r += ser_string(stream, self.sp_ecdh_shares[scan_key]) + for scan_key in self.sp_dleq_proofs: + r += ser_string(stream, b"\x1e" + scan_key) + r += ser_string(stream, self.sp_dleq_proofs[scan_key]) + for pub_bytes, derivation in self.sp_spend_bip32_derivations.items(): + r += ser_string(stream, b"\x1f" + pub_bytes) + r += ser_string(stream, derivation.serialize()) + if self.sp_tweak is not None: + r += ser_string(stream, b"\x20") + r += ser_string(stream, self.sp_tweak) + if not skip_separator: + r += stream.write(b"\x00") + return r + + +class SPOutputScope(OutputScope): + def __init__(self, *args, **kwargs): + self.sp_data = None # SilentPaymentData (PSBT_OUT_SP_V0_INFO) + self.sp_label = None # uint32 label (PSBT_OUT_SP_V0_LABEL) + super().__init__(*args, **kwargs) + + def clear_metadata(self, *args, **kwargs): + super().clear_metadata(*args, **kwargs) + self.sp_data = None + self.sp_label = None + + def update(self, other): + super().update(other) + if isinstance(other, SPOutputScope): + self.sp_data = other.sp_data or self.sp_data + self.sp_label = ( + other.sp_label if other.sp_label is not None else self.sp_label + ) + + def read_value(self, stream, k, version=None): + if k == b"\x09": # PSBT_OUT_SP_V0_INFO (BIP-375) + v = read_string(stream) + if version != 2: + raise PSBTError("PSBT_OUT_SP_V0_INFO not allowed in PSBTv0") + if len(v) != 66: + raise PSBTError("PSBT_OUT_SP_V0_INFO must be 66 bytes") + if self.sp_data is not None: + raise PSBTError("Duplicated PSBT_OUT_SP_V0_INFO") + try: + self.sp_data = SilentPaymentData.parse(v) + except SPFieldError as e: + raise PSBTError("Invalid PSBT_OUT_SP_V0_INFO: {}".format(e)) + elif k == b"\x0a": # PSBT_OUT_SP_V0_LABEL (BIP-375) + v = read_string(stream) + if version != 2: + raise PSBTError("PSBT_OUT_SP_V0_LABEL not allowed in PSBTv0") + if len(v) != 4: + raise PSBTError("PSBT_OUT_SP_V0_LABEL must be 4 bytes") + if self.sp_label is not None: + raise PSBTError("Duplicated PSBT_OUT_SP_V0_LABEL") + self.sp_label = int.from_bytes(v, "little") + else: + super().read_value(stream, k, version=version) + + def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: + r = super().write_to(stream, skip_separator=True, version=version, **kwargs) + if self.sp_data is not None: + r += ser_string(stream, b"\x09") + r += ser_string(stream, self.sp_data.serialize()) + if self.sp_label is not None: + r += ser_string(stream, b"\x0a") + r += ser_string(stream, self.sp_label.to_bytes(4, "little")) + if not skip_separator: + r += stream.write(b"\x00") + return r + + +# ── PSBT subclass ───────────────────────────────────────────────────────────── + + +class SilentPaymentsPSBT(PSBT): + PSBTIN_CLS = SPInputScope + PSBTOUT_CLS = SPOutputScope + + def __init__(self, *args, **kwargs): + self.sp_ecdh_shares = OrderedDict() # scan_key -> ecdh_share (33 bytes) + self.sp_dleq_proofs = OrderedDict() # scan_key -> dleq_proof (64 bytes) + super().__init__(*args, **kwargs) + + @classmethod + def _validate_v2_output(cls, out, i): + if out.value is None: + raise PSBTError( + "PSBTv2 output %d missing required PSBT_OUT_AMOUNT (0x03)" % i + ) + if out.script_pubkey is None and getattr(out, "sp_data", None) is None: + raise PSBTError( + "PSBTv2 output %d missing required PSBT_OUT_SCRIPT (0x04)" % i + ) + + def add_output(self, output_scope): + if not self.is_outputs_modifiable(): + raise PSBTError("Outputs are not modifiable") + if self.version == 2: + if output_scope.value is None: + raise PSBTError("PSBTv2 output must have PSBT_OUT_AMOUNT") + if ( + output_scope.script_pubkey is None + and getattr(output_scope, "sp_data", None) is None + ): + raise PSBTError("PSBTv2 output must have PSBT_OUT_SCRIPT") + self.outputs.append(output_scope) + if self.version == 2: + self._raw_output_count_from_global = len(self.outputs) + + def parse_unknowns(self): + super().parse_unknowns() + for k in list(self.unknown): + if k[0] == 0x07 and len(k) == 34: # PSBT_GLOBAL_SP_ECDH_SHARE + if self.version != 2: + continue + v = self.unknown.pop(k) + if len(v) != 33: + raise PSBTError("PSBT_GLOBAL_SP_ECDH_SHARE value must be 33 bytes") + scan_key = k[1:] + if scan_key in self.sp_ecdh_shares: + raise PSBTError("Duplicated PSBT_GLOBAL_SP_ECDH_SHARE for scan key") + self.sp_ecdh_shares[scan_key] = v + elif k[0] == 0x08 and len(k) == 34: # PSBT_GLOBAL_SP_DLEQ + if self.version != 2: + continue + v = self.unknown.pop(k) + if len(v) != 64: + raise PSBTError("PSBT_GLOBAL_SP_DLEQ value must be 64 bytes") + scan_key = k[1:] + if scan_key in self.sp_dleq_proofs: + raise PSBTError("Duplicated PSBT_GLOBAL_SP_DLEQ for scan key") + self.sp_dleq_proofs[scan_key] = v + + def _write_extra_globals(self, stream) -> int: + r = 0 + if self.version == 2: + for scan_key in self.sp_ecdh_shares: + r += ser_string(stream, b"\x07" + scan_key) + r += ser_string(stream, self.sp_ecdh_shares[scan_key]) + for scan_key in self.sp_dleq_proofs: + r += ser_string(stream, b"\x08" + scan_key) + r += ser_string(stream, self.sp_dleq_proofs[scan_key]) + return r + + def sign_with(self, root, sighash=None, **kwargs): + if sighash is not None: + counter = super().sign_with(root, sighash=sighash, **kwargs) + else: + counter = super().sign_with(root, **kwargs) + if self.version == 2 and any(out.sp_data is not None for out in self.outputs): + counter += self._sign_with_sp(root) + if self.version == 2: + counter += self._sign_sp_spends(root) + return counter + + @staticmethod + def _signing_fingerprint(root): + """Resolve the signing fingerprint for ``root``. + + Returns ``(fingerprint, can_sign)``. ``fingerprint`` is None for raw/WIF + keys (which match by key material instead). ``can_sign`` is False when + ``root`` is a public-only descriptor key that cannot produce signatures. + """ + fingerprint = None + if hasattr(root, "origin"): + if not getattr(root, "is_private", True): + return None, False + if getattr(root, "is_extended", False): + fingerprint = root.fingerprint + if not fingerprint and hasattr(root, "my_fingerprint"): + fingerprint = root.my_fingerprint + return fingerprint, True + + @staticmethod + def _derive_hdkey(root, derivation): + """Derive the HDKey for ``derivation``, honoring the root's origin prefix. + + Returns the derived HDKey, or None if the origin prefix doesn't match. + """ + der = derivation.derivation + if hasattr(root, "origin"): + if root.origin: + prefix = root.origin.derivation + if der[: len(prefix)] != prefix: + return None + der = der[len(prefix) :] + return root.key.derive(der) + return root.derive(der) + + def sign_input_with_sp_tweak( + self, + key: "ec.PrivateKey", + input_index: int, + inp=None, + sighash=SIGHASH.DEFAULT, + ) -> int: + """BIP-376: sign a Silent Payment spend input using sp_tweak field.""" + inp = inp or self.inputs[input_index] + sp_tweak = getattr(inp, "sp_tweak", None) + if sp_tweak is None: + return 0 + if not inp.is_taproot: + return 0 + try: + pk = key.sp_spend_tweak(sp_tweak) + except (EmbitError, ValueError): + return 0 + output_xonly = inp.utxo.script_pubkey.data[2:34] + if pk.xonly() != output_xonly: + return 0 + h = self.sighash(input_index, sighash=sighash) + sig = pk.schnorr_sign(h) + sigdata = sig.serialize() + if sighash != SIGHASH.DEFAULT: + sigdata += bytes([sighash]) + inp.taproot_key_sig = sigdata + return 1 + + def _sign_sp_spends(self, root) -> int: + """BIP-376: sign inputs that carry sp_tweak using sp_spend_bip32_derivations.""" + fingerprint, can_sign = self._signing_fingerprint(root) + if not can_sign: + return 0 + + counter = 0 + for i, inp in enumerate(self.inputs): + if getattr(inp, "sp_tweak", None) is None: + continue + if inp.taproot_key_sig is not None: + continue + + priv_key = None + + if fingerprint: + for pub_bytes, derivation in inp.sp_spend_bip32_derivations.items(): + if derivation.fingerprint != fingerprint: + continue + hdkey = self._derive_hdkey(root, derivation) + if hdkey is None or hdkey.xonly() != pub_bytes[1:]: + continue + priv_key = hdkey.key + break + + if priv_key is None and fingerprint is None and hasattr(root, "secret"): + priv_key = root + + if priv_key is None: + continue + + counter += self.sign_input_with_sp_tweak(priv_key, i, inp) + + return counter + + def _sign_with_sp(self, root, aux_rand=None) -> int: + """Compute per-input ECDH shares and DLEQ proofs for SP outputs.""" + scan_keys = {} + for out in self.outputs: + if out.sp_data is not None: + sk_bytes = out.sp_data.scan_key.sec() + if sk_bytes not in scan_keys: + scan_keys[sk_bytes] = out.sp_data.scan_key + + if not scan_keys: + return 0 + + try: + eligible = get_eligible_inputs(self.inputs, has_sp_outputs=True) + except SPValidationError: + return 0 + + if not eligible: + return 0 + + fingerprint, can_sign = self._signing_fingerprint(root) + if not can_sign: + return 0 + + counter = 0 + + for i in eligible: + inp = self.inputs[i] + + priv_bytes = None + if fingerprint: + for pub, derivation in inp.bip32_derivations.items(): + if derivation.fingerprint != fingerprint: + continue + hdkey = self._derive_hdkey(root, derivation) + if hdkey is None or hdkey.xonly() != pub.xonly(): + continue + priv_bytes = hdkey.key.secret + break + + if priv_bytes is None and fingerprint is None and hasattr(root, "secret"): + pkh = pubkey_hash_from_script(inp.script_pubkey, inp.redeem_script) + if pkh is not None and pkh == hashes.hash160( + root.get_public_key().sec() + ): + priv_bytes = root.secret + + if priv_bytes is None: + continue + + for sk_bytes, scan_key in scan_keys.items(): + if sk_bytes in inp.sp_ecdh_shares: + continue + try: + share = compute_ecdh_share(priv_bytes, scan_key) + proof = compute_dleq_proof( + priv_bytes, scan_key, share, aux_rand=aux_rand + ) + inp.sp_ecdh_shares[sk_bytes] = share + inp.sp_dleq_proofs[sk_bytes] = proof + counter += 1 + except SPFieldError: + continue + + return counter + + +# ── BIP-376 finalizer ─────────────────────────────────────────────────────────── + + +def finalize_sp_spends(psbt) -> int: + """ + BIP-376 Finalizer: for each signed SP spend input, construct the final + scriptwitness from taproot_key_sig and clear SP-specific fields. + + Returns number of inputs finalized. + """ + count = 0 + for inp in psbt.inputs: + if getattr(inp, "sp_tweak", None) is None: + continue + if inp.taproot_key_sig is None: + continue + inp.final_scriptwitness = Witness([inp.taproot_key_sig]) + inp.taproot_key_sig = None + inp.sp_tweak = None + inp.sp_spend_bip32_derivations = OrderedDict() + count += 1 + return count diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py new file mode 100644 index 00000000..371c2566 --- /dev/null +++ b/src/embit/silent_payments/validator.py @@ -0,0 +1,429 @@ +""" +BIP-375 PSBT Validation + +Implements the 4-stage validation pipeline for Silent Payments in PSBTs: +1. PSBT Structure - Verify BIP-375 field requirements +2. ECDH Coverage - Verify ECDH share presence and correctness +3. Input Eligibility - Verify input constraints with SP outputs +4. Output Scripts - Verify output scripts match SP derivation +""" + +from .. import ec +from . import dleq +from .fields import SPValidationError +from .ecdh import get_eligible_inputs, pubkey_hash_from_script +from .bip352 import get_input_hash, derive_silent_payment_outputs +from ..transaction import SIGHASH, COutPoint +from ..script import Script +from ..hashes import hash160 +from ..util.secp256k1 import ( + ec_pubkey_combine, + ec_pubkey_parse, + ec_pubkey_serialize, + ec_pubkey_tweak_mul, + EC_COMPRESSED, +) + + +def _sum_pubkeys(pubkeys) -> bytes: + """Sum a non-empty list of public keys, returning a 33-byte compressed point.""" + acc = ec_pubkey_parse(pubkeys[0].sec()) + for pk in pubkeys[1:]: + acc = ec_pubkey_combine(acc, ec_pubkey_parse(pk.sec())) + return ec_pubkey_serialize(acc, EC_COMPRESSED) + + +class BIP375Validator: + """Validates PSBTs for BIP-375 compliance.""" + + def __init__(self, psbt): + """ + Args: + psbt: A PSBTv2 instance to validate + """ + self.psbt = psbt + + def has_sp_outputs(self) -> bool: + """Check if PSBT has any SP outputs.""" + return any(out.sp_data is not None for out in self.psbt.outputs) + + def validate(self, skip_output_scripts: bool = False) -> bool: + """ + Run full 4-stage validation pipeline. + + Args: + skip_output_scripts: Skip stage 4 (output script validation) if True + + Returns: + True if validation passes (otherwise raises). + + Raises: + SPValidationError: With details of the first validation failure. + """ + if self.psbt.version != 2: + # SP fields only allowed in PSBTv2 + if self.has_sp_outputs(): + raise SPValidationError("SP fields only allowed in PSBTv2") + return True + + # Stage 1: PSBT Structure + try: + self._validate_structure() + except SPValidationError as e: + raise SPValidationError("Structure validation failed: {}".format(e)) + + if not self.has_sp_outputs(): + return True + + # Stage 2: ECDH Coverage + try: + self._validate_ecdh_coverage() + except SPValidationError as e: + raise SPValidationError("ECDH coverage validation failed: {}".format(e)) + + # Stage 3: Input Eligibility + try: + self._validate_input_eligibility() + except SPValidationError as e: + raise SPValidationError("Input eligibility validation failed: {}".format(e)) + + # Stage 4: Output Scripts (optional) + if not skip_output_scripts: + try: + self._validate_output_scripts() + except SPValidationError as e: + raise SPValidationError("Output script validation failed: {}".format(e)) + + return True + + def _validate_structure(self): + """ + Stage 1: PSBT Structure validation. + + Checks: + - SP fields only in outputs with PSBT_OUT_SP_V0_INFO + - Field sizes are correct + - Dependency relationships are valid + - Modifiable flags when scripts are set + """ + for i, out in enumerate(self.psbt.outputs): + has_sp_info = out.sp_data is not None + has_sp_label = out.sp_label is not None + has_script = out.script_pubkey is not None + + # Label requires SP info + if has_sp_label and not has_sp_info: + raise SPValidationError( + "Output {}: PSBT_OUT_SP_V0_LABEL present without " + "PSBT_OUT_SP_V0_INFO".format(i) + ) + + # Either script or SP info must be present + if not has_script and not has_sp_info: + raise SPValidationError( + "Output {}: Must have either PSBT_OUT_SCRIPT or " + "PSBT_OUT_SP_V0_INFO".format(i) + ) + + # If any SP output has script set, modifiable flags must be 0 + if self.has_sp_outputs(): + for i, out in enumerate(self.psbt.outputs): + if out.sp_data is not None and out.script_pubkey is not None: + # If script is set for SP output, modifiable flags must be 0 + if ( + self.psbt.tx_modifiable_flags is not None + and self.psbt.tx_modifiable_flags != 0 + ): + raise SPValidationError( + "Output {}: PSBT_GLOBAL_TX_MODIFIABLE must be 0 when " + "PSBT_OUT_SCRIPT is set for SP output".format(i) + ) + + def _validate_ecdh_coverage(self): + """ + Stage 2: ECDH Coverage validation. + + For each SP output, verify: + - All eligible inputs have ECDH shares or global share exists + - DLEQ proofs exist and verify correctly + - BIP32 derivations present when needed + """ + eligible_inputs = get_eligible_inputs(self.psbt.inputs, has_sp_outputs=True) + + for out_idx, out in enumerate(self.psbt.outputs): + if out.sp_data is None: + continue # Not an SP output + + scan_key = out.sp_data.scan_key + scan_key_bytes = scan_key.sec() + + # Check if global ECDH share exists for this scan key + has_global_share = scan_key_bytes in self.psbt.sp_ecdh_shares + has_global_proof = scan_key_bytes in self.psbt.sp_dleq_proofs + + if has_global_share and not has_global_proof: + raise SPValidationError( + "Output {}: PSBT_GLOBAL_SP_ECDH_SHARE present without " + "PSBT_GLOBAL_SP_DLEQ".format(out_idx) + ) + + if has_global_proof: + # Verify global DLEQ proof + if not out.script_pubkey: + # Can't verify without script (incomplete PSBT) + continue + + self._verify_global_dleq_proof(scan_key_bytes, out_idx, eligible_inputs) + else: + # Check per-input ECDH shares + for inp_idx in eligible_inputs: + inp = self.psbt.inputs[inp_idx] + + if scan_key_bytes not in inp.sp_ecdh_shares: + if out.script_pubkey: + # Script is set, all eligible inputs must have share + raise SPValidationError( + "Output {}: Input {} missing ECDH share for scan " + "key when output script is set".format(out_idx, inp_idx) + ) + else: + # Incomplete PSBT - this is OK + continue + + if scan_key_bytes not in inp.sp_dleq_proofs: + raise SPValidationError( + "Output {}: Input {} has ECDH share but missing DLEQ " + "proof".format(out_idx, inp_idx) + ) + + # Verify per-input DLEQ proof + if out.script_pubkey: # Can verify only if complete + self._verify_input_dleq_proof(inp_idx, scan_key, out_idx) + + # Check BIP32 derivation for per-input DLEQ. Only required + # when the output script is absent: trimmed/finalized PSBTs + # legitimately strip BIP32 derivations, and Stage 4 provides + # equivalent verification via the output script in that case. + if scan_key_bytes in inp.sp_dleq_proofs: + if len(inp.bip32_derivations) == 0 and not out.script_pubkey: + raise SPValidationError( + "Output {}: Input {} has DLEQ proof but missing " + "PSBT_IN_BIP32_DERIVATION".format(out_idx, inp_idx) + ) + + def _verify_global_dleq_proof( + self, scan_key_bytes: bytes, out_idx: int, eligible_inputs + ): + """Verify a global DLEQ proof.""" + scan_key = ec.PublicKey.parse(scan_key_bytes) + proof_bytes = self.psbt.sp_dleq_proofs[scan_key_bytes] + + # Reconstruct sum of eligible input public keys + eligible_pubkeys = [] + for inp_idx in eligible_inputs: + inp = self.psbt.inputs[inp_idx] + pubkey = self._get_input_public_key(inp, inp_idx) + if pubkey: + eligible_pubkeys.append(pubkey) + + if not eligible_pubkeys: + raise SPValidationError( + "Output {}: Cannot verify global DLEQ - no eligible inputs " + "with public keys".format(out_idx) + ) + + A_sum_bytes = _sum_pubkeys(eligible_pubkeys) + + # Get ECDH share as public key + ecdh_share = self.psbt.sp_ecdh_shares[scan_key_bytes] + C = ec.PublicKey.parse(ecdh_share) + + # Verify proof + if not dleq.verify_dleq_proof( + A_sum_bytes, scan_key.sec(), C.sec(), proof_bytes + ): + raise SPValidationError( + "Output {}: Global DLEQ proof verification failed".format(out_idx) + ) + + def _verify_input_dleq_proof( + self, inp_idx: int, scan_key: ec.PublicKey, out_idx: int + ): + """Verify a per-input DLEQ proof.""" + inp = self.psbt.inputs[inp_idx] + scan_key_bytes = scan_key.sec() + + # Get input's public key + pubkey = self._get_input_public_key(inp, inp_idx) + if not pubkey: + # Can't verify without public key + return + + # Get ECDH share + ecdh_share = inp.sp_ecdh_shares[scan_key_bytes] + C = ec.PublicKey.parse(ecdh_share) + + # Get proof + proof_bytes = inp.sp_dleq_proofs[scan_key_bytes] + + # Verify + if not dleq.verify_dleq_proof( + pubkey.sec(), scan_key.sec(), C.sec(), proof_bytes + ): + raise SPValidationError( + "Output {}: Per-input DLEQ proof verification failed " + "for input {}".format(out_idx, inp_idx) + ) + + def _get_input_public_key(self, inp, inp_idx: int): + """Extract the input's signing public key using hash-matching against the UTXO script. + + Falls back to partial_sigs keys when bip32_derivations is absent (e.g. a + trimmed PSBT where the signer stripped BIP-32 metadata to save space). + """ + pkh = pubkey_hash_from_script(inp.script_pubkey, inp.redeem_script) + if pkh is None: + return None + + for pubkey in inp.bip32_derivations: + if hash160(pubkey.sec()) == pkh: + return pubkey + for pubkey in inp.partial_sigs: + if hash160(pubkey.sec()) == pkh: + return pubkey + return None + + def _validate_input_eligibility(self): + """ + Stage 3: Input Eligibility validation. + + Checks: + - No Segwit v>1 inputs with SP outputs + - Only SIGHASH_ALL (if specified) + """ + # Get eligible inputs - this will raise if Segwit v>1 found + try: + get_eligible_inputs(self.psbt.inputs, has_sp_outputs=True) + except SPValidationError as e: + raise SPValidationError("Invalid input for SP: {}".format(e)) + + # Check sighash types + for i, inp in enumerate(self.psbt.inputs): + if inp.sighash_type is not None: + # BIP-375: Only SIGHASH_ALL allowed with SP outputs + if inp.sighash_type != SIGHASH.ALL: + raise SPValidationError( + "Input {}: Non-SIGHASH_ALL sighash type with SP outputs".format( + i + ) + ) + + def _validate_output_scripts(self): + """ + Stage 4: Output Scripts validation. + + For each SP output: + - Verify script is correctly derived from SP data and ECDH shares + - Verify sorting of outputs by scan/spend keys + """ + # Get eligible inputs + eligible_inputs = get_eligible_inputs(self.psbt.inputs, has_sp_outputs=True) + + # Build outpoints and A_sum for input_hash (same for all scan-key groups). + outpoints = [ + COutPoint(txid=self.psbt.tx.vin[i].txid, out_idx=self.psbt.tx.vin[i].vout) + for i in eligible_inputs + ] + eligible_pubkeys = [ + self._get_input_public_key(self.psbt.inputs[i], i) for i in eligible_inputs + ] + eligible_pubkeys = [pk for pk in eligible_pubkeys if pk is not None] + if not eligible_pubkeys: + raise SPValidationError( + "Cannot validate output scripts: no eligible input public keys found" + ) + A_sum_bytes = _sum_pubkeys(eligible_pubkeys) + input_hash = get_input_hash(outpoints, A_sum_bytes) + + # Group SP outputs by scan key + sp_outputs_by_scan = {} + for out_idx, out in enumerate(self.psbt.outputs): + if out.sp_data is not None: + scan_key_bytes = out.sp_data.scan_key.sec() + if scan_key_bytes not in sp_outputs_by_scan: + sp_outputs_by_scan[scan_key_bytes] = [] + sp_outputs_by_scan[scan_key_bytes].append((out_idx, out)) + + # For each scan key, validate outputs + for scan_key_bytes, outputs_for_scan in sp_outputs_by_scan.items(): + # Get ECDH share for this scan key + ecdh_share = None + if scan_key_bytes in self.psbt.sp_ecdh_shares: + ecdh_share = self.psbt.sp_ecdh_shares[scan_key_bytes] + else: + # Sum per-input shares + share_sum = None + for inp_idx in eligible_inputs: + inp = self.psbt.inputs[inp_idx] + if scan_key_bytes in inp.sp_ecdh_shares: + share = ec_pubkey_parse(inp.sp_ecdh_shares[scan_key_bytes]) + if share_sum is None: + share_sum = share + else: + share_sum = ec_pubkey_combine(share_sum, share) + if share_sum is not None: + ecdh_share = ec_pubkey_serialize(share_sum, EC_COMPRESSED) + + if not ecdh_share: + raise SPValidationError("No ECDH share found for scan key in outputs") + + # Apply input_hash: adjusted_share = input_hash · ecdh_share (BIP-352) + adjusted_handle = bytearray(ec_pubkey_parse(ecdh_share)) + ec_pubkey_tweak_mul(adjusted_handle, input_hash) + adjusted_share = ec_pubkey_serialize(adjusted_handle, EC_COMPRESSED) + + # Sort outputs for this scan key by spend key (lexicographic). The + # derivation counter k is the position in this sorted order. + sorted_outputs = sorted( + outputs_for_scan, + key=lambda x: (x[1].sp_data.spend_key.sec()), + ) + + derived = derive_silent_payment_outputs( + adjusted_share, + [ + (out.sp_data.scan_key, out.sp_data.spend_key, out.sp_label) + for _, out in sorted_outputs + ], + ) + + # Validate each output's script against the derived key + for pos, (out_idx, out) in enumerate(sorted_outputs): + if out.script_pubkey is None: + # Incomplete PSBT + continue + + expected_script = Script(b"\x51\x20" + derived[pos]) + if out.script_pubkey.serialize() != expected_script.serialize(): + raise SPValidationError( + "Output {}: Script does not match derived " + "silent payment script".format(out_idx) + ) + + +def validate_bip375_psbt(psbt, skip_output_scripts: bool = False) -> bool: + """ + Validate a PSBT for BIP-375 compliance. + + Args: + psbt: A PSBTv2 instance + skip_output_scripts: Skip output script validation if True + + Returns: + True if valid + + Raises: + SPValidationError: With validation error details + """ + validator = BIP375Validator(psbt) + return validator.validate(skip_output_scripts=skip_output_scripts) diff --git a/tests/tests/data/bip375_test_vectors.json b/tests/tests/data/bip375_test_vectors.json new file mode 100644 index 00000000..3f16f51b --- /dev/null +++ b/tests/tests/data/bip375_test_vectors.json @@ -0,0 +1,1759 @@ +{ + "description": "BIP-375 Test Vectors", + "version": "1.1", + "notes": [ + "Generated by https://github.com/macgyver13/bip375-test-generator/", + "Each vector includes: base64-encoded psbt and description", + "Supplementary material (inputs, outputs, sp_proofs) is included for diagnostics and should not be used for validation", + "'checks' overrides the validation sequence, e.g. skip structure checks to test input eligibility" + ], + "invalid": [ + { + "description": "psbt structure: missing PSBT_OUT_SP_V0_INFO field when PSBT_OUT_SP_V0_LABEL set", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwgYcwEAAAAAAAEEIlEgImjPUplZ8wrI96VHHKTGdTalHw5bForwPu1HFe3McuABCgQBAAAAAA==", + "supplementary": {} + }, + { + "description": "psbt structure: incorrect byte length for PSBT_OUT_SP_V0_INFO field", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwgYcwEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUECekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84AA=", + "supplementary": {} + }, + { + "description": "psbt structure: incorrect byte length for PSBT_IN_SP_ECDH_SHARE field", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GggA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QoiHgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaECKE7OYVUX3K9bolxSuuQmz41SoQqm7i1bNDt7SHfihmVmLMSKKSeC9fpXOEFP3xbKKy1Q6aHB2AOPOiYIu4yAhAAEDCBhzAQAAAAAAAQQiUSAybfUP4KB7estyBwvrO2Muua0VumlcweWqaqAHthRv2AEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": {} + }, + { + "description": "psbt structure: incorrect byte length for PSBT_IN_SP_DLEQ field", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4Gg/ihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgAAEDCBhzAQAAAAAAAQQiUSAybfUP4KB7estyBwvrO2Muua0VumlcweWqaqAHthRv2AEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": {} + }, + { + "description": "psbt structure: PSBT_GLOBAL_TX_MODIFIABLE field is non-zero when PSBT_OUT_SCRIPT set for sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAQABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwgYcwEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": {} + }, + { + "description": "psbt structure: missing PSBT_OUT_SCRIPT field when sending to non-sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwgYcwEAAAAAAAA=", + "supplementary": {} + }, + { + "description": "ecdh coverage: only one ineligible P2SH multisig input when PSBT_OUT_SCRIPT set for sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiDNOBpFUI/j2cq2O34PcEoPVp2/1VqYEC2eozruYht19AEPBAAAAAABAFMCAAAAAXyoMfPUiQn+aMsOqqoR+Hc97n6McwhTN1kOJLsGOQ20AAAAAAD/////AaCGAQAAAAAAF6kU2OZohxTu7Qqi7dSJqhyjwXFwHk2HAAAAAAEER1IhAr5Ar99ismPLfe4rmF0cOP5BsZlNKwUHU0PRcsIY6rHEIQJ6R5JT0AogWhCpQ483IBm582TgqDaAC/g529KOfOa5VVKuARAE/v///wABAwiQXwEAAAAAAAEEIlEgzeOdiwW0lvjxjyCyfQr5oyMzCeBHIhnggT/ztdN4fikBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "", + "public_key": "", + "prevout_txid": "cd381a45508fe3d9cab63b7e0f704a0f569dbfd55a98102d9ea33aee621b75f4", + "prevout_index": 0, + "prevout_scriptpubkey": "a914d8e6688714eeed0aa2edd489aa1ca3c171701e4d87", + "amount": 100000, + "non_witness_utxo": "02000000017ca831f3d48909fe68cb0eaaaa11f8773dee7e8c73085337590e24bb06390db40000000000ffffffff01a08601000000000017a914d8e6688714eeed0aa2edd489aa1ca3c171701e4d8700000000", + "sequence": 4294967294, + "signed": false + } + ] + } + }, + { + "description": "ecdh coverage: missing PSBT_IN_SP_ECDH_SHARE field for input 0 when PSBT_OUT_SCRIPT set for sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////AAEOIL6dZcg5FfsJJIq738qMo+7mzjUMVU9l6NUm5d9SerUiAQ8EAAAAAAEBHxAnAAAAAAAAFgAURjPVnK0TQ0eZcuDJlpIdCl2ttl0BAwQBAAAAIgYCQ6DUDp3giCeUFhdSuuQrqQlSeEIJ8GNDDwryVjVKhbsIAAAAgAEAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghAuV6etywGNj7MVToDBS5elCZS0I1aEHqn8ExcGFEAOY3Ih4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhA9/o4HeILO0WcbkJsYhPUF1MI8kgqROMyhCMmJNTa/Vcymb868WqzAsViY2OjfUSHB/fnoevqBke8zUhiA7srpgABAwgYcwEAAAAAAAEEIlEgzeOdiwW0lvjxjyCyfQr5oyMzCeBHIhnggT/ztdN4fikBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 1, + "private_key": "586b0810bbc3c7266971aeb7f8778b41707e092a1aeaf0d953b211f956e35541", + "public_key": "0243a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "00144633d59cad1343479972e0c996921d0a5dadb65d", + "amount": 10000, + "witness_utxo": "10270000000000001600144633d59cad1343479972e0c996921d0a5dadb65d", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "02e57a7adcb018d8fb3154e80c14b97a50994b42356841ea9fc13170614400e637", + "dleq_proof": "f7fa381de20b3b459c6e426c6213d4175308f2482a44e33284232624d4dafd573299bf3af16ab302c5626363a37d448707f7e7a1ebea0647bccd486203bb2ba6", + "input_index": 1 + } + ] + } + }, + { + "description": "ecdh coverage: missing PSBT_IN_SP_DLEQ field for input when PSBT_IN_SP_ECDH_SHARE set", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbAAEDCBhzAQAAAAAAAQQiUSAybfUP4KB7estyBwvrO2Muua0VumlcweWqaqAHthRv2AEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": null, + "input_index": 0 + } + ] + } + }, + { + "description": "ecdh coverage: missing PSBT_GLOBAL_SP_DLEQ field when PSBT_GLOBAL_SP_ECDH_SHARE set", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBACIHAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWwABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEiBgLIF7t1Ia/DXqlvO/snDm61Dd/6VWBie5Yf7ADymWUIvwgAAACAAAAAAAEQBP7///8AAQMIGHMBAAAAAAABBCJRIDJt9Q/goHt6y3IHC+s7Yy65rRW6aVzB5apqoAe2FG/YAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b" + } + ] + } + }, + { + "description": "ecdh coverage: invalid proof in PSBT_IN_SP_DLEQ field", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAbvHreDwFl9ZoVl4787j95b7SgVKV9D4XAVEk1EXcy9rdKkne51lhePEhbl1NuRdP/dpjNON8kDEQjAjOE3TeDwABAwgYcwEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "6ef1eb783c0597d668565e3bf3b8fde5bed2815295f43e17015124d445dccbdadd2a49dee7596178f1216e5d4db9174ffdda6334e37c9031108c08ce1374de0f", + "input_index": 0 + } + ] + } + }, + { + "description": "ecdh coverage: invalid proof in PSBT_GLOBAL_SP_DLEQ field", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBACIHAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIIAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQOfI/UQrlNiKKmoskrQ0ZI+gB8n+CARyozeA0LUjKETQm9iLP+6G0S1DhsRlf7t4Xi0vFL/PkYV0P+EqyCUPldkAAQ4gGKcXZjsLqxSxKhp3EyP/HkB53VMuXdE+KOoQgccAmEoBDwQAAAAAAQEfoIYBAAAAAAAWABQimnLTSmRb00lru/ULu4HJBj9PlCICAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/RzBEAiAJB3pqpkrBSuehqk/mjAKs4gbJ6scjSQYa52A5ALkibgIgbxQypXj44zCvC5ACVnRryfTlKqLozHkCncjnajwpSwYBIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////AAEDCBhzAQAAAAAAAQQiUSAybfUP4KB7estyBwvrO2Muua0VumlcweWqaqAHthRv2AEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "2430479d2da1310a024d2aab8e4c7b49d76cd51f45ee8127c159b0393a675b6f75c581ebe3d0817ddaf3bb598cfd7a022f1a87ed479664f6b947a47db5941cb2" + } + ] + } + }, + { + "description": "ecdh coverage: missing PSBT_IN_BIP32_DERIVATION field for input when PSBT_IN_SP_DLEQ set", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIAkHemqmSsFK56GqT+aMAqziBsnqxyNJBhrnYDkAuSJuAiBvFDKlePjjMK8LkAJWdGvJ9OUqoujMeQKdyOdqPClLBgEBAwQBAAAAARAE/v///yIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQMIGHMBAAAAAAABBCJRIDJt9Q/goHt6y3IHC+s7Yy65rRW6aVzB5apqoAe2FG/YAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ] + } + }, + { + "description": "ecdh coverage: output 1 missing ECDH share for scan key with one input / three sp outputs (different scan keys)", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEDAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR9ADQMAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdArlFYVfpRu8H90tU+gm+Sn6Tjcr7+ye0s3LFc8BZ8SqwIQLChkR0sISqyB9uMKxe30ZnOTzmBNgI0/SZoJhOi1JJdCIdA2UdLAc/ywKk2C3aU/HVAdd6A1BmZ5hUZHOyEmts0afeIQN2iSMnxOeAru7UzhvJK7ASK3t5ENlvCtUUi0lvnD6S+yIeArlFYVfpRu8H90tU+gm+Sn6Tjcr7+ye0s3LFc8BZ8SqwQPCxHVHD/ROzjgpsjvwu4Mpxdzr2dLMVHmiSFJJcS1fIFojJ4Hi3UIhabKsoa1d+k6JJfrSWNrGvG2vILnyRElEiHgNlHSwHP8sCpNgt2lPx1QHXegNQZmeYVGRzshJrbNGn3kCY8UrEWvZFmrDITUfaU2TPsUT/kL2scy+cq8bbAA3PGpXjsx97cKBCmKUbb9RRbIHXFsmVT4rxDD1ZgJruK704AAEDCJBfAQAAAAAAAQQiUSDTlxRqgoZbVOsSuepx1oJP6rD5KkgTf3p5OmhZazNnpAEJQgNlHSwHP8sCpNgt2lPx1QHXegNQZmeYVGRzshJrbNGn3gPycOClYyGDtqsBB06HfmgA2wD1x8XS+vu2j5wo1IVFJAABAwgQJwAAAAAAAAEEIlEgzeOdiwW0lvjxjyCyfQr5oyMzCeBHIhnggT/ztdN4fikBCUIDUteMQTkAMq2RgWppf8dA2OuQnPBNcIhSZLBR8jheJewC4MTBh7chX5l8hIWnW0WRtsKEd7laESO5lpItMVSGC/gAAQMIIE4AAAAAAAABBCJRINLSQezkGFFKoY45kuyVPE1K1exwSmk+IQa6IsOwWnqDAQlCArlFYVfpRu8H90tU+gm+Sn6Tjcr7+ye0s3LFc8BZ8SqwAhJoHj8J6uOKjy8/7NfdVvxYbPoCjoqzP3KpD4QKbSJcAA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 200000, + "witness_utxo": "400d030000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "03651d2c073fcb02a4d82dda53f1d501d77a0350666798546473b2126b6cd1a7de", + "ecdh_share": "0376892327c4e780aeeed4ce1bc92bb0122b7b7910d96f0ad5148b496f9c3e92fb", + "dleq_proof": "98f14ac45af6459ab0c84d47da5364cfb144ff90bdac732f9cabc6db000dcf1a95e3b31f7b70a04298a51b6fd4516c81d716c9954f8af10c3d59809aee2bbd38", + "input_index": 0 + }, + { + "scan_key": "02b9456157e946ef07f74b54fa09be4a7e938dcafbfb27b4b372c573c059f12ab0", + "ecdh_share": "02c2864474b084aac81f6e30ac5edf4667393ce604d808d3f499a0984e8b524974", + "dleq_proof": "f0b11d51c3fd13b38e0a6c8efc2ee0ca71773af674b3151e689214925c4b57c81688c9e078b750885a6cab286b577e93a2497eb49636b1af1b6bc82e7c911251", + "input_index": 0 + } + ] + } + }, + { + "description": "ecdh coverage: input 1 missing ECDH share for output 1 with two inputs / two sp outputs (different scan keys)", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQECAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdA1LXjEE5ADKtkYFqaX/HQNjrkJzwTXCIUmSwUfI4XiXsIQMJH9SAORd/RNe3npYWiEnGLrP4eADEZ2fFCo84+Y79FSIdA2UdLAc/ywKk2C3aU/HVAdd6A1BmZ5hUZHOyEmts0afeIQN2iSMnxOeAru7UzhvJK7ASK3t5ENlvCtUUi0lvnD6S+yIeA1LXjEE5ADKtkYFqaX/HQNjrkJzwTXCIUmSwUfI4XiXsQL4acjrvVeiYG5lZmpvBKzj4froBWMxwZokPImnK3kY7dzQal9KScZZNAcAkHMMi/VOQwpuZFg+CbzKTZYmMvwIiHgNlHSwHP8sCpNgt2lPx1QHXegNQZmeYVGRzshJrbNGn3kCY8UrEWvZFmrDITUfaU2TPsUT/kL2scy+cq8bbAA3PGpXjsx97cKBCmKUbb9RRbIHXFsmVT4rxDD1ZgJruK704AAEOIL6dZcg5FfsJJIq738qMo+7mzjUMVU9l6NUm5d9SerUiAQ8EAAAAAAEBH6CGAQAAAAAAFgAURjPVnK0TQ0eZcuDJlpIdCl2ttl0BAwQBAAAAIgYCQ6DUDp3giCeUFhdSuuQrqQlSeEIJ8GNDDwryVjVKhbsIAAAAgAEAAAABEAT+////Ih0DZR0sBz/LAqTYLdpT8dUB13oDUGZnmFRkc7ISa2zRp94hAnUEQAf+LQMNV3wymRzyo+deU1BCRbPL5bMFOufF8iBaIh4DZR0sBz/LAqTYLdpT8dUB13oDUGZnmFRkc7ISa2zRp95AOLnjgGtJvr7vxs7gZcKwZv2wCuzs8ei1sqr0fPBBeyma4KJW7Xy2yCcC63E31zv1eSccQoTQBuO1nXCpuWfYiQABAwiQXwEAAAAAAAEEIlEg9Jwu8ZtnBhG4bd7+iQzvlCaKgq4zHkb+GVHWxuNyPLEBCUIDZR0sBz/LAqTYLdpT8dUB13oDUGZnmFRkc7ISa2zRp94D8nDgpWMhg7arAQdOh35oANsA9cfF0vr7to+cKNSFRSQAAQMIkF8BAAAAAAABBCJRID2h4XbGT7RZU+xG4FA2NLx+jjQ6do2+77sKPojMSkyaAQlCA1LXjEE5ADKtkYFqaX/HQNjrkJzwTXCIUmSwUfI4XiXsAuDEwYe3IV+ZfISFp1tFkbbChHe5WhEjuZaSLTFUhgv4AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 1, + "private_key": "586b0810bbc3c7266971aeb7f8778b41707e092a1aeaf0d953b211f956e35541", + "public_key": "0243a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "00144633d59cad1343479972e0c996921d0a5dadb65d", + "amount": 100000, + "witness_utxo": "a0860100000000001600144633d59cad1343479972e0c996921d0a5dadb65d", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "03651d2c073fcb02a4d82dda53f1d501d77a0350666798546473b2126b6cd1a7de", + "ecdh_share": "0376892327c4e780aeeed4ce1bc92bb0122b7b7910d96f0ad5148b496f9c3e92fb", + "dleq_proof": "98f14ac45af6459ab0c84d47da5364cfb144ff90bdac732f9cabc6db000dcf1a95e3b31f7b70a04298a51b6fd4516c81d716c9954f8af10c3d59809aee2bbd38", + "input_index": 0 + }, + { + "scan_key": "0352d78c41390032ad91816a697fc740d8eb909cf04d70885264b051f2385e25ec", + "ecdh_share": "03091fd48039177f44d7b79e96168849c62eb3f87800c46767c50a8f38f98efd15", + "dleq_proof": "be1a723aef55e8981b99599a9bc12b38f87eba0158cc7066890f2269cade463b77341a97d29271964d01c0241cc322fd5390c29b99160f826f329365898cbf02", + "input_index": 0 + }, + { + "scan_key": "03651d2c073fcb02a4d82dda53f1d501d77a0350666798546473b2126b6cd1a7de", + "ecdh_share": "0275044007fe2d030d577c32991cf2a3e75e53504245b3cbe5b3053ae7c5f2205a", + "dleq_proof": "38b9e3806b49bebeefc6cee065c2b066fdb00aececf1e8b5b2aaf47cf0417b299ae0a256ed7cb6c82702eb7137d73bf579271c4284d006e3b59d70a9b967d889", + "input_index": 1 + } + ] + } + }, + { + "description": "ecdh coverage: input 1 missing ECDH share for scan key with two inputs / one sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR9QwwAAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQ4gvp1lyDkV+wkkirvfyoyj7ubONQxVT2Xo1Sbl31J6tSIBDwQAAAAAAQEfUMMAAAAAAAAWABRGM9WcrRNDR5ly4MmWkh0KXa22XSIGAkOg1A6d4IgnlBYXUrrkK6kJUnhCCfBjQw8K8lY1SoW7CAAAAIABAAAAARAE/v///wABAwgYcwEAAAAAAAEEIlEgzeOdiwW0lvjxjyCyfQr5oyMzCeBHIhnggT/ztdN4fikBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 50000, + "witness_utxo": "50c3000000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 1, + "private_key": "586b0810bbc3c7266971aeb7f8778b41707e092a1aeaf0d953b211f956e35541", + "public_key": "0243a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "00144633d59cad1343479972e0c996921d0a5dadb65d", + "amount": 50000, + "witness_utxo": "50c30000000000001600144633d59cad1343479972e0c996921d0a5dadb65d", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ] + } + }, + { + "description": "input eligibility: segwit version greater than 1 in transaction inputs with sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABZSFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIHLzeIUvuENyYHLyUHbw53Vg7UwuBHFm7mpibHW/2znWAiAhKq5fCVdONB9YhvX/8y9XFuq5AwpKU3nmAWtqTULcJAEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwgYcwEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "5214229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000165214229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ] + }, + "checks": [ + "input_eligibility" + ] + }, + { + "description": "input eligibility: non-SIGHASH_ALL signature on input with sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIHLzeIUvuENyYHLyUHbw53Vg7UwuBHFm7mpibHW/2znWAiAhKq5fCVdONB9YhvX/8y9XFuq5AwpKU3nmAWtqTULcJAEBAwQCAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwgYcwEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ] + } + }, + { + "description": "output scripts: P2TR input with NUMS internal key cannot derive sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAT8Qa2S1e1sTdvn2xHGQ9AzAazvty4qrqXhQqz1/Z9AgEPBAAAAAABASsQJwAAAAAAACJRIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAAQMEAQAAAAEQBP7///8BFyBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wCIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQMIHCUAAAAAAAABBCJRICdoyIruoiMiW6OthOeGNS+YKdLT6TeKOToL/1mvh83QAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "13f106b64b57b5b1376f9f6c47190f40cc06b3bedcb8aaba97850ab3d7f67d02", + "prevout_index": 0, + "prevout_scriptpubkey": "512050929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + "amount": 10000, + "witness_utxo": "102700000000000022512050929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 9500, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "51202768c88aeea223225ba3ad84e786352f9829d2d3e9378a393a0bff59af87cdd0" + } + ] + } + }, + { + "description": "output scripts: PSBT_OUT_SCRIPT does not match derived sp output", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQMIGHMBAAAAAAABBCJRIM3jnYsFtJb48Y8gsn0K+aMjMwngRyIZ4IE/87XTeH4pAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 95000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120cde39d8b05b496f8f18f20b27d0af9a3233309e0472219e0813ff3b5d3787e29" + } + ] + } + }, + { + "description": "output scripts: two sp outputs (same scan / different spend keys) not sorted lexicographically by spend key", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQECAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCID9iBcRocGpGJF8XE6u4uLmjp7/YOsdF98ByDUQxtM38AiBxWKv7gg8r9a1nRfVvwHCcmVzMrP4XCY2KobYfjJ/y/wEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwiQXwEAAAAAAAEEIlEgdUe4Fj1bvFSYQL5MwUaq0JUgc2e565RwbeZwjUCPk/kBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgCi4D5lDNIleEbx2x1q254/iwluzMvIXshHEAXvx9nfYYAAQMIECcAAAAAAAABBCJRIGziw1OV8XDyS20tgNCkDEb0a6S0hD9qsxGpNLMcXYTcAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA6r4Yq7amM+SU5r33DN10dpfsGsSHvCh8DGp9zgv1T27AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068028b80f994334895e11bc76c75ab6e78fe2c25bb332f217b211c4017bf1f677d86", + "script": "51207547b8163d5bbc549840be4cc146aad095207367b9eb94706de6708d408f93f9" + }, + { + "output_index": 1, + "amount": 10000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe06803aaf862aeda98cf92539af7dc3375d1da5fb06b121ef0a1f031a9f7382fd53dbb", + "script": "51206ce2c35395f170f24b6d2d80d0a40c46f46ba4b4843f6ab311a934b31c5d84dc" + } + ] + } + }, + { + "description": "output scripts: k values assigned to wrong output indices with three sp outputs (same scan / spend keys)", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEDAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIFg36hg2U7tRrGNQq+bDhHvokGogZdwwSNbSCOp8DkyCAiAQ+0/6sv7Vqmy2KAqTxHnPsE5Gyp7fF6owXNFnSiQsSQEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwiQXwEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkAAQMIECcAAAAAAAABBCJRIJcUQsifBHhuCCcorhFu6rgC8/qelf2w03U6aEAgIAbeAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AAEDCBAnAAAAAAAAAQQiUSA/y66kkIf44D2d79Ory9bejAMrWD+Fpl0FeIHExEPNwAEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120326df50fe0a07b7acb72070beb3b632eb9ad15ba695cc1e5aa6aa007b6146fd8" + }, + { + "output_index": 1, + "amount": 10000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120971442c89f04786e082728ae116eeab802f3fa9e95fdb0d3753a6840202006de" + }, + { + "output_index": 2, + "amount": 10000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "51203fcbaea49087f8e03d9defd3abcbd6de8c032b583f85a65d057881c4c443cdc0" + } + ] + } + } + ], + "valid": [ + { + "description": "can finalize: one P2PKH input single-signer", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiBSJ0jrF3ZNKMpJSBXsjUnn0w1SvHNCLHyG63TjlwVylAEPBAAAAAABAFUCAAAAAfTCEtWu0ef2/2M/LOCcZHxXvt2TAxTZjed1A9WOlAszAAAAAAD/////AaCGAQAAAAAAGXapFB4q14ctMpQTpW3wlovjOCIngxY7iKwAAAAAIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIDnBDcvHz0XG2UNW/1DBK42GqVUM8DcXPZzr94cU5nx1AiBxlVpC7SBTJDIHI8TwFCXc6J9CX4NwKEy0J2z9tt6jrAEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwgYcwEAAAAAAAEEIlEg4UDSh7RbRs1OqvpDdwYVcLq+g9G4vJUKdKn+oJIz16YBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "522748eb17764d28ca494815ec8d49e7d30d52bc73422c7c86eb74e397057294", + "prevout_index": 0, + "prevout_scriptpubkey": "76a9141e2ad7872d329413a56df0968be338222783163b88ac", + "amount": 100000, + "non_witness_utxo": "0200000001f4c212d5aed1e7f6ff633f2ce09c647c57bedd930314d98de77503d58e940b330000000000ffffffff01a0860100000000001976a9141e2ad7872d329413a56df0968be338222783163b88ac00000000", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 95000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120e140d287b45b46cd4eaafa4377061570babe83d1b8bc950a74a9fea09233d7a6" + } + ] + } + }, + { + "description": "can finalize: two inputs single-signer using global ECDH share", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBACIHAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQLTJicjNSYH6E/K92UfjS1jfgIK6obfrYDTBs1PSM/MYiIIAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQMZTtx+UaBipW9w58JGERbyQOgh2tzgGDp5dsdzsMKvfVOC18XhXP1EZ3nGqY60ZtEnjvLZUY5Nsi67qTZFzkrkAAQ4gGKcXZjsLqxSxKhp3EyP/HkB53VMuXdE+KOoQgccAmEoBDwQAAAAAAQEfoIYBAAAAAAAWABQimnLTSmRb00lru/ULu4HJBj9PlCICAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/RzBEAiAXBfzaBiZu2zKxaYZ3s39nu25F1JLr/OZWqIDufxOOtgIgPc3jI53ZBjavZPskEspB45fJWKAwd3z/nIlAucd310wBIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////AAEOIL6dZcg5FfsJJIq738qMo+7mzjUMVU9l6NUm5d9SerUiAQ8EAAAAAAEBH6CGAQAAAAAAFgAUE5NQEXxXeUGphYArue3vwROTK8ciAgL1tZ+l5JIiHr9VunitRCYFvq6VFmuh66MlDQu6rH4u3EcwRAIgA4mx4K7v1/q2Ce+rAYv7AWrP8hu4EPPEjDi6ckQYDpUCIBUP7GDiVvQMR1dN/RKaCOr3rB2Xq+4x9AaZ+Hk1RhnzASIGAvW1n6XkkiIev1W6eK1EJgW+rpUWa6HroyUNC7qsfi7cCAAAAIABAAAAARAE/v///wABAwgYcwEAAAAAAAEEIlEgEz1qT1CQlbV5T3SR+8PxLDxFvC2bYK8luLunBMgHKq8BCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 1, + "private_key": "295c2eedddd8331d20b5d4cf9e69bb523ed85cb0bf35ab12e04fea66fe6d4a4a", + "public_key": "02f5b59fa5e492221ebf55ba78ad442605beae95166ba1eba3250d0bbaac7e2edc", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "0014139350117c577941a985802bb9edefc113932bc7", + "amount": 100000, + "witness_utxo": "a086010000000000160014139350117c577941a985802bb9edefc113932bc7", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "02d3262723352607e84fcaf7651f8d2d637e020aea86dfad80d306cd4f48cfcc62", + "dleq_proof": "c653b71f946818a95bdc39f0918445bc903a0876b738060e9e5db1dcec30abdf54e0b5f178573f5119de71aa63ad19b449e3bcb65463936c8baeea4d917392b9" + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 95000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120133d6a4f509095b5794f7491fbc3f12c3c45bc2d9b60af25b8bba704c8072aaf" + } + ] + } + }, + { + "description": "can finalize: two inputs single-signer using per-input ECDH shares", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIBcF/NoGJm7bMrFphnezf2e7bkXUkuv85laogO5/E462AiA9zeMjndkGNq9k+yQSykHjl8lYoDB3fP+ciUC5x3fXTAEiBgLIF7t1Ia/DXqlvO/snDm61Dd/6VWBie5Yf7ADymWUIvwgAAACAAAAAAAEQBP7///8iHQJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaCED7KT/Ebco4uD2DOYiKUOm/1W52V9ie/mpnQhLyHLVClsiHgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaECKE7OYVUX3K9bolxSuuQmz41SoQqm7i1bNDt7SHfihmVmLMSKKSeC9fpXOEFP3xbKKy1Q6aHB2AOPOiYIu4yAhAAEOIL6dZcg5FfsJJIq738qMo+7mzjUMVU9l6NUm5d9SerUiAQ8EAAAAAAEBH6CGAQAAAAAAFgAUE5NQEXxXeUGphYArue3vwROTK8ciAgL1tZ+l5JIiHr9VunitRCYFvq6VFmuh66MlDQu6rH4u3EcwRAIgA4mx4K7v1/q2Ce+rAYv7AWrP8hu4EPPEjDi6ckQYDpUCIBUP7GDiVvQMR1dN/RKaCOr3rB2Xq+4x9AaZ+Hk1RhnzAQEDBAEAAAAiBgL1tZ+l5JIiHr9VunitRCYFvq6VFmuh66MlDQu6rH4u3AgAAACAAQAAAAEQBP7///8iHQJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaCEDNNLO907e1EZCJ2B/xZrB3oDBKiQzpWJxUo7cz1D6bCsiHgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaEA3wAwLQqBz9rZfDEtuVuBndLOAwd894otkCxIe4LO0z3hQ30VuC12OGXXd/yUmLIOB4g+FLNhwveP2qDKmMlvMAAEDCBhzAQAAAAAAAQQiUSATPWpPUJCVtXlPdJH7w/EsPEW8LZtgryW4u6cEyAcqrwEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 1, + "private_key": "295c2eedddd8331d20b5d4cf9e69bb523ed85cb0bf35ab12e04fea66fe6d4a4a", + "public_key": "02f5b59fa5e492221ebf55ba78ad442605beae95166ba1eba3250d0bbaac7e2edc", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "0014139350117c577941a985802bb9edefc113932bc7", + "amount": 100000, + "witness_utxo": "a086010000000000160014139350117c577941a985802bb9edefc113932bc7", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + }, + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "0334d2cef74eded4464227607fc59ac1de80c12a2433a56271528edccf50fa6c2b", + "dleq_proof": "37c00c0b42a073f6b65f0c4b6e56e06774b380c1df3de28b640b121ee0b3b4cf7850df456e0b5d8e1975ddff25262c8381e20f852cd870bde3f6a832a6325bcc", + "input_index": 1 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 95000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120133d6a4f509095b5794f7491fbc3f12c3c45bc2d9b60af25b8bba704c8072aaf" + } + ] + } + }, + { + "description": "can finalize: two inputs / two sp outputs with mixed global and per-input ECDH shares", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQECAQYBACIHAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQNmz8f9zgbgjtSbGzlSuNFRqPfgdf5fbdWN1o+dre7KlCIIAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oQF6riwhtSFD+nRyflhqR+Ze2HKjQhQr8BqeL3zx0nvVAUOaStT+1sIbl54eT8B5203R/ieMXen/v9vjBYX8bBuMAAQ4gGKcXZjsLqxSxKhp3EyP/HkB53VMuXdE+KOoQgccAmEoBDwQAAAAAAQEfQA0DAAAAAAAWABQimnLTSmRb00lru/ULu4HJBj9PlCICAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/SDBFAiEA1k6e8CREAEbpyjkwLrh7LfYN/FHJwKkX1oLzz4EFkIwCIB6+EqXx/SGW8u+11Tr+V850W5Af6ImXitWa7+FKT1A3AQEDBAEAAAAiBgLIF7t1Ia/DXqlvO/snDm61Dd/6VWBie5Yf7ADymWUIvwgAAACAAAAAAAEQBP7///8iHQKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVECED+9Fz2cSNWMUCJCbQUni8oXuajiKCZmkBobALngS3mUkiHgKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVEEDAXaQQSHz67DeP6+avUAXjrmyGI2gYsOVbIUAX61Bz7e9AF5V1tdzaqQY0kayLg/0GJNQXek0t1JlMRkDWuMzeAAEOIL6dZcg5FfsJJIq738qMo+7mzjUMVU9l6NUm5d9SerUiAQ8EAAAAAAEBH0ANAwAAAAAAFgAUE5NQEXxXeUGphYArue3vwROTK8ciAgL1tZ+l5JIiHr9VunitRCYFvq6VFmuh66MlDQu6rH4u3EcwRAIgWoJ5pshwRPECyFkwyJ7C/wGUKGJBraOMeFTy6DZR1fICICvMZkqflwTVcxLbwryDaGcIDyLEdYuR27ktF6HkEeqpAQEDBAEAAAAiBgL1tZ+l5JIiHr9VunitRCYFvq6VFmuh66MlDQu6rH4u3AgAAACAAQAAAAEQBP7///8iHQKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVECEDpqibG5hqmVUz4XvtZFALVBuw1zkHHv4q4rfvsKDKa88iHgKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVEEDDSmopQLvppzVsHMb/41jdREHbWIl8upnCEut1KF59oeyhHHksl6OASTNFqyJN6dlMKzbuhSauI1KBcaMjHeczAAEDCJBfAQAAAAAAAQQiUSCA65gxi89t1hGsF6lksJFzbATK6t8ubodOXO+Gx8YFFwEJQgMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKAMSZlgePoQcu5TeYupzVYOiMsTIiPpu3vLsL4cHifwd1AABAwiQXwEAAAAAAAEEIlEgELYvxakKvKAWeWHb/0zq6aoyTvssfyRmJ+i1oz3jfIQBCUICqfH6wsxFdoTzPekAc/4g0iyUfJIWO4YrFNCrod9HFRACSkjXbiDUC89fLhpcP/6z3CqLO4jXmkhtVMnZ6qj6GI4A", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 200000, + "witness_utxo": "400d030000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 1, + "private_key": "295c2eedddd8331d20b5d4cf9e69bb523ed85cb0bf35ab12e04fea66fe6d4a4a", + "public_key": "02f5b59fa5e492221ebf55ba78ad442605beae95166ba1eba3250d0bbaac7e2edc", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "0014139350117c577941a985802bb9edefc113932bc7", + "amount": 200000, + "witness_utxo": "400d030000000000160014139350117c577941a985802bb9edefc113932bc7", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "0366cfc7fdce06e08ed49b1b3952b8d151a8f7e075fe5f6dd58dd68f9dadeeca94", + "dleq_proof": "5eab8b086d4850fe9d1c9f961a91f997b61ca8d0850afc06a78bdf3c749ef54050e692b53fb5b086e5e78793f01e76d3747f89e3177a7feff6f8c1617f1b06e3" + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "03fbd173d9c48d58c5022426d05278bca17b9a8e2282666901a1b00b9e04b79949", + "dleq_proof": "c05da410487cfaec378febe6af5005e3ae6c86236818b0e55b214017eb5073edef40179575b5dcdaa9063491ac8b83fd0624d4177a4d2dd4994c4640d6b8ccde", + "input_index": 0 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "03a6a89b1b986a995533e17bed64500b541bb0d739071efe2ae2b7efb0a0ca6bcf", + "dleq_proof": "c34a6a2940bbe9a7356c1cc6ffe358dd4441db58897cba99c212eb75285e7da1eca11c792c97a380493345ab224de9d94c2b36ee8526ae23528171a3231de733", + "input_index": 1 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28031266581e3e841cbb94de62ea735583a232c4c888fa6edef2ec2f870789fc1dd4", + "script": "512080eb98318bcf6dd611ac17a964b091736c04caeadf2e6e874e5cef86c7c60517" + }, + { + "output_index": 1, + "amount": 90000, + "sp_v0_info": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510024a48d76e20d40bcf5f2e1a5c3ffeb3dc2a8b3b88d79a486d54c9d9eaa8fa188e", + "script": "512010b62fc5a90abca0167961dbff4ceae9aa324efb2c7f246627e8b5a33de37c84" + } + ] + } + }, + { + "description": "can finalize: one input / one sp output with both global and per-input ECDH shares", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBACIHAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQMKGLm7QZm4kzDrF/juWseG4IU9zS88zfF0S5fOTt4FPiIIAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oQCf7kMXWHHjjEVIA/9GD0o0KtIOAYslUGTTGQW1CZ84xvUKH6TWUUsCBevDvTVDP9XhDaw0o+e2PIbeaRHZAgI4AAQ4gGKcXZjsLqxSxKhp3EyP/HkB53VMuXdE+KOoQgccAmEoBDwQAAAAAAQEfQA0DAAAAAAAWABQimnLTSmRb00lru/ULu4HJBj9PlCICAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/RzBEAiA3eo+fWjPY+iVcM7OSJtO88CKd+tlgRw74AWpxQvIzWAIgCBvWlIh63us2Ia9aLeTw05yxNpLQQb99vM00oysFQekBAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQMKGLm7QZm4kzDrF/juWseG4IU9zS88zfF0S5fOTt4FPiIeAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oQAATg9RmG+hyXvKQSPSMkR+J9fNDF9fAwKzGDvZfG1K//3qW2DObYl34Orq1eqJz7c2aNceU2suxyGEXG7JnB18AAQMIkF8BAAAAAAABBCJRINxNRNnjYeXnQ2V5x44lklVB4in9wVOtWYmtiuawYkx4AQlCAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oAxJmWB4+hBy7lN5i6nNVg6IyxMiI+m7e8uwvhweJ/B3UAA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 200000, + "witness_utxo": "400d030000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "030a18b9bb4199b89330eb17f8ee5ac786e0853dcd2f3ccdf1744b97ce4ede053e", + "dleq_proof": "27fb90c5d61c78e3115200ffd183d28d0ab4838062c9541934c6416d4267ce31bd4287e9359452c0817af0ef4d50cff578436b0d28f9ed8f21b79a447640808e" + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28031266581e3e841cbb94de62ea735583a232c4c888fa6edef2ec2f870789fc1dd4", + "script": "5120dc4d44d9e361e5e7436579c78e25925541e229fdc153ad5989ad8ae6b0624c78" + } + ] + } + }, + { + "description": "can finalize: three sp outputs (different scan keys) with multiple global ECDH shares", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEDAQYBACIHAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQIQLC2d9g7mmlqGYE3ZpigsIu+OLJJ22m5ZWlRj83Gq3uJCIHAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQLcfS7KxWiwEVF1ujzVSW/0xLbZCUItvMrrjyUvpU+L0CIHA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsIQJwvvXAapqFX8Y7/yvQvi1wAzyCC2FTQTyNbtpR8lobKSIIAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQQOyCQr97WIHO4GBWbH8TzEfkcQyJomRYwgMBswMeSBc7wL6gFoDTN1616AYRqw1Qix5Jq7VxsYeAMqrknwsC99IiCAMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKEDqQ0cOXhvjKRtIoLy1P1WVLGSGDnY177pQA8yt7XfdEo4hZpew9SO+IX7Gh1B3l8zOvFzZQ2KvG0bYA2Qr1flAIggDjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGxAG9/lTtSioG//mwAJrk49gPxHYWR1r98hJEz+PG0QTfJHQah1UU14ZU+5yhEY5tPlUvOPjKujVS6DM8UBvwy9PwABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR8goQcAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9IMEUCIQCLg3ekbsHGTj/QlvLhIwW7hrrfzKDP2b9X3JYim55whAIgfn3H3eyoVVwXfr7/yUnqTDNSD+W+7Hun4nJ+NPvUjf0BIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////AAEOIL6dZcg5FfsJJIq738qMo+7mzjUMVU9l6NUm5d9SerUiAQ8EAAAAAAEBH6CGAQAAAAAAFgAURjPVnK0TQ0eZcuDJlpIdCl2ttl0iAgJDoNQOneCIJ5QWF1K65CupCVJ4QgnwY0MPCvJWNUqFu0gwRQIhAPmSLe+tUsYSCQS6pacAUymDbsHD2uFsoGjAFrJrBNLYAiBw+V8k+cklbkGWlGnDUqz6yOiUMjUC6s0WPJSi42nuHwEiBgJDoNQOneCIJ5QWF1K65CupCVJ4QgnwY0MPCvJWNUqFuwgAAACAAQAAAAEQBP7///8AAQMIkF8BAAAAAAABBCJRIKFPr9x+36QEQnF0sSasoOmZMTP+F+JCCurtIO9U/DjcAQlCAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oAxJmWB4+hBy7lN5i6nNVg6IyxMiI+m7e8uwvhweJ/B3UAAEDCJBfAQAAAAAAAQQiUSB99fJJWUQ0FLvv6J0tqJSGSlYotoAzuSPoaGvcw96dlAEJQgKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVEAJKSNduINQLz18uGlw//rPcKos7iNeaSG1UydnqqPoYjgABAwiQXwEAAAAAAAEEIlEgsvEqf1cMxQV4QPpjk/cfBpABQgiBgYEMNLuIdh2SlQ0BCUIDjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGwD+BRzhEyddMkh/xelr0ELYzc0jXr7/3OJG8XLDLJMsMAA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 500000, + "witness_utxo": "20a1070000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 1, + "private_key": "586b0810bbc3c7266971aeb7f8778b41707e092a1aeaf0d953b211f956e35541", + "public_key": "0243a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "00144633d59cad1343479972e0c996921d0a5dadb65d", + "amount": 100000, + "witness_utxo": "a0860100000000001600144633d59cad1343479972e0c996921d0a5dadb65d", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "02dc7d2ecac568b0115175ba3cd5496ff4c4b6d909422dbccaeb8f252fa54f8bd0", + "dleq_proof": "ea43470e5e1be3291b48a0bcb53f55952c64860e7635efba5003ccaded77dd128e216697b0f523be217ec687507797cccebc5cd94362af1b46d803642bd5f940" + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "02c2d9df60ee69a5a86604dd9a6282c22ef8e2c9276da6e595a5463f371aadee24", + "dleq_proof": "ec8242bf7b5881cee060566c7f13cc47e4710c89a26458c20301b3031e48173bc0bea01680d3375eb5e80611ab0d508b1e49abb571b1878032aae49f0b02f7d2" + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "0270bef5c06a9a855fc63bff2bd0be2d70033c820b6153413c8d6eda51f25a1b29", + "dleq_proof": "1bdfe54ed4a2a06fff9b0009ae4e3d80fc47616475afdf21244cfe3c6d104df24741a875514d78654fb9ca1118e6d3e552f38f8caba3552e8333c501bf0cbd3f" + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28031266581e3e841cbb94de62ea735583a232c4c888fa6edef2ec2f870789fc1dd4", + "script": "5120a14fafdc7edfa404427174b126aca0e9993133fe17e2420aeaed20ef54fc38dc" + }, + { + "output_index": 1, + "amount": 90000, + "sp_v0_info": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510024a48d76e20d40bcf5f2e1a5c3ffeb3dc2a8b3b88d79a486d54c9d9eaa8fa188e", + "script": "51207df5f24959443414bbefe89d2da894864a5628b68033b923e8686bdcc3de9d94" + }, + { + "output_index": 2, + "amount": 90000, + "sp_v0_info": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c03f81473844c9d74c921ff17a5af410b6337348d7afbff73891bc5cb0cb24cb0c0", + "script": "5120b2f12a7f570cc5057840fa6393f71f06900142088181810c34bb88761d92950d" + } + ] + } + }, + { + "description": "can finalize: one P2WPKH input / two mixed outputs - labeled sp output and BIP 32 change", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQECAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR8goQcAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9IMEUCIQCspcmETsmSLIHmcIF+My2i3RXSxdOpRiq21l5TtoKJXgIgTfMs6jgucsQ8BYQE/a5wWVHmuj3jPYM8g6lbsBg4CscBAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQMIoIYBAAAAAAABBCJRICJoz1KZWfMKyPelRxykxnU2pR8OWxaK8D7tRxXtzHLgAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA+qPIovitxbaze/LKBAHKHTwCkdvXAe9BRkXEmzGSz3vAQoEAQAAAAABAwhw8wUAAAAAAAEEFgAUOMGPNd2AY+OApw/+tPmP9O80gXciAgLIF7t1Ia/DXqlvO/snDm61Dd/6VWBie5Yf7ADymWUIvwwAAAAAAAAAAAAAAAEA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 500000, + "witness_utxo": "20a1070000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 100000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "sp_v0_label": 1, + "script": "51202268cf529959f30ac8f7a5471ca4c67536a51f0e5b168af03eed4715edcc72e0" + }, + { + "output_index": 1, + "amount": 390000, + "script": "001438c18f35dd8063e380a70ffeb4f98ff4ef348177" + } + ] + } + }, + { + "description": "can finalize: one input / two sp outputs - output 0 has no label / output 1 uses label=0 convention for sp change", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQECAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR/gkwQAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCICJa45kPVF365uoUKpN0arSqgSfjyBNY63wha08anrtiAiBl42BSGRArJQdeOVXz80zjvPKjXR4QFd1dw7/tPs81qQEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0CXw7Bs42ZhKfrYvFb5AMCBBDxQu301p42P1YVr9Kmts0hA84eNnXZDZkmSfqd2xH5AlSbs7AvVvbonMCgTIuMN92UIh0Ci3kOCYf96fDRI5jra+MMc3Z5PGR4ZaKgLy78lg2WZWghAlWBv6pzMVb2uY5xom9w/XazZ4ZJ9isfxed7jpq45hBQIh4CXw7Bs42ZhKfrYvFb5AMCBBDxQu301p42P1YVr9Kmts1ADolhPR9rJTbbj6kkIA2zpvjvyhciVWTVaxEuUIZbTTAuBY+m9hwp4jZqSnJp1FVaXGHWVVnb+/rJ4urf5TY31SIeAot5DgmH/enw0SOY62vjDHN2eTxkeGWioC8u/JYNlmVoQHlAgZ626fJYwVg4fApkzBb1F8fvRAkSLM6qlPTi/1N2xCR7/BcNmyY7Dj0ga2aMh+lLNN7q+gCheSsEeteIdbsAAQMIoIYBAAAAAAABBCJRIKBIDZZnnBk25au9SLqw4B/6yx7n0cnhaC8VQBaZZYXZAQlCAot5DgmH/enw0SOY62vjDHN2eTxkeGWioC8u/JYNlmVoA7RDRzNcY/UF5V4mirKA8xi0pqoUSS/zecNmih9U74ybAAEDCDDmAgAAAAAAAQQiUSB2Hb00kpiEpmcp69zbrPFnSC32LBTwmOTvGNcP4Grv0AEJQgJfDsGzjZmEp+ti8VvkAwIEEPFC7fTWnjY/VhWv0qa2zQK+EFEqlVNkHkB62OUJo0GEUqkC6FVqDboumD9eAPe8UQEKBAAAAAAA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 300000, + "witness_utxo": "e093040000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "025f0ec1b38d9984a7eb62f15be403020410f142edf4d69e363f5615afd2a6b6cd", + "ecdh_share": "03ce1e3675d90d992649fa9ddb11f902549bb3b02f56f6e89cc0a04c8b8c37dd94", + "dleq_proof": "0e89613d1f6b2536db8fa924200db3a6f8efca17225564d56b112e50865b4d302e058fa6f61c29e2366a4a7269d4555a5c61d65559dbfbfac9e2eadfe53637d5", + "input_index": 0 + }, + { + "scan_key": "028b790e0987fde9f0d12398eb6be30c7376793c647865a2a02f2efc960d966568", + "ecdh_share": "025581bfaa733156f6b98e71a26f70fd76b3678649f62b1fc5e77b8e9ab8e61050", + "dleq_proof": "7940819eb6e9f258c158387c0a64cc16f517c7ef4409122cceaa94f4e2ff5376c4247bfc170d9b263b0e3d206b668c87e94b34deeafa00a1792b047ad78875bb", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 100000, + "sp_v0_info": "028b790e0987fde9f0d12398eb6be30c7376793c647865a2a02f2efc960d96656803b44347335c63f505e55e268ab280f318b4a6aa14492ff379c3668a1f54ef8c9b", + "script": "5120a0480d96679c1936e5abbd48bab0e01ffacb1ee7d1c9e1682f154016996585d9" + }, + { + "output_index": 1, + "amount": 190000, + "sp_v0_info": "025f0ec1b38d9984a7eb62f15be403020410f142edf4d69e363f5615afd2a6b6cd037cd462358c69c40525247a8793944d9e67484b248fd84a1e7ee4f920b4da41b5", + "sp_v0_label": 0, + "script": "5120761dbd34929884a66729ebdcdbacf167482df62c14f098e4ef18d70fe06aefd0" + } + ] + } + }, + { + "description": "can finalize: two sp outputs - output 0 uses label=3 / output 1 uses label=1", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQECAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR/gkwQAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCICJa45kPVF365uoUKpN0arSqgSfjyBNY63wha08anrtiAiBl42BSGRArJQdeOVXz80zjvPKjXR4QFd1dw7/tPs81qQEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Ci3kOCYf96fDRI5jra+MMc3Z5PGR4ZaKgLy78lg2WZWghAlWBv6pzMVb2uY5xom9w/XazZ4ZJ9isfxed7jpq45hBQIh4Ci3kOCYf96fDRI5jra+MMc3Z5PGR4ZaKgLy78lg2WZWhAeUCBnrbp8ljBWDh8CmTMFvUXx+9ECRIszqqU9OL/U3bEJHv8Fw2bJjsOPSBrZoyH6Us03ur6AKF5KwR614h1uwABAwighgEAAAAAAAEEIlEgyvcy9B51UZCclKM10VeOp7dmAnuIbN0cFwHZv8a3hIgBCUICi3kOCYf96fDRI5jra+MMc3Z5PGR4ZaKgLy78lg2WZWgDliRhtuNd91TQDUhtw53swgJxhFB2YU5cDtLJDyPT6b4BCgQDAAAAAAEDCDDmAgAAAAAAAQQiUSBNB6DTsPYafF7+NsLDxYfItphBhUYAjPgZu9pM4xgSIQEJQgKLeQ4Jh/3p8NEjmOtr4wxzdnk8ZHhloqAvLvyWDZZlaANaVzfUxngmF8xrDElrsrxiVVoyslQkT7ycnANd9GnlqwEKBAEAAAAA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 300000, + "witness_utxo": "e093040000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "028b790e0987fde9f0d12398eb6be30c7376793c647865a2a02f2efc960d966568", + "ecdh_share": "025581bfaa733156f6b98e71a26f70fd76b3678649f62b1fc5e77b8e9ab8e61050", + "dleq_proof": "7940819eb6e9f258c158387c0a64cc16f517c7ef4409122cceaa94f4e2ff5376c4247bfc170d9b263b0e3d206b668c87e94b34deeafa00a1792b047ad78875bb", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 100000, + "sp_v0_info": "028b790e0987fde9f0d12398eb6be30c7376793c647865a2a02f2efc960d96656803b44347335c63f505e55e268ab280f318b4a6aa14492ff379c3668a1f54ef8c9b", + "sp_v0_label": 3, + "script": "5120caf732f41e7551909c94a335d1578ea7b766027b886cdd1c1701d9bfc6b78488" + }, + { + "output_index": 1, + "amount": 190000, + "sp_v0_info": "028b790e0987fde9f0d12398eb6be30c7376793c647865a2a02f2efc960d96656803b44347335c63f505e55e268ab280f318b4a6aa14492ff379c3668a1f54ef8c9b", + "sp_v0_label": 1, + "script": "51204d07a0d3b0f61a7c5efe36c2c3c587c8b698418546008cf819bbda4ce3181221" + } + ] + } + }, + { + "description": "can finalize: two inputs using per-input ECDH shares - only eligible inputs contribute shares (P2SH excluded)", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9IMEUCIQCLTLcGPIp7P5Ia4ABZbNd4jlRA4dY+8e4bzsjCrJQMogIgNw2OmoWgzI3SWwuwgfMaotzFugoiSOqGCoFlHKNKDGgBAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQ4g0iImLyaHKbt8hMVYEppdaYYYXO9TXsFJwN9g3UqSwgQBDwQAAAAAAQBTAgAAAAFA5vqmEmjxehAQHWJVNYKIKuSFi+4TNj24D98oH4Jf/AAAAAAA/////wHwSQIAAAAAABepFPRfjMomjsJuS76JkXDxOCUNfPVehwAAAAABBEdSIQKHfKAvFEBZvYLQDhs5muN094pSzvehyjfyjXiLNA0veSEDTIekSHL14fAG002IoAlZKCvUU150d8PWjDFlmR6hs5ZSrgEQBP7///8iHQJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaCECYototkRuaocCWXPZOvsZTkIKEPOhzLOK3YLUZKiUpCgiHgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEDCJBfAQAAAAAAAQQiUSAybfUP4KB7estyBwvrO2Muua0VumlcweWqaqAHthRv2AEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 1, + "private_key": "", + "public_key": "", + "prevout_txid": "d222262f268729bb7c84c558129a5d6986185cef535ec149c0df60dd4a92c204", + "prevout_index": 0, + "prevout_scriptpubkey": "a914f45f8cca268ec26e4bbe899170f138250d7cf55e87", + "amount": 150000, + "non_witness_utxo": "020000000140e6faa61268f17a10101d62553582882ae4858bee13363db80fdf281f825ffc0000000000ffffffff01f04902000000000017a914f45f8cca268ec26e4bbe899170f138250d7cf55e8700000000", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120326df50fe0a07b7acb72070beb3b632eb9ad15ba695cc1e5aa6aa007b6146fd8" + } + ] + } + }, + { + "description": "can finalize: two inputs using global ECDH share - only eligible inputs contribute shares (P2SH excluded)", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBACIHAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIIAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQCQwR50toTEKAk0qq45Me0nXbNUfRe6BJ8FZsDk6Z1tvdcWB6+PQgX3a87tZjP16Ai8ah+1HlmT2uUekfbWUHLIAAQ4gGKcXZjsLqxSxKhp3EyP/HkB53VMuXdE+KOoQgccAmEoBDwQAAAAAAQEfoIYBAAAAAAAWABQimnLTSmRb00lru/ULu4HJBj9PlCICAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/SDBFAiEAi0y3BjyKez+SGuAAWWzXeI5UQOHWPvHuG87IwqyUDKICIDcNjpqFoMyN0lsLsIHzGqLcxboKIkjqhgqBZRyjSgxoASIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///wABDiDSIiYvJocpu3yExVgSml1phhhc71NewUnA32DdSpLCBAEPBAAAAAABAFMCAAAAAUDm+qYSaPF6EBAdYlU1gogq5IWL7hM2PbgP3ygfgl/8AAAAAAD/////AfBJAgAAAAAAF6kU9F+MyiaOwm5LvomRcPE4JQ189V6HAAAAAAEER1IhAod8oC8UQFm9gtAOGzma43T3ilLO96HKN/KNeIs0DS95IQNMh6RIcvXh8AbTTYigCVkoK9RTXnR3w9aMMWWZHqGzllKuARAE/v///wABAwiQXwEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 1, + "private_key": "", + "public_key": "", + "prevout_txid": "d222262f268729bb7c84c558129a5d6986185cef535ec149c0df60dd4a92c204", + "prevout_index": 0, + "prevout_scriptpubkey": "a914f45f8cca268ec26e4bbe899170f138250d7cf55e87", + "amount": 150000, + "non_witness_utxo": "020000000140e6faa61268f17a10101d62553582882ae4858bee13363db80fdf281f825ffc0000000000ffffffff01f04902000000000017a914f45f8cca268ec26e4bbe899170f138250d7cf55e8700000000", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "2430479d2da1310a024d2aab8e4c7b49d76cd51f45ee8127c159b0393a675b6f75c581ebe3d0817ddaf3bb598cfd7a022f1a87ed479664f6b947a47db5941cb2" + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120326df50fe0a07b7acb72070beb3b632eb9ad15ba695cc1e5aa6aa007b6146fd8" + } + ] + } + }, + { + "description": "can finalize: two mixed input types - only eligible inputs contribute ECDH shares (NUMS internal key excluded)", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBAAABDiAT8Qa2S1e1sTdvn2xHGQ9AzAazvty4qrqXhQqz1/Z9AgEPBAAAAAABASsQJwAAAAAAACJRIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARAE/v///wEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAAAEOIL6dZcg5FfsJJIq738qMo+7mzjUMVU9l6NUm5d9SerUiAQ8EAAAAAAEBH6CGAQAAAAAAFgAURjPVnK0TQ0eZcuDJlpIdCl2ttl0iAgJDoNQOneCIJ5QWF1K65CupCVJ4QgnwY0MPCvJWNUqFu0gwRQIhAPRhsN/2Q/wKVsFSIUU2OBYiEkh+UGpZ4GnXXtHwq4T2AiBSxx6U6TEuqKkAH80mNyvnCZfdwqpinFqBFtxR9bfpogEBAwQBAAAAIgYCQ6DUDp3giCeUFhdSuuQrqQlSeEIJ8GNDDwryVjVKhbsIAAAAgAEAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghAuV6etywGNj7MVToDBS5elCZS0I1aEHqn8ExcGFEAOY3Ih4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhA9/o4HeILO0WcbkJsYhPUF1MI8kgqROMyhCMmJNTa/Vcymb868WqzAsViY2OjfUSHB/fnoevqBke8zUhiA7srpgABAwgcJQAAAAAAAAEEIlEgBXZyJSSDmoybydLZc46Yu0DdLsUCO9x9p/cGI818hfsBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "13f106b64b57b5b1376f9f6c47190f40cc06b3bedcb8aaba97850ab3d7f67d02", + "prevout_index": 0, + "prevout_scriptpubkey": "512050929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + "amount": 10000, + "witness_utxo": "102700000000000022512050929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 1, + "private_key": "586b0810bbc3c7266971aeb7f8778b41707e092a1aeaf0d953b211f956e35541", + "public_key": "0243a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "00144633d59cad1343479972e0c996921d0a5dadb65d", + "amount": 100000, + "witness_utxo": "a0860100000000001600144633d59cad1343479972e0c996921d0a5dadb65d", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "02e57a7adcb018d8fb3154e80c14b97a50994b42356841ea9fc13170614400e637", + "dleq_proof": "f7fa381de20b3b459c6e426c6213d4175308f2482a44e33284232624d4dafd573299bf3af16ab302c5626363a37d448707f7e7a1ebea0647bccd486203bb2ba6", + "input_index": 1 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 9500, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "51200576722524839a8c9bc9d2d9738e98bb40dd2ec5023bdc7da7f70623cd7c85fb" + } + ] + } + }, + { + "description": "can finalize: three sp outputs (same scan key) - each output has distinct k value", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEEAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+AGgYAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9HMEQCIA3+9XB4r8pV2yK0ze42yxF/h2HkeAgptCHRBkisr62PAiBk5vWJD0/QW3emSnGDHJiGTZyYTJ7x/r6afFC08VkHIQEBAwQBAAAAIgYCyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL8IAAAAgAAAAAABEAT+////Ih0Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GghA+yk/xG3KOLg9gzmIilDpv9VudlfYnv5qZ0IS8hy1QpbIh4Cekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GhAihOzmFVF9yvW6JcUrrkJs+NUqEKpu4tWzQ7e0h34oZlZizEiikngvX6VzhBT98WyistUOmhwdgDjzomCLuMgIQABAwighgEAAAAAAAEEIlEgMm31D+Cge3rLcgcL6ztjLrmtFbppXMHlqmqgB7YUb9gBCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkAAQMIoIYBAAAAAAABBCJRID/LrqSQh/jgPZ3v06vL1t6MAytYP4WmXQV4gcTEQ83AAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AAEDCKCGAQAAAAAAAQQiUSCXFELInwR4bggnKK4Rbuq4AvP6npX9sNN1OmhAICAG3gEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQABAwiQXwEAAAAAAAEEIlEgee9WiKP6f+/DkidFWx8cLwjHAIdCTWoA+EwxQhrKjaQA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 400000, + "witness_utxo": "801a060000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 100000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120326df50fe0a07b7acb72070beb3b632eb9ad15ba695cc1e5aa6aa007b6146fd8" + }, + { + "output_index": 1, + "amount": 100000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "51203fcbaea49087f8e03d9defd3abcbd6de8c032b583f85a65d057881c4c443cdc0" + }, + { + "output_index": 2, + "amount": 100000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120971442c89f04786e082728ae116eeab802f3fa9e95fdb0d3753a6840202006de" + }, + { + "output_index": 3, + "amount": 90000, + "script": "512079ef5688a3fa7fefc39227455b1f1c2f08c70087424d6a00f84c31421aca8da4" + } + ] + } + }, + { + "description": "can finalize: three sp outputs (same scan key) / two regular outputs - k values assigned independently of output index", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEGAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR8goQcAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9IMEUCIQD333KAYwDd2+SyTNcz9TsypkayNsKUYFfeR65QOyqxaAIgTGlAW3e+2vOyB+3ll0m7o9VMRhWoi7025Ugkr3fJ5zEBAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQMIoIYBAAAAAAABBCJRIDJt9Q/goHt6y3IHC+s7Yy65rRW6aVzB5apqoAe2FG/YAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AAEDCFDDAAAAAAAAAQQiUSCcaj0jgY6CDtHQz965Y2V8jsMne32bhlx411V/Xe3WogABAwighgEAAAAAAAEEIlEgP8uupJCH+OA9ne/Tq8vW3owDK1g/haZdBXiBxMRDzcABCUICekh/wZ+3aYd7h0LW6hgRjzxOcrHqjG3mAqetSkHb4GgDYeGx6d5eQssgB/fKVLng1X7ROTj61W0/GeV1E6j84DkAAQMIUMMAAAAAAAABBCJRIHnvVoij+n/vw5InRVsfHC8IxwCHQk1qAPhMMUIayo2kAAEDCKCGAQAAAAAAAQQiUSCXFELInwR4bggnKK4Rbuq4AvP6npX9sNN1OmhAICAG3gEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQABAwjILAEAAAAAAAEEIlEgb8UG+AUqn1qKqbmQ1emU9JXd7W1HusRMqs2t2NiZHNAA", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 500000, + "witness_utxo": "20a1070000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 100000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120326df50fe0a07b7acb72070beb3b632eb9ad15ba695cc1e5aa6aa007b6146fd8" + }, + { + "output_index": 1, + "amount": 50000, + "script": "51209c6a3d23818e820ed1d0cfdeb963657c8ec3277b7d9b865c78d7557f5dedd6a2" + }, + { + "output_index": 2, + "amount": 100000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "51203fcbaea49087f8e03d9defd3abcbd6de8c032b583f85a65d057881c4c443cdc0" + }, + { + "output_index": 3, + "amount": 50000, + "script": "512079ef5688a3fa7fefc39227455b1f1c2f08c70087424d6a00f84c31421aca8da4" + }, + { + "output_index": 4, + "amount": 100000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120971442c89f04786e082728ae116eeab802f3fa9e95fdb0d3753a6840202006de" + }, + { + "output_index": 5, + "amount": 77000, + "script": "51206fc506f8052a9f5a8aa9b990d5e994f495dded6d47bac44caacdadd8d8991cd0" + } + ] + } + }, + { + "description": "in progress: two P2TR inputs, neither is signed", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBAAABDiAT8Qa2S1e1sTdvn2xHGQ9AzAazvty4qrqXhQqz1/Z9AgEPBAAAAAABASughgEAAAAAACJRIMgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/AQMEAQAAAAEQBP7///8BFyDIF7t1Ia/DXqlvO/snDm61Dd/6VWBie5Yf7ADymWUIvyIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQ4gK5A3/0HdF05VIvD0BFw/QENiMq8mANTaXJ6hPMPKtLUBDwQAAAAAAQErECcAAAAAAAAiUSBDoNQOneCIJ5QWF1K65CupCVJ4QgnwY0MPCvJWNUqFuwEDBAEAAAABEAT+////ARcgQ6DUDp3giCeUFhdSuuQrqQlSeEIJ8GNDDwryVjVKhbsiHQJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaCEC5Xp63LAY2PsxVOgMFLl6UJlLQjVoQeqfwTFwYUQA5jciHgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaED3+jgd4gs7RZxuQmxiE9QXUwjySCpE4zKEIyYk1Nr9VzKZvzrxarMCxWJjY6N9RIcH9+eh6+oGR7zNSGIDuyumAAEDCLj5AgAAAAAAAQQiUSDLH766UNBuv/tpC9qdX/Oc4vrbT87clmMu/xukHMDAewEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "13f106b64b57b5b1376f9f6c47190f40cc06b3bedcb8aaba97850ab3d7f67d02", + "prevout_index": 0, + "prevout_scriptpubkey": "5120c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "amount": 100000, + "witness_utxo": "a086010000000000225120c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 1, + "private_key": "586b0810bbc3c7266971aeb7f8778b41707e092a1aeaf0d953b211f956e35541", + "public_key": "0243a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "prevout_txid": "2b9037ff41dd174e5522f0f4045c3f40436232af2600d4da5c9ea13cc3cab4b5", + "prevout_index": 0, + "prevout_scriptpubkey": "512043a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "amount": 10000, + "witness_utxo": "102700000000000022512043a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + }, + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "02e57a7adcb018d8fb3154e80c14b97a50994b42356841ea9fc13170614400e637", + "dleq_proof": "f7fa381de20b3b459c6e426c6213d4175308f2482a44e33284232624d4dafd573299bf3af16ab302c5626363a37d448707f7e7a1ebea0647bccd486203bb2ba6", + "input_index": 1 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 195000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + "script": "5120cb1fbeba50d06ebffb690bda9d5ff39ce2fadb4fcedc96632eff1ba41cc0c07b" + } + ] + } + }, + { + "description": "in progress: one P2TR input / one sp output with no ECDH shares when PSBT_OUT_SCRIPT field is not set", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQEBAQYBAAABDiAT8Qa2S1e1sTdvn2xHGQ9AzAazvty4qrqXhQqz1/Z9AgEPBAAAAAABASughgEAAAAAACJRIMgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/ARAE/v///wEXIMgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/AAEDCBhzAQAAAAAAAQlCAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoA2HhseneXkLLIAf3ylS54NV+0Tk4+tVtPxnldROo/OA5AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "13f106b64b57b5b1376f9f6c47190f40cc06b3bedcb8aaba97850ab3d7f67d02", + "prevout_index": 0, + "prevout_scriptpubkey": "5120c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "amount": 100000, + "witness_utxo": "a086010000000000225120c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [], + "outputs": [ + { + "output_index": 0, + "amount": 95000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + } + ] + } + }, + { + "description": "in progress: two inputs / one sp output, input 1 missing ECDH share when PSBT_OUT_SCRIPT field is not set", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQIBBQEBAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoIQPspP8Rtyji4PYM5iIpQ6b/VbnZX2J7+amdCEvIctUKWyIeAnpIf8Gft2mHe4dC1uoYEY88TnKx6oxt5gKnrUpB2+BoQIoTs5hVRfcr1uiXFK65CbPjVKhCqbuLVs0O3tId+KGZWYsxIopJ4L1+lc4QU/fFsorLVDpocHYA486Jgi7jICEAAQ4gvp1lyDkV+wkkirvfyoyj7ubONQxVT2Xo1Sbl31J6tSIBDwQAAAAAAQEf8EkCAAAAAAAWABRGM9WcrRNDR5ly4MmWkh0KXa22XSIGAkOg1A6d4IgnlBYXUrrkK6kJUnhCCfBjQw8K8lY1SoW7CAAAAIABAAAAARAE/v///wABAwhADQMAAAAAAAEJQgJ6SH/Bn7dph3uHQtbqGBGPPE5yseqMbeYCp61KQdvgaANh4bHp3l5CyyAH98pUueDVftE5OPrVbT8Z5XUTqPzgOQA=", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 1, + "private_key": "586b0810bbc3c7266971aeb7f8778b41707e092a1aeaf0d953b211f956e35541", + "public_key": "0243a0d40e9de0882794161752bae42ba90952784209f063430f0af256354a85bb", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "00144633d59cad1343479972e0c996921d0a5dadb65d", + "amount": 150000, + "witness_utxo": "f0490200000000001600144633d59cad1343479972e0c996921d0a5dadb65d", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "ecdh_share": "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b", + "dleq_proof": "8a13b3985545f72bd6e89714aeb909b3e354a842a9bb8b56cd0eded21df8a199598b31228a49e0bd7e95ce1053f7c5b28acb543a68707600e3ce89822ee32021", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 200000, + "sp_v0_info": "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe0680361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + } + ] + } + }, + { + "description": "in progress: one input / two sp outputs, input 0 missing ECDH share for output 0 when PSBT_OUT_SCRIPT field is not set", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQEBBQECAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR9ADQMAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdA1LXjEE5ADKtkYFqaX/HQNjrkJzwTXCIUmSwUfI4XiXsIQMJH9SAORd/RNe3npYWiEnGLrP4eADEZ2fFCo84+Y79FSIeA1LXjEE5ADKtkYFqaX/HQNjrkJzwTXCIUmSwUfI4XiXsQL4acjrvVeiYG5lZmpvBKzj4froBWMxwZokPImnK3kY7dzQal9KScZZNAcAkHMMi/VOQwpuZFg+CbzKTZYmMvwIAAQMIkF8BAAAAAAABCUIDZR0sBz/LAqTYLdpT8dUB13oDUGZnmFRkc7ISa2zRp94D8nDgpWMhg7arAQdOh35oANsA9cfF0vr7to+cKNSFRSQAAQMIkF8BAAAAAAABBCJRIENYHfbIefmqrcU2YN6xVrnxIr0a7KczIDvOh/zx84buAQlCA1LXjEE5ADKtkYFqaX/HQNjrkJzwTXCIUmSwUfI4XiXsAuDEwYe3IV+ZfISFp1tFkbbChHe5WhEjuZaSLTFUhgv4AA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 200000, + "witness_utxo": "400d030000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": false + } + ], + "sp_proofs": [ + { + "scan_key": "0352d78c41390032ad91816a697fc740d8eb909cf04d70885264b051f2385e25ec", + "ecdh_share": "03091fd48039177f44d7b79e96168849c62eb3f87800c46767c50a8f38f98efd15", + "dleq_proof": "be1a723aef55e8981b99599a9bc12b38f87eba0158cc7066890f2269cade463b77341a97d29271964d01c0241cc322fd5390c29b99160f826f329365898cbf02", + "input_index": 0 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 90000, + "sp_v0_info": "03651d2c073fcb02a4d82dda53f1d501d77a0350666798546473b2126b6cd1a7de03f270e0a5632183b6ab01074e877e6800db00f5c7c5d2fafbb68f9c28d4854524" + }, + { + "output_index": 1, + "amount": 90000, + "sp_v0_info": "0352d78c41390032ad91816a697fc740d8eb909cf04d70885264b051f2385e25ec02e0c4c187b7215f997c8485a75b4591b6c28477b95a1123b996922d3154860bf8", + "script": "512043581df6c879f9aaadc53660deb156b9f122bd1aeca733203bce87fcf1f386ee" + } + ] + } + }, + { + "description": "in progress: large PSBT with nine mixed inputs / six outputs - some inputs signed", + "psbt": "cHNidP8B+wQCAAAAAQIEAgAAAAEEAQkBBQEGAQYBAAABDiAYpxdmOwurFLEqGncTI/8eQHndUy5d0T4o6hCBxwCYSgEPBAAAAAABAR+ghgEAAAAAABYAFCKactNKZFvTSWu79Qu7gckGP0+UIgICyBe7dSGvw16pbzv7Jw5utQ3f+lVgYnuWH+wA8pllCL9IMEUCIQCLNThMVlDzsjgXC0oPt/Sorr8CiDHHC50NMY3FUceJxgIgMZmwKWN19OdCfS+4KMjLfkHblItuYaaIKCeY8x+RIogBAQMEAQAAACIGAsgXu3Uhr8NeqW87+ycObrUN3/pVYGJ7lh/sAPKZZQi/CAAAAIAAAAAAARAE/v///yIdAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQIQP70XPZxI1YxQIkJtBSeLyhe5qOIoJmaQGhsAueBLeZSSIdAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQMKGLm7QZm4kzDrF/juWseG4IU9zS88zfF0S5fOTt4FPiIdA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsIQNzctF59PYo2oLMvU5CF72wgJ8UF8mr6qNfqAXUitZVTCIeAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQQMBdpBBIfPrsN4/r5q9QBeOubIYjaBiw5VshQBfrUHPt70AXlXW13NqpBjSRrIuD/QYk1Bd6TS3UmUxGQNa4zN4iHgMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKEAAE4PUZhvocl7ykEj0jJEfifXzQxfXwMCsxg72XxtSv/96ltgzm2Jd+Dq6tXqic+3NmjXHlNrLschhFxuyZwdfIh4DjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGxApoctTrEZ/ir3CVP3UqIiNrVKO1f3DlEfEL2LQv9Wtaz2NlCTxIDqnxN3Z4SUW1kxisKSfZFariRM+2g/QYijpwABDiC+nWXIORX7CSSKu9/KjKPu5s41DFVPZejVJuXfUnq1IgEPBAAAAAABAR+ghgEAAAAAABYAFBOTUBF8V3lBqYWAK7nt78ETkyvHIgIC9bWfpeSSIh6/Vbp4rUQmBb6ulRZroeujJQ0Luqx+LtxHMEQCIGBe6cE9y5EoaPHeTsHHUaxLRis49eXxBj+EHthVABbpAiBHqaafS5jOmNbyz3jQjoWlVWhRC3tc+65oAn9FjH5+BQEBAwQBAAAAIgYC9bWfpeSSIh6/Vbp4rUQmBb6ulRZroeujJQ0Luqx+LtwIAAAAgAEAAAABEAT+////Ih0CqfH6wsxFdoTzPekAc/4g0iyUfJIWO4YrFNCrod9HFRAhA6aomxuYaplVM+F77WRQC1QbsNc5Bx7+KuK377CgymvPIh0DM2HT/3LzTmtOenvvluW4FR5mqXmtznU8OcWDy+V2bSghAvaue4qsmg+cnwTqtIQGIXLfLjjZ8vbsMbQEu1ooaqvwIh0DjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGwhArHT7S+kvDFqCOsUhfp17X3VTKeprhjQxKF9qT8ww4XVIh4CqfH6wsxFdoTzPekAc/4g0iyUfJIWO4YrFNCrod9HFRBAw0pqKUC76ac1bBzG/+NY3URB21iJfLqZwhLrdShefaHsoRx5LJejgEkzRasiTenZTCs27oUmriNSgXGjIx3nMyIeAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oQDNKztgQbrqkKH2spWzKJnlfLSpzl4TPa3NNozcHV/SZ4SDA5AvkBDbNdMTvJpBXBvUvp/pujnZFHoWDeDBRqEMiHgOO0yIOczRPkeLTM5Hj4Wkds52vBuQjfhl+0NAHvycobEDYQaNzrSiWuXK4z+oWNP8Uow+A2Ez9bvGIkzl7jzRsgFGcmxYaciRNDptk5UNsmM6d1OF6Byj8mXtce885zstoAAEOINBBx0LKNkRy0aGMeo6uB+bpBKnqG4XxmFJfuD7yduNDAQ8EAAAAAAEBH6CGAQAAAAAAFgAUIwf8d0yhdLQq2oFLwubkRP89gwwiAgJlXvXa8F0o0cpzuo4hEwDbK3BBZzcYPg+ow1XuTBy6xkcwRAIgKTOk1x6k+7U8mzjYjLK+bqIQKQBrzEx5rJgFGKrIYxkCICj9GcotjGhqD9CjsK6gmpqVgMw4oisFIgS+RXXwNa0/AQEDBAEAAAAiBgJlXvXa8F0o0cpzuo4hEwDbK3BBZzcYPg+ow1XuTBy6xggAAACAAgAAAAEQBP7///8iHQKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVECED33Bv8m1nL+UMb1aiyh5xdOdQVckgu9M34afWvygm9twiHQMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKCEDv7hiU765b1i4xGnkQs/Pn3ilQTqVQjvo5oZ2Ty+nvT8iHQOO0yIOczRPkeLTM5Hj4Wkds52vBuQjfhl+0NAHvycobCEDhgSqjP8wnd30mx82b4nCaWFlh/xB22CFQPlDFTb2ZG8iHgKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVEED9LowiubZy07ueJtS22i3D8SVFWpaf2OKywbp5EHatcbOMAfkhjD1ndv2JXS8wZ0hJltheJoOQwrQz5sSyGJHxIh4DM2HT/3LzTmtOenvvluW4FR5mqXmtznU8OcWDy+V2bShAUt/78AlfKbHeJMnWjVSdHFS85pAEVP43/RD1yQPf7oX3utinKtqOUs2QkvUdnpC1p+TzbKBN+Y53+60TRzBbxyIeA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsQJPh5dSIi8HP4C7s+p3qFE6thPI6EPwaMBEDweVjk1J7Y0F8bEpa9FNZ/4tuF5r3OXjk1i6GLu3Soob1J45Ms0AAAQ4gUiYs1dQCvzJhbQf8Ayhqj3/nivesQR3/9BZV3cEBHm8BDwQAAAAAAQEfoIYBAAAAAAAWABRwGLzpZSiNi/DM8+DsSLbx27/lSCICAwOg9FKNJk6OlSVmNLlEFOJ0j3KWJwT+V+iwosxE0jg9SDBFAiEA2yW73RvPhQMJZj64j7uw62ETCVeF1/bVM42du8cFAycCIHmV0Cb1bqmeBz39CnlsbnT8Ye0I7NJGcM7zQNrVCueaAQEDBAEAAAAiBgMDoPRSjSZOjpUlZjS5RBTidI9ylicE/lfosKLMRNI4PQgAAACAAwAAAAEQBP7///8iHQKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVECEDgCmZjHPCsTnbdVV+wwEexl7NISRHljAGVxhSo+kiicwiHQMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKCECmAuscmThS/ggzENbaaeN9ybBd+sCbxuBtS3oEScvWOsiHQOO0yIOczRPkeLTM5Hj4Wkds52vBuQjfhl+0NAHvycobCEDon2fH6U+Q/cINaaZCVS3ijX3RwZve4K0qeqC6LteBqYiHgKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVEEA4HOPBrJ/H9qUVNkViUphHaOj70BQHAjn0yxOxZ5PHTMXtZ3yKfqzBc+mI01Oj8onH4hQdt6S0FFxUmeyDfzauIh4DM2HT/3LzTmtOenvvluW4FR5mqXmtznU8OcWDy+V2bShAOOA0Y7ZFDb0FEGWoEqbyYxCH95UeRqyE5m2oEXVXF8CTGA5NSYJ7jAl8pwk/6S05P5Wl162EmkGALcTkVYq96SIeA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsQHMqoJV6nbLHWeOayl0BZ+8T0qNKQ8gS6cydcS1Fj3sihdp1EesW9h+WANIL5OlN/dK8OuN/2UP8NJR9/D/cZ5cAAQ4g4pjq8k/hymi0Hktrg8w9kc7ZnG541bmSz7FXcbVFcGQBDwQAAAAAAQEfoIYBAAAAAAAWABSTMrsCDLA9SetNBHEsHMCOKwbY2CICA6iRG+taSP4RnLAVCIzww6EYzwJnckSJctG3DHZKxZQSRzBEAiBr1hL2RzkhB3F8V6+V7oRzl08sMjurcHnoGRbsuQIcjgIgWlToLfcW0eQKehOGkN9w6tSjzILOEImugKMj+1/ui1UBAQMEAQAAACIGA6iRG+taSP4RnLAVCIzww6EYzwJnckSJctG3DHZKxZQSCAAAAIAEAAAAARAE/v///yIdAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQIQK+9HOMbzJ/FySxQHmIlZzfK9GSEIlL/QQ1NbYROCcIGCIdAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQJ+t8HEr81+Yqtq09K6JzOjdFIgZB+RkD6/57zF29xrXSIdA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsIQPKiLzbbR9SkekMddPaYBqL0pmR6rv3+UsvOUg+OFjJoSIeAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQQApYSScQKCC3tsrWvPsDGiOjcxKth9I/HLWoNZvi1f85tqKi/jfNDVPuNH8T5rNSV2my54nwaZy5hViOrC2vEN0iHgMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKEB3sVcjNtm9Ti9zQR649qliNaEw9ETL6wekifRHkh43SJ1x/T2yigu6o1uIw3t1eezb9s4WOHANuB38hr95zRvnIh4DjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGxAXQ3oon8NUogCzzNUoa9Qe88P7UGdpkHJwIB811Tgevh5C1EccF1fv2MwWrIq/gSX+s8HC1r+r87jppAFR3Z3VgABDiAXMIM+y1vgE2LXFJUiYESEIW4cexjPMT0c8VQ++F3ztgEPBAAAAAABASughgEAAAAAACJRINFVcTxSDOLYJ2FkIV1lmrYjxfcwb4ZSwXVuJoM+iG4BAQMEAQAAAAEQBP7///8BFyDRVXE8Ugzi2CdhZCFdZZq2I8X3MG+GUsF1biaDPohuASIdAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQIQJPKhWksVprePCmMvx/3xEpKfZL1N0U9npjz0+48yWiBCIdAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQOAK2mzYNpFhVErHPlnsYQBAibXXOW3Yud6ASi8sGjMuyIdA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsIQJAdhVQXsveqIExJQTu9vRoGiYxbvc9M1N6f9eT9GWOqSIeAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQQI8iPeJ5ht2HkqA9nVtmIyPoaXgw66u2c2OIH+cshkgq5hoJJo0IO4NUz8K601wUjuspGvYi3UYGNGBjMq8bgpciHgMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKEAQOuBcEOQymDdsP+4dxfDQQ4n08gr0k0bUjq0yTWFdbyj8oKMYRFEw6laoYgGbLeDCY32zxBjAsHS1hUiuiyDJIh4DjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGxA8FWZBzhUTQmbFZKQ5/VELLkSJiYBdKqdjByDELfLAc4rlMxdK4/fgWZ+Avt5bbDlwnh33g1BoqDnCbAURbrylgABDiDpNTLg7aOcTTnaLT+R0UUpvENfwu+5GW4l+6rEtOibgQEPBAAAAAABASughgEAAAAAACJRINAOFftian7+6FxbzmdtY2tgSJFOmjSs4LTLxEvJSAd6AQMEAQAAAAEQBP7///8BFyDQDhX7Ymp+/uhcW85nbWNrYEiRTpo0rOC0y8RLyUgHeiIdAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQIQIVP83+lq5wNj/RVCZG0wnCEyuDJVQOPdf4jjf5eWB5piIdAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQIPqjoMMa52ledpVG4wqngqvTH7aBuXChOqNcwh39E4ACIdA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsIQPzE/j0desB487LwOqsL3JtKQMsb/fDrWzDv4uxAYZSfCIeAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQQEMg6fYP08Km9zeqzlRsI7DH126EFZwK0onB30wPilAOUSCfKW+dfSI6HSkr1B0mb6rLG+hDvs9LcG6xXbpwKAYiHgMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKEDkB0puyJPquAbV35p07SV+eF/zeGQ9uqeTPHgTp7X2yQyoK2JTnzmzjCxy/lz4HCKl5kH2c2RPbEhrPbV3RkRjIh4DjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGxAOynhLPaT8nITn3RFLyvMwrzQdDLc6PsQc1TBRYoSpHyFRNLWyh4zvOBXi2UIz4pZNGc/n8Q1pfhKIqVLWty8/AABDiC2Nou8Mwzd/p4lUO2338Fa50ysm8x9ln/oHm8n+Fl7HQEPBAAAAAABAFMCAAAAAf6hTz0WmysEANsUmWIhsxGCu56GyhaekbnHvZP5xz6IAAAAAAD/////AaCGAQAAAAAAF6kUDfEBNX9z5mWFAM9W86Ey17uK+92HAAAAAAEBIKCGAQAAAAAAF6kUDfEBNX9z5mWFAM9W86Ey17uK+92HIgICBQDxZ89mnn2sOeauzaHyjGa3jscJfu/vJdVPe+eF5/ZHMEQCIA134+usWENCsMr5fopp9YcHxKsAzK2+hsRKoYe1Zfv/AiBubgVutYtmJeL++r0axbRapqEto5ypCKEaIcY396JcMAEBAwQBAAAAAQQWABTGvIEF7G22DE2lOsNc2iBAaekOiiIGAgUA8WfPZp59rDnmrs2h8oxmt47HCX7v7yXVT3vnhef2CAAAAIAHAAAAARAE/v///yIdAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQIQL+eEHN37Rv2yypveVslVjC0bNhIWZWWQgCJOJAMUOECCIdAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oIQJvAnsjWnWTyolDxte3432S6rM5YX9/DDRBBsWN0MvGqiIdA47TIg5zNE+R4tMzkePhaR2zna8G5CN+GX7Q0Ae/JyhsIQIGEUUMbBlE7N7LePYYFsdn+6Q2jyH36bFpd5x28jz0zCIeAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQQIgHJVFmLD7r7lRfGIuNr8aClzovIY58oH/I5C/1ehE0YwRJ/XAxd9ehLQyc7QJIRNwbng7K1nWH3VxHlDGzahoiHgMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKEAWtYFI97r/eD5DEPGFUD/pJBfbpDuWbIOiGEMiyx8gQky50ECuahrqTnREvceGJYqXVkTOGgGVVkwrn8PGBqKSIh4DjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGxASrNr3WnrHNDUsK6uQD3OTbUijFujopHccl3iM4LRc20mXXvDP4NpClylYJcZ8Z6BlS1MjV4oj9DLjGQREEuAowABDiADvOulN51z2ur+Z3lysD3PfvMLfxDqqDrQS8C/ANiy9wEPBAAAAAABAFUCAAAAASo0rBsNt3qBmMHikALMR7v81u2DCWAwTSyaOrVISGtdAAAAAAD/////AaCGAQAAAAAAGXapFFab8xHmijg3Z7Q/gwQt+r5gCT7eiKwAAAAAIgIDGZ0vFV8y67FnrrDMym5a+3NAiY3xt6uZNlAPbUORkCpHMEQCIGIn/SRuyIcF32NUW0i+PRLD1KVITUtOxDRAmsuEETxaAiA8y8m0eI77f7hg2oVYqUx+TEJa11XoaOLY8Fz7RhJflwEBAwQBAAAAIgYDGZ0vFV8y67FnrrDMym5a+3NAiY3xt6uZNlAPbUORkCoIAAAAgAgAAAABEAT+////Ih0CqfH6wsxFdoTzPekAc/4g0iyUfJIWO4YrFNCrod9HFRAhAvPBdbCLIHY3YWqnTme6rBoYB8lLznCAD3b1sXtSwAgtIh0DM2HT/3LzTmtOenvvluW4FR5mqXmtznU8OcWDy+V2bSghAvoc7ZMlmKJ/ooQQuVXqZ0ebWkAztp8smeTiZ2e7oKVWIh0DjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGwhAwDg/RcQ3FatfBc6qRQdcxV3AJvD3/y6hXakjnerNNlwIh4CqfH6wsxFdoTzPekAc/4g0iyUfJIWO4YrFNCrod9HFRBAmSJWWP7eeowkN+s4Ib3J03AKvIlui23O+E4pNHt4vCsD5tozg25PQHZzkMNmRM4Bazax1UaWNUD6UdCwQH221yIeAzNh0/9y805rTnp775bluBUeZql5rc51PDnFg8vldm0oQKCUjFF9cJdsDjnKZ6vxnnIXBA2+Fyu5ZTQTPm/6OUHBZZQW9UBmK3Vh77cAZLTXGpsXtjvFY/Yw20Qi8nPg3xIiHgOO0yIOczRPkeLTM5Hj4Wkds52vBuQjfhl+0NAHvycobEDuCqplynPMxUezHCt+0q4xpPTM2upz8q3SRjDWCO3prqXYCGiBhuvKQrh376sT815g8hDU9iBgL4UPwHMcIB6RAAEDCDB1AAAAAAAAAQQiUSAa+l2IGIzvm5Gz8QArEeD56UrTmBl4xNEqBgVWTAgSMwEJQgMzYdP/cvNOa056e++W5bgVHmapea3OdTw5xYPL5XZtKAMSZlgePoQcu5TeYupzVYOiMsTIiPpu3vLsL4cHifwd1AABAwgwdQAAAAAAAAEEIlEgyr9+Sg3kJOybut1/ckLM52c+FwH0nWoZqXBV2jMrEyQBCUIDM2HT/3LzTmtOenvvluW4FR5mqXmtznU8OcWDy+V2bSgDEmZYHj6EHLuU3mLqc1WDojLEyIj6bt7y7C+HB4n8HdQAAQMIIE4AAAAAAAABBCJRIEqtyeClkNIeDBnLICTycUD5sZwqEoLVDqfF9F6wP9SiAQlCAqnx+sLMRXaE8z3pAHP+INIslHySFjuGKxTQq6HfRxUQAkpI124g1AvPXy4aXD/+s9wqizuI15pIbVTJ2eqo+hiOAAEDCCBOAAAAAAAAAQQiUSAvpDcA6h0o/XrU3LMys+XloNjLVCWPZmHE3gN31ygE5QEJQgKp8frCzEV2hPM96QBz/iDSLJR8khY7hisU0Kuh30cVEAJKSNduINQLz18uGlw//rPcKos7iNeaSG1UydnqqPoYjgABAwgQJwAAAAAAAAEEIlEgUZhvruMZQ2tRs4QJwbMA9DluKzLjzJoSripL4TXab24BCUIDjtMiDnM0T5Hi0zOR4+FpHbOdrwbkI34ZftDQB78nKGwD+BRzhEyddMkh/xelr0ELYzc0jXr7/3OJG8XLDLJMsMAAAQMIUMMAAAAAAAABBCJRIG/FBvgFKp9aiqm5kNXplPSV3e1tR7rETKrNrdjYmRzQAA==", + "supplementary": { + "inputs": [ + { + "input_index": 0, + "private_key": "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31", + "public_key": "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf", + "prevout_txid": "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a", + "prevout_index": 0, + "prevout_scriptpubkey": "0014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "amount": 100000, + "witness_utxo": "a086010000000000160014229a72d34a645bd3496bbbf50bbb81c9063f4f94", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 1, + "private_key": "295c2eedddd8331d20b5d4cf9e69bb523ed85cb0bf35ab12e04fea66fe6d4a4a", + "public_key": "02f5b59fa5e492221ebf55ba78ad442605beae95166ba1eba3250d0bbaac7e2edc", + "prevout_txid": "be9d65c83915fb09248abbdfca8ca3eee6ce350c554f65e8d526e5df527ab522", + "prevout_index": 0, + "prevout_scriptpubkey": "0014139350117c577941a985802bb9edefc113932bc7", + "amount": 100000, + "witness_utxo": "a086010000000000160014139350117c577941a985802bb9edefc113932bc7", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 2, + "private_key": "e6db4a04e2fc29745ea1a9dcf1fed7449c8228ffe0503d8171e64126d5399800", + "public_key": "02655ef5daf05d28d1ca73ba8e211300db2b70416737183e0fa8c355ee4c1cbac6", + "prevout_txid": "d041c742ca364472d1a18c7a8eae07e6e904a9ea1b85f198525fb83ef276e343", + "prevout_index": 0, + "prevout_scriptpubkey": "00142307fc774ca174b42ada814bc2e6e444ff3d830c", + "amount": 100000, + "witness_utxo": "a0860100000000001600142307fc774ca174b42ada814bc2e6e444ff3d830c", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 3, + "private_key": "f5208ed67b0019ba4e306aff029d247931e0dfacd1fe6e69091bc6bc70a42619", + "public_key": "0303a0f4528d264e8e95256634b94414e2748f72962704fe57e8b0a2cc44d2383d", + "prevout_txid": "52262cd5d402bf32616d07fc03286a8f7fe78af7ac411dfff41655ddc1011e6f", + "prevout_index": 0, + "prevout_scriptpubkey": "00147018bce965288d8bf0ccf3e0ec48b6f1dbbfe548", + "amount": 100000, + "witness_utxo": "a0860100000000001600147018bce965288d8bf0ccf3e0ec48b6f1dbbfe548", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 4, + "private_key": "0c19c34dc1005feb5ca60104d20472f7f2f97bd4d521264618196bf71155b20a", + "public_key": "03a8911beb5a48fe119cb015088cf0c3a118cf026772448972d1b70c764ac59412", + "prevout_txid": "e298eaf24fe1ca68b41e4b6b83cc3d91ced99c6e78d5b992cfb15771b5457064", + "prevout_index": 0, + "prevout_scriptpubkey": "00149332bb020cb03d49eb4d04712c1cc08e2b06d8d8", + "amount": 100000, + "witness_utxo": "a0860100000000001600149332bb020cb03d49eb4d04712c1cc08e2b06d8d8", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 5, + "private_key": "5aaa151cbdeb566911784d166b01ce57f35993b98f96846efec1cfefcc0d8f56", + "public_key": "02d155713c520ce2d8276164215d659ab623c5f7306f8652c1756e26833e886e01", + "prevout_txid": "1730833ecb5be01362d7149522604484216e1c7b18cf313d1cf1543ef85df3b6", + "prevout_index": 0, + "prevout_scriptpubkey": "5120d155713c520ce2d8276164215d659ab623c5f7306f8652c1756e26833e886e01", + "amount": 100000, + "witness_utxo": "a086010000000000225120d155713c520ce2d8276164215d659ab623c5f7306f8652c1756e26833e886e01", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 6, + "private_key": "d92c1506167b3a00a21f4d90d7b5ccb4713c00194aa66f9c7deb162c467f9643", + "public_key": "02d00e15fb626a7efee85c5bce676d636b6048914e9a34ace0b4cbc44bc948077a", + "prevout_txid": "e93532e0eda39c4d39da2d3f91d14529bc435fc2efb9196e25fbaac4b4e89b81", + "prevout_index": 0, + "prevout_scriptpubkey": "5120d00e15fb626a7efee85c5bce676d636b6048914e9a34ace0b4cbc44bc948077a", + "amount": 100000, + "witness_utxo": "a086010000000000225120d00e15fb626a7efee85c5bce676d636b6048914e9a34ace0b4cbc44bc948077a", + "sequence": 4294967294, + "signed": false + }, + { + "input_index": 7, + "private_key": "d8bc8d0e1233a97133f3573d6dceb6736e8b6d2e4839925145022ea3efdad8f8", + "public_key": "020500f167cf669e7dac39e6aecda1f28c66b78ec7097eefef25d54f7be785e7f6", + "prevout_txid": "b6368bbc330cddfe9e2550edb7dfc15ae74cac9bcc7d967fe81e6f27f8597b1d", + "prevout_index": 0, + "prevout_scriptpubkey": "a9140df101357f73e6658500cf56f3a132d7bb8afbdd87", + "amount": 100000, + "witness_utxo": "a08601000000000017a9140df101357f73e6658500cf56f3a132d7bb8afbdd87", + "sequence": 4294967294, + "signed": true + }, + { + "input_index": 8, + "private_key": "81e30f5b5d049bdf5c5bea9c3329bf57cc8b473240182a9f2a6eee07435bd54a", + "public_key": "03199d2f155f32ebb167aeb0ccca6e5afb7340898df1b7ab9936500f6d4391902a", + "prevout_txid": "03bceba5379d73daeafe677972b03dcf7ef30b7f10eaa83ad04bc0bf00d8b2f7", + "prevout_index": 0, + "prevout_scriptpubkey": "76a914569bf311e68a383767b43f83042dfabe60093ede88ac", + "amount": 100000, + "non_witness_utxo": "02000000012a34ac1b0db77a8198c1e29002cc47bbfcd6ed830960304d2c9a3ab548486b5d0000000000ffffffff01a0860100000000001976a914569bf311e68a383767b43f83042dfabe60093ede88ac00000000", + "sequence": 4294967294, + "signed": true + } + ], + "sp_proofs": [ + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "030a18b9bb4199b89330eb17f8ee5ac786e0853dcd2f3ccdf1744b97ce4ede053e", + "dleq_proof": "001383d4661be8725ef29048f48c911f89f5f34317d7c0c0acc60ef65f1b52bfff7a96d8339b625df83abab57aa273edcd9a35c794dacbb1c861171bb267075f", + "input_index": 0 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "03fbd173d9c48d58c5022426d05278bca17b9a8e2282666901a1b00b9e04b79949", + "dleq_proof": "c05da410487cfaec378febe6af5005e3ae6c86236818b0e55b214017eb5073edef40179575b5dcdaa9063491ac8b83fd0624d4177a4d2dd4994c4640d6b8ccde", + "input_index": 0 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "037372d179f4f628da82ccbd4e4217bdb0809f1417c9abeaa35fa805d48ad6554c", + "dleq_proof": "a6872d4eb119fe2af70953f752a22236b54a3b57f70e511f10bd8b42ff56b5acf6365093c480ea9f13776784945b59318ac2927d915aae244cfb683f4188a3a7", + "input_index": 0 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "02f6ae7b8aac9a0f9c9f04eab484062172df2e38d9f2f6ec31b404bb5a286aabf0", + "dleq_proof": "334aced8106ebaa4287daca56cca26795f2d2a739784cf6b734da3370757f499e120c0e40be40436cd74c4ef26905706f52fa7fa6e8e76451e8583783051a843", + "input_index": 1 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "03a6a89b1b986a995533e17bed64500b541bb0d739071efe2ae2b7efb0a0ca6bcf", + "dleq_proof": "c34a6a2940bbe9a7356c1cc6ffe358dd4441db58897cba99c212eb75285e7da1eca11c792c97a380493345ab224de9d94c2b36ee8526ae23528171a3231de733", + "input_index": 1 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "02b1d3ed2fa4bc316a08eb1485fa75ed7dd54ca7a9ae18d0c4a17da93f30c385d5", + "dleq_proof": "d841a373ad2896b972b8cfea1634ff14a30f80d84cfd6ef18893397b8f346c80519c9b161a72244d0e9b64e5436c98ce9dd4e17a0728fc997b5c7bcf39cecb68", + "input_index": 1 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "03bfb86253beb96f58b8c469e442cfcf9f78a5413a95423be8e686764f2fa7bd3f", + "dleq_proof": "52dffbf0095f29b1de24c9d68d549d1c54bce6900454fe37fd10f5c903dfee85f7bad8a72ada8e52cd9092f51d9e90b5a7e4f36ca04df98e77fbad1347305bc7", + "input_index": 2 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "03df706ff26d672fe50c6f56a2ca1e7174e75055c920bbd337e1a7d6bf2826f6dc", + "dleq_proof": "fd2e8c22b9b672d3bb9e26d4b6da2dc3f125455a969fd8e2b2c1ba791076ad71b38c01f9218c3d6776fd895d2f3067484996d85e268390c2b433e6c4b21891f1", + "input_index": 2 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "038604aa8cff309dddf49b1f366f89c269616587fc41db608540f9431536f6646f", + "dleq_proof": "93e1e5d4888bc1cfe02eecfa9dea144ead84f23a10fc1a301103c1e56393527b63417c6c4a5af45359ff8b6e179af73978e4d62e862eedd2a286f5278e4cb340", + "input_index": 2 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "02980bac7264e14bf820cc435b69a78df726c177eb026f1b81b52de811272f58eb", + "dleq_proof": "38e03463b6450dbd051065a812a6f2631087f7951e46ac84e66da811755717c093180e4d49827b8c097ca7093fe92d393f95a5d7ad849a41802dc4e4558abde9", + "input_index": 3 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "038029998c73c2b139db75557ec3011ec65ecd212447963006571852a3e92289cc", + "dleq_proof": "381ce3c1ac9fc7f6a51536456252984768e8fbd014070239f4cb13b16793c74cc5ed677c8a7eacc173e988d353a3f289c7e2141db7a4b4145c5499ec837f36ae", + "input_index": 3 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "03a27d9f1fa53e43f70835a6990954b78a35f747066f7b82b4a9ea82e8bb5e06a6", + "dleq_proof": "732aa0957a9db2c759e39aca5d0167ef13d2a34a43c812e9cc9d712d458f7b2285da7511eb16f61f9600d20be4e94dfdd2bc3ae37fd943fc34947dfc3fdc6797", + "input_index": 3 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "027eb7c1c4afcd7e62ab6ad3d2ba2733a3745220641f91903ebfe7bcc5dbdc6b5d", + "dleq_proof": "77b1572336d9bd4e2f73411eb8f6a96235a130f444cbeb07a489f447921e37489d71fd3db28a0bbaa35b88c37b7579ecdbf6ce1638700db81dfc86bf79cd1be7", + "input_index": 4 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "02bef4738c6f327f1724b1407988959cdf2bd19210894bfd043535b61138270818", + "dleq_proof": "0a584927102820b7b6cad6bcfb031a23a37312ad87d23f1cb5a8359be2d5ff39b6a2a2fe37cd0d53ee347f13e6b3525769b2e789f0699cb985588eac2daf10dd", + "input_index": 4 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "03ca88bcdb6d1f5291e90c75d3da601a8bd29991eabbf7f94b2f39483e3858c9a1", + "dleq_proof": "5d0de8a27f0d528802cf3354a1af507bcf0fed419da641c9c0807cd754e07af8790b511c705d5fbf63305ab22afe0497facf070b5afeafcee3a6900547767756", + "input_index": 4 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "03802b69b360da4585512b1cf967b184010226d75ce5b762e77a0128bcb068ccbb", + "dleq_proof": "103ae05c10e43298376c3fee1dc5f0d04389f4f20af49346d48ead324d615d6f28fca0a318445130ea56a862019b2de0c2637db3c418c0b074b58548ae8b20c9", + "input_index": 5 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "024f2a15a4b15a6b78f0a632fc7fdf112929f64bd4dd14f67a63cf4fb8f325a204", + "dleq_proof": "8f223de27986dd8792a03d9d5b662323e8697830ebabb67363881fe72c86482ae61a09268d083b8354cfc2bad35c148eeb291af622dd460634606332af1b8297", + "input_index": 5 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "02407615505ecbdea881312504eef6f4681a26316ef73d33537a7fd793f4658ea9", + "dleq_proof": "f055990738544d099b159290e7f5442cb91226260174aa9d8c1c8310b7cb01ce2b94cc5d2b8fdf81667e02fb796db0e5c27877de0d41a2a0e709b01445baf296", + "input_index": 5 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "020faa3a0c31ae7695e769546e30aa782abd31fb681b970a13aa35cc21dfd13800", + "dleq_proof": "e4074a6ec893eab806d5df9a74ed257e785ff378643dbaa7933c7813a7b5f6c90ca82b62539f39b38c2c72fe5cf81c22a5e641f673644f6c486b3db577464463", + "input_index": 6 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "02153fcdfe96ae70363fd1542646d309c2132b8325540e3dd7f88e37f9796079a6", + "dleq_proof": "4320e9f60fd3c2a6f737aace546c23b0c7d76e84159c0ad289c1df4c0f8a500e51209f296f9d7d223a1d292bd41d266faacb1be843becf4b706eb15dba702806", + "input_index": 6 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "03f313f8f475eb01e3cecbc0eaac2f726d29032c6ff7c3ad6cc3bf8bb10186527c", + "dleq_proof": "3b29e12cf693f272139f74452f2bccc2bcd07432dce8fb107354c1458a12a47c8544d2d6ca1e33bce0578b6508cf8a5934673f9fc435a5f84a22a54b5adcbcfc", + "input_index": 6 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "026f027b235a7593ca8943c6d7b7e37d92eab339617f7f0c344106c58dd0cbc6aa", + "dleq_proof": "16b58148f7baff783e4310f185503fe92417dba43b966c83a2184322cb1f20424cb9d040ae6a1aea4e7444bdc786258a975644ce1a0195564c2b9fc3c606a292", + "input_index": 7 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "02fe7841cddfb46fdb2ca9bde56c9558c2d1b36121665659080224e24031438408", + "dleq_proof": "88072551662c3eebee545f188b8dafc682973a2f218e7ca07fc8e42ff57a1134630449fd703177d7a12d0c9ced024844dc1b9e0ecad67587dd5c479431b36a1a", + "input_index": 7 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "020611450c6c1944ecdecb78f61816c767fba4368f21f7e9b169779c76f23cf4cc", + "dleq_proof": "4ab36bdd69eb1cd0d4b0aeae403dce4db5228c5ba3a291dc725de23382d1736d265d7bc33f83690a5ca5609719f19e81952d4c8d5e288fd0cb8c6411104b80a3", + "input_index": 7 + }, + { + "scan_key": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28", + "ecdh_share": "02fa1ced932598a27fa28410b955ea67479b5a4033b69f2c99e4e26767bba0a556", + "dleq_proof": "a0948c517d70976c0e39ca67abf19e7217040dbe172bb96534133e6ffa3941c1659416f540662b7561efb70064b4d71a9b17b63bc563f630db4422f273e0df12", + "input_index": 8 + }, + { + "scan_key": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510", + "ecdh_share": "02f3c175b08b207637616aa74e67baac1a1807c94bce70800f76f5b17b52c0082d", + "dleq_proof": "99225658fede7a8c2437eb3821bdc9d3700abc896e8b6dcef84e29347b78bc2b03e6da33836e4f40767390c36644ce016b36b1d546963540fa51d0b0407db6d7", + "input_index": 8 + }, + { + "scan_key": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c", + "ecdh_share": "0300e0fd1710dc56ad7c173aa9141d731577009bc3dffcba8576a48e77ab34d970", + "dleq_proof": "ee0aaa65ca73ccc547b31c2b7ed2ae31a4f4ccdaea73f2add24630d608ede9aea5d808688186ebca42b877efab13f35e60f210d4f620602f850fc0731c201e91", + "input_index": 8 + } + ], + "outputs": [ + { + "output_index": 0, + "amount": 30000, + "sp_v0_info": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28031266581e3e841cbb94de62ea735583a232c4c888fa6edef2ec2f870789fc1dd4", + "script": "51201afa5d88188cef9b91b3f1002b11e0f9e94ad3981978c4d12a0605564c081233" + }, + { + "output_index": 1, + "amount": 30000, + "sp_v0_info": "033361d3ff72f34e6b4e7a7bef96e5b8151e66a979adce753c39c583cbe5766d28031266581e3e841cbb94de62ea735583a232c4c888fa6edef2ec2f870789fc1dd4", + "script": "5120cabf7e4a0de424ec9bbadd7f7242cce7673e1701f49d6a19a97055da332b1324" + }, + { + "output_index": 2, + "amount": 20000, + "sp_v0_info": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510024a48d76e20d40bcf5f2e1a5c3ffeb3dc2a8b3b88d79a486d54c9d9eaa8fa188e", + "script": "51204aadc9e0a590d21e0c19cb2024f27140f9b19c2a1282d50ea7c5f45eb03fd4a2" + }, + { + "output_index": 3, + "amount": 20000, + "sp_v0_info": "02a9f1fac2cc457684f33de90073fe20d22c947c92163b862b14d0aba1df471510024a48d76e20d40bcf5f2e1a5c3ffeb3dc2a8b3b88d79a486d54c9d9eaa8fa188e", + "script": "51202fa43700ea1d28fd7ad4dcb332b3e5e5a0d8cb54258f6661c4de0377d72804e5" + }, + { + "output_index": 4, + "amount": 10000, + "sp_v0_info": "038ed3220e73344f91e2d33391e3e1691db39daf06e4237e197ed0d007bf27286c03f81473844c9d74c921ff17a5af410b6337348d7afbff73891bc5cb0cb24cb0c0", + "script": "512051986faee319436b51b38409c1b300f4396e2b32e3cc9a12ae2a4be135da6f6e" + }, + { + "output_index": 5, + "amount": 50000, + "script": "51206fc506f8052a9f5a8aa9b990d5e994f495dded6d47bac44caacdadd8d8991cd0" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/tests/test_psbt_signer_sp.py b/tests/tests/test_psbt_signer_sp.py new file mode 100644 index 00000000..89346184 --- /dev/null +++ b/tests/tests/test_psbt_signer_sp.py @@ -0,0 +1,342 @@ +""" +End-to-end Silent Payment signing tests via SilentPaymentsPSBT.sign_with(). + +This is the hardware-signer flow: the signer holds the key, sign_with() +populates per-input ECDH shares + DLEQ proofs for SP outputs and signs. + +Covers: + - P2WPKH single-sig signing verified against BIP-375 test vectors. + - P2TR input + SP output is rejected (BIP-375 prohibits Segwit v>1). + - Ineligible (bare-multisig P2SH) input produces no SP fields. + - Explicit aux_rand threads through to DLEQ proof generation. +""" + +import json +import unittest +from pathlib import Path + +from embit import bip32, ec +from embit.silent_payments import dleq +from embit.psbt import DerivationPath +from embit.silent_payments import SilentPaymentsPSBT as PSBT +from embit.silent_payments.psbt import ( + SPInputScope as InputScope, + SPOutputScope as OutputScope, +) +from embit.silent_payments import ( + SPValidationError, + SilentPaymentData, + get_eligible_inputs, +) +from embit.script import Script, p2wpkh, p2tr +from embit.transaction import TransactionOutput +from binascii import unhexlify + +# ── test-vector helpers ──────────────────────────────────────────────────────── + + +def _load_vectors(): + p = Path(__file__).parent / "data" / "bip375_test_vectors.json" + if not p.exists(): + return [] + with open(p) as f: + data = json.load(f) + return data.get("valid", []) + + +def _find_vector(vectors, description_fragment): + return next( + (v for v in vectors if description_fragment in v["description"]), + None, + ) + + +# ── fixtures ─────────────────────────────────────────────────────────────────── + + +def _root(): + return bip32.HDKey.from_seed(bytes(range(32))) + + +def _sp_keys(scan_hex, spend_hex): + scan = ec.PublicKey.parse(unhexlify(scan_hex)) + spend = ec.PublicKey.parse(unhexlify(spend_hex)) + return scan, spend + + +def _make_p2wpkh_psbt(root, scan_pub, spend_pub, value=100_000, txid_byte=0xAA): + """ + Minimal PSBTv2 with one P2WPKH input and one SP output. + + tx_modifiable_flags is set to 0, required by BIP-375 when a + script_pubkey is already present on an SP output. + """ + child = root.derive([0, 0]) + pub = child.get_public_key() + + psbt = PSBT.create_v2() + + inp = InputScope() + inp.txid = bytes([txid_byte] * 32) + inp.vout = 0 + inp.sequence = 0xFFFFFFFE + inp.witness_utxo = TransactionOutput(value=value, script_pubkey=p2wpkh(pub)) + inp.bip32_derivations[pub] = DerivationPath(root.my_fingerprint, [0, 0]) + psbt.add_input(inp) + + out = OutputScope() + out.value = value - 1_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub) + psbt.add_output(out) + + psbt.tx_modifiable_flags = 0 + + return psbt, child + + +# ── end-to-end vector test ───────────────────────────────────────────────────── + + +class TestE2EP2WPKHFromVector(unittest.TestCase): + """ + End-to-end signing test using data from the BIP-375 test vectors. + + We reconstruct input 0 (P2WPKH) of the "two inputs single-signer using + per-input ECDH shares" vector. The expected ECDH share is deterministic + and must match the value in the test vector exactly. + """ + + PRIV_HEX = "7e31eeeb1aa2597b6d63b357541461d75ddae76b7603d24619f5ebed9e88ec31" + PUB_HEX = "02c817bb7521afc35ea96f3bfb270e6eb50ddffa5560627b961fec00f2996508bf" + SCAN_HEX = "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068" + SPEND_HEX = "0361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + EXPECTED_SHARE_HEX = ( + "03eca4ff11b728e2e0f60ce6222943a6ff55b9d95f627bf9a99d084bc872d50a5b" + ) + PREVOUT_TXID = "18a717663b0bab14b12a1a771323ff1e4079dd532e5dd13e28ea1081c700984a" + + @classmethod + def setUpClass(cls): + vectors = _load_vectors() + cls.vector = _find_vector( + vectors, "two inputs single-signer using per-input ECDH shares" + ) + + def _build_psbt(self): + """ + Build a PSBTv2 matching vector input 0: a P2WPKH input controlled by + PRIV_HEX, sending to the SCAN/SPEND SP output. The raw signing key is + loaded directly; sign_with() matches it to the input by script hash. + """ + priv = ec.PrivateKey(unhexlify(self.PRIV_HEX)) + pub = priv.get_public_key() + + scan_pub, spend_pub = _sp_keys(self.SCAN_HEX, self.SPEND_HEX) + + psbt = PSBT.create_v2() + + inp = InputScope() + inp.txid = unhexlify(self.PREVOUT_TXID)[::-1] + inp.vout = 0 + inp.sequence = 0xFFFFFFFE + inp.witness_utxo = TransactionOutput(value=100_000, script_pubkey=p2wpkh(pub)) + inp.bip32_derivations[pub] = DerivationPath(b"\x00\x00\x00\x00", [0, 0]) + psbt.add_input(inp) + + out = OutputScope() + out.value = 95_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub) + psbt.add_output(out) + + psbt.tx_modifiable_flags = 0 + + return psbt, priv + + def test_ecdh_share_matches_vector(self): + """ECDH share populated by sign_with() matches the test vector value.""" + if self.vector is None: + self.skipTest("Test vector not found in bip375_test_vectors.json") + + psbt, priv = self._build_psbt() + psbt.sign_with(priv) + + scan_key_bytes = unhexlify(self.SCAN_HEX) + share = psbt.inputs[0].sp_ecdh_shares.get(scan_key_bytes) + self.assertIsNotNone(share, "ECDH share not populated") + self.assertEqual(share.hex(), self.EXPECTED_SHARE_HEX) + + def test_dleq_proof_valid(self): + """DLEQ proof generated by sign_with() verifies correctly.""" + if self.vector is None: + self.skipTest("Test vector not found in bip375_test_vectors.json") + + psbt, priv = self._build_psbt() + psbt.sign_with(priv) + + scan_key_bytes = unhexlify(self.SCAN_HEX) + share = psbt.inputs[0].sp_ecdh_shares[scan_key_bytes] + proof = psbt.inputs[0].sp_dleq_proofs[scan_key_bytes] + + pub = ec.PublicKey.parse(unhexlify(self.PUB_HEX)) + C = ec.PublicKey.parse(share) + self.assertTrue( + dleq.verify_dleq_proof( + pub.sec(), unhexlify(self.SCAN_HEX), C.sec(), proof + ) + ) + + def test_full_psbt_sign_produces_signature(self): + """sign_with() also places a partial_sig on the input.""" + psbt, priv = self._build_psbt() + count = psbt.sign_with(priv) + self.assertGreater(count, 0) + self.assertGreater(len(psbt.inputs[0].partial_sigs), 0) + + +# ── input eligibility ─────────────────────────────────────────────────────────── + + +class TestInputEligibility(unittest.TestCase): + """BIP-375 input eligibility constraints for SP outputs.""" + + SCAN_HEX = "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068" + SPEND_HEX = "0361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + + def _scan_spend(self): + return _sp_keys(self.SCAN_HEX, self.SPEND_HEX) + + def test_p2tr_input_with_sp_output_raises(self): + """P2TR input with SP output is prohibited by BIP-375 (Segwit v>1).""" + root = _root() + child = root.derive([0, 0]) + pub = child.get_public_key() + scan_pub, spend_pub = self._scan_spend() + + psbt = PSBT.create_v2() + + inp = InputScope() + inp.txid = bytes([0xBB] * 32) + inp.vout = 0 + inp.sequence = 0xFFFFFFFE + inp.witness_utxo = TransactionOutput(value=100_000, script_pubkey=p2tr(pub)) + inp.taproot_internal_key = pub + inp.bip32_derivations[pub] = DerivationPath(root.my_fingerprint, [0, 0]) + psbt.add_input(inp) + + out = OutputScope() + out.value = 99_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub) + psbt.add_output(out) + + psbt.tx_modifiable_flags = 0 + + with self.assertRaises(SPValidationError): + get_eligible_inputs(psbt.inputs, has_sp_outputs=True) + + def test_multisig_p2sh_input_produces_no_sp_fields(self): + """ + A P2SH bare-multisig input is ineligible for SP (not P2WPKH/P2PKH/ + P2SH-P2WPKH). sign_with() completes without adding SP fields for it. + """ + root = _root() + child1 = root.derive([0, 0]) + child2 = root.derive([0, 1]) + pub1 = child1.get_public_key() + pub2 = child2.get_public_key() + scan_pub, spend_pub = self._scan_spend() + + # OP_1 <33-byte-pub1> <33-byte-pub2> OP_2 OP_CHECKMULTISIG + redeem_raw = ( + bytes([0x51]) + + bytes([0x21]) + + pub1.sec() + + bytes([0x21]) + + pub2.sec() + + bytes([0x52, 0xAE]) + ) + from embit.script import p2sh + + redeem = Script(redeem_raw) + p2sh_script = p2sh(redeem) + + psbt = PSBT.create_v2() + + inp = InputScope() + inp.txid = bytes([0xCC] * 32) + inp.vout = 0 + inp.sequence = 0xFFFFFFFE + inp.witness_utxo = TransactionOutput(value=100_000, script_pubkey=p2sh_script) + inp.redeem_script = redeem + inp.bip32_derivations[pub1] = DerivationPath(root.my_fingerprint, [0, 0]) + inp.bip32_derivations[pub2] = DerivationPath(root.my_fingerprint, [0, 1]) + psbt.add_input(inp) + + out = OutputScope() + out.value = 99_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub) + psbt.add_output(out) + + psbt.tx_modifiable_flags = 0 + + psbt.sign_with(root) + self.assertEqual(len(psbt.inputs[0].sp_ecdh_shares), 0) + self.assertEqual(len(psbt.inputs[0].sp_dleq_proofs), 0) + + +# ── aux_rand threading ────────────────────────────────────────────────────────── + + +class TestAuxRand(unittest.TestCase): + + def setUp(self): + self.root = _root() + scan_pub, spend_pub = _sp_keys( + "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068", + "0361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039", + ) + self.psbt, self.child = _make_p2wpkh_psbt(self.root, scan_pub, spend_pub) + self.scan_key_bytes = scan_pub.sec() + + def test_aux_rand_deterministic(self): + """Explicit aux_rand makes the DLEQ proof deterministic across runs.""" + aux = bytes(range(32)) + + psbt_a, _ = _make_p2wpkh_psbt( + self.root, + ec.PublicKey.parse( + unhexlify( + "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068" + ) + ), + ec.PublicKey.parse( + unhexlify( + "0361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + ) + ), + txid_byte=0x01, + ) + psbt_b, _ = _make_p2wpkh_psbt( + self.root, + ec.PublicKey.parse( + unhexlify( + "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068" + ) + ), + ec.PublicKey.parse( + unhexlify( + "0361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + ) + ), + txid_byte=0x01, + ) + + psbt_a._sign_with_sp(self.root, aux_rand=aux) + psbt_b._sign_with_sp(self.root, aux_rand=aux) + + proof_a = psbt_a.inputs[0].sp_dleq_proofs[self.scan_key_bytes] + proof_b = psbt_b.inputs[0].sp_dleq_proofs[self.scan_key_bytes] + self.assertEqual(proof_a, proof_b) From 78cb7a77a372374c47b71afdc25bb37afb1e44a7 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 18:58:53 -0300 Subject: [PATCH 35/61] fix(sp): correct SP input eligibility (taproot, NUMS, segwit v>1) BIP-352 lists P2TR as an eligible input type; the old code rejected it as 'Segwit version > 1' (P2TR is v1) while silently ignoring true v>1 outputs. Now: P2TR is eligible unless it commits to the NUMS internal key, and an input spending a real Segwit v>1 output raises when SP outputs are present. --- src/embit/silent_payments/ecdh.py | 73 ++++++++++++++++++++++-------- tests/tests/test_psbt_signer_sp.py | 30 +++++++++++- 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/src/embit/silent_payments/ecdh.py b/src/embit/silent_payments/ecdh.py index 62814aba..5de2c842 100644 --- a/src/embit/silent_payments/ecdh.py +++ b/src/embit/silent_payments/ecdh.py @@ -152,13 +152,43 @@ def pubkey_hash_from_script(script, redeem_script=None): return None +# BIP-341 nothing-up-my-sleeve (NUMS) internal key. A taproot output that +# commits to H as its internal key is script-path-only and is NOT eligible for +# silent-payment shared-secret derivation (BIP-352). +NUMS_H = bytes.fromhex( + "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" +) + + +def witness_version(script): + """Return the segwit witness version (0-16) of a witness program script, + or None when the script is not a canonical witness program.""" + data = script.data + if len(data) < 4 or len(data) > 42: + return None + op = data[0] + if op == 0x00: + version = 0 + elif 0x51 <= op <= 0x60: # OP_1 .. OP_16 + version = op - 0x50 + else: + return None + # second byte must be a direct push of the remaining (2..40) program bytes + if data[1] != len(data) - 2 or not (2 <= data[1] <= 40): + return None + return version + + def get_eligible_inputs(inputs, has_sp_outputs: bool = False): """ Get list of eligible input indices for SP computation. - An input is eligible if: - 1. Its previous output is P2WPKH, P2PKH, or P2SH-P2WPKH - 2. When SP outputs are present, no Segwit version > 1 inputs are allowed + Per BIP-352 the eligible input types are P2PKH, P2WPKH, P2SH-P2WPKH and + P2TR (taproot, segwit v1). Taproot inputs committing to the NUMS internal + key are excluded (script-path-only, no usable key for ECDH). + + Per BIP-375, when there are SP outputs an input spending a Segwit version + > 1 output is forbidden and the Signer must fail. Args: inputs: List of PSBT input scopes @@ -176,21 +206,28 @@ def get_eligible_inputs(inputs, has_sp_outputs: bool = False): script_type = script.script_type() - # With SP outputs, reject Segwit v>1 - if has_sp_outputs and script_type == "p2tr": - raise SPValidationError( - "Input {} uses Segwit version > 1 (P2TR) with SP outputs".format(i) - ) - - # Eligible: P2PKH, P2WPKH, P2SH-P2WPKH - if script_type in {"p2pkh", "p2wpkh", "p2sh"}: - # For P2SH, check if it wraps P2WPKH - if script_type == "p2sh" and inp.redeem_script: - redeem_type = inp.redeem_script.script_type() - if redeem_type == "p2wpkh": - # For P2SH-wrapped, we need the public key from redeem script - eligible.append(i) - elif script_type != "p2sh": + # BIP-375: refuse to sign when an input spends a Segwit v>1 output. + if has_sp_outputs: + wv = witness_version(script) + if wv is not None and wv > 1: + raise SPValidationError( + "Input {} spends a Segwit version > 1 output with SP " + "outputs".format(i) + ) + + if script_type == "p2tr": + # NUMS internal key -> script-path-only, not eligible. + if ( + inp.taproot_internal_key is not None + and inp.taproot_internal_key.xonly() == NUMS_H + ): + continue + eligible.append(i) + elif script_type in {"p2pkh", "p2wpkh"}: + eligible.append(i) + elif script_type == "p2sh": + # Only P2SH-P2WPKH is eligible. + if inp.redeem_script and inp.redeem_script.script_type() == "p2wpkh": eligible.append(i) return eligible diff --git a/tests/tests/test_psbt_signer_sp.py b/tests/tests/test_psbt_signer_sp.py index 89346184..5de78625 100644 --- a/tests/tests/test_psbt_signer_sp.py +++ b/tests/tests/test_psbt_signer_sp.py @@ -207,8 +207,8 @@ class TestInputEligibility(unittest.TestCase): def _scan_spend(self): return _sp_keys(self.SCAN_HEX, self.SPEND_HEX) - def test_p2tr_input_with_sp_output_raises(self): - """P2TR input with SP output is prohibited by BIP-375 (Segwit v>1).""" + def test_p2tr_input_is_eligible(self): + """P2TR (Segwit v1) is an eligible SP input per BIP-352/BIP-375.""" root = _root() child = root.derive([0, 0]) pub = child.get_public_key() @@ -233,6 +233,32 @@ def test_p2tr_input_with_sp_output_raises(self): psbt.tx_modifiable_flags = 0 + self.assertEqual(get_eligible_inputs(psbt.inputs, has_sp_outputs=True), [0]) + + def test_segwit_v2_input_raises(self): + """An input spending a Segwit v>1 output with SP outputs must fail.""" + root = _root() + scan_pub, spend_pub = self._scan_spend() + + psbt = PSBT.create_v2() + + inp = InputScope() + inp.txid = bytes([0xBD] * 32) + inp.vout = 0 + inp.sequence = 0xFFFFFFFE + # OP_2 <20-byte program> = witness version 2 + v2_script = Script(b"\x52\x14" + bytes(20)) + inp.witness_utxo = TransactionOutput(value=100_000, script_pubkey=v2_script) + psbt.add_input(inp) + + out = OutputScope() + out.value = 99_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub) + psbt.add_output(out) + + psbt.tx_modifiable_flags = 0 + with self.assertRaises(SPValidationError): get_eligible_inputs(psbt.inputs, has_sp_outputs=True) From 101bba4b843e8a4331b7c5f070e1293725adbae7 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:11:45 -0300 Subject: [PATCH 36/61] fix(sp): correct SP output-script derivation and ordering - The spend key in PSBT_OUT_SP_V0_INFO is already the final (label-tweaked) key; drop the incorrect label re-tweak in derive_silent_payment_outputs (the label hash needs the private scan key and can't be recomputed). - Assign the k counter by output-index order within a scan-key group, not by sorting the stored spend keys (labeled outputs sharing a base code are not ordered by their tweaked keys). - Tolerate incomplete PSBTs (no output scripts) instead of failing when a scan key has no ECDH share yet. --- src/embit/silent_payments/bip352.py | 37 ++++++-------- src/embit/silent_payments/validator.py | 67 +++++++++++++++++--------- 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index ba26e391..384ff19a 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -191,13 +191,21 @@ def derive_silent_payment_outputs(ecdh_share, recipients, shared_secret=None): this takes the ECDH shared-secret point directly, as used by the BIP-375 PSBT flow where shares are carried in the PSBT. + The spend key of each recipient is the final per-output spend key as carried + in PSBT_OUT_SP_V0_INFO (already label-tweaked when the address was labeled), + so no label tweak is applied here — the label requires the private scan key + and cannot be recomputed by a validator. ``k`` is the recipient's position + in ``recipients`` (the caller fixes the ordering). + Args: - ecdh_share: The ECDH shared secret point C (33 bytes) - recipients: List of (scan_key, spend_key, label) tuples - shared_secret: Precomputed xonly shared secret (for efficiency) + ecdh_share: ECDH shared-secret point, serialized compressed (33 bytes), + already multiplied by input_hash. + recipients: ordered list of (scan_key, spend_key, label) tuples; label + is informational and unused. + shared_secret: precomputed compressed shared secret (for efficiency). Returns: - Dict mapping recipient index to output pubkey xonly (32 bytes each) + Dict mapping recipient position k to output pubkey x-only (32 bytes). """ if not recipients: return {} @@ -208,29 +216,14 @@ def derive_silent_payment_outputs(ecdh_share, recipients, shared_secret=None): result = {} - for k, (scan_key, spend_key, label) in enumerate(recipients): - # For labeled recipients, tweak the spend key - if label is not None and label != 0: - if isinstance(label, int): - label_bytes = label.to_bytes(4, "big") - else: - label_bytes = label - - tweak = tagged_hash("BIP0352/Label", scan_key.sec() + label_bytes) - spend_internal = bytearray(ec_pubkey_parse(spend_key.sec())) - ec_pubkey_tweak_add(spend_internal, tweak) - tweaked_spend = ec_pubkey_serialize(spend_internal, EC_COMPRESSED) - else: - tweaked_spend = spend_key.sec() - - # Compute t_k + for k, (scan_key, spend_key, _label) in enumerate(recipients): t_k = tagged_hash( "BIP0352/SharedSecret", shared_secret + k.to_bytes(4, "big"), ) - # P_k = B_spend + t_k - p_k_internal = bytearray(ec_pubkey_parse(tweaked_spend)) + # P_k = B_spend + t_k·G + p_k_internal = bytearray(ec_pubkey_parse(spend_key.sec())) ec_pubkey_tweak_add(p_k_internal, t_k) p_k = ec_pubkey_serialize(p_k_internal, EC_COMPRESSED) diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py index 371c2566..5c5737df 100644 --- a/src/embit/silent_payments/validator.py +++ b/src/embit/silent_payments/validator.py @@ -276,21 +276,40 @@ def _verify_input_dleq_proof( ) def _get_input_public_key(self, inp, inp_idx: int): - """Extract the input's signing public key using hash-matching against the UTXO script. - - Falls back to partial_sigs keys when bip32_derivations is absent (e.g. a - trimmed PSBT where the signer stripped BIP-32 metadata to save space). + """Return the input's public key used for SP shared-secret derivation. + + For taproot the key is the (even-Y) output key taken from the + scriptPubKey. For the other eligible types it comes from + PSBT_IN_BIP32_DERIVATION (preferred, matched by hash160 against the + script) or PSBT_IN_PARTIAL_SIG, falling back to the sole candidate key + when the scriptPubKey does not commit to it (e.g. a placeholder UTXO as + used in the BIP-375 test vectors, or a trimmed PSBT). """ - pkh = pubkey_hash_from_script(inp.script_pubkey, inp.redeem_script) - if pkh is None: + script = inp.script_pubkey + if script is None: + return None + + if script.script_type() == "p2tr": + return ec.PublicKey.from_xonly(bytes(script.data[2:34])) + + candidates = list(inp.bip32_derivations) + list(inp.partial_sigs) + if not candidates: return None - for pubkey in inp.bip32_derivations: - if hash160(pubkey.sec()) == pkh: - return pubkey - for pubkey in inp.partial_sigs: - if hash160(pubkey.sec()) == pkh: - return pubkey + pkh = pubkey_hash_from_script(script, inp.redeem_script) + if pkh is not None: + for pubkey in candidates: + if hash160(pubkey.sec()) == pkh: + return pubkey + + # Placeholder / non-committing script: use the sole candidate if + # unambiguous. A wrong key here is caught by DLEQ / output-script checks. + unique = [] + for pubkey in candidates: + if pubkey not in unique: + unique.append(pubkey) + if len(unique) == 1: + return unique[0] return None def _validate_input_eligibility(self): @@ -345,7 +364,10 @@ def _validate_output_scripts(self): A_sum_bytes = _sum_pubkeys(eligible_pubkeys) input_hash = get_input_hash(outpoints, A_sum_bytes) - # Group SP outputs by scan key + # Group SP outputs by scan key, preserving output-index order. The + # derivation counter k is the output's position within its scan-key + # group in this order (BIP-375: outputs are placed in the order that + # determines k; outputs sharing scan+spend are ordered by output index). sp_outputs_by_scan = {} for out_idx, out in enumerate(self.psbt.outputs): if out.sp_data is not None: @@ -375,30 +397,29 @@ def _validate_output_scripts(self): ecdh_share = ec_pubkey_serialize(share_sum, EC_COMPRESSED) if not ecdh_share: - raise SPValidationError("No ECDH share found for scan key in outputs") + # Incomplete PSBT (output scripts not yet set) legitimately lacks + # ECDH shares; only fail if a script is actually present to check. + if any(out.script_pubkey is not None for _, out in outputs_for_scan): + raise SPValidationError( + "No ECDH share found for scan key in outputs" + ) + continue # Apply input_hash: adjusted_share = input_hash · ecdh_share (BIP-352) adjusted_handle = bytearray(ec_pubkey_parse(ecdh_share)) ec_pubkey_tweak_mul(adjusted_handle, input_hash) adjusted_share = ec_pubkey_serialize(adjusted_handle, EC_COMPRESSED) - # Sort outputs for this scan key by spend key (lexicographic). The - # derivation counter k is the position in this sorted order. - sorted_outputs = sorted( - outputs_for_scan, - key=lambda x: (x[1].sp_data.spend_key.sec()), - ) - derived = derive_silent_payment_outputs( adjusted_share, [ (out.sp_data.scan_key, out.sp_data.spend_key, out.sp_label) - for _, out in sorted_outputs + for _, out in outputs_for_scan ], ) # Validate each output's script against the derived key - for pos, (out_idx, out) in enumerate(sorted_outputs): + for pos, (out_idx, out) in enumerate(outputs_for_scan): if out.script_pubkey is None: # Incomplete PSBT continue From 83ed75c7f71ec058bf58c80c8b3732be3da121ff Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:13:02 -0300 Subject: [PATCH 37/61] fix(sp): compute input_hash over all inputs, not just eligible ones BIP-352 input_hash commits to the lexicographically smallest outpoint of the whole transaction; using only eligible-input outpoints produced the wrong shared secret when an ineligible input (e.g. NUMS taproot) held the smallest outpoint. --- src/embit/silent_payments/validator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py index 5c5737df..17177923 100644 --- a/src/embit/silent_payments/validator.py +++ b/src/embit/silent_payments/validator.py @@ -349,9 +349,11 @@ def _validate_output_scripts(self): eligible_inputs = get_eligible_inputs(self.psbt.inputs, has_sp_outputs=True) # Build outpoints and A_sum for input_hash (same for all scan-key groups). + # BIP-352 input_hash commits to the smallest outpoint over ALL transaction + # inputs (not just the eligible ones), while A is the sum of eligible keys. outpoints = [ COutPoint(txid=self.psbt.tx.vin[i].txid, out_idx=self.psbt.tx.vin[i].vout) - for i in eligible_inputs + for i in range(len(self.psbt.inputs)) ] eligible_pubkeys = [ self._get_input_public_key(self.psbt.inputs[i], i) for i in eligible_inputs From 25317d9ab2b76b956b69153cf36bffc7ba403bfa Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:17:01 -0300 Subject: [PATCH 38/61] fix(sp): require PSBT_IN_BIP32_DERIVATION for non-taproot DLEQ inputs BIP-375 requires the BIP-32 derivation alongside a per-input DLEQ proof so verifiers can recover the input key. The old check only fired for incomplete PSBTs; now it fires for every eligible non-taproot input that carries a proof. Taproot inputs are exempt (key comes from the output key in the script). --- src/embit/silent_payments/validator.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py index 17177923..d1d32928 100644 --- a/src/embit/silent_payments/validator.py +++ b/src/embit/silent_payments/validator.py @@ -200,12 +200,16 @@ def _validate_ecdh_coverage(self): if out.script_pubkey: # Can verify only if complete self._verify_input_dleq_proof(inp_idx, scan_key, out_idx) - # Check BIP32 derivation for per-input DLEQ. Only required - # when the output script is absent: trimmed/finalized PSBTs - # legitimately strip BIP32 derivations, and Stage 4 provides - # equivalent verification via the output script in that case. + # BIP-375: a per-input DLEQ proof requires + # PSBT_IN_BIP32_DERIVATION so verifiers can recover the input + # key. Taproot inputs are exempt — their key comes from the + # output key in the scriptPubKey, not a BIP-32 derivation. if scan_key_bytes in inp.sp_dleq_proofs: - if len(inp.bip32_derivations) == 0 and not out.script_pubkey: + is_taproot = ( + inp.script_pubkey is not None + and inp.script_pubkey.script_type() == "p2tr" + ) + if not is_taproot and len(inp.bip32_derivations) == 0: raise SPValidationError( "Output {}: Input {} has DLEQ proof but missing " "PSBT_IN_BIP32_DERIVATION".format(out_idx, inp_idx) From 9eb4211d26999eefa1ac95f82dd3c18cb2ca52d9 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:32:42 -0300 Subject: [PATCH 39/61] test(sp): drive all official BIP-375 vectors through the validator Adds the upstream BIP-375 vector suite (19 valid, 22 invalid) as an actual validation test. The previous SP tests only hand-built one input and never ran the validator against the vectors, hiding the validator defects fixed in this branch. --- tests/tests/test_bip375_vectors.py | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/tests/test_bip375_vectors.py diff --git a/tests/tests/test_bip375_vectors.py b/tests/tests/test_bip375_vectors.py new file mode 100644 index 00000000..f529a64d --- /dev/null +++ b/tests/tests/test_bip375_vectors.py @@ -0,0 +1,51 @@ +""" +Official BIP-375 PSBT test vectors driven through the validator. + +Source: https://github.com/macgyver13/bip375-test-generator/ + +Each valid vector must parse and pass full BIP-375 validation; each invalid +vector must be rejected either at parse time (malformed fields) or by the +validator. The vectors use placeholder UTXO scripts, so the input public key +for SP/DLEQ comes from PSBT_IN_BIP32_DERIVATION (non-taproot) or the output key +in the scriptPubKey (taproot). +""" + +import json +import unittest +from pathlib import Path + +from embit.base import EmbitError +from embit.silent_payments import SilentPaymentsPSBT, validate_bip375_psbt +from embit.silent_payments.fields import SPValidationError + +_VECTORS = Path(__file__).parent / "data" / "bip375_test_vectors.json" + + +def _load(): + with open(_VECTORS) as f: + return json.load(f) + + +class TestBIP375ValidVectors(unittest.TestCase): + def test_valid_vectors(self): + data = _load() + for v in data["valid"]: + with self.subTest(description=v["description"]): + psbt = SilentPaymentsPSBT.from_base64(v["psbt"]) + self.assertTrue(validate_bip375_psbt(psbt)) + + +class TestBIP375InvalidVectors(unittest.TestCase): + def test_invalid_vectors(self): + data = _load() + for v in data["invalid"]: + with self.subTest(description=v["description"]): + # Rejection may occur at parse time (malformed field lengths) or + # during validation; both are acceptable failures. + with self.assertRaises((SPValidationError, EmbitError)): + psbt = SilentPaymentsPSBT.from_base64(v["psbt"]) + validate_bip375_psbt(psbt) + + +if __name__ == "__main__": + unittest.main() From 95a053f5d8bd4b7a0ffdeff7e0e5832d0b5d8286 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:36:53 -0300 Subject: [PATCH 40/61] feat(sp): sign taproot inputs for silent payments (BIP-352 negation) _sign_with_sp now resolves the per-input ECDH scalar for taproot inputs via taproot_tweak, which returns the even-Y output key required by BIP-352, and matches the input through PSBT_IN_TAP_BIP32_DERIVATION or the raw key. The share and DLEQ proof verify against the even-Y output key. --- src/embit/silent_payments/psbt.py | 72 ++++++++++++++++++++++-------- tests/tests/test_psbt_signer_sp.py | 44 ++++++++++++++++++ 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/embit/silent_payments/psbt.py b/src/embit/silent_payments/psbt.py index 6665ab7e..49844621 100644 --- a/src/embit/silent_payments/psbt.py +++ b/src/embit/silent_payments/psbt.py @@ -369,6 +369,59 @@ def _sign_sp_spends(self, root) -> int: return counter + def _resolve_input_privkey(self, inp, root, fingerprint): + """Return the 32-byte private scalar 'a' for an eligible input's ECDH + share, or None if ``root`` does not control the input. + + For taproot inputs this is the (even-Y) output private key per the + BIP-352 negation rule, obtained via taproot_tweak; for the other types + it is the input key matched by derivation or by script hash. + """ + is_taproot = ( + inp.script_pubkey is not None + and inp.script_pubkey.script_type() == "p2tr" + ) + + if is_taproot: + output_xonly = bytes(inp.script_pubkey.data[2:34]) + merkle = inp.taproot_merkle_root or b"" + if fingerprint: + for pub, (_leaves, derivation) in ( + inp.taproot_bip32_derivations.items() + ): + if derivation.fingerprint != fingerprint: + continue + hdkey = self._derive_hdkey(root, derivation) + if hdkey is None or hdkey.xonly() != pub.xonly(): + continue + out_priv = hdkey.key.taproot_tweak(merkle) + if out_priv.xonly() == output_xonly: + return out_priv.secret + if fingerprint is None and hasattr(root, "secret"): + try: + out_priv = root.taproot_tweak(merkle) + except (EmbitError, ValueError): + return None + if out_priv.xonly() == output_xonly: + return out_priv.secret + return None + + if fingerprint: + for pub, derivation in inp.bip32_derivations.items(): + if derivation.fingerprint != fingerprint: + continue + hdkey = self._derive_hdkey(root, derivation) + if hdkey is None or hdkey.xonly() != pub.xonly(): + continue + return hdkey.key.secret + + if fingerprint is None and hasattr(root, "secret"): + pkh = pubkey_hash_from_script(inp.script_pubkey, inp.redeem_script) + if pkh is not None and pkh == hashes.hash160(root.get_public_key().sec()): + return root.secret + + return None + def _sign_with_sp(self, root, aux_rand=None) -> int: """Compute per-input ECDH shares and DLEQ proofs for SP outputs.""" scan_keys = {} @@ -398,24 +451,7 @@ def _sign_with_sp(self, root, aux_rand=None) -> int: for i in eligible: inp = self.inputs[i] - priv_bytes = None - if fingerprint: - for pub, derivation in inp.bip32_derivations.items(): - if derivation.fingerprint != fingerprint: - continue - hdkey = self._derive_hdkey(root, derivation) - if hdkey is None or hdkey.xonly() != pub.xonly(): - continue - priv_bytes = hdkey.key.secret - break - - if priv_bytes is None and fingerprint is None and hasattr(root, "secret"): - pkh = pubkey_hash_from_script(inp.script_pubkey, inp.redeem_script) - if pkh is not None and pkh == hashes.hash160( - root.get_public_key().sec() - ): - priv_bytes = root.secret - + priv_bytes = self._resolve_input_privkey(inp, root, fingerprint) if priv_bytes is None: continue diff --git a/tests/tests/test_psbt_signer_sp.py b/tests/tests/test_psbt_signer_sp.py index 5de78625..fc1c52ce 100644 --- a/tests/tests/test_psbt_signer_sp.py +++ b/tests/tests/test_psbt_signer_sp.py @@ -262,6 +262,50 @@ def test_segwit_v2_input_raises(self): with self.assertRaises(SPValidationError): get_eligible_inputs(psbt.inputs, has_sp_outputs=True) + def test_p2tr_input_produces_valid_sp_share(self): + """A P2TR input we control yields an ECDH share + DLEQ proof that + verify against the even-Y output key (BIP-352 taproot negation).""" + root = _root() + child = root.derive([0, 0]) + internal = child.get_public_key() + out_priv = child.key.taproot_tweak(b"") # even-Y output key + scan_pub, spend_pub = self._scan_spend() + + psbt = PSBT.create_v2() + + inp = InputScope() + inp.txid = bytes([0xAB] * 32) + inp.vout = 0 + inp.sequence = 0xFFFFFFFE + inp.witness_utxo = TransactionOutput( + value=100_000, script_pubkey=p2tr(internal) + ) + inp.taproot_internal_key = internal + inp.taproot_bip32_derivations[internal] = ( + [], + DerivationPath(root.my_fingerprint, [0, 0]), + ) + psbt.add_input(inp) + + out = OutputScope() + out.value = 95_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub) + psbt.add_output(out) + psbt.tx_modifiable_flags = 0 + + self.assertEqual(psbt._sign_with_sp(root), 1) + + sk = scan_pub.sec() + share = psbt.inputs[0].sp_ecdh_shares.get(sk) + proof = psbt.inputs[0].sp_dleq_proofs.get(sk) + self.assertIsNotNone(share) + # A is the even-Y output key taken from the scriptPubKey + output_xonly = inp.script_pubkey.data[2:34] + A = ec.PublicKey.from_xonly(bytes(output_xonly)) + self.assertEqual(A.xonly(), out_priv.xonly()) + self.assertTrue(dleq.verify_dleq_proof(A.sec(), sk, share, proof)) + def test_multisig_p2sh_input_produces_no_sp_fields(self): """ A P2SH bare-multisig input is ineligible for SP (not P2WPKH/P2PKH/ From 01065d816e4c3fac8c380d8491132da94b0ac0b1 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:45:27 -0300 Subject: [PATCH 41/61] feat(sp): enforce SIGHASH_ALL and verify existing ECDH shares when signing - BIP-375: refuse to sign SP outputs with a non-SIGHASH_ALL sighash type. - Before adding our own shares, verify the DLEQ proofs of any ECDH shares already present on eligible inputs, so an invalid share added by another signer aborts signing instead of being silently re-serialized. - Extract the shared input-pubkey resolution into ecdh.input_public_key, used by both the signer and the validator. --- src/embit/silent_payments/ecdh.py | 37 +++++++++++++++++++ src/embit/silent_payments/psbt.py | 50 +++++++++++++++++++++++++- src/embit/silent_payments/validator.py | 39 ++------------------ tests/tests/test_psbt_signer_sp.py | 30 ++++++++++++++++ 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/src/embit/silent_payments/ecdh.py b/src/embit/silent_payments/ecdh.py index 5de2c842..324af0f9 100644 --- a/src/embit/silent_payments/ecdh.py +++ b/src/embit/silent_payments/ecdh.py @@ -5,6 +5,7 @@ import os from .. import ec +from ..hashes import hash160 from . import dleq from ..util.key import SECP256K1_ORDER from ..util.secp256k1 import ( @@ -179,6 +180,42 @@ def witness_version(script): return version +def input_public_key(inp): + """Resolve an input's public key A used for SP shared-secret derivation. + + For taproot the key is the (even-Y) output key from the scriptPubKey. For + the other eligible types it comes from PSBT_IN_BIP32_DERIVATION (preferred, + matched by hash160 against the script) or PSBT_IN_PARTIAL_SIG, falling back + to the sole candidate when the scriptPubKey does not commit to it (e.g. the + placeholder UTXOs in the BIP-375 test vectors). Returns an ``ec.PublicKey`` + or None. + """ + script = inp.script_pubkey + if script is None: + return None + + if script.script_type() == "p2tr": + return ec.PublicKey.from_xonly(bytes(script.data[2:34])) + + candidates = list(inp.bip32_derivations) + list(inp.partial_sigs) + if not candidates: + return None + + pkh = pubkey_hash_from_script(script, inp.redeem_script) + if pkh is not None: + for pubkey in candidates: + if hash160(pubkey.sec()) == pkh: + return pubkey + + unique = [] + for pubkey in candidates: + if pubkey not in unique: + unique.append(pubkey) + if len(unique) == 1: + return unique[0] + return None + + def get_eligible_inputs(inputs, has_sp_outputs: bool = False): """ Get list of eligible input indices for SP computation. diff --git a/src/embit/silent_payments/psbt.py b/src/embit/silent_payments/psbt.py index 49844621..1eac47f9 100644 --- a/src/embit/silent_payments/psbt.py +++ b/src/embit/silent_payments/psbt.py @@ -23,11 +23,13 @@ read_string, ser_string, ) +from . import dleq from .ecdh import ( compute_ecdh_share, compute_dleq_proof, get_eligible_inputs, pubkey_hash_from_script, + input_public_key, ) from .fields import SilentPaymentData, SPFieldError, SPValidationError @@ -261,11 +263,24 @@ def _write_extra_globals(self, stream) -> int: return r def sign_with(self, root, sighash=None, **kwargs): + has_sp = self.version == 2 and any( + out.sp_data is not None for out in self.outputs + ) + # BIP-375: only SIGHASH_ALL may be used when SP outputs are present. + # SIGHASH.DEFAULT (taproot) is functionally SIGHASH_ALL. + if ( + has_sp + and sighash is not None + and sighash not in (SIGHASH.ALL, SIGHASH.DEFAULT) + ): + raise SPValidationError( + "Silent payment signing requires SIGHASH_ALL" + ) if sighash is not None: counter = super().sign_with(root, sighash=sighash, **kwargs) else: counter = super().sign_with(root, **kwargs) - if self.version == 2 and any(out.sp_data is not None for out in self.outputs): + if has_sp: counter += self._sign_with_sp(root) if self.version == 2: counter += self._sign_sp_spends(root) @@ -422,6 +437,34 @@ def _resolve_input_privkey(self, inp, root, fingerprint): return None + def _verify_existing_sp_shares(self, eligible, scan_keys) -> None: + """Verify DLEQ proofs of ECDH shares already present on eligible inputs. + + Raises SPValidationError if a present share lacks a proof or its proof + fails to verify against the input's public key. + """ + for i in eligible: + inp = self.inputs[i] + pubkey = input_public_key(inp) + for sk_bytes in scan_keys: + if sk_bytes not in inp.sp_ecdh_shares: + continue + proof = inp.sp_dleq_proofs.get(sk_bytes) + if proof is None: + raise SPValidationError( + "Input %d has an SP ECDH share without a DLEQ proof" % i + ) + if pubkey is None: + # Cannot resolve the input key to verify; leave the share as + # provided (a malformed share is caught later by validation). + continue + if not dleq.verify_dleq_proof( + pubkey.sec(), sk_bytes, inp.sp_ecdh_shares[sk_bytes], proof + ): + raise SPValidationError( + "Input %d has an invalid SP ECDH share DLEQ proof" % i + ) + def _sign_with_sp(self, root, aux_rand=None) -> int: """Compute per-input ECDH shares and DLEQ proofs for SP outputs.""" scan_keys = {} @@ -442,6 +485,11 @@ def _sign_with_sp(self, root, aux_rand=None) -> int: if not eligible: return 0 + # BIP-375: verify ECDH shares already present (added by other signers) + # using their DLEQ proofs before contributing our own, so we never + # endorse an invalid share by adding to the same PSBT. + self._verify_existing_sp_shares(eligible, scan_keys) + fingerprint, can_sign = self._signing_fingerprint(root) if not can_sign: return 0 diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py index d1d32928..bc474304 100644 --- a/src/embit/silent_payments/validator.py +++ b/src/embit/silent_payments/validator.py @@ -11,7 +11,7 @@ from .. import ec from . import dleq from .fields import SPValidationError -from .ecdh import get_eligible_inputs, pubkey_hash_from_script +from .ecdh import get_eligible_inputs, pubkey_hash_from_script, input_public_key from .bip352 import get_input_hash, derive_silent_payment_outputs from ..transaction import SIGHASH, COutPoint from ..script import Script @@ -280,41 +280,8 @@ def _verify_input_dleq_proof( ) def _get_input_public_key(self, inp, inp_idx: int): - """Return the input's public key used for SP shared-secret derivation. - - For taproot the key is the (even-Y) output key taken from the - scriptPubKey. For the other eligible types it comes from - PSBT_IN_BIP32_DERIVATION (preferred, matched by hash160 against the - script) or PSBT_IN_PARTIAL_SIG, falling back to the sole candidate key - when the scriptPubKey does not commit to it (e.g. a placeholder UTXO as - used in the BIP-375 test vectors, or a trimmed PSBT). - """ - script = inp.script_pubkey - if script is None: - return None - - if script.script_type() == "p2tr": - return ec.PublicKey.from_xonly(bytes(script.data[2:34])) - - candidates = list(inp.bip32_derivations) + list(inp.partial_sigs) - if not candidates: - return None - - pkh = pubkey_hash_from_script(script, inp.redeem_script) - if pkh is not None: - for pubkey in candidates: - if hash160(pubkey.sec()) == pkh: - return pubkey - - # Placeholder / non-committing script: use the sole candidate if - # unambiguous. A wrong key here is caught by DLEQ / output-script checks. - unique = [] - for pubkey in candidates: - if pubkey not in unique: - unique.append(pubkey) - if len(unique) == 1: - return unique[0] - return None + """Return the input's public key used for SP shared-secret derivation.""" + return input_public_key(inp) def _validate_input_eligibility(self): """ diff --git a/tests/tests/test_psbt_signer_sp.py b/tests/tests/test_psbt_signer_sp.py index fc1c52ce..406d730b 100644 --- a/tests/tests/test_psbt_signer_sp.py +++ b/tests/tests/test_psbt_signer_sp.py @@ -360,6 +360,36 @@ def test_multisig_p2sh_input_produces_no_sp_fields(self): # ── aux_rand threading ────────────────────────────────────────────────────────── +class TestSignerBIP375Constraints(unittest.TestCase): + """Signer-side BIP-375 constraints.""" + + SCAN_HEX = "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068" + SPEND_HEX = "0361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + + def _psbt(self): + root = _root() + scan_pub, spend_pub = _sp_keys(self.SCAN_HEX, self.SPEND_HEX) + return _make_p2wpkh_psbt(root, scan_pub, spend_pub) + + def test_non_sighash_all_rejected(self): + """Signing SP outputs with a non-ALL sighash type must fail.""" + from embit.transaction import SIGHASH + + psbt, _ = self._psbt() + with self.assertRaises(SPValidationError): + psbt.sign_with(_root(), sighash=SIGHASH.SINGLE) + + def test_invalid_existing_share_rejected(self): + """An existing ECDH share with a bad DLEQ proof aborts signing.""" + psbt, _ = self._psbt() + sk = unhexlify(self.SCAN_HEX) + # a valid-looking but unrelated share + bogus proof + psbt.inputs[0].sp_ecdh_shares[sk] = unhexlify(self.SPEND_HEX) + psbt.inputs[0].sp_dleq_proofs[sk] = bytes(64) + with self.assertRaises(SPValidationError): + psbt.sign_with(_root()) + + class TestAuxRand(unittest.TestCase): def setUp(self): From 6f1e430f9962bd8327bcb319f155d8426f5b2618 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:48:08 -0300 Subject: [PATCH 42/61] refactor(sp): use embit.misc.urandom instead of os.urandom Routes DLEQ auxiliary randomness through the project's portable RNG helper, which falls back to /dev/urandom where os.urandom is unavailable (MicroPython). --- src/embit/silent_payments/ecdh.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/embit/silent_payments/ecdh.py b/src/embit/silent_payments/ecdh.py index 324af0f9..281ae003 100644 --- a/src/embit/silent_payments/ecdh.py +++ b/src/embit/silent_payments/ecdh.py @@ -2,10 +2,9 @@ BIP-375 ECDH share and DLEQ proof computation, plus input eligibility. """ -import os - from .. import ec from ..hashes import hash160 +from ..misc import urandom from . import dleq from ..util.key import SECP256K1_ORDER from ..util.secp256k1 import ( @@ -84,13 +83,13 @@ def compute_dleq_proof( scan_key: The scan key (B_scan) ecdh_share: The ECDH share (a·B_scan) - used for self-verification aux_rand: 32-byte auxiliary randomness. When None, fresh bytes are - generated via os.urandom; pass explicit bytes for + generated via the platform RNG; pass explicit bytes for deterministic behaviour or hardware wallet use. Returns: 64-byte DLEQ proof """ - r = aux_rand if aux_rand is not None else os.urandom(32) + r = aux_rand if aux_rand is not None else urandom(32) try: return dleq.generate_dleq_proof(private_key, scan_key.sec(), r=r) except dleq.DLEQError as e: @@ -111,7 +110,7 @@ def compute_global_dleq_proof( scan_key: The scan key (B_scan) global_share: The global ECDH share (a_sum·B_scan) aux_rand: 32-byte auxiliary randomness. When None, fresh bytes are - generated via os.urandom. + generated via the platform RNG. Returns: 64-byte DLEQ proof @@ -122,7 +121,7 @@ def compute_global_dleq_proof( raise SPFieldError("Cannot generate proof for zero sum") a_sum_bytes = a_sum.to_bytes(32, "big") - r = aux_rand if aux_rand is not None else os.urandom(32) + r = aux_rand if aux_rand is not None else urandom(32) try: return dleq.generate_dleq_proof(a_sum_bytes, scan_key.sec(), r=r) From 9b2f4da326c0520da15edd19c0ed3242b742136c Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 19:50:17 -0300 Subject: [PATCH 43/61] chore(sp): drop now-unused validator imports _get_input_public_key delegates to ecdh.input_public_key, so hash160 and pubkey_hash_from_script are no longer referenced in validator.py. --- src/embit/silent_payments/validator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py index bc474304..7bcb14ac 100644 --- a/src/embit/silent_payments/validator.py +++ b/src/embit/silent_payments/validator.py @@ -11,11 +11,10 @@ from .. import ec from . import dleq from .fields import SPValidationError -from .ecdh import get_eligible_inputs, pubkey_hash_from_script, input_public_key +from .ecdh import get_eligible_inputs, input_public_key from .bip352 import get_input_hash, derive_silent_payment_outputs from ..transaction import SIGHASH, COutPoint from ..script import Script -from ..hashes import hash160 from ..util.secp256k1 import ( ec_pubkey_combine, ec_pubkey_parse, From 3aa557de9d88895d8bbd884eb31462c7d0aed3dd Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 1 Jun 2026 21:04:54 -0300 Subject: [PATCH 44/61] fix: MicroPython friendly syntax --- src/embit/descriptor/descriptor.py | 6 +++--- src/embit/descriptor/sp.py | 6 ++++-- src/embit/silent_payments/ecdh.py | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/embit/descriptor/descriptor.py b/src/embit/descriptor/descriptor.py index fe8b576e..e0cc00f7 100644 --- a/src/embit/descriptor/descriptor.py +++ b/src/embit/descriptor/descriptor.py @@ -296,7 +296,6 @@ def from_string(cls, desc): @classmethod def read_from(cls, s): # starts with sh(wsh()), sh() or wsh() - start_pos = s.tell() start = s.read(8) sh = False wsh = False @@ -305,8 +304,9 @@ def read_from(cls, s): taproot = False taptree = TapTree() if start.startswith(b"sp("): - # position right after "sp(" (absolute, robust to a short read(8)) - s.seek(start_pos + 3) + # rewind to right after "sp(" with a relative seek (robust to a + # short read(8)). MicroPython's BytesIO has no tell(), so avoid it. + s.seek(3 - len(start), 1) sp_desc = SilentPaymentDescriptor.read_from(s) end = s.read(1) if end != b")": diff --git a/src/embit/descriptor/sp.py b/src/embit/descriptor/sp.py index 3d3963ed..de48369c 100644 --- a/src/embit/descriptor/sp.py +++ b/src/embit/descriptor/sp.py @@ -136,7 +136,6 @@ def _read_sp_key_expression(s): else: s.seek(-1, 1) - pos_before = s.tell() token, char = read_until(s, b",)") if char is not None: s.seek(-1, 1) @@ -150,7 +149,10 @@ def _read_sp_key_expression(s): if lower.startswith(hrp + "1"): return SPSpendKey.decode(token_str, origin), char - s.seek(pos_before) + # Rewind to before the token via a relative seek (no tell(); MicroPython's + # BytesIO lacks it). Only `token` was consumed since; the delimiter, if any, + # was already pushed back above. + s.seek(-len(token), 1) if origin: origin_str = "[%s]" % origin combined = BytesIO(origin_str.encode() + s.read()) diff --git a/src/embit/silent_payments/ecdh.py b/src/embit/silent_payments/ecdh.py index 281ae003..4fd868b8 100644 --- a/src/embit/silent_payments/ecdh.py +++ b/src/embit/silent_payments/ecdh.py @@ -2,6 +2,8 @@ BIP-375 ECDH share and DLEQ proof computation, plus input eligibility. """ +from binascii import unhexlify + from .. import ec from ..hashes import hash160 from ..misc import urandom @@ -155,7 +157,7 @@ def pubkey_hash_from_script(script, redeem_script=None): # BIP-341 nothing-up-my-sleeve (NUMS) internal key. A taproot output that # commits to H as its internal key is script-path-only and is NOT eligible for # silent-payment shared-secret derivation (BIP-352). -NUMS_H = bytes.fromhex( +NUMS_H = unhexlify( "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" ) From 3fbcd45a5ad9c83ece5d7f30b2dfecc9fa0edf8a Mon Sep 17 00:00:00 2001 From: odudex Date: Tue, 2 Jun 2026 14:39:42 -0300 Subject: [PATCH 45/61] fix: MicroPython friendly COutPoint.serialize (no stepped slice) --- src/embit/transaction.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/embit/transaction.py b/src/embit/transaction.py index dac876fa..15ec995d 100644 --- a/src/embit/transaction.py +++ b/src/embit/transaction.py @@ -424,4 +424,5 @@ def __init__(self, txid: bytes, out_idx: int): self.out_idx = out_idx def serialize(self) -> bytes: - return self.txid[::-1] + self.out_idx.to_bytes(4, "little") + # bytes(reversed(...)), not txid[::-1]: MicroPython rejects stepped slices. + return bytes(reversed(self.txid)) + self.out_idx.to_bytes(4, "little") From 36e60e41f7cf7e4ea24efd2e379e337b1f0a1db9 Mon Sep 17 00:00:00 2001 From: odudex Date: Tue, 2 Jun 2026 15:40:12 -0300 Subject: [PATCH 46/61] feat: P2TR input support for silent payments send Resolve taproot input keys (BIP-86 and BIP-376 spend-from) for the BIP-352 shared secret, normalized to even-Y via PrivateKey.even_y(). Accept taproot SIGHASH_DEFAULT as SIGHASH_ALL-equivalent in the BIP-375 validator. Adds even_y and spend-from test coverage. --- src/embit/ec.py | 12 ++ src/embit/silent_payments/psbt.py | 29 ++++ src/embit/silent_payments/validator.py | 7 +- tests/tests/test_ecc.py | 19 +++ tests/tests/test_psbt_signer_sp.py | 184 ++++++++++++++++++++++++- 5 files changed, 248 insertions(+), 3 deletions(-) diff --git a/src/embit/ec.py b/src/embit/ec.py index 23b5f957..490b8282 100644 --- a/src/embit/ec.py +++ b/src/embit/ec.py @@ -192,6 +192,18 @@ def sp_spend_tweak(self, tweak: bytes) -> "PrivateKey": res = secp256k1.ec_privkey_add(self._secret, tweak) return PrivateKey(res) + def even_y(self) -> "PrivateKey": + """Return this key negated if needed so its public key has even Y. + + BIP-352 sums the even-Y output keys of taproot inputs, so a taproot + input's scalar must be normalized before it contributes to the shared + secret. sp_spend_tweak and raw keys may be odd-Y; taproot_tweak already + returns an even-Y key, so calling this on its result is a no-op. + """ + if self.sec()[0] == 0x03: + return PrivateKey(secp256k1.ec_privkey_negate(self._secret)) + return self + @classmethod def from_wif(cls, s): """Import private key from Wallet Import Format string.""" diff --git a/src/embit/silent_payments/psbt.py b/src/embit/silent_payments/psbt.py index 1eac47f9..46acc805 100644 --- a/src/embit/silent_payments/psbt.py +++ b/src/embit/silent_payments/psbt.py @@ -399,6 +399,35 @@ def _resolve_input_privkey(self, inp, root, fingerprint): if is_taproot: output_xonly = bytes(inp.script_pubkey.data[2:34]) + + # BIP-376 spend-from input: the key is the tweaked spend key + # b_spend + t, normalized to even Y so it can be summed into the + # BIP-352 shared secret (Schnorr signing handles its own parity). + # Matched via sp_spend_bip32_derivations rather than the BIP-86 path. + sp_tweak = getattr(inp, "sp_tweak", None) + if sp_tweak is not None: + spend_bases = [] + if fingerprint: + for pub_bytes, derivation in ( + inp.sp_spend_bip32_derivations.items() + ): + if derivation.fingerprint != fingerprint: + continue + hdkey = self._derive_hdkey(root, derivation) + if hdkey is None or hdkey.xonly() != pub_bytes[1:]: + continue + spend_bases.append(hdkey.key) + if fingerprint is None and hasattr(root, "secret"): + spend_bases.append(root) + for base in spend_bases: + try: + out_priv = base.sp_spend_tweak(sp_tweak).even_y() + except (EmbitError, ValueError): + continue + if out_priv.xonly() == output_xonly: + return out_priv.secret + return None + merkle = inp.taproot_merkle_root or b"" if fingerprint: for pub, (_leaves, derivation) in ( diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py index 7bcb14ac..d832402a 100644 --- a/src/embit/silent_payments/validator.py +++ b/src/embit/silent_payments/validator.py @@ -299,8 +299,11 @@ def _validate_input_eligibility(self): # Check sighash types for i, inp in enumerate(self.psbt.inputs): if inp.sighash_type is not None: - # BIP-375: Only SIGHASH_ALL allowed with SP outputs - if inp.sighash_type != SIGHASH.ALL: + # BIP-375 allows only SIGHASH_ALL with SP outputs. For taproot + # inputs SIGHASH_DEFAULT (0x00) is signed with SIGHASH_ALL + # semantics (BIP-341), so accept it as equivalent — matching + # embit's signer and Krux's own sighash check. + if inp.sighash_type not in (SIGHASH.ALL, SIGHASH.DEFAULT): raise SPValidationError( "Input {}: Non-SIGHASH_ALL sighash type with SP outputs".format( i diff --git a/tests/tests/test_ecc.py b/tests/tests/test_ecc.py index bcae9bc4..55e3f2d6 100644 --- a/tests/tests/test_ecc.py +++ b/tests/tests/test_ecc.py @@ -29,6 +29,25 @@ def test_identity(self): g_hex = hexlify(der) self.assertEqual(answer, g_hex) + def test_even_y(self): + """even_y() returns a key whose pubkey has even Y, negating if needed.""" + # Cover both parities: a key already even-Y is returned unchanged + # (same secret), an odd-Y key is negated to the even-Y counterpart. + seen_even = seen_odd = False + for i in range(1, 40): + pk = PrivateKey(bytes([i] * 32)) + ev = pk.even_y() + self.assertEqual(ev.sec()[0], 0x02) # always even after normalization + self.assertEqual(ev.xonly(), pk.xonly()) # x-coordinate unchanged + if pk.sec()[0] == 0x02: + seen_even = True + self.assertEqual(ev.secret, pk.secret) # no-op on even key + else: + seen_odd = True + self.assertNotEqual(ev.secret, pk.secret) # negated + self.assertEqual(ev.even_y().secret, ev.secret) # idempotent + self.assertTrue(seen_even and seen_odd, "did not exercise both parities") + def test_grind(self): pk = PrivateKey(b"1" * 32) msgs = [bytes([i] * 32) for i in range(255)] diff --git a/tests/tests/test_psbt_signer_sp.py b/tests/tests/test_psbt_signer_sp.py index 406d730b..422079de 100644 --- a/tests/tests/test_psbt_signer_sp.py +++ b/tests/tests/test_psbt_signer_sp.py @@ -6,9 +6,11 @@ Covers: - P2WPKH single-sig signing verified against BIP-375 test vectors. - - P2TR input + SP output is rejected (BIP-375 prohibits Segwit v>1). + - P2TR input + SP output is eligible (BIP-352); only Segwit v>1 is rejected. - Ineligible (bare-multisig P2SH) input produces no SP fields. - Explicit aux_rand threads through to DLEQ proof generation. + - BIP-376 spend-from: signing a received SP UTXO and contributing its + even-Y tweaked key to a new SP output (self-transfer). """ import json @@ -22,6 +24,7 @@ from embit.silent_payments.psbt import ( SPInputScope as InputScope, SPOutputScope as OutputScope, + finalize_sp_spends, ) from embit.silent_payments import ( SPValidationError, @@ -440,3 +443,182 @@ def test_aux_rand_deterministic(self): proof_a = psbt_a.inputs[0].sp_dleq_proofs[self.scan_key_bytes] proof_b = psbt_b.inputs[0].sp_dleq_proofs[self.scan_key_bytes] self.assertEqual(proof_a, proof_b) + + +class TestSpendFromSP(unittest.TestCase): + """BIP-376: spending a received Silent Payment UTXO via the sp_tweak field.""" + + SCAN_HEX = "027a487fc19fb769877b8742d6ea18118f3c4e72b1ea8c6de602a7ad4a41dbe068" + SPEND_HEX = "0361e1b1e9de5e42cb2007f7ca54b9e0d57ed13938fad56d3f19e57513a8fce039" + + @staticmethod + def _odd_y_tweak(spend_priv): + """A tweak whose tweaked spend key has odd Y, to exercise the negation.""" + for b in range(1, 256): + t = bytes([b] * 32) + if spend_priv.sp_spend_tweak(t).sec()[0] == 0x03: + return t + raise AssertionError("no odd-Y tweak found") + + def _input(self, root, tweak, output_xonly=None, fingerprint=None, value=100_000): + """An SP-received P2TR input: output key P_k = B_spend + t*G, with the + per-input sp_tweak and sp_spend_bip32_derivations a coordinator records.""" + child = root.derive([0, 0]) + spend_priv = child.key + spend_pub = child.get_public_key() + if output_xonly is None: + output_xonly = spend_priv.sp_spend_tweak(tweak).xonly() + fp = root.my_fingerprint if fingerprint is None else fingerprint + inp = InputScope() + inp.txid = bytes([0xCD] * 32) + inp.vout = 0 + inp.sequence = 0xFFFFFFFE + inp.witness_utxo = TransactionOutput( + value=value, script_pubkey=Script(b"\x51\x20" + output_xonly) + ) + inp.sp_tweak = tweak + inp.sp_spend_bip32_derivations[spend_pub.sec()] = DerivationPath(fp, [0, 0]) + return inp, spend_priv, spend_pub + + def _dest_psbt(self, root, inp): + """PSBT with the SP-spend input and an ordinary P2WPKH destination.""" + psbt = PSBT.create_v2() + psbt.add_input(inp) + out = OutputScope() + out.value = 95_000 + out.script_pubkey = p2wpkh(root.derive([1, 0]).get_public_key()) + psbt.add_output(out) + psbt.tx_modifiable_flags = 0 + return psbt + + def test_resolve_returns_even_y_tweaked_key(self): + """_resolve_input_privkey returns the even-Y normalized b_spend + t.""" + root = _root() + spend_priv = root.derive([0, 0]).key + tweak = self._odd_y_tweak(spend_priv) # force the negation path + inp, _, _ = self._input(root, tweak) + psbt = self._dest_psbt(root, inp) + + secret = psbt._resolve_input_privkey(psbt.inputs[0], root, root.my_fingerprint) + self.assertIsNotNone(secret) + expected = spend_priv.sp_spend_tweak(tweak).even_y() + self.assertEqual(secret, expected.secret) + self.assertEqual(expected.sec()[0], 0x02) # even-Y + self.assertEqual( + expected.xonly(), bytes(inp.witness_utxo.script_pubkey.data[2:34]) + ) + + def test_sign_produces_valid_signature(self): + """_sign_sp_spends signs the input; the sig verifies against P_k.""" + from embit.transaction import SIGHASH + + root = _root() + inp, _, _ = self._input(root, bytes([0x11] * 32)) + psbt = self._dest_psbt(root, inp) + + self.assertEqual(psbt._sign_sp_spends(root), 1) + sig = psbt.inputs[0].taproot_key_sig + self.assertIsNotNone(sig) + self.assertEqual(len(sig), 64) + + output_xonly = bytes(psbt.inputs[0].witness_utxo.script_pubkey.data[2:34]) + msg = psbt.sighash(0, sighash=SIGHASH.DEFAULT) + pub = ec.PublicKey.from_xonly(output_xonly) + self.assertTrue(pub.schnorr_verify(ec.SchnorrSig(sig), msg)) + + def test_foreign_tweak_not_signed(self): + """A tweak that doesn't reproduce P_k yields no signature/key.""" + root = _root() + spend_priv = root.derive([0, 0]).key + onchain = spend_priv.sp_spend_tweak(bytes([0x11] * 32)).xonly() + inp, _, _ = self._input(root, bytes([0x22] * 32), output_xonly=onchain) + psbt = self._dest_psbt(root, inp) + + self.assertEqual(psbt._sign_sp_spends(root), 0) + self.assertIsNone(psbt.inputs[0].taproot_key_sig) + self.assertIsNone( + psbt._resolve_input_privkey(psbt.inputs[0], root, root.my_fingerprint) + ) + + def test_foreign_derivation_not_signed(self): + """A derivation with a non-matching fingerprint is not signed.""" + root = _root() + inp, _, _ = self._input(root, bytes([0x11] * 32), fingerprint=b"\xff\xff\xff\xff") + psbt = self._dest_psbt(root, inp) + + self.assertEqual(psbt._sign_sp_spends(root), 0) + self.assertIsNone(psbt.inputs[0].taproot_key_sig) + + def test_finalize_builds_witness(self): + """finalize_sp_spends turns the signature into a witness, clearing SP fields.""" + root = _root() + inp, _, _ = self._input(root, bytes([0x11] * 32)) + psbt = self._dest_psbt(root, inp) + + psbt._sign_sp_spends(root) + self.assertEqual(finalize_sp_spends(psbt), 1) + self.assertIsNotNone(psbt.inputs[0].final_scriptwitness) + self.assertIsNone(psbt.inputs[0].sp_tweak) + self.assertEqual(len(psbt.inputs[0].sp_spend_bip32_derivations), 0) + + def _combined_psbt(self, root, inp): + """PSBT with the SP-spend input and a (placeholder) SP output.""" + scan_pub, spend_pub_out = _sp_keys(self.SCAN_HEX, self.SPEND_HEX) + psbt = PSBT.create_v2() + psbt.add_input(inp) + out = OutputScope() + out.value = 95_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub_out) + psbt.add_output(out) + psbt.tx_modifiable_flags = 0 + return psbt + + def test_input_sighash_eligibility(self): + """Taproot SIGHASH_DEFAULT (0x00) is accepted as SIGHASH_ALL-equivalent; + SIGHASH_ALL is accepted; other sighash types are rejected (BIP-375).""" + from embit.transaction import SIGHASH + from embit.silent_payments import BIP375Validator + + root = _root() + inp, _, _ = self._input(root, bytes([0x11] * 32)) + psbt = self._combined_psbt(root, inp) + + # Coordinators set 0x00 on taproot inputs; must not be rejected. + psbt.inputs[0].sighash_type = SIGHASH.DEFAULT + BIP375Validator(psbt)._validate_input_eligibility() # no raise + + psbt.inputs[0].sighash_type = SIGHASH.ALL + BIP375Validator(psbt)._validate_input_eligibility() # no raise + + psbt.inputs[0].sighash_type = SIGHASH.NONE + with self.assertRaises(SPValidationError): + BIP375Validator(psbt)._validate_input_eligibility() + + def test_self_transfer_share_verifies(self): + """Combined: an SP-spend input contributes a valid SP share to a new SP + output. Uses an odd-Y tweak so the even-Y negation is exercised, and the + per-input DLEQ proof must verify against the even-Y output key A.""" + root = _root() + spend_priv = root.derive([0, 0]).key + tweak = self._odd_y_tweak(spend_priv) + scan_pub, spend_pub_out = _sp_keys(self.SCAN_HEX, self.SPEND_HEX) + + inp, _, _ = self._input(root, tweak) + psbt = PSBT.create_v2() + psbt.add_input(inp) + out = OutputScope() + out.value = 95_000 + out.script_pubkey = Script(b"\x51\x20" + bytes(32)) + out.sp_data = SilentPaymentData(scan_pub, spend_pub_out) + psbt.add_output(out) + psbt.tx_modifiable_flags = 0 + + self.assertEqual(psbt._sign_with_sp(root), 1) + sk = scan_pub.sec() + share = psbt.inputs[0].sp_ecdh_shares.get(sk) + proof = psbt.inputs[0].sp_dleq_proofs.get(sk) + self.assertIsNotNone(share) + output_xonly = bytes(inp.witness_utxo.script_pubkey.data[2:34]) + A = ec.PublicKey.from_xonly(output_xonly) + self.assertTrue(dleq.verify_dleq_proof(A.sec(), sk, share, proof)) From 72a591b48348b015ae29014a2cab4d3aba27fef1 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Wed, 3 Jun 2026 11:45:18 +0530 Subject: [PATCH 47/61] =?UTF-8?q?refactor(sp):=20audit=20fixes=20=E2=80=94?= =?UTF-8?q?=20version=20guard,=20error=20propagation,=20memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four targeted fixes from the post-implementation code audit: - SPOutputScope.write_to: add `if version == 2` guard so sp_data / sp_label are never written into a PSBTv0 (or version=None) stream. Without it the round-trip raises PSBTError on parse. - _sign_with_sp: remove silent `except SPFieldError: continue`. We only reach share computation when we've already resolved the private key for that input; swallowing the error hides RNG or key-op failures and returns success with an incomplete PSBT. - create_outputs: replace signing_keys list + sum() with a running accumulator, eliminating the O(n) bigint allocation before the sum. - derive_silent_payment_outputs: drop the dead `shared_secret` parameter (no caller ever passed it; body always fell through to `= ecdh_share`). --- src/embit/silent_payments/bip352.py | 26 +++++++++---------------- src/embit/silent_payments/psbt.py | 30 ++++++++++++++--------------- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 384ff19a..55e3bc67 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -124,19 +124,17 @@ def create_outputs(input_privkeys, outpoints, recipients): if not input_privkeys: return {} - signing_keys = [] + a_sum = 0 for sec, is_xonly in input_privkeys: if not ec_seckey_verify(sec): raise ValueError("Invalid private key") - if is_xonly: pub = ec_pubkey_create(sec) ser = ec_pubkey_serialize(pub) if ser[0] == 0x03: sec = ec_privkey_negate(sec) - signing_keys.append(int.from_bytes(sec, "big")) + a_sum = (a_sum + int.from_bytes(sec, "big")) % SECP256K1_ORDER - a_sum = sum(signing_keys) % SECP256K1_ORDER if a_sum == 0: return {} @@ -183,7 +181,7 @@ def create_outputs(input_privkeys, outpoints, recipients): return result -def derive_silent_payment_outputs(ecdh_share, recipients, shared_secret=None): +def derive_silent_payment_outputs(ecdh_share, recipients): """ Derive silent payment outputs for recipients from a precomputed ECDH share. @@ -193,16 +191,14 @@ def derive_silent_payment_outputs(ecdh_share, recipients, shared_secret=None): The spend key of each recipient is the final per-output spend key as carried in PSBT_OUT_SP_V0_INFO (already label-tweaked when the address was labeled), - so no label tweak is applied here — the label requires the private scan key - and cannot be recomputed by a validator. ``k`` is the recipient's position - in ``recipients`` (the caller fixes the ordering). + so no label tweak is applied here. ``k`` is the recipient's position in + ``recipients`` (the caller fixes the ordering). Args: - ecdh_share: ECDH shared-secret point, serialized compressed (33 bytes), - already multiplied by input_hash. + ecdh_share: 33-byte compressed shared-secret point, already multiplied + by input_hash (validator does this before calling). recipients: ordered list of (scan_key, spend_key, label) tuples; label - is informational and unused. - shared_secret: precomputed compressed shared secret (for efficiency). + is informational and unused here. Returns: Dict mapping recipient position k to output pubkey x-only (32 bytes). @@ -210,16 +206,12 @@ def derive_silent_payment_outputs(ecdh_share, recipients, shared_secret=None): if not recipients: return {} - # Use precomputed or default to the full 33-byte compressed point (BIP-352 §1) - if shared_secret is None: - shared_secret = ecdh_share - result = {} for k, (scan_key, spend_key, _label) in enumerate(recipients): t_k = tagged_hash( "BIP0352/SharedSecret", - shared_secret + k.to_bytes(4, "big"), + ecdh_share + k.to_bytes(4, "big"), ) # P_k = B_spend + t_k·G diff --git a/src/embit/silent_payments/psbt.py b/src/embit/silent_payments/psbt.py index 46acc805..5a42af6a 100644 --- a/src/embit/silent_payments/psbt.py +++ b/src/embit/silent_payments/psbt.py @@ -178,12 +178,13 @@ def read_value(self, stream, k, version=None): def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: r = super().write_to(stream, skip_separator=True, version=version, **kwargs) - if self.sp_data is not None: - r += ser_string(stream, b"\x09") - r += ser_string(stream, self.sp_data.serialize()) - if self.sp_label is not None: - r += ser_string(stream, b"\x0a") - r += ser_string(stream, self.sp_label.to_bytes(4, "little")) + if version == 2: + if self.sp_data is not None: + r += ser_string(stream, b"\x09") + r += ser_string(stream, self.sp_data.serialize()) + if self.sp_label is not None: + r += ser_string(stream, b"\x0a") + r += ser_string(stream, self.sp_label.to_bytes(4, "little")) if not skip_separator: r += stream.write(b"\x00") return r @@ -535,16 +536,13 @@ def _sign_with_sp(self, root, aux_rand=None) -> int: for sk_bytes, scan_key in scan_keys.items(): if sk_bytes in inp.sp_ecdh_shares: continue - try: - share = compute_ecdh_share(priv_bytes, scan_key) - proof = compute_dleq_proof( - priv_bytes, scan_key, share, aux_rand=aux_rand - ) - inp.sp_ecdh_shares[sk_bytes] = share - inp.sp_dleq_proofs[sk_bytes] = proof - counter += 1 - except SPFieldError: - continue + share = compute_ecdh_share(priv_bytes, scan_key) + proof = compute_dleq_proof( + priv_bytes, scan_key, share, aux_rand=aux_rand + ) + inp.sp_ecdh_shares[sk_bytes] = share + inp.sp_dleq_proofs[sk_bytes] = proof + counter += 1 return counter From aff9a6704e86089fb7e3c903cefd5d680fb1526e Mon Sep 17 00:00:00 2001 From: notTanveer Date: Wed, 3 Jun 2026 12:19:12 +0530 Subject: [PATCH 48/61] refactor(sp): replace local NUMS_H with ec.NUMS_PUBKEY.xonly() NUMS_PUBKEY is already defined in ec.py. NUMS_H was a redundant copy of the same 32-byte value, with its own unhexlify import. Replace both with a module-level _NUMS_XONLY = ec.NUMS_PUBKEY.xonly() so the canonical definition lives in one place. --- src/embit/silent_payments/ecdh.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/embit/silent_payments/ecdh.py b/src/embit/silent_payments/ecdh.py index 4fd868b8..678e1a80 100644 --- a/src/embit/silent_payments/ecdh.py +++ b/src/embit/silent_payments/ecdh.py @@ -2,8 +2,6 @@ BIP-375 ECDH share and DLEQ proof computation, plus input eligibility. """ -from binascii import unhexlify - from .. import ec from ..hashes import hash160 from ..misc import urandom @@ -154,12 +152,7 @@ def pubkey_hash_from_script(script, redeem_script=None): return None -# BIP-341 nothing-up-my-sleeve (NUMS) internal key. A taproot output that -# commits to H as its internal key is script-path-only and is NOT eligible for -# silent-payment shared-secret derivation (BIP-352). -NUMS_H = unhexlify( - "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" -) +_NUMS_XONLY = ec.NUMS_PUBKEY.xonly() def witness_version(script): @@ -257,7 +250,7 @@ def get_eligible_inputs(inputs, has_sp_outputs: bool = False): # NUMS internal key -> script-path-only, not eligible. if ( inp.taproot_internal_key is not None - and inp.taproot_internal_key.xonly() == NUMS_H + and inp.taproot_internal_key.xonly() == _NUMS_XONLY ): continue eligible.append(i) From 84879bc7cf397cb996e26ed9d4f1c2be6b0f5aee Mon Sep 17 00:00:00 2001 From: odudex Date: Wed, 3 Jun 2026 09:47:42 -0300 Subject: [PATCH 49/61] =?UTF-8?q?test(sp):=20fix=20lint=20nits=20=E2=80=94?= =?UTF-8?q?=20single=20import=20style,=20drop=20unused=20var?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/tests/test_dleq.py | 3 +-- tests/tests/test_psbt_signer_sp.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/tests/test_dleq.py b/tests/tests/test_dleq.py index 9b8c2033..73a3ab2f 100644 --- a/tests/tests/test_dleq.py +++ b/tests/tests/test_dleq.py @@ -4,7 +4,6 @@ from binascii import unhexlify import embit.silent_payments.dleq as dleq -from embit.silent_payments.dleq import DLEQError from embit.util import py_secp256k1 _DATA_DIR = Path(__file__).parent / "data" @@ -40,7 +39,7 @@ def _check_generate_vectors(test): B_sec = _FAKE_INFINITY if B_hex == "INFINITY" else unhexlify(B_hex) if result == "INVALID": - with test.assertRaises(DLEQError): + with test.assertRaises(dleq.DLEQError): dleq.generate_dleq_proof( a_bytes, B_sec, r=r_bytes, m=m, G=G_bytes ) diff --git a/tests/tests/test_psbt_signer_sp.py b/tests/tests/test_psbt_signer_sp.py index 422079de..2f3c8b64 100644 --- a/tests/tests/test_psbt_signer_sp.py +++ b/tests/tests/test_psbt_signer_sp.py @@ -240,7 +240,6 @@ def test_p2tr_input_is_eligible(self): def test_segwit_v2_input_raises(self): """An input spending a Segwit v>1 output with SP outputs must fail.""" - root = _root() scan_pub, spend_pub = self._scan_spend() psbt = PSBT.create_v2() From b7b19418d6a2c857a170f76fc25b1f2eea53f4c7 Mon Sep 17 00:00:00 2001 From: odudex Date: Wed, 3 Jun 2026 09:50:01 -0300 Subject: [PATCH 50/61] fix(bech32): re-enforce 90-char segwit limit in decode() --- src/embit/bech32.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/embit/bech32.py b/src/embit/bech32.py index b6766c52..ba99219d 100644 --- a/src/embit/bech32.py +++ b/src/embit/bech32.py @@ -141,6 +141,9 @@ def decode(hrp, addr): Silent payment (sp/tsp) addresses are not witness programs and must not be decoded here; use bech32_decode + convertbits for those. """ + # BIP-173/BIP-350: segwit addresses are capped at 90 characters. + if len(addr) > 90: + raise Bech32DecodeError("Segwit address too long (max 90 characters)") encoding, hrpgot, data = bech32_decode(addr) if hrpgot != hrp: raise Bech32DecodeError("HRP mismatch: expected {}, got {}".format(hrp, hrpgot)) From 6cddba0f908f13995d6b82dd5336d125b0b45ccb Mon Sep 17 00:00:00 2001 From: odudex Date: Wed, 3 Jun 2026 09:51:52 -0300 Subject: [PATCH 51/61] fix(psbt): reject negative PSBT_OUT_AMOUNT at parse time --- src/embit/psbt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/embit/psbt.py b/src/embit/psbt.py index c60b5d05..fc092b2e 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -706,6 +706,8 @@ def read_value(self, stream, k, version=None): if self.value is not None: raise PSBTError("Duplicated PSBT_OUT_AMOUNT") self.value = _signed_from_bytes(v) + if self.value < 0: + raise PSBTError("PSBT_OUT_AMOUNT must be non-negative") elif k == b"\x04": if version != 2: raise PSBTError("PSBT_OUT_SCRIPT not allowed in PSBTv0") From f77b5909880938e11f5a8097535830071c6addd7 Mon Sep 17 00:00:00 2001 From: odudex Date: Wed, 3 Jun 2026 09:54:34 -0300 Subject: [PATCH 52/61] fix(sp): propagate SPValidationError from _sign_with_sp --- src/embit/silent_payments/psbt.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/embit/silent_payments/psbt.py b/src/embit/silent_payments/psbt.py index 5a42af6a..ca751d74 100644 --- a/src/embit/silent_payments/psbt.py +++ b/src/embit/silent_payments/psbt.py @@ -507,10 +507,7 @@ def _sign_with_sp(self, root, aux_rand=None) -> int: if not scan_keys: return 0 - try: - eligible = get_eligible_inputs(self.inputs, has_sp_outputs=True) - except SPValidationError: - return 0 + eligible = get_eligible_inputs(self.inputs, has_sp_outputs=True) if not eligible: return 0 From b0984fbb2ea4921e8eb3310769b672044933ad6a Mon Sep 17 00:00:00 2001 From: odudex Date: Wed, 3 Jun 2026 09:59:49 -0300 Subject: [PATCH 53/61] fix(sp): case-insensitive HRP detection, drop stale TODO --- src/embit/silent_payments/bip352.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 55e3bc67..55681ce5 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -59,14 +59,16 @@ def generate_silent_payment_address( return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data) -# TODO: use the bech32 decode function once the flexible bech32 PR is in def decode_silent_payment_address(address: str): """ Decode a silent payment address and return the scan and spend public keys. """ - if address.startswith("sp1"): + # Bech32m addresses may be all-uppercase; detect HRP case-insensitively + # (bech32_decode lowercases internally, so hrpgot is always lowercase). + lowered = address.lower() + if lowered.startswith("sp1"): hrp = "sp" - elif address.startswith("tsp1"): + elif lowered.startswith("tsp1"): hrp = "tsp" else: raise ValueError("Invalid silent payment address: unknown HRP") From 114230ee261f232eeb1917a9cc128b51821dea2a Mon Sep 17 00:00:00 2001 From: odudex Date: Wed, 3 Jun 2026 10:01:13 -0300 Subject: [PATCH 54/61] test(sp): assert per-label and narrow exception types for invalid labels --- tests/tests/test_bip352.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py index b4da4091..7933dd8a 100644 --- a/tests/tests/test_bip352.py +++ b/tests/tests/test_bip352.py @@ -143,8 +143,8 @@ def test_generate_labeled_silent_payment_address(self): ) assert sp_address == address - with pytest.raises(Exception): - for label in INVALID_LABEL_TEST_VECTORS: + for label in INVALID_LABEL_TEST_VECTORS: + with pytest.raises((TypeError, ValueError)): bip352.generate_silent_payment_address( scan_priv_key, spend_priv_key.get_public_key(), label ) From bb540d7c03eb4d508d4651d66af83290ad5cd1f2 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Fri, 5 Jun 2026 22:23:54 +0530 Subject: [PATCH 55/61] fix(sp): reject global DLEQ proof without its ECDH share - _validate_ecdh_coverage now raises SPValidationError when a scan key carries a PSBT_GLOBAL_SP_DLEQ proof but no matching PSBT_GLOBAL_SP_ECDH_SHARE. Previously this hit an unguarded dict index in _verify_global_dleq_proof and raised a bare KeyError, breaking the documented "raises SPValidationError" contract. Add a regression test derived from the official global-share vectors. - minor fixes on code readability Signed-off-by: notTanveer --- .gitignore | 2 +- src/embit/silent_payments/bip352.py | 4 ++-- src/embit/silent_payments/validator.py | 16 +++++++++------- tests/tests/test_bip375_vectors.py | 23 +++++++++++++++++++++++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 2a23cb29..d021eecb 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,4 @@ src/embit/util/prebuilt/*.dylib src/embit/util/prebuilt/*.dll .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 55681ce5..3a6a687a 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -163,14 +163,14 @@ def create_outputs(input_privkeys, outpoints, recipients): # secp256k1 backend, and works with the ctypes one too) ecdh_point = bytearray(ec_pubkey_parse(B_scan.sec())) ec_pubkey_tweak_mul(ecdh_point, scalar_bytes) - xonly_shared_secret = ec_pubkey_serialize(ecdh_point) + shared_secret = ec_pubkey_serialize(ecdh_point) k = 0 for B_spend, addr, count in B_spend_list: for _ in range(count): t_k = tagged_hash( "BIP0352/SharedSecret", - xonly_shared_secret + k.to_bytes(4, "big"), + shared_secret + k.to_bytes(4, "big"), ) P_k = bytearray(ec_pubkey_parse(B_spend.sec())) diff --git a/src/embit/silent_payments/validator.py b/src/embit/silent_payments/validator.py index d832402a..94b1eab7 100644 --- a/src/embit/silent_payments/validator.py +++ b/src/embit/silent_payments/validator.py @@ -166,6 +166,12 @@ def _validate_ecdh_coverage(self): "PSBT_GLOBAL_SP_DLEQ".format(out_idx) ) + if has_global_proof and not has_global_share: + raise SPValidationError( + "Output {}: PSBT_GLOBAL_SP_DLEQ present without " + "PSBT_GLOBAL_SP_ECDH_SHARE".format(out_idx) + ) + if has_global_proof: # Verify global DLEQ proof if not out.script_pubkey: @@ -225,7 +231,7 @@ def _verify_global_dleq_proof( eligible_pubkeys = [] for inp_idx in eligible_inputs: inp = self.psbt.inputs[inp_idx] - pubkey = self._get_input_public_key(inp, inp_idx) + pubkey = input_public_key(inp) if pubkey: eligible_pubkeys.append(pubkey) @@ -257,7 +263,7 @@ def _verify_input_dleq_proof( scan_key_bytes = scan_key.sec() # Get input's public key - pubkey = self._get_input_public_key(inp, inp_idx) + pubkey = input_public_key(inp) if not pubkey: # Can't verify without public key return @@ -278,10 +284,6 @@ def _verify_input_dleq_proof( "for input {}".format(out_idx, inp_idx) ) - def _get_input_public_key(self, inp, inp_idx: int): - """Return the input's public key used for SP shared-secret derivation.""" - return input_public_key(inp) - def _validate_input_eligibility(self): """ Stage 3: Input Eligibility validation. @@ -329,7 +331,7 @@ def _validate_output_scripts(self): for i in range(len(self.psbt.inputs)) ] eligible_pubkeys = [ - self._get_input_public_key(self.psbt.inputs[i], i) for i in eligible_inputs + input_public_key(self.psbt.inputs[i]) for i in eligible_inputs ] eligible_pubkeys = [pk for pk in eligible_pubkeys if pk is not None] if not eligible_pubkeys: diff --git a/tests/tests/test_bip375_vectors.py b/tests/tests/test_bip375_vectors.py index f529a64d..e7b09774 100644 --- a/tests/tests/test_bip375_vectors.py +++ b/tests/tests/test_bip375_vectors.py @@ -47,5 +47,28 @@ def test_invalid_vectors(self): validate_bip375_psbt(psbt) +class TestBIP375GlobalProofWithoutShare(unittest.TestCase): + """A global DLEQ proof present without its matching ECDH share must fail + cleanly with SPValidationError, not raise an unguarded KeyError.""" + + def test_proof_without_share_raises_validation_error(self): + data = _load() + for v in data["valid"]: + psbt = SilentPaymentsPSBT.from_base64(v["psbt"]) + shared = set(psbt.sp_ecdh_shares) & set(psbt.sp_dleq_proofs) + if not shared: + continue + # Drop the global ECDH share but keep the global DLEQ proof. + scan_key = next(iter(shared)) + del psbt.sp_ecdh_shares[scan_key] + with self.assertRaisesRegex( + SPValidationError, + "PSBT_GLOBAL_SP_DLEQ present without PSBT_GLOBAL_SP_ECDH_SHARE", + ): + validate_bip375_psbt(psbt) + return + self.skipTest("no vector exercises the global ECDH share path") + + if __name__ == "__main__": unittest.main() From d432a7149ff4ec753d2919409a92c4392775d6c6 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 8 Jun 2026 15:33:22 -0300 Subject: [PATCH 56/61] fix(sp): raise clear errors for empty outpoints and empty key expression get_input_hash() now rejects an empty outpoints list with a ValueError instead of crashing with IndexError. _read_sp_key_expression() rejects an empty key expression with DescriptorError instead of an opaque WIF checksum ValueError, and test_empty_sp asserts DescriptorError. --- src/embit/descriptor/sp.py | 3 +++ src/embit/silent_payments/bip352.py | 2 ++ tests/tests/test_sp_descriptor.py | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/embit/descriptor/sp.py b/src/embit/descriptor/sp.py index de48369c..b4d0960d 100644 --- a/src/embit/descriptor/sp.py +++ b/src/embit/descriptor/sp.py @@ -141,6 +141,9 @@ def _read_sp_key_expression(s): s.seek(-1, 1) token_str = token.decode() + if not token_str: + raise DescriptorError("Empty key expression in sp()") + lower = token_str.lower() for hrp in SPSCAN_HRPS: if lower.startswith(hrp + "1"): diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 3a6a687a..8b0d375f 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -106,6 +106,8 @@ def decode_silent_payment_address(address: str): def get_input_hash(outpoints, sum_pubkey_bytes: bytes) -> bytes: + if not outpoints: + raise ValueError("get_input_hash requires at least one outpoint") lowest_outpoint = sorted(outpoints, key=lambda o: o.serialize())[0] preimage = lowest_outpoint.serialize() + sum_pubkey_bytes return tagged_hash("BIP0352/Inputs", preimage) diff --git a/tests/tests/test_sp_descriptor.py b/tests/tests/test_sp_descriptor.py index 39a9c5f1..004e7ef7 100644 --- a/tests/tests/test_sp_descriptor.py +++ b/tests/tests/test_sp_descriptor.py @@ -233,7 +233,9 @@ def _spend_pub(self): return ec.PrivateKey(bytes([0x02] * 32)).get_public_key() def test_empty_sp(self): - self.assertRaises(Exception, SilentPaymentDescriptor.from_string, "sp()") + self.assertRaises( + DescriptorError, SilentPaymentDescriptor.from_string, "sp()" + ) def test_bare_xpub_single_arg(self): """Single-arg sp() with a plain xpub (not spscan/spspend) is rejected.""" From f6c7de75f5f1405be0de37398dacc7e47ee47463 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Fri, 12 Jun 2026 22:19:05 +0530 Subject: [PATCH 57/61] fix(sp): correct k assignment for interleaved recipients, add with_sp_shares flag - create_outputs: iterate recipient occurrences in list order instead of collapsing to counts, so k is assigned correctly when addresses sharing a scan key are interleaved (matches BIP-375 validator's derivation order) - sign_with: add with_sp_shares=True param to allow callers to skip SP ECDH share generation independently of SP spend signing --- src/embit/silent_payments/bip352.py | 40 ++++++++++++++--------------- src/embit/silent_payments/psbt.py | 4 +-- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 8b0d375f..22166fe1 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -63,8 +63,6 @@ def decode_silent_payment_address(address: str): """ Decode a silent payment address and return the scan and spend public keys. """ - # Bech32m addresses may be all-uppercase; detect HRP case-insensitively - # (bech32_decode lowercases internally, so hrpgot is always lowercase). lowered = address.lower() if lowered.startswith("sp1"): hrp = "sp" @@ -120,7 +118,8 @@ def create_outputs(input_privkeys, outpoints, recipients): Args: input_privkeys: List of (private_key_bytes, is_xonly) tuples outpoints: List of transaction outpoints - recipients: List of silent payment addresses (strings) - duplicates are allowed + recipients: List of silent payment addresses (strings) - duplicates + allowed; pass in vout order so k matches the BIP-375 validator. Returns: Dictionary mapping each unique recipient address to list of output hex strings @@ -147,16 +146,18 @@ def create_outputs(input_privkeys, outpoints, recipients): input_hash = get_input_hash(outpoints, ec_pubkey_serialize(A)) - recipient_counts = {} + # Decode each unique address once; iterate occurrences to assign k in list order. + decoded = {} for addr in recipients: - recipient_counts[addr] = recipient_counts.get(addr, 0) + 1 + if addr not in decoded: + decoded[addr] = decode_silent_payment_address(addr) groups = {} - for addr, count in recipient_counts.items(): - B_scan, B_spend = decode_silent_payment_address(addr) - groups.setdefault(B_scan, []).append((B_spend, addr, count)) + for addr in recipients: + B_scan, B_spend = decoded[addr] + groups.setdefault(B_scan, []).append((B_spend, addr)) - result = {addr: [] for addr in recipient_counts.keys()} + result = {addr: [] for addr in decoded} scalar = (int.from_bytes(input_hash, "big") * a_sum) % SECP256K1_ORDER scalar_bytes = scalar.to_bytes(32, "big") @@ -167,20 +168,17 @@ def create_outputs(input_privkeys, outpoints, recipients): ec_pubkey_tweak_mul(ecdh_point, scalar_bytes) shared_secret = ec_pubkey_serialize(ecdh_point) - k = 0 - for B_spend, addr, count in B_spend_list: - for _ in range(count): - t_k = tagged_hash( - "BIP0352/SharedSecret", - shared_secret + k.to_bytes(4, "big"), - ) + for k, (B_spend, addr) in enumerate(B_spend_list): + t_k = tagged_hash( + "BIP0352/SharedSecret", + shared_secret + k.to_bytes(4, "big"), + ) - P_k = bytearray(ec_pubkey_parse(B_spend.sec())) - ec_pubkey_tweak_add(P_k, t_k) + P_k = bytearray(ec_pubkey_parse(B_spend.sec())) + ec_pubkey_tweak_add(P_k, t_k) - xonly = ec_pubkey_serialize(P_k)[1:33] - result[addr].append(hexlify(xonly).decode()) - k += 1 + xonly = ec_pubkey_serialize(P_k)[1:33] + result[addr].append(hexlify(xonly).decode()) return result diff --git a/src/embit/silent_payments/psbt.py b/src/embit/silent_payments/psbt.py index ca751d74..34faf726 100644 --- a/src/embit/silent_payments/psbt.py +++ b/src/embit/silent_payments/psbt.py @@ -263,7 +263,7 @@ def _write_extra_globals(self, stream) -> int: r += ser_string(stream, self.sp_dleq_proofs[scan_key]) return r - def sign_with(self, root, sighash=None, **kwargs): + def sign_with(self, root, sighash=None, with_sp_shares=True, **kwargs): has_sp = self.version == 2 and any( out.sp_data is not None for out in self.outputs ) @@ -281,7 +281,7 @@ def sign_with(self, root, sighash=None, **kwargs): counter = super().sign_with(root, sighash=sighash, **kwargs) else: counter = super().sign_with(root, **kwargs) - if has_sp: + if has_sp and with_sp_shares: counter += self._sign_with_sp(root) if self.version == 2: counter += self._sign_sp_spends(root) From 7866ba97212ce01fde20e83e7a4564ca54d1888b Mon Sep 17 00:00:00 2001 From: notTanveer Date: Sat, 13 Jun 2026 02:15:31 +0530 Subject: [PATCH 58/61] fix(sp): add kmax limit --- src/embit/silent_payments/bip352.py | 17 +++++++++++++++++ tests/tests/test_bip352.py | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 22166fe1..67a16266 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -19,6 +19,8 @@ ) from binascii import hexlify +K_MAX = 2323 + def generate_silent_payment_address( scan_privkey: ec.PrivateKey, @@ -157,6 +159,14 @@ def create_outputs(input_privkeys, outpoints, recipients): B_scan, B_spend = decoded[addr] groups.setdefault(B_scan, []).append((B_spend, addr)) + for group in groups.values(): + if len(group) > K_MAX: + raise ValueError( + "Too many outputs for one scan key: {} > {}".format( + len(group), K_MAX + ) + ) + result = {addr: [] for addr in decoded} scalar = (int.from_bytes(input_hash, "big") * a_sum) % SECP256K1_ORDER scalar_bytes = scalar.to_bytes(32, "big") @@ -207,6 +217,13 @@ def derive_silent_payment_outputs(ecdh_share, recipients): """ if not recipients: return {} + + if len(recipients) > K_MAX: + raise ValueError( + "Too many outputs for one scan key: {} > {}".format( + len(recipients), K_MAX + ) + ) result = {} diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py index 7933dd8a..7702038b 100644 --- a/tests/tests/test_bip352.py +++ b/tests/tests/test_bip352.py @@ -235,3 +235,22 @@ def test_create_silent_payments_outputs(self): ), f"Actual outputs {set(actual_outputs)} did not match any expected set {expected_outputs}", ) + + def test_create_outputs_rejects_too_many_outputs_per_scan_key(self): + """Should fail when one scan-key group exceeds K_MAX recipients""" + vector = BASIC_TEST_VECTORS[0] + input_privkeys = [(unhexlify(vector["spend_priv_key"]), False)] + outpoints = [COutPoint(txid=bytes(32), out_idx=0)] + recipients = [vector["sp_address"]] * (bip352.K_MAX + 1) + + with pytest.raises(ValueError, match="Too many outputs"): + bip352.create_outputs(input_privkeys, outpoints, recipients) + + def test_derive_outputs_rejects_too_many_outputs_per_scan_key(self): + """Should fail when the per-scan-key recipient list exceeds K_MAX""" + vector = BASIC_TEST_VECTORS[0] + B_scan, B_spend = bip352.decode_silent_payment_address(vector["sp_address"]) + recipients = [(B_scan, B_spend, None)] * (bip352.K_MAX + 1) + + with pytest.raises(ValueError, match="Too many outputs"): + bip352.derive_silent_payment_outputs(b"\x02" + bytes(32), recipients) From 6ad890b7d40ac6114d456cbf83e6b0afeb98a430 Mon Sep 17 00:00:00 2001 From: odudex Date: Sat, 13 Jun 2026 09:35:56 -0300 Subject: [PATCH 59/61] test(sp): cover interleaved k assignment; document create_outputs vout ordering - create_outputs: add regression test asserting k follows recipients (vout) order when addresses share a scan key - create_outputs: document the FIFO return-mapping contract in the docstring - derive_silent_payment_outputs: strip trailing whitespace --- src/embit/silent_payments/bip352.py | 9 ++++++-- tests/tests/test_bip352.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/embit/silent_payments/bip352.py b/src/embit/silent_payments/bip352.py index 67a16266..79cf6a39 100644 --- a/src/embit/silent_payments/bip352.py +++ b/src/embit/silent_payments/bip352.py @@ -124,7 +124,12 @@ def create_outputs(input_privkeys, outpoints, recipients): allowed; pass in vout order so k matches the BIP-375 validator. Returns: - Dictionary mapping each unique recipient address to list of output hex strings + Dictionary mapping each unique recipient address to list of output hex + strings. A repeated address's outputs are listed in occurrence order, so + to rebuild the transaction in vout order, walk ``recipients`` and pop the + next output from ``result[addr]`` (FIFO) for each entry. The k baked into + each output assumes that ordering; flattening the dict by address instead + would place outputs at the wrong vouts. """ if not input_privkeys: return {} @@ -217,7 +222,7 @@ def derive_silent_payment_outputs(ecdh_share, recipients): """ if not recipients: return {} - + if len(recipients) > K_MAX: raise ValueError( "Too many outputs for one scan key: {} > {}".format( diff --git a/tests/tests/test_bip352.py b/tests/tests/test_bip352.py index 7702038b..9ba4bc3a 100644 --- a/tests/tests/test_bip352.py +++ b/tests/tests/test_bip352.py @@ -254,3 +254,35 @@ def test_derive_outputs_rejects_too_many_outputs_per_scan_key(self): with pytest.raises(ValueError, match="Too many outputs"): bip352.derive_silent_payment_outputs(b"\x02" + bytes(32), recipients) + + def test_create_outputs_assigns_k_by_vout_order_when_interleaved(self): + """Addresses sharing a scan key get k by recipients-list (vout) order, + not grouped per address. Regression test matching the BIP-375 + validator's derivation order for interleaved recipients.""" + # Two addresses with a common scan key but distinct spend keys. + scan_priv = PrivateKey(b"\x11" * 32) + addr_x = bip352.generate_silent_payment_address( + scan_priv, PrivateKey(b"\x22" * 32).get_public_key() + ) + addr_y = bip352.generate_silent_payment_address( + scan_priv, PrivateKey(b"\x33" * 32).get_public_key() + ) + + input_privkeys = [(b"\x44" * 32, False)] + outpoints = [COutPoint(txid=bytes(32), out_idx=0)] + + # The shared secret depends only on (inputs, outpoints, scan key), so + # single-address runs yield the per-k outputs to compare against. + out_x = bip352.create_outputs(input_privkeys, outpoints, [addr_x] * 3)[addr_x] + out_y = bip352.create_outputs(input_privkeys, outpoints, [addr_y] * 3)[addr_y] + + # Interleaved vout order [X, Y, X] -> k = 0, 1, 2. + result = bip352.create_outputs( + input_privkeys, outpoints, [addr_x, addr_y, addr_x] + ) + + self.assertEqual(result[addr_x][0], out_x[0]) # first X -> k=0 + self.assertEqual(result[addr_y][0], out_y[1]) # Y -> k=1 + self.assertEqual(result[addr_x][1], out_x[2]) # second X -> k=2 + # The old count-collapsing code assigned the second X to k=1. + self.assertNotEqual(result[addr_x][1], out_x[1]) From f49bc62fd028447037c028a6d57edfcb8d339533 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 15 Jun 2026 16:01:29 -0300 Subject: [PATCH 60/61] fix(sp): parse origin-prefixed key in-place to preserve two-arg stream The standard-Key branch built a temporary BytesIO from s.read(), draining the original stream to EOF and breaking parsing of the comma/closing paren in two-arg sp([origin]scan, spend). Rewind past the origin prefix and let Key.read_from(s) consume the expression in-place instead. --- src/embit/descriptor/sp.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/embit/descriptor/sp.py b/src/embit/descriptor/sp.py index b4d0960d..e3538ca5 100644 --- a/src/embit/descriptor/sp.py +++ b/src/embit/descriptor/sp.py @@ -128,11 +128,13 @@ def _read_sp_key_expression(s): """Read an spscan/spspend expression or a standard Key from stream.""" first = s.read(1) origin = None + origin_len = 0 if first == b"[": prefix, char = read_until(s, b"]") if char != b"]": raise DescriptorError("Invalid key - missing ]") origin = KeyOrigin.from_string(prefix.decode()) + origin_len = len(prefix) + 2 # '[' + prefix + ']' else: s.seek(-1, 1) @@ -152,16 +154,11 @@ def _read_sp_key_expression(s): if lower.startswith(hrp + "1"): return SPSpendKey.decode(token_str, origin), char - # Rewind to before the token via a relative seek (no tell(); MicroPython's - # BytesIO lacks it). Only `token` was consumed since; the delimiter, if any, - # was already pushed back above. - s.seek(-len(token), 1) - if origin: - origin_str = "[%s]" % origin - combined = BytesIO(origin_str.encode() + s.read()) - key = Key.read_from(combined) - else: - key = Key.read_from(s) + # Not a silent-payment key: rewind past the origin prefix and token (no + # tell(); MicroPython's BytesIO lacks it) and let Key.read_from(s) consume + # the expression in-place, leaving the stream at the delimiter for the caller. + s.seek(-(origin_len + len(token)), 1) + key = Key.read_from(s) return key, None From cf085f8c58c91ffd65deea5ca91debf1b6b80c74 Mon Sep 17 00:00:00 2001 From: odudex Date: Mon, 15 Jun 2026 16:08:12 -0300 Subject: [PATCH 61/61] fix(psbtview): reject duplicated global unsigned transaction A second PSBT_GLOBAL_UNSIGNED_TX (key 0x00) silently overwrote tx_offset/num_inputs/num_outputs. Guard on tx_offset being set and raise instead, matching the adjacent Duplicate global key check. --- src/embit/psbtview.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index 99716fbb..9ba1e56b 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -271,6 +271,8 @@ def view(cls, stream, offset=None, compress=CompressMode.KEEP_ALL): # we found global transaction; defer version==2 check until after the loop # so that PSBT_GLOBAL_UNSIGNED_TX is rejected even when it appears before # PSBT_GLOBAL_VERSION (key order is not guaranteed). + if tx_offset is not None: + raise PSBTError("Duplicate global transaction") if (num_inputs is not None) or (num_outputs is not None): raise PSBTError("Invalid global transaction") tx_len = compact.read_from(stream)