diff --git a/releases/QChangeLog.md b/releases/QChangeLog.md index 9c6cc02a..215e88ee 100644 --- a/releases/QChangeLog.md +++ b/releases/QChangeLog.md @@ -36,6 +36,7 @@ seed without master secret. - Bugfix: Battery idle timeout also considers last progress bar update - Enhancement: Allow export of multisig XPUBs via BBQr +- Enhancement: Import multisig via QR/BBQr - both legacy COLDCARD export and descriptors supported ## 1.1.0Q - 2024-04-02 diff --git a/shared/multisig.py b/shared/multisig.py index 2376d353..96ba08d7 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -10,7 +10,7 @@ from ux import import_export_prompt, ux_enter_bip32_index, show_qr_code from files import CardSlot, CardMissingError, needs_microsd from descriptor import MultisigDescriptor, multisig_descriptor_template from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS -from menu import MenuSystem, MenuItem +from menu import MenuSystem, MenuItem, ShortcutItem from opcodes import OP_CHECKMULTISIG from exceptions import FatalPSBTIssue from glob import settings @@ -875,7 +875,8 @@ class MultisigWallet(WalletABC): hdr = '%s %s' % (mode, my_xfp) label = "%s multisig setup" % name - choice = await import_export_prompt("%s file" % label, is_import=False) + choice = await import_export_prompt("%s file" % label, is_import=False, + no_qr=not version.has_qwerty) if choice == KEY_CANCEL: return elif choice in (KEY_NFC, KEY_QR): @@ -1259,8 +1260,10 @@ class MultisigMenu(MenuSystem): menu=make_ms_wallet_menu, arg=ms.storage_idx)) from glob import NFC rv.append(MenuItem('Import from File', f=import_multisig)) + rv.append(MenuItem('Import from QR', f=import_multisig_qr, + predicate=version.has_qwerty, shortcut=KEY_QR)) rv.append(MenuItem('Import via NFC', f=import_multisig_nfc, - predicate=bool(NFC))) + predicate=bool(NFC), shortcut=KEY_NFC)) rv.append(MenuItem('Export XPUB', f=export_multisig_xpubs)) rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) @@ -1309,8 +1312,10 @@ async def make_ms_wallet_descriptor_menu(menu, label, item): rv = [ MenuItem('View Descriptor', f=ms_wallet_show_descriptor, arg=ms), - MenuItem('Export', f=ms_wallet_ckcc_export, arg=(ms, {"descriptor": True, "desc_pretty": False})), - MenuItem('Bitcoin Core', f=ms_wallet_ckcc_export, arg=(ms, {"descriptor": True, "core": True})), + MenuItem('Export', f=ms_wallet_ckcc_export, + arg=(ms, {"descriptor": True, "desc_pretty": False})), + MenuItem('Bitcoin Core', f=ms_wallet_ckcc_export, + arg=(ms, {"descriptor": True, "core": True})), ] return rv @@ -1660,6 +1665,19 @@ async def import_multisig_nfc(*a): except Exception as e: await ux_show_story(title="ERROR", msg="Failed to import multisig. %s" % str(e)) +async def import_multisig_qr(*a): + from auth import maybe_enroll_xpub + from ux_q1 import QRScannerInteraction + data = await QRScannerInteraction().scan_text('Scan Multisig from a QR code') + if not data: + # pressed CANCEL + return + + try: + maybe_enroll_xpub(config=data) + except Exception as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + async def import_multisig(*a): # pick text file from SD card, import as multisig setup file from actions import file_picker diff --git a/testing/test_multisig.py b/testing/test_multisig.py index 5470ff4b..fdfe9d47 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -22,6 +22,7 @@ from pycoin.key.BIP32Node import BIP32Node from pycoin.tx import Tx from io import BytesIO from hashlib import sha256 +from bbqr import split_qrs def HARD(n=0): @@ -161,11 +162,12 @@ def offer_ms_import(cap_story, dev): return doit @pytest.fixture -def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select, is_q1): +def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select, + is_q1, request, need_keypress): 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): + int_ext_desc=False, dev_key=False, way=None): keys = keys or make_multisig(M, N, unique=unique, dev_key=dev_key, deriv=common or (derivs[0] if derivs else None)) name = name or f'test-{M}-{N}' @@ -211,7 +213,73 @@ def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select, is_q1): #print(config) open('debug/last-ms.txt', 'wt').write(config) - title, story = offer_ms_import(config) + if way is None: # 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) + + ms_menu = cap_menu() + if way == "qr": + if "Import from QR" not in ms_menu and not is_q1: + pytest.skip("No QR support") + + scan_a_qr = request.getfixturevalue('scan_a_qr') + pick_menu_item("Import from 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 ms_menu: + pytest.skip("NFC disabled") + + nfc_write_text = request.getfixturevalue('nfc_write_text') + pick_menu_item("Import via 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') + + fname = name + ".txt" + with open(path_f(fname), "w") as f: + f.write(config) + + pick_menu_item("Import from File") + time.sleep(.1) + _, story = cap_story() + 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(.2) + title, story = cap_story() assert 'Create new multisig' in story \ or 'Update existing multisig wallet' in story \ @@ -614,7 +682,7 @@ def test_import_detail(clear_ms, import_ms_wallet, need_keypress, press_cancel() -@pytest.mark.parametrize("way", ["sd", "vdisk", "nfc"]) +@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, @@ -740,7 +808,7 @@ def test_import_ux(N, vdisk, goto_home, cap_story, pick_menu_item, try: os.unlink(fname) except: pass -@pytest.mark.parametrize("way", ["sd", "vdisk", "nfc"]) +@pytest.mark.parametrize("way", [None, "sd", "vdisk", "nfc", "qr"]) @pytest.mark.parametrize('addr_fmt', ['p2sh-p2wsh', 'p2sh', 'p2wsh' ]) @pytest.mark.parametrize('comm_prefix', ['m/1/2/3/4/5/6/7/8/9/10/11/12', None, "m/45h"]) def test_export_single_ux(goto_home, comm_prefix, cap_story, pick_menu_item, cap_menu, press_select, @@ -752,8 +820,9 @@ def test_export_single_ux(goto_home, comm_prefix, cap_story, pick_menu_item, cap clear_ms() name = 'ex-test-%d' % random.randint(10000,99999) - M,N = 3, 15 - keys = import_ms_wallet(M, N, name=name, addr_fmt=addr_fmt, accept=1, common=comm_prefix) + M,N = 3, 5 + keys = import_ms_wallet(M, N, name=name, addr_fmt=addr_fmt, accept=1, + common=comm_prefix, way=way) goto_home() pick_menu_item('Settings') @@ -764,7 +833,10 @@ def test_export_single_ux(goto_home, comm_prefix, cap_story, pick_menu_item, cap pick_menu_item(item) pick_menu_item('Coldcard Export') - contents = load_export(way, label="Coldcard multisig setup", is_json=False, sig_check=False) + contents = load_export(way or "sd", label="Coldcard multisig setup", is_json=False, sig_check=False) + if way == "qr": + # QR code still displayed on screen + press_select() got = set() for ln in io.StringIO(contents).readlines(): @@ -902,7 +974,8 @@ def test_import_dup_safe(N, clear_ms, make_multisig, offer_ms_import, menu = cap_menu() assert f'{M}/{N}: {name}' in menu - assert (len(menu) - num_wallets) in [5, 6] # depending if NFC enabled or not + # depending if NFC enabled or not, and if Q (has QR) + assert (len(menu) - num_wallets) in [5, 6, 7] title, story = offer_ms_import(make_named('xxx-orig')) assert 'Create new multisig wallet' in story