diff --git a/src/embit/liquid/pset.py b/src/embit/liquid/pset.py index 3a2c8b8..0c6749a 100644 --- a/src/embit/liquid/pset.py +++ b/src/embit/liquid/pset.py @@ -138,10 +138,10 @@ def blinded_vin(self): witness=TxInWitness(self.issue_rangeproof, self.token_rangeproof), ) - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): # standard bitcoin stuff if (b"\xfc\x08elements" not in k) and (b"\xfc\x04pset" not in k): - super().read_value(stream, k) + super().read_value(stream, k, version=version) elif k == b"\xfc\x04pset\x0e": # range proof is very large, # so we don't load it if compress flag is set. @@ -384,9 +384,9 @@ def reblind(self, nonce, blinding_pubkey=None, extra_message=b""): secp256k1.generator_parse(self.asset_commitment), ) - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): if (b"\xfc\x08elements" not in k) and (b"\xfc\x04pset" not in k): - super().read_value(stream, k) + super().read_value(stream, k, version=version) # range proof and surjection proof are very large, # so we don't load them if compress flag is set. elif k in [b"\xfc\x08elements\x04", b"\xfc\x04pset\x04"]: diff --git a/src/embit/psbt.py b/src/embit/psbt.py index d20c88b..001f956 100644 --- a/src/embit/psbt.py +++ b/src/embit/psbt.py @@ -12,6 +12,9 @@ from io import BytesIO +LOCKTIME_THRESHOLD = 500000000 + + class PSBTError(EmbitError): pass @@ -148,6 +151,8 @@ def __init__(self, unknown: dict = {}, vin=None, compress=CompressMode.KEEP_ALL) self.final_scriptsig = None self.final_scriptwitness = None + self.required_time_locktime = None + self.required_height_locktime = None self.parse_unknowns() def clear_metadata(self, compress=CompressMode.CLEAR_ALL): @@ -193,6 +198,16 @@ def update(self, other): self.taproot_scripts.update(other.taproot_scripts) self.final_scriptsig = other.final_scriptsig or self.final_scriptsig self.final_scriptwitness = other.final_scriptwitness or self.final_scriptwitness + self.required_time_locktime = ( + other.required_time_locktime + if other.required_time_locktime is not None + else self.required_time_locktime + ) + self.required_height_locktime = ( + other.required_height_locktime + if other.required_height_locktime is not None + else self.required_height_locktime + ) @property def vin(self): @@ -245,7 +260,7 @@ def verify(self, ignore_missing=False): raise PSBTError("Missing non_witness_utxo") return False - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): # separator if len(k) == 0: return @@ -345,13 +360,50 @@ def read_value(self, stream, k): else: raise PSBTError("Duplicated final scriptwitness") + # PSBTv2 fields - check if we're in PSBTv0 mode elif k == b"\x0e": + if version != 2: + raise PSBTError("PSBT_IN_PREVIOUS_TXID not allowed in PSBTv0") self.txid = bytes(reversed(v)) elif k == b"\x0f": + if version != 2: + raise PSBTError("PSBT_IN_OUTPUT_INDEX not allowed in PSBTv0") self.vout = int.from_bytes(v, "little") elif k == b"\x10": + if version != 2: + raise PSBTError("PSBT_IN_SEQUENCE not allowed in PSBTv0") self.sequence = int.from_bytes(v, "little") + # PSBT_IN_REQUIRED_TIME_LOCKTIME + elif k[0] == 0x11: + if version != 2: + raise PSBTError("PSBT_IN_REQUIRED_TIME_LOCKTIME not allowed in PSBTv0") + if len(k) != 1: + raise PSBTError("Invalid required time locktime key") + if self.required_time_locktime is not None: + raise PSBTError("Duplicated required time locktime") + locktime = int.from_bytes(v, "little") + if locktime < LOCKTIME_THRESHOLD: + raise PSBTError(f"Time-based locktime must be >= {LOCKTIME_THRESHOLD}") + self.required_time_locktime = locktime + + # PSBT_IN_REQUIRED_HEIGHT_LOCKTIME + elif k[0] == 0x12: + if version != 2: + raise PSBTError( + "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME not allowed in PSBTv0" + ) + if len(k) != 1: + raise PSBTError("Invalid required height locktime key") + if self.required_height_locktime is not None: + raise PSBTError("Duplicated required height locktime") + locktime = int.from_bytes(v, "little") + if locktime >= LOCKTIME_THRESHOLD or locktime == 0: + raise PSBTError( + f"Height-based locktime must be > 0 and < {LOCKTIME_THRESHOLD}" + ) + self.required_height_locktime = locktime + # PSBT_IN_TAP_KEY_SIG elif k[0] == 0x13: # read the taproot key sig @@ -444,6 +496,20 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: r += ser_string(stream, b"\x10") r += ser_string(stream, self.sequence.to_bytes(4, "little")) + # Add required time locktime if present + if self.required_time_locktime is not None: + r += ser_string(stream, b"\x11") + r += ser_string( + stream, self.required_time_locktime.to_bytes(4, "little") + ) + + # Add required height locktime if present + if self.required_height_locktime is not None: + r += ser_string(stream, b"\x12") + r += ser_string( + stream, self.required_height_locktime.to_bytes(4, "little") + ) + # PSBT_IN_TAP_KEY_SIG if self.taproot_key_sig is not None: r += ser_string(stream, b"\x13") @@ -489,6 +555,17 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: r += stream.write(b"\x00") return r + @classmethod + def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vin=None, version=None): + res = cls({}, vin=vin, compress=compress) + while True: + key = read_string(stream) + # separator + if len(key) == 0: + break + res.read_value(stream, key, version=version) + return res + class OutputScope(PSBTScope): def __init__(self, unknown: dict = {}, vout=None, compress=CompressMode.KEEP_ALL): @@ -531,7 +608,7 @@ def update(self, other): def vout(self): return TransactionOutput(self.value, self.script_pubkey) - def read_value(self, stream, k): + def read_value(self, stream, k, version=None): # separator if len(k) == 0: return @@ -562,9 +639,14 @@ def read_value(self, stream, k): else: self.bip32_derivations[pub] = DerivationPath.parse(v) + # PSBTv2 fields - check if we're in PSBTv0 mode elif k == b"\x03": + if version != 2: + raise PSBTError("PSBT_OUT_AMOUNT not allowed in PSBTv0") self.value = int.from_bytes(v, "little") elif k == b"\x04": + if version != 2: + raise PSBTError("PSBT_OUT_SCRIPT not allowed in PSBTv0") self.script_pubkey = Script(v) # PSBT_OUT_TAP_INTERNAL_KEY @@ -633,6 +715,23 @@ def write_to(self, stream, skip_separator=False, version=None, **kwargs) -> int: r += stream.write(b"\x00") return r + @classmethod + def read_from(cls, stream, compress=CompressMode.KEEP_ALL, vout=None, version=None): + res = cls({}, vout=vout, compress=compress) + while True: + key = read_string(stream) + # separator + if len(key) == 0: + break + res.read_value(stream, key, version=version) + return res + + +class TxModifiable: + INPUTS = 0b00000001 + OUTPUTS = 0b00000010 + SIGHASH_SINGLE = 0b00000100 + class PSBT(EmbitBase): MAGIC = b"psbt\xff" @@ -642,11 +741,12 @@ class PSBT(EmbitBase): TX_CLS = Transaction def __init__(self, tx=None, unknown={}, version=None): - self.version = version # None for v0 + self.version = version # None for v0, 2 for v2 self.inputs = [] self.outputs = [] self.tx_version = None self.locktime = None + self.tx_modifiable_flags = None if tx is not None: self.parse_tx(tx) @@ -661,11 +761,75 @@ def parse_tx(self, tx): self.inputs = [self.PSBTIN_CLS(vin=vin) for vin in tx.vin] self.outputs = [self.PSBTOUT_CLS(vout=vout) for vout in tx.vout] + def determine_locktime(self): + """ + Determines the appropriate locktime according to PSBTv2 rules. + Returns the locktime that should be used for the transaction. + """ + if self.version != 2: + return self.locktime or 0 + + height_locktimes = [] + time_locktimes = [] + inputs_with_locktime_requirements = 0 + + # Collect all locktime values and count inputs with requirements + for inp in self.inputs: + has_requirement = False + if inp.required_height_locktime is not None: + height_locktimes.append(inp.required_height_locktime) + has_requirement = True + if inp.required_time_locktime is not None: + time_locktimes.append(inp.required_time_locktime) + has_requirement = True + if has_requirement: + inputs_with_locktime_requirements += 1 + + # If no inputs have locktime requirements, use fallback + if inputs_with_locktime_requirements == 0: + return self.locktime or 0 + + inputs_supporting_height = 0 + inputs_supporting_time = 0 + + for inp in self.inputs: + has_height = inp.required_height_locktime is not None + has_time = inp.required_time_locktime is not None + + if not has_height and not has_time: + continue + + if has_height: + inputs_supporting_height += 1 + if has_time: + inputs_supporting_time += 1 + + # Apply BIP 370 rules: + # If all inputs with requirements support height locktime (including those with both), + # OR if we have both types but height is more widely supported, use height + if ( + inputs_supporting_height == inputs_with_locktime_requirements + or inputs_supporting_height >= inputs_supporting_time + ): + return max(height_locktimes) if height_locktimes else 0 + + # If all inputs with requirements support time locktime, use time + if inputs_supporting_time == inputs_with_locktime_requirements: + return max(time_locktimes) if time_locktimes else 0 + + # Fallback (should not be reached for valid PSBTs) + return self.locktime or 0 + @property def tx(self): + if self.version == 2: + locktime = self.determine_locktime() + else: + locktime = self.locktime or 0 + return self.TX_CLS( version=self.tx_version or 2, - locktime=self.locktime or 0, + locktime=locktime, vin=[inp.vin for inp in self.inputs], vout=[out.vout for out in self.outputs], ) @@ -727,6 +891,9 @@ def write_to(self, stream) -> int: r += ser_string(stream, b"\x04") r += ser_string(stream, compact.to_bytes(len(self.inputs))) r += ser_string(stream, b"\x05") + if self.tx_modifiable_flags is not None: + r += ser_string(stream, b"\x06") + r += ser_string(stream, bytes([self.tx_modifiable_flags])) r += ser_string(stream, compact.to_bytes(len(self.outputs))) r += ser_string(stream, b"\xfb") r += ser_string(stream, self.version.to_bytes(4, "little")) @@ -772,72 +939,191 @@ def read_from(cls, stream, compress=CompressMode.KEEP_ALL): without storing them in memory and save the utxo internally for signing. This helps against out-of-memory errors. """ - tx = None - unknown = {} - version = None - # check magic if stream.read(len(cls.MAGIC)) != cls.MAGIC: raise PSBTError("Invalid PSBT magic") + + global_kvs = OrderedDict() while True: key = read_string(stream) - # separator - if len(key) == 0: + if len(key) == 0: # Separator break value = read_string(stream) - # tx - if key == b"\x00": - if tx is None: - tx = cls.TX_CLS.parse(value) - else: + if key in global_kvs: + raise PSBTError(f"Duplicated global key: {hexlify(key).decode()}") + global_kvs[key] = value + + # Determine PSBT version from PSBT_GLOBAL_VERSION (0xfb) + version = None + if b"\xfb" in global_kvs: + version_bytes = global_kvs[b"\xfb"] # Keep in global_kvs for PSBT init + parsed_version = int.from_bytes(version_bytes, "little") + if parsed_version == 0: + version = 0 + elif parsed_version == 2: + version = 2 + else: + raise PSBTError( + f"Unsupported PSBT_GLOBAL_VERSION value: {parsed_version}" + ) + + tx_for_v0 = None + + if version == 2: # PSBTv2 + if b"\x00" in global_kvs: # PSBT_GLOBAL_UNSIGNED_TX + raise PSBTError("PSBT_GLOBAL_UNSIGNED_TX is not allowed in PSBTv2") + # Pass all global key value pairs to unknown, parse_unknowns will sort them. + psbt = cls(tx=None, unknown=global_kvs, version=version) + psbt.parse_unknowns() # Processes 0xfb, 0x02-0x06, xpubs etc. + + # Validate that input/output counts were processed by parse_unknowns + if ( + not hasattr(psbt, "_raw_input_count_from_global") + or psbt._raw_input_count_from_global is None + ): + raise PSBTError( + "PSBTv2 missing or invalid PSBT_GLOBAL_INPUT_COUNT (0x04)" + ) + if ( + not hasattr(psbt, "_raw_output_count_from_global") + or psbt._raw_output_count_from_global is None + ): + raise PSBTError( + "PSBTv2 missing or invalid PSBT_GLOBAL_OUTPUT_COUNT (0x05)" + ) + else: # PSBTv0 (version is None) + if b"\x00" not in global_kvs: + raise PSBTError("PSBT_GLOBAL_UNSIGNED_TX (0x00) is required for PSBTv0") + + # Check for forbidden v2-only global keys in v0 context + v2_only_global_keys = {b"\x02", b"\x03", b"\x04", b"\x05", b"\x06"} + # 0xfb is allowed if its value was 0 (version = 0) + if b"\xfb" in global_kvs and version != 0: + raise PSBTError( + f"PSBT_GLOBAL_VERSION (0xfb) with value other than 0 is not allowed in PSBTv0 context" + ) + + for k_v2_only in v2_only_global_keys: + if k_v2_only in global_kvs: raise PSBTError( - "Failed to parse PSBT - duplicated transaction field" + f"Global key {hexlify(k_v2_only).decode()} is not allowed in PSBTv0 context" ) - elif key == b"\xfb": - version = int.from_bytes(value, "little") - else: - if key in unknown: - raise PSBTError("Duplicated key") - unknown[key] = value - - if tx and version == 2: - raise PSBTError("Global TX field is not allowed in PSBTv2") - psbt = cls(tx, unknown, version=version) - # input scopes - for i, vin in enumerate(psbt.tx.vin): - psbt.inputs[i] = cls.PSBTIN_CLS.read_from( - stream, compress=compress, vin=vin - ) - # output scopes - for i, vout in enumerate(psbt.tx.vout): - psbt.outputs[i] = cls.PSBTOUT_CLS.read_from( - stream, compress=compress, vout=vout - ) + + tx_bytes = global_kvs.pop(b"\x00") # Remove so it's not in unknown + tx_for_v0 = cls.TX_CLS.parse(tx_bytes) + # Remaining global_kvs (xpubs, proprietary, potentially 0xfb if it was 0) go to unknown + psbt = cls(tx=tx_for_v0, unknown=global_kvs, version=version) + psbt.parse_unknowns() # Processes xpubs and 0xfb (if it was 0) + + # Parse Input and Output Scopes + # For v0, psbt.inputs/outputs are already populated from tx_for_v0 via __init__ & parse_tx. + # For v2, psbt.inputs/outputs are lists of empty scopes, correctly sized by parse_unknowns. + + if version == 2: + num_inputs = psbt._raw_input_count_from_global + num_outputs = psbt._raw_output_count_from_global + + parsed_inputs = [] + for _ in range(num_inputs): + parsed_inputs.append( + cls.PSBTIN_CLS.read_from( + stream, + compress=compress, + vin=None, + version=version, + ) + ) + psbt.inputs = parsed_inputs + + parsed_outputs = [] + for _ in range(num_outputs): + parsed_outputs.append( + cls.PSBTOUT_CLS.read_from( + stream, + compress=compress, + vout=None, + version=version, + ) + ) + psbt.outputs = parsed_outputs + else: + temp_inputs = [] + for i in range(len(psbt.inputs)): + temp_inputs.append( + cls.PSBTIN_CLS.read_from( + stream, + compress=compress, + vin=psbt.inputs[i].vin, + version=version, + ) + ) + psbt.inputs = temp_inputs + + temp_outputs = [] + for i in range(len(psbt.outputs)): + temp_outputs.append( + cls.PSBTOUT_CLS.read_from( + stream, + compress=compress, + vout=psbt.outputs[i].vout, + version=version, + ) + ) + psbt.outputs = temp_outputs + return psbt def parse_unknowns(self): + # Handle PSBT_GLOBAL_VERSION first + if b"\xfb" in self.unknown: + self.unknown.pop(b"\xfb", None) + + if b"\x06" in self.unknown: + flags_bytes = self.unknown.pop(b"\x06") + if len(flags_bytes) != 1: + raise PSBTError("PSBT_GLOBAL_TX_MODIFIABLE must be 1 byte") + self.tx_modifiable_flags = flags_bytes[0] + for k in list(self.unknown): # xpub field if k[0] == 0x01: xpub = bip32.HDKey.parse(k[1:]) self.xpubs[xpub] = DerivationPath.parse(self.unknown.pop(k)) elif k == b"\x02": - self.tx_version = int.from_bytes(self.unknown.pop(k), "little") + if self.version == 2: + self.tx_version = int.from_bytes(self.unknown.pop(k), "little") elif k == b"\x03": - self.locktime = int.from_bytes(self.unknown.pop(k), "little") + if self.version == 2: + self.locktime = int.from_bytes(self.unknown.pop(k), "little") elif k == b"\x04": - if len(self.inputs) > 0: - raise PSBTError("Inputs already initialized") - self.inputs = [ - self.PSBTIN_CLS() - for _ in range(compact.from_bytes(self.unknown.pop(k))) - ] + if self.version == 2: + if ( + hasattr(self, "_raw_input_count_from_global") + and self._raw_input_count_from_global is not None + ): + self.unknown.pop(k, None) + continue + self._raw_input_count_from_global = compact.from_bytes( + self.unknown.pop(k) + ) + self.inputs = [ + self.PSBTIN_CLS() + for _ in range(self._raw_input_count_from_global) + ] elif k == b"\x05": - if len(self.outputs) > 0: - raise PSBTError("Outputs already initialized") - self.outputs = [ - self.PSBTOUT_CLS() - for _ in range(compact.from_bytes(self.unknown.pop(k))) - ] + if self.version == 2: + if ( + hasattr(self, "_raw_output_count_from_global") + and self._raw_output_count_from_global is not None + ): + self.unknown.pop(k, None) + continue + self._raw_output_count_from_global = compact.from_bytes( + self.unknown.pop(k) + ) + self.outputs = [ + self.PSBTOUT_CLS() + for _ in range(self._raw_output_count_from_global) + ] def sighash(self, i, sighash=SIGHASH.ALL, **kwargs): inp = self.inputs[i] @@ -1057,13 +1343,64 @@ def sign_with(self, root, sighash=SIGHASH.DEFAULT) -> int: # check if root itself is included in the script if sec in sc.data or pkh in sc.data: sig = root.sign(h) - # sig plus sighash flag inp.partial_sigs[rootpub] = sig.serialize() + bytes([inp_sighash]) counter += 1 + # Update tx_modifiable_flags after signature creation + if self.version == 2 and self.tx_modifiable_flags is not None: + if not (inp_sighash & SIGHASH.ANYONECANPAY): + self.tx_modifiable_flags &= ~TxModifiable.INPUTS + if (inp_sighash & 0x1F) != SIGHASH.NONE: + self.tx_modifiable_flags &= ~TxModifiable.OUTPUTS + if (inp_sighash & 0x1F) == SIGHASH.SINGLE: + self.tx_modifiable_flags |= TxModifiable.SIGHASH_SINGLE for prv, pub in derived_keypairs: sig = prv.sign(h) - # sig plus sighash flag inp.partial_sigs[pub] = sig.serialize() + bytes([inp_sighash]) counter += 1 + # Update tx_modifiable_flags after signature creation + if self.version == 2 and self.tx_modifiable_flags is not None: + if not (inp_sighash & SIGHASH.ANYONECANPAY): + self.tx_modifiable_flags &= ~TxModifiable.INPUTS + if (inp_sighash & 0x1F) != SIGHASH.NONE: + self.tx_modifiable_flags &= ~TxModifiable.OUTPUTS + if (inp_sighash & 0x1F) == SIGHASH.SINGLE: + self.tx_modifiable_flags |= TxModifiable.SIGHASH_SINGLE return counter + + def get_tx_modifiable(self): + return self.tx_modifiable_flags + + def set_tx_modifiable(self, flags): + if self.version != 2: + raise PSBTError("GLOBAL_TX_MODIFIABLE only supported in PSBTv2") + self.tx_modifiable_flags = flags + + def is_inputs_modifiable(self): + if self.version != 2 or self.tx_modifiable_flags is None: + return True + return bool(self.tx_modifiable_flags & TxModifiable.INPUTS) + + def is_outputs_modifiable(self): + if self.version != 2 or self.tx_modifiable_flags is None: + return True + return bool(self.tx_modifiable_flags & TxModifiable.OUTPUTS) + + def has_sighash_single(self): + if self.version != 2 or self.tx_modifiable_flags is None: + return False + return bool(self.tx_modifiable_flags & TxModifiable.SIGHASH_SINGLE) + + def add_input(self, input_scope): + if not self.is_inputs_modifiable(): + raise PSBTError("Inputs are not modifiable") + self.inputs.append(input_scope) + if self.version == 2: + self._raw_input_count_from_global = len(self.inputs) + + def add_output(self, output_scope): + if not self.is_outputs_modifiable(): + raise PSBTError("Outputs are not modifiable") + self.outputs.append(output_scope) + if self.version == 2: + self._raw_output_count_from_global = len(self.outputs) diff --git a/src/embit/psbtview.py b/src/embit/psbtview.py index 4bcd664..01b25cd 100644 --- a/src/embit/psbtview.py +++ b/src/embit/psbtview.py @@ -311,7 +311,9 @@ def input(self, i, compress=None): raise PSBTError("Invalid input index") vin = self.tx.vin(i) if self.tx else None self.seek_to_scope(i) - return self.PSBTIN_CLS.read_from(self.stream, vin=vin, compress=compress) + return self.PSBTIN_CLS.read_from( + self.stream, vin=vin, compress=compress, version=self.version + ) def output(self, i, compress=None): """Reads, parses and returns PSBT OutputScope #i""" @@ -321,7 +323,9 @@ def output(self, i, compress=None): raise PSBTError("Invalid output index") vout = self.tx.vout(i) if self.tx else None self.seek_to_scope(self.num_inputs + i) - return self.PSBTOUT_CLS.read_from(self.stream, vout=vout, compress=compress) + return self.PSBTOUT_CLS.read_from( + self.stream, vout=vout, compress=compress, version=self.version + ) # compress is not used here, but may be used by subclasses (liquid) def vin(self, i, compress=None): diff --git a/tests/tests/test_psbtV2.py b/tests/tests/test_psbtV2.py new file mode 100644 index 0000000..d1dcc07 --- /dev/null +++ b/tests/tests/test_psbtV2.py @@ -0,0 +1,231 @@ +""" +BIP 370 Test Vectors for PSBTv2 +https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki#user-content-Test_Vectors +""" + +import pytest +from binascii import unhexlify +from embit.psbt import PSBT, PSBTError + + +class TestPSBTVectors: + """Test vectors for PSBT parsing and validation""" + + def test_invalid_psbtv0_with_version_field(self): + """PSBTv0 but with PSBT_GLOBAL_VERSION set to 2 should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_tx_version(self): + """PSBTv0 but with PSBT_GLOBAL_TX_VERSION should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001020402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_fallback_locktime(self): + """PSBTv0 but with PSBT_GLOBAL_FALLBACK_LOCKTIME should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001030402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_input_count(self): + """PSBTv0 but with PSBT_GLOBAL_INPUT_COUNT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001040102000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_count(self): + """PSBTv0 but with PSBT_GLOBAL_OUTPUT_COUNT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001050102000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_tx_modifiable(self): + """PSBTv0 but with PSBT_GLOBAL_TX_MODIFIABLE should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001060100000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_previous_txid(self): + """PSBTv0 but with PSBT_IN_PREVIOUS_TXID should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc800220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_index(self): + """PSBTv0 but with PSBT_IN_OUTPUT_INDEX should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_sequence(self): + """PSBTv0 but with PSBT_IN_SEQUENCE should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27011004ffffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_required_time_locktime(self): + """PSBTv0 but with PSBT_IN_REQUIRED_TIME_LOCKTIME should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a270111048c8dc46200220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_required_height_locktime(self): + """PSBTv0 but with PSBT_IN_REQUIRED_HEIGHT_LOCKTIME should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a270112041027000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_amount(self): + """PSBTv0 but with PSBT_OUT_AMOUNT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f00000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv0_with_output_script(self): + """PSBTv0 but with PSBT_OUT_SCRIPT should be invalid""" + hex_data = "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000104160014a07dac8ab6ca942d379ed795f835ba71c9cc6885002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_input_count(self): + """PSBTv2 missing PSBT_GLOBAL_INPUT_COUNT should be invalid""" + hex_data = "70736274ff01020402000000010304000000000105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_missing_output_count(self): + """PSBTv2 missing PSBT_GLOBAL_OUTPUT_COUNT should be invalid""" + hex_data = "70736274ff01020402000000010304000000000104010101fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_invalid_time_locktime(self): + """PSBTv2 with PSBT_IN_REQUIRED_TIME_LOCKTIME less than 500000000 should be invalid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011104ff64cd1d00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + def test_invalid_psbtv2_invalid_height_locktime(self): + """PSBTv2 with PSBT_IN_REQUIRED_HEIGHT_LOCKTIME >= 500000000 should be invalid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f04000000000112040065cd1d00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + with pytest.raises(PSBTError): + PSBT.parse(unhexlify(hex_data)) + + # Valid PSBTs + def test_valid_psbtv2_minimal(self): + """1 input, 2 output PSBTv2, required fields only should be valid""" + hex_data = "70736274ff01020402000000010401010105010201fb040200000000010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + + def test_valid_psbtv2_updated(self): + """1 input, 2 output updated PSBTv2 should be valid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert len(psbt.inputs) == 1 + assert len(psbt.outputs) == 2 + + def test_valid_psbtv2_with_sequence(self): + """1 input, 2 output PSBTv2 with sequence should be valid""" + hex_data = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert psbt.inputs[0].sequence == 0xFFFFFFFE + + def test_valid_psbtv2_with_all_fields(self): + """PSBTv2 with all possible fields should be valid""" + hex_data = "70736274ff010204020000000103040000000001040101010501020106010701fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff0111048c8dc4620112041027000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.version == 2 + assert psbt.inputs[0].required_time_locktime == 1657048460 + assert psbt.inputs[0].required_height_locktime == 10000 + + +class TestPSBTLocktime: + """Test locktime determination algorithm for PSBTv2""" + + def test_locktime_no_locktimes_specified(self): + """No locktimes specified should return 0""" + hex_data = "70736274ff01020402000000010401010105010201fb040200000000010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 0 + + def test_locktime_fallback_zero(self): + """Fallback locktime of 0 should return 0""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f040100000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f0400000000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 0 + + def test_locktime_height_10000(self): + """Height locktime of 10000 should return 10000""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f0400000000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 10000 + + def test_locktime_max_height(self): + """Multiple height locktimes should return maximum""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f040000000001120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 10000 # max(10000, 9000) + + def test_locktime_height_and_time(self): + """1 input with Height and other with height and time locktimes should return max height value""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc46201120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 10000 + + def test_locktime_time_preferred_over_height(self): + """Time locktime should be preferred when both present""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000111048b8dc4620112041027000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc46201120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 10000 # Height takes priority over time + + def test_locktime_time_only(self): + """Time locktime only should return time value""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f04010000000111048b8dc46200010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc46201120428230000000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 1657048460 + + def test_locktime_max_time(self): + """Multiple time locktimes should return maximum""" + hex_data = "70736274ff0102040200000001030400000000010401020105010101fb040200000000010e200f758dbfbd4da7c16c8a3309c3c81e1100f561ea646db5b01752c485e1bdde9f010f040100000000010e203a1b3b3c837d6489ea7a31d8e6c7dd503c001bef3e06958e7574808d68ca78a5010f04000000000111048c8dc462000103084f9335770000000001041600140b1352cacd03cf6aa1b7f3c8d6388671b34a5e1100" + + psbt = PSBT.parse(unhexlify(hex_data)) + assert psbt.determine_locktime() == 1657048460 + + +if __name__ == "__main__": + pytest.main([__file__])