# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # Transaction Signing. Important. # import time, pytest, os, random, pdb, struct, base64, binascii, itertools, datetime from ckcc_protocol.protocol import CCProtocolPacker, CCProtoError from binascii import b2a_hex, a2b_hex from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput, PSBT_IN_REDEEM_SCRIPT from io import BytesIO from pprint import pprint from decimal import Decimal from base64 import b64encode, b64decode from base58 import encode_base58_checksum from helpers import B2A, fake_dest_addr, parse_change_back, addr_from_display_format from helpers import xfp2str, seconds2human_readable, hash160 from msg import verify_message from bip32 import BIP32Node from constants import ADDR_STYLES, ADDR_STYLES_SINGLE, SIGHASH_MAP, simulator_fixed_xfp from txn import * from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint from ckcc_protocol.constants import STXN_VISUALIZE, STXN_SIGNED from charcodes import KEY_QR, KEY_RIGHT, KEY_LEFT SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22) @pytest.mark.parametrize('finalize', [ False, True ]) def test_sign1(dev, finalize): in_psbt = a2b_hex(open('data/p2pkh-in-scriptsig.psbt', 'rb').read()) ll, sha = dev.upload_file(in_psbt) dev.send_recv(CCProtocolPacker.sign_transaction(ll, sha, finalize)) with pytest.raises(CCProtoError) as ee: while dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None) == None: pass #assert 'None of the keys' in str(ee) #assert 'require subpaths' in str(ee) assert 'PSBT does not contain any key path information' in str(ee) @pytest.mark.parametrize('fn', [ 'data/missing_ins.psbt', 'data/missing_txn.psbt', 'data/truncated.psbt', 'data/unknowns-ins.psbt', 'data/unknowns-ins.psbt', 'data/dup_keys.psbt', ]) def test_psbt_parse_fails(try_sign, fn): # just parse them with pytest.raises(CCProtoError) as ee: orig, result = try_sign(fn, accept=False) msg = ee.value.args[0] assert ('PSBT parse failed' in msg) or ('Invalid PSBT' in msg) @pytest.mark.parametrize('fn', [ 'data/2-of-2.psbt', 'data/filled_scriptsig.psbt', 'data/one-p2pkh-in.psbt', 'data/p2pkh+p2sh+outs.psbt', 'data/p2pkh-in-scriptsig.psbt', 'data/p2pkh-p2sh-p2wpkh.psbt', 'data/worked-1.psbt', 'data/worked-2.psbt', 'data/worked-unsigned.psbt', 'data/worked-4.psbt', 'data/worked-5.psbt', 'data/worked-combined.psbt', 'data/worked-7.psbt', ]) @pytest.mark.parametrize('accept', [True, False]) def test_psbt_parse_good(try_sign, fn, accept): # successful parses, but not signable # just parse them with pytest.raises(CCProtoError) as ee: orig, result = try_sign(fn, accept=accept) msg = ee.value.args[0] assert ('Missing UTXO' in msg) \ or ('None of the keys' in msg) \ or ('completely signed already' in msg) \ or ('PSBT does not contain any key path information' in msg) \ or ('require subpaths' in msg), msg # works, but annoying output def xxx_test_sign_truncated(dev): ll, sha = dev.upload_file(open('data/truncated.psbt', 'rb').read()) dev.send_recv(CCProtocolPacker.sign_transaction(ll, sha)) with pytest.raises(CCProtoError): done = None while done == None: time.sleep(0.050) done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None) @pytest.mark.parametrize('fn', [ 'data/2-of-2.psbt', 'data/filled_scriptsig.psbt', 'data/one-p2pkh-in.psbt', 'data/p2pkh+p2sh+outs.psbt', 'data/p2pkh-in-scriptsig.psbt', 'data/p2pkh-p2sh-p2wpkh.psbt', 'data/worked-1.psbt', 'data/worked-2.psbt', 'data/worked-unsigned.psbt', 'data/worked-4.psbt', 'data/worked-5.psbt', 'data/worked-combined.psbt', 'data/worked-7.psbt', ]) def test_psbt_proxy_parsing(fn, sim_execfile, sim_exec, src_root_dir, sim_root_dir): # unit test: parsing by the psbt proxy object sim_exec('import main; main.FILENAME = %r; ' % (f'{src_root_dir}/testing/'+fn)) rv = sim_execfile('devtest/unit_psbt.py') assert not rv, rv rb = f'{sim_root_dir}/readback.psbt' oo = BasicPSBT().parse(open(fn, 'rb').read()) rb = BasicPSBT().parse(open(rb, 'rb').read()) assert oo == rb @pytest.mark.unfinalized def test_speed_test(dev, fake_txn, start_sign, end_sign, press_select, press_cancel, sim_root_dir): # measure time to sign a larger txn # Mk4: expect # 20/250 => 15.5s (or 10.0 if seed is cached) # 200/500 => 96.3s num_in = 20 num_out = 250 psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=True) with open(f'{sim_root_dir}/debug/speed.psbt', 'wb') as f: f.write(psbt) dt = time.time() start_sign(psbt, finalize=False) tx_time = time.time() - dt press_select(timeout=None) dt = time.time() done = None while done == None: time.sleep(0.05) done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None) ready_time = time.time() - dt print(" Tx time: %.1f" % tx_time) print("Sign time: %.1f" % ready_time) press_cancel() if 0: # TODO: attempt to re-create the mega transaction: 5,569 inputs, one out # see # - how big woudl PSBT be? # - not a great test case because so slow. def test_mega_txn(fake_txn, start_sign, end_sign, dev): psbt = fake_txn(5569, 1, dev.master_xpub) open('debug/mega.psbt', 'wb').write(psbt) _, txn = try_sign(psbt, accept=True, finalize=True) open('debug/mega.txn', 'wb').write(txn) @pytest.mark.bitcoind @pytest.mark.veryslow @pytest.mark.parametrize('segwit', [True, False]) def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, start_sign, end_sign, dev, segwit, sim_root_dir): # try a bunch of different bigger sized txns # - important to test on real device, due to it's limited memory # - cmdline: "pytest test_sign.py -k test_io_size --dev --manual -s --durations=50" # - simulator can do 400/400 but takes long time # - offical target: 20 inputs, 250 outputs (see docs/limitations.md) # - Mk4: complete run on real hardware takes 1800.94 seconds = 30 minutes # - Historical: time on Mk3, v4.0.0 firmware: 13 minutes for ins/outs=20/250 # for this test you need to configure core `repcservertimeout` to something big # in bitcoin.conf `rpcservertimeout=2000` should do the trick use_regtest() num_in = 250 num_out = 2000 psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=segwit, outstyles=ADDR_STYLES) with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.write(psbt) start_sign(psbt, finalize=True) # on simulator, read screen try: cap_story = request.getfixturevalue('cap_story') time.sleep(.1) title, story = cap_story() assert 'OK TO SEND' in title except: cap_story = None signed = end_sign(accept=True, finalize=True) with open(f'{sim_root_dir}/debug/signed.txn', 'wb') as f: f.write(signed) decoded = decode_with_bitcoind(signed) #print("Bitcoin code says:", end=''); pprint(decoded) if cap_story: # check we are showing right addresses shown = set() hidden = set() for i in decoded['vout']: dest = i['scriptPubKey']['address'] val = i['value'] if dest in story: shown.add((val, dest)) assert str(val) in story else: hidden.add((val, dest)) # UI only shows 10 largest outputs if there are too many # - assuming no change outputs here MAX_VIZ = 10 if num_out <= MAX_VIZ: assert len(shown) == num_out assert not hidden else: assert 'which total' in story assert len(shown) == MAX_VIZ assert len(hidden) >= 1 assert len(shown) + len(hidden) == len(decoded['vout']) assert max(v for v,d in hidden) >= min(v for v,d in shown) @pytest.mark.bitcoind @pytest.mark.parametrize('num_ins', [ 2, 7, 15 ]) @pytest.mark.parametrize('segwit', [True, False]) def test_real_signing(fake_txn, use_regtest, try_sign, dev, num_ins, segwit, decode_with_bitcoind, sim_root_dir): # create a TXN using actual addresses that are correct for DUT xp = dev.master_xpub psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit) with open(f'{sim_root_dir}/debug/real-%d.psbt' % num_ins, 'wb') as f: f.write(psbt) _, txn = try_sign(psbt, accept=True, finalize=True) #print('Signed; ' + B2A(txn)) decoded = decode_with_bitcoind(txn) #pprint(decoded) assert len(decoded['vin']) == num_ins if segwit: assert all(x['txinwitness'] for x in decoded['vin']) @pytest.mark.unfinalized # iff we_finalize=F @pytest.mark.parametrize('we_finalize', [ False, True ]) @pytest.mark.parametrize('num_dests', [ 1, 10, 25 ]) @pytest.mark.bitcoind def test_vs_bitcoind(match_key, use_regtest, check_against_bitcoind, bitcoind, start_sign, end_sign, we_finalize, num_dests, sim_root_dir): wallet_xfp = match_key use_regtest() bal = bitcoind.supply_wallet.getbalance() assert bal > 0, "need some play money; drink from a faucet" amt = round((bal/4)/num_dests, 6) args = {} for no in range(num_dests): dest = bitcoind.supply_wallet.getrawchangeaddress() assert dest.startswith('bcrt1'), dest args[dest] = amt if 0: # old approach: fundraw + convert to psbt # working with hex strings here txn = bitcoind.supply_wallet.createrawtransaction([], args) assert txn[0:2] == '02' #print(txn) resp = bitcoind.supply_wallet.fundrawtransaction(txn) txn2 = resp['hex'] fee = resp['fee'] chg_pos = resp['changepos'] #print(txn2) print("Sending %.8f XTN to %s (Change back in position: %d)" % (amt, dest, chg_pos)) psbt = b64decode(bitcoind.supply_wallet.converttopsbt(txn2, True)) # use walletcreatefundedpsbt # - updated/validated against 0.17.1 resp = bitcoind.supply_wallet.walletcreatefundedpsbt([], args, 0, { 'subtractFeeFromOutputs': list(range(num_dests)), 'feeRate': 0.00001500}, True) if 0: # OMFG all this to reconstruct the rpc command! import json, decimal def EncodeDecimal(o): if isinstance(o, decimal.Decimal): return float(round(o, 8)) raise TypeError print('walletcreatefundedpsbt "[]" "[%s]" 0 {} true' % json.dumps(args, default=EncodeDecimal).replace('"', '\\"')) psbt = b64decode(resp['psbt']) fee = resp['fee'] chg_pos = resp['changepos'] with open(f'{sim_root_dir}/debug/vs.psbt', 'wb') as f: f.write(psbt) # check some basics mine = BasicPSBT().parse(psbt) for i in mine.inputs: got_xfp, = struct.unpack_from('I', list(i.bip32_paths.values())[0]) #assert hex(got_xfp) == hex(wallet_xfp), "wrong HD master key fingerprint" # see if hex(got_xfp) != hex(wallet_xfp): raise pytest.xfail("wrong HD master key fingerprint") start_sign(psbt, finalize=we_finalize) if mine.txn: # pull out included txn txn2 = B2A(mine.txn) # verify against how bitcoind reads it check_against_bitcoind(txn2, fee) else: assert mine.version == 2 signed = end_sign(accept=True, finalize=we_finalize) with open(f'{sim_root_dir}/debug/vs-signed.psbt', 'wb') as f: f.write(signed) if not we_finalize: b4 = BasicPSBT().parse(psbt) aft = BasicPSBT().parse(signed) assert b4 != aft, "signing didn't change anything?" with open(f'{sim_root_dir}/debug/signed.psbt', 'wb') as f: f.write(signed) resp = bitcoind.supply_wallet.finalizepsbt(str(b64encode(signed), 'ascii'), True) #combined_psbt = b64decode(resp['psbt']) #open('debug/combined.psbt', 'wb').write(combined_psbt) assert resp['complete'] == True, "bitcoind wasn't able to finalize it" network = a2b_hex(resp['hex']) # assert resp['complete'] print("Final txn: %r" % network) with open(f'{sim_root_dir}/debug/finalized-by-btcd.txn', 'wb') as f: f.write(network) # try to send it txed = bitcoind.supply_wallet.sendrawtransaction(B2A(network)) print("Final txn hash: %r" % txed) else: assert signed[0:4] != b'psbt', "expecting raw bitcoin txn" #print("Final txn: %s" % B2A(signed)) with open(f'{sim_root_dir}/debug/finalized-by-cc.txn', 'wb') as f: f.write(signed) txed = bitcoind.supply_wallet.sendrawtransaction(B2A(signed)) print("Final txn hash: %r" % txed) def test_sign_example(set_master_key, sim_execfile, start_sign, end_sign): # use the private key given in BIP 174 and do similar signing # as the examples. # TODO fix this # - doesn't work anymore, because we won't sign a multisig we don't know the wallet details for raise pytest.skip('needs rework') exk = 'tprv8ZgxMBicQKsPd9TeAdPADNnSyH9SSUUbTVeFszDE23Ki6TBB5nCefAdHkK8Fm3qMQR6sHwA56zqRmKmxnHk37JkiFzvncDqoKmPWubu7hDF' set_master_key(exk) mk = BIP32Node.from_wallet_key(exk) psbt = a2b_hex(open('data/worked-unsigned.psbt', 'rb').read()) start_sign(psbt) signed = end_sign(True) aft = BasicPSBT().parse(signed) expect = BasicPSBT().parse(open('data/worked-combined.psbt', 'rb').read()) assert aft == expect #assert 'require subpaths to be spec' in str(ee) @pytest.mark.bitcoind @pytest.mark.unfinalized def test_sign_p2sh_p2wpkh(match_key, use_regtest, start_sign, end_sign, bitcoind, sim_root_dir): # Check we can finalize p2sh_p2wpkh inputs right. # TODO fix this # - doesn't work anymore, because we won't sign a multisig we don't know the wallet details for raise pytest.skip('needs rework') wallet_xfp = match_key fn = 'data/p2sh_p2wpkh.psbt' psbt = open(fn, 'rb').read() start_sign(psbt, finalize=True) signed = end_sign(accept=True) #signed = end_sign(None) with open(f'{sim_root_dir}/debug/p2sh-signed.psbt', 'wb') as f: f.write(signed) #print('my finalization: ' + B2A(signed)) start_sign(psbt, finalize=False) signed_psbt = end_sign(accept=True) # use bitcoind to combine with open(f'{sim_root_dir}/debug/signed.psbt', 'wb') as f: f.write(signed_psbt) resp = bitcoind.rpc.finalizepsbt(str(b64encode(signed_psbt), 'ascii'), True) assert resp['complete'] == True, "bitcoind wasn't able to finalize it" network = a2b_hex(resp['hex']) #print('his finalization: ' + B2A(network)) assert network == signed @pytest.mark.bitcoind @pytest.mark.unfinalized def test_sign_p2sh_example(set_master_key, use_regtest, sim_execfile, start_sign, end_sign, decode_psbt_with_bitcoind, offer_ms_import, press_select, clear_ms, sim_root_dir): # Use the private key given in BIP 174 and do similar signing # as the examples. # PROBLEM: we can't handle this, since we don't allow same cosigner key to be used # more than once and that check happens after we decide we can sign an input, and yet # no way to provide the right set of keys needed since 4 in total, etc, etc. # - code below nearly works tho raise pytest.skip('difficult example') # expect xfp=4F6A0CD9 exk = 'tprv8ZgxMBicQKsPd9TeAdPADNnSyH9SSUUbTVeFszDE23Ki6TBB5nCefAdHkK8Fm3qMQR6sHwA56zqRmKmxnHk37JkiFzvncDqoKmPWubu7hDF' set_master_key(exk) # Peeked at PSBT to know the full, deep hardened path we'll need. # in1: 0'/0'/0' and 0'/0'/1' # in2: 0'/0'/3' and 0'/0'/2' config = "name: p2sh-example\npolicy: 2 of 2\n\n" n1 = BIP32Node.from_hwif(exk).subkey_for_path("0'/0'").hwif() n2 = BIP32Node.from_hwif(exk).subkey_for_path("0'/0'").hwif() xfp = '4F6A0CD9' config += f'{xfp}: {n1}\n{xfp}: {n2}\n' clear_ms() offer_ms_import(config) time.sleep(.1) press_select() psbt = a2b_hex(open('data/worked-unsigned.psbt', 'rb').read()) # PROBLEM: revised BIP-174 has p2sh multisig cases which we don't support yet. # - it has two signatures from same key on same input # - that's a rare case and not worth supporting in the firmware # - but we can do it in two passes # - the MS wallet is also hard, since dup xfp (same actual key) ... altho can # provide different subkeys start_sign(psbt) part_signed = end_sign(True) with open(f'{sim_root_dir}/debug/ex-signed-part.psbt', 'wb') as f: f.write(part_signed) b4 = BasicPSBT().parse(psbt) aft = BasicPSBT().parse(part_signed) assert b4 != aft, "(partial) signing didn't change anything?" # NOTE: cannot handle combining multisig txn yet, so cannot finalize on-device start_sign(part_signed, finalize=False) signed = end_sign(True, finalize=False) with open(f'{sim_root_dir}/debug/ex-signed.psbt', 'wb') as f: f.write(signed) aft2 = BasicPSBT().parse(signed) decode = decode_psbt_with_bitcoind(signed) pprint(decode) mx_expect = BasicPSBT().parse(a2b_hex(open('data/worked-combined.psbt', 'rb').read())) assert aft2 == mx_expect expect = a2b_hex(open('data/worked-combined.psbt', 'rb').read()) decode_ex = decode_psbt_with_bitcoind(expect) # NOTE: because we are using RFC6979, the exact bytes of the signatures should match for i in range(2): assert decode['inputs'][i]['partial_signatures'] == \ decode_ex['inputs'][i]['partial_signatures'] if 0: import json, decimal def EncodeDecimal(o): if isinstance(o, decimal.Decimal): return float(round(o, 8)) raise TypeError json.dump(decode, open('debug/core-decode.json', 'wt'), indent=2, default=EncodeDecimal) @pytest.mark.bitcoind def test_change_case(start_sign, use_regtest, end_sign, check_against_bitcoind, cap_story, sim_root_dir): # is change shown/hidden at right times. no fraud checks # NOTE: out#1 is change: chg_addr = 'mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo' psbt = open('data/example-change.psbt', 'rb').read() b4 = BasicPSBT().parse(psbt) b4.convert_witness_utxo_to_utxo(0) psbt = b4.as_bytes() start_sign(psbt) time.sleep(.1) _, story = cap_story() split_sory = story.split("\n\n")[3].split("\n") assert split_sory[0] == "Change back:" assert chg_addr == addr_from_display_format(split_sory[-1]) check_against_bitcoind(B2A(b4.txn), Decimal('0.00000294'), change_outs=[1,]) signed = end_sign(True) with open(f'{sim_root_dir}/debug/chg-signed.psbt', 'wb') as f: f.write(signed) # modify it: remove bip32 path b4.outputs[1].bip32_paths = {} with BytesIO() as fd: b4.serialize(fd) mod_psbt = fd.getvalue() start_sign(mod_psbt) time.sleep(.1) _, story = cap_story() # no change expected (they are outputs) assert 'Change back' not in story check_against_bitcoind(B2A(b4.txn), Decimal('0.00000294'), change_outs=[]) signed2 = end_sign(True) with open(f'{sim_root_dir}/debug/chg-signed2.psbt', 'wb') as f: f.write(signed) aft = BasicPSBT().parse(signed) aft2 = BasicPSBT().parse(signed2) assert aft.txn == aft2.txn @pytest.mark.parametrize('case', [ 1, 2]) @pytest.mark.bitcoind def test_change_fraud_path(start_sign, use_regtest, end_sign, case, check_against_bitcoind, cap_story, sim_root_dir): # fraud: BIP-32 path of output doesn't lead to pubkey indicated # NOTE: out#1 is change: chg_addr = 'mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo' psbt = open('data/example-change.psbt', 'rb').read() b4 = BasicPSBT().parse(psbt) b4.convert_witness_utxo_to_utxo(0) (pubkey, path), = b4.outputs[1].bip32_paths.items() skp = bytearray(b4.outputs[1].bip32_paths[pubkey]) if case == 1: # change subkey skp[-2] ^= 0x01 elif case == 2: # change xfp skp[0] ^= 0x01 b4.outputs[1].bip32_paths[pubkey] = bytes(skp) with BytesIO() as fd: b4.serialize(fd) mod_psbt = fd.getvalue() with open(f'{sim_root_dir}/debug/mod-%d.psbt' % case, 'wb') as f: f.write(mod_psbt) if case == 1: start_sign(mod_psbt) with pytest.raises(CCProtoError) as ee: signed = end_sign(True) assert 'BIP-32 path' in str(ee) elif case == 2: # will not consider it a change output, but not an error either start_sign(mod_psbt) check_against_bitcoind(B2A(b4.txn), Decimal('0.00000294'), change_outs=[]) time.sleep(.1) _, story = cap_story() assert chg_addr == addr_from_display_format(story.split("\n\n")[3].split("\n")[-1]) assert 'Change back:' not in story end_sign(True) @pytest.mark.bitcoind def test_change_fraud_addr(start_sign, end_sign, use_regtest, check_against_bitcoind, cap_story, sim_root_dir): # fraud: BIP-32 path of output doesn't match TXO address # NOTE: out#1 is change: #chg_addr = 'mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo' psbt = open('data/example-change.psbt', 'rb').read() b4 = BasicPSBT().parse(psbt) b4.convert_witness_utxo_to_utxo(0) # tweak output addr to garbage t = CTransaction() t.deserialize(BytesIO(b4.txn)) chg = t.vout[1] # tx.CTxOut b = bytearray(chg.scriptPubKey) b[-5] ^= 0x55 chg.scriptPubKey = bytes(b) b4.txn = t.serialize_with_witness() with BytesIO() as fd: b4.serialize(fd) mod_psbt = fd.getvalue() with open(f'{sim_root_dir}/debug/mod-addr.psbt', 'wb') as f: f.write(mod_psbt) start_sign(mod_psbt) with pytest.raises(CCProtoError) as ee: signed = end_sign(True) assert 'Change output is fraud' in str(ee) @pytest.mark.parametrize('case', ['p2sh-p2wpkh', 'p2wpkh', 'p2sh', 'p2sh-p2pkh']) @pytest.mark.bitcoind def test_change_p2sh_p2wpkh(start_sign, end_sign, check_against_bitcoind, use_regtest, cap_story, case, sim_root_dir): # not fraud: output address encoded in various equiv forms use_regtest() # NOTE: out#1 is change: #chg_addr = 'mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo' with open('data/example-change.psbt', 'rb') as f: psbt = f.read() b4 = BasicPSBT().parse(psbt) b4.convert_witness_utxo_to_utxo(0) t = CTransaction() t.deserialize(BytesIO(b4.txn)) scpk = t.vout[1].scriptPubKey assert scpk[:3] == bytes.fromhex("76a914") # OP_DUP OP_HASH160 OP_PUSH20 assert scpk[-2:] == bytes.fromhex("88ac") # OP_EQUALVERIFY OP_CHECKSIG pkh = scpk[3:-2] if case == 'p2wpkh': t.vout[1].scriptPubKey = bytes([0, 20]) + pkh from bech32 import encode expect_addr = encode('bcrt', 0, pkh) elif case == 'p2sh-p2wpkh': redeem_scr = bytes([0, 20]) + pkh h160_redeem_scr = hash160(redeem_scr) spk = bytes([0xa9, 0x14]) + h160_redeem_scr + bytes([0x87]) b4.outputs[1].redeem_script = redeem_scr t.vout[1].scriptPubKey = spk expect_addr = encode_base58_checksum(b"\xc4" + h160_redeem_scr) elif case == 'p2sh-p2pkh': # not supported redeem_scr = scpk h160_redeem_scr = hash160(redeem_scr) spk = bytes([0xa9, 0x14]) + h160_redeem_scr + bytes([0x87]) b4.outputs[1].redeem_script = redeem_scr t.vout[1].scriptPubKey = spk expect_addr = encode_base58_checksum(b"\xc4" + h160_redeem_scr) elif case == 'p2sh': # plain wrong for p2sh-p2wpkh (check for case == 'p2sh-p2wpkh' for correct) # scriptPubKey for p2sh-p2wpkh uses hash of the script not hash of pubkey # also check 'test_wrong_p2sh_p2wpkh' below spk = bytes([0xa9, 0x14]) + pkh + bytes([0x87]) b4.outputs[1].redeem_script = bytes([0, 20]) + pkh t.vout[1].scriptPubKey = spk expect_addr = encode_base58_checksum(b"\xc4" + scpk) b4.txn = t.serialize_with_witness() with BytesIO() as fd: b4.serialize(fd) mod_psbt = fd.getvalue() with open(f'{sim_root_dir}/debug/mod-%s.psbt' % case, 'wb') as f: f.write(mod_psbt) start_sign(mod_psbt) time.sleep(.1) _, story = cap_story() if case in ["p2sh", "p2sh-p2pkh"]: assert "Output#1: Change output is fraudulent" == story return check_against_bitcoind(B2A(b4.txn), Decimal('0.00000294'), change_outs=[1,], dests=[(1, expect_addr)]) split_sory = story.split("\n\n")[3].split("\n") assert split_sory[0] == "Change back:" assert expect_addr == addr_from_display_format(split_sory[-1]) assert parse_change_back(story) == (Decimal('1.09997082'), [expect_addr]) end_sign(True) def test_wrong_p2sh_p2wpkh(bitcoind, start_sign, end_sign, bitcoind_d_sim_watch, cap_story): sim = bitcoind_d_sim_watch sim_addr = sim.getnewaddress("", "bech32") bitcoind.supply_wallet.sendtoaddress(sim_addr, 2) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) utxos = sim.listunspent() assert len(utxos) == 1 conso_addr = sim.getnewaddress("", "legacy") psbt_resp = sim.walletcreatefundedpsbt([], [{conso_addr: 1}], 0, {"fee_rate": 2, "change_type": "bech32"}) psbt = psbt_resp.get("psbt") b4 = BasicPSBT().parse(base64.b64decode(psbt)) t = CTransaction() t.deserialize(BytesIO(b4.txn)) if t.vout[0].scriptPubKey[:2] == b"\x00\x14": target_out_idx = 1 else: target_out_idx = 0 # looking for p2pkh output scpk = t.vout[target_out_idx].scriptPubKey assert scpk[:3] == bytes.fromhex("76a914") # OP_DUP OP_HASH160 OP_PUSH20 assert scpk[-2:] == bytes.fromhex("88ac") # OP_EQUALVERIFY OP_CHECKSIG pkh = scpk[3:-2] # below is wrong - but that is the point of this test spk = bytes([0xa9, 0x14]) + pkh + bytes([0x87]) b4.outputs[target_out_idx].redeem_script = bytes([0, 20]) + bytes(pkh) t.vout[target_out_idx].scriptPubKey = spk b4.txn = t.serialize_with_witness() with BytesIO() as fd: b4.serialize(fd) mod_psbt = fd.getvalue() start_sign(mod_psbt) try: fin = end_sign(True) except Exception as e: assert "Change output is fraudulent" in e.args[0] # this is the correct ending return # for people with un-patched psbt.py (proof) res = sim.finalizepsbt(base64.b64encode(fin).decode()) # sure core allows you to send your money wherever you please assert res["complete"] assert sim.testmempoolaccept([res['hex']])[0]["allowed"] is True tx_id = sim.sendrawtransaction(res['hex']) assert isinstance(tx_id, str) and len(tx_id) == 64 bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) utxos = sim.listunspent() # but now not even core can spot the utxo as ours, so money send to limbo # would need to find a script that hash160 == hash160(pubkey) assert len(utxos) == 2 def test_sign_multisig_partial_fail(start_sign, end_sign): # file from AChow, via slack: a partially signed multisig setup (which we can't handle) #fn = 'data/multisig-single.psbt' fn = 'data/multisig-single-unsigned.psbt' from base64 import b64decode psbt = b64decode(open(fn, 'rb').read()) with pytest.raises(CCProtoError) as ee: start_sign(psbt, finalize=True) signed = end_sign(accept=True) assert 'None of the keys involved' in str(ee) @pytest.mark.unfinalized def test_sign_wutxo(start_sign, set_seed_words, end_sign, cap_story, sim_exec, sim_execfile, sim_root_dir): # Example from SomberNight, normalized to use a full UTXO so this test still # reaches the fee display path after legacy witness-only UTXOs are rejected. set_seed_words('fault lava rice chest uncle exclude power tornado catalog stool' ' swear rival sun aspect oyster deer pepper exchange scrap toward' ' mix second world shaft') snight = BasicPSBT().parse(a2b_hex(open('data/snight-example.psbt', 'rb').read()[:-1])) snight.convert_witness_utxo_to_utxo(0) in_psbt = snight.as_bytes() for fin in (False, True): start_sign(in_psbt, finalize=fin) time.sleep(.1) _, story = cap_story() #print(story) assert 'Network fee 0.00000500 XTN' in story # check we understood it right ex = dict( had_witness=False, num_inputs=1, num_outputs=1, sw_inputs=[None], miner_fee=500, warnings_expected=0, lock_time=1442308, total_value_out=99500, total_value_in=100000) rv= sim_exec('import main; main.EXPECT = %r; ' % ex) if rv: pytest.fail(rv) rv = sim_execfile('devtest/check_decode.py') if rv: pytest.fail(rv) signed = end_sign(True, finalize=fin) with open(f'{sim_root_dir}/debug/sn-signed.'+ ('txn' if fin else 'psbt'), 'wt') as f: f.write(B2A(signed)) @pytest.mark.parametrize('fee_max', [ 10, 25, 50]) @pytest.mark.parametrize('under', [ False, True]) def test_network_fee_amts(fee_max, under, fake_txn, try_sign, start_sign, dev, settings_set, sim_exec, cap_story, sim_root_dir): settings_set('fee_limit', fee_max) # creat a txn with single 1BTC input, and one output, equal to 1BTC-fee target = (fee_max - 2) if under else fee_max outval = int(1E8 / ((target/100.) + 1.)) psbt = fake_txn(1, 1, dev.master_xpub, fee=None, outvals=[outval]) with open(f'{sim_root_dir}/debug/fee.psbt', 'wb') as f: f.write(psbt) if not under: with pytest.raises(CCProtoError) as ee: try_sign(psbt, False) msg = ee.value.args[0] assert 'Network fee bigger than' in msg assert ('than %d%% of total' % target) in msg else: start_sign(psbt, False) time.sleep(.1) _, story = cap_story() assert 'warning below' in story assert 'Big Fee' in story assert 'more than 5% of total' in story settings_set('fee_limit', 10) def test_network_fee_unlimited(fake_txn, start_sign, end_sign, dev, settings_set, cap_story, sim_root_dir): settings_set('fee_limit', -1) # creat a txn with single 1BTC input, and tiny one output; the rest is fee outval = 100 psbt = fake_txn(1, 1, dev.master_xpub, fee=None, outvals=[outval]) with open(f'{sim_root_dir}/debug/fee-un.psbt', 'wb') as f: f.write(psbt) # should be able to sign, but get warning start_sign(psbt, False) time.sleep(.1) _, story = cap_story() #print(story) assert 'warning below' in story assert 'Big Fee' in story assert 'more than 5% of total' in story settings_set('fee_limit', 10) @pytest.mark.parametrize('num_outs', [ 2, 7, 15 ]) @pytest.mark.parametrize('act_outs', [ 2, 1, -1]) @pytest.mark.parametrize('segwit', [True, False]) @pytest.mark.parametrize('add_xpub', [True, False]) @pytest.mark.parametrize('out_style', ADDR_STYLES_SINGLE) @pytest.mark.parametrize('visualized', [0, STXN_VISUALIZE, STXN_VISUALIZE|STXN_SIGNED]) def test_change_outs(fake_txn, start_sign, end_sign, cap_story, dev, num_outs, master_xpub, act_outs, segwit, out_style, visualized, add_xpub, sim_root_dir): # create a TXN which has change outputs, which shouldn't be shown to user, and also not fail. xp = dev.master_xpub num_ins = 3 couts = num_outs if act_outs == -1 else num_ins-act_outs psbt = fake_txn(num_ins, num_outs, xp, segwit_in=segwit, outstyles=[out_style], change_outputs=range(couts), add_xpub=add_xpub) with open(f'{sim_root_dir}/debug/change.psbt', 'wb') as f: f.write(psbt) # should be able to sign, but get warning if not visualized: start_sign(psbt, False) time.sleep(.1) title, story = cap_story() print(repr(story)) assert title == "OK TO SEND?" else: # use new feature to have Coldcard return the 'visualization' of transaction start_sign(psbt, False, stxn_flags=visualized) story = end_sign(accept=None, expect_txn=False) story = story.decode('ascii') if (visualized & STXN_SIGNED): # last line should be signature, using 'm' over the rest assert story[-1] == '\n' last_nl = story[:-1].rindex('\n') msg, sig = story[0:last_nl+1], story[last_nl:] wallet = BIP32Node.from_wallet_key(master_xpub) assert verify_message(wallet.address(), sig, message=msg) is True story = msg assert 'Network fee' in story if couts < num_outs: assert '- to address -' in story else: assert 'Consolidating' in story if couts == 1: assert "- to address -" in story else: assert "- to addresses -" in story val, addrs = parse_change_back(story) assert val > 0 # hard to calc here assert len(addrs) == couts if out_style == 'p2pkh': assert all((i[0] in 'mn') for i in addrs) elif out_style == 'p2wpkh': assert set(i[0:4] for i in addrs) == {'tb1q'} elif out_style == 'p2wpkh-p2sh': assert set(i[0] for i in addrs) == {'2'} def KEEP_test_random_psbt(try_sign, sim_exec, fname="data/ .psbt"): # allow almost any PSBT to run on simulator, at least up until wrong pubkeys detected # - detects expected XFP and changes to match # - good for debug of random psbt oo = BasicPSBT().parse(open(fname, 'rb').read()) paths = [] for i in oo.inputs: paths.extend(i.bip32_paths.values()) used = set(i[0:4] for i in paths) assert len(used) == 1, "multiple key fingerprints in inputs, can only handle 1" need_xfp, = struct.unpack(" 0, "need some play money; drink from a faucet" amt = round((bal/4)/num_dests, 6) args = {} for no in range(num_dests): dest = bitcoind.supply_wallet.getrawchangeaddress() assert dest.startswith('bcrt1q'), dest args[dest] = amt # use walletcreatefundedpsbt # - updated/validated against 0.17.1 resp = bitcoind.supply_wallet.walletcreatefundedpsbt([], args, 0, { 'subtractFeeFromOutputs': list(range(num_dests)), 'feeRate': 0.00001500}, True) psbt = b64decode(resp['psbt']) fee = resp['fee'] chg_pos = resp['changepos'] with open(f'{sim_root_dir}/debug/vs.psbt', 'wb') as f: f.write(psbt) # check some basics mine = BasicPSBT().parse(psbt) for i in mine.inputs: got_xfp, = struct.unpack_from('I', list(i.bip32_paths.values())[0]) #assert hex(got_xfp) == hex(wallet_xfp), "wrong HD master key fingerprint" # see if hex(got_xfp) != hex(wallet_xfp): raise pytest.xfail("wrong HD master key fingerprint") start_sign(psbt, finalize=True) if mine.txn: # pull out included txn (only available in PSBTv0) txn2 = B2A(mine.txn) # verify against how bitcoind reads it check_against_bitcoind(txn2, fee) else: assert mine.version == 2 signed_final = end_sign(accept=True, finalize=True) assert signed_final[0:4] != b'psbt', "expecting raw bitcoin txn" with open(f'{sim_root_dir}/debug/finalized-by-ckcc.txn', 'wt') as f: f.write(B2A(signed_final)) # Sign again, but don't finalize it. start_sign(psbt, finalize=False) signed = end_sign(accept=True) with open(f'{sim_root_dir}/debug/vs-signed-unfin.psbt', 'wb') as f: f.write(signed) # Use bitcoind to finalize it this time. resp = bitcoind.supply_wallet.finalizepsbt(str(b64encode(signed), 'ascii'), True) assert resp['complete'] == True, "bitcoind wasn't able to finalize it" network = a2b_hex(resp['hex']) # assert resp['complete'] #print("Final txn: %r" % network) with open(f'{sim_root_dir}/debug/finalized-by-btcd.txn', 'wt') as f: f.write(B2A(network)) assert network == signed_final, "Finalized differently" # try to send it txed = bitcoind.supply_wallet.sendrawtransaction(B2A(network)) print("Final txn hash: %r" % txed) # Correct change path is: (m=4369050F)/44'/1'/0'/1/5 @pytest.mark.parametrize('try_path,expect', [ ("44'/1'/0'/1/40000", 'last component beyond'), ("44'/1'/0'/1/405", 'last component beyond'), ("44'/1'/0'/1'/5", 'hardening'), ("44'/1'/0'/1/5'", 'hardening'), ("44'/1/0'/1/5'", 'hardening'), ("45'/1'/0'/1/5", 'diff path prefix'), ("44'/2'/0'/1/5", 'diff path prefix'), ("44'/1'/1'/1/5", 'diff path prefix'), ("44'/1'/0'/3000/5", '2nd last component'), ("44'/1'/0'/3/5", '2nd last component'), ]) def test_change_troublesome(dev, start_sign, cap_story, try_path, expect, sim_root_dir): # NOTE: out#1 is change: # addr = 'mvBGHpVtTyjmcfSsy6f715nbTGvwgbgbwo' # path = (m=4369050F)/44'/1'/0'/1/5 # pubkey = 03c80814536f8e801859fc7c2e5129895b261153f519d4f3418ffb322884a7d7e1 if dev.master_fingerprint != 0x4369050f: # file relies on XFP=0F056943 value raise pytest.skip('simulator only') psbt = open('data/example-change.psbt', 'rb').read() b4 = BasicPSBT().parse(psbt) b4.convert_witness_utxo_to_utxo(0) pubkey = a2b_hex('03c80814536f8e801859fc7c2e5129895b261153f519d4f3418ffb322884a7d7e1') path = [int(p) if ("'" not in p) else 0x80000000+int(p[:-1]) for p in try_path.split('/')] bin_path = b4.outputs[1].bip32_paths[pubkey][0:4] \ + b''.join(struct.pack(' 2 spendables[0][1].nValue += amt psbt3, raw = spend_outputs(psbt, txn, tweaker=value_tweak) with open(f'{sim_root_dir}/debug/spend_outs.psbt', 'wb') as f: f.write(raw) with pytest.raises(CCProtoError) as ee: orig, result = try_sign(raw, accept=True, finalize=True) assert 'but PSBT claims' in str(ee), ee @pytest.mark.parametrize('segwit', [False, True]) @pytest.mark.parametrize('num_ins', [1, 17]) def test_txid_calc(num_ins, fake_txn, try_sign, dev, segwit, decode_with_bitcoind, cap_story, txid_from_export_prompt, press_cancel): # verify correct txid for transactions is being calculated xp = dev.master_xpub psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit) _, txn = try_sign(psbt, accept=True, finalize=True, exit_export_loop=False) txid = txid_from_export_prompt() press_cancel() # exit QR press_cancel() # exit re-export loop if 1: t = CTransaction() t.deserialize(BytesIO(txn)) assert t.txid().hex() == txid if 1: # compare to bitcoin core decoded = decode_with_bitcoind(txn) pprint(decoded) assert len(decoded['vin']) == num_ins if segwit: assert all(x['txinwitness'] for x in decoded['vin']) assert decoded['txid'] == txid @pytest.mark.unfinalized # iff partial=1 @pytest.mark.reexport @pytest.mark.parametrize('encoding', ['binary', 'hex', 'base64']) @pytest.mark.parametrize('num_outs', [1,15]) @pytest.mark.parametrize('del_after', [1, 0]) @pytest.mark.parametrize('partial', [1, 0]) def test_sdcard_signing(encoding, num_outs, del_after, partial, try_sign_microsd, fake_txn, dev, settings_set, signing_artifacts_reexport): # exercise the txn encode/decode from sdcard xp = dev.master_xpub settings_set('del', del_after) def hack(psbt): if partial: # change first input to not be ours pk = list(psbt.inputs[0].bip32_paths.keys())[0] pp = psbt.inputs[0].bip32_paths[pk] psbt.inputs[0].bip32_paths[pk] = b'what' + pp[4:] psbt = fake_txn(2, num_outs, xp, segwit_in=True, psbt_hacker=hack) _, txn, txid = try_sign_microsd(psbt, finalize=not partial, encoding=encoding, del_after=del_after) _psbt, _txn = signing_artifacts_reexport("sd", tx_final=not partial, txid=txid, encoding=encoding, del_after=del_after) if partial: assert _psbt == txn else: assert _txn == txn @pytest.mark.unfinalized @pytest.mark.parametrize('num_ins', [2,3,8]) @pytest.mark.parametrize('num_outs', [1,2,8]) def test_payjoin_signing(num_ins, num_outs, fake_txn, try_sign, start_sign, end_sign, cap_story, sim_root_dir): # Try to simulate a PSBT that might be involved in a Payjoin (BIP-78 txn) def hack(psbt): # change an input to be "not ours" ... but with utxo details psbt.inputs[num_ins-1].bip32_paths.clear() psbt = fake_txn(num_ins, num_outs, segwit_in=True, psbt_hacker=hack) with open(f'{sim_root_dir}/debug/payjoin.psbt', 'wb') as f: f.write(psbt) start_sign(psbt, finalize=False) time.sleep(.1) _, story = cap_story() assert 'warning below' in story assert 'Limited Signing' in story assert 'because we do not know the key' in story assert ': %s' % (num_ins-1) in story end_sign(True, finalize=False) @pytest.mark.parametrize('segwit', [False, True]) def test_fully_unsigned(fake_txn, try_sign, segwit): # A PSBT which is unsigned but all inputs lack keypaths def hack(psbt): # change all inputs to be "not ours" ... but with utxo details for i in psbt.inputs: i.bip32_paths.clear() psbt = fake_txn(7, 2, segwit_in=segwit, psbt_hacker=hack) with pytest.raises(CCProtoError) as ee: orig, result = try_sign(psbt, accept=True) assert 'does not contain any key path information' in str(ee) @pytest.mark.parametrize('segwit', [False, True]) def test_wrong_xfp(fake_txn, try_sign, segwit): # A PSBT which is unsigned and doesn't involve our XFP value wrong_xfp = b'\x12\x34\x56\x78' def hack(psbt): # change all inputs to be "not ours" ... but with utxo details for i in psbt.inputs: for pubkey in i.bip32_paths: i.bip32_paths[pubkey] = wrong_xfp + i.bip32_paths[pubkey][4:] psbt = fake_txn(7, 2, segwit_in=segwit, psbt_hacker=hack) with pytest.raises(CCProtoError) as ee: orig, result = try_sign(psbt, accept=True) assert 'None of the keys' in str(ee) assert 'found 12345678' in str(ee) @pytest.mark.parametrize('segwit', [False, True]) def test_wrong_xfp_multi(fake_txn, try_sign, segwit, sim_root_dir): # A PSBT which is unsigned and doesn't involve our XFP value # - but multiple wrong XFP values wrongs = set() def hack(psbt): # change all inputs to be "not ours" ... but with utxo details for idx, i in enumerate(psbt.inputs): for pubkey in i.bip32_paths: here = struct.pack(' not now if len(op_return_data) > 80: assert "(1 warning below)" in story # looking for warning at the top assert "OP_RETURN > 80 bytes" in story else: assert "warning" not in story assert "OP_RETURN" in story assert "Multiple OP_RETURN outputs:" not in story # always just one - core restriction try: assert len(op_return_data) <= 160 try: expect = op_return_data.decode("ascii") except: # not ascii expect = op_return_data.hex() except: expect = binascii.hexlify(op_return_data).decode() expect = expect[:160] + "\n ⋯\n" + expect[-160:] assert expect in story tx = end_sign(accept=True, finalize=True).hex() # tx is final at this point and consensus valid # tx = cc.finalizepsbt(base64.b64encode(signed).decode())["hex"] res = cc.testmempoolaccept([tx])[0] if (bitcoind.version < 300000) and (len(op_return_data) > 80): # policy assert res["allowed"] is False assert res["reject-reason"] == "scriptpubkey" else: assert res["allowed"] is True tx_id = cc.sendrawtransaction(tx) assert isinstance(tx_id, str) and len(tx_id) == 64 def test_op_return_trailing_data_not_hidden(fake_txn, start_sign, cap_story): weird = b'\x6a\x00\x04hide' # OP_RETURN OP_0 def hack(psbt): t = CTransaction() t.deserialize(BytesIO(psbt.txn)) t.vout[0].scriptPubKey = weird psbt.txn = t.serialize_with_witness() psbt = fake_txn(1, 2, segwit_in=True, psbt_v2=False, psbt_hacker=hack) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert title == 'OK TO SEND?' flat = story.lower().replace(' ', '') assert 'null-data' not in flat assert weird.hex() in flat @pytest.mark.parametrize("unknowns", [ # tuples (unknown_global, unknown_ins, unknown_outs) ({b"x" * 16: b"y" * 16}, {b"q": b"p"}, {b"w" * 5: b"z" * 22}), ({b"x": b"y", b"cccccc": b"oooooooooooooooooooooooooo", b"a": b"a"}, {b"q" * 2: b"p" * 16}, {b"w" * 64: b"z"}), ({b"x" * 64: b"y"}, {b"q" * 45: b"p"}, {b"w": b"z" * 64}), ({b"x": b"y" * 64}, {b"q": b"p" * 64}, {b"w" * 16: b"z" * 64}), ({b"x" * 64: b"y" * 64}, {b"q" * 71: b"p" * 22}, {b"w" * 71: b"z" * 128, b"keyp": 32 * b"\x00"}), ({b"x" * 64: b"y" * 128}, {b"q" * 64: b"p" * 128}, {b"w" * 90: b"z" * 256}), ({b"x" * 32: b"y" * 256}, {b"q" * 32: b"p" * 256, b"f" * 15: 32 * b"\x01"}, {b"w": b"z"}), ]) def test_unknow_values_in_psbt(unknowns, dev, start_sign, end_sign, fake_txn, sim_root_dir): unknown_global, unknown_ins, unknown_outs = unknowns def hack(psbt): psbt.unknown = unknown_global for i in psbt.inputs: i.unknown = unknown_ins for o in psbt.outputs: o.unknown = unknown_outs psbt = fake_txn(5, 5, dev.master_xpub, segwit_in=True, psbt_hacker=hack) with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.write(psbt) psbt_o = BasicPSBT().parse(psbt) assert psbt_o.unknown == unknown_global for inp in psbt_o.inputs: assert inp.unknown == unknown_ins for out in psbt_o.outputs: assert out.unknown == unknown_outs start_sign(psbt) signed = end_sign() assert signed != psbt res = BasicPSBT().parse(signed) assert res.unknown == unknown_global for inp in res.inputs: assert inp.unknown == unknown_ins for out in res.outputs: assert out.unknown == unknown_outs def test_read_write_prop_attestation_keys(try_sign, fake_txn, sim_root_dir): from psbt import ser_prop_key, PSBT_PROP_CK_ID def attach_attest_to_outs(psbt): for idx, o in enumerate(psbt.outputs): key = ser_prop_key(PSBT_PROP_CK_ID, 0) value = b"fake attestation" o.proprietary[key] = value psbt = fake_txn(2, 2, psbt_hacker=attach_attest_to_outs) with open(f'{sim_root_dir}/debug/propkeys.psbt', 'wb') as f: f.write(psbt) orig, signed = try_sign(psbt) res = BasicPSBT().parse(signed) for o in res.outputs: assert len(o.proprietary) == 1 for key, val in o.proprietary.items(): assert key == b'\x08COINKITE\x00' # generated using external lib just to be safe assert val == b"fake attestation" def test_duplicate_unknow_values_in_psbt(dev, start_sign, end_sign, fake_txn): # duplicate keys for global unknowns def hack(psbt): psbt.unknown = [(b"xxx", 32 * b"\x00"), (b"xxx", 32 * b"\x01")] psbt = fake_txn(5, 5, dev.master_xpub, segwit_in=True, psbt_hacker=hack) start_sign(psbt) with pytest.raises(Exception): end_sign() # duplicate keys for input unknowns def hack(psbt): for i in psbt.inputs: i.unknown = [(b"xxx", 32 * b"\x00"), (b"xxx", 32 * b"\x01")] psbt = fake_txn(5, 5, dev.master_xpub, segwit_in=True, psbt_hacker=hack) start_sign(psbt) with pytest.raises(Exception): end_sign() # duplicate keys for output unknown def hack(psbt): for o in psbt.outputs: o.unknown = [(b"xxx", 32 * b"\x00"), (b"xxx", 32 * b"\x01")] psbt = fake_txn(5, 5, dev.master_xpub, segwit_in=True, psbt_hacker=hack) start_sign(psbt) with pytest.raises(Exception): end_sign() @pytest.fixture def _test_single_sig_sighash(cap_story, press_select, start_sign, end_sign, dev, bitcoind, bitcoind_d_dev_watch, settings_set, finalize_v2_v0_convert, pytestconfig, sim_root_dir): def doit(addr_fmt, sighash, num_inputs=2, num_outputs=2, consolidation=False, sh_checks=False, psbt_v2=None, tx_check=True): from decimal import Decimal, ROUND_DOWN if psbt_v2 is None: # anything passed directly to this function overrides # pytest flag --psbt2 - only care about pytest flag # if psbt_v2 is not specified (None) psbt_v2 = pytestconfig.getoption('psbt2') if dev.is_simulator: # if running against real HW you need to set CC to correct sighshchk mode # Below test need to run with sighshchk disabled: # * test_sighash_same # * test_sighash_different # * test_sighash_fullmix # # With sighshchk enabled (CC default): # * test_sighash_disallowed_consolidation # * test_sighash_disallowed_NONE settings_set("sighshchk", int(not sh_checks)) not_all_ALL = any(sh != "ALL" for sh in sighash) bitcoind_d_dev_watch.keypoolrefill(num_inputs + num_outputs) input_val = bitcoind.supply_wallet.getbalance() / num_inputs cc_dest = [ {bitcoind_d_dev_watch.getnewaddress("", addr_fmt): Decimal(input_val).quantize(Decimal('.0000001'), rounding=ROUND_DOWN)} for _ in range(num_inputs) ] psbt = bitcoind.supply_wallet.walletcreatefundedpsbt( [], cc_dest, 0, {"fee_rate": 20, "subtractFeeFromOutputs": [0]} )["psbt"] psbt = bitcoind.supply_wallet.walletprocesspsbt(psbt, True, "ALL")["psbt"] resp = bitcoind.supply_wallet.finalizepsbt(psbt) assert resp["complete"] is True assert len(bitcoind.supply_wallet.sendrawtransaction(resp["hex"])) == 64 # mine above txs bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) unspent = bitcoind_d_dev_watch.listunspent() output_val = bitcoind_d_dev_watch.getbalance() / num_outputs # consolidation or not? dest_wal = bitcoind_d_dev_watch if consolidation else bitcoind.supply_wallet destinations = [ {dest_wal.getnewaddress("", addr_fmt): Decimal(output_val).quantize(Decimal('.0000001'), rounding=ROUND_DOWN)} for _ in range(num_outputs) ] assert len(unspent) >= num_inputs psbt = bitcoind_d_dev_watch.walletcreatefundedpsbt( unspent[:num_inputs], destinations, 0, {"fee_rate": 20, "subtractFeeFromOutputs": list(range(num_outputs))} )["psbt"] x = BasicPSBT().parse(base64.b64decode(psbt)) assert len(x.inputs) == num_inputs assert len(x.outputs) == num_outputs for idx, i in enumerate(x.inputs): if len(sighash) == 1: i.sighash = SIGHASH_MAP.get(sighash[0], sighash[0]) else: i.sighash = SIGHASH_MAP.get(sighash[idx], sighash[idx]) if psbt_v2: # below is noop if psbt is already v2 psbt_sh_bytes = x.to_v2() psbt_sh = base64.b64encode(psbt_sh_bytes).decode() else: psbt_sh_bytes = x.as_bytes() psbt_sh = x.as_b64_str() # make useful reference psbt along the way with open(f'{sim_root_dir}/debug/sighash-{sighash[0] if len(sighash) == 1 else "MIX"}.psbt'\ .replace('|', '-'), 'wt') as f: f.write(psbt_sh) # get story out of CC via visualize feature start_sign(psbt_sh_bytes, False, stxn_flags=STXN_VISUALIZE) if sh_checks is True: # checks enabled if consolidation and not_all_ALL: with pytest.raises(Exception) as e: end_sign(accept=None, expect_txn=False).decode() # assert title == "Failure" assert "Only sighash ALL is allowed for pure consolidation transaction" in e.value.args[0] return elif not consolidation and any("NONE" in sh for sh in sighash if isinstance(sh, str)): with pytest.raises(Exception) as e: end_sign(accept=None, expect_txn=False).decode() # assert title == "Failure" assert "Sighash NONE is not allowed as funds could be going anywhere" in e.value.args[0] return story = end_sign(accept=None, expect_txn=False).decode() # assert title == "OK TO SEND?" if any("NONE" in sh for sh in sighash): assert "(1 warning below)" in story assert "---WARNING---" in story assert "Danger" in story assert "Destination address can be changed after signing (sighash NONE)." in story elif any(sh != "ALL" for sh in sighash): assert "(1 warning below)" in story assert "---WARNING---" in story assert "Caution" in story assert "Some inputs have unusual SIGHASH values not used in typical cases." in story # sign and get PSBT out start_sign(psbt_sh_bytes) # now not just legacy but also segwit prohibits SINGLE out of bounds # consensus allows it but it really is just bad usage - restricted if (num_outputs < num_inputs) and any("SINGLE" in sh for sh in sighash): with pytest.raises(Exception) as e: end_sign(accept=True) assert "Signing failed late" in e.value.args[0] # assert "SINGLE corresponding output" in story # assert "missing" in story return psbt_out = end_sign(accept=True) y = BasicPSBT().parse(psbt_out) for idx, i in enumerate(y.inputs): if len(sighash) == 1: target = sighash[0] sh_num = SIGHASH_MAP[target] if target == "ALL": assert i.sighash is None else: assert i.sighash == sh_num else: target = sighash[idx] sh_num = SIGHASH_MAP[target] if target == "ALL": assert i.sighash is None else: assert i.sighash == sh_num # check signature hash correct checksum appended for _, sig in i.part_sigs.items(): assert sig[-1] == sh_num resp = finalize_v2_v0_convert(y) assert resp["complete"] is True tx_hex = resp["hex"] if tx_check: # sign and get finalized tx ready for broadcast out start_sign(psbt_sh_bytes, finalize=True) cc_tx_hex = end_sign(accept=True, finalize=True) assert tx_hex == cc_tx_hex.hex() if psbt_v2: # check txn_modifiable properly set po = BasicPSBT().parse(psbt_out) mod = po.txn_modifiable used_sh = [SIGHASH_MAP[sh] for sh in sighash] if all(sh > 128 for sh in used_sh): # all sighash flags are ANYONECANPAY assert mod & 1 # allow inputs modification else: assert mod & 1 == 0 # inputs modification not allowed if all(sh in (2, 130) for sh in used_sh): # all sighash flags are NONE assert mod & 2 # allow outputs modification else: assert mod & 2 == 0 if any(sh in (3, 131) for sh in used_sh): # some sighash flag/s are SINGLE assert mod & 4 # allow outputs modification else: assert mod & 4 == 0 # for PSBTv2 here we check if we correctly finalize res = bitcoind.supply_wallet.testmempoolaccept([tx_hex]) assert res[0]["allowed"] txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) assert txn_id return doit # TODO Sighash test MUST be run with --psbt2 flag on and off # pytest test_sign.py -k sighash {--psbt2,} @pytest.mark.bitcoind @pytest.mark.parametrize("addr_fmt", ["legacy", "p2sh-segwit", "bech32"]) @pytest.mark.parametrize("sighash", [sh for sh in SIGHASH_MAP if sh != 'ALL']) @pytest.mark.parametrize("num_outs", [1, 3, 5]) @pytest.mark.parametrize("num_ins", [2, 5]) def test_sighash_same(addr_fmt, sighash, num_ins, num_outs, _test_single_sig_sighash): # sighash is the same among all inputs _test_single_sig_sighash(addr_fmt, [sighash], num_inputs=num_ins, num_outputs=num_outs) @pytest.mark.bitcoind @pytest.mark.parametrize("addr_fmt", ["legacy", "p2sh-segwit", "bech32"]) @pytest.mark.parametrize("sighash", list(itertools.combinations(SIGHASH_MAP.keys(), 2))) @pytest.mark.parametrize("num_outs", [2, 3, 5]) def test_sighash_different(addr_fmt, sighash, num_outs, _test_single_sig_sighash): # sighash differ among all inputs _test_single_sig_sighash(addr_fmt, sighash, num_inputs=2, num_outputs=num_outs) @pytest.mark.bitcoind @pytest.mark.parametrize("addr_fmt", ["legacy", "p2sh-segwit", "bech32"]) @pytest.mark.parametrize("num_outs", [5, 8]) def test_sighash_fullmix(addr_fmt, num_outs, _test_single_sig_sighash): # tx with 6 inputs representing all possible sighashes _test_single_sig_sighash(addr_fmt, tuple(SIGHASH_MAP.keys()), num_inputs=6, num_outputs=num_outs) @pytest.mark.bitcoind @pytest.mark.parametrize("sighash", [sh for sh in SIGHASH_MAP if sh != 'ALL']) def test_sighash_disallowed_consolidation(sighash, _test_single_sig_sighash): # sighash != ALL blocked for pure consolidations _test_single_sig_sighash("bech32", [sighash], num_inputs=2, num_outputs=2, sh_checks=True, consolidation=True) @pytest.mark.bitcoind @pytest.mark.parametrize("sighash", ["NONE", "NONE|ANYONECANPAY"]) def test_sighash_disallowed_NONE(sighash, _test_single_sig_sighash): # sighash is the same among all inputs _test_single_sig_sighash("bech32", [sighash], num_inputs=2, num_outputs=2, consolidation=False, sh_checks=True) @pytest.mark.bitcoind def test_sighash_nonexistent(_test_single_sig_sighash): # invalid sighash value with pytest.raises(Exception) as exc: _test_single_sig_sighash("legacy", [0xe2], num_inputs=2, num_outputs=2, consolidation=True, sh_checks=False) assert "Unsupported sighash flag 0xe2" in exc.value.args[0] def test_no_outputs_tx(fake_txn, microsd_path, goto_home, press_select, pick_menu_item, cap_story): goto_home() psbt = fake_txn(3, 0) # no outputs fname = "zero_outputs.psbt" fpath = microsd_path(fname) with open(fpath, "wb") as f: f.write(psbt) pick_menu_item('Ready To Sign') time.sleep(0.1) try: pick_menu_item(fname) except KeyError: pass time.sleep(0.1) title, story = cap_story() assert title == "Failure" assert "Invalid PSBT" in story assert "need outputs" in story try: os.remove(fpath) except: pass def test_send2taproot_addresss(fake_txn , start_sign, end_sign, cap_story, use_testnet): use_testnet() psbt = fake_txn(2, 2, segwit_in=True, change_outputs=[0], outstyles=["p2tr"]) start_sign(psbt) title, story = cap_story() assert title == "OK TO SEND?" # we do not understand change in taproot (taproot not supported) assert "warning" not in story # no warning for taproot, just not change assert "Consolidating" not in story assert "Change back" not in story # but we should show address assert "to script" not in story assert "tb1p" in story signed = end_sign(accept=True, finalize=False) assert signed def test_sign_taproot_input(fake_txn, start_sign, end_sign, cap_story): psbt = fake_txn(1, 2, taproot_in=True) start_sign(psbt) title, story = cap_story() assert title == "Failure" assert "Install EDGE firmware" in story @pytest.mark.parametrize("num_unknown", [1,3]) def test_send2unknown_script(fake_txn , start_sign, end_sign, cap_story, use_testnet, num_unknown): use_testnet() unknowns = ["unknown"] * num_unknown num_out = 2 if num_unknown == 1 else 4 psbt = fake_txn(2, num_out, segwit_in=True, change_outputs=[0], outstyles=["p2tr"]+unknowns) start_sign(psbt) title, story = cap_story() assert title == "OK TO SEND?" # we do not understand change in taproot (taproot not supported) assert "(1 warning below)" in story # unknown script assert ("Sending to %d not well understood script(s)" % num_unknown) in story assert "Consolidating" not in story assert "Change back" not in story assert "to script" in story signed = end_sign(accept=True, finalize=False) assert signed @pytest.mark.parametrize("num_tx", [1, 2, 10]) @pytest.mark.parametrize("ui_path", [True, False]) @pytest.mark.parametrize("action", ["sign", "skip", "refuse"]) def test_batch_sign(num_tx, ui_path, action, fake_txn, need_keypress, pick_menu_item, cap_story, microsd_path, cap_menu, microsd_wipe, goto_home, press_select, press_cancel, X): goto_home() microsd_wipe() for i in range(num_tx): psbt = fake_txn(2, 2, segwit_in=random.getrandbits(1)) with open(microsd_path(f"{i}.psbt"), "wb") as f: f.write(psbt) if ui_path: pick_menu_item("Advanced/Tools") pick_menu_item("File Management") pick_menu_item("Batch Sign PSBT") else: # shortcut via Ready To Sign pick_menu_item("Ready To Sign") time.sleep(.1) if num_tx == 1: press_cancel() pytest.skip("classic sign") m = cap_menu() mi = "[Sign All]" assert mi in m pick_menu_item(mi) time.sleep(.1) title, story = cap_story() if "Press (1)" in story: need_keypress("1") time.sleep(.1) title, story = cap_story() for i in range(num_tx): assert "Sign" in story assert "(1) to skip" in story assert f"{X} to quit and exit" in story if action == "skip": need_keypress("1") # skip this PSBT time.sleep(.5) title, story = cap_story() continue press_select() # sign this PSBT time.sleep(.5) title, story = cap_story() assert title == "OK TO SEND?" if action == "refuse": press_cancel() # refuse time.sleep(.5) title, story = cap_story() continue press_select() # confirm send time.sleep(.5) title, story = cap_story() assert "-signed.psbt" in story press_cancel() time.sleep(.5) title, story = cap_story() @pytest.mark.parametrize("desc_psbt_hex", [ ("PSBTv0 but with PSBT_GLOBAL_VERSION set to 2.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_GLOBAL_TX_VERSION.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001020402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_GLOBAL_FALLBACK_LOCKTIME.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001030402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_GLOBAL_INPUT_COUNT.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001040102000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_GLOBAL_OUTPUT_COUNT.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001050102000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_GLOBAL_TX_MODIFIABLE.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc68850000000001060100000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_IN_PREVIOUS_TXID.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc800220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_IN_OUTPUT_INDEX.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27010f040000000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_IN_SEQUENCE.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a27011004ffffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_IN_REQUIRED_TIME_LOCKTIME.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a270111048c8dc46200220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_IN_REQUIRED_HEIGHT_LOCKTIME.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a270112041027000000220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_OUT_AMOUNT.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f00000000002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv0 but with PSBT_OUT_SCRIPT.", "70736274ff01007102000000010b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc80000000000feffffff020008af2f00000000160014c430f64c4756da310dbd1a085572ef299926272c8bbdeb0b00000000160014a07dac8ab6ca942d379ed795f835ba71c9cc688500000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e01086b02473044022005275a485734e0ae1f3b971237586f0e72dc85833d278c0e474cd23112c0fa5e02206b048c83cebc3c41d0b93cc7da76185cedbd030d005b08018be2b98bbacbdf7b012103760dcca05f3997dc65b293060f7f29f1514c8c527048e12802b041d4fc340a2700220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000104160014a07dac8ab6ca942d379ed795f835ba71c9cc6885002202036efe2c255621986553ba9d65c3ddc64165ca1436e05aa35a4c6eb02451cf796d18f69d873e540000800100008000000080010000006200000000"), ("PSBTv2 missing PSBT_GLOBAL_INPUT_COUNT.", "70736274ff01020402000000010304000000000105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ("PSBTv2 missing PSBT_GLOBAL_OUTPUT_COUNT.", "70736274ff01020402000000010304000000000104010101fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ("PSBTv2 missing PSBT_IN_PREVIOUS_TXID.", "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ("PSBTv2 missing PSBT_IN_OUTPUT_INDEX.", "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ("PSBTv2 missing PSBT_OUT_AMOUNT.", "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ("PSBTv2 missing PSBT_OUT_SCRIPT.", "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f0000000000220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ("PSBTv2 with PSBT_IN_REQUIRED_TIME_LOCKTIME less than 500000000.", "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011104ff64cd1d00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ("PSBTv2 with PSBT_IN_REQUIRED_HEIGHT_LOCKTIME greater than or equal to 500000000.", "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f04000000000112040065cd1d00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"), ]) def test_v2_psbt_bip370_invalid(desc_psbt_hex, start_sign, cap_story): desc, psbt_hex = desc_psbt_hex psbt = bytes.fromhex(psbt_hex) print(desc) start_sign(psbt) title, story = cap_story() assert title == "Failure" assert ".py:" in story # problem file line @pytest.mark.bitcoind @pytest.mark.parametrize("outstyle", ADDR_STYLES_SINGLE) @pytest.mark.parametrize("segwit_in", [True, False]) @pytest.mark.parametrize("wrapped_segwit_in", [True, False]) def test_psbt_v2(outstyle, segwit_in, wrapped_segwit_in, fake_txn , start_sign, end_sign, cap_story, microsd_path, bitcoind, finalize_v2_v0_convert): psbt = fake_txn(2, 2, segwit_in=segwit_in, wrapped=wrapped_segwit_in, change_outputs=[0], outstyles=[outstyle], psbt_v2=True) start_sign(psbt) title, story = cap_story() assert title == "OK TO SEND?" # we do not understand change in taproot (taproot not supported) assert "Consolidating" not in story assert "Change back" in story # but we should show address assert "to script" not in story signed = end_sign(accept=True, finalize=False) assert signed po = BasicPSBT().parse(signed) assert po.version == 2 assert po.txn_version == 2 assert po.input_count is not None assert po.output_count is not None for inp in po.inputs: assert inp.previous_txid assert inp.prevout_idx is not None for out in po.outputs: assert out.amount assert out.script resp = finalize_v2_v0_convert(po) assert resp["complete"] is True def test_psbt_v2_tx_modifiable_parse(fake_txn, start_sign, end_sign): psbt = fake_txn(2, 2, segwit_in=True, wrapped=True, change_outputs=[0], psbt_v2=True) p = BasicPSBT().parse(psbt) # 3 = both inputs and outputs are modifiable # need just some value instead of None, in that case flag is ommited p.txn_modifiable = 3 with BytesIO() as fd: p.serialize(fd) mod_psbt = fd.getvalue() start_sign(mod_psbt) signed = end_sign(accept=True, finalize=False) assert signed po = BasicPSBT().parse(signed) # signed with sighash ALL - meaning nothing is modifiable now assert po.txn_modifiable == 0 @pytest.mark.bitcoind @pytest.mark.parametrize("way", ["i+", "i-", "o+", "o-"]) def test_psbt_v2_global_quantities(way, fake_txn, start_sign, end_sign, cap_story, microsd_path, bitcoind, finalize_v2_v0_convert): def hacker(psbt, way): actual_len_i = len(psbt.inputs) actual_len_o = len(psbt.outputs) if way == "i-": psbt.input_count = actual_len_i - 1 elif way == "i+": psbt.input_count = actual_len_i - 1 elif way == "o-": psbt.output_count = actual_len_o - 1 elif way == "o+": psbt.output_count = actual_len_o + 1 psbt = fake_txn(2, 2, segwit_in=True, wrapped=True, change_outputs=[0], outstyles=["p2pkh", "p2wpkh"], psbt_v2=True, psbt_hacker=lambda psbt: hacker(psbt, way)) start_sign(psbt) title, story = cap_story() assert "failed" in story or "Invalid PSBT" in story or "Network fee bigger" in story @pytest.mark.bitcoind @pytest.mark.parametrize("locktime", [ 0, # zero default False, # current block height 800000, 1513209600, # 2017-12-14 00:00:00 1387324800, # 2013-12-18 00:00:00 1294790399, # 2011-11-01 23:59:59 1748671747, # 2025-05-31 07:09:07 ]) def test_locktime_ux(use_regtest, bitcoind_d_sim_watch, start_sign, end_sign, microsd_path, cap_story, goto_home, press_select, pick_menu_item, bitcoind, locktime, file_tx_signing_done): use_regtest() sim = bitcoind_d_sim_watch addr = sim.getnewaddress() bitcoind.supply_wallet.sendtoaddress(addr, 2) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) bi = sim.getblockchaininfo() blocks = bi["blocks"] success = True if locktime is False: # current height - allowed locktime = blocks if locktime < 500000000: # blocks if locktime > blocks: success = False else: # MTP if locktime > datetime.datetime.utcnow().timestamp(): success = False dest_addr = sim.getnewaddress() # self-spend psbt_resp = sim.walletcreatefundedpsbt([], [{dest_addr: 1.0}], locktime, {"fee_rate": 20}) psbt = psbt_resp.get("psbt") psbt_fname = "locktime.psbt" with open(microsd_path(psbt_fname), "w") as f: f.write(psbt) goto_home() pick_menu_item('Ready To Sign') time.sleep(0.1) title, story = cap_story() if 'OK TO SEND' not in title: pick_menu_item(psbt_fname) time.sleep(0.1) title, story = cap_story() assert "WARNING" not in story if locktime != 0: assert "LOCKTIMES" in story assert "Abs Locktime" in story if locktime < 500000000: assert f"This tx can only be spent after block height of {locktime}" in story else: dt = datetime.datetime.utcfromtimestamp(locktime) ux_dt = dt.strftime("%Y-%m-%d %H:%M:%S") assert f"This tx can only be spent after {ux_dt} UTC (MTP)" in story # assert f"This tx can only be spent after {locktime} (unix timestamp)" in story else: assert "LOCKTIMES" not in story press_select() # confirm signing time.sleep(0.1) title, story = cap_story() assert "Updated PSBT is:" in story assert "Finalized transaction (ready for broadcast)" in story assert "TXID" in story signed_psbt, signed_txn, story_txid = file_tx_signing_done(story) assert signed_psbt != psbt finalize_res = sim.finalizepsbt(signed_psbt) bitcoind_signed_txn = finalize_res["hex"] assert finalize_res["complete"] is True accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] assert accept_res["allowed"] is success assert signed_txn == bitcoind_signed_txn if success: txid = sim.sendrawtransaction(signed_txn) else: with pytest.raises(Exception): sim.sendrawtransaction(signed_txn) txid = accept_res["txid"] assert len(txid) == 64 assert txid == story_txid @pytest.mark.bitcoind @pytest.mark.parametrize("num_ins", [1, 4, 11]) @pytest.mark.parametrize("differ", [True, False]) @pytest.mark.parametrize("sequence", [0, 1, 50, 65534]) def test_nsequence_blockheight_relative_locktime_ux(sequence, use_regtest, bitcoind_d_sim_watch, start_sign, end_sign, microsd_path, cap_story, goto_home, press_select, pick_menu_item, bitcoind, num_ins, differ, file_tx_signing_done): if differ and (sequence == 0): # this case makes no sense return use_regtest() sim = bitcoind_d_sim_watch sim.keypoolrefill(20) for i in range(num_ins): addr = sim.getnewaddress() bitcoind.supply_wallet.sendtoaddress(addr, 1) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) dest_addr = sim.getnewaddress() # self-spend utxos = sim.listunspent() assert len(utxos) == num_ins ins = [] num_ins_locked = 0 locks = [] for i, utxo in enumerate(utxos): confirmations = utxo["confirmations"] lock = (confirmations + sequence) if sequence else 0 if i and differ: # not first one (0th) as it should have sequence provided via parametrize # all others decremented by iteration count nSeq = lock - i if nSeq < 0: nSeq = 0 else: nSeq = lock if nSeq > 0: num_ins_locked += 1 locks.append(nSeq) # block height based RTL inp = { "txid": utxo["txid"], "vout": utxo["vout"], "sequence": nSeq, } ins.append(inp) psbt_resp = sim.walletcreatefundedpsbt(ins, [{dest_addr: (num_ins - 0.1)}], 0, {"fee_rate": 20}) psbt = psbt_resp.get("psbt") psbt_fname = "rtl-blockheight.psbt" with open(microsd_path(psbt_fname), "w") as f: f.write(psbt) goto_home() pick_menu_item('Ready To Sign') time.sleep(0.1) title, story = cap_story() if 'OK TO SEND' not in title: pick_menu_item(psbt_fname) time.sleep(0.1) title, story = cap_story() assert "WARNING" not in story if sequence: assert "TX LOCKTIMES" in story assert "Block height RTL" in story if num_ins_locked == 1: assert ("has relative block height timelock of %d" % lock) in story else: if differ: assert ("%d inputs have relative block height timelock." % num_ins_locked) in story for i in range(num_ins_locked): if not (("%d. " % i) in story): assert "only 10 with highest values" in story else: assert ("%d inputs have relative block height timelock of %d" % (num_ins_locked, lock)) in story else: assert "TX LOCKTIMES" not in story press_select() # confirm signing time.sleep(0.1) title, story = cap_story() assert "Updated PSBT is:" in story assert "Finalized transaction (ready for broadcast)" in story assert "TXID" in story press_select() # exit saved story signed_psbt, signed_txn, story_txid = file_tx_signing_done(story) assert signed_psbt != psbt finalize_res = sim.finalizepsbt(signed_psbt) bitcoind_signed_txn = finalize_res["hex"] assert finalize_res["complete"] is True accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] if sequence == 0: assert accept_res["allowed"] return assert accept_res["allowed"] is False assert accept_res["reject-reason"] == 'non-BIP68-final' if sequence > 50: # not gonna mine 65k blocks return sim.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) # mine N blocks accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] assert accept_res["allowed"] is True assert signed_txn == bitcoind_signed_txn txid = sim.sendrawtransaction(signed_txn) assert len(txid) == 64 assert txid == story_txid @pytest.mark.bitcoind @pytest.mark.parametrize("num_ins", [1, 4, 11]) @pytest.mark.parametrize("differ", [True, False]) @pytest.mark.parametrize("seconds", [512, 10240, 1024000, 33554431]) def test_nsequence_timebased_relative_locktime_ux(seconds, use_regtest, bitcoind_d_sim_watch, start_sign, microsd_path, cap_story, goto_home, press_select, pick_menu_item, bitcoind, end_sign, num_ins, differ, file_tx_signing_done): sequence = SEQUENCE_LOCKTIME_TYPE_FLAG | (seconds >> 9) use_regtest() sim = bitcoind_d_sim_watch sim.keypoolrefill(20) for i in range(num_ins): addr = sim.getnewaddress() bitcoind.supply_wallet.sendtoaddress(addr, 1) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) dest_addr = sim.getnewaddress() # self-spend utxos = sim.listunspent() assert len(utxos) == num_ins bi = sim.getblockchaininfo() ins = [] num_ins_locked = 0 locked_indexes = [] for i, utxo in enumerate(utxos): # time-based RTL if i and differ and (seconds > 512): secs = seconds // i nSeq = SEQUENCE_LOCKTIME_TYPE_FLAG | (secs >> 9) if nSeq < 0: nSeq = 0 else: secs = seconds nSeq = sequence if nSeq > 0: num_ins_locked += 1 locked_indexes.append((i, secs)) inp = { "txid": utxo["txid"], "vout": utxo["vout"], "sequence": nSeq, } ins.append(inp) psbt_resp = sim.walletcreatefundedpsbt(ins, [{dest_addr: (num_ins - 0.1)}], 0, {"fee_rate": 20}) psbt = psbt_resp.get("psbt") psbt_fname = "rtl-time.psbt" with open(microsd_path(psbt_fname), "w") as f: f.write(psbt) goto_home() pick_menu_item("Ready To Sign") time.sleep(0.1) title, story = cap_story() if 'OK TO SEND' not in title: pick_menu_item(psbt_fname) time.sleep(0.1) title, story = cap_story() assert "WARNING" not in story assert "TX LOCKTIMES" in story assert "Time-based RTL" in story t_from_seq = (sequence & 0x0000ffff) << 9 base_msg = "relative time-based timelock of:\n %s" % seconds2human_readable(t_from_seq) if num_ins_locked == 1: assert ("has " + base_msg) in story else: if differ and (seconds > 512): assert ("%d inputs have relative time-based timelock." % num_ins_locked) in story for i, _ in sorted(locked_indexes, key=lambda i: i[1], reverse=True)[:10]: assert ("%d. " % i) in story else: msg1 = "%d inputs have " % num_ins_locked assert (msg1 + base_msg) in story press_select() # confirm signing time.sleep(0.1) title, story = cap_story() assert "Updated PSBT is:" in story assert "Finalized transaction (ready for broadcast)" in story assert "TXID" in story signed_psbt, signed_txn, story_txid = file_tx_signing_done(story) assert signed_psbt != psbt finalize_res = sim.finalizepsbt(signed_psbt) bitcoind_signed_txn = finalize_res["hex"] assert finalize_res["complete"] is True accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] assert accept_res["allowed"] is False assert accept_res["reject-reason"] == 'non-BIP68-final' if seconds > 512: # not gonna wait for it return # mine blocks - mining increases the timestamp but somehow randomly while True: sim.generatetoaddress(5, bitcoind.supply_wallet.getnewaddress()) t = sim.getblockchaininfo()["time"] if (t - bi["time"]) > 600: break accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] assert accept_res["allowed"] is True assert signed_txn == bitcoind_signed_txn txid = sim.sendrawtransaction(signed_txn) assert len(txid) == 64 assert txid == story_txid @pytest.mark.bitcoind @pytest.mark.parametrize("abs_lock", [True, False]) @pytest.mark.parametrize("num_rtl", [(2,3),(4,7),(8,3),(6,7)]) def test_mixed_locktimes(num_rtl, use_regtest, bitcoind_d_sim_watch, start_sign, microsd_path, cap_story, goto_home, press_select, pick_menu_item, bitcoind, end_sign, abs_lock, file_tx_signing_done): tb, bb = num_rtl num_ins = tb + bb sequence = SEQUENCE_LOCKTIME_TYPE_FLAG | (512 >> 9) use_regtest() sim = bitcoind_d_sim_watch sim.keypoolrefill(20) for i in range(num_ins): addr = sim.getnewaddress() bitcoind.supply_wallet.sendtoaddress(addr, 1) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) dest_addr = sim.getnewaddress() # self-spend utxos = sim.listunspent() assert len(utxos) == num_ins bi = sim.getblockchaininfo() blocks = bi["blocks"] if abs_lock: # absolute locktime smaller then relative locktime = blocks + 10 else: locktime = 0 ins = [] for i, utxo in enumerate(utxos): # time-based RTL if i < tb: nSeq = sequence else: confirmations = utxo["confirmations"] nSeq = confirmations + 20 # blocks inp = { "txid": utxo["txid"], "vout": utxo["vout"], "sequence": nSeq, } ins.append(inp) psbt_resp = sim.walletcreatefundedpsbt(ins, [{dest_addr: (num_ins - 0.1)}], locktime, {"fee_rate": 20}) psbt = psbt_resp.get("psbt") psbt_fname = "mixed-locktimes.psbt" with open(microsd_path(psbt_fname), "w") as f: f.write(psbt) goto_home() pick_menu_item('Ready To Sign') time.sleep(0.1) title, story = cap_story() if 'OK TO SEND' not in title: pick_menu_item(psbt_fname) time.sleep(0.1) title, story = cap_story() assert "WARNING" not in story assert "TX LOCKTIMES" in story assert "Time-based RTL" in story t_from_seq = (sequence & 0x0000ffff) << 9 base_msg = "relative time-based timelock of:\n %s" % seconds2human_readable(t_from_seq) msg1 = "%d inputs have " % tb assert (msg1 + base_msg) in story assert "Block height RTL" in story assert ("%d inputs have relative block height timelock of %d" % (bb, 21)) in story if abs_lock: assert "Abs Locktime" in story assert f"This tx can only be spent after block height of {locktime}" in story else: assert "Abs Locktime" not in story press_select() # confirm signing time.sleep(0.1) title, story = cap_story() assert "Updated PSBT is:" in story assert "Finalized transaction (ready for broadcast)" in story assert "TXID" in story signed_psbt, signed_txn, story_txid = file_tx_signing_done(story) assert signed_psbt != psbt finalize_res = sim.finalizepsbt(signed_psbt) bitcoind_signed_txn = finalize_res["hex"] assert finalize_res["complete"] is True accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] assert accept_res["allowed"] is False if abs_lock: assert accept_res["reject-reason"] == 'non-final' else: assert accept_res["reject-reason"] == 'non-BIP68-final' # try to mine 21 blocks - which should unlock height based inpputs # and also absolute timelock which is smaller than relative # but tx must be still unspendable as time based are still locked sim.generatetoaddress(21, bitcoind.supply_wallet.getnewaddress()) accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] assert accept_res["allowed"] is False assert accept_res["reject-reason"] == 'non-BIP68-final' # mine blocks - mining increases the timestamp but somehow randomly while True: sim.generatetoaddress(5, bitcoind.supply_wallet.getnewaddress()) t = sim.getblockchaininfo()["time"] if (t - bi["time"]) > 600: break accept_res = sim.testmempoolaccept([bitcoind_signed_txn])[0] assert accept_res["allowed"] is True assert signed_txn == bitcoind_signed_txn txid = sim.sendrawtransaction(signed_txn) assert len(txid) == 64 assert txid == story_txid def random_nLockTime_test_cases(num=10): res = [] now = datetime.datetime.utcnow() for i in range(num): td = datetime.timedelta(days=i, hours=i+i, seconds=7**i) var = now + td var = var.replace(tzinfo=datetime.timezone.utc) res.append((int(var.timestamp()), var.strftime("%Y-%m-%d %H:%M:%S"))) return res @pytest.mark.parametrize("nLockTime", [ (1513209600, "2017-12-14 00:00:00"), (1387324800, "2013-12-18 00:00:00"), (1294790399, "2011-01-11 23:59:59"), (1748671747, "2025-05-31 06:09:07"), *random_nLockTime_test_cases() ]) def test_timelocks_visualize(start_sign, end_sign, dev, bitcoind, use_regtest, bitcoind_d_sim_watch, nLockTime, sim_root_dir): # - works on simulator and connected USB real-device nLockTime, expect_ux = nLockTime num_ins = 10 use_regtest() bitcoind_d_sim_watch.keypoolrefill(20) for i in range(num_ins): addr = bitcoind_d_sim_watch.getnewaddress() bitcoind.supply_wallet.sendtoaddress(addr, 1) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) dest_addr = bitcoind_d_sim_watch.getnewaddress() # self-spend utxos = bitcoind_d_sim_watch.listunspent() assert len(utxos) == num_ins ins = [] for i, utxo in enumerate(utxos): if i % 2 == 0: nSeq = (SEQUENCE_LOCKTIME_TYPE_FLAG | i) else: confirmations = utxo["confirmations"] nSeq = confirmations + (20*i) inp = { "txid": utxo["txid"], "vout": utxo["vout"], "sequence": nSeq, } ins.append(inp) psbt_resp = bitcoind_d_sim_watch.walletcreatefundedpsbt( ins, [{dest_addr: (num_ins - 0.1)}], nLockTime, {"fee_rate": 20} ) psbt = base64.b64decode(psbt_resp.get("psbt")) with open(f'{sim_root_dir}/debug/locktimes.psbt', 'wb') as f: f.write(psbt) # should be able to sign, but get warning # use new feature to have Coldcard return the 'visualization' of transaction start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) story = end_sign(accept=None, expect_txn=False) story = story.decode('ascii') assert datetime.datetime.utcfromtimestamp(nLockTime).strftime("%Y-%m-%d %H:%M:%S") == expect_ux assert f"Abs Locktime: This tx can only be spent after {expect_ux} UTC (MTP)" in story assert "Block height RTL: 5 inputs have relative block height timelock" in story # when i=0 in loop time based RTL is zero assert "Time-based RTL: 4 inputs have relative time-based timelock" in story @pytest.mark.parametrize('in_out', [(4,1),(2,2),(2,1)]) @pytest.mark.parametrize('partial', [False, True]) @pytest.mark.parametrize('segwit', [True, False]) def test_base64_psbt_qr(in_out, partial, segwit, scan_a_qr, readback_bbqr, goto_home, use_regtest, cap_story, fake_txn, dev, decode_psbt_with_bitcoind, decode_with_bitcoind, press_cancel, press_select, need_keypress, sim_root_dir): def hack(psbt): if partial: # change first input to not be ours pk = list(psbt.inputs[0].bip32_paths.keys())[0] pp = psbt.inputs[0].bip32_paths[pk] psbt.inputs[0].bip32_paths[pk] = b'what' + pp[4:] num_in, num_out = in_out if not segwit: psbt = fake_txn(num_in, num_out, dev.master_xpub, psbt_hacker=hack) else: psbt = fake_txn(num_in, num_out, dev.master_xpub, psbt_hacker=hack, segwit_in=True, outstyles=['p2wpkh']) psbt = base64.b64encode(psbt).decode() with open(f'{sim_root_dir}/debug/last.psbt', 'w') as f: f.write(psbt) goto_home() need_keypress(KEY_QR) scan_a_qr(psbt) for r in range(20): title, story = cap_story() if 'OK TO SEND' in title: break time.sleep(.1) else: raise pytest.fail('never saw it?') # approve it press_select() time.sleep(.2) file_type, rb = readback_bbqr() assert file_type in 'TP' if file_type == 'T': assert not partial decoded = decode_with_bitcoind(rb) ic, oc = len(decoded['vin']), len(decoded['vout']) else: assert file_type == 'P' assert partial assert rb[0:4] == b'psbt' decoded = decode_psbt_with_bitcoind(rb) assert not decoded['unknown'] if 'tx' in decoded: # psbt v0 decoded = decoded['tx'] ic, oc = len(decoded['vin']), len(decoded['vout']) else: # expect psbt v2 ic = decoded["input_count"] oc = decoded["output_count"] # just smoke test; syntax not content assert ic == num_in assert oc == num_out press_cancel() # back to menu def test_sorting_outputs_by_size(fake_txn, start_sign, cap_story, use_testnet, press_cancel): use_testnet() out_vals = [1000000, 2000000, 3000000, 4000000, 5000000, 6000000, 7000000, 8000000, 9000000, 179989995, 11000000, 12000000, 13000000, 14000000, 15000000] max_display_num = 10 rest_num = len(out_vals) - max_display_num psbt = fake_txn(3, 15, segwit_in=True, outstyles=ADDR_STYLES_SINGLE, outvals=out_vals) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert title == 'OK TO SEND?' rest_sum = 0 for i, ov in enumerate(sorted(out_vals, reverse=True)): str_ov = f'{ov / 100000000:.8f} XTN' if i < 10: # these must be in story assert str_ov in story else: # these are not covered in story, instead their vals are summed and # provided just as rest sum assert str_ov not in story rest_sum += ov # check rest sum is correct rest_story = f"plus {rest_num} smaller output(s), not shown here, which total: " rest_story += f'{rest_sum / 100000000:.8f} XTN' assert rest_story in story press_cancel() @pytest.mark.parametrize("chain", ["BTC", "XTN"]) @pytest.mark.parametrize("data", [ # (out_style, amount, is_change) [("p2pkh", 1000000, 0)] * 99, [("p2wpkh", 1000000, 1),("p2wpkh-p2sh", 800000, 1)] * 27, [("p2pkh", 1000000, 1)] * 11 + [("p2wpkh", 50000000, 0)] * 16, [("p2pkh", 1000000, 1), ("p2wpkh", 50000000, 0), ("p2wpkh-p2sh", 800000, 1)] * 11, ]) def test_txout_explorer(chain, data, fake_txn, start_sign, settings_set, txout_explorer, cap_story, pytestconfig): # TODO This test MUST be run with --psbt2 flag on and off settings_set("chain", chain) outstyles = [] outvals = [] change_outputs = [] for i in range(len(data)): os, ov, is_change = data[i] outstyles.append(os) outvals.append(ov) if is_change: change_outputs.append(i) inp_amount = sum(outvals) + 100000 # 100k sat fee psbt = fake_txn(1, len(data), segwit_in=True, outstyles=outstyles, outvals=outvals, change_outputs=change_outputs, psbt_v2=pytestconfig.getoption('psbt2'), input_amount=inp_amount) start_sign(psbt) txout_explorer(data, chain) @pytest.mark.parametrize("chain", ["BTC", "XTN"]) @pytest.mark.parametrize("addr_fmt", ["p2wpkh", "p2pkh", "p2wpkh-p2sh"]) def test_txin_explorer(chain, addr_fmt, fake_txn, start_sign, settings_set, txin_explorer, cap_story, pytestconfig): # TODO This test MUST be run with --psbt2 flag on and off settings_set("chain", chain) inp_amount = 1000000 num_ins = 3 if addr_fmt == "p2wpkh": segwit = True wrapped = False sh = "SINGLE" seq = 1100 elif addr_fmt == "p2pkh": segwit = False wrapped = False sh = "ALL|ANYONECANPAY" seq = SEQUENCE_LOCKTIME_TYPE_FLAG | (512 >> 9) else: segwit = True wrapped = True sh = "SINGLE|ANYONECANPAY" seq = 1 psbt = fake_txn(num_ins, 1, segwit_in=segwit, wrapped=wrapped, psbt_v2=pytestconfig.getoption('psbt2'), input_amount=inp_amount, sequences=[seq], sighashes=[sh]) start_sign(psbt) txin_explorer(num_ins, [(addr_fmt, inp_amount, 1, chain, False, sh, seq)]) @pytest.mark.parametrize("finalize", [True, False]) @pytest.mark.parametrize("data", [ [(1, b"Coinkite"), (0, b"Mk1 Mk2 Mk3 Mk4 Q"), (100, b"binarywatch.org"), (100, b"a" * 75)], [(0, b"W"*160), (10000, b"W"*153)], [(0, b"a" * 300), (10, b"x" * 1000), (0, b"anchor output")], [(0, b""), (10, b"")], [(0, os.urandom(32)), (10, os.urandom(64)), (1000, os.urandom(160)), (0, os.urandom(161))], ]) def test_txout_explorer_op_return(finalize, data, fake_txn, start_sign, cap_story, is_q1, need_keypress, press_cancel, press_select, end_sign, cap_screen_qr, cap_screen, pick_menu_item): psbt = fake_txn(1, 20, segwit_in=False, op_return=data) start_sign(psbt, finalize=finalize) time.sleep(.1) title, story = cap_story() assert title == 'OK TO SEND?' assert "(1 warning below)" in story if len(data) > 1: assert ("Multiple OP_RETURN outputs: %d" % len(data)) in story else: assert "Multiple OP_RETURN outputs" not in story if sum(int(len(x[1]) > 80) for x in data): assert "OP_RETURN > 80 bytes" in story else: assert "OP_RETURN > 80 bytes" not in story assert "Press (2) to explore transaction" in story need_keypress("2") pick_menu_item("Outputs") time.sleep(.1) # OP_RETURN is put at the end of output list (fake_txn) # 20 normal outputs, all OP_RETURN on last page for _ in range(2): need_keypress(KEY_RIGHT if is_q1 else "9") time.sleep(.1) _, story = cap_story() ss = story.split("\n\n") # collect QR codes first need_keypress(KEY_QR if is_q1 else "4") qr_list = [] for v, d in data: try: qr = cap_screen_qr().decode() qr_list.append(qr) except RuntimeError: scr = cap_screen() if is_q1: too_big = 650 assert "QR too big" in scr else: too_big = 158 assert "QR too" in scr assert "big" in scr assert len(d) > too_big qr_list.append(None) need_keypress(KEY_RIGHT if is_q1 else "9") time.sleep(.5) press_cancel() # QR code on screen - exit for i, (sa, sb, (amount, data)) in enumerate(zip(ss[:-1:2], ss[1::2], data), start=20): assert f"Output {i}:" == sa try: val, name, dd = sb.split("\n") except: dd = None val, name, dd0, _, dd1 = sb.split("\n") assert "OP_RETURN" in name assert f'{amount / 100000000:.8f} XTN' == val if dd == "null-data": assert qr_list[i - 20] == "" elif dd: is_ascii = False try: data.decode("ascii") is_ascii = True except UnicodeDecodeError: pass if is_ascii: hex_str, ascii_str = dd.split(" ", 1) else: hex_str = dd qr_target = qr_list[i-20] if qr_target: assert hex_str in qr_target assert qr_target.startswith("6a") # OP_RETURN assert data.hex() == hex_str if is_ascii: assert f"(ascii: {data.decode()})" == ascii_str else: s = data[:80].hex() e = data[-80:].hex() assert s == dd0 assert e == dd1 press_cancel() # exit output explorer press_cancel() # exit txn out explorer end_sign(finalize=finalize) def test_txout_explorer_qr_too_big_single_item(fake_txn, start_sign, cap_story, cap_screen, need_keypress, pick_menu_item, press_cancel, is_q1): if not is_q1: raise pytest.skip("Q1 QR fallback") psbt = fake_txn(1, 10, segwit_in=True, psbt_v2=False, op_return=[(0, b'a' * 1000)]) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert title == "OK TO SEND?" need_keypress("2") pick_menu_item("Outputs") time.sleep(.1) need_keypress(KEY_RIGHT) time.sleep(.1) need_keypress(KEY_QR) time.sleep(.5) scr = cap_screen() assert "QR too big" in scr press_cancel() press_cancel() def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_import, cap_story, try_sign, reset_seed_words, clear_ms): reset_seed_words() clear_ms() desc = "sh(sortedmulti(2,[6ba6cfd0/45h]tpubD9429UXFGCTKJ9NdiNK4rC5ygqSUkginycYHccqSg5gkmyQ7PZRHNjk99M6a6Y3NY8ctEUUJvCu6iCCui8Ju3xrHRu3Ez1CKB4ZFoRZDdP9/0/*,[747b698e/45h]tpubD97nVL37v5tWyMf9ofh5rznwhh1593WMRg6FT4o6MRJkKWANtwAMHYLrcJFsFmPfYbY1TE1LLQ4KBb84LBPt1ubvFwoosvMkcWJtMwvXgSc/0/*,[7bb026be/45h]tpubD9ArfXowvGHnuECKdGXVKDMfZVGdephVWg8fWGWStH3VKHzT4ph3A4ZcgXWqFu1F5xGTfxncmrnf3sLC86dup2a8Kx7z3xQ3AgeNTQeFxPa/0/*,[0f056943/45h]tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n/0/*))#up0sw2xp" # PSBT created via fake_ms_txn, grinded in test_ms_sign_myself psbt_fname = "myself-72sig.psbt" with open(f"data/{psbt_fname}", "r") as f: b64psbt = f.read() goto_home() passphrase = "Myself" dev.send_recv(CCProtocolPacker.bip39_passphrase(passphrase), timeout=None) press_select() time.sleep(.1) title, story = cap_story() if 'Seed Vault' in story: press_select() time.sleep(.1) title, story = cap_story() assert "[747B698E]" in title press_select() time.sleep(.1) _, story = offer_ms_import(desc) assert "Create new multisig wallet?" in story \ or 'Update NAME only of existing multisig' in story time.sleep(.1) press_select() # below raises for 72 bytes long signature # only on firmware versions that do only 10 grinding iterations try_sign(base64.b64decode(b64psbt), accept=True) reset_seed_words() def test_null_data_op_return(fake_txn, start_sign, end_sign, reset_seed_words): reset_seed_words() psbt = fake_txn(1, 1, op_return=[(50, b"")]) start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) story = end_sign(accept=None, expect_txn=False).decode() assert "null-data" in story assert "OP_RETURN" in story def test_smallest_txn(fake_txn, start_sign, end_sign, reset_seed_words, settings_set): # serialized txn has just 62 bytes and is the smallest that we support # 1 input (iregardless of script type) and 1 zero value null OP_RETURN reset_seed_words() settings_set('fee_limit', -1) psbt = fake_txn(1, 0, op_return=[(10, b"")], input_amount=10) start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) story = end_sign(accept=None, expect_txn=False).decode() assert "null-data" in story assert "OP_RETURN" in story @pytest.mark.parametrize("num_outs", [1, 12]) @pytest.mark.parametrize("change", [True, False]) def test_zero_value_outputs(num_outs, change, fake_txn, start_sign, end_sign, reset_seed_words, settings_set): reset_seed_words() # user needs to disable fee limit checks to be able to do this settings_set("fee_limit", -1) change_outs = list(range(num_outs)) if change else [] psbt = fake_txn(1, num_outs, outvals=num_outs*[0], change_outputs=change_outs, input_amount=1) start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) story = end_sign(accept=None, expect_txn=False).decode() assert f"Zero Value: Non-standard zero value output(s)" in story assert "1 input" in story assert f"{num_outs} output{'' if num_outs == 1 else 's'}" in story assert 'Network fee 0.00000001 XTN' in story if change: assert "0.00000000 XTN" in story.split("\n\n")[4] # change back is zero assert "Consolidating 0.00000000 XTN" in story assert "Change back" in story if num_outs > 1: assert "to addresses" in story else: assert "to address" in story else: # even if num_outs == 12: # even tho we do not see 2 outputs, fee is also 0 and 2 smaller not shown here have also value o 0 assert story.count('0.00000000 XTN') == 12 else: assert story.count('0.00000000 XTN') == 2 assert "Change back" not in story @pytest.mark.parametrize("change", [True, False]) @pytest.mark.parametrize("num_ins", [True, False]) def test_zero_value_input(change, num_ins, fake_txn, start_sign, end_sign, reset_seed_words, cap_story): # 0 value inputs - not allowed reset_seed_words() psbt = fake_txn(1, 1, fee=0, input_amount=0) start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) with pytest.raises(Exception): end_sign(accept=None, expect_txn=False).decode() _, story = cap_story() assert "zero value txn" in story @pytest.mark.parametrize("change", [True, False]) def test_zero_value_inputs(change, fake_txn, start_sign, end_sign, reset_seed_words): # one input is-non zero # others are zero --> allowed reset_seed_words() invals = [0 for i in range(4)] + [100] psbt = fake_txn(5, 1, invals=invals, outvals=[99], change_outputs=[0] if change else [], fee=20) start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) end_sign(accept=None, expect_txn=False).decode() def test_negative_amount_inputs(reset_seed_words, fake_txn, start_sign, end_sign, cap_story): reset_seed_words() psbt = fake_txn(1, 1, fee=0, input_amount=-1000) start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) with pytest.raises(Exception): end_sign(accept=None, expect_txn=False).decode() _, story = cap_story() assert "negative input value: i0" in story def test_negative_amount_outputs(reset_seed_words, fake_txn, start_sign, end_sign, cap_story): reset_seed_words() psbt = fake_txn(1, 1, outvals=[-1000], fee=0) start_sign(psbt, False, stxn_flags=STXN_VISUALIZE) with pytest.raises(Exception): end_sign(accept=None, expect_txn=False).decode() _, story = cap_story() assert "negative output value: o0" in story def test_mk4_done_signing_infinite_loop(goto_home, try_sign, fake_txn, enable_hw_ux, settings_get, is_q1): if is_q1: raise pytest.skip("Irrelecant on Q as it always provides QR option") goto_home() had_nfc = settings_get("nfc", None) had_vdisk = settings_get("vidsk", None) enable_hw_ux("nfc", disable=True) enable_hw_ux("vdisk", disable=True) psbt = fake_txn(1, 2, segwit_in=True, change_outputs=[0]) try_sign(psbt, accept=True) # above never returns in unpatched version and fills up the disk if had_nfc: enable_hw_ux("nfc") if had_vdisk: enable_hw_ux("vdisk") @pytest.mark.bitcoind def test_finalize_with_foreign_inputs(bitcoind, bitcoind_d_sim_watch, start_sign, end_sign, cap_story, try_sign_microsd): # foreign inputs that have partial sigs filled # we still do not care about final_scriptsig & final_scriptwitness PSBT fields dest_address = bitcoind.supply_wallet.getnewaddress() alice = bitcoind.create_wallet(wallet_name="alice") bob = bitcoind.create_wallet(wallet_name="bob") cc = bitcoind_d_sim_watch alice_addr = alice.getnewaddress() bob_addr = bob.getnewaddress() cc_addr = cc.getnewaddress() # fund all addresses for addr in (alice_addr, bob_addr, cc_addr): bitcoind.supply_wallet.sendtoaddress(addr, 2.0) # mine above sends bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) psbt_list = [] for w in (alice, bob, cc): assert w.listunspent() psbt = w.walletcreatefundedpsbt([], [{dest_address: 1.0}], 0, {"fee_rate": 20})["psbt"] psbt_list.append(psbt) # join PSBTs to one the_psbt = bitcoind.supply_wallet.joinpsbts(psbt_list) # bitcoin core would just fill finalscriptwitness, we need partial signatures # just add dummy signatures and remove pp = BasicPSBT().parse(base64.b64decode(the_psbt)) for i in pp.inputs: assert len(i.bip32_paths) == 1 # single sigs der = list(i.bip32_paths.values())[0] if der[:4].hex().upper() == xfp2str(simulator_fixed_xfp): # our key continue pubkey = list(i.bip32_paths.keys())[0] assert not i.part_sigs # empty i.part_sigs[pubkey] = os.urandom(71) # dummy sig # USB works and our signature is added (but only if we do not finalize) psbt = pp.as_bytes() start_sign(psbt) signed = end_sign(accept=True) assert signed != psbt for i in BasicPSBT().parse(signed).inputs: assert i.part_sigs try_sign_microsd(psbt, finalize=True, accept=True) title, story = cap_story() assert title == "PSBT Signed" assert "Finalized transaction (ready for broadcast)" in story @pytest.mark.parametrize("psbt_v2", [False, True]) def test_txn_v3(psbt_v2, fake_txn, start_sign, end_sign, cap_story): psbt = fake_txn(2, 2, segwit_in=True, change_outputs=[0], outstyles=ADDR_STYLES_SINGLE, psbt_v2=psbt_v2) po = BasicPSBT().parse(psbt) if psbt_v2: po.txn_version = 3 else: txo = CTransaction() txo.deserialize(BytesIO(po.txn)) txo.nVersion = 3 po.txn = txo.serialize() start_sign(po.as_bytes()) title, story = cap_story() assert title == "OK TO SEND?" assert "Consolidating" not in story assert "Change back" in story assert "to script" not in story signed = end_sign(accept=True, finalize=False) assert signed po_signed = BasicPSBT().parse(signed) if psbt_v2: assert po_signed.version == 2 assert po_signed.txn is None assert po_signed.txn_version == 3 else: assert po_signed.txn assert po_signed.version == 0 assert po_signed.txn_version is None txo0 = CTransaction() txo0.deserialize(BytesIO(po_signed.txn)) assert txo0.nVersion == 3 @pytest.mark.parametrize("finalize", [False, True]) def test_txn_v3_eph_anchor(finalize, set_seed_words, start_sign, end_sign, cap_story): # https://github.com/portlandhodl/jade-docker-web-server/blob/main/example_tools/test_vectors.py TEST_PSBT_V3 = "cHNidP8BAFIDAAAAAdqopVcNAsr10z7dkzqG6J+3/vd10vriIDDe4O7u8GwfAQAAAAD9////AYOtdgAAAAAAFgAUlOv8+jtC6QYYNWgtA5ekaOGSfHH6WAQATwEENYfPA2elg8CAAAAA+dpuPB69NWUcEs/VfnCtrdxTyQG8xphTtTK6VDbgWHICWD2jKQxipNxf2bJk+0aHxEjn0N6nZD7MFrR/4o5QKZIQgLjDfFQAAIABAACAAAAAgAABAHECAAAAAU4xPHhslj2Nik1d9YVWuCSWLCjsI7ZTbACg4uOEtiJTAAAAAAD9////AqCKBgAAAAAAFgAUIzpZyckZ7FOvLecQynCBNbFwTf3xrXYAAAAAABYAFDpfSBYMJlSC2MY0FF4NUiMpxeZI8VgEAAEBH/GtdgAAAAAAFgAUOl9IFgwmVILYxjQUXg1SIynF5kgBAwQBAAAAIgYDz3o9vHG3Vkh9MjjDMaWr+SiQQZDs8AgzFTBs+xUnQkEYgLjDfFQAAIABAACAAAAAgAAAAAAAAAAAACICApGbj0iZaA6IAmws63XtQbV6hfJAxMKojI6QwWSIRflDGIC4w3xUAACAAQAAgAAAAIAAAAAAAQAAAAA=" TEST_PSBT_V3_EPHEMERAL_ANCHOR = "cHNidP8BAHEDAAAAAdqopVcNAsr10z7dkzqG6J+3/vd10vriIDDe4O7u8GwfAQAAAAD9////AoOtdgAAAAAAFgAUlOv8+jtC6QYYNWgtA5ekaOGSfHEAAAAAAAAAABYAFJTr/Po7QukGGDVoLQOXpGjhknxx+lgEAE8BBDWHzwNnpYPAgAAAAPnabjwevTVlHBLP1X5wra3cU8kBvMaYU7UyulQ24FhyAlg9oykMYqTcX9myZPtGh8RI59Dep2Q+zBa0f+KOUCmSEIC4w3xUAACAAQAAgAAAAIAAAQBxAgAAAAFOMTx4bJY9jYpNXfWFVrgkliwo7CO2U2wAoOLjhLYiUwAAAAAA/f///wKgigYAAAAAABYAFCM6WcnJGexTry3nEMpwgTWxcE398a12AAAAAAAWABQ6X0gWDCZUgtjGNBReDVIjKcXmSPFYBAABAR/xrXYAAAAAABYAFDpfSBYMJlSC2MY0FF4NUiMpxeZIAQMEAQAAACIGA896Pbxxt1ZIfTI4wzGlq/kokEGQ7PAIMxUwbPsVJ0JBGIC4w3xUAACAAQAAgAAAAIAAAAAAAAAAAAAiAgKRm49ImWgOiAJsLOt17UG1eoXyQMTCqIyOkMFkiEX5QxiAuMN8VAAAgAEAAIAAAACAAAAAAAEAAAAAIgICkZuPSJloDogCbCzrde1BtXqF8kDEwqiMjpDBZIhF+UMYgLjDfFQAAIABAACAAAAAgAAAAAABAAAAAA==" TEST_MNEMONIC = "tobacco tobacco tobacco tobacco tobacco tobacco tobacco tobacco tobacco tobacco tobacco tobacco" set_seed_words(TEST_MNEMONIC) v3 = base64.b64decode(TEST_PSBT_V3) po = BasicPSBT().parse(v3) assert po.version == 0 start_sign(v3, finalize=finalize) title, story = cap_story() assert title == "OK TO SEND?" assert "warning" not in story res = end_sign(finalize=finalize) if not finalize: po_signed = BasicPSBT().parse(res) assert po_signed.txn_version is None res = po_signed.txn txo = CTransaction() txo.deserialize(BytesIO(res)) assert txo.nVersion == 3 v3_eph_anchor = base64.b64decode(TEST_PSBT_V3_EPHEMERAL_ANCHOR) po = BasicPSBT().parse(v3) assert po.version == 0 start_sign(v3_eph_anchor, finalize=finalize) title, story = cap_story() assert title == "OK TO SEND?" assert "1 warning below" in story assert "Non-standard zero value output(s)" in story res = end_sign(finalize=finalize) if not finalize: po_signed = BasicPSBT().parse(res) assert po_signed.txn_version is None res = po_signed.txn txo = CTransaction() txo.deserialize(BytesIO(res)) assert txo.nVersion == 3 @pytest.mark.parametrize("stype", ["p2tr", "unknown", "no_utxo"]) def test_unknown_input_script(stype, fake_txn , start_sign, cap_story, use_testnet, txin_explorer): use_testnet() def hack(psbt): psbt.inputs[0].bip32_paths = None psbt.inputs[0].utxo = None psbt.inputs[0].witness_utxo = None if stype != "no_utxo": if stype == "p2tr": scr = bytes([81, 32]) + os.urandom(32) else: scr = bytes([90, 45]) + os.urandom(45) psbt.inputs[0].witness_utxo = CTxOut(100000000, scr).serialize() # second input is always ours if stype == "no_utxo": af = None else: af = stype ins = [(af, 100000000, 0), ("p2wpkh", 100000000, 1)] psbt = fake_txn(2, 2, segwit_in=True, change_outputs=[0], psbt_hacker=hack) start_sign(psbt) title, story = cap_story() assert title == "OK TO SEND?" txin_explorer(len(ins), ins) @pytest.mark.parametrize("mi", ["Inputs", "Outputs"]) def test_tx_explorer_goto_idx_single_item_yikes(mi, fake_txn, start_sign, cap_story, use_testnet, need_keypress, pick_menu_item, press_cancel, cap_menu): use_testnet() psbt = fake_txn(1, 1, segwit_in=True) start_sign(psbt) title, story = cap_story() assert title == "OK TO SEND?" need_keypress("2") pick_menu_item(mi) time.sleep(.1) title, story = cap_story() assert "(2)" not in story need_keypress("2") # must not yikes press_cancel() menu = cap_menu() assert "Inputs" in menu assert "Outputs" in menu press_cancel() press_cancel() def test_tx_explorer_goto_idx(fake_txn, start_sign, cap_story, use_testnet, need_keypress, pick_menu_item, cap_screen, enter_number, press_cancel, is_q1, goto_home): use_testnet() num_ins = 27 num_outs = 32 psbt = fake_txn(num_ins, num_outs, segwit_in=True, change_outputs=[0]) goto_home() start_sign(psbt) title, story = cap_story() assert title == "OK TO SEND?" need_keypress("2") pick_menu_item("Inputs") time.sleep(.1) title, story = cap_story() assert "(2)" in story need_keypress("2") time.sleep(.1) scr = cap_screen() assert f"0-{num_ins - 1}" in scr enter_number(15) time.sleep(.1) title, story = cap_story() assert title == "Input 15" need_keypress("2") enter_number(1) time.sleep(.1) title, story = cap_story() assert title == "Input 1" need_keypress("2") enter_number(59) # over max time.sleep(.1) title, story = cap_story() assert title == f"Input {num_ins - 1}" press_cancel() pick_menu_item("Outputs") time.sleep(.1) title, story = cap_story() assert "(2)" in story need_keypress("2") enter_number(4) time.sleep(.1) title, story = cap_story() assert title == f"4-{4 + 10 - 1}" # try to move left need_keypress(KEY_LEFT if is_q1 else "7") time.sleep(.1) title, story = cap_story() assert title == f"0-9" need_keypress("2") enter_number(59) time.sleep(.1) title, story = cap_story() num = num_outs - 1 assert title == f"{num}-{num}" for _ in range(3): press_cancel() def test_input_explorer_foreign_bad_sighash(fake_txn, start_sign, cap_story, need_keypress, pick_menu_item, press_cancel, use_testnet, goto_home): # PSBT has a foreign input (not ours) carrying a PSBT_IN_SIGHASH_TYPE value # outside ALL_SIGHASH_FLAGS. consider_dangerous_sighash() only validates # our-key inputs, so the PSBT passes validation and reaches the approval UX. # Browsing the TX Explorer -> Inputs must not crash on the foreign input. use_testnet() def hack(psbt): # Make input 0 foreign: replace its xfp prefix with a non-matching one. foreign_xfp = b"\xab\xcd\xef\x01" new_paths = {} for pk, path_bytes in psbt.inputs[0].bip32_paths.items(): new_paths[pk] = foreign_xfp + path_bytes[4:] psbt.inputs[0].bip32_paths = new_paths psbt.inputs[0].sighash = 0x05 psbt = fake_txn(2, 2, segwit_in=True, psbt_hacker=hack) goto_home() start_sign(psbt) time.sleep(.1) title, _ = cap_story() assert title == "OK TO SEND?" need_keypress("2") time.sleep(.1) pick_menu_item("Inputs") time.sleep(.2) title, story = cap_story() # foreign input shown first; must render the raw value, not crash assert title == "Input 0" assert "sighash: 0x05 (non-standard)" in story press_cancel() press_cancel() press_cancel() @pytest.mark.parametrize("segwit", [True, False]) def test_txn_nVersion_zero(segwit, fake_txn, start_sign, cap_story, goto_home): goto_home() def hack(psbt): if psbt.is_v2(): psbt.txn_version = 0 else: t = CTransaction() t.deserialize(BytesIO(psbt.txn)) t.nVersion = 0 psbt.txn = t.serialize() psbt = fake_txn(1, 2, segwit_in=segwit, change_outputs=[0], psbt_hacker=hack) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert title == "Failure" assert "txn version" in story @pytest.mark.parametrize("segwit_in", [True, False]) @pytest.mark.parametrize("num_ins", [2, 110]) def test_duplicate_inputs(segwit_in, num_ins, fake_txn, start_sign, end_sign, cap_story, goto_home): goto_home() psbt = fake_txn(num_ins, 2, segwit_in=segwit_in, dupe_ins=[num_ins-1]) start_sign(psbt) title, story = cap_story() if num_ins <= 100: assert "Duplicate input" in story else: assert title == "OK TO SEND?" def test_txid_qr(fake_txn, start_sign, cap_story, press_cancel, press_select, goto_home): goto_home() psbt = fake_txn(1, 2, change_outputs=[0]) start_sign(psbt, finalize=False) press_select() # confirm signing time.sleep(.1) title, story = cap_story() assert "(6) for QR Code of TXID" not in story press_cancel() # refuse time.sleep(.1) start_sign(psbt, finalize=True) press_select() # confirm signing time.sleep(.1) title, story = cap_story() assert "(6) for QR Code of TXID" in story press_cancel() @pytest.mark.parametrize("segwit_in", [True, False]) def test_empty_input_scriptPubKey(segwit_in, dev, fake_txn, start_sign, cap_story): def hack(psbt): target_idx = 0 if segwit_in: txo = CTxOut() txo.deserialize(BytesIO(psbt.inputs[target_idx].witness_utxo)) txo.scriptPubKey = b"" psbt.inputs[target_idx].witness_utxo = txo.serialize() else: supply_tx = CTransaction() supply_tx.deserialize(BytesIO(psbt.inputs[target_idx].utxo)) supply_tx.vout[0].scriptPubKey = b"" psbt.inputs[target_idx].utxo = supply_tx.serialize_with_witness() supply_tx.calc_sha256() spend_tx = CTransaction() spend_tx.deserialize(BytesIO(psbt.txn)) spend_tx.vin[target_idx] = CTxIn( COutPoint(supply_tx.sha256, 0), nSequence=spend_tx.vin[target_idx].nSequence, ) psbt.txn = spend_tx.serialize_with_witness() psbt = fake_txn(2, 1, dev.master_xpub, psbt_hacker=hack, segwit_in=segwit_in) start_sign(psbt) title, _ = cap_story() assert title == "Failure" @pytest.mark.bitcoind @pytest.mark.parametrize('finalize', [True, False]) @pytest.mark.parametrize('mode', ['compressed', 'uncompressed']) def test_spend_p2pk(mode, finalize, bitcoind, bitcoind_d_wallet, dev, start_sign, end_sign, cap_story, use_regtest, goto_home): use_regtest() goto_home() xfp_str = xfp2str(dev.master_fingerprint).lower() path = "44h/1h/0h/0/0" xpub = dev.send_recv(CCProtocolPacker.get_xpub("m/" + path), timeout=5000) node = BIP32Node.from_wallet_key(xpub) if mode == 'compressed': pubkey_hex = node.node.public_key.sec(compressed=True).hex() else: pubkey_hex = node.node.public_key.sec(compressed=False).hex() # pk() descriptor with origin info — Core writes the (xfp, path) into the # PSBT's BIP32_DERIVATION so Coldcard recognizes it as its own key. desc = "pk([%s/%s]%s)" % (xfp_str, path, pubkey_hex) desc = bitcoind.rpc.getdescriptorinfo(desc)["descriptor"] res = bitcoind_d_wallet.importdescriptors([{ "desc": desc, "timestamp": "now", "watchonly": True, }]) assert res[0]["success"], res # Fund the P2PK output. No Core RPC accepts a raw scriptPubKey as an # output, so we hand-build a skeleton tx with the P2PK output and let # fundrawtransaction fill in inputs, change, and fee from supply_wallet. push_op = b'\x21' if mode == 'compressed' else b'\x41' spk = push_op + bytes.fromhex(pubkey_hex) + b'\xac' txn = CTransaction() txn.vout = [CTxOut(5_00_000_000, spk)] # 5 BTC funded = bitcoind.supply_wallet.fundrawtransaction(txn.serialize().hex()) signed = bitcoind.supply_wallet.signrawtransactionwithwallet(funded["hex"]) bitcoind.rpc.sendrawtransaction(signed["hex"]) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) assert bitcoind_d_wallet.listunspent() dest = bitcoind.supply_wallet.getnewaddress() resp = bitcoind_d_wallet.walletcreatefundedpsbt( [], [{dest: bitcoind_d_wallet.getbalance()}], 0, {"fee_rate": 3, "subtractFeeFromOutputs": [0]}) psbt_bytes = base64.b64decode(resp["psbt"]) # Sanity: confirm Core encoded the pubkey form we asked for in the PSBT's # BIP32_DERIVATION (key field). 33 bytes for compressed, 65 for uncompressed. # Single-sig P2PK has exactly one entry; `all` rejects any extra entries # in unexpected form. expect_pk_len = 33 if mode == 'compressed' else 65 pre_po = BasicPSBT().parse(psbt_bytes) pre_keys = list(pre_po.inputs[0].bip32_paths.keys()) assert pre_keys assert all(len(k) == expect_pk_len for k in pre_keys) start_sign(psbt_bytes, finalize=finalize) time.sleep(.1) title, story = cap_story() assert title == "OK TO SEND?" out = end_sign(accept=True, finalize=finalize) if finalize: # Coldcard returned the network tx; broadcast directly. tx_hex = out.hex() else: # Coldcard returned a partially-signed PSBT. Verify the partial sig # carries the same-form pubkey as keydata, then have bitcoind finalize. signed_po = BasicPSBT().parse(out) sig_keys = list(signed_po.inputs[0].part_sigs.keys()) assert sig_keys assert all(len(k) == expect_pk_len for k in sig_keys) finalized = bitcoind.rpc.finalizepsbt(base64.b64encode(out).decode()) assert finalized["complete"], finalized tx_hex = finalized["hex"] accept = bitcoind.rpc.testmempoolaccept([tx_hex]) assert accept[0]["allowed"], accept txid = bitcoind.rpc.sendrawtransaction(tx_hex) assert len(txid) == 64 goto_home() @pytest.mark.parametrize('finalize', [True, False]) @pytest.mark.parametrize('mode', ['compressed', 'uncompressed']) def test_fake_txn_spend_p2pk(mode, finalize, fake_txn, start_sign, end_sign, cap_story): expect_pk_len = 33 if mode == 'compressed' else 65 style = 'p2pk' if mode == 'compressed' else 'p2pk-uncompressed' psbt = fake_txn(1, 2, p2pk_in=mode, outstyles=[style, 'p2pkh'], change_outputs=[0]) pre = BasicPSBT().parse(psbt) change_keys = list(pre.outputs[0].bip32_paths.keys()) assert change_keys and all(len(k) == expect_pk_len for k in change_keys) start_sign(psbt, finalize=finalize) time.sleep(.1) title, story = cap_story() assert title == "OK TO SEND?" assert "Change back:" in story out = end_sign(accept=True, finalize=finalize) if not finalize: signed = BasicPSBT().parse(out) sig_keys = list(signed.inputs[0].part_sigs.keys()) assert sig_keys and all(len(k) == expect_pk_len for k in sig_keys) @pytest.mark.parametrize('mode', ['compressed', 'uncompressed']) @pytest.mark.parametrize('change', [True, False]) def test_p2pk_change_output_renders(mode, change, fake_txn, start_sign, cap_story, dev, goto_home, need_keypress, pick_menu_item, press_cancel): master_xpub = dev.master_xpub or simulator_fixed_tprv mk = BIP32Node.from_wallet_key(master_xpub) xfp = mk.fingerprint() input_leaf = mk.subkey_for_path("0/0") input_pk = input_leaf.node.public_key.sec(compressed=(mode == 'compressed')) input_spk = bytes([len(input_pk)]) + input_pk + b'\xac' leaf = mk.subkey_for_path("0/77") if mode == 'compressed': pk = leaf.node.public_key.sec(compressed=True) push_op = b'\x21' else: pk = leaf.node.public_key.sec(compressed=False) push_op = b'\x41' assert len(pk) == (33 if mode == 'compressed' else 65) p2pk_spk = push_op + pk + b'\xac' # OP_CHECKSIG def hack(psbt): t = CTransaction() t.deserialize(BytesIO(psbt.txn)) t.vout[0].scriptPubKey = p2pk_spk psbt.txn = t.serialize_with_witness() if change: psbt.outputs[0].bip32_paths = {pk: xfp + struct.pack('