# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # # Multisig-related tests. # # After this file passes, also run again like this: # # py.test test_multisig.py -m ms_danger --ms-danger # import time, pytest, os, random, json, shutil, pdb from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput, PSBT_IN_REDEEM_SCRIPT from ckcc.protocol import CCProtocolPacker, CCProtoError, MAX_TXN_LEN, CCUserRefused from pprint import pprint, pformat from base64 import b64encode, b64decode from helpers import B2A, U2SAT, prandom, fake_dest_addr, swab32, xfp2str, parse_change_back from helpers import path_to_str, str_to_path, slip132undo from struct import unpack, pack from constants import * from pycoin.key.BIP32Node import BIP32Node from pycoin.encoding import a2b_hashed_base58 from io import BytesIO from hashlib import sha256 from test_bip39pw import set_bip39_pw def HARD(n=0): return 0x80000000 | n def str2ipath(s): # convert text to numeric path for BIP-174 for i in s.split('/'): if i == 'm': continue if not i: continue # trailing or duplicated slashes if i[-1] in "'ph": assert len(i) >= 2, i here = int(i[:-1]) | 0x80000000 else: here = int(i) assert 0 <= here < 0x80000000, here yield here @pytest.fixture(scope='function') def has_ms_checks(request, sim_exec): # Add this fixture to any test that should FAIL if ms checks are disabled # - in other words, tests that test the checks which are disabled. # - still need to run w/ --ms-danger flag set to test those cases # - also mark testcase with ms_danger danger_mode = (request.config.getoption('--ms-danger')) if danger_mode: print("Enabling multisig danger mode") request.node.add_marker(pytest.mark.xfail(True, strict=True, reason="check was bypassed, so testcase should fail")) sim_exec(f'from multisig import MultisigWallet; MultisigWallet.disable_checks={danger_mode}') return danger_mode @pytest.fixture() def bitcoind_p2sh(bitcoind): # Use bitcoind to generate a p2sh addres based on public keys. def doit(M, pubkeys, fmt): fmt = { AF_P2SH: 'legacy', AF_P2WSH: 'bech32', AF_P2WSH_P2SH: 'p2sh-segwit' }[fmt] try: rv = bitcoind.createmultisig(M, [B2A(i) for i in pubkeys], fmt) except ConnectionResetError: # bitcoind sleeps on us sometimes, give it another chance. rv = bitcoind.createmultisig(M, [B2A(i) for i in pubkeys], fmt) return rv['address'], rv['redeemScript'] return doit @pytest.fixture def clear_ms(unit_test): def doit(): unit_test('devtest/wipe_ms.py') return doit @pytest.fixture() def make_multisig(): # make a multsig wallet, always with simulator as an element # default is BIP-45: m/45'/... (but no co-signer idx) # - but can provide str format for deriviation, use {idx} for cosigner idx def doit(M, N, unique=0, deriv=None): keys = [] for i in range(N-1): pk = BIP32Node.from_master_secret(b'CSW is a fraud %d - %d' % (i, unique), 'XTN') xfp = unpack(" data = [] for cosigner_idx, (xfp, node, sk) in enumerate(keys): path = path_mapper(cosigner_idx) #print("path: " + ' / '.join(hex(i) for i in path)) if not node: # use xpubkey, otherwise master dpath = path[sk.tree_depth():] assert not dpath or max(dpath) < 1000 node = sk else: dpath = path for p in dpath: node = node.subkey(p & ~0x80000000, is_hardened=bool(p & 0x80000000)) pk = node.sec(use_uncompressed=False) data.append( (pk, xfp, path)) #print("path: %s => pubkey %s" % (path_to_str(path, skip=0), B2A(pk))) data.sort(key=lambda i:i[0]) if violate_bip67: # move them out of order data[0], data[1] = data[1], data[0] mm = [80 + M] if M <= 16 else [1, M] nn = [80 + N] if N <= 16 else [1, N] rv = bytes(mm) if tweak_pubkeys: tweak_pubkeys(data) for pk,_,_ in data: rv += bytes([len(pk)]) + pk rv += bytes(nn + [0xAE]) if tweak_redeem: rv = tweak_redeem(rv) #print("redeem script: " + B2A(rv)) xfp_paths = [[xfp]+xpath for _,xfp,xpath in data] #print("xfp_paths: " + repr(xfp_paths)) if tweak_xfps: tweak_xfps(xfp_paths) if finalizer_hack: rv = finalizer_hack(rv) return rv, [pk for pk,_,_ in data], xfp_paths def make_ms_address(M, keys, idx=0, is_change=0, addr_fmt=AF_P2SH, testnet=1, **make_redeem_args): # Construct addr and script need to represent a p2sh address import bech32 from pycoin.encoding import b2a_hashed_base58, hash160 if 'path_mapper' not in make_redeem_args: make_redeem_args['path_mapper'] = lambda cosigner: [HARD(45), cosigner, is_change, idx] script, pubkeys, xfp_paths = make_redeem(M, keys, **make_redeem_args) if addr_fmt == AF_P2WSH: hrp = ['bc', 'tb'][testnet] data = sha256(script).digest() addr = bech32.encode(hrp, 0, data) scriptPubKey = bytes([0x0, 0x20]) + data else: if addr_fmt == AF_P2SH: digest = hash160(script) elif addr_fmt == AF_P2WSH_P2SH: digest = hash160(b'\x00\x20' + sha256(script).digest()) else: raise ValueError(addr_fmt) prefix = bytes([196]) if testnet else bytes([5]) addr = b2a_hashed_base58(prefix + digest) scriptPubKey = bytes([0xa9, 0x14]) + digest + bytes([0x87]) return addr, scriptPubKey, script, zip(pubkeys, xfp_paths) @pytest.fixture def test_ms_show_addr(dev, cap_story, need_keypress, addr_vs_path, bitcoind_p2sh, has_ms_checks): def doit(M, keys, addr_fmt=AF_P2SH, bip45=True, **make_redeem_args): # test we are showing addresses correctly # - verifies against bitcoind as well addr_fmt = unmap_addr_fmt.get(addr_fmt, addr_fmt) # make a redeem script, using provided keys/pubkeys if bip45: make_redeem_args['path_mapper'] = lambda i: [HARD(45), i, 0,0] scr, pubkeys, xfp_paths = make_redeem(M, keys, **make_redeem_args) assert len(scr) <= 520, "script too long for standard!" got_addr = dev.send_recv(CCProtocolPacker.show_p2sh_address( M, xfp_paths, scr, addr_fmt=addr_fmt), timeout=None) title, story = cap_story() #print(story) if not has_ms_checks: assert got_addr in story assert all((xfp2str(xfp) in story) for xfp,_,_ in keys) if bip45: for i in range(len(keys)): assert ('/_/%d/0/0' % i) in story else: assert 'UNVERIFIED' in story need_keypress('y') # check expected addr was generated based on my math addr_vs_path(got_addr, addr_fmt=addr_fmt, script=scr) # also check against bitcoind core_addr, core_scr = bitcoind_p2sh(M, pubkeys, addr_fmt) assert B2A(scr) == core_scr assert core_addr == got_addr return doit @pytest.mark.parametrize('m_of_n', [(1,3), (2,3), (3,3), (3,6), (10, 15), (15,15)]) @pytest.mark.parametrize('addr_fmt', ['p2sh-p2wsh', 'p2sh', 'p2wsh' ]) def test_import_ranges(m_of_n, addr_fmt, clear_ms, import_ms_wallet, need_keypress, test_ms_show_addr): M, N = m_of_n keys = import_ms_wallet(M, N, addr_fmt, accept=1) #print("imported: %r" % [x for x,_,_ in keys]) try: # test an address that should be in that wallet. time.sleep(.1) test_ms_show_addr(M, keys, addr_fmt=addr_fmt) finally: clear_ms() @pytest.mark.ms_danger def test_violate_bip67(clear_ms, import_ms_wallet, need_keypress, test_ms_show_addr, has_ms_checks): # detect when pubkeys are not in order in the redeem script M, N = 1, 15 keys = import_ms_wallet(M, N, accept=1) try: # test an address that should be in that wallet. time.sleep(.1) with pytest.raises(BaseException) as ee: test_ms_show_addr(M, keys, violate_bip67=1) assert 'BIP-67' in str(ee.value) finally: clear_ms() @pytest.mark.parametrize('which_pubkey', [0, 1, 14]) def test_bad_pubkey(has_ms_checks, clear_ms, import_ms_wallet, need_keypress, test_ms_show_addr, which_pubkey): # give incorrect pubkey inside redeem script M, N = 1, 15 keys = import_ms_wallet(M, N, accept=1) try: # test an address that should be in that wallet. time.sleep(.1) def tweaker(scr): # corrupt the pubkey return bytes((s if i != (5 + (34*which_pubkey)) else s^0x1) for i,s in enumerate(scr)) with pytest.raises(BaseException) as ee: test_ms_show_addr(M, keys, tweak_redeem=tweaker) assert ('pk#%d wrong' % (which_pubkey+1)) in str(ee.value) finally: clear_ms() @pytest.mark.parametrize('addr_fmt', ['p2sh-p2wsh', 'p2sh', 'p2wsh' ]) def test_zero_depth(clear_ms, addr_fmt, import_ms_wallet, need_keypress, test_ms_show_addr, make_multisig): # test having a co-signer with "m" only key ... ie. depth=0 M, N = 1, 2 keys = make_multisig(M, N, unique=99) # censor first co-signer to look like a master key kk = keys[0][1].public_copy() kk._depth = 0 kk._child_index = 0 kk._parent_fingerprint = b'\0\0\0\0' keys[0] = (keys[0][0], keys[0][1], kk) try: keys = import_ms_wallet(M, N, accept=1, keys=keys, addr_fmt=addr_fmt, derivs=["m", "m/45'"]) def pm(i): return [] if i == 0 else [HARD(45), i, 0,0] test_ms_show_addr(M, keys, bip45=False, path_mapper=pm) finally: clear_ms() @pytest.mark.parametrize('mode', ['wrong-xfp', 'long-path', 'short-path', 'zero-path']) @pytest.mark.ms_danger def test_bad_xfp(mode, clear_ms, import_ms_wallet, need_keypress, test_ms_show_addr, has_ms_checks, request): # give incorrect xfp+path args during show_address if has_ms_checks and (mode in {'zero-path', 'wrong-xfp'}): # for these 2 cases, we detect the issue regardless of has_ms_checks mode request.node.get_closest_marker('xfail').kwargs['strict'] = False M, N = 1, 15 keys = import_ms_wallet(M, N, accept=1) try: time.sleep(.1) def tweaker(xfps): print(f"xfps={xfps}") if mode == 'wrong-xfp': # bad XFP => not right multisig wallet xfps[0][0] ^= 0x55 elif mode == 'long-path': # add garbage xfps[0].extend([69, 69, 69, 69, 69]) elif mode == 'short-path': # trim last derivation part xfps[0] = xfps[0][0:-1] elif mode == 'zero-path': # just XFP, no path xfps[0] = xfps[0][0:1] else: raise ValueError with pytest.raises(BaseException) as ee: test_ms_show_addr(M, keys, tweak_xfps=tweaker) if mode in { 'wrong-xfp', 'zero-path' }: assert 'with those fingerprints not found' in str(ee.value) else: assert 'pk#1 wrong' in str(ee.value) if ('zero' in mode): assert 'shallow' in str(ee.value) finally: clear_ms() @pytest.mark.parametrize('cpp', [ "m///", "m/", "m/1/2/3/4/5/6/7/8/9/10/11/12/13", # assuming MAX_PATH_DEPTH==12 ]) def test_bad_common_prefix(cpp, clear_ms, import_ms_wallet, need_keypress, test_ms_show_addr): # give some incorrect path values as the common prefix derivation M, N = 1, 15 with pytest.raises(BaseException) as ee: keys = import_ms_wallet(M, N, accept=1, common=cpp) assert 'bad derivation line' in str(ee) def test_import_detail(clear_ms, import_ms_wallet, need_keypress, cap_story): # check all details are shown right M,N = 14, 15 keys = import_ms_wallet(M, N) time.sleep(.2) need_keypress('1') time.sleep(.1) title, story = cap_story() #assert title == f'{M} of {N}' assert title == f'test-{M}-{N}' xpubs = [sk.hwif() for _,_,sk in keys] for xp in xpubs: assert xp in story need_keypress('x') time.sleep(.1) need_keypress('x') @pytest.mark.parametrize('acct_num', [ 0, 99, 123]) def test_export_airgap(acct_num, goto_home, cap_story, pick_menu_item, cap_menu, need_keypress, microsd_path): # test UX and math for bip45 export goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item('Export XPUB') time.sleep(.1) title, story = cap_story() assert 'BIP-48' in story assert "m/45'" not in story assert "m/48'/" in story assert "acct'" in story need_keypress('y') # enter account number every time time.sleep(.1) for n in str(acct_num): need_keypress(n) need_keypress('y') time.sleep(.1) title, story = cap_story() fname = story.split('\n')[-1] assert fname.startswith('ccxp-') assert fname.endswith('.json') with open(microsd_path(fname), 'rt') as fp: rv = json.load(fp) assert 'xfp' in rv assert len(rv) >= 6 e = BIP32Node.from_wallet_key(simulator_fixed_xprv) if 'p2sh' in rv: # perhaps obsolete, but not removed assert acct_num == 0 n = BIP32Node.from_wallet_key(rv['p2sh']) assert n.tree_depth() == 1 assert n.child_index() == 45 | (1<<31) mxfp = unpack(" # - the constructed multisig walelt will only work for a single pubkey on core side # - before starting this test, have some funds already deposited to bitcoind testnet wallet from pycoin.encoding import sec_to_public_pair from binascii import a2b_hex import re if addr_style == 'legacy': addr_fmt = AF_P2SH elif addr_style == 'p2sh-segwit': addr_fmt = AF_P2WSH_P2SH elif addr_style == 'bech32': addr_fmt = AF_P2WSH try: addr, = bitcoind.getaddressesbylabel("sim-cosign").keys() except: addr = bitcoind.getnewaddress("sim-cosign") info = bitcoind.getaddressinfo(addr) #pprint(info) assert info['address'] == addr bc_xfp = swab32(int(info['hdmasterfingerprint'], 16)) bc_deriv = info['hdkeypath'] # example: "m/0'/0'/3'" bc_pubkey = info['pubkey'] # 02f75ae81199559c4aa... pp = sec_to_public_pair(a2b_hex(bc_pubkey)) # No means to export XPUB from bitcoind! Still. In 2019. # - this fake will only work for for one pubkey value, the first/topmost node = BIP32Node('XTN', b'\x23'*32, depth=len(bc_deriv.split('/'))-1, parent_fingerprint=a2b_hex('%08x' % bc_xfp), public_pair=pp) keys = [ (bc_xfp, None, node), (simulator_fixed_xfp, None, BIP32Node.from_hwif('tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n')), # simulator: m/45' ] M,N=2,2 clear_ms() import_ms_wallet(M, N, keys=keys, accept=1, name="core-cosign", derivs=[bc_deriv, "m/45'"]) cc_deriv = "m/45'/55" cc_pubkey = B2A(BIP32Node.from_hwif(simulator_fixed_xprv).subkey_for_path(cc_deriv[2:]).sec()) # NOTE: bitcoind doesn't seem to implement pubkey sorting. We have to do it. resp = bitcoind.addmultisigaddress(M, list(sorted([cc_pubkey, bc_pubkey])), 'shared-addr-'+addr_style, addr_style) ms_addr = resp['address'] bc_redeem = a2b_hex(resp['redeemScript']) assert bc_redeem[0] == 0x52 def mapper(cosigner_idx): return list(str2ipath(cc_deriv if cosigner_idx else bc_deriv)) scr, pubkeys, xfp_paths = make_redeem(M, keys, mapper) assert scr == bc_redeem # check Coldcard calcs right address to match got_addr = dev.send_recv(CCProtocolPacker.show_p2sh_address( M, xfp_paths, scr, addr_fmt=addr_fmt), timeout=None) assert got_addr == ms_addr time.sleep(.1) need_keypress('x') # clear screen / start over print(f"Will be signing an input from {ms_addr}") if xfp2str(bc_xfp) in ('5380D0ED', 'EDD08053'): # my own expected values assert ms_addr in ( '2NDT3ymKZc8iMfbWqsNd1kmZckcuhixT5U4', '2N1hZJ5mazTX524GQTPKkCT4UFZn5Fqwdz6', 'tb1qpcv2rkc003p5v8lrglrr6lhz2jg8g4qa9vgtrgkt0p5rteae5xtqn6njw9') # Need some UTXO to sign # # - but bitcoind can't give me that (using listunspent) because it's only a watched addr?? # did_fund = False while 1: rr = explora('address', ms_addr, 'utxo') pprint(rr) avail = [] amt = 0 for i in rr: txn = i['txid'] vout = i['vout'] avail.append( (txn, vout) ) amt += i['value'] # just use first UTXO available; save other for later tests break else: # doesn't need to confirm, but does need to reach public testnet/blockstream assert not amt and not avail if not did_fund: print(f"Sending some XTN to {ms_addr} (wait)") bitcoind.sendtoaddress(ms_addr, 0.0001, 'fund testing') did_fund = True else: print(f"Still waiting ...") time.sleep(2) if amt: break ret_addr = bitcoind.getrawchangeaddress() ''' If you get insufficent funds, even tho we provide the UTXO (!!), do this: bitcoin-cli importaddress "2NDT3ymKZc8iMfbWqsNd1kmZckcuhixT5U4" true true Better method: always fund addresses for testing here from same wallet (ie. got from non-multisig to multisig on same bitcoin-qt instance). -> Now doing that, automated, above. ''' resp = bitcoind.walletcreatefundedpsbt([dict(txid=t, vout=o) for t,o in avail], [{ret_addr: amt/1E8}], 0, {'subtractFeeFromOutputs': [0], 'includeWatching': True}, True) assert resp['changepos'] == -1 psbt = b64decode(resp['psbt']) open('debug/funded.psbt', 'wb').write(psbt) # patch up the PSBT a little ... bitcoind doesn't know the path for the CC's key ex = BasicPSBT().parse(psbt) cxpk = a2b_hex(cc_pubkey) for i in ex.inputs: assert cxpk in i.bip32_paths, 'input not to be signed by CC?' i.bip32_paths[cxpk] = pack('<3I', keys[1][0], *str2ipath(cc_deriv)) psbt = ex.as_bytes() open('debug/patched.psbt', 'wb').write(psbt) _, updated = try_sign(psbt, finalize=False) open('debug/cc-updated.psbt', 'wb').write(updated) # have bitcoind do the rest of the signing rr = bitcoind.walletprocesspsbt(b64encode(updated).decode('ascii')) pprint(rr) open('debug/bc-processed.psbt', 'wt').write(rr['psbt']) assert rr['complete'] # finalize and send rr = bitcoind.finalizepsbt(rr['psbt'], True) open('debug/bc-final-txn.txn', 'wt').write(rr['hex']) assert rr['complete'] txn_id = bitcoind.sendrawtransaction(rr['hex']) print(txn_id) @pytest.mark.parametrize('addr_fmt', [AF_P2WSH] ) @pytest.mark.parametrize('num_ins', [ 3]) @pytest.mark.parametrize('incl_xpubs', [ False]) @pytest.mark.parametrize('out_style', ['p2wsh']) @pytest.mark.parametrize('bitrot', list(range(0,6)) + [98, 99, 100] + list(range(-5, 0))) @pytest.mark.ms_danger def test_ms_sign_bitrot(num_ins, dev, addr_fmt, clear_ms, incl_xpubs, import_ms_wallet, addr_vs_path, fake_ms_txn, start_sign, end_sign, out_style, cap_story, bitrot, has_ms_checks): M = 1 N = 3 num_outs = 2 clear_ms() keys = import_ms_wallet(M, N, accept=1) # given script, corrupt it a little or a lot def rotten(track, bitrot, scr): if bitrot == 98: rv = scr + scr elif bitrot == 98: rv = scr[::-1] elif bitrot == 100: rv = scr*3 else: rv = bytearray(scr) rv[bitrot] ^= 0x01 track.append(rv) return rv track = [] psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=incl_xpubs, outstyles=[out_style], change_outputs=[0], hack_change_out=lambda idx: dict(finalizer_hack= lambda scr: rotten(track, bitrot, scr))) assert len(track) == 1 open('debug/last.psbt', 'wb').write(psbt) start_sign(psbt) with pytest.raises(Exception) as ee: signed = end_sign(True) assert 'Output#0:' in str(ee) assert 'change output script' in str(ee) # Check error details are shown time.sleep(.5) title, story = cap_story() assert story.strip() in str(ee) assert len(story.split(':')[-1].strip()), story @pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH] ) @pytest.mark.parametrize('num_ins', [ 1]) @pytest.mark.parametrize('incl_xpubs', [ True]) @pytest.mark.parametrize('out_style', ['p2wsh']) @pytest.mark.parametrize('pk_num', range(4)) @pytest.mark.parametrize('case', ['pubkey', 'path']) def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, make_multisig, addr_vs_path, fake_ms_txn, start_sign, end_sign, out_style, cap_story): M = 1 N = 3 num_outs = 2 clear_ms() keys = make_multisig(M, N) # given def tweak(case, pk_num, data): # added from make_redeem() as tweak_pubkeys option #(pk, xfp, path)) assert len(data) == N if case == 'xpub': return if pk_num == 3: pk_num = [xfp for _,xfp,_ in data].index(simulator_fixed_xfp) pk, xfp, path = data[pk_num] if case == 'pubkey': pk = pk[:-2] + bytes(2) elif case == 'path': path[-1] ^= 0x1 else: assert False, case data[pk_num] = (pk, xfp, path) psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=True, outstyles=[out_style], change_outputs=[0], hack_change_out=lambda idx: dict(tweak_pubkeys= lambda data: tweak(case, pk_num, data))) open('debug/last.psbt', 'wb').write(psbt) with pytest.raises(Exception) as ee: start_sign(psbt) signed = end_sign(accept=True, accept_ms_import=False) assert 'Output#0:' in str(ee) assert 'P2WSH or P2SH change output script' in str(ee) #assert 'Deception regarding change output' in str(ee) # Check error details are shown time.sleep(.5) title, story = cap_story() assert story.strip() in str(ee.value.args[0]) assert len(story.split(':')[-1].strip()), story @pytest.mark.parametrize('repeat', range(2) ) def test_iss6743(repeat, set_seed_words, sim_execfile, try_sign): # from SomberNight psbt_b4 = bytes.fromhex('''\ 70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae3000008001000080000000800100008000000000030000000000''') # pre 3.2.0 result psbt_wrong = bytes.fromhex('''\ 70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100a85d08eef6675803fe2b58dda11a553641080e07da36a2f3e116f1224201931b022071b0ba83ef920d49b520c37993c039d13ae508a1adbd47eb4b329713fcc8baef01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000''') psbt_right = bytes.fromhex('''\ 70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100ae90a7e4c350389816b03af0af46df59a2f53da04cc95a2abd81c0bbc5950c1d02202f9471d6b0664b7a46e81da62d149f688adc7ba2b3413372d26fa618a8460eba01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000''') seed_words = 'all all all all all all all all all all all all' expect_xfp = swab32(int('5c9e228d', 16)) assert xfp2str(expect_xfp) == '5c9e228d'.upper() # load specific private key xfp = set_seed_words(seed_words) assert xfp == expect_xfp # check Coldcard derives expected Upub derivation = "m/48'/1'/0'/1'" # part of devtest/unit_iss6743.py expect_xpub = 'Upub5SJWbuhs5tM4mkJST69tnpGGaf8dDTqByx3BLSocWFpq5YLh1fky4DQTFGQVG6nCSqZfUiAAeStdxSQteUcfMsWjDkhniZx4GdwpB18Tnbq' pub = sim_execfile('devtest/unit_iss6743.py') assert pub == expect_xpub # verify psbt globals section tp = BasicPSBT().parse(psbt_b4) (hdr_xpub, hdr_path), = [(v,k) for v,k in tp.xpubs if k[0:4] == pack(' %s was %d, gonna be %d' % ( xfp2str(xfp), dp, sk._depth, dp.count('/'))) sk._depth = dp.count('/') config += '%s: %s\n' % (xfp2str(xfp), sk.hwif(as_private=False)) title, story = offer_ms_import(config) assert f'Policy: {M} of {N}\n' in story assert f'P2SH-P2WSH' in story assert 'Derivation:\n Varies' in story assert f' Varies ({len(set(derivs))})\n' in story need_keypress('y') goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item(f'{M}/{N}: impmany') pick_menu_item('Coldcard Export') time.sleep(.1) title, story = cap_story() fname = story.split('\n')[-1] assert fname, story need_keypress('y') with open(microsd_path(fname), 'rt') as fp: lines = list(fp.readlines()) for xfp,_,_ in keys: m = xfp2str(xfp) assert any(m in ln for ln in lines) pick_menu_item('Electrum Wallet') time.sleep(.25) title, story = cap_story() assert 'This saves a skeleton Electrum wallet file' in story need_keypress('y') time.sleep(.25) title, story = cap_story() fname2 = story.split('\n')[-1] assert fname2, story need_keypress('y') if M == 1 and N == 15: # useful and easier-to-use test wallet shutil.copy(microsd_path(fname), f'debug/test-wallet-ms.txt') shutil.copy(microsd_path(fname2), f'debug/test-wallet-ms.json') with open(microsd_path(fname2), 'rt') as fp: el = json.load(fp) assert el['seed_version'] == 17 assert el['wallet_type'] == f"{M}of{N}" for n in range(1, N+1): kk = f'x{n}/' assert kk in el co = el[kk] assert 'Coldcard' in co['label'] dd = co['derivation'] assert (dd in derivs) or (dd == actual) or ("42069'" in dd) or (dd == 'm') clear_ms() @pytest.mark.ms_danger def test_danger_warning(request, clear_ms, import_ms_wallet, cap_story, fake_ms_txn, start_sign, sim_exec): # note: cant use has_ms_checks fixture here danger_mode = (request.config.getoption('--ms-danger')) sim_exec(f'from multisig import MultisigWallet; MultisigWallet.disable_checks={danger_mode}') clear_ms() M,N = 2,3 keys = import_ms_wallet(M, N, accept=1) psbt = fake_ms_txn(1, 1, M, keys, incl_xpubs=True) open('debug/last.psbt', 'wb').write(psbt) start_sign(psbt) title, story = cap_story() if danger_mode: assert 'WARNING' in story assert 'Danger' in story assert 'Some multisig checks are disabled' in story else: assert 'WARNING' not in story @pytest.mark.parametrize('N', [ 3, 15]) @pytest.mark.parametrize('M', [ 3, 15]) @pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH] ) def test_ms_addr_explorer(M, N, addr_fmt, make_multisig, clear_ms, offer_ms_import, need_keypress, goto_home, pick_menu_item, cap_story, cap_menu, import_ms_wallet): wal_name = f"ax{M}-{N}-{addr_fmt}" M = min(M, N) dd = { AF_P2WSH: ("m/48'/1'/0'/2'/{idx}", 'p2wsh'), AF_P2SH: ("m/45'/{idx}", 'p2sh'), AF_P2WSH_P2SH: ("m/48'/1'/0'/1'/{idx}", 'p2sh-p2wsh'), } deriv, text_a_fmt = dd[addr_fmt] keys = make_multisig(M, N, unique=1, deriv=deriv) derivs = [deriv.format(idx=i) for i in range(N)] clear_ms() keys = import_ms_wallet(M, N, accept=1, keys=keys, name=wal_name, derivs=derivs, addr_fmt=text_a_fmt) goto_home() pick_menu_item("Address Explorer") need_keypress('4') # warning m = cap_menu() assert wal_name in m pick_menu_item(wal_name) time.sleep(.5) title, story = cap_story() # unwrap text a bit story = story.replace("=>\n", "=> ").replace('0/0\n =>', "0/0 =>") maps = [] for ln in story.split('\n'): if '=>' not in ln: continue path,chk,addr = ln.split() assert chk == '=>' assert '/' in path maps.append( (path, addr) ) assert len(maps) == 10 for idx, (subpath, addr) in enumerate(maps): path_mapper = lambda co_idx: str_to_path(derivs[co_idx]) + [0, idx] expect, pubkey, script, _ = make_ms_address(M, keys, idx=idx, addr_fmt=addr_fmt, path_mapper=path_mapper) assert int(subpath.split('/')[-1]) == idx #print('../0/%s => \n %s' % (idx, B2A(script))) trunc = expect[0:8] + "-" + expect[-7:] assert trunc == addr def test_dup_ms_wallet_bug(goto_home, cap_story, pick_menu_item, cap_menu, need_keypress, microsd_path, import_ms_wallet, clear_ms, M=2, N=3): deriv = ["m/48'/1'/0'/69'/1"]*N fmts = [ 'p2wsh', 'p2sh-p2wsh'] clear_ms() for n, ty in enumerate(fmts): import_ms_wallet(M, N, name=f'name-{n}', accept=1, derivs=deriv, addr_fmt=ty) goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') # drill down to second one time.sleep(.1) pick_menu_item('2/3: name-1') pick_menu_item('Delete') need_keypress('y') # BUG: pre v4.0.3, would be showing a "Yikes" referencing multisig:419 at this point pick_menu_item('2/3: name-0') pick_menu_item('Delete') need_keypress('y') clear_ms() # EOF