# (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 sys sys.path.append("../shared") from descriptor import MultisigDescriptor, append_checksum, MULTI_FMT_TO_SCRIPT, parse_desc_str import time, pytest, os, random, json, shutil, pdb, io, base64, struct, bech32, itertools, re from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput from ckcc.protocol import CCProtocolPacker, MAX_TXN_LEN from pprint import pprint from base64 import b64encode, b64decode from base58 import encode_base58_checksum from helpers import B2A, fake_dest_addr, xfp2str, addr_from_display_format from helpers import path_to_str, str_to_path, slip132undo, swab32, hash160, bitcoind_addr_fmt from struct import unpack, pack from constants import * from bip32 import BIP32Node from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint, uint256_from_str from io import BytesIO from hashlib import sha256 from bbqr import split_qrs from charcodes import KEY_QR 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 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.rpc.createmultisig(M, [B2A(i) for i in pubkeys], fmt) except ConnectionResetError: # bitcoind sleeps on us sometimes, give it another chance. rv = bitcoind.rpc.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(dev, sim_execfile): # 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, dev_key=False, netcode="XTN"): def _derive(master, origin_der, idx): if origin_der == "m": return master d = origin_der.format(idx=idx) if origin_der else "m/45h" try: child = master.subkey_for_path(d) except IndexError: # some test cases are using bogus paths child = master return child keys = [] for i in range(N-1): pk = BIP32Node.from_master_secret(b'CSW is a fraud %d - %d' % (i, unique), netcode) xfp = unpack("I', xfp_bytes)[0]) else: pk = BIP32Node.from_wallet_key(simulator_fixed_tprv if netcode == "XTN" else simulator_fixed_xprv) xfp = simulator_fixed_xfp dev_sim = _derive(pk, deriv, N-1) keys.append((xfp, pk, dev_sim)) return keys return doit @pytest.fixture def offer_ms_import(cap_story, dev, sim_root_dir): def doit(config, allow_non_ascii=False): # upload the file, trigger import file_len, sha = dev.upload_file(config.encode('utf-8' if allow_non_ascii else 'ascii')) with open(f'{sim_root_dir}/debug/last-config.txt', 'wt') as f: f.write(config) dev.send_recv(CCProtocolPacker.multisig_enroll(file_len, sha)) time.sleep(.2) title, story = cap_story() #print(repr(story)) return title, story return doit @pytest.fixture def import_multisig(request, is_q1, need_keypress, offer_ms_import, press_nfc): def doit(fname=None, way="sd", data=None, name=None): assert fname or data if fname: if way == "sd": microsd_path = request.getfixturevalue("microsd_path") fpath = microsd_path(fname) else: virtdisk_path = request.getfixturevalue("virtdisk_path") fpath = virtdisk_path(fname) with open(fpath, 'r') as f: config = f.read() else: config = data if way in (None, "usb"): # USB title, story = offer_ms_import(config) else: # only get those simulator related fixtures here, to be able to # use this with real HW cap_menu = request.getfixturevalue('cap_menu') cap_story = request.getfixturevalue('cap_story') goto_home = request.getfixturevalue('goto_home') pick_menu_item = request.getfixturevalue('pick_menu_item') if "Skip Checks?" not in cap_menu(): # we are not in multisig menu goto_home() pick_menu_item("Settings") pick_menu_item("Multisig Wallets") time.sleep(.1) pick_menu_item("Import") time.sleep(.1) title, story = cap_story() if way == "qr": if "to scan QR code" not in story and not is_q1: pytest.skip("No QR support") scan_a_qr = request.getfixturevalue('scan_a_qr') need_keypress(KEY_QR) actual_vers, parts = split_qrs(config, 'U', max_version=20) random.shuffle(parts) for p in parts: scan_a_qr(p) time.sleep(2.0 / len(parts)) elif way == "nfc": if "import via NFC" not in story: pytest.skip("NFC disabled") nfc_write_text = request.getfixturevalue('nfc_write_text') press_nfc() nfc_write_text(config) time.sleep(0.5) else: assert way in ("sd", "vdisk") if way == "sd": path_f = request.getfixturevalue('microsd_path') else: path_f = request.getfixturevalue('virtdisk_path') if not fname: fname = (name or "ms_wal.txt") + ".txt" with open(path_f(fname), "w") as f: f.write(config) if way == "vdisk": if "(2) to import from Virtual Disk" not in story: pytest.skip("VDisk disabled") need_keypress("2") else: if "Press (1)" in story: need_keypress("1") pick_menu_item(fname) time.sleep(.1) title, story = cap_story() return title, story return doit @pytest.fixture def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select, is_q1, request, need_keypress, import_multisig, settings_set, sim_root_dir): def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, keys=None, do_import=True, derivs=None, descriptor=False, int_ext_desc=False, dev_key=False, way=None, bip67=True, force_unsort_ms=True, netcode="XTN", return_desc=False): # param: bip67 if false, only usable together with descriptor=True if not bip67: assert descriptor, "needs descriptor=True" if (not bip67) and force_unsort_ms: settings_set("unsort_ms", 1) keys = keys or make_multisig(M, N, unique=unique, dev_key=dev_key, deriv=common or (derivs[0] if derivs else None), netcode=netcode) name = name or f'test-{M}-{N}' if not do_import: return keys if descriptor: if not derivs: if not common: common = "m/45h" key_list = [(xfp, common, dd.hwif(as_private=False)) for xfp, m, dd in keys] else: assert len(derivs) == N key_list = [(xfp, derivs[idx], dd.hwif(as_private=False)) for idx, (xfp, m, dd) in enumerate(keys)] desc = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=addr_fmt, is_sorted=bip67) if int_ext_desc: desc_str = desc.serialize(int_ext=True) else: desc_str = desc.serialize() config = "%s\n" % desc_str else: # render as a file for import config = f"name: {name}\npolicy: {M} / {N}\n\n" if addr_fmt is None: # default now is segwit v0 addr_fmt = "p2wsh" if addr_fmt: if isinstance(addr_fmt, int): addr_fmt = addr_fmt_names[addr_fmt] config += f'format: {addr_fmt.title()}\n' # not good enuf anymore, but maybe in some cases, just need one at top if common: config += f'derivation: {common}\n' if not derivs: config += '\n'.join('%s: %s' % (xfp2str(xfp), dd.hwif(as_private=False)) for xfp, m, dd in keys) else: # for cases where derivation of each leg is not same/simple assert not common and len(derivs) == N for idx, (xfp, m, dd) in enumerate(keys): config += 'Derivation: %s\n%s: %s\n\n' % (derivs[idx], xfp2str(xfp), dd.hwif(as_private=False)) #print(config) with open(f'{sim_root_dir}/debug/last-ms.txt', 'wt') as f: f.write(config) title, story = import_multisig(data=config, way=way) assert 'Create new multisig' in story \ or 'Update existing multisig wallet' in story \ or 'new wallet is similar to' in story if descriptor is False: # descriptors wallet does not have a name assert name in story assert f'Policy: {M} of {N}\n' in story if accept: time.sleep(.1) press_select() # Test it worked. time.sleep(.1) # required xor = 0 for xfp, _, _ in keys: xor ^= xfp assert dev.send_recv(CCProtocolPacker.multisig_check(M, N, xor)) == 1 if return_desc and descriptor: return config return keys return doit @pytest.mark.parametrize('N', [ 3, 15]) def test_ms_import_variations(N, make_multisig, offer_ms_import, press_cancel, is_q1): # all the different ways... keys = make_multisig(N, N) # bare, no fingerprints # - no xfps # - no meta data config = '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) title, story = offer_ms_import(config) assert f'Policy: {N} of {N}\n' in story press_cancel() # exclude myself (expect fail) config = '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys if xfp != simulator_fixed_xfp) with pytest.raises(BaseException) as ee: title, story = offer_ms_import(config) assert 'my key not included' in str(ee.value) # normal names for name in [ 'Zy', 'Z'*20, 'Vault #3' ]: config = f'name: {name}\n' config += '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) title, story = offer_ms_import(config) press_cancel() assert name in story # too long name config = 'name: ' + ('A'*21) + '\n' config += '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) with pytest.raises(BaseException) as ee: title, story = offer_ms_import(config) assert '20 long' in str(ee.value) # comments, blank lines config = [sk.hwif(as_private=False) for xfp,m,sk in keys] for i in range(len(config)): config.insert(i, '# comment') config.insert(i, ' #') config.insert(i, ' # ') config.insert(i, ' # ') config.insert(i, '') title, story = offer_ms_import('\n'.join(config)) assert f'Policy: {N} of {N}\n' in story press_cancel() # the different addr formats for af in unmap_addr_fmt.keys(): config = f'format: {af}\n' config += '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) title, story = offer_ms_import(config) press_cancel() assert f'Policy: {N} of {N}\n' in story def make_redeem(M, keys, path_mapper=None, violate_script_key_order=False, tweak_redeem=None, tweak_xfps=None, finalizer_hack=None, tweak_pubkeys=None, bip67=True): # Construct a redeem script, and ordered list of xfp+path to match. N = len(keys) assert path_mapper # see BIP 67: 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.node.depth:] assert not dpath or max(dpath) < 1000 node = sk else: dpath = path node = node.subkey_for_path(path_to_str(dpath, skip=0)) pk = node.sec() data.append( (pk, xfp, path)) #print("path: %s => pubkey %s" % (path_to_str(path, skip=0), B2A(pk))) if bip67: data.sort(key=lambda i:i[0]) if violate_script_key_order: # move them out of order works for both multi and sortedmulti 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, bip67=True, **make_redeem_args): # Construct addr and script need to represent a p2sh address if not make_redeem_args.get('path_mapper'): make_redeem_args['path_mapper'] = lambda cosigner: [HARD(45), cosigner, is_change, idx] script, pubkeys, xfp_paths = make_redeem(M, keys, bip67=bip67, **make_redeem_args) if addr_fmt == AF_P2WSH: # testnet=2 --> regtest hrp = ['bc', 'tb', 'bcrt'][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 = encode_base58_checksum(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, press_select, addr_vs_path, bitcoind_p2sh, has_ms_checks, is_q1): 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 == addr_from_display_format(story.split("\n\n")[0]) 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 press_select() # 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.bitcoind @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, use_regtest, addr_fmt, clear_ms, import_ms_wallet, test_ms_show_addr): use_regtest() 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.bitcoind @pytest.mark.ms_danger def test_violate_bip67(clear_ms, use_regtest, import_ms_wallet, test_ms_show_addr, has_ms_checks, fake_ms_txn, try_sign, sim_root_dir): # detect when pubkeys are not in order in the redeem script clear_ms() M, N = 1, 15 keys = import_ms_wallet(M, N, accept=True) # 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_script_key_order=True) assert 'BIP-67' in str(ee.value) psbt = fake_ms_txn(1, 3, M, keys, outstyles=ADDR_STYLES_MS, change_outputs=[1], violate_script_key_order=True) with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.write(psbt) with pytest.raises(Exception) as e: try_sign(psbt) assert 'BIP-67' in e.value.args[0] @pytest.mark.parametrize("has_change", [True, False]) def test_violate_import_order_multi(has_change, clear_ms, import_ms_wallet, fake_ms_txn, try_sign, test_ms_show_addr, sim_root_dir): clear_ms() M, N = 3, 5 keys = import_ms_wallet(M, N, accept=True, descriptor=True, bip67=False) time.sleep(.1) with pytest.raises(BaseException) as ee: test_ms_show_addr(M, keys, violate_script_key_order=True) assert "script key order" in str(ee.value) psbt = fake_ms_txn(4, 2, M, keys, outstyles=ADDR_STYLES_MS, change_outputs=[1] if has_change else [], bip67=False, violate_script_key_order=True) with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.write(psbt) with pytest.raises(Exception) as e: try_sign(psbt) assert "script key order" in e.value.args[0] @pytest.mark.bitcoind @pytest.mark.parametrize('which_pubkey', [0, 1, 14]) def test_bad_pubkey(has_ms_checks, use_regtest, clear_ms, import_ms_wallet, test_ms_show_addr, which_pubkey): # give incorrect pubkey inside redeem script M, N = 1, 15 keys = import_ms_wallet(M, N, accept=True) 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.bitcoind @pytest.mark.parametrize('addr_fmt', ['p2sh-p2wsh', 'p2sh', 'p2wsh' ]) def test_zero_depth(clear_ms, use_regtest, addr_fmt, import_ms_wallet , 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 from copy import deepcopy kk = deepcopy(keys[0][1]) kk.node.depth = 0 kk.node.index = 0 kk.node.parsed_parent_fingerprint = None 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 @pytest.mark.bitcoind def test_bad_xfp(mode, clear_ms, use_regtest, import_ms_wallet , 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 ]) @pytest.mark.bitcoind def test_bad_common_prefix(cpp, use_regtest, clear_ms, import_ms_wallet, 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) @pytest.mark.parametrize("desc", ["multi", "sortedmulti"]) def test_import_detail(desc, clear_ms, import_ms_wallet, need_keypress, cap_story, is_q1, press_cancel): # check all details are shown right M,N = 14, 15 descriptor, bip67 = (True, False) if desc == "multi" else (False, True) keys = import_ms_wallet(M, N, descriptor=descriptor, bip67=bip67) time.sleep(.2) title, story = cap_story() assert f'{M} of {N}' in story if desc == "multi": assert "WARNING" in story assert "BIP-67 disabled" in story else: assert "WARNING" not in story assert "BIP-67 disabled" not in story need_keypress('1') time.sleep(.1) title, story = cap_story() if desc == "sortedmulti": assert title == f'test-{M}-{N}' else: # imported from descriptor - name will be just M N assert title == f'{M}-of-{N}' xpubs = [sk.hwif() for _,_,sk in keys] for xp in xpubs: assert xp in story press_cancel() time.sleep(.1) press_cancel() @pytest.mark.parametrize("way", ["qr", "sd", "vdisk", "nfc"]) @pytest.mark.parametrize('acct_num', [0, 99, 123]) @pytest.mark.parametrize('testnet', [True, False]) def test_export_airgap(acct_num, goto_home, cap_story, pick_menu_item, cap_menu, need_keypress, microsd_path, load_export, use_mainnet, testnet, way, is_q1, press_select, skip_if_useless_way): skip_if_useless_way(way) if not testnet: use_mainnet() 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/45h" not in story assert f"m/48h/{int(testnet)}h" in story assert "{acct}h" in story press_select() # enter account number every time time.sleep(.1) for n in str(acct_num): need_keypress(n) press_select() rv = load_export(way, is_json=True, label="Multisig XPUB", fpattern="ccxp-") assert 'xfp' in rv assert len(rv) >= 6 e = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else 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.node.depth == 1 assert n.node.index == 45 | (1<<31) mxfp = unpack("= 4: assert count == 9 # unlimited now else: if N == 3: assert count == 9, "Expect fail at 9" if N == 15: assert count == 2, "Expect fail at 2" press_select() clear_ms() @pytest.fixture def test_make_example_file(microsd_path, make_multisig): def doit(M, N, addr_fmt=None): keys = make_multisig(M, N) # render as a file for import name = f'sample-{M}-{N}' config = f"name: {name}\npolicy: {M} / {N}\n\n" if addr_fmt: config += f'format: {addr_fmt.upper()}\n' config += '\n'.join('%s: %s' % (xfp2str(xfp), sk.hwif(as_private=False)) for xfp,m,sk in keys) fname = microsd_path(f'{name}.txt') with open(fname, 'wt') as fp: fp.write(config+'\n') print(f"Created: {fname}") return fname return doit @pytest.mark.parametrize('N', [ 5, 10]) def test_import_dup_safe(N, clear_ms, make_multisig, offer_ms_import, need_keypress, cap_story, goto_home, pick_menu_item, cap_menu, is_q1, press_select, OK): # import wallet, rename it, (check that indicated, works), attempt same w/ addr fmt different M = N clear_ms() keys = make_multisig(M, N) # render as a file for import def make_named(name, af='p2sh', m=M): config = f"name: {name}\npolicy: {m} / {N}\nformat: {af}\n\n" config += '\n'.join('%s: %s' % (xfp2str(xfp), sk.hwif(as_private=False)) for xfp,m,sk in keys) return config def has_name(name, num_wallets=1): # check worked: look in menu for name goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') menu = cap_menu() assert f'{M}/{N}: {name}' in menu # depending if NFC enabled or not, and if Q (has QR) assert (len(menu) - num_wallets) in [7,8,9] title, story = offer_ms_import(make_named('xxx-orig')) assert 'Create new multisig wallet' in story assert 'xxx-orig' in story assert 'P2SH' in story press_select() has_name('xxx-orig') # just simple rename title, story = offer_ms_import(make_named('xxx-new')) assert 'update name only' in story.lower() assert 'xxx-new' in story press_select() has_name('xxx-new') assert N < 15, 'cant make more, no space' newer = make_named('xxx-newer', 'p2wsh') title, story = offer_ms_import(newer) assert 'update name only' not in story.lower() assert 'address type' in story.lower() assert 'will NOT replace it' in story assert 'xxx-newer' in story assert 'WARNING:' in story assert 'P2WSH' in story # should be 2 now, slightly different press_select() has_name('xxx-newer', 2) # TODO # repeat last one, should still be two for keys in ['yn', 'n']: title, story = offer_ms_import(newer) assert 'Duplicate wallet' in story assert f'{OK} to approve' not in story assert 'xxx-newer' in story for key in keys: need_keypress(key) has_name('xxx-newer', 2) clear_ms() @pytest.mark.parametrize('N', [ 5]) def test_import_dup_diff_xpub(N, clear_ms, make_multisig, offer_ms_import, press_select, cap_story, goto_home, pick_menu_item, cap_menu, is_q1): # import wallet, tweak xpub only, check that change detected clear_ms() M = N keys = make_multisig(M, N) # render as a file for import def make_named(name, af='p2sh', m=M, tweaked=False): config = f"name: {name}\npolicy: {m} / {N}\nformat: {af}\n\n" lines = [] for idx, (xfp,m,sk) in enumerate(keys): if idx == 1 and tweaked: x = bytearray(sk.node.key) x[9] = 254 sk.node.key = bytes(x) hwif = sk.hwif() lines.append('%s: %s' % (xfp2str(xfp), hwif) ) config += '\n'.join(lines) return config title, story = offer_ms_import(make_named('xxx-orig')) assert 'Create new multisig wallet' in story assert 'xxx-orig' in story assert 'P2SH' in story press_select() # change one key. title, story = offer_ms_import(make_named('xxx-new', tweaked=True)) assert 'WARNING:' in story assert 'xxx-new' in story assert 'xpubs' in story clear_ms() @pytest.mark.bitcoind @pytest.mark.parametrize('m_of_n', [(2,2), (2,3), (15,15)]) @pytest.mark.parametrize('addr_fmt', ['p2sh-p2wsh', 'p2sh', 'p2wsh' ]) def test_import_dup_xfp_fails(m_of_n, use_regtest, addr_fmt, clear_ms, make_multisig, import_ms_wallet, test_ms_show_addr): M, N = m_of_n keys = make_multisig(M, N) pk = BIP32Node.from_master_secret(b'example', 'XTN') sub = pk.subkey_for_path("m/45h") sub.node.parent = None sub.node.parsed_parent_fingerprint = keys[-1][2].parent_fingerprint() keys[-1] = (simulator_fixed_xfp, pk, sub) with pytest.raises(Exception) as ee: import_ms_wallet(M, N, addr_fmt, accept=1, keys=keys) #assert 'XFP' in str(ee) assert 'wrong pubkey' in str(ee) @pytest.mark.parametrize('addr_fmt', [AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH]) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) def test_ms_cli(dev, addr_fmt, clear_ms, import_ms_wallet, addr_vs_path, desc): # exercise the p2sh command of ckcc:cli ... hard to do manually. M, N = 2, 3 clear_ms() bip67, descriptor = (False, True) if desc == "multi" else (True, False) keys = import_ms_wallet(M, N, name='cli-test', accept=True, addr_fmt=addr_fmt_names[addr_fmt], descriptor=descriptor, bip67=bip67) pmapper = lambda i: [HARD(45), i, 0,3] scr, pubkeys, xfp_paths = make_redeem(M, keys, pmapper, bip67=bip67) addr = dev.send_recv(CCProtocolPacker.show_p2sh_address( scr[0] - 80, xfp_paths, scr, addr_fmt=addr_fmt), timeout=None ) addr_vs_path(addr, addr_fmt=addr_fmt, script=scr) # test case for make_ms_address really. expect_addr, _, scr2, _ = make_ms_address(M, keys, path_mapper=pmapper, addr_fmt=addr_fmt, bip67=bip67) assert expect_addr == addr assert scr2 == scr clear_ms() @pytest.fixture def make_myself_wallet(dev, set_bip39_pw, offer_ms_import, press_select, clear_ms, reset_seed_words, is_q1): # construct a wallet (M of 4) using different bip39 passwords, and default sim def doit(M, addr_fmt=None, do_import=True): passwords = ['Me', 'Myself', 'And I', ''] if 0: # WORKING, but slow .. and it's constant data keys = [] for pw in passwords: xfp = set_bip39_pw(pw) sk = dev.send_recv(CCProtocolPacker.get_xpub("m/45'")) node = BIP32Node.from_wallet_key(sk) keys.append((xfp, None, node)) assert len(set(x for x,_,_ in keys)) == 4, keys pprint(keys) else: # Much, FASTER! # XXX assumes testnet assert dev.is_simulator keys = [(3503269483, None, BIP32Node.from_hwif('tpubD9429UXFGCTKJ9NdiNK4rC5ygqSUkginycYHccqSg5gkmyQ7PZRHNjk99M6a6Y3NY8ctEUUJvCu6iCCui8Ju3xrHRu3Ez1CKB4ZFoRZDdP9')), (2389277556, None, BIP32Node.from_hwif('tpubD97nVL37v5tWyMf9ofh5rznwhh1593WMRg6FT4o6MRJkKWANtwAMHYLrcJFsFmPfYbY1TE1LLQ4KBb84LBPt1ubvFwoosvMkcWJtMwvXgSc')), (3190206587, None, BIP32Node.from_hwif('tpubD9ArfXowvGHnuECKdGXVKDMfZVGdephVWg8fWGWStH3VKHzT4ph3A4ZcgXWqFu1F5xGTfxncmrnf3sLC86dup2a8Kx7z3xQ3AgeNTQeFxPa')), (1130956047, None, BIP32Node.from_hwif('tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n')), ] if do_import: # render as a file for import config = f"name: Myself-{M}\npolicy: {M} / 4\n\n" if addr_fmt: config += f'format: {addr_fmt.upper()}\n' # default is sh config += '\n'.join('%s: %s' % (xfp2str(xfp), sk.hwif()) for xfp, _, sk in keys) #print(config) title, story = offer_ms_import(config) #print(story) # don't care if update or create; accept it. time.sleep(.1) press_select() def select_wallet(idx, no_import=False): # select to specific pw print(f"--- switch to another leg of MS: {idx} ---") xfp = set_bip39_pw(passwords[idx]) if do_import and not no_import: offer_ms_import(config) time.sleep(.1) press_select() assert xfp == keys[idx][0] return xfp return keys, select_wallet yield doit reset_seed_words() @pytest.fixture def fake_ms_txn(pytestconfig): # make various size MULTISIG txn's ... completely fake and pointless values # - but has UTXO's to match needs from struct import pack def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None, outstyles=['p2pkh'], change_outputs=[], incl_xpubs=False, hack_psbt=None, hack_change_out=False, input_amount=1E8, psbt_v2=None, bip67=True, violate_script_key_order=False, path_mapper=None, inp_af=AF_P2WSH, force_outstyle=None, lock_time=0): psbt = BasicPSBT() 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 psbt_v2: psbt.version = 2 psbt.txn_version = 2 psbt.input_count = num_ins psbt.output_count = num_outs if lock_time: psbt.fallback_locktime = lock_time txn = CTransaction() txn.nVersion = 2 txn.nLockTime = lock_time if incl_xpubs: # add global header with XPUB's # - assumes BIP-45 for idx, (xfp, m, sk) in enumerate(keys): if callable(incl_xpubs): psbt.xpubs.append( incl_xpubs(idx, xfp, m, sk) ) else: kk = pack(' # - 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 if not bitcoind.has_bdb: # addmultisigaddress not supported by descriptor wallets pytest.skip("Needs BDB legacy wallet") from bip32 import PubKeyNode from binascii import a2b_hex use_regtest() 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 addr = bitcoind.supply_wallet.getnewaddress("sim-cosign") info = bitcoind.supply_wallet.getaddressinfo(addr) 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... node = BIP32Node(PubKeyNode( key=a2b_hex(bc_pubkey), chain_code=b'\x23'*32, depth=len(bc_deriv.split('/'))-1, parent_fingerprint=a2b_hex('%08x' % bc_xfp), testnet=True )) # No means to export XPUB from bitcoind! Still. In 2019. # - this fake will only work for one pubkey value, the first/topmost 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", addr_fmt=addr_fmt_names[addr_fmt], derivs=[bc_deriv, "m/45h"]) cc_deriv = "m/45h/55" cc_pubkey = B2A(BIP32Node.from_hwif(simulator_fixed_tprv).subkey_for_path(cc_deriv).sec()) # NOTE: bitcoind doesn't seem to implement pubkey sorting. We have to do it. resp = bitcoind.supply_wallet.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) press_cancel() # 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') # fund multisig address bitcoind.supply_wallet.importaddress(ms_addr, 'shared-addr-'+addr_style, True) bitcoind.supply_wallet.sendtoaddress(address=ms_addr, amount=5) bitcoind.supply_wallet.generatetoaddress(101, bitcoind.supply_wallet.getnewaddress()) # mining unspent = bitcoind.supply_wallet.listunspent(addresses=[ms_addr]) ret_addr = bitcoind.supply_wallet.getrawchangeaddress() resp = bitcoind.supply_wallet.walletcreatefundedpsbt([dict(txid=unspent[0]["txid"], vout=unspent[0]["vout"])], [{ret_addr: 2}], 0, {'subtractFeeFromOutputs': [0], 'includeWatching': True}, True) if not cc_sign_first: # signing first with bitcoind resp = bitcoind.supply_wallet.walletprocesspsbt(resp["psbt"]) # assert resp['changepos'] == -1 psbt = b64decode(resp['psbt']) with open(f'{sim_root_dir}/debug/funded.psbt', 'wb') as f: f.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: # issues/47 in secret - from 24.0 core does not add out key into PSBT input bip32 paths - no need to check # 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() with open(f'{sim_root_dir}/debug/patched.psbt', 'wb') as f: f.write(psbt) _, updated = try_sign(psbt, finalize=False) with open(f'{sim_root_dir}/debug/cc-updated.psbt', 'wb') as f: f.write(updated) if cc_sign_first: # cc signed first - bitcoind is now second rr = bitcoind.supply_wallet.walletprocesspsbt(b64encode(updated).decode('ascii'), True, "ALL") assert rr["complete"] both_signed = rr["psbt"] else: both_signed = b64encode(updated).decode('ascii') # finalize and send rr = bitcoind.supply_wallet.finalizepsbt(both_signed, True) with open(f'{sim_root_dir}/debug/bc-final-txn.txn', 'wt') as f: f.write(rr['hex']) assert rr['complete'] tx_hex = rr["hex"] res = bitcoind.supply_wallet.testmempoolaccept([tx_hex]) assert res[0]["allowed"] txn_id = bitcoind.supply_wallet.sendrawtransaction(rr['hex']) assert len(txn_id) == 64 @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, sim_root_dir): M = 1 N = 3 num_outs = 2 clear_ms() keys = import_ms_wallet(M, N, accept=1, addr_fmt=out_style) # 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 with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.write(psbt) start_sign(psbt) with pytest.raises(Exception) as ee: signed = end_sign(accept=None) assert 'Output#0:' in str(ee) assert 'change output script' in str(ee) # Check error details are shown time.sleep(.01) 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, AF_P2WSH_P2SH] ) @pytest.mark.parametrize('num_ins', [1]) @pytest.mark.parametrize('incl_xpubs', [ True]) @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, import_ms_wallet, addr_vs_path, fake_ms_txn, start_sign, end_sign, cap_story, sim_root_dir): M = 1 N = 3 num_outs = 2 clear_ms() keys = import_ms_wallet(M, N, addr_fmt=addr_fmt, accept=True) # 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, change_outputs=[0], inp_af=addr_fmt, hack_change_out=lambda idx: dict(tweak_pubkeys= lambda data: tweak(case, pk_num, data))) with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.write(psbt) start_sign(psbt) # Check error details are shown time.sleep(.5) title, story = cap_story() assert len(story.split(':')[-1].strip()), story with pytest.raises(Exception) as ee: 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) assert story.strip() in str(ee.value.args[0]) @pytest.mark.parametrize('repeat', range(2) ) def test_iss6743(repeat, set_seed_words, sim_execfile, try_sign, sim_root_dir): # from SomberNight psbt_b4 = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae3000008001000080000000800100008000000000030000000000') # pre 3.2.0 result psbt_wrong = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100a85d08eef6675803fe2b58dda11a553641080e07da36a2f3e116f1224201931b022071b0ba83ef920d49b520c37993c039d13ae508a1adbd47eb4b329713fcc8baef01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') # psbt_right = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100ae90a7e4c350389816b03af0af46df59a2f53da04cc95a2abd81c0bbc5950c1d02202f9471d6b0664b7a46e81da62d149f688adc7ba2b3413372d26fa618a8460eba01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') # changed with with introduction of signature grinding psbt_right = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381947304402201008b084f53d3064ee381dfb3ff4373b29d6ae765b2af15a4e217e8d5d049c650220576af95d79b8fc686627da8a534141208b225ceb6085cd93fcaffb153ac016ea01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') 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/48h/1h/0h/1h" # 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.node.depth, dp.count('/'))) sk.node.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 press_select() goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item(f'{M}/{N}: impmany') pick_menu_item('Coldcard Export') contents = load_export(way, label="Coldcard multisig setup", is_json=False) lines = io.StringIO(contents).readlines() for xfp,_,_ in keys: m = xfp2str(xfp) assert any(m in ln for ln in lines) press_cancel() if way != "nfc": press_cancel() pick_menu_item('Electrum Wallet') time.sleep(.25) title, story = cap_story() assert 'This saves a skeleton Electrum wallet file' in story press_select() el = load_export(way, label="Electrum multisig wallet", is_json=True) 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 ("42069h" in dd) or (dd == 'm') clear_ms() @pytest.mark.ms_danger @pytest.mark.parametrize('descriptor', [True, False]) def test_danger_warning(request, descriptor, clear_ms, import_ms_wallet, cap_story, fake_ms_txn, start_sign, sim_exec, sim_root_dir): # 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, descriptor=descriptor, addr_fmt="p2wsh") psbt = fake_ms_txn(1, 1, M, keys, incl_xpubs=True) with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.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('msas', [True, False]) @pytest.mark.parametrize('change', [True, False]) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) @pytest.mark.parametrize('start_idx', [1000, MAX_BIP32_IDX, 0]) @pytest.mark.parametrize('M_N', [(2,3), (15,15)]) @pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH] ) def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, need_keypress, goto_home, pick_menu_item, cap_story, import_ms_wallet, make_multisig, settings_set, enter_number, set_addr_exp_start_idx, desc, msas, cap_screen_qr, press_cancel, press_right): clear_ms() M, N = M_N wal_name = f"ax{M}-{N}-{addr_fmt}" settings_set("aei", True if start_idx else False) settings_set("msas", 1 if msas else 0) dd = { AF_P2WSH: ("m/48h/1h/0h/2h/{idx}", 'p2wsh'), AF_P2SH: ("m/45h/{idx}", 'p2sh'), AF_P2WSH_P2SH: ("m/48h/1h/0h/1h/{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() descriptor = None bip67 = True if desc == "multi": descriptor, bip67 = True, False keys = import_ms_wallet(M, N, accept=1, keys=keys, name=wal_name, derivs=derivs, addr_fmt=text_a_fmt, descriptor=descriptor, bip67=bip67) goto_home() pick_menu_item("Address Explorer") need_keypress('4') # warning set_addr_exp_start_idx(start_idx) m = cap_menu() if wal_name in m: pick_menu_item(wal_name) else: # descriptor pick_menu_item(f"{M}-of-{N}") time.sleep(.5) title, story = cap_story() assert "(0)" in story assert "change addresses." in story if change: need_keypress("0") time.sleep(0.2) title, story = cap_story() # once change is selected - do not offer this option again assert "change addresses." not in story assert "(0)" not in story # unwrap text a bit if change: story = story.replace("=>\n", "=> ").replace('1/0]\n =>', "1/0 =>") else: 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(" ", 2) assert chk == '=>' assert '/' in path maps.append( (path, addr) ) if start_idx <= 2147483638: assert len(maps) == 10 else: assert len(maps) == (MAX_BIP32_IDX - start_idx) + 1 if msas: need_keypress(KEY_QR) qr_addrs = [] for i in range(10): addr_qr = cap_screen_qr().decode() if addr_fmt == AF_P2WSH: # segwit addresses are case insensitive addr_qr = addr_qr.lower() qr_addrs.append(addr_qr) press_right() time.sleep(.2) press_cancel() else: assert "show QR code" not in story c = 0 for idx, (subpath, addr) in enumerate(maps, start=start_idx): chng_idx = 1 if change else 0 path_mapper = lambda co_idx: str_to_path(derivs[co_idx]) + [chng_idx, idx] expect, pubkey, script, _ = make_ms_address(M, keys, idx=idx, addr_fmt=addr_fmt, path_mapper=path_mapper, bip67=bip67) assert int(subpath.split('/')[-1]) == idx #print('../0/%s => \n %s' % (idx, B2A(script))) addr = addr_from_display_format(addr) if msas: assert addr == expect == qr_addrs[c] else: start, end = addr.strip().split('___') assert expect.startswith(start) assert expect.endswith(end) c += 1 def test_dup_ms_wallet_bug(goto_home, pick_menu_item, press_select, import_ms_wallet, clear_ms, is_q1): M = 2 N = 3 deriv = ["m/48h/1h/0h/69h/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') press_select() # 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') press_select() clear_ms() @pytest.mark.parametrize('M_N', [(2, 3), (3, 5), (15, 15)]) @pytest.mark.parametrize('addr_fmt', [ AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH ]) @pytest.mark.parametrize('int_ext_desc', [True, False]) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) def test_import_descriptor(M_N, addr_fmt, int_ext_desc, way, import_ms_wallet, goto_home, pick_menu_item, press_select, clear_ms, cap_story, microsd_path, virtdisk_path, nfc_read_text, load_export, is_q1, desc): clear_ms() M, N = M_N desc_import = import_ms_wallet( M, N, addr_fmt=addr_fmt, accept=True, descriptor=True, int_ext_desc=int_ext_desc, bip67=False if desc == "multi" else True, return_desc=True ) desc_import = desc_import.strip() goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') press_select() # only one enrolled multisig - choose it pick_menu_item('Descriptors') pick_menu_item('Export') contents = load_export(way, label="Descriptor multisig setup", is_json=False) desc_export = contents.strip() normalized = parse_desc_str(desc_export) # as new format is not widely supported we only allow to import it - no export yet if int_ext_desc: # checksum will differ - ignore it assert desc_import.split("#")[0] == normalized.split("#")[0].replace("0/*", "<0;1>/*") else: assert desc_import == normalized starts_with = MULTI_FMT_TO_SCRIPT[addr_fmt].split("%")[0] assert normalized.startswith(starts_with) assert f"{desc}(" in desc_export @pytest.mark.bitcoind @pytest.mark.parametrize("change", [True, False]) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) @pytest.mark.parametrize("start_idx", [2147483540, MAX_BIP32_IDX, 0]) @pytest.mark.parametrize('M_N', [(2, 2), (3, 5), (15, 15)]) @pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH]) @pytest.mark.parametrize('way', ["sd", "nfc"]) # vdisk def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, cap_menu, cap_story, make_multisig, import_ms_wallet, microsd_path, bitcoind_d_wallet_w_sk, use_regtest, load_export, way, is_q1, press_select, start_idx, settings_set, set_addr_exp_start_idx, desc, garbage_collector, virtdisk_path): use_regtest() clear_ms() bitcoind = bitcoind_d_wallet_w_sk M, N = M_N path_f = microsd_path if way == "sd" else virtdisk_path # whether to import as descriptor or old school to CC descriptor = random.choice([True, False]) bip67 = True if desc == "multi": bip67 = False descriptor = True settings_set("aei", True if start_idx else False) # adding this as parameter doubles the time this runs msas = random.getrandbits(1) settings_set("msas", 1 if msas else 0) wal_name = f"ax{M}-{N}-{addr_fmt}" dd = { AF_P2WSH: ("m/48h/1h/0h/2h/{idx}", 'p2wsh'), AF_P2SH: ("m/45h/{idx}", 'p2sh'), AF_P2WSH_P2SH: ("m/48h/1h/0h/1h/{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() import_ms_wallet(M, N, accept=1, keys=keys, name=wal_name, derivs=derivs, addr_fmt=text_a_fmt, descriptor=descriptor, bip67=bip67) goto_home() pick_menu_item("Address Explorer") need_keypress('4') # warning set_addr_exp_start_idx(start_idx) m = cap_menu() if descriptor: wal_name = m[-2 if start_idx else -1] else: assert wal_name in m pick_menu_item(wal_name) time.sleep(0.2) title, story = cap_story() assert "(0)" in story assert "change addresses." in story if change: need_keypress("0") time.sleep(0.2) title, story = cap_story() # once change is selected - do not offer this option again assert "change addresses." not in story assert "(0)" not in story if way != "nfc": contents, exp_fname = load_export(way, label="Address summary", is_json=False, ret_fname=True) garbage_collector.append(path_f(exp_fname)) else: contents = load_export(way, label="Address summary", is_json=False) addr_cont = contents.strip() goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') press_select() # only one enrolled multisig - choose it pick_menu_item('Descriptors') pick_menu_item("Bitcoin Core") if way != "nfc": contents, exp_fname = load_export(way, label="Bitcoin Core multisig setup", is_json=False, ret_fname=True) garbage_collector.append(path_f(exp_fname)) else: contents = load_export(way, label="Bitcoin Core multisig setup", is_json=False) text = contents.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[") r2 = text.find("]", -1, 0) text = text[r1: r2] core_desc_object = json.loads(text) if change: # in descriptor.py we always append external descriptor first desc_export = core_desc_object[1]["desc"] else: desc_export = core_desc_object[0]["desc"] if descriptor: assert f"({desc}(" in desc_export if way == "nfc": end_idx = start_idx + 9 if end_idx > MAX_BIP32_IDX: end_idx = start_idx + (MAX_BIP32_IDX - start_idx) addr_range = [start_idx, end_idx] cc_addrs = addr_cont.split("\n") part_addr_index = 0 else: end_idx = start_idx + 249 if end_idx > MAX_BIP32_IDX: end_idx = start_idx + (MAX_BIP32_IDX - start_idx) addr_range = [start_idx, end_idx] cc_addrs = addr_cont.split("\n")[1:] part_addr_index = 1 bitcoind_addrs = bitcoind.deriveaddresses(desc_export, addr_range) for idx, cc_item in enumerate(cc_addrs): cc_item = cc_item.split(",") if msas: addr = cc_item[part_addr_index] if way != "nfc": addr = addr[1:-1] assert bitcoind_addrs[idx] == addr else: partial_address = cc_item[part_addr_index] _start, _end = partial_address.split("___") if way != "nfc": _start, _end = _start[1:], _end[:-1] assert bitcoind_addrs[idx].startswith(_start) assert bitcoind_addrs[idx].endswith(_end) @pytest.mark.bitcoind def test_legacy_multisig_witness_utxo_in_psbt(bitcoind, use_regtest, clear_ms, microsd_wipe, goto_home, need_keypress, pick_menu_item, cap_story, load_export, microsd_path, cap_menu, try_sign, is_q1, press_select): use_regtest() clear_ms() microsd_wipe() M,N = 2,2 cosigner = bitcoind.create_wallet(wallet_name=f"bitcoind--signer-wit-utxo", disable_private_keys=False, blank=False, passphrase=None, avoid_reuse=False, descriptors=True) ms = bitcoind.create_wallet( wallet_name=f"watch_only_legacy_2of2", disable_private_keys=True, blank=True, passphrase=None, avoid_reuse=False, descriptors=True ) goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item('Export XPUB') time.sleep(0.5) title, story = cap_story() assert "extended public keys (XPUB) you would need to join a multisig wallet" in story press_select() need_keypress("0") # account press_select() xpub_obj = load_export("sd", label="Multisig XPUB", is_json=True) template = xpub_obj["p2sh_desc"] # get key from bitcoind cosigner target_desc = "" bitcoind_descriptors = cosigner.listdescriptors()["descriptors"] for desc in bitcoind_descriptors: if desc["desc"].startswith("pkh(") and desc["internal"] is False: target_desc = desc["desc"] core_desc, checksum = target_desc.split("#") # remove pkh(....) core_key = core_desc[4:-1] desc = template.replace("M", str(M), 1).replace("...", core_key) desc_info = ms.getdescriptorinfo(desc) desc_w_checksum = desc_info["descriptor"] # with checksum name = f"core{M}of{N}_legacy.txt" with open(microsd_path(name), "w") as f: f.write(desc_w_checksum + "\n") goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item('Import') time.sleep(0.3) _, story = cap_story() if "Press (1) to import multisig wallet file from SD Card" in story: # in case Vdisk is enabled need_keypress("1") time.sleep(0.5) pick_menu_item(name) _, story = cap_story() assert "Create new multisig wallet?" in story assert name.split(".")[0] in story assert f"{M} of {N}" in story assert f"All {N} co-signers must approve spends" in story assert "P2SH" in story assert "Derivation:\n Varies (2)" in story press_select() # approve multisig import goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') menu = cap_menu() pick_menu_item(menu[0]) # pick imported descriptor multisig wallet pick_menu_item("Descriptors") pick_menu_item("Bitcoin Core") text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False) text = text.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[") r2 = text.find("]", -1, 0) text = text[r1: r2] core_desc_object = json.loads(text) # import descriptors to watch only wallet res = ms.importdescriptors(core_desc_object) for obj in res: assert obj["success"], obj # send to address type addr_type = "legacy" multi_addr = ms.getnewaddress("", addr_type) bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=49) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mining dest_addr = ms.getnewaddress("", addr_type) assert all([addr.startswith("2") for addr in [multi_addr, dest_addr]]) # create funded PSBT psbt_resp = ms.walletcreatefundedpsbt( [], [{dest_addr: 5}], 0, {"fee_rate": 1, "change_type": addr_type, "subtractFeeFromOutputs": [0]} ) psbt = psbt_resp.get("psbt") import base64 o = BasicPSBT().parse(base64.b64decode(psbt)) assert len(o.inputs) == 1 non_witness_utxo = o.inputs[0].utxo from io import BytesIO parsed_tx = CTransaction() parsed_tx.deserialize(BytesIO(non_witness_utxo)) witness_utxo = None for oo in parsed_tx.vout: if oo.nValue == 4900000000: witness_utxo = oo.serialize() assert witness_utxo is not None o.inputs[0].witness_utxo = witness_utxo updated = o.as_bytes() try_sign(updated) @pytest.fixture def get_cc_key(dev): def doit(path, subderiv=None): # cc device key master_xfp_str = struct.pack('/*'}" return doit @pytest.fixture def bitcoind_multisig(bitcoind, bitcoind_d_sim_watch, need_keypress, cap_story, load_export, pick_menu_item, goto_home, cap_menu, microsd_path, settings_get, press_select, import_multisig, get_cc_key): def doit(M, N, script_type, cc_account=0, funded=True, ms_script="sortedmulti", name=None, way="sd", keypool_size=10): # remove all previous wallet from datadir assert settings_get("chain", None) == "XRT" bitcoind.delete_wallet_files(pattern="bitcoind--signer") bitcoind.delete_wallet_files(pattern="bitcoind_ms_wo_") bitcoind_signers = [ bitcoind.create_wallet(wallet_name=f"bitcoind--signer{i}", disable_private_keys=False, blank=False, passphrase=None, avoid_reuse=False, descriptors=True) for i in range(N - 1) ] for signer in bitcoind_signers: signer.keypoolrefill(keypool_size) # watch only wallet where multisig descriptor will be imported ms = bitcoind.create_wallet( wallet_name=f"bitcoind_ms_wo_{script_type}_{M}of{N}", disable_private_keys=True, blank=True, passphrase=None, avoid_reuse=False, descriptors=True ) # get keys from bitcoind signers bitcoind_signers_xpubs = [] for signer in bitcoind_signers: target_desc = "" bitcoind_descriptors = signer.listdescriptors()["descriptors"] for desc in bitcoind_descriptors: if desc["desc"].startswith("pkh(") and desc["internal"] is False: target_desc = desc["desc"] core_desc, checksum = target_desc.split("#") # remove pkh(....) core_key = core_desc[4:-1] bitcoind_signers_xpubs.append(core_key) cc_key = get_cc_key(f"100h/0h/{cc_account}h", subderiv="/0/*") # subderiv compat all_signers = bitcoind_signers_xpubs + [cc_key] if script_type == 'p2wsh': tmplt = "wsh(%s)" elif script_type == "p2sh-p2wsh": tmplt = "sh(wsh(%s))" else: assert script_type == "p2sh" tmplt = "sh(%s)" inner = f"{ms_script}({M},{','.join(all_signers)})" desc = tmplt % inner if name: res = json.dumps({"desc": desc, "name": name}) else: res = desc title, story = import_multisig(way=way, data=res) assert "Create new multisig wallet?" in story assert f"{M} of {N}" in story if M == N: assert f"All {N} co-signers must approve spends" in story else: assert f"{M} signatures, from {N} possible" in story if script_type == "p2wsh": assert "P2WSH" in story elif script_type == "p2sh": assert "P2SH" in story else: assert script_type == "p2sh-p2wsh" assert "P2SH-P2WSH" in story assert "Derivation:\n Varies (2)" in story press_select() # approve multisig import goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') menu = cap_menu() pick_menu_item(menu[0]) # pick imported descriptor multisig wallet pick_menu_item("Descriptors") pick_menu_item("Bitcoin Core") text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False) text = text.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[") r2 = text.find("]", -1, 0) text = text[r1: r2] core_desc_object = json.loads(text) # import descriptors to watch only wallet res = ms.importdescriptors(core_desc_object) assert res[0]["success"] assert res[1]["success"] if funded: addr = ms.getnewaddress("", bitcoind_addr_fmt(script_type)) if script_type == "p2wsh": sw = "bcrt1q" else: sw = "2" assert addr.startswith(sw) # get some coins and fund above multisig address bitcoind.supply_wallet.sendtoaddress(addr, 49) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above ms.keypoolrefill(keypool_size) return ms, bitcoind_signers return doit @pytest.mark.bitcoind @pytest.mark.parametrize("m_n", [(2, 2), (2, 3), (3, 5), (6, 6), (5, 8), (10, 15)]) @pytest.mark.parametrize("script", ["p2wsh", "p2sh-p2wsh", "p2sh"]) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) def test_finalization(m_n, script, desc, use_regtest, clear_ms, bitcoind_multisig, bitcoind, try_sign, cap_story, settings_set, txid_from_export_prompt, press_cancel): if desc == "multi": settings_set("unsort_ms", 1) M, N = m_n use_regtest() clear_ms() addr_type = bitcoind_addr_fmt(script) wo, bitcoind_signers = bitcoind_multisig(M, N, script, ms_script=desc, keypool_size=30, way="usb") # 3 outputs going out destinations = [{bitcoind.supply_wallet.getnewaddress("", "bech32"): 5.0} for _ in range(3)] # 3 going back (below 2 + rest cc 24btc) destinations.append({wo.getnewaddress("", addr_type): 5.0}) destinations.append({wo.getnewaddress("", addr_type): 5.0}) psbt = wo.walletcreatefundedpsbt( [], destinations, 0, {"fee_rate": 2, "change_type": addr_type} )["psbt"] # sign with M - 1 bitcoind signers so COLDCARD can just sign+finalize for signer in bitcoind_signers[:M-1]: half_signed_psbt = signer.walletprocesspsbt(psbt, True, "ALL", True) # do not finalize psbt = half_signed_psbt["psbt"] psbt_bytes = base64.b64decode(psbt) # USB sign with COLDCARD & finalize _, txn = try_sign(psbt_bytes, finalize=True, exit_export_loop=False) tx_hex = txn.hex() res = wo.testmempoolaccept([tx_hex]) assert res[0]["allowed"] res = wo.sendrawtransaction(tx_hex) assert len(res) == 64 # tx id cc_tx_id = txid_from_export_prompt() press_cancel() # exit QR display press_cancel() # exit export loop assert res == cc_tx_id wo.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) assert len(wo.listunspent()) == 3 # consolidate psbt = wo.walletcreatefundedpsbt( [], [{wo.getnewaddress("", addr_type): wo.getbalance()}], 0, {"fee_rate": 4, "subtractFeeFromOutputs": [0], "change_type": addr_type} )["psbt"] for signer in bitcoind_signers[:M-1]: half_signed_psbt = signer.walletprocesspsbt(psbt, True, "ALL", True) # do not finalize psbt = half_signed_psbt["psbt"] psbt_bytes = base64.b64decode(psbt) # USB sign with COLDCARD & finalize _, txn = try_sign(psbt_bytes, finalize=True, exit_export_loop=False) tx_hex = txn.hex() res = wo.testmempoolaccept([tx_hex]) assert res[0]["allowed"] res = wo.sendrawtransaction(tx_hex) assert len(res) == 64 # tx id cc_tx_id = txid_from_export_prompt() press_cancel() # exit QR display press_cancel() # exit export loop assert res == cc_tx_id wo.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) assert len(wo.listunspent()) == 1 @pytest.mark.bitcoind @pytest.mark.parametrize("m_n", [(2,3), (3,5), (15,15)]) @pytest.mark.parametrize("script", ["p2wsh", "p2sh-p2wsh", "p2sh"]) @pytest.mark.parametrize("sighash", list(SIGHASH_MAP.keys())) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) def test_bitcoind_MofN_tutorial(m_n, script, clear_ms, goto_home, need_keypress, pick_menu_item, sighash, cap_menu, cap_story, microsd_path, use_regtest, bitcoind, microsd_wipe, settings_set, is_q1, try_sign, press_select, finalize_v2_v0_convert, desc, bitcoind_multisig, press_cancel, txid_from_export_prompt, pytestconfig, file_tx_signing_done): # 2of2 case here is described in docs with tutorial # TODO This test MUST be run with --psbt2 flag on and off if desc == "multi": settings_set("unsort_ms", 1) addr_type = bitcoind_addr_fmt(script) M, N = m_n settings_set("sighshchk", 1) # disable checks use_regtest() clear_ms() microsd_wipe() # actual bitcoind watch-only creation + COLDCARD enroll bitcoind_watch_only, bitcoind_signers = bitcoind_multisig(M, N, script, ms_script=desc, keypool_size=30) dest_addr = bitcoind_watch_only.getnewaddress("", addr_type) # create funded PSBT all_of_it = bitcoind_watch_only.getbalance() psbt_resp = bitcoind_watch_only.walletcreatefundedpsbt( [], [{dest_addr: all_of_it}], 0, {"fee_rate": 20, "subtractFeeFromOutputs": [0], "change_type": addr_type} ) psbt = psbt_resp.get("psbt") x = BasicPSBT().parse(base64.b64decode(psbt)) # simple 1 in 1 out shady business assert len(x.inputs) == 1 assert len(x.outputs) == 1 for idx, i in enumerate(x.inputs): i.sighash = SIGHASH_MAP[sighash] psbt = x.as_b64_str() # sign with M - 1 bitcoind signers for signer in bitcoind_signers[:M-1]: half_signed_psbt = signer.walletprocesspsbt(psbt, True, sighash, True) # do not finalize psbt = half_signed_psbt["psbt"] if pytestconfig.getoption('psbt2'): # below is noop if psbt is already v2 po = BasicPSBT().parse(base64.b64decode(psbt)) po.to_v2() psbt = po.as_b64_str() name = f"hsc_{M}of{N}_{script}.psbt" with open(microsd_path(name), "w") as f: f.write(psbt) goto_home() pick_menu_item("Ready To Sign") time.sleep(0.5) title, story = cap_story() if not "OK TO SEND?" in title: pick_menu_item(name) title, story = cap_story() assert title == "OK TO SEND?" assert "Consolidating" in story if sighash != "ALL": assert "(1 warning below)" in story assert "---WARNING---" in story if sighash in ("NONE", "NONE|ANYONECANPAY"): assert "Danger" in story assert "Destination address can be changed after signing (sighash NONE)." in story else: assert "Caution" in story assert "Some inputs have unusual SIGHASH values not used in typical cases." in story press_select() # confirm signing time.sleep(0.1) title, story = cap_story() assert "Updated PSBT is:" in story press_select() os.remove(microsd_path(name)) final_psbt, final_tx, cc_tx_id = file_tx_signing_done(story) po = BasicPSBT().parse(base64.b64decode(final_psbt)) res = finalize_v2_v0_convert(po) assert res["complete"] tx_hex = res["hex"] assert final_tx == tx_hex res = bitcoind_watch_only.testmempoolaccept([tx_hex]) assert res[0]["allowed"] res = bitcoind_watch_only.sendrawtransaction(tx_hex) assert len(res) == 64 # tx id assert res == cc_tx_id bitcoind_watch_only.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # need to mine above tx # split UTXO into many for further consolidation out_num = 21 dest_outs = [{bitcoind_watch_only.getnewaddress("", addr_type):1.0} for _ in range(out_num-1)] psbt_resp = bitcoind_watch_only.walletcreatefundedpsbt( [], dest_outs, 0, {"fee_rate": 7, "change_type": addr_type} ) psbt = psbt_resp.get("psbt") # sign with M - 1 bitcoind signers for signer in bitcoind_signers[:M-1]: half_signed_psbt = signer.walletprocesspsbt(psbt, True, sighash, True) # do not finalize psbt = half_signed_psbt["psbt"] if pytestconfig.getoption('psbt2'): # below is noop if psbt is already v2 po = BasicPSBT().parse(base64.b64decode(psbt)) po.to_v2() psbt = po.as_b64_str() psbt_bytes = base64.b64decode(psbt) # USB sign with COLDCARD & finalize _, txn = try_sign(psbt_bytes, finalize=True, exit_export_loop=False) tx_hex = txn.hex() res = bitcoind_watch_only.testmempoolaccept([tx_hex]) assert res[0]["allowed"] res = bitcoind_watch_only.sendrawtransaction(tx_hex) assert len(res) == 64 # tx id cc_tx_id = txid_from_export_prompt() press_cancel() # exit QR display press_cancel() # exit export loop assert res == cc_tx_id bitcoind_watch_only.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # need to mine above tx assert len(bitcoind_watch_only.listunspent()) == 21 # try to sign change - do a consolidation transaction which spends all inputs consolidate = bitcoind_watch_only.getnewaddress("", addr_type) balance = bitcoind_watch_only.getbalance() psbt_outs = [{consolidate: balance}] res0 = bitcoind_watch_only.walletcreatefundedpsbt([], psbt_outs, 0, {"fee_rate": 5, "subtractFeeFromOutputs": [0], "change_type": addr_type}) psbt = res0["psbt"] x = BasicPSBT().parse(base64.b64decode(psbt)) for idx, i in enumerate(x.inputs): i.sighash = SIGHASH_MAP[sighash] if pytestconfig.getoption('psbt2'): x.to_v2() psbt = x.as_b64_str() name = f"change_{M}of{N}_{script}.psbt" with open(microsd_path(name), "w") as f: f.write(psbt) goto_home() pick_menu_item("Ready To Sign") time.sleep(0.5) title, _ = cap_story() if not "OK TO SEND?" in title: pick_menu_item(name) title, story = cap_story() assert title == "OK TO SEND?" press_select() # confirm signing time.sleep(0.5) title, story = cap_story() if "SINGLE" in sighash: # we have only one output (consolidation) and legacy sighash does not support index out of range # now not just legacy but also segwit prohibits SINGLE out of bounds # consensus allows it but it really is just bad usage - restricted assert "SINGLE corresponding output" in story assert "missing" in story return assert "Updated PSBT is:" in story cc_signed_psbt, _txn, _txid = file_tx_signing_done(story) assert _txn is None and _txid is None press_cancel() # exit re-export loop po = BasicPSBT().parse(base64.b64decode(cc_signed_psbt)) cc_signed_psbt = finalize_v2_v0_convert(po)["psbt"] # CC already signed - now all bitcoin signers for signer in bitcoind_signers[:M-1]: res1 = signer.walletprocesspsbt(cc_signed_psbt, True, sighash, True) psbt = res1["psbt"] cc_signed_psbt = psbt res = bitcoind_watch_only.finalizepsbt(cc_signed_psbt) assert res["complete"] tx_hex = res["hex"] res = bitcoind_watch_only.testmempoolaccept([tx_hex]) assert res[0]["allowed"] res = bitcoind_watch_only.sendrawtransaction(tx_hex) assert len(res) == 64 # tx id bitcoind_signers[0].generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine block assert len(bitcoind_watch_only.listunspent()) == 1 # merged all inputs to one @pytest.mark.parametrize("desc", [ # commented out are cases that were disallowed in past, but are no longer - keep for documentation # ("Missing descriptor checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"), ("Wrong checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#gs2fqgl7"), ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"), ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"), ("Key origin info is required", "wsh(sortedmulti(2,tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#ypuy22nw"), # ("Malformed key derivation info", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#gs2fqgl6"), ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"), ("Cannot use hardened sub derivation path", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0'/*))#3w6hpha3"), # ("Unsupported descriptor", "wsh(multi(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))#t2zpj2eu"), ("Unsupported descriptor", "pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)#ml40v0wf"), ("M must be <= N", "wsh(sortedmulti(3,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#uueddtsy"), ]) def test_exotic_descriptors(desc, clear_ms, goto_home, need_keypress, pick_menu_item, cap_menu, cap_story, make_multisig, microsd_path, use_regtest, is_q1, press_select): use_regtest() clear_ms() msg, desc = desc name = "exotic.txt" if os.path.exists(microsd_path(name)): os.remove(microsd_path(name)) with open(microsd_path(name), "w") as f: f.write(desc + "\n") goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item('Import') time.sleep(0.1) _, story = cap_story() if "Press (1) to import multisig wallet file from SD Card" in story: need_keypress("1") time.sleep(0.1) pick_menu_item(name) _, story = cap_story() assert "Failed to import" in story assert msg in story press_select() def test_ms_wallet_ordering(clear_ms, import_ms_wallet, try_sign_microsd, fake_ms_txn): clear_ms() all_out_styles = list(unmap_addr_fmt.keys()) index = all_out_styles.index("p2sh-p2wsh") all_out_styles[index] = "p2wsh-p2sh" # create two wallets from same master seed (same extended keys and paths, different length (N)) # 1. 3of6 # 2. 3of5 (import in this order, import one with more keys first) # create PSBT for wallet with less keys # sign it # WHY: as we store wallets in list, they are ordered by their addition/import. Iterating over # wallet candindates in psbt.py M are equal N differs --> assertion error name = f'ms1' import_ms_wallet(3, 6, name=name, accept=1, do_import=True, addr_fmt="p2wsh") name = f'ms2' keys3 = import_ms_wallet(3, 5, name=name, accept=1, do_import=True, addr_fmt="p2wsh") psbt = fake_ms_txn(5, 5, 3, keys3, outstyles=all_out_styles, incl_xpubs=True) try_sign_microsd(psbt, encoding='base64') @pytest.mark.parametrize("descriptor", [True, False]) @pytest.mark.parametrize("m_n", [(2, 3), (3, 5), (5, 10)]) def test_ms_xpub_ordering(descriptor, m_n, clear_ms, make_multisig, import_ms_wallet, try_sign_microsd, fake_ms_txn): clear_ms() M, N = m_n all_out_styles = list(unmap_addr_fmt.keys()) index = all_out_styles.index("p2sh-p2wsh") all_out_styles[index] = "p2wsh-p2sh" name = f'ms1' keys = make_multisig(M, N) all_options = list(itertools.combinations(keys, len(keys))) for opt in all_options: import_ms_wallet(M, N, keys=opt, name=name, accept=1, do_import=True, addr_fmt="p2wsh", descriptor=descriptor) psbt = fake_ms_txn(5, 5, M, opt, outstyles=all_out_styles, incl_xpubs=True) try_sign_microsd(psbt, encoding='base64') for opt_1 in all_options: # create PSBT with original keys order psbt = fake_ms_txn(5, 5, M, opt_1, outstyles=all_out_styles, incl_xpubs=True) try_sign_microsd(psbt, encoding='base64') @pytest.mark.parametrize('cmn_pth_from_root', [True, False]) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) @pytest.mark.parametrize('M_N', [(2, 3), (3, 5), (15, 15)]) @pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) @pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH]) def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear_ms, make_multisig, import_ms_wallet, goto_home, pick_menu_item, cap_menu, nfc_read_text, microsd_path, cap_story, need_keypress, load_export, desc): def choose_multisig_wallet(): goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') menu = cap_menu() pick_menu_item(menu[0]) M, N = M_N wal_name = f"reexport_{M}-{N}-{addr_fmt}" dd = { AF_P2WSH: ("m/48h/1h/0h/2h/{idx}", 'p2wsh'), AF_P2SH: ("m/45h/{idx}", 'p2sh'), AF_P2WSH_P2SH: ("m/48h/1h/0h/1h/{idx}", 'p2sh-p2wsh'), } deriv, text_a_fmt = dd[addr_fmt] keys = make_multisig(M, N, unique=1, deriv=None if cmn_pth_from_root else deriv) derivs = [deriv.format(idx=i) for i in range(N)] clear_ms() import_ms_wallet(M, N, accept=1, keys=keys, name=wal_name, derivs=None if cmn_pth_from_root else derivs, addr_fmt=text_a_fmt, descriptor=True, common="m/45h" if cmn_pth_from_root else None, bip67=False if desc == "multi" else True) # get bare descriptor choose_multisig_wallet() pick_menu_item("Descriptors") pick_menu_item("Export") contents = load_export(way, label="Descriptor multisig setup", is_json=False) bare_desc = contents.strip() # get pretty descriptor choose_multisig_wallet() pick_menu_item("Descriptors") pick_menu_item("View Descriptor") for _ in range(5): _, story = cap_story() if "Press (1) to export" in story: need_keypress("1") break else: time.sleep(1) contents = load_export(way, label="Descriptor multisig setup", is_json=False) pretty_desc = contents.strip() # get core descriptor json choose_multisig_wallet() pick_menu_item("Descriptors") pick_menu_item("Bitcoin Core") core_desc_text = load_export(way, label="Bitcoin Core multisig setup", is_json=False) # remove junk text = core_desc_text.replace("importdescriptors ", "").strip() r1 = text.find("[") r2 = text.find("]", -1, 0) text = text[r1: r2] core_desc_object = json.loads(text) # get descriptor from view descriptor choose_multisig_wallet() pick_menu_item("Descriptors") pick_menu_item("View Descriptor") for _ in range(5): try: _, story = cap_story() if "Press (1)" in story: break except: time.sleep(1) view_desc = story.strip().split("\n\n")[1] # assert that bare and pretty are the same after parse assert f"({desc}(" in bare_desc assert bare_desc == view_desc assert parse_desc_str(pretty_desc) == bare_desc for obj in core_desc_object: if obj["internal"]: pass else: assert obj["desc"] == bare_desc clear_ms() def test_multisig_name_validation(microsd_path, offer_ms_import): with open("data/multisig/export-p2wsh-myself.txt", "r") as f: config = f.read() c0 = config.replace("Name: CC-2-of-4", "Name: eê") with pytest.raises(Exception) as e: offer_ms_import(c0, allow_non_ascii=True) assert "must be ascii" in e.value.args[0] c0 = config.replace("Name: CC-2-of-4", "Name: eee\teee") with pytest.raises(Exception) as e: offer_ms_import(c0, allow_non_ascii=True) assert "must be ascii" in e.value.args[0] def test_multisig_deriv_path_migration(settings_set, clear_ms, import_ms_wallet, press_cancel, settings_get, make_multisig, goto_home, start_sign, cap_story, end_sign, pick_menu_item, cap_menu): # this test case simulates multisig wallets imported to CC before 5.3.0 # release; these wallets, saved in user settings, still have "'" in derivation # paths; 5.3.1 firmware implements migration to "h" in MultisigWallet.deserialize clear_ms() deriv, text_a_fmt = ("m/48h/1h/0h/2h/{idx}", 'p2wsh') keys = make_multisig(2, 3, unique=1, deriv=deriv) derivs = [deriv.format(idx=i) for i in range(3)] import_ms_wallet(2, 3, accept=True, keys=keys, name="ms1", derivs=derivs, addr_fmt=text_a_fmt) time.sleep(.1) import_ms_wallet(3, 5, name="ms2", addr_fmt='p2wsh-p2sh', accept=True) time.sleep(.1) ms = settings_get("multisig") pths0 = ms[0][3]["d"] new_pths0 = [p.replace("h", "'") for p in pths0] ms[0][3]["d"] = new_pths0 ms[1][3]["pp"] = ms[1][3]["pp"].replace("h", "'") # this matches data/PSBT ms.append( ( 'ms', (2, 2), [(2285969762, 0, 'tpubDEy2hd2VTrqbBS8cS2svq12UmjGM2j7FHmocjHzAXfVhmJdhBFVVbmAi13humi49esaAuSmz36NEJ6GL3u58RzNuUkExP9vL4d81PM3s8u6'), (1130956047, 1, 'tpubDEFX3QojMWh7x4vSAHN17wpsywpP78aSs2t6nyELHuq1k34gub9mQ7QiaHNCBAYjSQ4UCMMpfBkf5np1cTQaStrvvRCxwxZ7kZaGHqYxUv3')], {'ch': 'XTN', 'ft': 14, 'd': ["m/48'/0'/99'/2'", "m/48'/0'/33'/2'"]} ) ) settings_set("multisig", ms) # psbt from nunchuk, with global xpubs belonging to above ms wallet b64_psbt = "cHNidP8BAF4CAAAAAfkDjXlS32gzOjVhSRArKxvkAecMTnp1g8wwMJTtq74/AAAAAAD9////AekaAAAAAAAAIgAgzs2e4h4vctbFvvauK+QVFAPzCFnMi1H9hTacH7498P8AAAAATwEENYfPBC7g3O2AAAACLvzTgnL7V0DNOnISJdvOgq/6Pw6DAtkPflmZ+Hc04qwC5CShG0rDIlh8gu7gH2NMBLfrIzYSzoSomnVHeMxtxVQUDwVpQzAAAIAAAACAIQAAgAIAAIBPAQQ1h88EkEB8moAAAALv/1L+Cfeg2EPc01pS00f18DIdU5BOeExlGsXyEFOKGwL71tcAiRuL4Bs+uT1JJjU6AbR3j3X60/rI+rTMJmnOgRRiIUGIMAAAgAAAAIBjAACAAgAAgAABAIkCAAAAAZ5Im3CxbYDyByyrr4luss5vr+s0r7Vt8pK+OvicPLO7AAAAAAD9////AnM2AAAAAAAAIgAgvZi0zfKCeBasTet1hNKm73GA4MEkwiSVwCB9cN0/EnTmvqUXAAAAACJRIJF/VcIeZ3E4f+ZEjwiUl5AUUxBJgoaEaPaHHJecq18lq+4qAAEBK3M2AAAAAAAAIgAgvZi0zfKCeBasTet1hNKm73GA4MEkwiSVwCB9cN0/EnQiAgNRdmGxEwsP88xu9rl/tGAXq7kPm/730yTyQ6XHQL/D3kcwRAIgHNmbk4J9wu4ljq6UouY132eX1i/2jWvJjuuWWyLRFScCIBPyPCuZ/Hmd06h9KtVkSropBonIuqIc/BK8JZ50YKp/AQEDBAEAAAABBUdSIQMBr34TVHrqSk8K6505//5YTOkHmHqF83J8iUURtL/ptCEDUXZhsRMLD/PMbva5f7RgF6u5D5v+99Mk8kOlx0C/w95SriIGAwGvfhNUeupKTwrrnTn//lhM6QeYeoXzcnyJRRG0v+m0HA8FaUMwAACAAAAAgCEAAIACAACAAAAAAAAAAAAiBgNRdmGxEwsP88xu9rl/tGAXq7kPm/730yTyQ6XHQL/D3hxiIUGIMAAAgAAAAIBjAACAAgAAgAAAAAAAAAAAAAEBR1IhAscIZVvBcy3Q0GKO4UqR3gDB3pm/tWas8siH3Ej8MmuCIQN8lTj0MMTpT+Dlk2MbMdAaL93hezzNP3WDsRn/gwlVQlKuIgICxwhlW8FzLdDQYo7hSpHeAMHemb+1ZqzyyIfcSPwya4IcYiFBiDAAAIAAAACAYwAAgAIAAIAAAAAAAQAAACICA3yVOPQwxOlP4OWTYxsx0Bov3eF7PM0/dYOxGf+DCVVCHA8FaUMwAACAAAAAgCEAAIACAACAAAAAAAEAAAAA" goto_home() # in time of creatin of PSBT, lopp was making testnet3 unusable... settings_set("fee_limit", -1) start_sign(base64.b64decode(b64_psbt)) title, story = cap_story() assert title == "OK TO SEND?" end_sign() settings_set("fee_limit", 10) # rollback pick_menu_item("Settings") pick_menu_item("Multisig Wallets") m = cap_menu() for msi in m[:3]: # three wallets imported pick_menu_item(msi) pick_menu_item("View Details") time.sleep(.1) _, story = cap_story() assert "'" not in story press_cancel() press_cancel() @pytest.mark.parametrize("fpath", [ # CC export format "data/multisig/export-p2sh-myself.txt", "data/multisig/export-p2sh-p2wsh-myself.txt", "data/multisig/export-p2wsh-myself.txt", # descriptors "data/multisig/desc-p2sh-myself.txt", "data/multisig/desc-p2sh-p2wsh-myself.txt", "data/multisig/desc-p2wsh-myself.txt", ]) def test_scan_any_qr(fpath, is_q1, scan_a_qr, clear_ms, goto_home, pick_menu_item, cap_story, press_cancel): if not is_q1: pytest.skip("No QR support for Mk4") clear_ms() goto_home() pick_menu_item("Scan Any QR Code") with open(fpath, "r") as f: config = f.read() actual_vers, parts = split_qrs(config, 'U', max_version=20) random.shuffle(parts) for p in parts: scan_a_qr(p) time.sleep(2.0 / len(parts)) time.sleep(.1) title, story = cap_story() assert "Create new multisig wallet?" in story press_cancel() @pytest.mark.parametrize("N", [3, 15]) def test_bare_cc_ms_qr_import(N, make_multisig, scan_a_qr, clear_ms, goto_home, pick_menu_item, cap_story, press_cancel, is_q1, need_keypress): # bare: # - no fingerprints # - no xfps # - no meta data if not is_q1: raise pytest.skip("No QR support for Mk4") keys = make_multisig(N, N) config = '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) actual_vers, parts = split_qrs(config, 'U', max_version=20) random.shuffle(parts) # will not work in scan any qr in main menu (no xfp) clear_ms() goto_home() pick_menu_item("Scan Any QR Code") for p in parts: scan_a_qr(p) time.sleep(2.0 / len(parts)) title, story = cap_story() assert title == 'Simple Text' press_cancel() # if someone uses this bare format with keys of depth 1 # multisig import path needs to be used pick_menu_item("Settings") pick_menu_item("Multisig Wallets") pick_menu_item("Import") need_keypress(KEY_QR) for p in parts: scan_a_qr(p) time.sleep(2.0 / len(parts)) title, story = cap_story() assert "Create new multisig wallet?" in story assert f"{N}-of-{N}" in story press_cancel() def test_ms_qr_import_per_cosigner_paths(make_multisig, scan_a_qr, clear_ms, goto_home, pick_menu_item, cap_story, press_cancel, is_q1): # this wasn't tested # not needed on EDGE if not is_q1: raise pytest.skip("No QR support for Mk4") clear_ms() M, N = 2, 3 deriv_tmpl = "m/214748364{idx}h/" + "/".join(["2147483647h"] * 11) # 12 components keys = make_multisig(M, N, deriv=deriv_tmpl) config = "Name: per-path-qr\nPolicy: %d of %d\nFormat: P2WSH\n\n" % (M, N) for idx, (xfp, master, sub) in enumerate(keys): config += "Derivation: %s\n%s: %s\n\n" % (deriv_tmpl.format(idx=idx), xfp2str(xfp), sub.hwif(as_private=False)) actual_vers, parts = split_qrs(config, 'U', max_version=20) random.shuffle(parts) goto_home() pick_menu_item("Scan Any QR Code") for p in parts: scan_a_qr(p) time.sleep(2.0 / len(parts)) time.sleep(.1) title, story = cap_story() assert "Create new multisig wallet?" in story press_cancel() @pytest.mark.parametrize("desc", ["multi", "sortedmulti"]) @pytest.mark.parametrize("data", [ # (out_style, amount, is_change) [("p2wsh", 1000000, 0)] * 99, [("p2sh", 1000000, 1)] * 33, [("p2wsh-p2sh", 1000000, 1)] * 18 + [("p2wsh", 50000000, 0)] * 12, [("p2sh", 1000000, 0), ("p2wsh-p2sh", 50000000, 0), ("p2wsh", 800000, 1)] * 14, ]) def test_txout_explorer(data, clear_ms, import_ms_wallet, fake_ms_txn, start_sign, txout_explorer, desc, pytestconfig): # TODO This test MUST be run with --psbt2 flag on and off clear_ms() M, N = 2, 3 descriptor, bip67 = False, True if desc == "multi": descriptor, bip67 = True, False outstyles = [] outvals = [] change_outputs = [] the_style = "p2wsh" for i in range(len(data)): os, ov, is_change = data[i] outstyles.append(os) outvals.append(ov) if is_change: # only one style will always be the change the_style = os change_outputs.append(i) keys = import_ms_wallet(2, 3, name='ms-test', accept=True, descriptor=descriptor, bip67=bip67, addr_fmt=the_style) inp_amount = sum(outvals) + 100000 # 100k sat fee psbt = fake_ms_txn(1, len(data), M, keys, outstyles=outstyles, outvals=outvals, change_outputs=change_outputs, inp_af=unmap_addr_fmt[the_style], bip67=bip67, input_amount=inp_amount, psbt_v2=pytestconfig.getoption('psbt2')) start_sign(psbt) txout_explorer(data) def test_import_duplicate_shuffled_keys_legacy(clear_ms, make_multisig, import_ms_wallet, cap_story, press_cancel, OK): clear_ms() M, N = 2, 3 wname = "ms02" keys = make_multisig(M, N) import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, keys=keys, descriptor=False) # shuffle keys[0], keys[1] = keys[1], keys[0] with pytest.raises(AssertionError): import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, keys=keys, descriptor=False) time.sleep(.1) title, story = cap_story() assert 'Duplicate wallet' in story assert f'{OK} to approve' not in story press_cancel() def test_import_reorder_different_name_multi(clear_ms, make_multisig, offer_ms_import, settings_set, cap_story, press_select, press_cancel): settings_set("unsort_ms", 1) clear_ms() M, N = 2, 3 keys = make_multisig(M, N) def build_desc(klist): key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in klist] d = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH, is_sorted=False) return d.serialize() val_a = json.dumps({"name": "victim", "desc": build_desc(keys)}) title, story = offer_ms_import(val_a) assert "Create new multisig" in story press_select() time.sleep(.1) keys[0], keys[1] = keys[1], keys[0] val_b = json.dumps({"name": "attacker", "desc": build_desc(keys)}) title, story = offer_ms_import(val_b) assert "Update NAME only" not in story assert "Duplicate wallet. key order" in story press_cancel() @pytest.mark.parametrize("is_sorted", [True, False]) def test_import_same_keys_same_order_rename(is_sorted, clear_ms, make_multisig, offer_ms_import, settings_set, cap_story, press_select, press_cancel): settings_set("unsort_ms", 1) clear_ms() M, N = 2, 3 keys = make_multisig(M, N) key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in keys] desc = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH, is_sorted=is_sorted).serialize() title, story = offer_ms_import(json.dumps({"name": "original", "desc": desc})) assert "Create new multisig" in story press_select() time.sleep(.1) title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": desc})) assert "Update NAME only" in story assert "Duplicate wallet" not in story press_cancel() def test_import_sortedmulti_reorder_rename(clear_ms, make_multisig, offer_ms_import, cap_story, press_select, press_cancel): clear_ms() M, N = 2, 3 keys = make_multisig(M, N) def build_desc(klist): key_list = [(xfp, "m/45h", sk.hwif(as_private=False)) for xfp, _, sk in klist] return MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=AF_P2WSH, is_sorted=True).serialize() title, story = offer_ms_import(json.dumps({"name": "original", "desc": build_desc(keys)})) assert "Create new multisig" in story press_select() time.sleep(.1) keys[0], keys[1] = keys[1], keys[0] title, story = offer_ms_import(json.dumps({"name": "renamed", "desc": build_desc(keys)})) assert "Update NAME only" in story assert "Duplicate wallet" not in story press_cancel() @pytest.mark.parametrize("order", list(itertools.product([True, False], repeat=2))) def test_import_duplicate_shuffled_keys(clear_ms, make_multisig, import_ms_wallet, cap_story, press_cancel, order, OK): # DO NOT allow to import both wsh(sortedmulti(2,A,B,C)) and wsh(sortedmulti(2,B,C,A)) # DO NOT allow to import both wsh(multi(2,A,B,C)) and wsh(multi(2,B,C,A)) # DO NOT allow to import both wsh(sortedmulti(2,A,B,C)) and wsh(multi(2,B,C,A)) # MUST BE treated as duplicates clear_ms() M, N = 2, 3 A, B = order # defines bip67 wname = "ms02" keys = make_multisig(M, N) import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, keys=keys, descriptor=True, bip67=A) # shuffle keys[0], keys[1] = keys[1], keys[0] with pytest.raises(AssertionError): import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, keys=keys, descriptor=True, bip67=B) time.sleep(.1) title, story = cap_story() assert 'Duplicate wallet' in story assert f'{OK} to approve' not in story if A != B: assert "BIP-67 clash" in story press_cancel() @pytest.mark.parametrize("int_ext", [True, False]) def test_multi_sortedmulti_duplicate(clear_ms, make_multisig, import_ms_wallet, OK, cap_story, press_cancel, int_ext, offer_ms_import, settings_set): clear_ms() settings_set("unsort_ms", 1) M, N = 3, 5 wname = "ms001" fstr = "m/48h/1h/0h/2h/{idx}" derivs = [fstr.format(idx=i) for i in range(N)] keys = make_multisig(M, N, deriv=fstr) import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, keys=keys, int_ext_desc=True, derivs=derivs) # create identical but unsorted descriptor obj_keys = [(keys[i][0], derivs[i], keys[i][2].hwif()) for i in range(len(keys))] d = MultisigDescriptor(M, N, obj_keys, addr_fmt=AF_P2WSH, is_sorted=False) ser_desc = d.serialize(int_ext=int_ext) title, story = offer_ms_import(ser_desc) assert 'Duplicate wallet' in story assert f'{OK} to approve' not in story assert "BIP-67 clash" in story press_cancel() def test_unsort_multisig_setting(settings_set, import_ms_wallet, goto_home, pick_menu_item, cap_story, need_keypress, settings_get, clear_ms, press_select, is_q1): clear_ms() mi = "Unsorted Multisig?" if is_q1 else "Unsorted Multi?" settings_set("unsort_ms", 0) # OFF by default with pytest.raises(Exception) as e: import_ms_wallet(2, 3, "p2wsh", descriptor=True, bip67=False, accept=True, force_unsort_ms=False) assert '"multi(...)" not allowed' in e.value.args[0] goto_home() pick_menu_item("Settings") pick_menu_item("Multisig Wallets") pick_menu_item(mi) time.sleep(.1) title, story = cap_story() assert '"multi(...)" unsorted multisig wallets that DO NOT follow BIP-67.' in story assert ("CRUCIAL importance to backup multisig descriptor" " for unsorted wallets in order to preserve key ordering") in story assert 'USE AT YOUR OWN RISK' in story assert 'Press (4)' in story need_keypress("4") time.sleep(.1) pick_menu_item("Allow") time.sleep(.3) assert settings_get("unsort_ms") == 1 import_ms_wallet(2, 3, "p2wsh", descriptor=True, bip67=False, accept=True, force_unsort_ms=False) assert len(settings_get("multisig")) == 1 pick_menu_item("Settings") pick_menu_item("Multisig Wallets") pick_menu_item(mi) time.sleep(.1) title, story = cap_story() assert "Remove already saved multi(...) wallets first" in story assert "2-of-3" in story # wallet that needs to be removed press_select() assert len(settings_get("multisig")) == 1 clear_ms() pick_menu_item(mi) pick_menu_item("Do Not Allow") time.sleep(.3) with pytest.raises(Exception) as e: import_ms_wallet(2, 3, "p2wsh", descriptor=True, bip67=False, accept=True, force_unsort_ms=False) assert '"multi(...)" not allowed' in e.value.args[0] @pytest.mark.bitcoind @pytest.mark.parametrize("cs", [True, False]) @pytest.mark.parametrize("way", ["usb", "nfc", "sd", "vdisk", "qr"]) def test_import_multisig_usb_json(use_regtest, cs, way, cap_menu, clear_ms, pick_menu_item, goto_home, need_keypress, offer_ms_import, bitcoind, microsd_path, virtdisk_path, import_multisig): name = "my_ms_wal" use_regtest() clear_ms() with open("data/multisig/desc-p2wsh-myself.txt", "r") as f: desc = f.read().strip() if not cs: desc, cs = desc.split("#") val = json.dumps({"name": name, "desc": desc}) data = None fname = None if way == "usb": title, story = offer_ms_import(val) else: if way in ["nfc", "qr"]: data = val else: fname = "diff_name.txt" # will be ignored as name in the json has preference if way == "sd": fpath = microsd_path(fname) else: fpath = virtdisk_path(fname) with open(fpath, "w") as f: f.write(val) title, story = import_multisig(fname=fname, way=way, data=data) assert "Create new multisig wallet?" in story assert name in story need_keypress("y") time.sleep(.2) goto_home() pick_menu_item("Settings") pick_menu_item("Multisig Wallets") m = cap_menu() assert name in m[0] @pytest.mark.parametrize("err,config", [ # all dummy data there to satisfy badlen check in usb.py ( "'desc' key required", {"name": "my_miniscript", "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} ), ( "'name' length", {"name": "a" * 41, "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} ), ( "'name' length", {"name": "a", "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} ), ( "'desc' empty", {"name": "ab", "desc": "", "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} ), ( "'desc' empty", {"name": "ab", "desc": None, "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} ), ]) def test_json_import_failures(err, config, offer_ms_import): with pytest.raises(Exception) as e: offer_ms_import(json.dumps(config)) assert err in e.value.args[0] def test_msas_enable_disable(import_ms_wallet, pick_menu_item, cap_story, goto_home, is_q1, settings_remove, need_keypress, press_select, clear_ms): clear_ms() goto_home() settings_remove("msas") # default name = "msas_test" import_ms_wallet(2,3,"p2wsh", accept=True, name=name) goto_home() pick_menu_item("Address Explorer") need_keypress("4") # confirm msg pick_menu_item(name) # ms wallet time.sleep(.1) _, story = cap_story() assert "___" in story goto_home() pick_menu_item("Settings") pick_menu_item("Multisig Wallets") pick_menu_item("Full %s View?" % ("Address" if is_q1 else "Addr")) time.sleep(.1) _, story = cap_story() assert "full multisig addresses are shown" in story press_select() time.sleep(.1) pick_menu_item("Show Full") goto_home() pick_menu_item("Address Explorer") need_keypress("4") # confirm msg pick_menu_item(name) # ms wallet time.sleep(.1) _, story = cap_story() assert "___" not in story goto_home() pick_menu_item("Settings") pick_menu_item("Multisig Wallets") pick_menu_item("Full %s View?" % ("Address" if is_q1 else "Addr")) # now enabled - so no story pick_menu_item("Partly Censor") goto_home() pick_menu_item("Address Explorer") need_keypress("4") # confirm msg pick_menu_item(name) # ms wallet time.sleep(.1) _, story = cap_story() assert "___" in story @pytest.mark.parametrize("desc", [True, False]) def test_root_keys_import(desc, import_ms_wallet, clear_ms, goto_address_explorer, pick_menu_item, cap_story, cap_menu): clear_ms() M, N = 2, 3 keys = import_ms_wallet(M, N, "p2wsh", accept=True, name="root", common="m", descriptor=desc) # just xfp + internal/external + index target_der_paths = [f"[{xfp2str(tup[0])}/0/0]" for tup in keys] goto_address_explorer() pick_menu_item(cap_menu()[-1]) _, story = cap_story() assert "//" not in story der_paths = story.split("\n\n")[1].split("\n")[:N] assert der_paths == target_der_paths @pytest.mark.bitcoind def test_cc_root_key(import_ms_wallet, bitcoind, use_regtest, clear_ms, microsd_wipe, goto_home, pick_menu_item, cap_story, press_select, need_keypress, offer_ms_import, cap_menu, load_export, try_sign, goto_address_explorer, settings_set): # only CC has root key here, not practical to attempt get xpub from core, if possible settings_set("msas", 1) use_regtest() clear_ms() microsd_wipe() M, N = 2, 2 cosigner = bitcoind.create_wallet(wallet_name=f"bds", disable_private_keys=False, blank=False, passphrase=None, avoid_reuse=False, descriptors=True) ms = bitcoind.create_wallet( wallet_name=f"watch_only_roots", disable_private_keys=True, blank=True, passphrase=None, avoid_reuse=False, descriptors=True ) goto_home() target_first_der = [] # get key from bitcoind cosigner target_desc = "" bitcoind_descriptors = cosigner.listdescriptors()["descriptors"] for desc in bitcoind_descriptors: if desc["desc"].startswith("pkh(") and desc["internal"] is False: target_desc = desc["desc"] core_desc, checksum = target_desc.split("#") # remove pkh(....) core_key = core_desc[4:-1] _idx = core_key.find("]") assert _idx != -1 inner = core_key[1:_idx].split("/") # xfp to upper inner[0] = inner[0].upper() core_der_base = f"[{'/'.join(inner)}/0/%d]" cc_der_base = f"[{xfp2str(simulator_fixed_xfp)}/0/%d]" target_first_der.append(core_der_base % 0) target_first_der.append(cc_der_base % 0) desc = f"wsh(sortedmulti(2,{core_key},[{xfp2str(simulator_fixed_xfp).lower()}]{simulator_fixed_tpub}/0/*))" desc_info = ms.getdescriptorinfo(desc) desc_w_checksum = desc_info["descriptor"] # with checksum title, story = offer_ms_import(desc_w_checksum) assert "Create new multisig wallet?" in story assert f"All {N} co-signers must approve spends" in story assert "P2WSH" in story press_select() # approve multisig import goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') menu = cap_menu() pick_menu_item(menu[0]) # pick imported descriptor multisig wallet pick_menu_item("Descriptors") pick_menu_item("Bitcoin Core") text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False) text = text.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[") r2 = text.find("]", -1, 0) text = text[r1: r2] core_desc_object = json.loads(text) # bump range to be able to verify multisig scripts against bitcoind # default exported range from us is just 100 addresses for i in range(len(core_desc_object)): core_desc_object[i]["range"] = [0,250] # import descriptors to watch only wallet res = ms.importdescriptors(core_desc_object) for obj in res: assert obj["success"], obj addr_type = "bech32" multi_addr = ms.getnewaddress("", addr_type) bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=49) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mining dest_addr = ms.getnewaddress("", addr_type) # create funded PSBT psbt_resp = ms.walletcreatefundedpsbt( [], [{dest_addr: 5}], 0, {"fee_rate": 2, "change_type": addr_type} ) _, updated = try_sign(base64.b64decode(psbt_resp.get("psbt"))) done = cosigner.walletprocesspsbt(base64.b64encode(updated).decode(), True)["psbt"] rr = ms.finalizepsbt(done) assert rr['complete'] tx_hex = rr["hex"] res = bitcoind.supply_wallet.testmempoolaccept([tx_hex]) assert res[0]["allowed"] txn_id = bitcoind.supply_wallet.sendrawtransaction(rr['hex']) assert len(txn_id) == 64 bitcoind_addrs = ms.deriveaddresses(desc_w_checksum, [0,250]) goto_address_explorer() pick_menu_item("2-of-2") _, story = cap_story() # 2of2 - full paths shown for first address der_paths = story.split("\n\n")[1].split("\n")[:N] assert der_paths == target_first_der need_keypress('1') # SD contents = load_export("sd", label="Address summary", is_json=False) cc_addrs = contents.strip().split("\n")[1:] # Generate the addresses file and get each line in a list for i, line in enumerate(cc_addrs): split_line = line.split(",") addr = split_line[1][1:-1] script_hex = split_line[2][1:-1] cc_der = split_line[-1][1:-1] core_der = split_line[-2][1:-1] assert cc_der == (cc_der_base % i) assert core_der == (core_der_base % i) assert addr == bitcoind_addrs[i] addr_info = ms.getaddressinfo(addr) assert addr_info["ismine"] assert addr_info["hex"] == script_hex @pytest.mark.parametrize("way", ["nfc", "qr"]) def test_multisig_nfc_qr_finalization(way, clear_ms, make_multisig, import_ms_wallet, cap_story, press_cancel, OK, settings_set, fake_ms_txn, try_sign_nfc, settings_remove, try_sign_bbqr): clear_ms() settings_remove("ptxurl") # tesing above parameter, ptxurl needs to be off M, N = 1, 2 wname = "finms-%s" % way keys = import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, descriptor=False) psbt = fake_ms_txn(2, 2, M, keys, outstyles=ADDR_STYLES_MS, change_outputs=[0]) if way == "nfc": ip, result, txid = try_sign_nfc(psbt, expect_finalize=True, nfc_tools=True, encoding="hex") is_fin = bool(txid) else: assert way == "qr" ip, ft, result = try_sign_bbqr(psbt) is_fin = (ft == "T") assert is_fin def test_input_script_type(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story, press_cancel, settings_set, fake_ms_txn): def sign_check(psbt): # start sign MUST raise scriptPubKey mismatch on inputs or change outputs # it does not in current master start_sign(psbt) _, story = cap_story() try: end_sign() assert False, story except Exception as e: assert e.args[0] == 'Coldcard Error: Unknown multisig wallet' return clear_ms() M, N = 2, 3 wname = "bugg" # import wallet with script type p2wsh keys = import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, descriptor=True) # create txn with p2sh inputs # we shouldn't even recognize these input as ours psbt = fake_ms_txn(2, 2, M, keys, inp_af=AF_P2SH, change_outputs=[0,1]) sign_check(psbt) # create txn with p2sh-p2wsh # we shouldn't even recognize these input as ours psbt = fake_ms_txn(2, 2, M, keys, change_outputs=[0,1], inp_af=AF_P2WSH_P2SH) sign_check(psbt) # ============================ clear_ms() # import wallet with script type p2sh-p2wsh keys = import_ms_wallet(M, N, addr_fmt="p2sh-p2wsh", name=wname, accept=True, descriptor=True) # create txn with p2wsh inputs # we shouldn't even recognize these input as ours psbt = fake_ms_txn(2, 2, M, keys, change_outputs=[0,1], inp_af=AF_P2WSH) sign_check(psbt) # create txn with p2sh inputs # we shouldn't even recognize these input as ours psbt = fake_ms_txn(2, 2, M, keys, change_outputs=[0,1], inp_af=AF_P2SH) sign_check(psbt) # ============================ clear_ms() # import wallet with script type p2sh keys = import_ms_wallet(M, N, addr_fmt="p2sh", name=wname, accept=True, descriptor=True) # create txn with p2wsh inputs # we shouldn't even recognize these input as ours psbt = fake_ms_txn(2, 2, M, keys, change_outputs=[0,1], inp_af=AF_P2WSH) sign_check(psbt) # create txn with p2sh-p2wsh inputs # we shouldn't even recognize these input as ours psbt = fake_ms_txn(2, 2, M, keys, change_outputs=[0,1], inp_af=AF_P2WSH_P2SH) sign_check(psbt) def test_change_output_script_type(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story, press_cancel, settings_set, fake_ms_txn): def sign_check(psbt): # start sign MUST raise scriptPubKey mismatch on inputs or change outputs # it does not in current master start_sign(psbt) _, story = cap_story() assert "Change back" not in story assert "Consolidating" not in story assert "Sending" in story end_sign() # must work clear_ms() M, N = 2, 3 wname = "bugg" # import wallet with script type p2wsh keys = import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True, descriptor=True) # inputs correct, change outputs wrong address format psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh", inp_af=AF_P2WSH, change_outputs=[0,1]) sign_check(psbt) psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh-p2wsh", change_outputs=[0,1], inp_af=AF_P2WSH) sign_check(psbt) # ============================ clear_ms() # import wallet with script type p2sh-p2wsh keys = import_ms_wallet(M, N, addr_fmt="p2sh-p2wsh", name=wname, accept=True, descriptor=True) # inputs correct, change outputs wrong address format psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2wsh", change_outputs=[0,1], inp_af=AF_P2WSH_P2SH) sign_check(psbt) psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh", change_outputs=[0,1], inp_af=AF_P2WSH_P2SH) sign_check(psbt) # ============================ clear_ms() M, N = 2, 3 wname = "bugg" # import wallet with script type p2sh keys = import_ms_wallet(M, N, addr_fmt="p2sh", name=wname, accept=True, descriptor=True) # inputs correct, change outputs wrong address format psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2wsh", change_outputs=[0,1], inp_af=AF_P2SH) sign_check(psbt) psbt = fake_ms_txn(2, 2, M, keys, force_outstyle="p2sh-p2wsh", change_outputs=[0,1], inp_af=AF_P2SH) sign_check(psbt) def test_sh_vs_wrapped_segwit_psbt(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story, press_cancel, settings_set, fake_ms_txn): clear_ms() M, N = 2, 3 wname = "spk_check_sh_shwsh" # import wallet with script type p2sh keys = import_ms_wallet(M, N, addr_fmt="p2sh", name=wname, accept=True, descriptor=True) def hack(psbt_in): for inp in psbt_in.inputs: # switch scripts so it looks like bare p2sh instead wrapped segwit script hash # it even has our keys, and script is correct inp.redeem_script = inp.witness_script inp.witness_script = None # PSBT has p2sh-p2wsh inputs & outputs # but PSBT creator made a mistake and filled redeem/witness like in p2sh (see hack) psbt = fake_ms_txn(2, 2, M, keys, inp_af=AF_P2WSH_P2SH, hack_psbt=hack) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert "OK TO SEND?" not in title assert "spk mismatch" in story def test_wrapped_segwit_vs_sh_psbt(clear_ms, import_ms_wallet, start_sign, end_sign, cap_story, press_cancel, settings_set, fake_ms_txn): clear_ms() M, N = 2, 3 wname = "spk_check_shwsh_sh" # import wallet with script type p2sh-p2wsh keys = import_ms_wallet(M, N, addr_fmt="p2sh-p2wsh", name=wname, accept=True, descriptor=True) def hack(psbt_in): for inp in psbt_in.inputs: # switch scripts so it looks like bare p2sh instead wrapped segwit script hash # it even has our keys, and script is correct inp.witness_script = inp.redeem_script inp.redeem_script = b"\x00\x20" + sha256(inp.witness_script).digest() # PSBT has p2sh inputs & outputs # but PSBT creator made a mistake and filled redeem/witness like in p2sh (see hack) psbt = fake_ms_txn(2, 2, M, keys, inp_af=AF_P2SH, hack_psbt=hack) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert "OK TO SEND?" not in title assert "spk mismatch" in story @pytest.mark.parametrize("af", [AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH]) def test_af_psbt_input_matching(af, clear_ms, fake_ms_txn, import_ms_wallet, goto_home, cap_story, start_sign, end_sign, settings_set): M, N = 3, 5 clear_ms() goto_home() settings_set("pms", 2) # Trust PSBT # random path that does not match anything path = "m/21/21/21" def path_mapper(idx): kk = str_to_path(path) return kk + [0, 0] def incl_xpubs(idx, xfp, m, sk): kk = str_to_path(path) bp = pack('<%dI' % (path.count("/") + 1), xfp, *kk) return sk.node.serialize_public(), bp keys = import_ms_wallet(M, N, name='psbt_af_match', accept=True, addr_fmt=af, common=path, do_import=False) psbt = fake_ms_txn(1, 2, M, keys, incl_xpubs=incl_xpubs, inp_af=af, outstyles=ADDR_STYLES_MS, change_outputs=[0], path_mapper=path_mapper) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert "Invalid PSBT" not in story res = end_sign(accept=True) po = BasicPSBT().parse(res) assert len(po.inputs[0].part_sigs) == 1 def test_casa_case(clear_ms, settings_set, start_sign, end_sign, cap_story, set_seed_words): clear_ms() set_seed_words("cannon budget unknown inhale select virtual absurd chapter inch firm inquiry valley") settings_set("pms", 2) # Trust PSBT # got this PSBT directly form Casa (part of their test suite) psbt = 'cHNidP8BAFMBAAAAAeB18EjWQ2J8kHcbWSOWLZ4XG9TROiK2EqIAn2a5pe+PAAAAAAD9////AS9EAgAAAAAAF6kURuQXuB5Udus5+DWGg/ZP1bK5/5mHAAAAAE8BAkKJ7wM+PJ4JAAAAAOIDwvV5ejMJ0rSyNey8cKbskf4kk73yRvCe8cUEiNhiA4E0IkUc+Xmx5ndEYFbZ9sHkOnOXJWeSjxIN6Go1AMfiEM3yQGYxAAAAAQAAAAAAAABPAQJCie8DR+pdIQAADTESbO7YkHNCwPnMVS6sXbxDRiMahe6Eil9h9RzUx1aiKQL+RIAGlCJ8PIu+x5O+oSdz9kSY/1vbnZxjm99fMRYWuRAS1W01MQAAAAEAAAAAAAAATwECQonvA+HsXFkAAAAAsdd6QnUkHTmhRlBNy/VQOWcZHfdPJSf4tX6LWUj1VWMCYWPVp4pXPi5mg/AC9ZP4sdbLtwyRwvalwzNO6KfrzaIQXbGC5jEAAAABAAAAAAAAAAABAPgBAAAAAAEB+Qe27L6aqLnQJ4sbxsWvQR6mhcNk0Y1DIbARPdJjSd4BAAAAFxYAFCU3KVhnuRLNeMk85jv3FgbOR9PH/f///wIrcgIAAAAAABepFJanKkFHtvWWwbHNOjPR6NP7RPfqhwoxrQUAAAAAF6kU5xbgzlw1qmkUVzLnuWp6lOPJpImHAkgwRQIhAMtF6v3RgUOxfTs9uGKAV6jjFb3TPlcZSrhRqgO8QlQ2AiANiNAi5rEGfAR0cAp8AadOOIlcQFH+X0Pf98Nz0KF5vQEhAqiLyMuk2fePxFgctRiB5QB/jwBA7q/zWtHgUbskc3rQAAAAAAEBICtyAgAAAAAAF6kUlqcqQUe29ZbBsc06M9Ho0/tE9+qHAQQiACDHvYHyHI3mL9BOaF+AgriPtki9tfeDyUhVBytva0dqmgEFaVIhAnJjmbStmsYp7bb8aAN/aN2hKiLk+6SzNpcjJftG5703IQKO3IofMd3egH0WqIpjS/M3iusXuFuAHA06s2eLBSCs+CECpbdrv+ihGqUyCBYU+K7QgpXuMD7sOt0zcltPV04PJz1TriIGAnJjmbStmsYp7bb8aAN/aN2hKiLk+6SzNpcjJftG5703GM3yQGYxAAAAAQAAAAAAAAAAAAAAAAAAACIGAo7cih8x3d6AfRaoimNL8zeK6xe4W4AcDTqzZ4sFIKz4GBLVbTUxAAAAAQAAAAAAAAAAAAAAAAAAACIGAqW3a7/ooRqlMggWFPiu0IKV7jA+7DrdM3JbT1dODyc9GF2xguYxAAAAAQAAAAAAAAAAAAAAAAAAAAAA' start_sign(base64.b64decode(psbt)) time.sleep(.1) title, story = cap_story() assert "Invalid PSBT" not in story res = end_sign(psbt) po = BasicPSBT().parse(res) assert len(po.inputs[0].part_sigs) == 1 @pytest.mark.parametrize("af", [AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH]) @pytest.mark.parametrize("psbt_v2", [True, False]) def test_af_matching_convoluted_case(af, psbt_v2, clear_ms, fake_ms_txn, import_ms_wallet, goto_home, pick_menu_item, cap_story, press_select, start_sign, end_sign, is_q1, settings_set): # merge two multisig PSBTs, each with one input (two inputs after merge) # first input is not ours, but has same M, N # second is ours, but address format matching will be based on first M, N = 3, 5 clear_ms() goto_home() settings_set("pms", 2) # TRUST PSBT # random path that does not match anything path = "m/21/21/21" def path_mapper(idx): kk = str_to_path(path) return kk + [0, 0] def incl_xpubs(idx, xfp, m, sk): kk = str_to_path(path) bp = pack('<%dI' % (path.count("/") + 1), xfp, *kk) return sk.node.serialize_public(), bp keys0 = import_ms_wallet(M, N, name='00', accept=True, addr_fmt=af, common=path, do_import=False) psbt0 = fake_ms_txn(1, 2, M, keys0, incl_xpubs=incl_xpubs, inp_af=af, outstyles=ADDR_STYLES_MS, change_outputs=[0], path_mapper=path_mapper) # max confusion af1 = { AF_P2SH: AF_P2WSH_P2SH, AF_P2WSH: AF_P2WSH_P2SH, AF_P2WSH_P2SH: AF_P2SH }[af] keys1 = import_ms_wallet(M, N+1, name='11', accept=True, addr_fmt=af1, common=path, do_import=False) # last key is ours - drop it - as if it has our key, we will fail keys1 = keys1[:-1] psbt1 = fake_ms_txn(1, 2, M, keys1, incl_xpubs=incl_xpubs, inp_af=af1, outstyles=ADDR_STYLES_MS, change_outputs=[0], path_mapper=path_mapper) # now combine above PSBT so that one that we wanna sign (and preserve XPUBS is only the second input) # aka trick our matching algo to be wrong p0 = BasicPSBT().parse(psbt0) p1 = BasicPSBT().parse(psbt1) # change to PSBT v2 to not need handle txn p00 = BasicPSBT().parse(p0.to_v2()) p11 = BasicPSBT().parse(p1.to_v2()) combined = BasicPSBT() combined.version = 2 combined.txn_version = 2 combined.xpubs = p0.xpubs combined.input_count = p00.input_count + p11.input_count combined.output_count = p00.output_count + p11.output_count combined.fallback_locktime = 0 # put the one that we will not be signig first (i.e no matching PSBT_XPUBS) combined.inputs = p11.inputs + p00.inputs combined.outputs = p11.outputs + p00.outputs # drop xfp paths for input 0 - otherwise failure - correct combined.inputs[0].bip32_paths = {} psbt = combined.to_v2() if psbt_v2 else combined.to_v0() start_sign(psbt) time.sleep(.1) title, story = cap_story() assert "(1 warning below)" in story assert "Limited Signing" in story assert "We are not signing these inputs, because we do not know the key: 0" in story res = end_sign(accept=True) po = BasicPSBT().parse(res) assert len(po.inputs[0].part_sigs) == 0 # considered not ours assert len(po.inputs[1].part_sigs) == 1 # signature added def test_fwd_slash_in_name(import_ms_wallet, clear_ms, pick_menu_item, need_keypress, cap_story, press_cancel, garbage_collector, microsd_path): clear_ms() name = "2/3 me/her/it" import_ms_wallet(2,3, "p2wsh", name=name, accept=True) pick_menu_item("Settings") pick_menu_item("Multisig Wallets") pick_menu_item(f"2/3: {name}") pick_menu_item("Coldcard Export") need_keypress("1") # SD time.sleep(.1) title, story = cap_story() fname = story.split("\n\n")[1] garbage_collector.append(microsd_path(fname)) assert fname.strip().startswith("export-2-3_me-her-it") press_cancel() press_cancel() pick_menu_item("Descriptors") pick_menu_item("Export") need_keypress("1") # SD time.sleep(.1) title, story = cap_story() fname = story.split("\n\n")[1] garbage_collector.append(microsd_path(fname)) assert fname.strip().startswith("desc-2-3_me-her-it") press_cancel() press_cancel() @pytest.mark.parametrize("chain", ["BTC", "XTN"]) @pytest.mark.parametrize("M_N", [(3, 5)])#, (14, 15)]) @pytest.mark.parametrize("complete", [False, None]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh", "p2sh-p2wsh"]) def test_txin_explorer(dev, chain, M_N, addr_fmt, fake_ms_txn, start_sign, settings_set, txin_explorer, cap_story, pytestconfig, import_ms_wallet, complete, clear_ms): # TODO This test MUST be run with --psbt2 flag on and off clear_ms() settings_set("chain", chain) inp_amount = 100000000 num_ins = 2 M, N = M_N keys = import_ms_wallet(M, N, name='txin_expl', accept=True, netcode=chain, descriptor=True, addr_fmt=addr_fmt) all_xfps = [xfp2str(k[0]) for k in keys][:-1] # remove myself if complete is False: target_xfps = all_xfps[:M-1] else: target_xfps = [] def hack(psbt): for inp in psbt.inputs: for i, (pk, pth) in enumerate(inp.bip32_paths.items()): xfp = pth[:4].hex().upper() if xfp in target_xfps: inp.part_sigs[pk] = os.urandom(71) psbt = fake_ms_txn(num_ins, 1, M, keys, inp_af=unmap_addr_fmt[addr_fmt], input_amount=inp_amount, psbt_v2=pytestconfig.getoption('psbt2'), hack_psbt=hack) start_sign(psbt) txin_explorer(num_ins, [(addr_fmt, inp_amount, 1, chain, (M,N), None, None, complete, target_xfps)]) def test_txin_explorer_our_sig(dev, fake_ms_txn, start_sign, settings_set, clear_ms, txin_explorer, cap_story, pytestconfig, import_ms_wallet): # TODO This test MUST be run with --psbt2 flag on and off clear_ms() inp_amount = 100000000 num_ins = 3 M, N = 5,7 af = "p2wsh" keys = import_ms_wallet(M, N, name='txin_expl', accept=True, netcode="XTN", descriptor=True, addr_fmt="p2wsh") my_xfp = xfp2str(keys[-1][0]) def hack(psbt): for inp in psbt.inputs: for i, (pk, pth) in enumerate(inp.bip32_paths.items()): xfp = pth[:4].hex().upper() if xfp in my_xfp: inp.part_sigs[pk] = os.urandom(71) psbt = fake_ms_txn(num_ins, 1, M, keys, inp_af=unmap_addr_fmt[af], input_amount=inp_amount, psbt_v2=pytestconfig.getoption('psbt2'), hack_psbt=hack) start_sign(psbt) txin_explorer(num_ins, [(af, inp_amount, 0, "XTN", (M,N), None, None, False, [my_xfp])]) def test_ms_xpubs_account_cancel(goto_home, pick_menu_item, press_cancel, cap_menu, press_select): goto_home() pick_menu_item('Settings') pick_menu_item('Multisig Wallets') pick_menu_item('Export XPUB') press_select() # confirm story time.sleep(.1) press_cancel() time.sleep(.2) assert "Export XPUB" in cap_menu() @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh", "p2sh"]) @pytest.mark.parametrize("num_ins", [1, 10]) @pytest.mark.parametrize("incl_self", [True, False]) def test_fully_signed(addr_fmt, num_ins, import_ms_wallet, fake_ms_txn, start_sign, cap_story, press_cancel, clear_ms, incl_self): clear_ms() M, N = 2, 4 keys = import_ms_wallet(M, N, name='fully_signed', accept=True, netcode="XTN", descriptor=True, addr_fmt="p2wsh") # both below cases include full necessary (dummy)signature set (M) if incl_self: i, j = 2, 4 # remove two random co-signers, keep myself as already signed else: i, j = 0, 2 # remove myself + one more random co-signer xfps = [xfp2str(k[0]) for k in keys][i:j] assert len(xfps) == M def hack(psbt): for inp in psbt.inputs: for i, (pk, pth) in enumerate(inp.bip32_paths.items()): xfp = pth[:4].hex().upper() if xfp in xfps: inp.part_sigs[pk] = os.urandom(71) # fake sig psbt = fake_ms_txn(num_ins, 2, M, keys, inp_af=unmap_addr_fmt[addr_fmt], hack_psbt=hack) start_sign(psbt) time.sleep(.1) title, story = cap_story() assert "Failure" == title assert "completely signed already" in story press_cancel() # EOF