diff --git a/bitcoinutils/psbt.py b/bitcoinutils/psbt.py index db8a97d..048486b 100644 --- a/bitcoinutils/psbt.py +++ b/bitcoinutils/psbt.py @@ -64,6 +64,19 @@ PSBT_OUT_WITNESS_SCRIPT = 0x01 PSBT_OUT_BIP32_DERIVATION = 0x02 +# BIP-371 Taproot input types +PSBT_IN_TAP_KEY_SIG = 0x13 +PSBT_IN_TAP_SCRIPT_SIG = 0x14 +PSBT_IN_TAP_LEAF_SCRIPT = 0x15 +PSBT_IN_TAP_BIP32_DERIVATION = 0x16 +PSBT_IN_TAP_INTERNAL_KEY = 0x17 +PSBT_IN_TAP_MERKLE_ROOT = 0x18 + +# BIP-371 Taproot output types +PSBT_OUT_TAP_INTERNAL_KEY = 0x05 +PSBT_OUT_TAP_TREE = 0x06 +PSBT_OUT_TAP_BIP32_DERIVATION = 0x07 + # Sets of known key types for validation _KNOWN_INPUT_SINGLE_BYTE_KEYS = { @@ -74,6 +87,9 @@ PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG, PSBT_IN_FINAL_SCRIPTWITNESS, + PSBT_IN_TAP_KEY_SIG, + PSBT_IN_TAP_INTERNAL_KEY, + PSBT_IN_TAP_MERKLE_ROOT, } _KNOWN_INPUT_PUBKEY_KEYS = { @@ -84,6 +100,8 @@ _KNOWN_OUTPUT_SINGLE_BYTE_KEYS = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT, + PSBT_OUT_TAP_INTERNAL_KEY, + PSBT_OUT_TAP_TREE, } _KNOWN_OUTPUT_PUBKEY_KEYS = { @@ -189,7 +207,20 @@ def __init__(self): self.bip32_derivs: dict[bytes, tuple[bytes, list[int]]] = {} self.final_scriptsig: Optional[Script] = None self.final_scriptwitness: Optional[list[bytes]] = None - self.unknown: dict[bytes, bytes] = {} + self.unknown: dict[bytes, bytes] = {} + + # BIP-371 Taproot fields + self.tap_internal_key: Optional[bytes] = None + self.tap_tree: Optional[bytes] = None + self.tap_bip32_derivation: dict[bytes, tuple[list[bytes], bytes, list[int]]] = {} + + # BIP-371 Taproot fields + self.tap_key_sig: Optional[bytes] = None + self.tap_script_sigs: dict[tuple[bytes, bytes], bytes] = {} + self.tap_leaf_scripts: dict[bytes, tuple[bytes, int]] = {} + self.tap_bip32_derivation: dict[bytes, tuple[list[bytes], bytes, list[int]]] = {} + self.tap_internal_key: Optional[bytes] = None + self.tap_merkle_root: Optional[bytes] = None class PSBTOutput: @@ -199,7 +230,12 @@ def __init__(self): self.redeem_script: Optional[Script] = None self.witness_script: Optional[Script] = None self.bip32_derivs: dict[bytes, tuple[bytes, list[int]]] = {} - self.unknown: dict[bytes, bytes] = {} + self.unknown: dict[bytes, bytes] = {} + + # BIP-371 Taproot fields + self.tap_internal_key: Optional[bytes] = None + self.tap_tree: Optional[bytes] = None + self.tap_bip32_derivation: dict[bytes, tuple[list[bytes], bytes, list[int]]] = {} # --------------------------------------------------------------------------- @@ -297,6 +333,24 @@ def _serialize_input(psi: PSBTInput) -> bytes: data += _write_kv_simple(PSBT_IN_FINAL_SCRIPTWITNESS, wit_data) for k, v in psi.unknown.items(): data += encode_varint(len(k)) + k + encode_varint(len(v)) + v + + # BIP-371 Taproot fields + if psi.tap_key_sig is not None: + data += _write_kv_simple(PSBT_IN_TAP_KEY_SIG, psi.tap_key_sig) + for (pubkey, leaf_hash), sig in psi.tap_script_sigs.items(): + data += _write_kv(PSBT_IN_TAP_SCRIPT_SIG, pubkey + leaf_hash, sig) + for control_block, (script, leaf_ver) in psi.tap_leaf_scripts.items(): + data += _write_kv(PSBT_IN_TAP_LEAF_SCRIPT, control_block, script + bytes([leaf_ver])) + for pubkey, (leaf_hashes, fp, path) in psi.tap_bip32_derivation.items(): + value = b"".join(leaf_hashes) + fp + for idx in path: + value += struct.pack(" bytes: data += _write_kv(PSBT_OUT_BIP32_DERIVATION, pubkey, value) for k, v in pso.unknown.items(): data += encode_varint(len(k)) + k + encode_varint(len(v)) + v + + # BIP-371 Taproot fields + if pso.tap_internal_key is not None: + data += _write_kv_simple(PSBT_OUT_TAP_INTERNAL_KEY, pso.tap_internal_key) + if pso.tap_tree is not None: + data += _write_kv_simple(PSBT_OUT_TAP_TREE, pso.tap_tree) + for pubkey, (leaf_hashes, fp, path) in pso.tap_bip32_derivation.items(): + value = b"".join(leaf_hashes) + fp + for idx in path: + value += struct.pack(" str: @@ -468,6 +534,49 @@ def _parse_input_kv( item_len = _read_compact_size_from_stream(wit_stream) items.append(wit_stream.read(item_len)) psi.final_scriptwitness = items + # BIP-371 Taproot fields + elif key_type == PSBT_IN_TAP_KEY_SIG and len(key_data) == 1: + if len(value_data) not in (64, 65): + raise ValueError("Invalid tap_key_sig length") + psi.tap_key_sig = value_data + elif key_type == PSBT_IN_TAP_SCRIPT_SIG: + if len(key_data) != 65: # 1 byte type + 32 byte xonly + 32 byte leaf hash + raise ValueError("Invalid tap_script_sig key length") + pubkey = key_data[1:33] + leaf_hash = key_data[33:65] + if len(value_data) not in (64, 65): + raise ValueError("Invalid tap_script_sig value length") + psi.tap_script_sigs[(pubkey, leaf_hash)] = value_data + elif key_type == PSBT_IN_TAP_LEAF_SCRIPT: + if (len(key_data) - 1 - 33) % 32 != 0: + raise ValueError("Invalid tap_leaf_script control block size") + control_block = key_data[1:] + script = value_data[:-1] + leaf_ver = value_data[-1] + psi.tap_leaf_scripts[control_block] = (script, leaf_ver) + elif key_type == PSBT_IN_TAP_BIP32_DERIVATION: + if len(key_data) != 33: + raise ValueError("Invalid tap_bip32_derivation pubkey length") + pubkey = key_data[1:] + leaf_hashes = [] + num_hashes = _read_compact_size_from_stream(BytesIO(value_data)) + offset = len(encode_varint(num_hashes)) + for _ in range(num_hashes): + leaf_hashes.append(value_data[offset:offset+32]) + offset += 32 + fingerprint = value_data[offset:offset+4] + path = [] + for i in range(offset+4, len(value_data), 4): + path.append(struct.unpack(" "PSBT": dst.final_scriptsig = src.final_scriptsig if src.final_scriptwitness is not None: dst.final_scriptwitness = src.final_scriptwitness + + # Taproot combine + if src.tap_key_sig is not None: + dst.tap_key_sig = src.tap_key_sig + dst.tap_script_sigs.update(src.tap_script_sigs) + dst.tap_leaf_scripts.update(src.tap_leaf_scripts) + dst.tap_bip32_derivation.update(src.tap_bip32_derivation) + if src.tap_internal_key is not None: + dst.tap_internal_key = src.tap_internal_key + if src.tap_merkle_root is not None: + dst.tap_merkle_root = src.tap_merkle_root + dst.unknown.update(src.unknown) for i in range(len(self.outputs)): @@ -724,6 +891,14 @@ def combine(self, other: "PSBT") -> "PSBT": if src.witness_script is not None: dst.witness_script = src.witness_script dst.bip32_derivs.update(src.bip32_derivs) + + # Taproot combine + if src.tap_internal_key is not None: + dst.tap_internal_key = src.tap_internal_key + if src.tap_tree is not None: + dst.tap_tree = src.tap_tree + dst.tap_bip32_derivation.update(src.tap_bip32_derivation) + dst.unknown.update(src.unknown) return combined @@ -760,6 +935,8 @@ def finalize_input(self, input_index: int): self._finalize_p2sh_legacy(psi) elif _is_p2wsh(script_pubkey): self._finalize_p2wsh(psi) + elif _is_p2tr(script_pubkey): + self._finalize_p2tr(psi) else: raise ValueError("Unsupported script type for finalization") @@ -769,6 +946,12 @@ def finalize_input(self, input_index: int): psi.redeem_script = None psi.witness_script = None psi.bip32_derivs = {} + psi.tap_key_sig = None + psi.tap_script_sigs = {} + psi.tap_leaf_scripts = {} + psi.tap_bip32_derivation = {} + psi.tap_internal_key = None + psi.tap_merkle_root = None def finalize(self): """Finalize all inputs.""" @@ -798,6 +981,15 @@ def _finalize_p2sh_p2wpkh(psi: PSBTInput): psi.final_scriptsig = Script([psi.redeem_script.to_hex()]) psi.final_scriptwitness = [sig, pubkey] + @staticmethod + def _finalize_p2tr(psi: PSBTInput): + if psi.tap_key_sig is not None: + # Key path spend + psi.final_scriptsig = Script([]) + psi.final_scriptwitness = [psi.tap_key_sig] + else: + raise ValueError("Taproot script path finalization not yet fully supported by PSBT") + @staticmethod def _finalize_p2wsh(psi: PSBTInput): ws = psi.witness_script diff --git a/bitcoinutils/script.py b/bitcoinutils/script.py index 21e1fd7..6c54b4d 100644 --- a/bitcoinutils/script.py +++ b/bitcoinutils/script.py @@ -9,17 +9,22 @@ # propagated, or distributed except according to the terms contained in the # LICENSE file. + import copy import hashlib import struct from typing import Any, Union + from bitcoinutils.ripemd160 import ripemd160 from bitcoinutils.utils import b_to_h, h_to_b, vi_to_int + # import bitcoinutils.keys + + # Bitcoin's op codes. Complete list at: https://en.bitcoin.it/wiki/Script OP_CODES = { # constants @@ -133,7 +138,8 @@ "OP_NOP3": b"\xb2", "OP_CHECKSEQUENCEVERIFY": b"\xb2", -} + } + CODE_OPS = { # constants @@ -226,137 +232,224 @@ b"\xba": "OP_CHECKSIGADD", # added this new OPCODE # locktime # This used to be OP_NOP2 - b"\xb1": "OP_CHECKLOCKTIMEVERIFY", + b"\xb1": "OP_CHECKLOCKTIMEVERIFY", # This used to be OP_NOP3 - b"\xb2": "OP_CHECKSEQUENCEVERIFY", -} + b"\xb2": "OP_CHECKSEQUENCEVERIFY", + } + + class Script: """Represents any script in Bitcoin + A Script contains just a list of OP_CODES and also knows how to serialize into bytes + Attributes ---------- script : list the list with all the script OP_CODES and data + Methods ------- to_bytes() returns a serialized byte version of the script + to_hex() returns a serialized version of the script in hex + get_script() returns the list of strings that makes up this script + copy() creates a copy of the object (classmethod) + from_raw() + to_p2sh_script_pub_key() converts script to p2sh scriptPubKey (locking script) + to_p2wsh_script_pub_key() converts script to p2wsh scriptPubKey (locking script) + + is_p2wpkh() + checks if script is P2WPKH (Pay-to-Witness-Public-Key-Hash) + + + is_p2tr() + checks if script is P2TR (Pay-to-Taproot) + + + is_p2wsh() + checks if script is P2WSH (Pay-to-Witness-Script-Hash) + + + is_p2sh() + checks if script is P2SH (Pay-to-Script-Hash) + + + is_p2pkh() + checks if script is P2PKH (Pay-to-Public-Key-Hash) + + + is_multisig() + checks if script is a multisig script + + + get_script_type() + determines the type of script + + Raises ------ ValueError If string data is too large or integer is negative """ + def __init__(self, script: list[Any]): """See Script description""" - self.script: list[Any] = script + @classmethod def copy(cls, script: "Script") -> "Script": """Deep copy of Script""" scripts = copy.deepcopy(script.script) return cls(scripts) - def _op_push_data(self, data: str) -> bytes: - """Converts data to appropriate OP_PUSHDATA OP code including length - - 0x01-0x4b -> just length plus data bytes - 0x4c-0xff -> OP_PUSHDATA1 plus 1-byte-length plus data bytes - 0x0100-0xffff -> OP_PUSHDATA2 plus 2-byte-length plus data bytes - 0x010000-0xffffffff -> OP_PUSHDATA4 plus 4-byte-length plus data bytes - Also note that according to standarardness rules (BIP-62) the minimum - possible PUSHDATA operator must be used! - """ - data_bytes = h_to_b(data) - - if len(data_bytes) < 0x4C: - return bytes([len(data_bytes)]) + data_bytes - elif len(data_bytes) <= 0xFF: - return b"\x4c" + bytes([len(data_bytes)]) + data_bytes - elif len(data_bytes) <= 0xFFFF: - return b"\x4d" + struct.pack(" bytes: - """Converts integer to bytes; as signed little-endian integer - Currently supports only positive integers - """ + def _push_integer(self, integer: int) -> bytes: + """Converts integer to bytes; as signed little-endian integer""" if integer < 0: raise ValueError("Integer is currently required to be positive.") - # bytes required to represent the integer - number_of_bytes = (integer.bit_length() + 7) // 8 - # convert to little-endian bytes + number_of_bytes = (integer.bit_length() + 7) // 8 integer_bytes = integer.to_bytes(number_of_bytes, byteorder="little") - # if last bit is set then we need to add sign to signify positive - # integer + if integer & (1 << number_of_bytes * 8 - 1): integer_bytes += b"\x00" + return self._op_push_data(b_to_h(integer_bytes)) - def to_bytes(self) -> bytes: - """Converts the script to bytes - If an OP code the appropriate byte is included according to: - https://en.bitcoin.it/wiki/Script - If not consider it data (signature, public key, public key hash, etc.) and - and include with appropriate OP_PUSHDATA OP code plus length - """ - script_bytes = b"" - for token in self.script: - # add op codes directly - if token in OP_CODES: - script_bytes += OP_CODES[token] - # if integer between 0 and 16 add the appropriate op code - elif isinstance(token, int) and token >= 0 and token <= 16: - script_bytes += OP_CODES["OP_" + str(token)] - # it is data, so add accordingly - else: - if isinstance(token, int): - script_bytes += self._push_integer(token) - else: + def to_bytes(self) -> bytes: + """Converts the script to bytes""" + script_bytes = b"" + for token in self.script: + if isinstance(token, str) and token in OP_CODES: + # It's an opcode string like 'OP_CHECKMULTISIG' + script_bytes += OP_CODES[token] + elif isinstance(token, int): + # Handle integer opcodes directly + if token == 0: + script_bytes += b'\x00' # OP_0 + elif 1 <= token <= 16: + script_bytes += bytes([0x50 + token]) # OP_1 through OP_16 + elif token == 0x51: # OP_1 + script_bytes += b'\x51' + elif token == 0x52: # OP_2 + script_bytes += b'\x52' + elif token == 0x53: # OP_3 + script_bytes += b'\x53' + elif token == 0xae: # OP_CHECKMULTISIG + script_bytes += b'\xae' + elif 0x50 <= token <= 0x60: # Other single-byte opcodes + script_bytes += bytes([token]) + else: + # For other integers, push as data + script_bytes += self._push_integer(token) + elif isinstance(token, bytes): + # Raw bytes - push with length prefix script_bytes += self._op_push_data(token) - - return script_bytes + elif isinstance(token, str): + # Assume it's hex data if not an opcode + script_bytes += self._op_push_data(token) + else: + raise TypeError(f"Invalid token type in script: {type(token)}") + return script_bytes def to_hex(self) -> str: """Converts the script to hexadecimal""" return b_to_h(self.to_bytes()) + @classmethod + def from_bytes(cls, byte_data: bytes) -> "Script": + """ + Creates a Script object from raw bytes. + + Args: + byte_data (bytes): The raw bytes of the script. + + Returns: + Script: A new Script object parsed from the byte data. + """ + return cls.from_raw(byte_data) + + @staticmethod def from_raw(scriptrawhex: Union[str, bytes], has_segwit: bool = False): """Import a Script commands list from raw hexadecimal data. @@ -378,6 +471,7 @@ def from_raw(scriptrawhex: Union[str, bytes], has_segwit: bool = False): commands = [] index = 0 + while index < len(scriptraw): byte = scriptraw[index] if bytes([byte]) in CODE_OPS: @@ -388,8 +482,6 @@ def from_raw(scriptrawhex: Union[str, bytes], has_segwit: bool = False): ): commands.append(CODE_OPS[bytes([byte])]) index = index + 1 - # handle the 3 special bytes 0x4c,0x4d,0x4e if the transaction is - # not segwit type if has_segwit is False and bytes([byte]) == b"\x4c": bytes_to_read = int.from_bytes( scriptraw[index : index + 1], "little" @@ -418,36 +510,169 @@ def from_raw(scriptrawhex: Union[str, bytes], has_segwit: bool = False): ) index = index + data_size + size + return Script(script=commands) + def get_script(self) -> list[Any]: """Returns script as array of strings""" return self.script - def to_p2sh_script_pub_key(self) -> "Script": - """Converts script to p2sh scriptPubKey (locking script) - - Calculates hash160 of the script and uses it to construct a P2SH script. - """ + def to_p2sh_script_pub_key(self) -> "Script": + """Converts script to p2sh scriptPubKey (locking script)""" script_hash160 = ripemd160(hashlib.sha256(self.to_bytes()).digest()) hex_hash160 = b_to_h(script_hash160) return Script(["OP_HASH160", hex_hash160, "OP_EQUAL"]) - def to_p2wsh_script_pub_key(self) -> "Script": - """Converts script to p2wsh scriptPubKey (locking script) - Calculates the sha256 of the script and uses it to construct a P2WSH script. - """ + def to_p2wsh_script_pub_key(self) -> "Script": + """Converts script to p2wsh scriptPubKey (locking script)""" sha256 = hashlib.sha256(self.to_bytes()).digest() return Script(["OP_0", b_to_h(sha256)]) + + def is_p2wpkh(self) -> bool: + """ + Check if script is P2WPKH (Pay-to-Witness-Public-Key-Hash). + + P2WPKH format: OP_0 <20-byte-key-hash> + + Returns: + bool: True if script is P2WPKH + """ + ops = self.script + return (len(ops) == 2 and + (ops[0] == 0 or ops[0] == "OP_0") and + isinstance(ops[1], str) and + len(h_to_b(ops[1])) == 20) + + + def is_p2tr(self) -> bool: + """ + Check if script is P2TR (Pay-to-Taproot). + + P2TR format: OP_1 <32-byte-key> + + Returns: + bool: True if script is P2TR + """ + ops = self.script + return (len(ops) == 2 and + (ops[0] == 1 or ops[0] == "OP_1") and + isinstance(ops[1], str) and + len(h_to_b(ops[1])) == 32) + + + def is_p2wsh(self) -> bool: + """ + Check if script is P2WSH (Pay-to-Witness-Script-Hash). + + P2WSH format: OP_0 <32-byte-script-hash> + + Returns: + bool: True if script is P2WSH + """ + ops = self.script + return (len(ops) == 2 and + (ops[0] == 0 or ops[0] == "OP_0") and + isinstance(ops[1], str) and + len(h_to_b(ops[1])) == 32) + + + def is_p2sh(self) -> bool: + """ + Check if script is P2SH (Pay-to-Script-Hash). + + P2SH format: OP_HASH160 <20-byte-script-hash> OP_EQUAL + + Returns: + bool: True if script is P2SH + """ + ops = self.script + return (len(ops) == 3 and + ops[0] == 'OP_HASH160' and + isinstance(ops[1], str) and + len(h_to_b(ops[1])) == 20 and + ops[2] == 'OP_EQUAL') + + + def is_p2pkh(self) -> bool: + """ + Check if script is P2PKH (Pay-to-Public-Key-Hash). + + P2PKH format: OP_DUP OP_HASH160 <20-byte-key-hash> OP_EQUALVERIFY OP_CHECKSIG + + Returns: + bool: True if script is P2PKH + """ + ops = self.script + return (len(ops) == 5 and + ops[0] == 'OP_DUP' and + ops[1] == 'OP_HASH160' and + isinstance(ops[2], str) and + len(h_to_b(ops[2])) == 20 and + ops[3] == 'OP_EQUALVERIFY' and + ops[4] == 'OP_CHECKSIG') + + + def is_multisig(self) -> tuple[bool, Union[tuple[int, int], None]]: + """ + Check if script is a multisig script. + + Multisig format: OP_M ... OP_N OP_CHECKMULTISIG + + Returns: + tuple: (bool, (M, N) if multisig, None otherwise) + """ + ops = self.script + + if (len(ops) >= 4 and + isinstance(ops[0], int) and + ops[-1] == 'OP_CHECKMULTISIG' and + isinstance(ops[-2], int)): + + m = ops[0] # Required signatures + n = ops[-2] # Total public keys + + # Validate the structure + if len(ops) == n + 3: # M + N pubkeys + N + OP_CHECKMULTISIG + return True, (m, n) + + return False, None + + + def get_script_type(self) -> str: + """ + Determine the type of script. + + Returns: + str: Script type ('p2pkh', 'p2sh', 'p2wpkh', 'p2wsh', 'p2tr', 'multisig', 'unknown') + """ + if self.is_p2pkh(): + return 'p2pkh' + elif self.is_p2sh(): + return 'p2sh' + elif self.is_p2wpkh(): + return 'p2wpkh' + elif self.is_p2wsh(): + return 'p2wsh' + elif self.is_p2tr(): + return 'p2tr' + elif self.is_multisig()[0]: + return 'multisig' + else: + return 'unknown' + + def __str__(self) -> str: return str(self.script) + def __repr__(self) -> str: return self.__str__() + def __eq__(self, _other: object) -> bool: if not isinstance(_other, Script): return False diff --git a/bitcoinutils/transactions.py b/bitcoinutils/transactions.py index 9591759..31bccdd 100644 --- a/bitcoinutils/transactions.py +++ b/bitcoinutils/transactions.py @@ -8,10 +8,11 @@ # No part of python-bitcoin-utils, including this file, may be copied, modified, # propagated, or distributed except according to the terms contained in the # LICENSE file. - +from typing import Union import math import hashlib import struct +from io import BytesIO from typing import Optional, Union from bitcoinutils.constants import ( @@ -42,6 +43,8 @@ parse_compact_size, ) +def i_to_little_endian(value, length): + return value.to_bytes(length, byteorder='little') class TxInput: """Represents a transaction input. @@ -76,7 +79,7 @@ def __init__( txid: str, txout_index: int, script_sig: Optional[Script] = None, - sequence: str | bytes = DEFAULT_TX_SEQUENCE, + sequence: Union[str, bytes] = DEFAULT_TX_SEQUENCE, ) -> None: """See TxInput description""" @@ -348,7 +351,31 @@ def copy(cls, txout: "TxOutput") -> "TxOutput": """Deep copy of TxOutput""" return cls(txout.amount, txout.script_pubkey) - + + @classmethod + def from_bytes(cls, b): + """Deserialize a TxOutput from bytes.""" + from bitcoinutils.script import Script + from bitcoinutils.utils import read_varint + import struct + from io import BytesIO + + stream = BytesIO(b) + + # Read 8-byte value (little-endian) + value = struct.unpack(' bytes: """Double SHA-256 used by legacy and segwit v0 transaction digests.""" @@ -528,7 +555,7 @@ def __init__( self, inputs: Optional[list[TxInput]] = None, outputs: Optional[list[TxOutput]] = None, - locktime: str | bytes = DEFAULT_TX_LOCKTIME, + locktime: Union[str, bytes, int] = DEFAULT_TX_LOCKTIME, version: bytes = DEFAULT_TX_VERSION, has_segwit: bool = False, witnesses: Optional[list[TxWitnessInput]] = None, @@ -548,9 +575,11 @@ def __init__( self.has_segwit = has_segwit self.witnesses = witnesses - # if user provided a locktime it would be as string (for now...) + # Always store locktime as int if isinstance(locktime, str): - self.locktime = h_to_b(locktime) + self.locktime = int.from_bytes(h_to_b(locktime), 'little') + elif isinstance(locktime, bytes): + self.locktime = int.from_bytes(locktime, 'little') else: self.locktime = locktime @@ -641,7 +670,7 @@ def __str__(self) -> str: "outputs": self.outputs, "has_segwit": self.has_segwit, "witnesses": self.witnesses, - "locktime": self.locktime.hex(), + "locktime": self.locktime, "version": self.version.hex(), } ) @@ -875,7 +904,7 @@ def get_transaction_segwit_digest( tx_for_signing += hash_outputs # add locktime - tx_for_signing += self.locktime + tx_for_signing += self.locktime.to_bytes(4, byteorder="little") # add sighash type tx_for_signing += struct.pack(" bytes: witnesses_count_bytes = encode_varint(len(witness.stack)) data += witnesses_count_bytes data += witness.to_bytes() - data += self.locktime + data += i_to_little_endian(self.locktime, 4) return data def get_txid(self) -> str: @@ -1094,30 +1149,30 @@ def get_size(self) -> int: return len(self.to_bytes(self.has_segwit)) def get_vsize(self) -> int: - """Gets the virtual size of the transaction. + """ + Gets the virtual size of the transaction. For non-segwit txs this is identical to get_size(). For segwit txs the marker and witnesses length needs to be reduced to 1/4 of its original - length. Thus it is substructed from size and then it is divided by 4 - before added back to size to produce vsize (always rounded up). + length. Thus it is subtracted from size and then it is divided by 4 + before being added back to size to produce vsize (always rounded up). https://en.bitcoin.it/wiki/Weight_units """ - # return size if non segwit + # return size if non-segwit if not self.has_segwit: return self.get_size() marker_size = 2 - - wit_size = 0 data = b"" - # count witnesses data + # count witness data for witness in self.witnesses: - # add witnesses stack count + # add witness stack count witnesses_count_bytes = chr(len(witness.stack)).encode() data += witnesses_count_bytes data += witness.to_bytes() + wit_size = len(data) size = self.get_size() - (marker_size + wit_size) @@ -1126,15 +1181,106 @@ def get_vsize(self) -> int: return int(math.ceil(vsize)) def to_hex(self) -> str: - """Converts object to hexadecimal string""" - + """Converts object to hexadecimal string.""" return b_to_h(self.to_bytes(self.has_segwit)) def serialize(self) -> str: - """Converts object to hexadecimal string""" - + """Alias for to_hex().""" return self.to_hex() + @staticmethod + def from_bytes(tx_bytes): + """ + Minimal inline deserializer for Transaction from bytes. + Only for testing — not full-featured! + """ + stream = BytesIO(tx_bytes) + return Transaction._parse_from_stream(stream) + + @classmethod + def _parse_from_stream(cls, stream): + """ + Parse a Bitcoin transaction from a byte stream (PSBT raw transaction). + """ + def read_varint(s): + i = s.read(1)[0] + if i == 0xfd: + return struct.unpack(' bytes: byte_length = (i.bit_length() + 7) // 8 return i.to_bytes(byte_length, "big") +def read_varint(b: bytes) -> tuple: + """ + Reads a Bitcoin varint from the provided bytes. + + Returns: + A tuple (value, size) where: + - value: the decoded integer + - size: the number of bytes consumed + """ + if not b: + raise ValueError("Empty bytes for varint") + + prefix = b[0] + if prefix < 0xfd: + return prefix, 1 + elif prefix == 0xfd: + if len(b) < 3: + raise ValueError("Insufficient bytes for 0xfd varint") + return struct.unpack(' [ ...] + +Example: + python examples/combine_psbt.py p2wsh_signed_1.psbt p2wsh_signed_2.psbt +""" +import sys +from bitcoinutils.setup import setup +from bitcoinutils.psbt import PSBT + +def main(): + if len(sys.argv) < 3: + print("Usage: python examples/combine_psbt.py [ ...]") + print("\nExample:") + print(" python examples/combine_psbt.py p2wsh_signed_1.psbt p2wsh_signed_2.psbt") + print("\nThis combines multiple PSBTs that have different signatures for the same transaction.") + sys.exit(1) + + setup('testnet') + + try: + # Load all PSBTs + psbts = [] + for i, psbt_file in enumerate(sys.argv[1:], 1): + try: + # Try to load from file first + with open(psbt_file, 'r') as f: + psbt_b64 = f.read().strip() + print(f" Loaded PSBT {i} from: {psbt_file}") + except FileNotFoundError: + # If not a file, treat as base64 string + psbt_b64 = psbt_file + print(f" Using PSBT {i} from command line") + + psbt = PSBT.from_base64(psbt_b64) + psbts.append(psbt) + + # Show info about this PSBT + total_sigs = sum(len(inp.partial_sigs) if inp.partial_sigs else 0 + for inp in psbt.inputs) + print(f" - Inputs: {len(psbt.inputs)}") + print(f" - Total signatures: {total_sigs}") + + print(f"\n Combining {len(psbts)} PSBTs...") + + # Take the first PSBT as base + combined = psbts[0] + + # Combine with the rest + for i, other_psbt in enumerate(psbts[1:], 2): + print(f" Merging PSBT {i}...") + combined = combined.combine(other_psbt) + + # Show combined result info + print("\n Successfully combined PSBTs!") + + # Count signatures per input + print("\n Combined PSBT Summary:") + print(f" Total inputs: {len(combined.inputs)}") + + for i, inp in enumerate(combined.inputs): + sig_count = len(inp.partial_sigs) if inp.partial_sigs else 0 + print(f" Input {i}: {sig_count} signature(s)") + + # Show which pubkeys have signed + if inp.partial_sigs: + for pubkey in inp.partial_sigs: + print(f" - Signed by: {pubkey.hex()[:16]}...") + + # Check if ready to finalize + ready_to_finalize = True + for i, inp in enumerate(combined.inputs): + if inp.witness_script: + # For multisig, check if we have enough signatures + witness_hex = inp.witness_script.to_hex() + # Simple check for 2-of-3 multisig (you might need to adjust this) + if "52" in witness_hex[:4] and "53ae" in witness_hex[-4:]: # OP_2...OP_3 OP_CHECKMULTISIG + required_sigs = 2 + current_sigs = len(inp.partial_sigs) if inp.partial_sigs else 0 + if current_sigs < required_sigs: + ready_to_finalize = False + print(f"\n Input {i} needs {required_sigs - current_sigs} more signature(s)") + + if ready_to_finalize: + print("\n This PSBT has enough signatures and is ready to finalize!") + else: + print("\n This PSBT needs more signatures before it can be finalized.") + + # Save combined PSBT + combined_b64 = combined.to_base64() + + # Generate output filename + total_sigs = sum(len(inp.partial_sigs) if inp.partial_sigs else 0 + for inp in combined.inputs) + output_file = f'p2wsh_combined_{total_sigs}sigs.psbt' + + with open(output_file, 'w') as f: + f.write(combined_b64) + + print(f"\n Saved combined PSBT to: {output_file}") + print(f" Base64 preview: {combined_b64[:80]}...") + + # Show next steps + print("\n Next Steps:") + if ready_to_finalize: + print(f" python examples/finalize_psbt.py {output_file}") + else: + print(" 1. Get more signatures:") + print(f" python examples/sign_psbt.py {output_file} ") + print(" 2. Once you have enough signatures, finalize:") + print(f" python examples/finalize_psbt.py {output_file}") + + return 0 + + except ValueError as e: + print(f"\n Error: {e}") + if "different transactions" in str(e).lower(): + print(" PSBTs must be for the same transaction to be combined.") + print(" The PSBTs you're trying to combine appear to be for different transactions.") + return 1 + except Exception as e: + print(f"\n Error: {e}") + import traceback + traceback.print_exc() + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/examples/create_psbt_multisig.py b/examples/create_psbt_multisig.py new file mode 100644 index 0000000..9c2982d --- /dev/null +++ b/examples/create_psbt_multisig.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Create a PSBT for spending from a P2WSH (Pay-to-Witness-Script-Hash) multisig address. +This creates a 2-of-3 multisig setup using witness version 0. +""" +from bitcoinutils.setup import setup +from bitcoinutils.transactions import Transaction, TxInput, TxOutput +from bitcoinutils.keys import PrivateKey, PublicKey, P2wshAddress +from bitcoinutils.script import Script +from bitcoinutils.utils import to_satoshis +from bitcoinutils.psbt import PSBT + +def get_p2wsh_utxo(): + """Funded P2WSH UTXO""" + return { + "txid": "98520ec27732c27ba15cdeed00f73f313864b61d765b6e7687adbaaf8bb823c4", + "vout": 0, + "address": "tb1qqxxqgs5nlpgw86ws4uhfcarj9808g4e439pwgsh8tax2rnqgvutsqdur3g", + "amount": 500000 + } + + +def main(): + setup('testnet') + + # Your private keys for the multisig + wif1 = 'cVVJMEAzugqginoFL5Qu8WkWmD3KLarXoKJZMe8XTbbkL9N5e1bG' + wif2 = 'cSCuWoLorXsQ2fzWrPJnXEicFPJh4SrDwpzEpJKvz5aWPamnU9Ep' + wif3 = 'cSMW2qpL6vE32KrGiTJj3Ez8fVLgJseNfQmyiZqG8achRX1j8AAb' + + priv1 = PrivateKey.from_wif(wif1) + priv2 = PrivateKey.from_wif(wif2) + priv3 = PrivateKey.from_wif(wif3) + + pub1 = priv1.get_public_key() + pub2 = priv2.get_public_key() + pub3 = priv3.get_public_key() + + # Sort public keys for consistent script generation + pubkeys = sorted([pub1, pub2, pub3], key=lambda k: k.to_hex()) + + print("\n Public keys (sorted):") + for i, pk in enumerate(pubkeys, 1): + print(f" PubKey {i}: {pk.to_hex()}") + + # Create 2-of-3 multisig witness script + # IMPORTANT: Use hex strings for pubkeys, not raw bytes + # The Script class will handle the proper encoding + witness_script = Script([ + 'OP_2', # Use opcode name + pubkeys[0].to_hex(), # pubkey as hex string + pubkeys[1].to_hex(), + pubkeys[2].to_hex(), + 'OP_3', # Use opcode name + 'OP_CHECKMULTISIG' # Use opcode name + ]) + + print(f"\n Witness script hex: {witness_script.to_hex()}") + + # Generate P2WSH address from witness script + p2wsh_address = P2wshAddress.from_script(witness_script) + print(f" Multisig P2WSH Address: {p2wsh_address.to_string()}") + + # Verify the script hash + import hashlib + script_hash = hashlib.sha256(witness_script.to_bytes()).digest() + print(f" Script hash (SHA256): {script_hash.hex()}") + + # Create funding transaction to send funds to this address first + print("\n To fund this address, send testnet coins to:", p2wsh_address.to_string()) + print(" You can use: https://bitcoinfaucet.uo1.net/ or https://testnet-faucet.com/btc-testnet/") + + # If you already have a funded UTXO, update get_p2wsh_utxo() with the details + utxo = get_p2wsh_utxo() + + # Verify the UTXO address matches our generated address + if utxo['address'] != p2wsh_address.to_string(): + print(f"\n WARNING: UTXO address mismatch!") + print(f" Expected: {p2wsh_address.to_string()}") + print(f" Got: {utxo['address']}") + print(" The witness script used to create the UTXO must match exactly!") + + if utxo['txid'] == "replace_with_your_funded_txid": + print("\n Please fund the P2WSH address first and update the UTXO details in get_p2wsh_utxo()") + print(" Then run this script again to create the spending PSBT") + return + + # Create transaction input + txin = TxInput(utxo['txid'], utxo['vout']) + + # Calculate fee (P2WSH is more efficient than P2SH) + fee = 500 # Lower fee due to witness discount + sending_amount = utxo['amount'] - fee + + if sending_amount <= 0: + raise ValueError("UTXO amount too small to cover fee!") + + # Send to a simple P2WPKH address for testing + recipient_address = priv1.get_public_key().get_segwit_address() + txout = TxOutput(sending_amount, recipient_address.to_script_pub_key()) + + # Create transaction with witness flag + tx = Transaction([txin], [txout], has_segwit=True) + psbt = PSBT(tx) + + # For P2WSH, we need: + # 1. witness_script (the actual script) + # 2. witness_utxo (the output being spent) + + # Set the witness script + psbt.inputs[0].witness_script = witness_script + + # Create the witness UTXO (the output we're spending) + witness_utxo = TxOutput(utxo['amount'], p2wsh_address.to_script_pub_key()) + print(f"Debug - Witness UTXO amount: {witness_utxo.amount}") + print(f"Debug - Witness UTXO script: {witness_utxo.script_pubkey.to_hex()}") + psbt.inputs[0].witness_utxo = witness_utxo + + print("\n PSBT for P2WSH Spend Created Successfully") + + try: + base64_psbt = psbt.to_base64() + print("\nBase64 PSBT:\n", base64_psbt) + + # Save to file for convenience + with open('p2wsh_unsigned.psbt', 'w') as f: + f.write(base64_psbt) + print("\n PSBT saved to: p2wsh_unsigned.psbt") + + print("\n Transaction Summary:") + print(f" Input: {utxo['txid']}:{utxo['vout']} ({utxo['amount']} sats)") + print(f" Output: {recipient_address.to_string()} ({sending_amount} sats)") + print(f" Fee: {fee} satoshis") + print(f" Witness Script Type: 2-of-3 multisig") + + print("\n Next steps:") + print(" 1. Sign with at least 2 keys: python examples/sign_psbt.py p2wsh_unsigned.psbt 0") + print(" 2. Sign with the 2nd key: python examples/sign_psbt.py p2wsh_signed_1.psbt 0") + print(" 3. Finalize: python examples/finalize_psbt.py p2wsh_signed_2.psbt") + + except Exception as e: + print(f" Error creating PSBT: {str(e)}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/finalize_psbt.py b/examples/finalize_psbt.py new file mode 100644 index 0000000..45bd7b9 --- /dev/null +++ b/examples/finalize_psbt.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Fixed P2WSH PSBT finalizer with proper witness serialization +""" +import sys +from bitcoinutils.setup import setup +from bitcoinutils.psbt import PSBT +from bitcoinutils.transactions import Transaction, TxWitnessInput +from bitcoinutils.script import Script + +def main(): + if len(sys.argv) < 2: + print("Usage: python finalize_psbt.py ") + sys.exit(1) + + setup('testnet') + + psbt_file = sys.argv[1] + + try: + # Load PSBT + with open(psbt_file, 'r') as f: + psbt_b64 = f.read().strip() + + psbt = PSBT.from_base64(psbt_b64) + print(f" Loaded PSBT: {len(psbt.inputs)} inputs") + + all_finalized = True + + for i, psbt_input in enumerate(psbt.inputs): + print(f"\n Processing input {i}:") + + # Skip if already finalized + if psbt_input.final_scriptwitness: + print(f" Already finalized") + continue + + # Validate P2WSH + if not psbt_input.witness_script: + print(f" No witness script") + all_finalized = False + continue + + if not psbt_input.witness_utxo: + print(f" No witness UTXO") + all_finalized = False + continue + + if not psbt_input.partial_sigs: + print(f" No partial signatures") + all_finalized = False + continue + + witness_script_hex = psbt_input.witness_script.to_hex() + print(f" Witness script: {len(witness_script_hex)//2} bytes") + print(f" UTXO amount: {psbt_input.witness_utxo.amount:,} sats") + print(f" Partial signatures: {len(psbt_input.partial_sigs)}") + + # For a 2-of-3 multisig, we need at least 2 signatures + required_sigs = 2 # This is m in m-of-n + + if len(psbt_input.partial_sigs) < required_sigs: + print(f" Need {required_sigs} signatures, only have {len(psbt_input.partial_sigs)}") + all_finalized = False + continue + + # Get all available signatures and their corresponding pubkeys + sig_items = list(psbt_input.partial_sigs.items()) + print(f"\n Available signatures:") + for pk, sig in sig_items: + print(f" - Pubkey: {pk.hex()}") + print(f" Sig: {sig.hex()[:32]}...") + + # Sort signatures by public key to ensure consistent ordering + sorted_sig_items = sorted(sig_items, key=lambda x: x[0]) + + # Build witness stack for P2WSH multisig + # Format: [OP_0, sig1, sig2, witness_script] + witness_stack = [] + + # OP_0 for CHECKMULTISIG bug (empty hex string) + witness_stack.append("") # This will be an empty witness element + + # Add first m signatures (we need 2 for 2-of-3) + sigs_added = 0 + for pk, sig in sorted_sig_items: + if sigs_added < required_sigs: + # Convert signature bytes to hex string + witness_stack.append(sig.hex()) + print(f" Added signature {sigs_added + 1}") + sigs_added += 1 + + # Add the witness script as hex string + witness_stack.append(witness_script_hex) + + # Set final witness - store as list of hex strings + psbt_input.final_scriptwitness = witness_stack + + # Clear partial sigs and witness script (they're not needed after finalization) + psbt_input.partial_sigs = {} + psbt_input.witness_script = None + + print(f" Finalized with {required_sigs} signatures") + print(f" Witness stack has {len(witness_stack)} items:") + print(f" [0] OP_0 (empty)") + for j in range(1, required_sigs + 1): + print(f" [{j}] Signature {j}") + print(f" [{required_sigs + 1}] Witness script") + + if not all_finalized: + print("\n Not all inputs could be finalized") + return 1 + + # Extract final transaction + print("\n Extracting final transaction...") + + try: + # Create a new transaction with the same structure + final_tx = Transaction( + psbt.tx.inputs[:], + psbt.tx.outputs[:], + psbt.tx.locktime, + psbt.tx.version, + has_segwit=True # IMPORTANT: Must be True for witness transactions + ) + + # Set up witnesses + final_tx.witnesses = [] + for psbt_input in psbt.inputs: + if psbt_input.final_scriptwitness: + # Create TxWitnessInput from the witness stack + witness = TxWitnessInput(psbt_input.final_scriptwitness) + final_tx.witnesses.append(witness) + else: + # Empty witness for non-witness inputs + final_tx.witnesses.append(TxWitnessInput([])) + + # Serialize the transaction WITH witness data + tx_hex = final_tx.to_hex() + + # Get transaction IDs + txid = final_tx.get_txid() + wtxid = final_tx.get_wtxid() + + print(f"\n Transaction Finalized Successfully!") + print(f" TXID: {txid}") + print(f" WTXID: {wtxid}") + print(f" Size: {len(tx_hex)//2} bytes") + print(f" vSize: {final_tx.get_vsize()} vbytes") + + # Verify witness data is present + if "0001" in tx_hex[8:12]: # Check for witness marker + print(f" Witness data present") + else: + print(f" WARNING: No witness marker found!") + + print(f"\n Transaction hex preview:") + print(f" {tx_hex[:100]}...") + if len(tx_hex) > 200: + print(f" ...{tx_hex[-100:]}") + + # Save transaction hex + output_file = 'finalized_p2wsh_tx.hex' + with open(output_file, 'w') as f: + f.write(tx_hex) + + print(f"\n Transaction saved to: {output_file}") + print(f"\n To broadcast on testnet:") + print(f" bitcoin-cli -testnet sendrawtransaction {tx_hex}") + print(f"\n Or paste the hex at:") + print(f" https://blockstream.info/testnet/tx/push") + + # Save finalized PSBT + try: + finalized_psbt_b64 = psbt.to_base64() + with open('finalized.psbt', 'w') as f: + f.write(finalized_psbt_b64) + print(f"\n Finalized PSBT saved to: finalized.psbt") + except Exception as e: + print(f"\n Warning: Could not save finalized PSBT: {e}") + print(" This is OK - the transaction hex has been saved successfully!") + + except Exception as e: + print(f" Error during extraction: {e}") + import traceback + traceback.print_exc() + return 1 + + return 0 + + except Exception as e: + print(f"\n Error: {e}") + import traceback + traceback.print_exc() + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/examples/psbt/README.md b/examples/psbt/README.md new file mode 100644 index 0000000..e6a9a69 --- /dev/null +++ b/examples/psbt/README.md @@ -0,0 +1,218 @@ +# PSBT Examples for Testnet4 + +Educational examples demonstrating the complete PSBT (BIP-174) workflow on +Bitcoin Testnet4. Each script walks through every BIP-174 role and produces +a fully signed transaction ready for broadcast. + +## Compatibility Matrix + +| Feature | Status | +|---------------------|--------| +| BIP-174 Core PSBT | ✅ | +| BIP-371 Key-path | ✅ | +| BIP-371 Script-path | ❌ | +| BIP-370 (PSBTv2) | ❌ | +| P2PKH | ✅ | +| P2WPKH | ✅ | +| P2WSH (multisig) | ✅ | +| P2SH-P2WPKH | ✅ | +| P2TR Key-path | ✅ | +| P2TR Script-path | ❌ | + +## Prerequisites + +### 1. Get Testnet4 Coins + +Fund your address using one of these faucets: + +- https://faucet.testnet4.dev +- https://mempool.space/testnet4/faucet + +### 2. Wait for Confirmation + +Check that your funding transaction has at least one confirmation: + +- https://mempool.space/testnet4 + +### 3. Update the Example Scripts + +Each script has a clearly marked section at the top: + +```python +# ====================================================================== +# FILL THESE VALUES after funding your address on testnet4 +# ====================================================================== +UTXO_TXID = "your_funding_txid_here" +UTXO_VOUT = 0 +UTXO_AMOUNT = 0.001 # BTC received from faucet +``` + +Replace these with the actual values from your funding transaction. + +## Examples + +### `psbt_p2wpkh_testnet4.py` — Native SegWit (P2WPKH) + +Single-signature SegWit transaction through the full PSBT lifecycle: + +``` +Creator → Updater → Signer → Finalizer → Extractor +``` + +```bash +python examples/psbt/psbt_p2wpkh_testnet4.py +``` + +### `psbt_multisig_testnet4.py` — 2-of-3 P2WSH Multisig + +Multi-party signing workflow with two independent signers: + +``` +Creator → Updater → Signer A → Signer B → Combiner → Finalizer → Extractor +``` + +```bash +python examples/psbt/psbt_multisig_testnet4.py +``` + +### `psbt_p2tr_testnet4.py` — Taproot Key-Path (P2TR) + +Taproot key-path spending using Schnorr signatures and BIP-371 PSBT fields: + +``` +Creator → Updater (tap_internal_key) → Signer (tap_key_sig) → Finalizer → Extractor +``` + +```bash +python examples/psbt/psbt_p2tr_testnet4.py +``` + +### `psbt_p2tr_hd_testnet4.py` — Taproot HD Wallet + +Deterministic Taproot key-path spending using standard BIP-32/BIP-86 derivations from a mnemonic seed. + +```bash +python examples/psbt/psbt_p2tr_hd_testnet4.py +``` + +### `verify_testnet4.py` — Live Verification + +An interactive manual verification script that fetches live UTXOs, constructs a PSBT, signs, and broadcasts it to the network. + +```bash +python examples/verify_testnet4.py +``` + +## PSBT Workflow + +### Standard PSBT Flow (BIP-174) + +``` +Unsigned Transaction + │ + ▼ + ┌────────┐ + │Creator │ Wraps raw tx in PSBT container + └────┬───┘ + │ + ▼ + ┌────────┐ + │Updater │ Adds UTXOs, scripts, derivation paths + └────┬───┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ +┌────────┐ ┌────────┐ +│Signer A│ │Signer B│ Each adds partial signatures +└────┬───┘ └────┬───┘ + │ │ + └────┬────┘ + │ + ▼ + ┌──────────┐ + │ Combiner │ Merges signed PSBTs + └────┬─────┘ + │ + ▼ + ┌──────────┐ + │Finalizer │ Converts signatures → scriptSig / witness + └────┬─────┘ + │ + ▼ + ┌──────────┐ + │Extractor │ Produces network-ready transaction + └────┬─────┘ + │ + ▼ + Broadcast +``` + +### Taproot Key-Path Flow (BIP-371) + +``` +Unsigned Transaction + │ + ▼ + ┌────────┐ + │Creator │ Wraps raw tx in PSBT + └────┬───┘ + │ + ▼ + ┌────────┐ + │Updater │ Sets witness_utxo + tap_internal_key + └────┬───┘ + │ + ▼ + ┌────────┐ + │ Signer │ Schnorr sign → tap_key_sig (64 bytes) + └────┬───┘ + │ + ▼ + ┌──────────┐ + │Finalizer │ witness = [tap_key_sig] + └────┬─────┘ + │ + ▼ + ┌──────────┐ + │Extractor │ Signed transaction + └──────────┘ +``` + +## Broadcasting + +The example scripts do **not** broadcast automatically. Instead, they print +the raw signed transaction hex and instructions for manual broadcast. + +### Option 1: Bitcoin Core RPC + +```bash +bitcoin-cli -testnet4 sendrawtransaction +``` + +### Option 2: Mempool.space API + +```bash +curl -X POST https://mempool.space/testnet4/api/tx -d '' +``` + +### Option 3: Blockstream API + +```bash +curl -X POST https://blockstream.info/testnet/api/tx -d '' +``` + +After broadcasting, check the transaction at: + +``` +https://mempool.space/testnet4/tx/ +``` + +## Regtest Examples + +For regtest-based examples that use a local Bitcoin Core node, see: + +- [PSBT_2of3_MULTISIG.md](PSBT_2of3_MULTISIG.md) — 2-of-3 P2SH multisig on regtest +- [psbt_2of3_create.py](psbt_2of3_create.py) — Creator + Updater +- [psbt_2of3_sign1.py](psbt_2of3_sign1.py) — Signer 1 +- [psbt_2of3_sign2.py](psbt_2of3_sign2.py) — Signer 2 + Combiner + Finalizer + Extractor diff --git a/examples/psbt/psbt_multisig_testnet4.py b/examples/psbt/psbt_multisig_testnet4.py new file mode 100644 index 0000000..c98bd3f --- /dev/null +++ b/examples/psbt/psbt_multisig_testnet4.py @@ -0,0 +1,253 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, +# modified, propagated, or distributed except according to the terms contained +# in the LICENSE file. + + +"""PSBT 2-of-3 Multisig P2WSH — Testnet4 Educational Example. + +Demonstrates the complete BIP-174 PSBT lifecycle for a 2-of-3 multisig +P2WSH transaction on Bitcoin Testnet4. This is the canonical multi-party +signing use case for PSBT. + +BIP-174 roles exercised: + Creator — builds the unsigned transaction and wraps it in a PSBT + Updater — attaches witness UTXO and witness script metadata + Signer A — signs with private key 1 + Signer B — signs with private key 2 (independently) + Combiner — merges the two partially signed PSBTs + Finalizer — converts partial signatures into a witness stack + Extractor — pulls out the fully signed transaction + +Prerequisites: + 1. Run the script once to see the multisig address. + 2. Fund the multisig address using a testnet4 faucet. + 3. Fill in UTXO_TXID, UTXO_VOUT, and UTXO_AMOUNT below. + +The script prints the raw signed transaction hex and broadcast instructions. +It does NOT broadcast automatically. +""" + +from bitcoinutils.setup import setup +from bitcoinutils.utils import to_satoshis +from bitcoinutils.transactions import Transaction, TxInput, TxOutput +from bitcoinutils.keys import PrivateKey, P2pkhAddress +from bitcoinutils.script import Script +from bitcoinutils.psbt import PSBT + + +# ====================================================================== +# FILL THESE VALUES after funding the multisig address on testnet4 +# ====================================================================== +UTXO_TXID = "replace_with_your_funding_txid" +UTXO_VOUT = 0 +UTXO_AMOUNT = 0.001 # BTC received from faucet +# ====================================================================== + +SEND_AMOUNT = 0.0005 # BTC to send to destination +FEE = 0.00001 # BTC miner fee + +# Three private keys for the 2-of-3 multisig (testnet WIF format). +# In production, these would be held by different parties on separate devices. +SIGNER_1_WIF = "cTALNpTpRbbxTCJ2A5Vq88UxT44w1PE2cYqiB3n4hRvzyCev1Wwo" +SIGNER_2_WIF = "cRvyLwCPLU88jsyj94L7iJjQX5C2f8koG4G2gevN4BeSGcEvfKe9" +SIGNER_3_WIF = "cNxX8M7XU8VNa5ofd8yk1eiZxaxNrQQyb7xNpwAmsrzEhcVwtCjs" + +# Configurable explorer endpoint for broadcast instructions +API_ENDPOINT = "https://mempool.space/testnet4/api" + + +def main(): + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + setup("testnet4") + + sk1 = PrivateKey.from_wif(SIGNER_1_WIF) + sk2 = PrivateKey.from_wif(SIGNER_2_WIF) + sk3 = PrivateKey.from_wif(SIGNER_3_WIF) + + pk1 = sk1.get_public_key() + pk2 = sk2.get_public_key() + pk3 = sk3.get_public_key() + + # ------------------------------------------------------------------ + # Build the 2-of-3 witness script and P2WSH address + # ------------------------------------------------------------------ + witness_script = Script([ + "OP_2", + pk1.to_hex(), + pk2.to_hex(), + pk3.to_hex(), + "OP_3", + "OP_CHECKMULTISIG", + ]) + + p2wsh_script = witness_script.to_p2wsh_script_pub_key() + + # Derive the P2WSH address for display + # P2WSH uses OP_0 <32-byte-hash> — bech32 encoded + from bitcoinutils.keys import P2wshAddress + p2wsh_addr = P2wshAddress(script=witness_script) + + print("=" * 70) + print("PSBT 2-of-3 Multisig P2WSH — Testnet4 Example") + print("=" * 70) + print(f"\nMultisig address (P2WSH): {p2wsh_addr.to_string()}") + print(f"Fund this address, then update UTXO_TXID and UTXO_VOUT.\n") + print(f"Signer 1 pubkey: {pk1.to_hex()}") + print(f"Signer 2 pubkey: {pk2.to_hex()}") + print(f"Signer 3 pubkey: {pk3.to_hex()}") + print(f"Witness script: {witness_script.to_hex()}") + + if UTXO_TXID == "replace_with_your_funding_txid": + print("\nERROR: Please update UTXO_TXID with your funding transaction ID.") + print(" See examples/psbt/README.md for instructions.") + return + + change_amount = UTXO_AMOUNT - SEND_AMOUNT - FEE + + # ------------------------------------------------------------------ + # Creator role: build the unsigned transaction + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 1: Creator — Build unsigned transaction") + print("-" * 70) + + txin = TxInput(UTXO_TXID, UTXO_VOUT) + + # Send to a simple P2PKH destination for demonstration + dest_addr = pk1.get_address() + txout_send = TxOutput( + to_satoshis(SEND_AMOUNT), + dest_addr.to_script_pub_key(), + ) + + txout_change = TxOutput( + to_satoshis(change_amount), + p2wsh_script, # change back to the multisig + ) + + tx = Transaction([txin], [txout_send, txout_change], has_segwit=True) + + print(f" Inputs: 1 (from multisig)") + print(f" Outputs: 2 (send + change)") + print(f" Send: {SEND_AMOUNT} BTC → {dest_addr.to_string()}") + print(f" Change: {change_amount} BTC → multisig") + print(f" Fee: {FEE} BTC") + + # ------------------------------------------------------------------ + # Updater role: attach UTXO and script metadata + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 2: Updater — Attach witness UTXO and witness script") + print("-" * 70) + + # Create the PSBT and distribute to signers + psbt_base = PSBT(tx) + witness_utxo = TxOutput(to_satoshis(UTXO_AMOUNT), p2wsh_script) + psbt_base.update_input( + 0, + witness_utxo=witness_utxo, + witness_script=witness_script, + ) + + unsigned_b64 = psbt_base.to_base64() + print(f" Witness UTXO amount: {UTXO_AMOUNT} BTC") + print(f" Witness script attached: yes") + print(f"\nUnsigned PSBT (base64):\n{unsigned_b64}") + + # ------------------------------------------------------------------ + # Signer A: sign independently + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 3: Signer A — Sign with key 1") + print("-" * 70) + + # In production, Signer A receives the unsigned PSBT via file/QR/NFC. + # Here we simulate by deserializing from the base64 string. + psbt_a = PSBT.from_base64(unsigned_b64) + signed_a = psbt_a.sign_input(0, sk1) + print(f" Signature produced: {signed_a}") + print(f" Partial sigs: {len(psbt_a.inputs[0].partial_sigs)}") + + signed_a_b64 = psbt_a.to_base64() + print(f"\nSigner A PSBT (base64):\n{signed_a_b64}") + + # ------------------------------------------------------------------ + # Signer B: sign independently + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 4: Signer B — Sign with key 2") + print("-" * 70) + + # Signer B also starts from the unsigned PSBT (independent of Signer A) + psbt_b = PSBT.from_base64(unsigned_b64) + signed_b = psbt_b.sign_input(0, sk2) + print(f" Signature produced: {signed_b}") + print(f" Partial sigs: {len(psbt_b.inputs[0].partial_sigs)}") + + signed_b_b64 = psbt_b.to_base64() + print(f"\nSigner B PSBT (base64):\n{signed_b_b64}") + + # ------------------------------------------------------------------ + # Combiner role: merge the two signed PSBTs + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 5: Combiner — Merge signed PSBTs") + print("-" * 70) + + combined = psbt_a.combine(psbt_b) + print(f" Combined partial sigs: {len(combined.inputs[0].partial_sigs)}") + + combined_b64 = combined.to_base64() + print(f"\nCombined PSBT (base64):\n{combined_b64}") + + # ------------------------------------------------------------------ + # Finalizer role: convert partial sigs → witness stack + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 6: Finalizer — Build witness stack from partial signatures") + print("-" * 70) + + combined.finalize_input(0) + psi = combined.inputs[0] + print(f" Final witness items: {len(psi.final_scriptwitness)}") + # For 2-of-3 multisig: OP_0, sig1, sig2, witnessScript = 4 items + + # ------------------------------------------------------------------ + # Extractor role: pull out the signed transaction + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 7: Extractor — Extract signed transaction") + print("-" * 70) + + final_tx = combined.extract_transaction() + raw_hex = final_tx.serialize() + txid = final_tx.get_txid() + + print(f"\nRaw signed transaction:\n{raw_hex}") + print(f"\nTransaction ID:\n{txid}") + + # ------------------------------------------------------------------ + # Broadcast instructions + # ------------------------------------------------------------------ + print(f"\n{'=' * 70}") + print("Broadcast Instructions") + print("=" * 70) + print(f"\nOption 1 — Bitcoin Core RPC:") + print(f" bitcoin-cli -testnet4 sendrawtransaction {raw_hex}") + print(f"\nOption 2 — Mempool.space API:") + print(f" curl -X POST {API_ENDPOINT}/tx -d '{raw_hex}'") + print(f"\nAfter broadcasting, view at:") + print(f" https://mempool.space/testnet4/tx/{txid}") + + +if __name__ == "__main__": + main() diff --git a/examples/psbt/psbt_p2tr_hd_testnet4.py b/examples/psbt/psbt_p2tr_hd_testnet4.py new file mode 100755 index 0000000..c83389c --- /dev/null +++ b/examples/psbt/psbt_p2tr_hd_testnet4.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, +# modified, propagated, or distributed except according to the terms contained +# in the LICENSE file. + + +"""PSBT P2TR (Taproot) — Deterministic HD Wallet Example. + +Demonstrates creating a Taproot key-path spending PSBT using standard +BIP-32 / BIP-86 HD wallet derivations from a known seed phrase. + +Standard test mnemonic: +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about +""" + +from bitcoinutils.setup import setup +from bitcoinutils.hdwallet import HDWallet +from bitcoinutils.keys import P2trAddress +from bitcoinutils.transactions import Transaction, TxInput, TxOutput +from bitcoinutils.psbt import PSBT +from bitcoinutils.utils import to_satoshis + +def main(): + setup("testnet") + + # 1. Initialize HD Wallet from standard test seed + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + hdw = HDWallet(mnemonic=mnemonic, path="m/86'/1'/0'/0/0") + + priv = hdw.get_private_key() + pub = priv.get_public_key() + + address = pub.get_taproot_address() + print(f"Deterministic Taproot Address: {address.to_string()}") + + # 2. Create the raw transaction + # (Using dummy UTXO data) + txin = TxInput("1111111111111111111111111111111111111111111111111111111111111111", 0) + txout = TxOutput(to_satoshis(0.0005), address.to_script_pub_key()) + tx = Transaction([txin], [txout], has_segwit=True) + + # 3. Create PSBT + psbt = PSBT(tx) + + # 4. Populate Taproot fields + psbt.inputs[0].witness_utxo = TxOutput(to_satoshis(0.001), address.to_script_pub_key()) + from bitcoinutils.utils import h_to_b + # BIP-86 dummy internal key + psbt.inputs[0].tap_internal_key = h_to_b(pub.to_x_only_hex()) + + print("\n--- Unsigned PSBT ---") + print(psbt.to_base64()) + + # 5. Sign the PSBT + print("\nSigning PSBT with HD derived key...") + psbt.sign_input(0, priv) + + print("\n--- Signed PSBT ---") + print(psbt.to_base64()) + + # 6. Finalize and extract + psbt.finalize_input(0) + final_tx = psbt.extract_transaction() + + print("\n--- Final Extracted Transaction ---") + print(final_tx.serialize()) + +if __name__ == "__main__": + main() diff --git a/examples/psbt/psbt_p2tr_testnet4.py b/examples/psbt/psbt_p2tr_testnet4.py new file mode 100644 index 0000000..5426c34 --- /dev/null +++ b/examples/psbt/psbt_p2tr_testnet4.py @@ -0,0 +1,238 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, +# modified, propagated, or distributed except according to the terms contained +# in the LICENSE file. + + +"""PSBT Taproot Key-Path (P2TR) — Testnet4 Educational Example. + +Demonstrates Taproot key-path spending using the PSBT workflow on Bitcoin +Testnet4. This uses BIP-371 Taproot-specific PSBT fields (tap_key_sig, +tap_internal_key). + +BIP-174 / BIP-371 roles exercised: + Creator — builds the unsigned transaction and wraps it in a PSBT + Updater — attaches witness UTXO and tap_internal_key + Signer — Schnorr-signs the input (produces tap_key_sig) + Finalizer — converts tap_key_sig into a witness stack + Extractor — pulls out the fully signed transaction + +Taproot key-path spending uses: + - x-only public keys (32 bytes, BIP-340) + - Schnorr signatures (64 bytes for default sighash, BIP-340) + - Tweaked private keys (BIP-341) + - Taproot-specific sighash computation (BIP-341) + +Prerequisites: + 1. Fund the Taproot address using a testnet4 faucet. + 2. Fill in UTXO_TXID, UTXO_VOUT, and UTXO_AMOUNT below. + +The script prints the raw signed transaction hex and broadcast instructions. +It does NOT broadcast automatically. +""" + +from bitcoinutils.setup import setup +from bitcoinutils.utils import to_satoshis, h_to_b, b_to_h +from bitcoinutils.transactions import Transaction, TxInput, TxOutput, TxWitnessInput +from bitcoinutils.keys import PrivateKey, P2pkhAddress +from bitcoinutils.script import Script +from bitcoinutils.psbt import PSBT + + +# ====================================================================== +# FILL THESE VALUES after funding your Taproot address on testnet4 +# ====================================================================== +UTXO_TXID = "replace_with_your_funding_txid" +UTXO_VOUT = 0 +UTXO_AMOUNT = 0.001 # BTC received from faucet +# ====================================================================== + +SEND_AMOUNT = 0.0005 # BTC to send +FEE = 0.00001 # BTC miner fee + +# The sender's private key (testnet WIF format). +# The corresponding Taproot address is derived from the tweaked x-only pubkey. +SENDER_WIF = "cV3R88re3AZSBnWhBBNdiCKTfwpMKkYYjdiR13HQzsU7zoRNX7JL" + +# Configurable explorer endpoint for broadcast instructions +API_ENDPOINT = "https://mempool.space/testnet4/api" + + +def main(): + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + setup("testnet4") + + sender_key = PrivateKey.from_wif(SENDER_WIF) + sender_pubkey = sender_key.get_public_key() + sender_taproot_addr = sender_pubkey.get_taproot_address() + + # The x-only public key (32 bytes) and the tweaked version + x_only_hex = sender_pubkey.to_x_only_hex() + tweaked_hex, _ = sender_pubkey.to_taproot_hex() + + print("=" * 70) + print("PSBT Taproot Key-Path (P2TR) — Testnet4 Example") + print("=" * 70) + print(f"\nTaproot address (P2TR): {sender_taproot_addr.to_string()}") + print(f"Internal key (x-only): {x_only_hex}") + print(f"Tweaked key: {tweaked_hex}") + print(f"Fund this address, then update UTXO_TXID and UTXO_VOUT.\n") + + if UTXO_TXID == "replace_with_your_funding_txid": + print("ERROR: Please update UTXO_TXID with your funding transaction ID.") + print(" See examples/psbt/README.md for instructions.") + return + + change_amount = UTXO_AMOUNT - SEND_AMOUNT - FEE + + # ------------------------------------------------------------------ + # Creator role: build the unsigned transaction + # ------------------------------------------------------------------ + print("-" * 70) + print("Step 1: Creator — Build unsigned transaction") + print("-" * 70) + + txin = TxInput(UTXO_TXID, UTXO_VOUT) + + # The Taproot scriptPubKey: OP_1 <32-byte-tweaked-pubkey> + sender_script_pubkey = sender_taproot_addr.to_script_pub_key() + + # Send to a P2PKH destination for demonstration + dest_addr = P2pkhAddress("mtVHHCqCECGwiMbMoZe8ayhJHuTdDbYWdJ") + txout_send = TxOutput( + to_satoshis(SEND_AMOUNT), + dest_addr.to_script_pub_key(), + ) + + # Change back to Taproot address + txout_change = TxOutput( + to_satoshis(change_amount), + sender_script_pubkey, + ) + + tx = Transaction([txin], [txout_send, txout_change], has_segwit=True) + + print(f" Inputs: 1 (from Taproot address)") + print(f" Outputs: 2 (send + change)") + print(f" Send: {SEND_AMOUNT} BTC → {dest_addr.to_string()}") + print(f" Change: {change_amount} BTC → Taproot") + print(f" Fee: {FEE} BTC") + + # ------------------------------------------------------------------ + # Creator: wrap in PSBT + # ------------------------------------------------------------------ + psbt = PSBT(tx) + + # ------------------------------------------------------------------ + # Updater role: attach witness UTXO and Taproot metadata + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 2: Updater — Attach witness UTXO and Taproot metadata") + print("-" * 70) + + witness_utxo = TxOutput( + to_satoshis(UTXO_AMOUNT), + sender_script_pubkey, + ) + psbt.update_input(0, witness_utxo=witness_utxo) + + # BIP-371: set the internal key (x-only, 32 bytes) + # This tells the signer which key to use for Taproot signing. + psbt.inputs[0].tap_internal_key = h_to_b(x_only_hex) + + unsigned_b64 = psbt.to_base64() + print(f" Witness UTXO: {UTXO_AMOUNT} BTC") + print(f" tap_internal_key: {x_only_hex}") + print(f"\nUnsigned PSBT (base64):\n{unsigned_b64}") + + # ------------------------------------------------------------------ + # Signer role: Taproot key-path signing (Schnorr / BIP-340) + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 3: Signer — Schnorr sign (Taproot key-path)") + print("-" * 70) + + # For Taproot signing, we need ALL input amounts and scriptPubKeys + # (required by BIP-341 sighash computation) + amounts = [to_satoshis(UTXO_AMOUNT)] + utxo_scripts = [sender_script_pubkey] + + sig_hex = sender_key.sign_taproot_input( + psbt.tx, 0, utxo_scripts, amounts, + script_path=False, + tweak=True, + ) + + # BIP-371: store as tap_key_sig (NOT partial_sigs) + psbt.inputs[0].tap_key_sig = h_to_b(sig_hex) + + print(f" Schnorr signature: {sig_hex[:32]}...") + print(f" Signature length: {len(h_to_b(sig_hex))} bytes") + print(f" tap_key_sig set: yes") + + signed_b64 = psbt.to_base64() + print(f"\nSigned PSBT (base64):\n{signed_b64}") + + # ------------------------------------------------------------------ + # Finalizer role: convert tap_key_sig → witness stack + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 4: Finalizer — Build witness from tap_key_sig") + print("-" * 70) + + # For Taproot key-path: witness = [signature] + psi = psbt.inputs[0] + psi.final_scriptwitness = [psi.tap_key_sig] + psi.final_scriptsig = Script([]) + + # Clear non-final Taproot fields + psi.tap_key_sig = None + psi.tap_internal_key = None + + print(f" Final witness items: {len(psi.final_scriptwitness)}") + print(f" Witness[0] (sig): {b_to_h(psi.final_scriptwitness[0])[:32]}...") + + # ------------------------------------------------------------------ + # Extractor role: pull out the signed transaction + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 5: Extractor — Extract signed transaction") + print("-" * 70) + + final_tx = psbt.extract_transaction() + raw_hex = final_tx.serialize() + txid = final_tx.get_txid() + + print(f"\nRaw signed transaction:\n{raw_hex}") + print(f"\nTransaction ID:\n{txid}") + + # Verify witness structure + print(f"\n Witness stack:") + for i, wit in enumerate(final_tx.witnesses): + for j, item in enumerate(wit.stack): + print(f" [{j}] {item}") + + # ------------------------------------------------------------------ + # Broadcast instructions + # ------------------------------------------------------------------ + print(f"\n{'=' * 70}") + print("Broadcast Instructions") + print("=" * 70) + print(f"\nOption 1 — Bitcoin Core RPC:") + print(f" bitcoin-cli -testnet4 sendrawtransaction {raw_hex}") + print(f"\nOption 2 — Mempool.space API:") + print(f" curl -X POST {API_ENDPOINT}/tx -d '{raw_hex}'") + print(f"\nAfter broadcasting, view at:") + print(f" https://mempool.space/testnet4/tx/{txid}") + + +if __name__ == "__main__": + main() diff --git a/examples/psbt/psbt_p2wpkh_testnet4.py b/examples/psbt/psbt_p2wpkh_testnet4.py new file mode 100644 index 0000000..2709374 --- /dev/null +++ b/examples/psbt/psbt_p2wpkh_testnet4.py @@ -0,0 +1,189 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, +# modified, propagated, or distributed except according to the terms contained +# in the LICENSE file. + + +"""PSBT P2WPKH — Testnet4 Educational Example. + +Demonstrates the complete BIP-174 PSBT lifecycle for a native SegWit +(P2WPKH) transaction on Bitcoin Testnet4. + +BIP-174 roles exercised: + Creator — builds the unsigned transaction and wraps it in a PSBT + Updater — attaches the witness UTXO + Signer — signs the input with the private key + Finalizer — converts the partial signature into a witness stack + Extractor — pulls out the fully signed transaction + +Prerequisites: + 1. Fund the sender address using a testnet4 faucet: + - https://faucet.testnet4.dev + - https://mempool.space/testnet4/faucet + 2. Wait for at least one confirmation. + 3. Fill in UTXO_TXID, UTXO_VOUT, and UTXO_AMOUNT below. + +The script prints the raw signed transaction hex and broadcast instructions. +It does NOT broadcast automatically. +""" + +from bitcoinutils.setup import setup +from bitcoinutils.utils import to_satoshis +from bitcoinutils.transactions import Transaction, TxInput, TxOutput +from bitcoinutils.keys import PrivateKey +from bitcoinutils.psbt import PSBT + + +# ====================================================================== +# FILL THESE VALUES after funding your address on testnet4 +# ====================================================================== +UTXO_TXID = "replace_with_your_funding_txid" +UTXO_VOUT = 0 +UTXO_AMOUNT = 0.001 # BTC received from faucet +# ====================================================================== + +# Destination: send most of the funds to this address. +# Change goes back to the sender. Difference is the miner fee. +SEND_AMOUNT = 0.0005 # BTC to send +FEE = 0.00001 # BTC miner fee + +# The sender's private key (testnet WIF format). +# This corresponds to a P2WPKH (native SegWit) address on testnet4. +SENDER_WIF = "cTALNpTpRbbxTCJ2A5Vq88UxT44w1PE2cYqiB3n4hRvzyCev1Wwo" + +# Configurable explorer endpoint for broadcast instructions +API_ENDPOINT = "https://mempool.space/testnet4/api" + + +def main(): + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + setup("testnet4") + + sender_key = PrivateKey.from_wif(SENDER_WIF) + sender_pubkey = sender_key.get_public_key() + sender_addr = sender_pubkey.get_segwit_address() + + print("=" * 70) + print("PSBT P2WPKH — Testnet4 Example") + print("=" * 70) + print(f"\nSender address (P2WPKH): {sender_addr.to_string()}") + print(f"Fund this address, then update UTXO_TXID and UTXO_VOUT.\n") + + if UTXO_TXID == "replace_with_your_funding_txid": + print("ERROR: Please update UTXO_TXID with your funding transaction ID.") + print(" See examples/psbt/README.md for instructions.") + return + + change_amount = UTXO_AMOUNT - SEND_AMOUNT - FEE + + # ------------------------------------------------------------------ + # Creator role: build the unsigned transaction and wrap in PSBT + # ------------------------------------------------------------------ + print("-" * 70) + print("Step 1: Creator — Build unsigned transaction") + print("-" * 70) + + txin = TxInput(UTXO_TXID, UTXO_VOUT) + + # Send to a P2WPKH destination (here we use the same address for demo) + destination_addr = sender_addr + txout_send = TxOutput( + to_satoshis(SEND_AMOUNT), + destination_addr.to_script_pub_key(), + ) + + # Change output back to sender + txout_change = TxOutput( + to_satoshis(change_amount), + sender_addr.to_script_pub_key(), + ) + + tx = Transaction([txin], [txout_send, txout_change], has_segwit=True) + psbt = PSBT(tx) + + print(f" Transaction inputs: 1") + print(f" Transaction outputs: 2 (send + change)") + print(f" Send: {SEND_AMOUNT} BTC") + print(f" Change: {change_amount} BTC") + print(f" Fee: {FEE} BTC") + + # ------------------------------------------------------------------ + # Updater role: attach the witness UTXO + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 2: Updater — Attach witness UTXO metadata") + print("-" * 70) + + witness_utxo = TxOutput( + to_satoshis(UTXO_AMOUNT), + sender_addr.to_script_pub_key(), + ) + psbt.update_input(0, witness_utxo=witness_utxo) + + unsigned_b64 = psbt.to_base64() + print(f" Witness UTXO amount: {UTXO_AMOUNT} BTC") + print(f"\nUnsigned PSBT (base64):\n{unsigned_b64}") + + # ------------------------------------------------------------------ + # Signer role: sign the input + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 3: Signer — Sign input with private key") + print("-" * 70) + + signed = psbt.sign_input(0, sender_key) + print(f" Signature produced: {signed}") + print(f" Partial signatures: {len(psbt.inputs[0].partial_sigs)}") + + signed_b64 = psbt.to_base64() + print(f"\nSigned PSBT (base64):\n{signed_b64}") + + # ------------------------------------------------------------------ + # Finalizer role: convert partial sig → witness stack + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 4: Finalizer — Convert signature to witness") + print("-" * 70) + + psbt.finalize_input(0) + psi = psbt.inputs[0] + print(f" Final witness items: {len(psi.final_scriptwitness)}") + + # ------------------------------------------------------------------ + # Extractor role: pull out the signed transaction + # ------------------------------------------------------------------ + print(f"\n{'-' * 70}") + print("Step 5: Extractor — Extract signed transaction") + print("-" * 70) + + final_tx = psbt.extract_transaction() + raw_hex = final_tx.serialize() + txid = final_tx.get_txid() + + print(f"\nRaw signed transaction:\n{raw_hex}") + print(f"\nTransaction ID:\n{txid}") + + # ------------------------------------------------------------------ + # Broadcast instructions (no automatic network calls) + # ------------------------------------------------------------------ + print(f"\n{'=' * 70}") + print("Broadcast Instructions") + print("=" * 70) + print(f"\nOption 1 — Bitcoin Core RPC:") + print(f" bitcoin-cli -testnet4 sendrawtransaction {raw_hex}") + print(f"\nOption 2 — Mempool.space API:") + print(f" curl -X POST {API_ENDPOINT}/tx -d '{raw_hex}'") + print(f"\nAfter broadcasting, view at:") + print(f" https://mempool.space/testnet4/tx/{txid}") + + +if __name__ == "__main__": + main() diff --git a/examples/sign_psbt.py b/examples/sign_psbt.py new file mode 100644 index 0000000..6d00354 --- /dev/null +++ b/examples/sign_psbt.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Simple P2WSH PSBT signer that relies on bitcoinutils' native functionality. +""" +import sys +from bitcoinutils.setup import setup +from bitcoinutils.keys import PrivateKey +from bitcoinutils.psbt import PSBT +from bitcoinutils.constants import SIGHASH_ALL + +def main(): + if len(sys.argv) < 4: + print("Usage: python examples/sign_psbt.py ") + print("\nExample:") + print(" python examples/sign_psbt.py p2wsh_unsigned.psbt cVVJMEAz... 0") + sys.exit(1) + + psbt_input = sys.argv[1] + private_key_wif = sys.argv[2] + input_index = int(sys.argv[3]) + + setup('testnet') + + try: + # Load PSBT + try: + with open(psbt_input, 'r') as f: + psbt_base64 = f.read().strip() + print(f" Loaded PSBT from: {psbt_input}") + except: + psbt_base64 = psbt_input + print(f" Using PSBT from command line") + + psbt = PSBT.from_base64(psbt_base64) + + # Load private key + private_key = PrivateKey.from_wif(private_key_wif) + pubkey = private_key.get_public_key() + + print(f"\n Signing Details:") + print(f" Private Key: {private_key_wif[:8]}...") + print(f" Public Key: {pubkey.to_hex()}") + print(f" Input Index: {input_index}") + + # Validate input + if input_index >= len(psbt.inputs): + raise ValueError(f"Input index {input_index} out of range") + + psbt_input_data = psbt.inputs[input_index] + + # Basic validation + if not psbt_input_data.witness_script: + raise ValueError("No witness script found - not a P2WSH input") + + if not psbt_input_data.witness_utxo: + raise ValueError("No witness UTXO found") + + print(f"\n Input Validation:") + print(f" Witness script found ({len(psbt_input_data.witness_script.to_hex())//2} bytes)") + print(f" Witness UTXO found ({psbt_input_data.witness_utxo.amount:,} sats)") + + # Check existing signatures + existing_sigs = len(psbt_input_data.partial_sigs) if psbt_input_data.partial_sigs else 0 + print(f" Existing signatures: {existing_sigs}") + + # Check if already signed by this key + pubkey_bytes = pubkey.to_bytes() + if psbt_input_data.partial_sigs and pubkey_bytes in psbt_input_data.partial_sigs: + print(f" This key has already signed this input") + return 0 + + # Sign using bitcoinutils native method + print(f"\n Signing input {input_index}...") + + # Try signing + success = psbt.sign_input(input_index, private_key, SIGHASH_ALL) + + if success: + new_sig_count = len(psbt.inputs[input_index].partial_sigs) + print(f" Successfully signed! Signatures: {new_sig_count}") + + # Output signed PSBT + signed_psbt = psbt.to_base64() + output_file = f'p2wsh_signed_{new_sig_count}.psbt' + + with open(output_file, 'w') as f: + f.write(signed_psbt) + + print(f"\n Saved to: {output_file}") + print(f" First 100 chars: {signed_psbt[:100]}...") + + # Show next steps + print(f"\n Next Steps:") + print(f" - To sign with another key:") + print(f" python examples/sign_psbt.py {output_file} {input_index}") + print(f" - When you have enough signatures, finalize:") + print(f" python examples/finalize_psbt.py {output_file}") + + return 0 + else: + print(f" Failed to sign input {input_index}") + return 1 + + except Exception as e: + print(f"\n Error: {str(e)}") + import traceback + traceback.print_exc() + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/examples/verify_testnet4.py b/examples/verify_testnet4.py new file mode 100644 index 0000000..79b3ab8 --- /dev/null +++ b/examples/verify_testnet4.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, +# modified, propagated, or distributed except according to the terms contained +# in the LICENSE file. + + +"""Live Testnet4 PSBT verification script. + +This script performs a complete end-to-end PSBT lifecycle against the +Bitcoin Testnet4 network. It is intended for **manual** verification +only — it is NOT part of the automated test suite and should NOT be +run in CI. + +Workflow: + 1. Check balance of the test address + 2. Select a UTXO + 3. Create PSBT + 4. Sign + 5. Finalize + 6. Extract signed transaction + 7. Broadcast via API + 8. Print transaction URL for manual verification + +Prerequisites: + - The test address must be funded with testnet4 coins + - Internet access to query the API and broadcast + - The ``urllib`` module (standard library, no extra deps) + +Usage: + python examples/verify_testnet4.py [--api-endpoint URL] + + Default API endpoint: https://mempool.space/testnet4/api +""" + +import sys +import json +import urllib.request +import urllib.error +import argparse +import time + +from bitcoinutils.setup import setup +from bitcoinutils.utils import to_satoshis +from bitcoinutils.transactions import Transaction, TxInput, TxOutput +from bitcoinutils.keys import PrivateKey +from bitcoinutils.psbt import PSBT + + +# Default test key (testnet WIF) — DO NOT use on mainnet +TEST_WIF = "cTALNpTpRbbxTCJ2A5Vq88UxT44w1PE2cYqiB3n4hRvzyCev1Wwo" + +# Fee in satoshis (conservatively high for testnet4) +FEE_SATS = 1000 + +# Default API endpoint +DEFAULT_API = "https://mempool.space/testnet4/api" + + +def api_get(endpoint: str, path: str) -> dict: + """GET JSON from the API.""" + url = f"{endpoint}{path}" + try: + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + print(f" HTTP error {e.code} for {url}") + raise + except urllib.error.URLError as e: + print(f" URL error for {url}: {e.reason}") + raise + + +def api_post(endpoint: str, path: str, data: str) -> str: + """POST raw text data to the API, return response text.""" + url = f"{endpoint}{path}" + req = urllib.request.Request( + url, + data=data.encode("utf-8"), + headers={"Content-Type": "text/plain"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read().decode().strip() + + +def fetch_utxos(endpoint: str, address: str) -> list: + """Fetch confirmed UTXOs for an address.""" + return api_get(endpoint, f"/address/{address}/utxo") + + +def broadcast_tx(endpoint: str, raw_hex: str) -> str: + """Broadcast a raw transaction and return the txid.""" + return api_post(endpoint, "/tx", raw_hex) + + +def main(): + parser = argparse.ArgumentParser( + description="Live Testnet4 PSBT verification" + ) + parser.add_argument( + "--api-endpoint", + default=DEFAULT_API, + help=f"API endpoint (default: {DEFAULT_API})", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Do everything except broadcast", + ) + args = parser.parse_args() + + setup("testnet4") + + sk = PrivateKey.from_wif(TEST_WIF) + pub = sk.get_public_key() + addr = pub.get_segwit_address() + address_str = addr.to_string() + + print("=" * 70) + print("Live Testnet4 PSBT Verification") + print("=" * 70) + print(f"\nAddress (P2WPKH): {address_str}") + print(f"API endpoint: {args.api_endpoint}") + + # Step 1: Fetch UTXOs + print(f"\n--- Step 1: Fetch UTXOs ---") + try: + utxos = fetch_utxos(args.api_endpoint, address_str) + except Exception as e: + print(f"ERROR: Could not fetch UTXOs: {e}") + print(f"\nFund the address first:") + print(f" https://faucet.testnet4.dev") + print(f" https://mempool.space/testnet4/faucet") + sys.exit(1) + + confirmed = [u for u in utxos if u.get("status", {}).get("confirmed", False)] + if not confirmed: + print(f"No confirmed UTXOs found. Fund the address and wait for confirmation.") + print(f" Address: {address_str}") + sys.exit(1) + + # Pick the largest UTXO + utxo = max(confirmed, key=lambda u: u["value"]) + utxo_txid = utxo["txid"] + utxo_vout = utxo["vout"] + utxo_amount = utxo["value"] + + print(f" Found {len(confirmed)} confirmed UTXO(s)") + print(f" Using: {utxo_txid}:{utxo_vout} ({utxo_amount} sats)") + + if utxo_amount <= FEE_SATS: + print(f" ERROR: UTXO amount ({utxo_amount}) is too small for fee ({FEE_SATS})") + sys.exit(1) + + send_amount = utxo_amount - FEE_SATS + + # Step 2: Create PSBT + print(f"\n--- Step 2: Creator + Updater ---") + txin = TxInput(utxo_txid, utxo_vout) + txout = TxOutput(send_amount, addr.to_script_pub_key()) # send back to self + tx = Transaction([txin], [txout], has_segwit=True) + + psbt = PSBT(tx) + witness_utxo = TxOutput(utxo_amount, addr.to_script_pub_key()) + psbt.update_input(0, witness_utxo=witness_utxo) + + unsigned_b64 = psbt.to_base64() + print(f" Sending {send_amount} sats back to self (fee: {FEE_SATS} sats)") + print(f" Unsigned PSBT: {unsigned_b64[:60]}...") + + # Step 3: Sign + print(f"\n--- Step 3: Signer ---") + result = psbt.sign_input(0, sk) + print(f" Signed: {result}") + signed_b64 = psbt.to_base64() + print(f" Signed PSBT: {signed_b64[:60]}...") + + # Step 4: Finalize + print(f"\n--- Step 4: Finalizer ---") + psbt.finalize_input(0) + print(f" Finalized: witness items = {len(psbt.inputs[0].final_scriptwitness)}") + + # Step 5: Extract + print(f"\n--- Step 5: Extractor ---") + final_tx = psbt.extract_transaction() + raw_hex = final_tx.serialize() + txid = final_tx.get_txid() + print(f" Transaction ID: {txid}") + print(f" Raw hex: {raw_hex[:60]}...") + print(f" Size: {final_tx.get_size()} bytes, vSize: {final_tx.get_vsize()} vbytes") + + # Step 6: Broadcast + print(f"\n--- Step 6: Broadcast ---") + if args.dry_run: + print(f" DRY RUN — not broadcasting") + print(f"\n To broadcast manually:") + print(f" curl -X POST {args.api_endpoint}/tx -d '{raw_hex}'") + else: + try: + result_txid = broadcast_tx(args.api_endpoint, raw_hex) + print(f" Broadcast successful!") + print(f" Returned txid: {result_txid}") + except urllib.error.HTTPError as e: + body = e.read().decode() if hasattr(e, 'read') else str(e) + print(f" Broadcast failed: HTTP {e.code}") + print(f" Response: {body}") + print(f"\n To broadcast manually:") + print(f" curl -X POST {args.api_endpoint}/tx -d '{raw_hex}'") + sys.exit(1) + + # Step 7: Verify + print(f"\n{'=' * 70}") + print(f"Verification") + print(f"{'=' * 70}") + print(f"\nView transaction at:") + print(f" https://mempool.space/testnet4/tx/{txid}") + print(f"\nPSBT lifecycle: ✅ Creator → Updater → Signer → Finalizer → Extractor") + + if not args.dry_run: + print(f"\nWaiting 5 seconds before checking mempool status...") + time.sleep(5) + try: + tx_status = api_get(args.api_endpoint, f"/tx/{txid}") + print(f" Transaction found in mempool/chain: ✅") + confirmed = tx_status.get("status", {}).get("confirmed", False) + print(f" Confirmed: {'✅' if confirmed else '⏳ pending'}") + except Exception: + print(f" Transaction not yet visible (may take a moment)") + + +if __name__ == "__main__": + main() diff --git a/tests/test_psbt_bip371_vectors.py b/tests/test_psbt_bip371_vectors.py new file mode 100644 index 0000000..3f1c5e9 --- /dev/null +++ b/tests/test_psbt_bip371_vectors.py @@ -0,0 +1,137 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, +# modified, propagated, or distributed except according to the terms contained +# in the LICENSE file. + +import unittest +from bitcoinutils.setup import setup +from bitcoinutils.psbt import PSBT + +class TestBIP371Vectors(unittest.TestCase): + """Test official BIP-371 test vectors.""" + + def setUp(self): + setup('testnet') + + def test_vector_0(self): + """PSBT With PSBT_IN_TAP_INTERNAL_KEY key that is too long (incorrectly serialized as compressed DER)""" + b64 = "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXARchAv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyAAAA" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_1(self): + """PSBT With PSBT_IN_TAP_KEY_SIG signature that is too short""" + b64 = "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXARM/Fzuz02wHSvtxb+xjB6BpouRQuZXzyCeFlFq43w4kJg3NcDsMvzTeOZGEqUgawrNYbbZgHwJqd/fkk4SBvDR1AAAA" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_2(self): + """PSBT With PSBT_IN_TAP_KEY_SIG signature that is too long""" + b64 = "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXARNCFzuz02wHSvtxb+xjB6BpouRQuZXzyCeFlFq43w4kJg3NcDsMvzTeOZGEqUgawrNYbbZgHwJqd/fkk4SBvDR1FwGqAAAA" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_3(self): + """PSBT With PSBT_IN_TAP_BIP32_DERIVATION key that is too long (incorrectly serialized as compressed DER)""" + b64 = "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXIhYC/jSQZMmNbiqFP6PJsSvYswShnBlcYO+n7iOTBG0/ojIZAHcrLadWAACAAQAAgAAAAIABAAAAAAAAAAAAAA==" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_4(self): + """PSBT With PSBT_OUT_TAP_INTERNAL_KEY key that is too long (incorrectly serialized as compressed DER)""" + b64 = "cHNidP8BAH0CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Aoh7AQAAAAAAFgAUI4KHHH6EIaAAk/dU2RKB5nWHS59gawQqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAABBSEC/jSQZMmNbiqFP6PJsSvYswShnBlcYO+n7iOTBG0/ojIA" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_5(self): + """PSBT With PSBT_OUT_TAP_BIP32_DERIVATION key that is too long (incorrectly serialized as compressed DER)""" + b64 = "cHNidP8BAH0CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Aoh7AQAAAAAAFgAUI4KHHH6EIaAAk/dU2RKB5nWHS59gawQqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAAiBwL+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMhkAdystp1YAAIABAACAAAAAgAEAAAAAAAAAAA==" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_6(self): + """PSBT With PSBT_IN_TAP_SCRIPT_SIG key that is too long (incorrectly serialized as compressed DER)""" + b64 = "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJCFAIssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20s2XDhX1P8DIL5UP1WD/qRm3YXK+AXNoqJkTrwdPQAsJQIl1aqNznMxonsD886NgvjLMC1mxbpOh6LtGBXJrLKej/3BsQXZkljKyzGjh+RK4pXjjcZzncQiFx6lm9JvNQ8sAAA==" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_7(self): + """PSBT With PSBT_IN_TAP_SCRIPT_SIG signature that is too long""" + b64 = "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwlCiXVqo3OczGiewPzzo2C+MswLWbFuk6Hou0YFcmssp6P/cGxBdmSWMrLMaOH5ErileONxnOdxCIXHqWb0m81DywEBAAA=" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_8(self): + """PSBT With PSBT_IN_TAP_SCRIPT_SIG signature that is too short""" + b64 = "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwk/iXVqo3OczGiewPzzo2C+MswLWbFuk6Hou0YFcmssp6P/cGxBdmSWMrLMaOH5ErileONxnOdxCIXHqWb0m81DAAA=" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_9(self): + """PSBT With PSBT_IN_TAP_LEAF_SCRIPT Control block that is too long""" + b64 = "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJjFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgAIyAssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20qzAAAA=" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_10(self): + """PSBT With PSBT_IN_TAP_LEAF_SCRIPT Control block that is too short""" + b64 = "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJhFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4SMgLLE6xoJI3oBqpqNlnPPAPraCHQnIEUpOho/r3oZbttKswAAA" + with self.assertRaises(Exception): + PSBT.from_base64(b64) + + def test_vector_11(self): + """PSBT with one P2TR key only input with internal key and its derivation path""" + b64 = "cHNidP8BAFICAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAFgAUdo4e60z0IIZgM/gKzv8PlyB0SWkAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgAiAgNrdyptt02HU8mKgnlY3mx4qzMSEJ830+AwRIQkLs5z2Bh3Ky2nVAAAgAEAAIAAAACAAAAAAAAAAAAA" + psbt = PSBT.from_base64(b64) + self.assertIsInstance(psbt, PSBT) + # Should round-trip back to identical representation + self.assertEqual(psbt.to_base64(), b64) + + def test_vector_12(self): + """PSBT with one P2TR key only input with internal key, its derivation path, and signature""" + b64 = "cHNidP8BAFICAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAFgAUdo4e60z0IIZgM/gKzv8PlyB0SWkAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1cBE0C7U+yRe62dkGrxuocYHEi4as5aritTYFpyXKdGJWMUdvxvW67a9PLuD0d/NvWPOXDVuCc7fkl7l68uPxJcl680IRb+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMhkAdystp1YAAIABAACAAAAAgAEAAAAAAAAAARcg/jSQZMmNbiqFP6PJsSvYswShnBlcYO+n7iOTBG0/ojIAIgIDa3cqbbdNh1PJioJ5WN5seKszEhCfN9PgMESEJC7Oc9gYdystp1QAAIABAACAAAAAgAAAAAAAAAAAAA==" + psbt = PSBT.from_base64(b64) + self.assertIsInstance(psbt, PSBT) + # Should round-trip back to identical representation + self.assertEqual(psbt.to_base64(), b64) + + def test_vector_13(self): + """PSBT with one P2TR key only output with internal key and its derivation path""" + b64 = "cHNidP8BAF4CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgABBSARJNp67JLM0GyVRWJkf0N7E4uVchqEvivyJ2u92rPmcSEHESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEZAHcrLadWAACAAQAAgAAAAIAAAAAABQAAAAA=" + psbt = PSBT.from_base64(b64) + self.assertIsInstance(psbt, PSBT) + # Should round-trip back to identical representation + self.assertEqual(psbt.to_base64(), b64) + + def test_vector_14(self): + """PSBT with one P2TR script path only input with dummy internal key, scripts, derivation paths for keys in the scripts, and merkle root""" + b64 = "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgjICyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSrMBCFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJfG5v6l/3FP9XJEmZkIEOQG6YqhD1v35fZ4S8HQqabOIyBDILC/FvARtT6nvmFZJKp/J+XSmtIOoRVdhIZ2w7rRsqzAYhXBUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsDNlw4V9T/AyC+VD9Vg/6kZt2FyvgFzaKiZE68HT0ALCRFfLkkK98xFxPeFEfNgV85cWlxWMlop+0TfwgPzVuH4IyD6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqazAIRYssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20jkBzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwl3Ky2nVgAAgAEAAIACAACAAAAAAAAAAAAhFkMgsL8W8BG1Pqe+YVkkqn8n5dKa0g6hFV2EhnbDutGyOQERXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+HcrLadWAACAAQAAgAEAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvoPejzvOx0MCmzn0m4XraCy5cktGe+tSLQYWcuKRRypOQFvfWIFnpSXoaSiZ1admHbaYBAa/zjjUpubk5zn+RrpcHcrLadWAACAAQAAgAMAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARgg8DYuL3Wm9CClvePrIh2WrmcgzyX4GJDJWx13WstRXmUAAQUgESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEhBxEk2nrskszQbJVFYmR/Q3sTi5VyGoS+K/Ina73as+ZxGQB3Ky2nVgAAgAEAAIAAAACAAAAAAAUAAAAA" + psbt = PSBT.from_base64(b64) + self.assertIsInstance(psbt, PSBT) + # Should round-trip back to identical representation + self.assertEqual(psbt.to_base64(), b64) + + def test_vector_15(self): + """PSBT with one P2TR script path only output with dummy internal key, taproot tree, and script key derivation paths""" + b64 = "cHNidP8BAF4CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAIlEgCoy9yG3hzhwPnK6yLW33ztNoP+Qj4F0eQCqHk0HW9vUAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgABBSBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAEGbwLAIiBzblcpAP4SUliaIUPI88efcaBBLSNTr3VelwHHgmlKAqwCwCIgYxxfO1gyuPvev7GXBM7rMjwh9A96JPQ9aO8MwmsSWWmsAcAiIET6pJoDON5IjI3//s37bzKfOAvVZu8gyN9tgT6rHEJzrCEHRPqkmgM43kiMjf/+zftvMp84C9Vm7yDI322BPqscQnM5AfBreYuSoQ7ZqdC7/Trxc6U7FhfaOkFZygCCFs2Fay4Odystp1YAAIABAACAAQAAgAAAAAADAAAAIQdQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAUAfEYeXSEHYxxfO1gyuPvev7GXBM7rMjwh9A96JPQ9aO8MwmsSWWk5ARis5AmIl4Xg6nDO67jhyokqenjq7eDy4pbPQ1lhqPTKdystp1YAAIABAACAAgAAgAAAAAADAAAAIQdzblcpAP4SUliaIUPI88efcaBBLSNTr3VelwHHgmlKAjkBKaW0kVCQFi11mv0/4Pk/ozJgVtC0CIy5M8rngmy42Cx3Ky2nVgAAgAEAAIADAACAAAAAAAMAAAAA" + psbt = PSBT.from_base64(b64) + self.assertIsInstance(psbt, PSBT) + # Should round-trip back to identical representation + self.assertEqual(psbt.to_base64(), b64) + + def test_vector_16(self): + """PSBT with one P2TR script path only input with dummy internal key, scripts, script key derivation paths, merkle root, and script path signatures""" + b64 = "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwlAv4GNl1fW/+tTi6BX+0wfxOD17xhudlvrVkeR4Cr1/T1eJVHU404z2G8na4LJnHmu0/A5Wgge/NLMLGXdfmk9eUEUQyCwvxbwEbU+p75hWSSqfyfl0prSDqEVXYSGdsO60bIRXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+EDh8atvq/omsjbyGDNxncHUKKt2jYD5H5mI2KvvR7+4Y7sfKlKfdowV8AzjTsKDzcB+iPhCi+KPbvZAQ8MpEYEaQRT6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqW99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwQOwfA3kgZGHIM0IoVCMyZwirAx8NpKJT7kWq+luMkgNNi2BUkPjNE+APmJmJuX4hX6o28S3uNpPS2szzeBwXV/ZiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgjICyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSrMBCFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJfG5v6l/3FP9XJEmZkIEOQG6YqhD1v35fZ4S8HQqabOIyBDILC/FvARtT6nvmFZJKp/J+XSmtIOoRVdhIZ2w7rRsqzAYhXBUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsDNlw4V9T/AyC+VD9Vg/6kZt2FyvgFzaKiZE68HT0ALCRFfLkkK98xFxPeFEfNgV85cWlxWMlop+0TfwgPzVuH4IyD6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqazAIRYssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20jkBzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwl3Ky2nVgAAgAEAAIACAACAAAAAAAAAAAAhFkMgsL8W8BG1Pqe+YVkkqn8n5dKa0g6hFV2EhnbDutGyOQERXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+HcrLadWAACAAQAAgAEAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvoPejzvOx0MCmzn0m4XraCy5cktGe+tSLQYWcuKRRypOQFvfWIFnpSXoaSiZ1admHbaYBAa/zjjUpubk5zn+RrpcHcrLadWAACAAQAAgAMAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARgg8DYuL3Wm9CClvePrIh2WrmcgzyX4GJDJWx13WstRXmUAAQUgESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEhBxEk2nrskszQbJVFYmR/Q3sTi5VyGoS+K/Ina73as+ZxGQB3Ky2nVgAAgAEAAIAAAACAAAAAAAUAAAAA" + psbt = PSBT.from_base64(b64) + self.assertIsInstance(psbt, PSBT) + # Should round-trip back to identical representation + self.assertEqual(psbt.to_base64(), b64) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_psbt_taproot.py b/tests/test_psbt_taproot.py new file mode 100644 index 0000000..f4b33cf --- /dev/null +++ b/tests/test_psbt_taproot.py @@ -0,0 +1,621 @@ +# Copyright (C) 2018-2025 The python-bitcoin-utils developers +# +# This file is part of python-bitcoin-utils +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. +# +# No part of python-bitcoin-utils, including this file, may be copied, +# modified, propagated, or distributed except according to the terms contained +# in the LICENSE file. + +"""Tests for Taproot key-path spending in PSBT (BIP-371). + +Tests cover: + - Full P2TR key-path PSBT lifecycle (create → sign → finalize → extract) + - PSBT-signed transaction matches directly-signed transaction + - BIP-371 field serialization round-trip + - Taproot sighash types + - Multi-input mixed types (P2TR + P2WPKH) + - Combine preserves Taproot fields + - Finalization clears Taproot fields + - Missing tap_key_sig error on finalize + - Exact witness byte verification +""" + +import unittest + +from bitcoinutils.setup import setup +from bitcoinutils.utils import to_satoshis, h_to_b, b_to_h +from bitcoinutils.transactions import Transaction, TxInput, TxOutput, TxWitnessInput +from bitcoinutils.keys import PrivateKey, P2pkhAddress +from bitcoinutils.script import Script +from bitcoinutils.constants import ( + TAPROOT_SIGHASH_ALL, + SIGHASH_ALL, + SIGHASH_NONE, + SIGHASH_SINGLE, + SIGHASH_ANYONECANPAY, +) +from bitcoinutils.psbt import ( + PSBT, + PSBT_IN_TAP_KEY_SIG, + PSBT_IN_TAP_INTERNAL_KEY, + PSBT_IN_TAP_MERKLE_ROOT, + PSBT_IN_TAP_SCRIPT_SIG, + PSBT_IN_TAP_LEAF_SCRIPT, + PSBT_IN_TAP_BIP32_DERIVATION, + PSBT_OUT_TAP_INTERNAL_KEY, + PSBT_OUT_TAP_TREE, + PSBT_OUT_TAP_BIP32_DERIVATION, +) + + +class TestTaprootPSBTLifecycle(unittest.TestCase): + """Test the full P2TR key-path PSBT lifecycle.""" + + maxDiff = None + + def setUp(self): + setup("testnet") + # Keys that correspond to pubkey starting with 02 + self.sk = PrivateKey("cV3R88re3AZSBnWhBBNdiCKTfwpMKkYYjdiR13HQzsU7zoRNX7JL") + self.pub = self.sk.get_public_key() + self.taproot_addr = self.pub.get_taproot_address() + self.script_pubkey = self.taproot_addr.to_script_pub_key() + self.x_only_hex = self.pub.to_x_only_hex() + + self.txid = "7b6412a0eed56338731e83c606f13ebb7a3756b3e4e1dbbe43a7db8d09106e56" + self.vout = 1 + self.amount = to_satoshis(0.00005) + + self.dest = P2pkhAddress("mtVHHCqCECGwiMbMoZe8ayhJHuTdDbYWdJ") + self.send_amount = to_satoshis(0.00004) + + def _make_psbt(self): + """Create an unsigned PSBT with witness UTXO attached.""" + txin = TxInput(self.txid, self.vout) + txout = TxOutput(self.send_amount, self.dest.to_script_pub_key()) + tx = Transaction([txin], [txout], has_segwit=True) + psbt = PSBT(tx) + witness_utxo = TxOutput(self.amount, self.script_pubkey) + psbt.update_input(0, witness_utxo=witness_utxo) + return psbt + + def test_p2tr_key_path_lifecycle(self): + """Create → Update → Sign → Finalize → Extract.""" + psbt = self._make_psbt() + + # Sign + result = psbt.sign_input(0, self.sk) + self.assertTrue(result) + + # Verify tap_key_sig is set, not partial_sigs + psi = psbt.inputs[0] + self.assertIsNotNone(psi.tap_key_sig) + self.assertIn(len(psi.tap_key_sig), (64, 65)) + self.assertEqual(len(psi.partial_sigs), 0) + + # Verify tap_internal_key auto-populated + self.assertIsNotNone(psi.tap_internal_key) + self.assertEqual(len(psi.tap_internal_key), 32) + self.assertEqual(psi.tap_internal_key, h_to_b(self.x_only_hex)) + + # Finalize + psbt.finalize_input(0) + self.assertIsNotNone(psi.final_scriptwitness) + self.assertEqual(len(psi.final_scriptwitness), 1) + + # Extract + final_tx = psbt.extract_transaction() + self.assertIsNotNone(final_tx.get_txid()) + self.assertEqual(len(final_tx.witnesses), 1) + + def test_p2tr_matches_direct_signing(self): + """PSBT-signed tx produces identical output to directly-signed tx.""" + # PSBT path + psbt = self._make_psbt() + psbt.sign_input(0, self.sk) + psbt.finalize_input(0) + psbt_tx = psbt.extract_transaction() + + # Direct signing path + txin = TxInput(self.txid, self.vout) + txout = TxOutput(self.send_amount, self.dest.to_script_pub_key()) + direct_tx = Transaction([txin], [txout], has_segwit=True) + sig_hex = self.sk.sign_taproot_input( + direct_tx, 0, [self.script_pubkey], [self.amount] + ) + direct_tx.witnesses.append(TxWitnessInput([sig_hex])) + + # Verify identical txid and raw serialization + self.assertEqual(psbt_tx.get_txid(), direct_tx.get_txid()) + self.assertEqual(psbt_tx.serialize(), direct_tx.serialize()) + + def test_exact_witness_bytes(self): + """Verify exact witness stack structure for Taproot key-path.""" + psbt = self._make_psbt() + psbt.sign_input(0, self.sk) + + # Save the signature for later comparison + tap_key_sig = psbt.inputs[0].tap_key_sig + + psbt.finalize_input(0) + final_tx = psbt.extract_transaction() + + # Witness should have exactly 1 item: the Schnorr signature + wit = final_tx.witnesses[0] + self.assertEqual(len(wit.stack), 1) + + # The witness item should be the tap_key_sig hex + sig_hex = wit.stack[0] + self.assertEqual(h_to_b(sig_hex), tap_key_sig) + + # Default sighash (0x00) produces 64-byte signature (no sighash byte appended) + self.assertEqual(len(tap_key_sig), 64) + + +class TestTaprootPSBTSighashTypes(unittest.TestCase): + """Test Taproot signing with different sighash types.""" + + def setUp(self): + setup("testnet") + self.sk = PrivateKey("cV3R88re3AZSBnWhBBNdiCKTfwpMKkYYjdiR13HQzsU7zoRNX7JL") + self.pub = self.sk.get_public_key() + self.taproot_addr = self.pub.get_taproot_address() + self.script_pubkey = self.taproot_addr.to_script_pub_key() + + self.txid = "7b6412a0eed56338731e83c606f13ebb7a3756b3e4e1dbbe43a7db8d09106e56" + self.amount = to_satoshis(0.00005) + + def _make_psbt(self): + txin = TxInput(self.txid, 1) + dest = P2pkhAddress("mtVHHCqCECGwiMbMoZe8ayhJHuTdDbYWdJ") + txout = TxOutput(to_satoshis(0.00004), dest.to_script_pub_key()) + tx = Transaction([txin], [txout], has_segwit=True) + psbt = PSBT(tx) + psbt.update_input(0, witness_utxo=TxOutput(self.amount, self.script_pubkey)) + return psbt + + def test_default_sighash_all(self): + """SIGHASH_ALL (0x01) is mapped to TAPROOT_SIGHASH_ALL (0x00).""" + psbt = self._make_psbt() + psbt.sign_input(0, self.sk, sighash=SIGHASH_ALL) + # Default taproot sighash produces 64-byte sig (no sighash byte appended) + self.assertEqual(len(psbt.inputs[0].tap_key_sig), 64) + + def test_sighash_none(self): + """SIGHASH_NONE produces 65-byte sig (sighash byte appended).""" + psbt = self._make_psbt() + psbt.sign_input(0, self.sk, sighash=SIGHASH_NONE) + # Non-default sighash appends the sighash byte + self.assertEqual(len(psbt.inputs[0].tap_key_sig), 65) + + def test_sighash_single(self): + """SIGHASH_SINGLE produces 65-byte sig.""" + psbt = self._make_psbt() + psbt.sign_input(0, self.sk, sighash=SIGHASH_SINGLE) + self.assertEqual(len(psbt.inputs[0].tap_key_sig), 65) + + def test_sighash_anyonecanpay(self): + """SIGHASH_ANYONECANPAY|ALL produces 65-byte sig.""" + psbt = self._make_psbt() + psbt.sign_input(0, self.sk, sighash=SIGHASH_ALL | SIGHASH_ANYONECANPAY) + self.assertEqual(len(psbt.inputs[0].tap_key_sig), 65) + + +class TestBIP371FieldRoundTrip(unittest.TestCase): + """Test BIP-371 field serialization/deserialization round-trip.""" + + def setUp(self): + setup("testnet") + + def _make_base_psbt(self): + txin = TxInput( + "7b6412a0eed56338731e83c606f13ebb7a3756b3e4e1dbbe43a7db8d09106e56", 1 + ) + txout = TxOutput(to_satoshis(0.00004), Script(["OP_1", "aa" * 32])) + tx = Transaction([txin], [txout], has_segwit=True) + return PSBT(tx) + + def test_tap_key_sig_round_trip(self): + """tap_key_sig (64 bytes) round-trips correctly.""" + psbt = self._make_base_psbt() + fake_sig = b"\x01" * 64 + psbt.inputs[0].tap_key_sig = fake_sig + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertEqual(restored.inputs[0].tap_key_sig, fake_sig) + + def test_tap_key_sig_65_bytes_round_trip(self): + """tap_key_sig (65 bytes, non-default sighash) round-trips correctly.""" + psbt = self._make_base_psbt() + fake_sig = b"\x02" * 64 + b"\x03" + psbt.inputs[0].tap_key_sig = fake_sig + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertEqual(restored.inputs[0].tap_key_sig, fake_sig) + + def test_tap_internal_key_round_trip(self): + """tap_internal_key (32 bytes) round-trips for both inputs and outputs.""" + psbt = self._make_base_psbt() + fake_key = b"\xab" * 32 + psbt.inputs[0].tap_internal_key = fake_key + psbt.outputs[0].tap_internal_key = fake_key + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertEqual(restored.inputs[0].tap_internal_key, fake_key) + self.assertEqual(restored.outputs[0].tap_internal_key, fake_key) + + def test_tap_merkle_root_round_trip(self): + """tap_merkle_root (32 bytes) round-trips correctly.""" + psbt = self._make_base_psbt() + fake_root = b"\xcd" * 32 + psbt.inputs[0].tap_merkle_root = fake_root + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertEqual(restored.inputs[0].tap_merkle_root, fake_root) + + def test_tap_script_sig_round_trip(self): + """tap_script_sig entries round-trip correctly.""" + psbt = self._make_base_psbt() + xonly = b"\x11" * 32 + leaf_hash = b"\x22" * 32 + sig = b"\x33" * 64 + psbt.inputs[0].tap_script_sigs[(xonly, leaf_hash)] = sig + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertIn((xonly, leaf_hash), restored.inputs[0].tap_script_sigs) + self.assertEqual(restored.inputs[0].tap_script_sigs[(xonly, leaf_hash)], sig) + + def test_tap_leaf_script_round_trip(self): + """tap_leaf_script entries round-trip correctly.""" + psbt = self._make_base_psbt() + control_block = b"\x44" * 33 + script_bytes = b"\x55" * 20 + leaf_ver = 0xC0 + psbt.inputs[0].tap_leaf_scripts[control_block] = (script_bytes, leaf_ver) + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertIn(control_block, restored.inputs[0].tap_leaf_scripts) + rs_bytes, rs_ver = restored.inputs[0].tap_leaf_scripts[control_block] + self.assertEqual(rs_bytes, script_bytes) + self.assertEqual(rs_ver, leaf_ver) + + def test_tap_bip32_derivation_round_trip(self): + """tap_bip32_derivation entries round-trip correctly for inputs.""" + psbt = self._make_base_psbt() + xonly = b"\x66" * 32 + leaf_hashes = [b"\x77" * 32, b"\x88" * 32] + fp = b"\x99" * 4 + path = [0x80000056, 0x80000001, 0x80000000, 0, 0] + psbt.inputs[0].tap_bip32_derivs[xonly] = (leaf_hashes, fp, path) + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertIn(xonly, restored.inputs[0].tap_bip32_derivs) + r_hashes, r_fp, r_path = restored.inputs[0].tap_bip32_derivs[xonly] + self.assertEqual(r_hashes, leaf_hashes) + self.assertEqual(r_fp, fp) + self.assertEqual(r_path, path) + + def test_output_tap_tree_round_trip(self): + """Output tap_tree bytes round-trip correctly.""" + psbt = self._make_base_psbt() + fake_tree = b"\xaa\xbb\xcc\xdd" * 10 + psbt.outputs[0].tap_tree = fake_tree + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertEqual(restored.outputs[0].tap_tree, fake_tree) + + def test_output_tap_bip32_derivation_round_trip(self): + """Output tap_bip32_derivation entries round-trip correctly.""" + psbt = self._make_base_psbt() + xonly = b"\xaa" * 32 + leaf_hashes = [b"\xbb" * 32] + fp = b"\xcc" * 4 + path = [0x80000056, 0] + psbt.outputs[0].tap_bip32_derivs[xonly] = (leaf_hashes, fp, path) + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + self.assertIn(xonly, restored.outputs[0].tap_bip32_derivs) + r_hashes, r_fp, r_path = restored.outputs[0].tap_bip32_derivs[xonly] + self.assertEqual(r_hashes, leaf_hashes) + self.assertEqual(r_fp, fp) + self.assertEqual(r_path, path) + + def test_all_bip371_fields_together(self): + """All BIP-371 fields set simultaneously round-trip correctly.""" + psbt = self._make_base_psbt() + psi = psbt.inputs[0] + pso = psbt.outputs[0] + + # Input fields + psi.tap_key_sig = b"\x01" * 64 + psi.tap_internal_key = b"\x02" * 32 + psi.tap_merkle_root = b"\x03" * 32 + psi.tap_script_sigs[(b"\x04" * 32, b"\x05" * 32)] = b"\x06" * 64 + psi.tap_leaf_scripts[b"\x07" * 33] = (b"\x08" * 10, 0xC0) + psi.tap_bip32_derivs[b"\x09" * 32] = ([b"\x0a" * 32], b"\x0b" * 4, [0, 1]) + + # Output fields + pso.tap_internal_key = b"\x0c" * 32 + pso.tap_tree = b"\x0d" * 40 + pso.tap_bip32_derivs[b"\x0e" * 32] = ([], b"\x0f" * 4, [2, 3]) + + b64 = psbt.to_base64() + restored = PSBT.from_base64(b64) + ri = restored.inputs[0] + ro = restored.outputs[0] + + self.assertEqual(ri.tap_key_sig, psi.tap_key_sig) + self.assertEqual(ri.tap_internal_key, psi.tap_internal_key) + self.assertEqual(ri.tap_merkle_root, psi.tap_merkle_root) + self.assertEqual(ri.tap_script_sigs, psi.tap_script_sigs) + self.assertEqual(ri.tap_leaf_scripts, psi.tap_leaf_scripts) + self.assertEqual(ri.tap_bip32_derivs, psi.tap_bip32_derivs) + self.assertEqual(ro.tap_internal_key, pso.tap_internal_key) + self.assertEqual(ro.tap_tree, pso.tap_tree) + self.assertEqual(ro.tap_bip32_derivs, pso.tap_bip32_derivs) + + +class TestTaprootCombine(unittest.TestCase): + """Test that combine() preserves Taproot fields.""" + + def setUp(self): + setup("testnet") + + def _make_psbt(self): + txin = TxInput( + "7b6412a0eed56338731e83c606f13ebb7a3756b3e4e1dbbe43a7db8d09106e56", 1 + ) + txout = TxOutput(to_satoshis(0.00004), Script(["OP_1", "aa" * 32])) + tx = Transaction([txin], [txout], has_segwit=True) + return PSBT(tx) + + def test_combine_preserves_tap_key_sig(self): + """Combine merges tap_key_sig from the other PSBT.""" + psbt_a = self._make_psbt() + psbt_b = self._make_psbt() + + sig = b"\x01" * 64 + psbt_b.inputs[0].tap_key_sig = sig + + combined = psbt_a.combine(psbt_b) + self.assertEqual(combined.inputs[0].tap_key_sig, sig) + + def test_combine_preserves_tap_internal_key(self): + """Combine merges tap_internal_key from both PSBTs.""" + psbt_a = self._make_psbt() + psbt_b = self._make_psbt() + + key = b"\x02" * 32 + psbt_a.inputs[0].tap_internal_key = key + psbt_b.outputs[0].tap_internal_key = key + + combined = psbt_a.combine(psbt_b) + self.assertEqual(combined.inputs[0].tap_internal_key, key) + self.assertEqual(combined.outputs[0].tap_internal_key, key) + + def test_combine_merges_tap_script_sigs(self): + """Combine merges tap_script_sigs from both PSBTs.""" + psbt_a = self._make_psbt() + psbt_b = self._make_psbt() + + key_a = (b"\x03" * 32, b"\x04" * 32) + key_b = (b"\x05" * 32, b"\x06" * 32) + psbt_a.inputs[0].tap_script_sigs[key_a] = b"\x07" * 64 + psbt_b.inputs[0].tap_script_sigs[key_b] = b"\x08" * 64 + + combined = psbt_a.combine(psbt_b) + self.assertIn(key_a, combined.inputs[0].tap_script_sigs) + self.assertIn(key_b, combined.inputs[0].tap_script_sigs) + + +class TestTaprootFinalization(unittest.TestCase): + """Test finalization behavior for P2TR inputs.""" + + def setUp(self): + setup("testnet") + self.sk = PrivateKey("cV3R88re3AZSBnWhBBNdiCKTfwpMKkYYjdiR13HQzsU7zoRNX7JL") + self.pub = self.sk.get_public_key() + self.taproot_addr = self.pub.get_taproot_address() + self.script_pubkey = self.taproot_addr.to_script_pub_key() + + def _make_psbt(self): + txin = TxInput( + "7b6412a0eed56338731e83c606f13ebb7a3756b3e4e1dbbe43a7db8d09106e56", 1 + ) + txout = TxOutput( + to_satoshis(0.00004), + P2pkhAddress("mtVHHCqCECGwiMbMoZe8ayhJHuTdDbYWdJ").to_script_pub_key(), + ) + tx = Transaction([txin], [txout], has_segwit=True) + psbt = PSBT(tx) + psbt.update_input( + 0, witness_utxo=TxOutput(to_satoshis(0.00005), self.script_pubkey) + ) + return psbt + + def test_finalize_clears_taproot_fields(self): + """Finalization clears all tap_* fields.""" + psbt = self._make_psbt() + psbt.sign_input(0, self.sk) + + # Set extra fields to verify they get cleared + psi = psbt.inputs[0] + psi.tap_merkle_root = b"\xff" * 32 + psi.tap_bip32_derivs[b"\xaa" * 32] = ([], b"\x00" * 4, [0]) + + psbt.finalize_input(0) + + self.assertIsNone(psi.tap_key_sig) + self.assertIsNone(psi.tap_internal_key) + self.assertIsNone(psi.tap_merkle_root) + self.assertEqual(len(psi.tap_script_sigs), 0) + self.assertEqual(len(psi.tap_leaf_scripts), 0) + self.assertEqual(len(psi.tap_bip32_derivs), 0) + + def test_finalize_without_tap_key_sig_raises(self): + """Finalization without tap_key_sig raises ValueError.""" + psbt = self._make_psbt() + # Don't sign — go straight to finalize + with self.assertRaises(ValueError) as ctx: + psbt.finalize_input(0) + self.assertIn("tap_key_sig", str(ctx.exception)) + + def test_finalize_produces_single_witness_item(self): + """P2TR key-path witness is a single stack item (the signature).""" + psbt = self._make_psbt() + psbt.sign_input(0, self.sk) + psbt.finalize_input(0) + + psi = psbt.inputs[0] + self.assertEqual(len(psi.final_scriptwitness), 1) + + +class TestMultiInputMixedTypes(unittest.TestCase): + """Test PSBT with mixed P2TR and P2WPKH inputs.""" + + def setUp(self): + setup("testnet") + self.sk1 = PrivateKey("cV3R88re3AZSBnWhBBNdiCKTfwpMKkYYjdiR13HQzsU7zoRNX7JL") + self.pub1 = self.sk1.get_public_key() + self.taproot_addr = self.pub1.get_taproot_address() + self.taproot_spk = self.taproot_addr.to_script_pub_key() + + self.sk2 = PrivateKey("cNxX8M7XU8VNa5ofd8yk1eiZxaxNrQQyb7xNpwAmsrzEhcVwtCjs") + self.pub2 = self.sk2.get_public_key() + self.segwit_addr = self.pub2.get_segwit_address() + self.segwit_spk = self.segwit_addr.to_script_pub_key() + + def test_mixed_p2tr_p2wpkh(self): + """PSBT with one P2TR and one P2WPKH input.""" + txin1 = TxInput("aaaa" + "00" * 30, 0) # P2TR input + txin2 = TxInput("bbbb" + "00" * 30, 1) # P2WPKH input + + dest = P2pkhAddress("mtVHHCqCECGwiMbMoZe8ayhJHuTdDbYWdJ") + txout = TxOutput(to_satoshis(0.00008), dest.to_script_pub_key()) + + tx = Transaction([txin1, txin2], [txout], has_segwit=True) + psbt = PSBT(tx) + + # Update both inputs with UTXOs + psbt.update_input( + 0, witness_utxo=TxOutput(to_satoshis(0.00005), self.taproot_spk) + ) + psbt.update_input( + 1, witness_utxo=TxOutput(to_satoshis(0.00005), self.segwit_spk) + ) + + # Sign P2TR input + psbt.sign_input(0, self.sk1) + self.assertIsNotNone(psbt.inputs[0].tap_key_sig) + self.assertEqual(len(psbt.inputs[0].partial_sigs), 0) + + # Sign P2WPKH input + psbt.sign_input(1, self.sk2) + self.assertIsNone(psbt.inputs[1].tap_key_sig) + self.assertEqual(len(psbt.inputs[1].partial_sigs), 1) + + # Finalize both + psbt.finalize_input(0) + psbt.finalize_input(1) + + # Extract + final_tx = psbt.extract_transaction() + self.assertEqual(len(final_tx.witnesses), 2) + + # P2TR witness: 1 item (sig) + self.assertEqual(len(psbt.inputs[0].final_scriptwitness), 1) + # P2WPKH witness: 2 items (sig + pubkey) + self.assertEqual(len(psbt.inputs[1].final_scriptwitness), 2) + + +class TestTaprootPSBTWith03Key(unittest.TestCase): + """Test Taproot PSBT with a key starting with 03 (tests key negation).""" + + def setUp(self): + setup("testnet") + # Key that corresponds to pubkey starting with 03 + self.sk = PrivateKey("cNxX8M7XU8VNa5ofd8yk1eiZxaxNrQQyb7xNpwAmsrzEhcVwtCjs") + self.pub = self.sk.get_public_key() + self.taproot_addr = self.pub.get_taproot_address() + self.script_pubkey = self.taproot_addr.to_script_pub_key() + + self.txid = "2a28f8bd8ba0518a86a390da310073a30b7df863d04b42a9c487edf3a8b113af" + + def test_p2tr_03_key_lifecycle(self): + """Full lifecycle with a key requiring Y-coordinate negation.""" + txin = TxInput(self.txid, 1) + dest = P2pkhAddress("mtVHHCqCECGwiMbMoZe8ayhJHuTdDbYWdJ") + txout = TxOutput(to_satoshis(0.00004), dest.to_script_pub_key()) + tx = Transaction([txin], [txout], has_segwit=True) + + psbt = PSBT(tx) + psbt.update_input( + 0, witness_utxo=TxOutput(to_satoshis(0.00005), self.script_pubkey) + ) + + psbt.sign_input(0, self.sk) + psbt.finalize_input(0) + final_tx = psbt.extract_transaction() + + # Compare with direct signing + direct_tx = Transaction([TxInput(self.txid, 1)], [txout], has_segwit=True) + sig_hex = self.sk.sign_taproot_input( + direct_tx, 0, [self.script_pubkey], [to_satoshis(0.00005)] + ) + direct_tx.witnesses.append(TxWitnessInput([sig_hex])) + + self.assertEqual(final_tx.get_txid(), direct_tx.get_txid()) + self.assertEqual(final_tx.serialize(), direct_tx.serialize()) + + +class TestBIP371Validation(unittest.TestCase): + """Test BIP-371 field validation during deserialization.""" + + def setUp(self): + setup("testnet") + + def test_invalid_tap_key_sig_length(self): + """Reject tap_key_sig that is not 64 or 65 bytes.""" + txin = TxInput("aa" * 32, 0) + txout = TxOutput(to_satoshis(0.00004), Script(["OP_1", "bb" * 32])) + tx = Transaction([txin], [txout], has_segwit=True) + psbt = PSBT(tx) + + # Manually set invalid sig, serialize, and try to parse + psbt.inputs[0].tap_key_sig = b"\x00" * 63 # invalid: 63 bytes + + # The serialization will happen fine, but deserialization should fail + raw = psbt.to_bytes() + with self.assertRaises(ValueError) as ctx: + PSBT.from_bytes(raw) + self.assertIn("tap_key_sig", str(ctx.exception)) + + def test_invalid_tap_internal_key_length(self): + """Reject tap_internal_key that is not 32 bytes.""" + txin = TxInput("aa" * 32, 0) + txout = TxOutput(to_satoshis(0.00004), Script(["OP_1", "bb" * 32])) + tx = Transaction([txin], [txout], has_segwit=True) + psbt = PSBT(tx) + + psbt.inputs[0].tap_internal_key = b"\x00" * 31 # invalid: 31 bytes + + raw = psbt.to_bytes() + with self.assertRaises(ValueError) as ctx: + PSBT.from_bytes(raw) + self.assertIn("tap_internal_key", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main()