diff --git a/shared/address_explorer.py b/shared/address_explorer.py index 91347ba9..33b34510 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -9,7 +9,7 @@ from ux import ux_show_story, the_ux, ux_enter_bip32_index from ux import export_prompt_builder, import_export_prompt_decode from menu import MenuSystem, MenuItem from public_constants import AFC_BECH32, AFC_BECH32M, AF_P2WPKH, AF_P2TR, AF_CLASSIC -from miniscript import MiniScriptWallet +from wallet import MiniScriptWallet from uasyncio import sleep_ms from uhashlib import sha256 from glob import settings diff --git a/shared/auth.py b/shared/auth.py index 18805b86..2a807ece 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -814,7 +814,7 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None, # for specific cases, key teleport is an option offer_kt = False - if not is_complete and (psbt.active_multisig or psbt.active_miniscript) and version.has_qwerty: + if not is_complete and version.has_qwerty and psbt.active_miniscript: offer_kt = 'use Key Teleport to send PSBT to other co-signers' while True: @@ -1330,36 +1330,6 @@ def start_show_miniscript_address(msc, change, index): # provide the value back to attached desktop return UserAuthorizedAction.active_request.address -def start_show_p2sh_address(M, N, addr_format, xfp_paths, witdeem_script): - # Show P2SH address to user, also returns it. - # - first need to find appropriate multisig wallet associated - # - they must provide full redeem script, and we will re-verify it and check pubkeys inside it - - from multisig import MultisigWallet - - try: - assert addr_format in SUPPORTED_ADDR_FORMATS - assert addr_format & AFC_SCRIPT - except: - raise AssertionError('Unknown/unsupported addr format') - - # Search for matching multisig wallet that we must already know about - xs = list(xfp_paths) - xs.sort() - - ms = MultisigWallet.find_match(M, N, xs) - assert ms, 'Multisig wallet with those fingerprints not found' - assert ms.M == M - assert ms.N == N - - UserAuthorizedAction.check_busy(ShowAddressBase) - UserAuthorizedAction.active_request = ShowP2SHAddress(ms, addr_format, xfp_paths, witdeem_script) - - # kill any menu stack, and put our thing at the top - abort_and_goto(UserAuthorizedAction.active_request) - - # provide the value back to attached desktop - return UserAuthorizedAction.active_request.address def show_address(addr_format, subpath, restore_menu=False): try: @@ -1394,7 +1364,7 @@ class MiniscriptDeleteRequest(UserAuthorizedAction): self.wallet = msc async def interact(self): - from miniscript import miniscript_delete + from wallet import miniscript_delete await miniscript_delete(self.wallet) self.done() @@ -1454,7 +1424,7 @@ class NewMiniscriptEnrollRequest(UserAuthorizedAction): def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None): # Offer to import (enroll) a new multisig/miniscript wallet. Allow reject by user. from glob import dis - from miniscript import MiniScriptWallet + from wallet import MiniScriptWallet UserAuthorizedAction.cleanup() dis.fullscreen('Wait...') diff --git a/shared/ccc.py b/shared/ccc.py index 6da6d9ea..369d0fc5 100644 --- a/shared/ccc.py +++ b/shared/ccc.py @@ -183,7 +183,7 @@ class CCCFeature: if not cls.is_enabled: return False, False - ms = psbt.active_multisig + ms = psbt.active_miniscript if not ms: # single-sig CCC not supported return False, False @@ -192,7 +192,7 @@ class CCCFeature: # don't try to sign; maybe show warning? xfp = cls.get_xfp() - if xfp not in ms.xfp_paths: + if xfp not in [i[0] for i in ms.to_descriptor().xfp_paths()]: # does not involve us return False, False @@ -253,7 +253,7 @@ class CCCConfigMenu(MenuSystem): self.replace_items(tmp) def construct(self): - from multisig import MultisigWallet, make_ms_wallet_menu + from wallet import MiniScriptWallet, make_miniscript_wallet_menu my_xfp = CCCFeature.get_xfp() items = [ @@ -266,10 +266,13 @@ class CCCConfigMenu(MenuSystem): # look for wallets that are defined related to CCC feature, shortcut to them count = 0 - for ms in MultisigWallet.get_all(): - if my_xfp in ms.xfp_paths: - items.append(MenuItem('↳ %d/%d: %s' % (ms.M, ms.N, ms.name), - menu=make_ms_wallet_menu, arg=ms.storage_idx)) + for ms in MiniScriptWallet.get_all(): + if not ms.m_n: # basic multisig check + continue + if my_xfp in [i[0] for i in ms.xfp_paths()]: + M, N = ms.m_n + items.append(MenuItem('↳ %d/%d: %s' % (M, N, ms.name), + menu=make_miniscript_wallet_menu, arg=ms.storage_idx)) count += 1 items.append(MenuItem('↳ Build 2-of-N', f=self.build_2ofN, arg=count)) @@ -331,7 +334,7 @@ class CCCConfigMenu(MenuSystem): xfp = CCCFeature.get_xfp() enc = CCCFeature.get_encoded_secret() - from miniscript import export_miniscript_xpubs + from wallet import export_miniscript_xpubs await export_miniscript_xpubs(xfp=xfp, alt_secret=enc, skip_prompt=True) async def build_2ofN(self, m, l, i): diff --git a/shared/chains.py b/shared/chains.py index 9c0279b6..02541cc9 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -519,7 +519,8 @@ def addr_fmt_label(addr_fmt): AF_P2WPKH: "Segwit P2WPKH", AF_P2TR: "Taproot P2TR", AF_P2WSH: "Segwit P2WSH", - AF_P2WSH_P2SH: "P2SH-P2WSH" + AF_P2WSH_P2SH: "P2SH-P2WSH", + AF_P2SH: "Legacy P2SH", }[addr_fmt] def verify_recover_pubkey(sig, digest): diff --git a/shared/decoders.py b/shared/decoders.py index 66448f0d..a3a772f5 100644 --- a/shared/decoders.py +++ b/shared/decoders.py @@ -218,21 +218,6 @@ def decode_short_text(got): # was something else. pass - if ("\n" in got) and ('pub' in got): - # legacy multisig import/export format - # [0-9a-fA-F]{8}\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]{107} - # above is more precise BUT counted repetitions not supported in mpy - cc_ms_pat = r"[0-9a-fA-F]+\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]+" - rgx = ure.compile(cc_ms_pat) - # go line by line and match above, once 2 matches observed - considered multisig - # important to not use ure.search for big strings (can run out of stack) - c = 0 # match count - for l in got.split("\n"): - if rgx.search(l): - c += 1 - if c > 1: - return 'multi', (got,) - from descriptor import Descriptor if Descriptor.is_descriptor(got): return 'minisc', (got,) diff --git a/shared/desc_utils.py b/shared/desc_utils.py index d2ad5839..12cbac00 100644 --- a/shared/desc_utils.py +++ b/shared/desc_utils.py @@ -4,7 +4,7 @@ # import ngu, chains, ustruct, stash from io import BytesIO -from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_CLASSIC, AF_P2TR +from public_constants import MAX_PATH_DEPTH from binascii import unhexlify as a2b_hex from binascii import hexlify as b2a_hex from utils import keypath_to_str, str_to_keypath, swab32, xfp2str @@ -80,23 +80,6 @@ def parse_desc_str(string): return res -def multisig_descriptor_template(xpub, path, xfp, addr_fmt): - key_exp = "[%s%s]%s/0/*" % (xfp.lower(), path.replace("m", ''), xpub) - if addr_fmt == AF_P2WSH_P2SH: - descriptor_template = "sh(wsh(sortedmulti(M,%s,...)))" - elif addr_fmt == AF_P2WSH: - descriptor_template = "wsh(sortedmulti(M,%s,...))" - elif addr_fmt == AF_P2SH: - descriptor_template = "sh(sortedmulti(M,%s,...))" - elif addr_fmt == AF_P2TR: - # provably unspendable BIP-0341 - descriptor_template = "tr(" + b2a_hex(PROVABLY_UNSPENDABLE[1:]).decode() + ",sortedmulti_a(M,%s,...))" - else: - return None - descriptor_template = descriptor_template % key_exp - return descriptor_template - - def read_until(s, chars=b",)(#"): res = b"" while True: @@ -143,6 +126,7 @@ class KeyOriginInfo: arr[0] = "m" path = "/".join(arr) derivation = str_to_keypath(xfp, path)[1:] # ignoring xfp here, already stored + assert len(derivation) <= MAX_PATH_DEPTH, "origin too deep" return cls(xfp, derivation) def __str__(self): @@ -212,8 +196,8 @@ class KeyDerivationInfo: obj = cls() else: - if multi_i is not None: - assert len(idxs[multi_i]) == 2, "wrong multipath" + assert multi_i is not None, "need multipath" + assert len(idxs[multi_i]) == 2, "wrong multipath" obj = cls(tuple(idxs)) obj.multi_path_index = multi_i @@ -312,17 +296,27 @@ class Key: return node, chain_type - def validate(self, my_xfp): + def validate(self, my_xfp, disable_checks=False): assert self.chain_type == chains.current_key_chain().ctype, "wrong chain" - depth = self.node.depth() + # xfp is always available, even if key was serialized without origin info + # upon parse root origin info is generated from key itself xfp = self.origin.cc_fp - if depth == 1: - target = swab32(self.node.parent_fp()) - assert xfp == target, 'xfp depth=1 wrong' + if not disable_checks: + depth = self.node.depth() + # TODO we now allow blinded keys that have depth X bud derivation len is 0 + # print("depth", depth) + # print("origin der", self.origin.derivation) + # assert len(self.origin.derivation) == depth, "deriv len != xpub depth (xfp=%s)" % xfp2str(xfp) + if depth == 0: + assert swab32(self.node.my_fp()) == xfp, "master xfp mismatch" + elif depth == 1: + target = swab32(self.node.parent_fp()) + assert xfp == target, 'xfp depth=1 wrong' - if xfp == my_xfp: + is_mine = (xfp == my_xfp) + if is_mine and not disable_checks: # it's supposed to be my key, so I should be able to generate pubkey # - might indicate collision on xfp value between co-signers, # and that's not supported @@ -331,8 +325,8 @@ class Key: chk_node = sv.derive_path(deriv) assert self.node.pubkey() == chk_node.pubkey(), \ "[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:]) - return 1 - return 0 + + return is_mine def derive(self, idx=None, change=False): @@ -371,16 +365,21 @@ class Key: @classmethod def from_cc_data(cls, xfp, deriv, xpub): - koi = KeyOriginInfo.from_string("%s/%s" % (xfp2str(xfp), deriv.replace("m/", ""))) - node = ngu.hdnode.HDNode() - node.deserialize(xpub) - return cls(node, koi, KeyDerivationInfo()) + xfp_str = xfp if isinstance(xfp, str) else xfp2str(xfp) + koi = KeyOriginInfo.from_string("%s/%s" % (xfp_str, deriv.replace("m/", ""))) + node, chain_type = cls.parse_key(xpub.encode()) - def to_cc_data(self): - ch = chains.current_chain() - return (self.origin.cc_fp, - self.origin.str_derivation(), - ch.serialize_public(self.node, AF_CLASSIC)) + return cls(node, koi, KeyDerivationInfo(), chain_type=chain_type) + + @classmethod + def from_cc_json(cls, vals, af_str): + key_exp = af_str + "_key_exp" + if key_exp in vals: + # new firmware, prefer key expression + return cls.from_string(vals[key_exp]) + + ek = chains.slip32_deserialize(vals[af_str]) + return cls.from_cc_data(vals["xfp"], vals["%s_deriv" % af_str], ek) @property def is_provably_unspendable(self): diff --git a/shared/descriptor.py b/shared/descriptor.py index 36a22c36..89d361d2 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -186,7 +186,6 @@ class Descriptor: def xfp_paths(self, skip_unspend_ik=False): res = [] - for k in self.keys: if self.is_taproot and k.is_provably_unspendable and skip_unspend_ik: continue diff --git a/shared/export.py b/shared/export.py index 56e4486c..c5590ad7 100644 --- a/shared/export.py +++ b/shared/export.py @@ -390,7 +390,6 @@ def generate_unchained_export(account_num=0): def generate_generic_export(account_num=0): # Generate data that other programers will use to import Coldcard (single-signer) from descriptor import Descriptor, Key - from desc_utils import multisig_descriptor_template chain = chains.current_chain() master_xfp = settings.get("xfp") @@ -422,7 +421,9 @@ def generate_generic_export(account_num=0): xp = chain.serialize_public(node, AF_CLASSIC) zp = chain.serialize_public(node, fmt) if fmt not in (AF_CLASSIC, AF_P2TR) else None if is_ms: - desc = multisig_descriptor_template(xp, dd, master_xfp_str, fmt) + # TODO + # desc = multisig_descriptor_template(xp, dd, master_xfp_str, fmt) + pass else: key = Key.from_cc_data(master_xfp, dd, xp) desc_obj = Descriptor(key=key, addr_fmt=fmt) diff --git a/shared/flow.py b/shared/flow.py index 8c606b11..b2d85678 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -9,8 +9,7 @@ from glob import settings from actions import * from choosers import * from mk4 import dev_enable_repl -from multisig import make_multisig_menu, import_multisig_nfc -from miniscript import make_miniscript_menu +from wallet import make_miniscript_menu, import_miniscript_nfc from seed import make_ephemeral_seed_menu, make_seed_vault_menu, start_b39_pw from address_explorer import address_explore from drv_entro import drv_entro_start, password_entry @@ -143,8 +142,6 @@ SettingsMenu = [ # xxxxxxxxxxxxxxxx MenuItem('Login Settings', menu=LoginPrefsMenu), MenuItem('Hardware On/Off', menu=HWTogglesMenu), - NonDefaultMenuItem('Multisig Wallets', 'multisig', - menu=make_multisig_menu, predicate=has_secrets), NonDefaultMenuItem('Miniscript', 'miniscript', menu=make_miniscript_menu, predicate=has_secrets, shortcut="m"), NonDefaultMenuItem('NFC Push Tx', 'ptxurl', menu=pushtx_setup_menu), @@ -357,7 +354,7 @@ NFCToolsMenu = [ MenuItem('Verify Sig File', f=nfc_sign_verify), MenuItem('Verify Address', f=nfc_address_verify), MenuItem('File Share', f=nfc_share_file), - MenuItem('Import Multisig', f=import_multisig_nfc), + MenuItem('Import Miniscript', f=import_miniscript_nfc), MenuItem('Push Transaction', f=nfc_pushtx_file, predicate=lambda: settings.get("ptxurl", False)), ] diff --git a/shared/hsm.py b/shared/hsm.py index 53cc45ed..418fbd5d 100644 --- a/shared/hsm.py +++ b/shared/hsm.py @@ -11,7 +11,7 @@ from pincodes import AE_LONG_SECRET_LEN from stash import blank_object from users import Users, MAX_NUMBER_USERS, calc_local_pincode from public_constants import MAX_USERNAME_LEN -from miniscript import MiniScriptWallet +from wallet import MiniScriptWallet from ubinascii import hexlify as b2a_hex from uhashlib import sha256 from ucollections import OrderedDict diff --git a/shared/miniscript.py b/shared/miniscript.py index 8d41a386..37e62add 100644 --- a/shared/miniscript.py +++ b/shared/miniscript.py @@ -2,815 +2,12 @@ # # Copyright (c) 2020 Stepan Snigirev MIT License embit/miniscript.py # -import ngu, ujson, uio, chains, ure, version, stash +import ngu from binascii import unhexlify as a2b_hex from binascii import hexlify as b2a_hex -from serializations import ser_compact_size, ser_string -from desc_utils import Key, read_until, bip388_wallet_policy_to_descriptor, append_checksum, bip388_validate_policy -from public_constants import MAX_TR_SIGNERS, AF_P2TR -from wallet import BaseStorageWallet, MAX_BIP32_IDX -from menu import MenuSystem, MenuItem -from ux import ux_show_story, ux_confirm, ux_dramatic_pause -from files import CardSlot, CardMissingError, needs_microsd -from utils import problem_file_line, xfp2str, to_ascii_printable, swab32, show_single_address -from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER -from glob import settings - -# Arbitrary value, not 0 or 1, used to derive a pubkey from preshared xpub in Key Teleport -KT_RXPUBKEY_DERIV = const(20250317) - -# PSBT Xpub trust policies -TRUST_VERIFY = const(0) -TRUST_OFFER = const(1) -TRUST_PSBT = const(2) - - -class MiniScriptWallet(BaseStorageWallet): - key_name = "miniscript" - disable_checks = False - - def __init__(self, name, desc_tmplt=None, keys_info=None, desc=None, - af=None, ik_u=None): - - assert (desc_tmplt and keys_info) or desc - - super().__init__() - self.name = name - self.desc_tmplt = desc_tmplt - self.keys_info = keys_info - self.desc = desc - self.addr_fmt = af - self.ik_u = ik_u - - @classmethod - def get_trust_policy(cls): - - which = settings.get('pms', None) - if which is None: - which = TRUST_VERIFY if cls.exists() else TRUST_OFFER - - return which - - @property - def chain(self): - return chains.current_chain() - - def serialize(self): - return self.name, self.desc_tmplt, self.keys_info, self.addr_fmt, self.ik_u - - @classmethod - def deserialize(cls, c, idx=-1): - # after deserialization - we lack loaded descriptor object - # we do not need it for everything - name, desc_tmplt, keys_info, af, ik_u = c - rv = cls(name, desc_tmplt, keys_info, af=af, ik_u=ik_u) - rv.storage_idx = idx - return rv - - def to_descriptor(self, validate=False): - if self.desc is None: - # actual descriptor is not loaded, but was asked for - # fill policy - aka storage format - to actual descriptor - from descriptor import Descriptor - import glob - - if self.name in glob.DESC_CACHE: - # loaded descriptor from cache - print("to_descriptor CACHE") - self.desc = glob.DESC_CACHE[self.name] - else: - desc_str = bip388_wallet_policy_to_descriptor(self.desc_tmplt, self.keys_info) - print("loading... filled policy:\n", desc_str) - # no need to validate already saved descriptor - was validated upon enroll - self.desc = Descriptor.from_string(desc_str, validate=validate) - # cache len always 1 - glob.DESC_CACHE = {} - glob.DESC_CACHE[self.name] = self.desc - - return self.desc - - @classmethod - def find_match(cls, xfp_paths, addr_fmt=None): - for rv in cls.iter_wallets(): - if addr_fmt is not None: - if rv.addr_fmt != addr_fmt: - continue - - if rv.matching_subpaths(xfp_paths): - return rv - return None - - def matching_subpaths(self, xfp_paths): - my_xfp_paths = self.to_descriptor().xfp_paths() - - if len(xfp_paths) != len(my_xfp_paths): - return False - - for x in my_xfp_paths: - prefix_len = len(x) - for y in xfp_paths: - if x == y[:prefix_len]: - break - else: - return False - return True - - def subderivation_indexes(self, xfp_paths): - # we already know that they do match - my_xfp_paths = self.to_descriptor().xfp_paths() - res = set() - for x in my_xfp_paths: - prefix_len = len(x) - for y in xfp_paths: - if x == y[:prefix_len]: - to_derive = tuple(y[prefix_len:]) - res.add(to_derive) - - assert res - if len(res) == 1: - branch, idx = list(res)[0] - else: - branch = [i[0] for i in res] - indexes = set([i[1] for i in res]) - assert len(indexes) == 1 - idx = list(indexes)[0] - - return branch, idx - - def get_my_deriv(self, my_xfp): - # lowest public key from lexicographically sorted list is at index 0 - mine = self.xpubs_from_xfp(my_xfp) - return mine[0].origin.str_derivation() - - def derive_desc(self, xfp_paths): - branch, idx = self.subderivation_indexes(xfp_paths) - derived_desc = self.desc.derive(branch).derive(idx) - return derived_desc - - def validate_script_pubkey(self, script_pubkey, xfp_paths, merkle_root=None): - derived_desc = self.derive_desc(xfp_paths) - derived_spk = derived_desc.script_pubkey() - assert derived_spk == script_pubkey, "spk mismatch" - if merkle_root: - assert derived_desc.tapscript.merkle_root == merkle_root, "psbt merkle root" - return derived_desc - - async def _detail(self, new_wallet=False, is_duplicate=False): - - s = chains.addr_fmt_label(self.addr_fmt) + "\n\n" - s += self.desc_tmplt - - story = s + "\n\nPress (1) to see extended public keys" - if new_wallet and not is_duplicate: - story += ", OK to approve, X to cancel." - return story - - async def show_detail(self, new_wallet=False, duplicates=None): - title = self.name - story = "" - if duplicates: - title = None - story += "This wallet is a duplicate of already saved wallet %s\n\n" % duplicates[0].name - elif new_wallet: - title = None - story += "Create new miniscript wallet?\n\nWallet Name:\n %s\n\n" % self.name - - story += (chains.addr_fmt_label(self.addr_fmt) + "\n\n" + self.desc_tmplt) - story += "\n\nPress (1) to see extended public keys" - - if new_wallet and not duplicates: - story += ", OK to approve, X to cancel." - - while True: - ch = await ux_show_story(story, title=title, escape="1") - if ch == "1": - await self.show_keys() - - elif ch != "y": - return None - else: - return True - - async def show_keys(self): - msg = "" - for idx, k_str in enumerate(self.keys_info): - if idx: - msg += '\n---===---\n\n' - elif self.addr_fmt == AF_P2TR: - # index 0, taproot internal key - msg += "Taproot internal key:\n\n" - if self.ik_u: - msg += "(provably unspendable)\n\n" - - msg += '@%s:\n %s\n\n' % (idx, k_str) - - await ux_show_story(msg) - - @classmethod - def from_bip388_wallet_policy(cls, name, desc_template, keys_info): - bip388_validate_policy(desc_template, keys_info) - msc = cls(name, desc_template, keys_info) - msc.to_descriptor(validate=True) - return msc - - @classmethod - def from_file(cls, config, name=None, bip388=False): - from descriptor import Descriptor - - if bip388: - # config is JSON wallet policy - wal = cls.from_bip388_wallet_policy(config["name"], config["desc_template"], - config["keys_info"]) - else: - if name is None: - desc_obj, cs = Descriptor.from_string(config.strip(), checksum=True) - name = cs - else: - name = to_ascii_printable(name) - desc_obj = Descriptor.from_string(config.strip()) - - wal = cls(name, desc=desc_obj) - - # BIP388 wasn't generated yet - generating from descriptor upon import/enroll - wal.desc_tmplt, wal.keys_info = desc_obj.bip388_wallet_policy() - - bip388_validate_policy(wal.desc_tmplt, wal.keys_info) - - wal.ik_u = wal.desc.key and wal.desc.key.is_provably_unspendable - wal.addr_fmt = wal.desc.addr_fmt - return wal - - def find_duplicates(self): - matches = [] - name_unique = True - for rv in self.iter_wallets(): - if self.name == rv.name: - name_unique = False - if self.desc_tmplt != rv.desc_tmplt: - continue - if self.keys_info != rv.keys_info: - continue - - matches.append(rv) - - return matches, name_unique - - async def confirm_import(self): - nope, yes = (KEY_CANCEL, KEY_ENTER) if version.has_qwerty else ("x", "y") - dups, name_unique = self.find_duplicates() - if not name_unique: - await ux_show_story(title="FAILED", msg=("Miniscript wallet with name '%s'" - " already exists. All wallets MUST" - " have unique names.") % self.name) - return nope - to_save = await self.show_detail(new_wallet=True, duplicates=dups) - - ch = yes if to_save else nope - if to_save and not dups: - assert self.storage_idx == -1 - self.commit() - import glob - # new wallet was imported - cache descriptor - glob.DESC_CACHE = {} - assert self.desc - glob.DESC_CACHE[self.name] = self.desc - await ux_dramatic_pause("Saved.", 2) - - return ch - - def yield_addresses(self, start_idx, count, change=False, scripts=False, change_idx=0): - ch = chains.current_chain() - dd = self.to_descriptor().derive(None, change=change) - idx = start_idx - while count: - if idx > MAX_BIP32_IDX: - break - # make the redeem script, convert into address - d = dd.derive(idx) - scr = d.miniscript.compile() if d.miniscript else None - addr = ch.render_address(d.script_pubkey(compiled_scr=scr)) - ders = script = None - if scripts: - ders = ["[%s]" % str(k.origin) for k in d.keys] - if d.tapscript: - script = d.tapscript.script_tree() - else: - script = b2a_hex(ser_string(scr)).decode() - - yield idx, addr, ders, script - - idx += 1 - count -= 1 - - def make_addresses_msg(self, msg, start, n, change=0): - from glob import dis - - addrs = [] - - for idx, addr, *_ in self.yield_addresses(start, n, change=bool(change), scripts=False): - msg += '.../%d =>\n' % idx # just idx, if derivations or scripts needed - export csv - addrs.append(addr) - msg += show_single_address(addr) + '\n\n' - dis.progress_sofar(idx - start + 1, n) - - return msg, addrs - - def generate_address_csv(self, start, n, change): - yield '"' + '","'.join( - ['Index', 'Payment Address'] - ) + '"\n' - for idx, addr, ders, script in self.yield_addresses(start, n, change=bool(change)): - ln = '%d,"%s"' % (idx, addr) - if ders: - ln += ',"%s","' % script - ln += '","'.join(ders) - ln += '"' - ln += '\n' - yield ln - - def to_string(self, checksum=True): - # policy filling - not posible to specify internal/external always multipath export - # only supported from bitcoin-core 29.0 - if self.desc_tmplt and self.keys_info: - desc = bip388_wallet_policy_to_descriptor(self.desc_tmplt, self.keys_info) - if checksum: - desc = append_checksum(desc) - return desc - - return self.desc.to_string() - - def bitcoin_core_serialize(self): - return [{ - "desc": self.to_string(), # policy fill - "active": True, - "timestamp": "now", - "range": [0, 100], - }] - - async def export_wallet_file(self, extra_msg=None, core=False, bip388=False): - # do not load descriptor - just fill policy - # only with multipath format <0;1> - from glob import NFC, dis - from ux import import_export_prompt - - dis.fullscreen('Wait...') - - if core: - name = "Bitcoin Core miniscript" - fname_pattern = 'bitcoin-core-%s.txt' % self.name - msg = "importdescriptors cmd" - core_obj = self.bitcoin_core_serialize() - core_str = ujson.dumps(core_obj) - res = "importdescriptors '%s'\n" % core_str - elif bip388: - # policy as JSON - name = "BIP-388 Wallet Policy" - fname_pattern = 'b388-%s.json' % self.name - res = ujson.dumps({"name": self.name, - "desc_template": self.desc_tmplt.replace("/<0;1>/*", "/**"), - "keys_info": self.keys_info}) - else: - name = "Miniscript" - fname_pattern = 'minsc-%s.txt' % self.name - msg = self.name - res = self.to_string() - - ch = await import_export_prompt("%s file" % name) - if isinstance(ch, str): - if ch in "3"+KEY_NFC: - await NFC.share_text(res) - elif ch == KEY_QR: - try: - from ux import show_qr_code - await show_qr_code(res, msg=msg) - except: - if version.has_qwerty: - from ux_q1 import show_bbqr_codes - await show_bbqr_codes('U', res, msg) - return - - try: - with CardSlot(**ch) as card: - fname, nice = card.pick_filename(fname_pattern) - - # do actual write - with open(fname, 'w+') as fp: - fp.write(res) - # fp.seek(0) - # contents = fp.read() - # TODO re-enable once we know how to proceed with regards to with which key to sign - # TODO need function to get my xpub from just policy - # from auth import write_sig_file - # h = ngu.hash.sha256s(contents.encode()) - # sig_nice = write_sig_file([(h, fname)]) - - msg = '%s file written:\n\n%s' % (name, nice) - # msg += '\n\nColdcard multisig signature file written:\n\n%s' % sig_nice - if extra_msg: - msg += extra_msg - - await ux_show_story(msg) - - except CardMissingError: - await needs_microsd() - return - except Exception as e: - await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e))) - return - - def xpubs_from_xfp(self, xfp): - # return list of XPUB's which match xfp - res = [] - desc = self.to_descriptor() - for k in desc.keys: - if k.origin and k.origin.cc_fp == xfp: - res.append(k) - elif swab32(k.node.my_fp()) == xfp: - res.append(k) - - assert res, "missing xfp %s" % xfp2str(xfp) - # returned is list of keys with corresponding master xfp - # key in list are lexicographically sorted based on their public keys - # lowest public key first - return sorted(res, key=lambda o: o.serialize()) - - def kt_make_rxkey(self, xfp): - # Derive the receiver's pubkey from preshared xpub and a special derivation - # - also provide the keypair we're using from our side of connection - # - returns 4 byte nonce which is sent un-encrypted, his_pubkey and my_keypair - ri = ngu.random.uniform(1<<28) - - # sorted lexicographically, always use the lowest pubkey from the list at index 0 - keys = self.xpubs_from_xfp(xfp) - k = keys[0] - k = k.derive(KT_RXPUBKEY_DERIV).derive(ri) - pubkey = k.node.pubkey() - - kp = self.kt_my_keypair(ri) - return ri.to_bytes(4, 'big'), pubkey, kp - - def kt_my_keypair(self, ri): - # Calc my keypair for sending PSBT files. - # - # sorted lexicographically, always use the lowest pubkey from the list at index 0 - keys = self.xpubs_from_xfp(settings.get('xfp')) - - subpath = "/%d/%d" % (KT_RXPUBKEY_DERIV, ri) - path = keys[0].origin.str_derivation() + subpath - with stash.SensitiveValues() as sv: - node = sv.derive_path(path) - kp = ngu.secp256k1.keypair(node.privkey()) - return kp - - @classmethod - def kt_search_rxkey(cls, payload): - # Construct the keypair for to be decryption - # - has to try pubkey each all the unique XFP for all co-signers in all wallets - # - checks checksum of ECDH unwrapped data to see if it's the right one - # - returns session key, decrypted first layer, and XFP of sender - from teleport import decode_step1 - - # this nonce is part of the derivation path so each txn gets new keys - ri = int.from_bytes(payload[0:4], 'big') - - my_xfp = settings.get('xfp') - - for msc in cls.iter_wallets(): - kp = msc.kt_my_keypair(ri) - for k in msc.to_descriptor().keys: - if k.origin.cc_fp == my_xfp: - continue - kk = k.derive(KT_RXPUBKEY_DERIV).derive(ri) - his_pubkey = kk.node.pubkey() - # if implied session key decodes the checksum, it is right - ses_key, body = decode_step1(kp, his_pubkey, payload[4:]) - if ses_key: - return ses_key, body, kk.origin.cc_fp - - return None, None, None - -async def no_miniscript_yet(*a): - await ux_show_story("You don't have any miniscript wallets yet.") - -async def miniscript_delete(msc): - if not await ux_confirm("Delete miniscript wallet '%s'?\n\nFunds may be impacted." % msc.name): - await ux_dramatic_pause('Aborted.', 3) - return - - msc.delete() - await ux_dramatic_pause('Deleted.', 3) - -async def miniscript_wallet_delete(menu, label, item): - msc = item.arg - - await miniscript_delete(msc) - - from ux import the_ux - # pop stack - the_ux.pop() - - m = the_ux.top_of_stack() - m.update_contents() - -async def miniscript_wallet_detail(menu, label, item): - # show details of single multisig wallet - - msc = item.arg - - return await msc.show_detail() - -async def import_miniscript(*a): - # pick text file from SD card, import as multisig setup file - from actions import file_picker - from ux import import_export_prompt - - ch = await import_export_prompt("miniscript wallet file", is_import=True) - if isinstance(ch, str): - if ch == KEY_QR: - await import_miniscript_qr() - elif ch == KEY_NFC: - await import_miniscript_nfc() - return - - def possible(filename): - with open(filename, 'rt') as fd: - for ln in fd: - if "sh(" in ln or "wsh(" in ln or "tr(" in ln: - # descriptor import - return True - - fn = await file_picker(suffix=['.txt', '.json'], min_size=100, - taster=possible, **ch) - if not fn: return - - try: - with CardSlot(**ch) as card: - with open(fn, 'rt') as fp: - data = fp.read() - except CardMissingError: - await needs_microsd() - return - - from auth import maybe_enroll_xpub - try: - possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None - maybe_enroll_xpub(config=data, name=possible_name) - except BaseException as e: - await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e))) - -async def import_miniscript_nfc(*a): - from glob import NFC - try: - return await NFC.import_miniscript_nfc() - except Exception as e: - await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e))) - -async def import_miniscript_qr(*a): - from auth import maybe_enroll_xpub - from ux_q1 import QRScannerInteraction - data = await QRScannerInteraction().scan_text('Scan Miniscript from a QR code') - if not data: - # press 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 miniscript_wallet_export(menu, label, item): - # create a text file with the details; ready for import to next Coldcard - msc = item.arg[0] - kwargs = item.arg[1] - await msc.export_wallet_file(**kwargs) - -async def make_miniscript_wallet_descriptor_menu(menu, label, item): - # descriptor menu - msc = item.arg - if not msc: - return - - rv = [ - MenuItem('Export', f=miniscript_wallet_export, arg=(msc, {"core": False})), - MenuItem('Bitcoin Core', f=miniscript_wallet_export, arg=(msc, {"core": True})), - MenuItem('BIP-388 Policy', f=miniscript_wallet_export, arg=(msc, {"bip388":True})), - ] - return rv - -async def make_miniscript_wallet_menu(menu, label, item): - # details, actions on single multisig wallet - msc = MiniScriptWallet.get_by_idx(item.arg) - if not msc: return - - rv = [ - MenuItem('"%s"' % msc.name, f=miniscript_wallet_detail, arg=msc), - MenuItem('View Details', f=miniscript_wallet_detail, arg=msc), - MenuItem('Delete', f=miniscript_wallet_delete, arg=msc), - MenuItem('Descriptors', menu=make_miniscript_wallet_descriptor_menu, arg=msc), - ] - return rv - - -class MiniscriptMenu(MenuSystem): - @classmethod - def construct(cls): - import version - from menu import ShortcutItem - - if not MiniScriptWallet.exists(): - rv = [MenuItem(MiniScriptWallet.none_setup_yet(), f=no_miniscript_yet)] - else: - rv = [] - for msc in MiniScriptWallet.get_all(): - rv.append(MenuItem('%s' % msc.name, - menu=make_miniscript_wallet_menu, - arg=msc.storage_idx)) - from glob import NFC - rv.append(MenuItem('Import', f=import_miniscript)) - rv.append(MenuItem('Export XPUB', f=export_miniscript_xpubs)) - rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu)) - rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) - rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) - rv.append(MenuItem('Skip Checks?', f=disable_checks_menu)) - rv.append(ShortcutItem(KEY_NFC, predicate=lambda: NFC is not None, - f=import_miniscript_nfc)) - rv.append(ShortcutItem(KEY_QR, predicate=lambda: version.has_qwerty, - f=import_miniscript_qr)) - return rv - - def update_contents(self): - # Reconstruct the list of wallets on this dynamic menu, because - # we added or changed them and are showing that same menu again. - tmp = self.construct() - self.replace_items(tmp) - -async def make_miniscript_menu(*a): - # list of all multisig wallets, and high-level settings/actions - from pincodes import pa - - if pa.is_secret_blank(): - await ux_show_story("You must have wallet seed before creating miniscript wallets.") - return - - rv = MiniscriptMenu.construct() - return MiniscriptMenu(rv) - - -def disable_checks_chooser(): - ch = ['Normal', 'Skip Checks'] - - def xset(idx, text): - MiniScriptWallet.disable_checks = bool(idx) - - return int(MiniScriptWallet.disable_checks), ch, xset - -async def disable_checks_menu(*a): - - if not MiniScriptWallet.disable_checks: - ch = await ux_show_story('''\ -With many different wallet vendors and implementors involved, it can \ -be hard to create a PSBT consistent with the many keys involved. \ -With this setting, you can \ -disable the more stringent verification checks your Coldcard normally provides. - -USE AT YOUR OWN RISK. These checks exist for good reason! Signed txn may \ -not be accepted by network. - -This settings lasts only until power down. - -Press (4) to confirm entering this DANGEROUS mode. -''', escape='4') - - if ch != '4': return - - start_chooser(disable_checks_chooser) - - -def psbt_xpubs_policy_chooser(): - # Chooser for trust policy - ch = ['Verify Only', 'Offer Import', 'Trust PSBT'] - - def xset(idx, text): - settings.set('pms', idx) - - return MiniScriptWallet.get_trust_policy(), ch, xset - -async def trust_psbt_menu(*a): - # show a story then go into chooser - - ch = await ux_show_story('''\ -This setting controls what the Coldcard does \ -with the co-signer public keys (XPUB) that may \ -be provided inside a PSBT file. Three choices: - -- Verify Only. Do not import the xpubs found, but do \ -verify the correct wallet already exists on the Coldcard. - -- Offer Import. If it's a new multisig wallet, offer to import \ -the details and store them as a new wallet in the Coldcard. - -- Trust PSBT. Use the wallet data in the PSBT as a temporary, -multisig wallet, and do not import it. This permits some \ -deniability and additional privacy. - -When the XPUB data is not provided in the PSBT, regardless of the above, \ -we require the appropriate multisig wallet to already exist \ -on the Coldcard. Default is to 'Offer' unless a multisig wallet already \ -exists, otherwise 'Verify'.''') - - if ch == 'x': return - start_chooser(psbt_xpubs_policy_chooser) - - -async def ms_wallet_electrum_export(menu, label, item): - # create a JSON file that Electrum can use. Challenges: - # - file contains derivation paths for each co-signer to use - # - electrum is using BIP-43 with purpose=48 (purpose48_derivation) to make paths like: - # m/48h/1h/0h/2h - # - above is now called BIP-48 - # - other signers might not be coldcards (we don't know) - # solution: - # - when building air-gap, pick address type at that point, and matching path to suit - # - could check path prefix and addr_fmt make sense together, but meh. - ms = item.arg - from actions import electrum_export_story - - derivs, dsum = ms.get_deriv_paths() - - msg = 'The new wallet will have derivation path:\n %s\n and use %s addresses.\n' % ( - dsum, MultisigWallet.render_addr_fmt(ms.addr_fmt) ) - - if await ux_show_story(electrum_export_story(msg)) != 'y': - return - - await ms.export_electrum() - - -async def export_miniscript_xpubs(*a, xfp=None, alt_secret=None, skip_prompt=False): - # WAS: Create a single text file with lots of docs, and all possible useful xpub values. - # THEN: Just create the one-liner xpub export value they need/want to support BIP-45 - # NOW: Export JSON with one xpub per useful address type and semi-standard derivation path - # - # - consumer for this file is supposed to be ourselves, when we build on-device multisig. - # - however some 3rd parties are making use of it as well. - # - used for CCC feature now as well, but result looks just like normal export - # - xfp = xfp2str(xfp or settings.get('xfp', 0)) - chain = chains.current_chain() - - fname_pattern = 'ccxp-%s.json' % xfp - label = "Multisig XPUB" - - if not skip_prompt: - msg = '''\ -This feature creates a small file containing \ -the extended public keys (XPUB) you would need to join \ -a multisig wallet. - -Public keys for BIP-48 conformant paths are used: - -P2SH-P2WSH: - m/48h/{coin}h/{{acct}}h/1h -P2WSH: - m/48h/{coin}h/{{acct}}h/2h -P2TR: - m/48h/{coin}h/{{acct}}h/3h - -{ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X) - - ch = await ux_show_story(msg) - if ch != "y": - return - - acct = await ux_enter_bip32_index('Account Number:') or 0 - - def render(acct_num): - sign_der = None - with uio.StringIO() as fp: - fp.write('{\n') - with stash.SensitiveValues(secret=alt_secret) as sv: - for name, deriv, fmt in chains.MS_STD_DERIVATIONS: - if fmt == AF_P2SH and acct_num: - continue - dd = deriv.format(coin=chain.b44_cointype, acct_num=acct_num) - if fmt == AF_P2WSH: - sign_der = dd + "/0/0" - node = sv.derive_path(dd) - xp = chain.serialize_public(node, fmt) - fp.write(' "%s_deriv": "%s",\n' % (name, dd)) - fp.write(' "%s": "%s",\n' % (name, xp)) - xpub = chain.serialize_public(node) - descriptor_template = multisig_descriptor_template(xpub, dd, xfp, fmt) - if descriptor_template is None: - continue - fp.write(' "%s_desc": "%s",\n' % (name, descriptor_template)) - - fp.write(' "account": "%d",\n' % acct_num) - fp.write(' "xfp": "%s"\n}\n' % xfp) - return fp.getvalue(), sign_der, AF_CLASSIC - - from export import export_contents - await export_contents(label, lambda: render(acct), fname_pattern, - force_bbqr=True, is_json=True) +from serializations import ser_compact_size +from desc_utils import Key, read_until +from public_constants import MAX_TR_SIGNERS class Number: diff --git a/shared/multisig.py b/shared/multisig.py index f7f87578..b1b665ea 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -1,19 +1,18 @@ # (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -# multisig.py - support code for multisig signing and p2sh in general. +# multisig.py - ms coordinator code mostly + some utils # import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version +from public_constants import AF_P2WSH, AF_P2WSH_P2SH from ubinascii import hexlify as b2a_hex -from utils import xfp2str, keypath_to_str -from utils import check_xpub -from ux import ux_show_story, ux_dramatic_pause, ux_clear_keys -from ux import OK, X -from public_constants import AF_P2SH, MAX_SIGNERS, AF_CLASSIC +from utils import xfp2str, extract_cosigner, problem_file_line, get_filesize +from files import CardSlot, CardMissingError, needs_microsd +from ux import ux_show_story, ux_dramatic_pause, ux_enter_number, ux_enter_bip32_index +from public_constants import MAX_SIGNERS from opcodes import OP_CHECKMULTISIG -from exceptions import FatalPSBTIssue from glob import settings -from serializations import disassemble -from wallet import BaseStorageWallet +from charcodes import KEY_QR +from desc_utils import Key, KeyOriginInfo def disassemble_multisig_mn(redeem_script): @@ -27,51 +26,6 @@ def disassemble_multisig_mn(redeem_script): return M, N -def disassemble_multisig(redeem_script): - # Take apart a standard multisig's redeem/witness script, and return M/N and public keys - # - only for multisig scripts, not general purpose - # - expect OP_1 (pk1) (pk2) (pk3) OP_3 OP_CHECKMULTISIG for 1 of 3 case - # - returns M, N, (list of pubkeys) - # - for very unlikely/impossible asserts, don't document reason; otherwise do. - M, N = disassemble_multisig_mn(redeem_script) - assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range' - assert len(redeem_script) == 1 + (N * 34) + 1 + 1, 'bad len' - - # generator function - dis = disassemble(redeem_script) - - # expect M value first - ex_M, opcode = next(dis) - assert ex_M == M and opcode is None, 'bad M' - - # need N pubkeys - pubkeys = [] - for idx in range(N): - data, opcode = next(dis) - assert opcode is None and len(data) == 33, 'data' - assert data[0] == 0x02 or data[0] == 0x03, 'Y val' - pubkeys.append(data) - - assert len(pubkeys) == N - - # next is N value - ex_N, opcode = next(dis) - assert ex_N == N and opcode is None - - # finally, the opcode: CHECKMULTISIG - data, opcode = next(dis) - assert opcode == OP_CHECKMULTISIG - - # must have reached end of script at this point - try: - next(dis) - raise AssertionError("too long") - except StopIteration: - # expected, since we're reading past end - pass - - return M, N, pubkeys - def make_redeem_script(M, nodes, subkey_idx, bip67=True): # take a list of BIP-32 nodes, and derive Nth subkey (subkey_idx) and make # a standard M-of-N redeem script for that. Applies BIP-67 sorting by default. @@ -95,285 +49,8 @@ def make_redeem_script(M, nodes, subkey_idx, bip67=True): return b''.join(pubkeys) -class MultisigWallet(BaseStorageWallet): - def assert_matching(self, M, N, xfp_paths): - # compare in-memory wallet with details recovered from PSBT - # - xfp_paths must be sorted already - assert (self.M, self.N) == (M, N), "M/N mismatch" - assert len(xfp_paths) == N, "XFP count" - if self.disable_checks: return - assert self.matching_subpaths(xfp_paths), "wrong XFP/derivs" - - - def has_similar(self): - # check if we already have a saved duplicate to this proposed wallet - # - return (name_change, diff_items, count_similar) where: - # - name_change is existing wallet that has exact match, different name - # - diff_items: text list of similarity/differences - # - count_similar: same N, same xfp+paths - - lst = self.get_xfp_paths() - c = self.find_match(self.M, self.N, lst, addr_fmt=self.addr_fmt) - if c: - # All details are same: M/N, paths, addr fmt - if sorted(self.xpubs) != sorted(c.xpubs): - # this also applies to non-BIP-67 type multisig wallets - # multi(2,A,B) is treated as duplicate of multi(2,B,A) - # consensus-wise they are different script/wallet but CC - # don't allow to import one if other already imported - return None, ['xpubs'], 0 - elif self.bip67 != c.bip67: - # treat same keys inside different desc multi/sortedmulti as duplicates - # sortedmulti(2,A,B) is considered same as multi(2,A,B) or multi(2,B,A) - # do not allow to import multi if sortedmulti with the same set of keys - # already imported and vice-versa - return None, ["BIP-67 clash"], 1 - elif self.name == c.name: - return None, [], 1 - else: - return c, ['name'], 0 - - similar = MultisigWallet.find_candidates(lst) - if not similar: - # no matches, good. - return None, [], 0 - - # See if the xpubs are changing, which is risky... other differences like - # name are okay. - diffs = set() - for c in similar: - if c.M != self.M: - diffs.add('M differs') - if c.addr_fmt != self.addr_fmt: - diffs.add('address type') - if c.name != self.name: - diffs.add('name') - if c.xpubs != self.xpubs: - diffs.add('xpubs') - - return None, diffs, len(similar) - - async def export_electrum(self): - # Generate and save an Electrum JSON file. - from export import export_contents - - def doit(): - rv = dict(seed_version=17, use_encryption=False, - wallet_type='%dof%d' % (self.M, self.N)) - - ch = self.chain - - # the important stuff. - for idx, (xfp, deriv, xpub) in enumerate(self.xpubs): - - node = None - if self.addr_fmt != AF_P2SH: - # CHALLENGE: we must do slip-132 format [yz]pubs here when not p2sh mode. - node = ch.deserialize_node(xpub, AF_P2SH); assert node - xp = ch.serialize_public(node, self.addr_fmt) - else: - xp = xpub - - rv['x%d/' % (idx+1)] = dict( - hw_type='coldcard', type='hardware', - ckcc_xfp=xfp, - label='Coldcard %s' % xfp2str(xfp), - derivation=deriv, xpub=xp) - - # sign export with first p2pkh key - return ujson.dumps(rv), self.get_my_deriv(settings.get('xfp'))+"/0/0", AF_CLASSIC - - await export_contents('Electrum multisig wallet', doit, - self.make_fname('el', 'json'), is_json=True) - - - @classmethod - def import_from_psbt(cls, M, N, xpubs_list): - # given the raw data from PSBT global header, offer the user - # the details, and/or bypass that all and just trust the data. - # - xpubs_list is a list of (xfp+path, binary BIP-32 xpub) - # - already know not in our records. - trust_mode = cls.get_trust_policy() - - if trust_mode == TRUST_VERIFY: - # already checked for existing import and wasn't found, so fail - raise FatalPSBTIssue("XPUBs in PSBT do not match any existing wallet") - - # build up an in-memory version of the wallet. - # - capture address format based on path used for my leg (if standards compliant) - - assert N == len(xpubs_list) - assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range' - my_xfp = settings.get('xfp') - - expect_chain = chains.current_chain().ctype - xpubs = [] - has_mine = 0 - - for k, v in xpubs_list: - xfp, *path = ustruct.unpack_from('<%dI' % (len(k)//4), k, 0) - xpub = ngu.codecs.b58_encode(v) - is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0), - expect_chain, my_xfp, cls.disable_checks) - xpubs.append(item) - if is_mine: - has_mine += 1 - addr_fmt = cls.guess_addr_fmt(path) - - assert has_mine == 1 # 'my key not included' - - name = 'PSBT-%d-of-%d' % (M, N) - # this will always create sortedmulti multisig (BIP-67) - # because BIP-174 came years after wide spread acceptance of BIP-67 policy - ms = cls(name, (M, N), xpubs, addr_fmt=addr_fmt or AF_P2SH) # TODO why legacy - - # may just keep in-memory version, no approval required, if we are - # trusting PSBT's today, otherwise caller will need to handle UX w.r.t new wallet - return ms, (trust_mode != TRUST_PSBT) - - def validate_psbt_xpubs(self, xpubs_list): - # The xpubs provided in PSBT must be exactly right, compared to our record. - # But we're going to use our own values from setup time anyway. - # Check: - # - chain codes match what we have stored already - # - pubkey vs. path will be checked later - # - xfp+path already checked when selecting this wallet - # - some cases we cannot check, so count those for a warning - # Any issue here is a fraud attempt in some way, not innocent. - # But it would not have tricked us and so the attack targets some other signer. - assert len(xpubs_list) == self.N - - for k, v in xpubs_list: - xfp, *path = ustruct.unpack_from('<%dI' % (len(k)//4), k, 0) - xpub = ngu.codecs.b58_encode(v) - - # cleanup and normalize xpub - tmp = [] - is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0), - chains.current_chain().ctype, 0, self.disable_checks) - tmp.append(item) - (_, deriv, xpub_reserialized) = tmp[0] - assert deriv # because given as arg - - if self.disable_checks: - # allow wrong derivation paths in PSBT; but also allows usage when - # old pre-3.2.1 MS wallet lacks derivation details for all legs - continue - - # find in our records. - for (x_xfp, x_deriv, x_xpub) in self.xpubs: - if x_xfp != xfp: continue - # found matching XFP - assert deriv == x_deriv - - assert xpub_reserialized == x_xpub, 'xpub wrong (xfp=%s)' % xfp2str(xfp) - break - else: - assert False # not reachable, since we picked wallet based on xfps - - async def confirm_import(self): - # prompt them about a new wallet, let them see details and then commit change. - M, N = self.M, self.N - - if M == N == 1: - exp = 'The one signer must approve spends.' - elif M == N: - exp = 'All %d co-signers must approve spends.' % N - elif M == 1: - exp = 'Any signature from %d co-signers will approve spends.' % N - else: - exp = '{M} signatures, from {N} possible co-signers, will be required to approve spends.'.format(M=M, N=N) - - # Look for duplicate stuff - name_change, diff_items, num_dups = self.has_similar() - - is_dup = False - if name_change: - story = 'Update NAME only of existing multisig wallet?' - elif num_dups and isinstance(diff_items, list): - # failures only - story = "Duplicate wallet." - if diff_items: - story += diff_items[0] - else: - story += ' All details are the same as existing!' - is_dup = True - elif diff_items: - # Concern here is overwrite when similar, but we don't overwrite anymore, so - # more of a warning about funny business. - story = '''\ -WARNING: This new wallet is similar to an existing wallet, but will NOT replace it. Consider deleting previous wallet first. Differences: \ -''' + ', '.join(diff_items) - else: - story = 'Create new multisig wallet?' - - derivs, dsum = self.get_deriv_paths() - - if not self.bip67 and not is_dup: - # do not need to warn if duplicate, won;t be allowed to import anyways - story += "\nWARNING: BIP-67 disabled! Unsorted multisig - order of keys in descriptor/backup is crucial" - - story += '''\n -Wallet Name: - {name} - -Policy: {M} of {N} - -{exp} - -Addresses: - {at} - -Derivation: - {dsum} - -Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp=exp, dsum=dsum, - at=self.render_addr_fmt(self.addr_fmt)) - if not is_dup: - story += ('%s to approve, %s to cancel.' % (OK, X)) - else: - story += '%s to cancel' % X - - ux_clear_keys(True) - while 1: - ch = await ux_show_story(story, escape='1') - - if ch == '1': - await self.show_detail(verbose=False) - continue - - if ch == 'y' and not is_dup: - # save to nvram, may raise WalletOutOfSpace - if name_change: - name_change.delete() - - assert self.storage_idx == -1 - self.commit() - await ux_dramatic_pause("Saved.", 2) - break - - return ch - - - -async def validate_xpub_for_ms(obj, af_str, chain, my_xfp, xpubs): - # Read xpub and validate from JSON received via SD card or BBQr - # - obj => JSON object (mapping) - # - af_str => address format we expect/need - - # value in file is BE32, but we want LE32 internally - # - KeyError here handled by caller - xfp = str2xfp(obj['xfp']) - deriv = cleanup_deriv_path(obj[af_str + '_deriv']) - ln = obj.get(af_str) - - is_mine, item = check_xpub(xfp, ln, deriv, chain.ctype, my_xfp, xpubs) - xpubs.append(item) - return is_mine - - -async def ms_coordinator_qr(af_str, my_xfp, chain): +async def ms_coordinator_qr(af_str, my_xfp): # Scan a number of JSON files from BBQr w/ derive, xfp and xpub details. # from ux_q1 import QRScannerInteraction, decode_qr_result, QRDecodeExplained @@ -390,28 +67,32 @@ async def ms_coordinator_qr(af_str, my_xfp, chain): file_type = 'J' if file_type == 'J': try: - import json - return json.loads(data) + return ujson.loads(data) except: raise QRDecodeExplained('Unable to decode JSON data') else: for line in data.split("\n"): - if len(line) > 112: - l_data = extract_cosigner(line, af_str) - if l_data: - return l_data + if len(line) > 112 and ("pub" in line): + return line.strip() num_mine = 0 num_files = 0 - xpubs = [] + keys = [] msg = 'Scan Exported XPUB from Coldcard' while True: - vals = await QRScannerInteraction().scan_general(msg, convertor, enter_quits=True) - if vals is None: + key = await QRScannerInteraction().scan_general(msg, convertor, enter_quits=True) + if key is None: break try: - is_mine = await validate_xpub_for_ms(vals, af_str, chain, my_xfp, xpubs) + if isinstance(key, dict): + k = Key.from_cc_json(key, af_str) + else: + k = Key.from_string(key) + + num_mine += k.validate(my_xfp) + keys.append(k) + except KeyError as e: # random JSON will end up here msg = "Missing value: %s" % str(e) @@ -421,19 +102,17 @@ async def ms_coordinator_qr(af_str, my_xfp, chain): msg = "Failure: %s" % str(e) continue - if is_mine: - num_mine += 1 num_files += 1 msg = "Number of keys scanned: %d" % num_files - return xpubs, num_mine, num_files + return keys, num_mine, num_files -async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None): +async def ms_coordinator_file(af_str, my_xfp, slot_b=None): num_mine = 0 num_files = 0 - xpubs = [] + keys = [] try: with CardSlot(slot_b=slot_b) as card: for path in card.get_paths(): @@ -471,10 +150,13 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None): if vals: break - is_mine = await validate_xpub_for_ms(vals, af_str, chain, - my_xfp, xpubs) - if is_mine: - num_mine += 1 + if isinstance(vals, dict): + k = Key.from_cc_json(vals, af_str) + else: + k = Key.from_string(vals) + + num_mine += k.validate(my_xfp) + keys.append(k) num_files += 1 @@ -490,19 +172,21 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None): await needs_microsd() return - return xpubs, num_mine, num_files + return keys, num_mine, num_files def add_own_xpub(chain, acct_num, addr_fmt, secret=None): # Build out what's required for using master secret (or another # encoded secret) as a co-signer - deriv = "m/48h/%dh/%dh/%dh" % (chain.b44_cointype, acct_num, - 2 if addr_fmt == AF_P2WSH else 1) + deriv = "48h/%dh/%dh/%dh" % (chain.b44_cointype, acct_num, + 2 if addr_fmt == AF_P2WSH else 1) with stash.SensitiveValues(secret=secret) as sv: - node = sv.derive_path(deriv) - the_xfp = sv.get_xfp() - return (the_xfp, deriv, chain.serialize_public(node, AF_P2SH)) + the_xfp = xfp2str(sv.get_xfp()) + koi = KeyOriginInfo.from_string(the_xfp + "/" + deriv) + node = sv.derive_path(deriv, register=False) + key = Key(node, koi, chain_type=chain.ctype) + return key async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, for_ccc=None): @@ -518,21 +202,21 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, my_xfp = settings.get('xfp') if is_qr: - xpubs, num_mine, num_files = await ms_coordinator_qr(mode, my_xfp, chain) + keys, num_mine, num_files = await ms_coordinator_qr(mode, my_xfp) else: - xpubs, num_mine, num_files = await ms_coordinator_file(mode, my_xfp, chain) + keys, num_mine, num_files = await ms_coordinator_file(mode, my_xfp) if CardSlot.both_inserted(): # handle dual slot usage: assumes slot A used by first call above - bxpubs, bnum_mine, bnum_files = await ms_coordinator_file(mode, my_xfp, - chain, True) - xpubs.extend(bxpubs) + bkeys, bnum_mine, bnum_files = await ms_coordinator_file(mode, my_xfp, + slot_b=True) + keys.extend(bkeys) num_mine += bnum_mine num_files += bnum_files # remove dups; easy to happen if you double-tap the export - xpubs = list(set(xpubs)) + keys = list(set(keys)) - if not xpubs or (len(xpubs) == 1 and num_mine): + if not keys or (len(keys) == 1 and num_mine): if is_qr: msg = "No XPUBs scanned. Exit." else: @@ -554,15 +238,15 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, # problem: above file searching may find xpub export from key C # (or our master seed, exported) .. we can't add them again, # since xfp are not unique and that's probably not what they wanted - got_xfps = [a[0], c[0]] - xpubs = [x for x in xpubs if x[0] not in got_xfps] + got_xfps = [a.origin.fingerprint, c.origin.fingerprint] + keys = [k for k in keys if k.origin.fingerprint not in got_xfps] - if not xpubs: + if not keys: await ux_show_story("Need at least one other co-signer (key B).") return # master seed is always key0, key C is key1, k2..kn backup keys - xpubs = [a, c] + xpubs + keys = [a, c] + keys num_mine += 2 elif not num_mine: @@ -572,10 +256,10 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, if ch == "y": acct = await ux_enter_bip32_index('Account Number:') or 0 dis.fullscreen("Wait...") - xpubs.append(add_own_xpub(chain, acct, addr_fmt)) + keys.append(add_own_xpub(chain, acct, addr_fmt)) num_mine += 1 - N = len(xpubs) + N = len(keys) if (N > MAX_SIGNERS) or (N < 2): await ux_show_story("Invalid number of signers,min is 2 max is %d." % MAX_SIGNERS) @@ -603,12 +287,18 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, else: name = 'CC-%d-of-%d' % (M, N) - ms = MultisigWallet(name, (M, N), xpubs, addr_fmt=addr_fmt) + from miniscript import Sortedmulti, Number + from wallet import MiniScriptWallet + from descriptor import Descriptor + + desc_obj = Descriptor(miniscript=Sortedmulti(Number(M), *keys), + addr_fmt=addr_fmt) + msc = MiniScriptWallet.from_descriptor_obj(name, desc_obj) if num_mine: from auth import NewMiniscriptEnrollRequest, UserAuthorizedAction - UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(ms) + UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc) # menu item case: add to stack from ux import the_ux @@ -616,7 +306,8 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False, else: # we cannot enroll multisig in which we do not participate # thou we can put descriptor on screen or on SD - await ms.export_wallet_file(descriptor=True, desc_pretty=False) + # cannot sign export if my key not included + await msc.export_wallet_file(sign=False) async def create_ms_step1(*a, for_ccc=None): @@ -654,6 +345,7 @@ Default is P2WSH addresses (segwit) or press (1) for P2SH-P2WSH.''', escape='1') try: return await ondevice_multisig_create(n, f, is_qr, for_ccc=for_ccc) except Exception as e: + # sys.print_exception(e) await ux_show_story('Failed to create multisig.\n\n%s\n%s' % (e, problem_file_line(e)), title="ERROR") # EOF diff --git a/shared/nfc.py b/shared/nfc.py index a3ec08e8..fa5fe1c0 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -631,29 +631,6 @@ class NFCHandler: else: raise ValueError(ext) - async def import_multisig_nfc(self, *a): - # user is pushing a file downloaded from another CC over NFC - # - would need an NFC app in between for the sneakernet step - # get some data - def f(m): - if len(m) < 70: - return - m = m.decode() - - # multi( catches both multi( and sortedmulti( - if 'pub' in m or "multi(" in m: - return m - - winner = await self._nfc_reader(f, 'Unable to find multisig descriptor.') - - if winner: - from auth import maybe_enroll_xpub - try: - maybe_enroll_xpub(config=winner) - except Exception as e: - #import sys; sys.print_exception(e) - await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) - async def import_ephemeral_seed_words_nfc(self, *a): def f(m): sm = m.decode().strip().split(" ") diff --git a/shared/ownership.py b/shared/ownership.py index a685c37b..c08ee359 100644 --- a/shared/ownership.py +++ b/shared/ownership.py @@ -208,7 +208,7 @@ class OwnershipCache: # Find it! # - returns wallet object, and tuple2 of final 2 subpath components # - if you start w/ testnet, we'll follow that - from miniscript import MiniScriptWallet + from wallet import MiniScriptWallet from glob import dis ch = chains.current_chain() @@ -308,7 +308,7 @@ class OwnershipCache: # Provide a simple UX. Called functions do fullscreen, progress bar stuff. from ux import ux_show_story, show_qr_code from charcodes import KEY_QR - from miniscript import MiniScriptWallet + from wallet import MiniScriptWallet from public_constants import AFC_BECH32, AFC_BECH32M try: diff --git a/shared/psbt.py b/shared/psbt.py index 4427c092..d768f609 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -12,7 +12,7 @@ from uhashlib import sha256 from uio import BytesIO from sffile import SizerFile from chains import taptweak, tapleaf_hash -from miniscript import MiniScriptWallet +from wallet import MiniScriptWallet from multisig import disassemble_multisig_mn from exceptions import FatalPSBTIssue, FraudulentChangeOutput from serializations import ser_compact_size, deser_compact_size, hash160 @@ -2679,7 +2679,7 @@ class psbtObject(psbtProxy): desc = self.active_miniscript.to_descriptor() if desc.is_basic_multisig: # we can only finalize multisig inputs from all miniscript set - M, N = desc.miniscript.m_n + M, N = desc.miniscript.m_n() if len(inp.part_sigs) >= M: return True return False @@ -2707,7 +2707,7 @@ class psbtObject(psbtProxy): assert self.active_miniscript desc = self.active_miniscript.to_descriptor() assert desc.is_basic_multisig - M, N = desc.miniscript.m_n + M, N = desc.miniscript.m_n() if desc.is_sortedmulti: # BIP-67 easy just sort by public keys @@ -2804,7 +2804,7 @@ class psbtObject(psbtProxy): assert ssig, 'No signature on input #%d' % in_idx if inp.is_segwit: - if inp.is_multisig: + if inp.is_miniscript: if inp.redeem_script: # p2sh-p2wsh txi.scriptSig = ser_string(self.get(inp.redeem_script)) diff --git a/shared/teleport.py b/shared/teleport.py index 8e4d6f64..4f7ae470 100644 --- a/shared/teleport.py +++ b/shared/teleport.py @@ -15,7 +15,7 @@ from bbqr import b32encode, b32decode from menu import MenuItem, MenuSystem from notes import NoteContentBase from sffile import SFFile -from miniscript import MiniScriptWallet +from wallet import MiniScriptWallet from stash import SensitiveValues, SecretStash, blank_object, bip39_passphrase # One page github-hosted static website that shows QR based on URL contents pushed by NFC @@ -626,15 +626,9 @@ async def kt_send_psbt(psbt, psbt_len): # who remains to sign? look at inputs # all_xfps is set, no need to list one master xfp more than once - assuming CC can sign it all - if psbt.active_multisig: - ms = psbt.active_multisig - all_xfps = {x for x,*p in psbt.active_multisig.get_xfp_paths()} - - elif psbt.active_miniscript: - ms = psbt.active_miniscript - all_xfps = {x for x,*p in psbt.active_miniscript.to_descriptor().xfp_paths(skip_unspend_ik=True)} - else: - assert False + assert psbt.active_miniscript + ms = psbt.active_miniscript + all_xfps = {x for x,*p in psbt.active_miniscript.to_descriptor().xfp_paths(skip_unspend_ik=True)} need = [x for x in psbt.miniscript_xfps_needed() if x in all_xfps] # maybe it's not really a PSBT where we know the other signers? might be @@ -769,8 +763,8 @@ async def kt_send_file_psbt(*a): finally: dis.progress_bar_show(1) - if (not psbt.active_multisig) and (not psbt.active_miniscript): - await ux_show_story("We are not part of this multisig wallet.", "Cannot Teleport PSBT") + if not psbt.active_miniscript: + await ux_show_story("We are not part of this wallet.", "Cannot Teleport PSBT") return await kt_send_psbt(psbt, psbt_len=psbt_len) diff --git a/shared/usb.py b/shared/usb.py index d2b54ede..43610e39 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -435,39 +435,6 @@ class USBHandler: sign_msg(msg, subpath, addr_fmt) return None - if cmd == 'p2sh': - # show P2SH (probably multisig) address on screen (also provides it back) - # - must provide redeem script, and list of [xfp+path] - from auth import start_show_p2sh_address - - if hsm_active and not hsm_active.approve_address_share(is_p2sh=True): - raise HSMDenied - - # new multsig goodness, needs mapping from xfp->path and M values - addr_fmt, M, N, script_len = unpack_from('= 1, but - # seems like more restrictive than needed, so "m" is allowed - import stash - from public_constants import AF_P2SH - try: - # Note: addr fmt detected here via SLIP-132 isn't useful - node, chain, _ = parse_extended_key(xpub) - except: - raise AssertionError('unable to parse xpub') - - try: - assert node.privkey() == None # 'no privkeys plz' - except ValueError: - pass - - if expect_chain == "XRT": - # HACK but there is no difference extended_keys - just bech32 hrp - assert chain.ctype == "XTN" - else: - assert chain.ctype == expect_chain, 'wrong chain' - - depth = node.depth() - - if depth == 1: - if not xfp: - # allow a shortcut: zero/omit xfp => use observed parent value - xfp = swab32(node.parent_fp()) - else: - # generally cannot check fingerprint values, but if we can, do so. - if not disable_checks: - assert swab32(node.parent_fp()) == xfp, 'xfp depth=1 wrong' - - assert xfp, 'need fingerprint' # happens if bare xpub given - - # In most cases, we cannot verify the derivation path because it's hardened - # and we know none of the private keys involved. - if depth == 1: - # but derivation is implied at depth==1 - kn, is_hard = node.child_number() - if is_hard: kn |= 0x80000000 - guess = keypath_to_str([kn], skip=0) - - if deriv: - if not disable_checks: - assert guess == deriv, '%s != %s' % (guess, deriv) - else: - deriv = guess # reachable? doubt it - - assert deriv, 'empty deriv' # or force to be 'm'? - assert deriv[0] == 'm' - - # path length of derivation given needs to match xpub's depth - if not disable_checks: - p_len = deriv.count('/') - if p_len: - # only check this for keys that have origin derivation - # originless keys are expected to be blinded - assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % ( - p_len, depth, xfp2str(xfp) - ) - else: - # depth can be more than zero here - keys can be blinded - assert xfp == swab32(node.my_fp()), "xpub xfp wrong %s" % xfp2str(xfp) - - if xfp == my_xfp: - # it's supposed to be my key, so I should be able to generate pubkey - # - might indicate collision on xfp value between co-signers, - # and that's not supported - with stash.SensitiveValues() as sv: - chk_node = sv.derive_path(deriv) - assert node.pubkey() == chk_node.pubkey(), \ - "[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:]) - - # serialize xpub w/ BIP-32 standard now. - # - this has effect of stripping SLIP-132 confusion away - return xfp == my_xfp, (xfp, deriv, chain.serialize_public(node, AF_P2SH)) - def encode_seed_qr(words): return ''.join('%04d' % bip39.get_word_index(w) for w in words) diff --git a/shared/ux_q1.py b/shared/ux_q1.py index 37177f58..b661e84a 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -950,12 +950,11 @@ class QRScannerInteraction: proto, addr, args = vals await ux_visualize_bip21(proto, addr, args) - elif what in ("multi", "minisc"): + elif what == "minisc": from auth import maybe_enroll_xpub ms_config, = vals try: - maybe_enroll_xpub(config=ms_config, - miniscript=False if what == "multi" else None) + maybe_enroll_xpub(config=ms_config) except Exception as e: await ux_show_story( 'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) diff --git a/shared/wallet.py b/shared/wallet.py index cb5aa4ab..4a161b91 100644 --- a/shared/wallet.py +++ b/shared/wallet.py @@ -2,13 +2,30 @@ # # wallet.py - A place you find UTXO, addresses and descriptors. # -import chains -from glob import settings -from stash import SensitiveValues +import ngu, ujson, uio, chains, ure, version, stash +from binascii import hexlify as b2a_hex +from serializations import ser_string +from desc_utils import bip388_wallet_policy_to_descriptor, append_checksum, bip388_validate_policy, Key +from public_constants import AF_P2TR, AF_P2WSH, AF_CLASSIC, AF_P2SH +from menu import MenuSystem, MenuItem, start_chooser +from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_enter_bip32_index +from files import CardSlot, CardMissingError, needs_microsd +from utils import problem_file_line, xfp2str, to_ascii_printable, swab32, show_single_address +from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER +from glob import settings + +# Arbitrary value, not 0 or 1, used to derive a pubkey from preshared xpub in Key Teleport +KT_RXPUBKEY_DERIV = const(20250317) + +# PSBT Xpub trust policies +TRUST_VERIFY = const(0) +TRUST_OFFER = const(1) +TRUST_PSBT = const(2) MAX_BIP32_IDX = (2 ** 31) - 1 + class WalletOutOfSpace(RuntimeError): pass @@ -85,7 +102,7 @@ class MasterSingleSigWallet(WalletABC): assert 0 <= change_idx <= 1 path += '/%d' % change_idx - with SensitiveValues() as sv: + with stash.SensitiveValues() as sv: node = sv.derive_path(path) if count is None: # special case - showing single, ignoring start_idx @@ -110,7 +127,7 @@ class MasterSingleSigWallet(WalletABC): def render_address(self, change_idx, idx): # Optimized for a single address. path = self._path + '/%d/%d' % (change_idx, idx) - with SensitiveValues() as sv: + with stash.SensitiveValues() as sv: node = sv.derive_path(path) return self.chain.address(node, self.addr_fmt) @@ -127,20 +144,32 @@ class MasterSingleSigWallet(WalletABC): return d -class BaseStorageWallet(WalletABC): - key_name = None +class MiniScriptWallet(WalletABC): + skey = "miniscript" + disable_checks = False + + def __init__(self, name, desc_tmplt, keys_info, af, ik_u, + desc=None, m_n=None, bip67=None): + + assert len(name) <= 20, "name > 20" - def __init__(self): self.storage_idx = -1 - - @classmethod - def none_setup_yet(cls): - return '(none setup yet)' + self.name = name + self.desc_tmplt = desc_tmplt + self.keys_info = keys_info + self.desc = desc + self.addr_fmt = af + # internal key unspendable + self.ik_u = ik_u + # below are basic multisig meta + # if m_n is not None, we are dealing with basic multisig + self.m_n = m_n + self.bip67 = bip67 @classmethod def exists(cls): # are there any wallets defined? - return bool(settings.get(cls.key_name, [])) + return bool(settings.get(cls.skey, [])) @classmethod def get_all(cls): @@ -150,21 +179,14 @@ class BaseStorageWallet(WalletABC): @classmethod def iter_wallets(cls): # - this is only place we should be searching this list, please!! - lst = settings.get(cls.key_name, []) + lst = settings.get(cls.skey, []) for idx, rec in enumerate(lst): yield cls.deserialize(rec, idx) - def serialize(self): - raise NotImplemented - - @classmethod - def deserialize(cls, c, idx=-1): - raise NotImplemented - @classmethod def get_by_idx(cls, nth): # instance from index number (used in menu) - lst = settings.get(cls.key_name, []) + lst = settings.get(cls.skey, []) try: obj = lst[nth] except IndexError: @@ -178,7 +200,7 @@ class BaseStorageWallet(WalletABC): # - important that this fails immediately when nvram overflows obj = self.serialize() - v = settings.get(self.key_name, []) + v = settings.get(self.skey, []) orig = v.copy() if not v or self.storage_idx == -1: # create @@ -188,7 +210,7 @@ class BaseStorageWallet(WalletABC): # update in place v[self.storage_idx] = obj - settings.set(self.key_name, v) + settings.set(self.skey, v) # save now, rather than in background, so we can recover # from out-of-space situation @@ -197,7 +219,7 @@ class BaseStorageWallet(WalletABC): except: # back out change; no longer sure of NVRAM state try: - settings.set(self.key_name, orig) + settings.set(self.skey, orig) settings.save() except: pass # give up on recovery @@ -207,16 +229,824 @@ class BaseStorageWallet(WalletABC): # remove saved entry # - important: not expecting more than one instance of this class in memory assert self.storage_idx >= 0 - lst = settings.get(self.key_name, []) + lst = settings.get(self.skey, []) try: del lst[self.storage_idx] if lst: - settings.set(self.key_name, lst) + settings.set(self.skey, lst) else: - settings.remove_key(self.key_name) + settings.remove_key(self.skey) settings.save() # actual write except IndexError: pass self.storage_idx = -1 + def serialize(self): + return (self.name, self.desc_tmplt, self.keys_info, + self.addr_fmt, self.ik_u, self.m_n, self.bip67) + + @classmethod + def deserialize(cls, c, idx=-1): + # after deserialization - we lack loaded descriptor object + # we do not need it for everything + name, desc_tmplt, keys_info, af, ik_u, m_n, b67 = c + rv = cls(name, desc_tmplt, keys_info, af, ik_u, m_n=m_n, bip67=b67) + rv.storage_idx = idx + return rv + + @classmethod + def get_trust_policy(cls): + which = settings.get('pms', None) + if which is None: + which = TRUST_VERIFY if cls.exists() else TRUST_OFFER + + return which + + @property + def chain(self): + return chains.current_chain() + + @classmethod + def find_match(cls, xfp_paths, addr_fmt=None, M_N=None): + for rv in cls.iter_wallets(): + if addr_fmt is not None: + if rv.addr_fmt != addr_fmt: + continue + + if M_N: + if not rv.m_n: + continue + if rv.m_n != M_N: + continue + + if rv.matching_subpaths(xfp_paths): + return rv + return None + + def xfp_paths(self, skip_unspend_ik=False): + if not self.desc: + res = [] + for i, k_str in enumerate(self.keys_info): + if not i and self.ik_u and skip_unspend_ik: + continue + k = Key.from_string(k_str) + res.append(k.origin.psbt_derivation()) + return res + + return self.desc.xfp_paths(skip_unspend_ik=skip_unspend_ik) + + def matching_subpaths(self, xfp_paths): + my_xfp_paths = self.to_descriptor().xfp_paths() + + if len(xfp_paths) != len(my_xfp_paths): + return False + + for x in my_xfp_paths: + prefix_len = len(x) + for y in xfp_paths: + if x == y[:prefix_len]: + break + else: + return False + return True + + def subderivation_indexes(self, xfp_paths): + # we already know that they do match + my_xfp_paths = self.to_descriptor().xfp_paths() + res = set() + for x in my_xfp_paths: + prefix_len = len(x) + for y in xfp_paths: + if x == y[:prefix_len]: + to_derive = tuple(y[prefix_len:]) + res.add(to_derive) + + assert res + if len(res) == 1: + branch, idx = list(res)[0] + else: + branch = [i[0] for i in res] + indexes = set([i[1] for i in res]) + assert len(indexes) == 1 + idx = list(indexes)[0] + + return branch, idx + + def get_my_deriv(self, my_xfp): + # lowest public key from lexicographically sorted list is at index 0 + mine = self.xpubs_from_xfp(my_xfp) + return mine[0].origin.str_derivation() + + def derive_desc(self, xfp_paths): + branch, idx = self.subderivation_indexes(xfp_paths) + derived_desc = self.desc.derive(branch).derive(idx) + return derived_desc + + def validate_script_pubkey(self, script_pubkey, xfp_paths, merkle_root=None): + derived_desc = self.derive_desc(xfp_paths) + derived_spk = derived_desc.script_pubkey() + assert derived_spk == script_pubkey, "spk mismatch\n%s\n%s" % (b2a_hex(derived_spk), b2a_hex(script_pubkey)) + if merkle_root: + assert derived_desc.tapscript.merkle_root == merkle_root, "psbt merkle root" + return derived_desc + + def detail(self): + s = "Wallet Name:\n %s\n\n" % self.name + if self.m_n: + # basic multisig + s += "Policy: %d of %d\n\n" % self.m_n + + s += chains.addr_fmt_label(self.addr_fmt) + s += "\n\n" + self.desc_tmplt + return s + + async def show_detail(self, story="", allow_import=False): + story += self.detail() + story += "\n\nPress (1) to see extended public keys" + + if allow_import: + story += ", OK to approve, X to cancel." + + while True: + ch = await ux_show_story(story, escape="1") + if ch == "1": + await self.show_keys() + + elif ch != "y": + return None + else: + return True + + async def show_keys(self): + msg = "" + for idx, k_str in enumerate(self.keys_info): + if idx: + msg += '\n---===---\n\n' + elif self.addr_fmt == AF_P2TR: + # index 0, taproot internal key + msg += "Taproot internal key:\n\n" + if self.ik_u: + msg += "(provably unspendable)\n\n" + + msg += '@%s:\n %s\n\n' % (idx, k_str) + + await ux_show_story(msg) + + def to_descriptor(self): + if self.desc is None: + # actual descriptor is not loaded, but was asked for + # fill policy - aka storage format - to actual descriptor + import glob + + if self.name in glob.DESC_CACHE: + # loaded descriptor from cache + print("to_descriptor CACHE") + self.desc = glob.DESC_CACHE[self.name] + else: + print("loading... policy --> descriptor !!!") + # no need to validate already saved descriptor - was validated upon enroll + self.desc = self._from_bip388_wallet_policy(self.desc_tmplt, self.keys_info, + validate=False) + # cache len always 1 + glob.DESC_CACHE = {} + glob.DESC_CACHE[self.name] = self.desc + + return self.desc + + @staticmethod + def _from_bip388_wallet_policy(desc_template, keys_info, validate=True): + desc_str = bip388_wallet_policy_to_descriptor( + desc_template.replace("/<0;1>/*", "/**"), + keys_info + ) + from descriptor import Descriptor + desc_obj = Descriptor.from_string(desc_str, validate=validate) + return desc_obj + + @classmethod + def from_bip388_wallet_policy(cls, name, desc_template, keys_info): + bip388_validate_policy(desc_template, keys_info) + desc_obj = cls._from_bip388_wallet_policy(desc_template, keys_info) + msc = cls.from_descriptor_obj(name, desc_obj) + return msc + + @classmethod + def from_descriptor_obj(cls, name, desc_obj): + # BIP388 wasn't generated yet - generating from descriptor upon import/enroll + desc_tmplt, keys_info = desc_obj.bip388_wallet_policy() + # self-validation + bip388_validate_policy(desc_tmplt, keys_info) + + ik_u = desc_obj.key and desc_obj.key.is_provably_unspendable + af = desc_obj.addr_fmt + m_n = None + bip67 = None + if desc_obj.is_basic_multisig: + m_n = desc_obj.miniscript.m_n() + bip67 = desc_obj.is_sortedmulti + + return cls(name, desc_tmplt, keys_info, af, ik_u, desc_obj, m_n, bip67) + + @classmethod + def from_file(cls, config, name=None, bip388=False): + from descriptor import Descriptor + + if bip388: + # config is JSON wallet policy + wal = cls.from_bip388_wallet_policy(config["name"], config["desc_template"], + config["keys_info"]) + else: + if name is None: + desc_obj, cs = Descriptor.from_string(config.strip(), checksum=True) + name = cs + else: + name = to_ascii_printable(name) + desc_obj = Descriptor.from_string(config.strip()) + + wal = cls.from_descriptor_obj(name, desc_obj) + + return wal + + def find_duplicates(self): + for rv in self.iter_wallets(): + assert self.name != rv.name, ("Miniscript wallet with name '%s'" + " already exists. All wallets MUST" + " have unique names.\n\n" % self.name) + + # optimization miniscript vs. multisig & different M/N multisigs + if self.m_n != rv.m_n: + # different M/N + continue + + if self.m_n: + # enrolling basic multisig wallet + if self.addr_fmt == rv.addr_fmt and sorted(self.keys_info) == sorted(rv.keys_info): + err = "Duplicate wallet." + if self.bip67 != rv.bip67: + err += " BIP-67 clash." + err += "\n\n" + assert False, err + + assert self.desc_tmplt != rv.desc_tmplt \ + and self.keys_info != rv.keys_info, ("This wallet is a duplicate " + "of already saved wallet " + "%s.\n\n" % rv.name) + + async def confirm_import(self): + nope, yes = (KEY_CANCEL, KEY_ENTER) if version.has_qwerty else ("x", "y") + try: + self.find_duplicates() + story, allow_import = "Create new miniscript wallet?\n\n", True + except AssertionError as e: + story, allow_import = str(e), False + + to_save = await self.show_detail(story, allow_import=allow_import) + + ch = yes if to_save else nope + if to_save and allow_import: + assert self.storage_idx == -1 + self.commit() + import glob + # new wallet was imported - cache descriptor + glob.DESC_CACHE = {} + assert self.desc + glob.DESC_CACHE[self.name] = self.desc + await ux_dramatic_pause("Saved.", 2) + + return ch + + def yield_addresses(self, start_idx, count, change=False, scripts=False, change_idx=0): + ch = chains.current_chain() + dd = self.to_descriptor().derive(None, change=change) + idx = start_idx + while count: + if idx > MAX_BIP32_IDX: + break + # make the redeem script, convert into address + d = dd.derive(idx) + scr = d.miniscript.compile() if d.miniscript else None + addr = ch.render_address(d.script_pubkey(compiled_scr=scr)) + ders = script = None + if scripts: + ders = ["[%s]" % str(k.origin) for k in d.keys] + if d.tapscript: + script = d.tapscript.script_tree() + else: + script = b2a_hex(ser_string(scr)).decode() + + yield idx, addr, ders, script + + idx += 1 + count -= 1 + + def make_addresses_msg(self, msg, start, n, change=0): + from glob import dis + + addrs = [] + + for idx, addr, *_ in self.yield_addresses(start, n, change=bool(change), scripts=False): + msg += '.../%d =>\n' % idx # just idx, if derivations or scripts needed - export csv + addrs.append(addr) + msg += show_single_address(addr) + '\n\n' + dis.progress_sofar(idx - start + 1, n) + + return msg, addrs + + def generate_address_csv(self, start, n, change): + yield '"' + '","'.join( + ['Index', 'Payment Address'] + ) + '"\n' + for idx, addr, ders, script in self.yield_addresses(start, n, change=bool(change)): + ln = '%d,"%s"' % (idx, addr) + if ders: + ln += ',"%s","' % script + ln += '","'.join(ders) + ln += '"' + ln += '\n' + yield ln + + def to_string(self, checksum=True): + # policy filling - not posible to specify internal/external always multipath export + # only supported from bitcoin-core 29.0 + if self.desc_tmplt and self.keys_info: + desc = bip388_wallet_policy_to_descriptor(self.desc_tmplt, self.keys_info) + if checksum: + desc = append_checksum(desc) + return desc + + return self.desc.to_string() + + def bitcoin_core_serialize(self): + return [{ + "desc": self.to_string(), # policy fill + "active": True, + "timestamp": "now", + "range": [0, 100], + }] + + async def export_wallet_file(self, core=False, bip388=False, sign=True): + # do not load descriptor - just fill policy + # only with multipath format <0;1> + from glob import NFC, dis + from ux import import_export_prompt + + dis.fullscreen('Wait...') + + if core: + name = "Bitcoin Core miniscript" + fname_pattern = 'bitcoin-core-%s.txt' % self.name + msg = "importdescriptors cmd" + core_obj = self.bitcoin_core_serialize() + core_str = ujson.dumps(core_obj) + res = "importdescriptors '%s'\n" % core_str + elif bip388: + # policy as JSON + name = "BIP-388 Wallet Policy" + fname_pattern = 'b388-%s.json' % self.name + res = ujson.dumps({"name": self.name, + "desc_template": self.desc_tmplt, + "keys_info": self.keys_info}) + else: + name = "Miniscript" + fname_pattern = 'minsc-%s.txt' % self.name + msg = self.name + res = self.to_string() + + ch = await import_export_prompt("%s file" % name) + if isinstance(ch, str): + if ch in "3"+KEY_NFC: + await NFC.share_text(res) + elif ch == KEY_QR: + try: + from ux import show_qr_code + await show_qr_code(res, msg=msg) + except: + if version.has_qwerty: + from ux_q1 import show_bbqr_codes + await show_bbqr_codes('U', res, msg) + return + + try: + with CardSlot(**ch) as card: + fname, nice = card.pick_filename(fname_pattern) + + # do actual write + with open(fname, 'w+') as fp: + fp.write(res) + + if sign: + # TODO need function to get my xpub from just policy + # sign with my key at the same path as first address of export + derive = self.get_my_deriv(settings.get('xfp')) + "/0/0" + from msgsign import write_sig_file + h = ngu.hash.sha256s(res.encode()) + sig_nice = write_sig_file([(h, fname)], derive, AF_CLASSIC) + + msg = '%s file written:\n\n%s' % (name, nice) + if sign: + msg += '\n\n%s signature file written:\n\n%s' % (name, sig_nice) + await ux_show_story(msg) + + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e))) + return + + def xpubs_from_xfp(self, xfp): + # return list of XPUB's which match xfp + res = [] + desc = self.to_descriptor() + for k in desc.keys: + if k.origin and k.origin.cc_fp == xfp: + res.append(k) + elif swab32(k.node.my_fp()) == xfp: + res.append(k) + + assert res, "missing xfp %s" % xfp2str(xfp) + # returned is list of keys with corresponding master xfp + # key in list are lexicographically sorted based on their public keys + # lowest public key first + return sorted(res, key=lambda o: o.serialize()) + + def kt_make_rxkey(self, xfp): + # Derive the receiver's pubkey from preshared xpub and a special derivation + # - also provide the keypair we're using from our side of connection + # - returns 4 byte nonce which is sent un-encrypted, his_pubkey and my_keypair + ri = ngu.random.uniform(1<<28) + + # sorted lexicographically, always use the lowest pubkey from the list at index 0 + keys = self.xpubs_from_xfp(xfp) + k = keys[0] + k = k.derive(KT_RXPUBKEY_DERIV).derive(ri) + pubkey = k.node.pubkey() + + kp = self.kt_my_keypair(ri) + return ri.to_bytes(4, 'big'), pubkey, kp + + def kt_my_keypair(self, ri): + # Calc my keypair for sending PSBT files. + # + # sorted lexicographically, always use the lowest pubkey from the list at index 0 + keys = self.xpubs_from_xfp(settings.get('xfp')) + + subpath = "/%d/%d" % (KT_RXPUBKEY_DERIV, ri) + path = keys[0].origin.str_derivation() + subpath + with stash.SensitiveValues() as sv: + node = sv.derive_path(path) + kp = ngu.secp256k1.keypair(node.privkey()) + return kp + + @classmethod + def kt_search_rxkey(cls, payload): + # Construct the keypair for to be decryption + # - has to try pubkey each all the unique XFP for all co-signers in all wallets + # - checks checksum of ECDH unwrapped data to see if it's the right one + # - returns session key, decrypted first layer, and XFP of sender + from teleport import decode_step1 + + # this nonce is part of the derivation path so each txn gets new keys + ri = int.from_bytes(payload[0:4], 'big') + + my_xfp = settings.get('xfp') + + for msc in cls.iter_wallets(): + kp = msc.kt_my_keypair(ri) + for k in msc.to_descriptor().keys: + if k.origin.cc_fp == my_xfp: + continue + kk = k.derive(KT_RXPUBKEY_DERIV).derive(ri) + his_pubkey = kk.node.pubkey() + # if implied session key decodes the checksum, it is right + ses_key, body = decode_step1(kp, his_pubkey, payload[4:]) + if ses_key: + return ses_key, body, kk.origin.cc_fp + + return None, None, None + +async def miniscript_delete(msc): + if not await ux_confirm("Delete miniscript wallet '%s'?\n\nFunds may be impacted." % msc.name): + await ux_dramatic_pause('Aborted.', 3) + return + + msc.delete() + await ux_dramatic_pause('Deleted.', 3) + +async def miniscript_wallet_delete(menu, label, item): + msc = item.arg + + await miniscript_delete(msc) + + from ux import the_ux + # pop stack + the_ux.pop() + + m = the_ux.top_of_stack() + m.update_contents() + +async def miniscript_wallet_detail(menu, label, item): + # show details of single multisig wallet + + msc = item.arg + + return await msc.show_detail() + +async def import_miniscript(*a): + # pick text file from SD card, import as multisig setup file + from actions import file_picker + from ux import import_export_prompt + + ch = await import_export_prompt("miniscript wallet file", is_import=True) + if isinstance(ch, str): + if ch == KEY_QR: + await import_miniscript_qr() + elif ch == KEY_NFC: + await import_miniscript_nfc() + return + + def possible(filename): + with open(filename, 'rt') as fd: + for ln in fd: + if "sh(" in ln or "wsh(" in ln or "tr(" in ln: + # descriptor import + return True + + fn = await file_picker(suffix=['.txt', '.json'], min_size=100, + taster=possible, **ch) + if not fn: return + + try: + with CardSlot(**ch) as card: + with open(fn, 'rt') as fp: + data = fp.read() + except CardMissingError: + await needs_microsd() + return + + from auth import maybe_enroll_xpub + try: + possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None + maybe_enroll_xpub(config=data, name=possible_name) + except BaseException as e: + await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e))) + +async def import_miniscript_nfc(*a): + from glob import NFC + try: + return await NFC.import_miniscript_nfc() + except Exception as e: + await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e))) + +async def import_miniscript_qr(*a): + from auth import maybe_enroll_xpub + from ux_q1 import QRScannerInteraction + data = await QRScannerInteraction().scan_text('Scan Miniscript from a QR code') + if not data: + # press pressed CANCEL + return + try: + maybe_enroll_xpub(config=data) + except Exception as e: + await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e))) + +async def miniscript_wallet_export(menu, label, item): + # create a text file with the details; ready for import to next Coldcard + msc = item.arg[0] + kwargs = item.arg[1] + await msc.export_wallet_file(**kwargs) + +async def make_miniscript_wallet_descriptor_menu(menu, label, item): + # descriptor menu + msc = item.arg + if not msc: + return + + rv = [ + MenuItem('Export', f=miniscript_wallet_export, arg=(msc, {"core": False})), + MenuItem('Bitcoin Core', f=miniscript_wallet_export, arg=(msc, {"core": True})), + MenuItem('BIP-388 Policy', f=miniscript_wallet_export, arg=(msc, {"bip388":True})), + ] + return rv + +async def make_miniscript_wallet_menu(menu, label, item): + # details, actions on single multisig wallet + msc = MiniScriptWallet.get_by_idx(item.arg) + if not msc: return + + rv = [ + MenuItem('"%s"' % msc.name, f=miniscript_wallet_detail, arg=msc), + MenuItem('View Details', f=miniscript_wallet_detail, arg=msc), + MenuItem('Delete', f=miniscript_wallet_delete, arg=msc), + MenuItem('Descriptors', menu=make_miniscript_wallet_descriptor_menu, arg=msc), + ] + return rv + + +class MiniscriptMenu(MenuSystem): + @classmethod + def construct(cls): + import version + from menu import ShortcutItem + from bsms import make_ms_wallet_bsms_menu + from multisig import create_ms_step1 + + if not MiniScriptWallet.exists(): + rv = [MenuItem("(none setup yet)")] + else: + rv = [] + for msc in MiniScriptWallet.get_all(): + rv.append(MenuItem('%s' % msc.name, + menu=make_miniscript_wallet_menu, + arg=msc.storage_idx)) + from glob import NFC + rv.append(MenuItem('Import', f=import_miniscript)) + rv.append(MenuItem('Export XPUB', f=export_miniscript_xpubs)) + rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu)) + rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) + rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) + rv.append(MenuItem('Skip Checks?', f=disable_checks_menu)) + rv.append(ShortcutItem(KEY_NFC, predicate=lambda: NFC is not None, + f=import_miniscript_nfc)) + rv.append(ShortcutItem(KEY_QR, predicate=lambda: version.has_qwerty, + f=import_miniscript_qr)) + return rv + + def update_contents(self): + # Reconstruct the list of wallets on this dynamic menu, because + # we added or changed them and are showing that same menu again. + tmp = self.construct() + self.replace_items(tmp) + +async def make_miniscript_menu(*a): + # list of all multisig wallets, and high-level settings/actions + from pincodes import pa + + if pa.is_secret_blank(): + await ux_show_story("You must have wallet seed before creating miniscript wallets.") + return + + rv = MiniscriptMenu.construct() + return MiniscriptMenu(rv) + + +def disable_checks_chooser(): + ch = ['Normal', 'Skip Checks'] + + def xset(idx, text): + MiniScriptWallet.disable_checks = bool(idx) + + return int(MiniScriptWallet.disable_checks), ch, xset + +async def disable_checks_menu(*a): + + if not MiniScriptWallet.disable_checks: + ch = await ux_show_story('''\ +With many different wallet vendors and implementors involved, it can \ +be hard to create a PSBT consistent with the many keys involved. \ +With this setting, you can \ +disable the more stringent verification checks your Coldcard normally provides. + +USE AT YOUR OWN RISK. These checks exist for good reason! Signed txn may \ +not be accepted by network. + +This settings lasts only until power down. + +Press (4) to confirm entering this DANGEROUS mode. +''', escape='4') + + if ch != '4': return + + start_chooser(disable_checks_chooser) + + +def psbt_xpubs_policy_chooser(): + # Chooser for trust policy + ch = ['Verify Only', 'Offer Import', 'Trust PSBT'] + + def xset(idx, text): + settings.set('pms', idx) + + return MiniScriptWallet.get_trust_policy(), ch, xset + +async def trust_psbt_menu(*a): + # show a story then go into chooser + + ch = await ux_show_story('''\ +This setting controls what the Coldcard does \ +with the co-signer public keys (XPUB) that may \ +be provided inside a PSBT file. Three choices: + +- Verify Only. Do not import the xpubs found, but do \ +verify the correct wallet already exists on the Coldcard. + +- Offer Import. If it's a new multisig wallet, offer to import \ +the details and store them as a new wallet in the Coldcard. + +- Trust PSBT. Use the wallet data in the PSBT as a temporary, +multisig wallet, and do not import it. This permits some \ +deniability and additional privacy. + +When the XPUB data is not provided in the PSBT, regardless of the above, \ +we require the appropriate multisig wallet to already exist \ +on the Coldcard. Default is to 'Offer' unless a multisig wallet already \ +exists, otherwise 'Verify'.''') + + if ch == 'x': return + start_chooser(psbt_xpubs_policy_chooser) + + +async def ms_wallet_electrum_export(menu, label, item): + # create a JSON file that Electrum can use. Challenges: + # - file contains derivation paths for each co-signer to use + # - electrum is using BIP-43 with purpose=48 (purpose48_derivation) to make paths like: + # m/48h/1h/0h/2h + # - above is now called BIP-48 + # - other signers might not be coldcards (we don't know) + # solution: + # - when building air-gap, pick address type at that point, and matching path to suit + # - could check path prefix and addr_fmt make sense together, but meh. + ms = item.arg + from actions import electrum_export_story + + derivs, dsum = ms.get_deriv_paths() + + msg = 'The new wallet will have derivation path:\n %s\n and use %s addresses.\n' % ( + dsum, MultisigWallet.render_addr_fmt(ms.addr_fmt) ) + + if await ux_show_story(electrum_export_story(msg)) != 'y': + return + + await ms.export_electrum() + + +async def export_miniscript_xpubs(*a, xfp=None, alt_secret=None, skip_prompt=False): + # WAS: Create a single text file with lots of docs, and all possible useful xpub values. + # THEN: Just create the one-liner xpub export value they need/want to support BIP-45 + # NOW: Export JSON with one xpub per useful address type and semi-standard derivation path + # + # - consumer for this file is supposed to be ourselves, when we build on-device multisig. + # - however some 3rd parties are making use of it as well. + # - used for CCC feature now as well, but result looks just like normal export + # + xfp = xfp2str(xfp or settings.get('xfp', 0)) + chain = chains.current_chain() + + fname_pattern = 'ccxp-%s.json' % xfp + label = "Multisig XPUB" + + if not skip_prompt: + msg = '''\ +This feature creates a small file containing \ +the extended public keys (XPUB) you would need to join \ +a multisig wallet. + +Public keys for BIP-48 conformant paths are used: + +P2SH-P2WSH: + m/48h/{coin}h/{{acct}}h/1h +P2WSH: + m/48h/{coin}h/{{acct}}h/2h +P2TR: + m/48h/{coin}h/{{acct}}h/3h + +{ok} to continue. {x} to abort.'''.format(coin=chain.b44_cointype, ok=OK, x=X) + + ch = await ux_show_story(msg) + if ch != "y": + return + + acct = await ux_enter_bip32_index('Account Number:') or 0 + + def render(acct_num): + sign_der = None + with uio.StringIO() as fp: + fp.write('{\n') + with stash.SensitiveValues(secret=alt_secret) as sv: + for name, deriv, fmt in chains.MS_STD_DERIVATIONS: + if fmt == AF_P2SH and acct_num: + continue + dd = deriv.format(coin=chain.b44_cointype, acct_num=acct_num) + if fmt == AF_P2WSH: + sign_der = dd + "/0/0" + node = sv.derive_path(dd) + xp = chain.serialize_public(node, fmt) + fp.write(' "%s_deriv": "%s",\n' % (name, dd)) + fp.write(' "%s": "%s",\n' % (name, xp)) + xpub = chain.serialize_public(node) + fp.write(' "%s_key_exp": "%s",\n' % (name, "[%s/%s]%s" % (xfp, dd.replace("m/", ""), xpub))) + + # descriptor_template = multisig_descriptor_template(xpub, dd, xfp, fmt) + # if descriptor_template is None: + # continue + # fp.write(' "%s_desc": "%s",\n' % (name, descriptor_template)) + + fp.write(' "account": "%d",\n' % acct_num) + fp.write(' "xfp": "%s"\n}\n' % xfp) + return fp.getvalue(), sign_der, AF_CLASSIC + + from export import export_contents + await export_contents(label, lambda: render(acct), fname_pattern, + force_bbqr=True, is_json=True) + # EOF diff --git a/testing/conftest.py b/testing/conftest.py index 7188b521..cc3be516 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -2665,9 +2665,9 @@ from test_ephemeral import generate_ephemeral_words, import_ephemeral_xprv, goto from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable from test_msg import verify_msg_sign_story, sign_msg_from_text, msg_sign_export, sign_msg_from_address -from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn -from test_miniscript import offer_minsc_import, get_cc_key, bitcoin_core_signer, import_miniscript -from test_multisig import make_ms_address, clear_ms, make_myself_wallet, import_multisig +from test_multisig import import_ms_wallet, make_multisig, fake_ms_txn +from test_miniscript import offer_minsc_import, get_cc_key, bitcoin_core_signer, import_miniscript, usb_miniscript_get, usb_miniscript_addr +from test_multisig import make_ms_address, make_myself_wallet from test_notes import need_some_notes, need_some_passwords from test_nfc import try_sign_nfc, ndef_parse_txn_psbt from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed diff --git a/testing/devtest/wipe_ms.py b/testing/devtest/wipe_ms.py deleted file mode 100644 index 293b6125..00000000 --- a/testing/devtest/wipe_ms.py +++ /dev/null @@ -1,13 +0,0 @@ -# (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. -# -# quickly clear all multisig wallets installed -from glob import settings -from ux import restore_menu - -if settings.get('multisig'): - del settings.current['multisig'] - settings.save() - - print("cleared multisigs") - -restore_menu() diff --git a/testing/helpers.py b/testing/helpers.py index 66b30460..5858ad8a 100644 --- a/testing/helpers.py +++ b/testing/helpers.py @@ -45,7 +45,7 @@ def fake_dest_addr(style='p2pkh'): if style == 'p2wsh': return bytes([0, 32]) + prandom(32) - if style in ['p2sh', 'p2wsh-p2sh', 'p2wpkh-p2sh']: + if style in ['p2sh', 'p2wsh-p2sh', 'p2sh-p2wsh', 'p2wpkh-p2sh']: # all equally bogus P2SH outputs return bytes([0xa9, 0x14]) + prandom(20) + bytes([0x87]) diff --git a/testing/test_backup.py b/testing/test_backup.py index a81af598..9df6b066 100644 --- a/testing/test_backup.py +++ b/testing/test_backup.py @@ -214,11 +214,11 @@ def test_make_backup(multisig, goto_home, pick_menu_item, cap_story, need_keypre pass_word_quiz, reset_seed_words, import_ms_wallet, get_setting, reuse_pw, save_pw, settings_set, settings_remove, press_select, generate_ephemeral_words, set_bip39_pw, verify_backup_file, - check_and_decrypt_backup, restore_backup_cs, clear_ms, seedvault, + check_and_decrypt_backup, restore_backup_cs, clear_miniscript, seedvault, restore_main_seed, import_ephemeral_xprv, backup_system, press_cancel, sim_exec, pass_way, garbage_collector, make_big_notes): # Make an encrypted 7z backup, verify it, and even restore it! - clear_ms() + clear_miniscript() reset_seed_words() settings_set("seedvault", int(seedvault)) settings_set("seeds", [] if seedvault else None) diff --git a/testing/test_bsms.py b/testing/test_bsms.py index fdd5e71d..c1011a6a 100644 --- a/testing/test_bsms.py +++ b/testing/test_bsms.py @@ -269,7 +269,7 @@ def make_coordinator_round2(make_coordinator_round1, settings_get, settings_set, @pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) -def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, +def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_miniscript, goto_home, need_keypress, pick_menu_item, cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, microsd_wipe, press_select, is_q1, press_cancel): @@ -424,7 +424,7 @@ def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_ @pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) -def test_signer_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, +def test_signer_round1(way, encryption_type, M_N, addr_fmt, clear_miniscript, goto_home, need_keypress, cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, make_coordinator_round1, nfc_write_text, microsd_wipe, press_select, is_q1, pick_menu_item, cap_menu, press_cancel): @@ -581,7 +581,7 @@ def test_signer_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) @pytest.mark.parametrize("auto_collect", [True, False]) -def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, clear_ms, goto_home, +def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, clear_miniscript, goto_home, cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, make_coordinator_round1, make_signer_round1, nfc_write_text, microsd_wipe, pick_menu_item, press_select, is_q1, need_keypress, press_cancel): @@ -806,7 +806,7 @@ def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, c @pytest.mark.parametrize("with_checksum", [True, False]) @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) -def test_signer_round2(refuse, way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, +def test_signer_round2(refuse, way, encryption_type, M_N, addr_fmt, clear_miniscript, goto_home, need_keypress, pick_menu_item, cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, make_coordinator_round2, nfc_write_text, microsd_wipe, with_checksum, press_select, press_cancel, is_q1): @@ -815,7 +815,7 @@ def test_signer_round2(refuse, way, encryption_type, M_N, addr_fmt, clear_ms, go virtdisk_path = request.getfixturevalue("virtdisk_path") virtdisk_wipe() M, N = M_N - clear_ms() + clear_miniscript() microsd_wipe() desc_template, token = make_coordinator_round2(M, N, addr_fmt, encryption_type, way=way, add_checksum=with_checksum) goto_home() @@ -1184,7 +1184,7 @@ def test_failure_signer_round2(encryption_type, goto_home, press_select, pick_me @pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) -def test_integration_signer(encryption_type, M_N, addr_fmt, clear_ms, microsd_wipe, goto_home, pick_menu_item, cap_story, +def test_integration_signer(encryption_type, M_N, addr_fmt, clear_miniscript, microsd_wipe, goto_home, pick_menu_item, cap_story, press_select, settings_remove, microsd_path, settings_get, cap_menu, use_mainnet, need_keypress): # test CC signer full with bsms lib coordinator (test just SD card no need to retest IO paths again - tested above) @@ -1200,7 +1200,7 @@ def test_integration_signer(encryption_type, M_N, addr_fmt, clear_ms, microsd_wi M, N = M_N settings_remove(BSMS_SETTINGS) use_mainnet() - clear_ms() + clear_miniscript() microsd_wipe() coordinator = CoordinatorSession(M, N, addr_fmt, et_map[encryption_type]) session_data = coordinator.generate_token_key_pairs() @@ -1332,13 +1332,13 @@ def test_integration_signer(encryption_type, M_N, addr_fmt, clear_ms, microsd_wi @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) @pytest.mark.parametrize("cr1_shortcut", [True, False]) -def test_integration_coordinator(encryption_type, M_N, addr_fmt, clear_ms, microsd_wipe, goto_home, pick_menu_item, +def test_integration_coordinator(encryption_type, M_N, addr_fmt, clear_miniscript, microsd_wipe, goto_home, pick_menu_item, cap_story, need_keypress, settings_remove, microsd_path, settings_get, cap_menu, use_mainnet, cr1_shortcut, press_select): M, N = M_N settings_remove(BSMS_SETTINGS) use_mainnet() - clear_ms() + clear_miniscript() microsd_wipe() goto_home() pick_menu_item('Settings') diff --git a/testing/test_ccc.py b/testing/test_ccc.py index e6151d39..45f2d3ac 100644 --- a/testing/test_ccc.py +++ b/testing/test_ccc.py @@ -513,11 +513,11 @@ def ccc_ms_setup(microsd_path, virtdisk_path, scan_a_qr, is_q1, cap_menu, pick_m for _ in range(5): time.sleep(.1) title, story = cap_story() - if "Create new multisig wallet" in story: + if "Create new miniscript wallet" in story: break else: press_cancel() - assert False, "failed to create ms wallet" + assert False, "failed to create miniscript wallet" assert f"Policy: 2 of {N}" in story if is_q1: @@ -546,7 +546,7 @@ def bitcoind_create_watch_only_wallet(pick_menu_item, need_keypress, microsd_pat pick_menu_item("Descriptors") pick_menu_item("Bitcoin Core") - res = load_export("sd", label="Bitcoin Core multisig setup", is_json=False) + res = load_export("sd", label="Bitcoin Core miniscript", is_json=False) res = res.replace("importdescriptors ", "").strip() r1 = res.find("[") @@ -563,7 +563,7 @@ def bitcoind_create_watch_only_wallet(pick_menu_item, need_keypress, microsd_pat for obj in res: assert obj["success"], obj - for _ in range(4): + for _ in range(3): press_cancel() return bitcoind_wo @@ -622,7 +622,7 @@ def test_ccc_magnitude(mag_ok, mag, setup_ccc, ccc_ms_setup, settings_set("ccc", None) settings_set("chain", "XRT") - settings_set("multisig", []) + settings_set("miniscript", []) if mag_ok: # always try limit/border value @@ -660,7 +660,7 @@ def test_ccc_whitelist(whitelist_ok, setup_ccc, ccc_ms_setup, settings_set("ccc", None) settings_set("chain", "XRT") - settings_set("multisig", []) + settings_set("miniscript", []) whitelist = [ "bcrt1qqca9eefwz8tzn7rk6aumhwhapyf5vsrtrddxxp", @@ -696,7 +696,7 @@ def test_ccc_velocity(velocity_mi, setup_ccc, ccc_ms_setup, bitcoind, settings_s settings_set("ccc", None) settings_set("chain", "XRT") - settings_set("multisig", []) + settings_set("miniscript", []) blocks = int(velocity_mi.split()[0]) @@ -780,7 +780,7 @@ def test_ccc_warnings(setup_ccc, ccc_ms_setup, bitcoind, settings_set, policy_si settings_set("ccc", None) settings_set("chain", "XRT") - settings_set("multisig", []) + settings_set("miniscript", []) whitelist = ["bcrt1qlk39jrclgnawa42tvhu2n7se987qm96qg8v76e", "2Mxp1Dy2MyR4w36J2VaZhrFugNNFgh6LC1j", @@ -836,12 +836,12 @@ def test_ccc_warnings(setup_ccc, ccc_ms_setup, bitcoind, settings_set, policy_si def test_maxed_out(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim_exec, bitcoind, settings_get, load_export, press_cancel, restore_main_seed, bitcoind_create_watch_only_wallet, policy_sign, goto_eph_seed_menu, - pick_menu_item, word_menu_entry, press_select, import_multisig): + pick_menu_item, word_menu_entry, press_select, import_miniscript): # - maxed out values: 24 words, 25 whitelisted p2wsh values settings_set("ccc", None) settings_set("chain", "XRT") - settings_set("multisig", []) + settings_set("miniscript", []) # C mnemonic is 24 words c_words = "cluster comic depend absent grain circle demand tag pass clock certain strategy lunar bless pulse useful comfort fatigue glove decorate taste allow adult journey".split() @@ -862,8 +862,9 @@ def test_maxed_out(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim pick_menu_item(target_mi) # choose already created multisig - pick_menu_item("Coldcard Export") - ms_conf = load_export("sd", "Coldcard multisig setup", is_json=False) + pick_menu_item("Descriptors") + pick_menu_item("Export") + ms_conf = load_export("sd", "Miniscript", is_json=False) press_cancel() # fund CCC multisig @@ -884,7 +885,7 @@ def test_maxed_out(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim time.sleep(0.1) word_menu_entry(b_words) press_select() - import_multisig(data=ms_conf) + import_miniscript(data=ms_conf) press_select() # confirm multisig import # get rid of last violation - as it is held as global @@ -899,11 +900,11 @@ def test_maxed_out(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim def test_load_and_sign_key_C(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, sim_exec, bitcoind_create_watch_only_wallet, pick_menu_item, load_export, cap_story, press_cancel, bitcoind, policy_sign, restore_main_seed, - verify_ephemeral_secret_ui, word_menu_entry, import_multisig, + verify_ephemeral_secret_ui, word_menu_entry, import_miniscript, press_select, settings_get, seed_vault, confirm_tmp_seed): settings_set("ccc", None) settings_set("chain", "XRT") - settings_set("multisig", []) + settings_set("miniscript", []) settings_set("seedvault", int(seed_vault)) settings_set("seeds", []) @@ -912,8 +913,9 @@ def test_load_and_sign_key_C(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_ bitcoind_wo = bitcoind_create_watch_only_wallet(target_mi) pick_menu_item(target_mi) # choose already created multisig - pick_menu_item("Coldcard Export") - ms_conf = load_export("sd", "Coldcard multisig setup", is_json=False) + pick_menu_item("Descriptors") + pick_menu_item("Export") + ms_conf = load_export("sd", "Miniscript", is_json=False) press_cancel() # fund CCC multisig @@ -942,7 +944,7 @@ def test_load_and_sign_key_C(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_ confirm_tmp_seed(seedvault=seed_vault) verify_ephemeral_secret_ui(mnemonic=c_words.split(), seed_vault=seed_vault) - import_multisig(data=ms_conf) + import_miniscript(data=ms_conf) press_select() # confirm multisig import # get rid of last violation - as it is held as global @@ -974,7 +976,7 @@ def test_ccc_xpub_export(chain, c_num_words, acct, settings_set, load_export, se goto_home() settings_set("ccc", None) settings_set("chain", chain) - settings_set("multisig", []) + settings_set("miniscript", []) words = None if isinstance(c_num_words, int): @@ -1018,18 +1020,18 @@ def test_ccc_xpub_export(chain, c_num_words, acct, settings_set, load_export, se subkey = master.subkey_for_path(xpub_obj[l+"_deriv"]) xpub = subkey.hwif() assert slip132undo(xpub_obj[l])[0] == xpub - assert xpub in xpub_obj[l+"_desc"] + assert xpub in xpub_obj[l+"_key_exp"] def test_multiple_multisig_wallets(settings_set, setup_ccc, enter_enabled_ccc, ccc_ms_setup, bitcoind_create_watch_only_wallet, cap_story, bitcoind, policy_sign, settings_get, cap_menu, pick_menu_item, - press_select, load_export, offer_ms_import, goto_home): + press_select, load_export, offer_minsc_import, goto_home): # - 'build 2-of-N' path goto_home() settings_set("ccc", None) settings_set("chain", "XRT") - settings_set("multisig", []) + settings_set("miniscript", []) words = setup_ccc(c_words=None, mag=2, vel='6 blocks (hour)') b_keys_0, mi = ccc_ms_setup(N=5) @@ -1091,16 +1093,17 @@ def test_multiple_multisig_wallets(settings_set, setup_ccc, enter_enabled_ccc, c w_mn, w_name = ami.rsplit(" ", 1) new_name = "new" pick_menu_item(ami) # just another ms wallet - pick_menu_item("Coldcard Export") - ms_conf = load_export("sd", label="Coldcard multisig setup", is_json=False) + pick_menu_item("Descriptors") + pick_menu_item("Export") + ms_conf = load_export("sd", "Miniscript", is_json=False) # try importing duplicate does not work - _, story = offer_ms_import(ms_conf) - assert "Duplicate wallet" in story + _, story = offer_minsc_import(ms_conf) + assert "duplicate of already saved wallet" in story # try rename ms_conf = ms_conf.replace(w_name, new_name) - _, story = offer_ms_import(ms_conf) + _, story = offer_minsc_import(ms_conf) assert "Update NAME only of existing multisig wallet?" in story press_select() time.sleep(.1) @@ -1115,7 +1118,7 @@ def test_remove_ccc(settings_set, setup_ccc, ccc_ms_setup, settings_get, policy_ bitcoind_create_watch_only_wallet, bitcoind, goto_home): goto_home() settings_set("ccc", None) - settings_set("multisig", []) + settings_set("miniscript", []) setup_ccc(c_words=None, mag=2, vel='6 blocks (hour)') _, mi = ccc_ms_setup(N=3) @@ -1124,7 +1127,7 @@ def test_remove_ccc(settings_set, setup_ccc, ccc_ms_setup, settings_get, policy_ ccc_ms_setup(N=5) - assert len(settings_get("multisig")) == 2 + assert len(settings_get("miniscript")) == 2 pick_menu_item("Remove CCC") # start remove time.sleep(.1) @@ -1141,7 +1144,7 @@ def test_remove_ccc(settings_set, setup_ccc, ccc_ms_setup, settings_get, policy_ need_keypress("4") # multisig wallets are not impacted by removal of ccc - assert len(settings_get("multisig")) == 2 + assert len(settings_get("miniscript")) == 2 bitcoind.supply_wallet.sendtoaddress(address=w0.getnewaddress(), amount=5) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) @@ -1157,7 +1160,7 @@ def test_c_key_from_seed_vault(has_candidates, setup_ccc, build_test_seed_vault, cap_story, press_cancel, enter_enabled_ccc): goto_home() settings_set("ccc", None) - settings_set("multisig", []) + settings_set("miniscript", []) settings_set("seedvault", True) sv = build_test_seed_vault() @@ -1215,23 +1218,21 @@ def test_c_key_from_seed_vault(has_candidates, setup_ccc, build_test_seed_vault, @pytest.mark.parametrize("is_bbqr", [True, False]) @pytest.mark.parametrize("N", [3, 15]) def test_ms_setup_cosigner_import(way, ftype, is_bbqr, N, goto_home, settings_set, setup_ccc, - ccc_ms_setup, pick_menu_item, cap_story, is_q1): + ccc_ms_setup, pick_menu_item, is_q1, load_export): if ((way == "sd") and is_bbqr) or ((not is_q1) and (way == "qr")): pytest.skip("useless") goto_home() settings_set("ccc", None) - settings_set("multisig", []) + settings_set("miniscript", []) setup_ccc() keys, target_mi = ccc_ms_setup(N=N, way=way, ftype=ftype, bbqr=is_bbqr) pick_menu_item(target_mi) pick_menu_item("Descriptors") - pick_menu_item("View Descriptor") - time.sleep(.1) - _, story = cap_story() - desc = story.split("\n\n")[-1] + pick_menu_item("Export") + desc = load_export("sd", "Miniscript", is_json=False) for _, obj in keys: assert f"[{obj['xfp'].lower()}/{obj['p2wsh_deriv'].replace('m/', '')}]{obj['p2wsh']}" in desc diff --git a/testing/test_ephemeral.py b/testing/test_ephemeral.py index 82e85d75..5d31f3da 100644 --- a/testing/test_ephemeral.py +++ b/testing/test_ephemeral.py @@ -1252,7 +1252,7 @@ def test_add_current_active(reset_seed_words, settings_set, import_ephemeral_xpr press_cancel, verify_ephemeral_secret_ui, seed_vault_enable, refuse, press_select, set_bip39_pw, need_some_notes, need_some_passwords, import_ms_wallet, - restore_main_seed, settings_get, clear_ms): + restore_main_seed, settings_get, clear_miniscript): ADD_MI = "Add current tmp" reset_seed_words() @@ -1260,7 +1260,7 @@ def test_add_current_active(reset_seed_words, settings_set, import_ephemeral_xpr seed_vault_enable(True) # clear settings_set("seeds", []) - clear_ms() + clear_miniscript() settings_set("notes", []) if not refuse: @@ -1329,7 +1329,7 @@ def test_add_current_active(reset_seed_words, settings_set, import_ephemeral_xpr @pytest.mark.parametrize('data', SEEDVAULT_TEST_DATA) def test_temporary_from_backup(multisig, backup_system, import_ms_wallet, get_setting, data, press_select, cap_story, set_encoded_secret, - reset_seed_words, check_and_decrypt_backup, clear_ms, + reset_seed_words, check_and_decrypt_backup, clear_miniscript, goto_eph_seed_menu, pick_menu_item, word_menu_entry, verify_ephemeral_secret_ui, seedvault, settings_set, seed_vault_enable, confirm_tmp_seed, set_seed_words, @@ -1343,7 +1343,7 @@ def test_temporary_from_backup(multisig, backup_system, import_ms_wallet, get_se set_encoded_secret(encoded) settings_set("chain", "XTN") - clear_ms() + clear_miniscript() if multisig: import_ms_wallet(15, 15, dev_key=True) diff --git a/testing/test_miniscript.py b/testing/test_miniscript.py index a0e211ee..09d45679 100644 --- a/testing/test_miniscript.py +++ b/testing/test_miniscript.py @@ -55,63 +55,104 @@ def offer_minsc_import(cap_story, dev, sim_root_dir): @pytest.fixture -def import_miniscript(goto_home, pick_menu_item, cap_story, need_keypress, - nfc_write_text, press_select, scan_a_qr, press_nfc): - def doit(fname, way="sd", data=None): - goto_home() - pick_menu_item('Settings') - pick_menu_item('Miniscript') - pick_menu_item('Import') - time.sleep(.3) - _, story = cap_story() - if way == "nfc": - if "via NFC" not in story: - pytest.skip("nfc disabled") +def import_miniscript(request, is_q1, need_keypress, offer_minsc_import, press_cancel): + def doit(fname=None, way="sd", data=None, name=None): + assert fname or data - press_nfc() - time.sleep(.1) - if isinstance(data, dict): - data = json.dumps(data) - nfc_write_text(data) - time.sleep(1) - return cap_story() - elif way == "qr": - if isinstance(data, dict): - data = json.dumps(data) - - need_keypress(KEY_QR) - try: - scan_a_qr(data) - except: - # always as text - even if it is json - actual_vers, parts = split_qrs(data, 'U', max_version=20) - random.shuffle(parts) - - for p in parts: - scan_a_qr(p) - time.sleep(1) # just so we can watch - - time.sleep(1) - return cap_story() - - if "Press (1) to import miniscript wallet file from SD Card" in story: - # in case Vdisk or NFC is enabled + if fname: if way == "sd": - need_keypress("1") + 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 - elif way == "vdisk": - if "ress (2)" not in story: + if way in ("usb", None): + return offer_minsc_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') + press_nfc = request.getfixturevalue('press_nfc') + 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("Miniscript") + time.sleep(.1) + + pick_menu_item('Import') + time.sleep(.2) + _, story = cap_story() + if way == "nfc": + if "via NFC" not in story: + press_cancel() + pytest.skip("nfc disabled") + + press_nfc() + time.sleep(.1) + if isinstance(config, dict): + config = json.dumps(config) + + nfc_write_text = request.getfixturevalue('nfc_write_text') + nfc_write_text(config) + time.sleep(1) + return cap_story() + elif way == "qr": + scan_a_qr = request.getfixturevalue('scan_a_qr') + if isinstance(data, dict): + data = json.dumps(data) + + need_keypress(KEY_QR) + try: + scan_a_qr(data) + except: + # always as text - even if it is json + actual_vers, parts = split_qrs(data, 'U', max_version=20) + random.shuffle(parts) + + for p in parts: + scan_a_qr(p) + time.sleep(1) # just so we can watch + + time.sleep(1) + return cap_story() + + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk or NFC is enabled + if way == "sd": + need_keypress("1") + + elif way == "vdisk": + if "ress (2)" not in story: + press_cancel() + pytest.xfail(way) + + need_keypress("2") + else: + if way != "sd": pytest.xfail(way) - need_keypress("2") - else: - if way != "sd": - pytest.xfail(way) + if not fname: + microsd_path = request.getfixturevalue("microsd_path") + virtdisk_path = request.getfixturevalue("virtdisk_path") + path_f = microsd_path if way == "sd" else virtdisk_path + fname = (name or "ms_wal") + ".txt" + with open(path_f(fname), "w") as f: + f.write(config) - time.sleep(.5) - pick_menu_item(fname) - time.sleep(.1) - return cap_story() + time.sleep(.3) + pick_menu_item(fname) + time.sleep(.1) + return cap_story() return doit @@ -1983,12 +2024,10 @@ def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, ca "or_d(pk(@A),and_v(v:pkh(@B),after(100)))", "or_d(multi(2,@A,@C),and_v(v:pkh(@B),after(100)))", ]) -def test_import_same_policy_same_keys_diff_order(taproot_ikspendable, minisc, - clear_miniscript, use_regtest, - get_cc_key, bitcoin_core_signer, - offer_minsc_import, cap_menu, - bitcoind, pick_menu_item, - press_select): +def test_import_same_policy_same_keys_diff_order(taproot_ikspendable, minisc, use_regtest, + clear_miniscript, bitcoin_core_signer, + get_cc_key, settings_get, cap_menu, + offer_minsc_import, bitcoind, press_select): use_regtest() clear_miniscript() taproot, ik_spendable = taproot_ikspendable @@ -2032,21 +2071,15 @@ def test_import_same_policy_same_keys_diff_order(taproot_ikspendable, minisc, title, story = offer_minsc_import(desc1) assert "Create new miniscript wallet?" in story press_select() - pick_menu_item("Settings") - pick_menu_item("Miniscript") - m = cap_menu() - m = [i for i in m if not i.startswith("Import")] - assert len(m) == 2 + assert len(settings_get("miniscript", [])) == 2 @pytest.mark.parametrize("cs", [True, False]) @pytest.mark.parametrize("way", ["usb", "nfc", "sd", "vdisk"]) -def test_import_miniscript_usb_json(use_regtest, cs, way, cap_menu, - clear_miniscript, pick_menu_item, - get_cc_key, bitcoin_core_signer, - offer_minsc_import, bitcoind, microsd_path, - virtdisk_path, import_miniscript, goto_home, - press_select): +def test_import_miniscript_usb_json(use_regtest, cs, way, cap_menu, clear_miniscript, get_cc_key, + bitcoin_core_signer, offer_minsc_import, bitcoind, microsd_path, + virtdisk_path, import_miniscript, goto_home, press_select, + settings_get): name = "my_minisc" minsc = f"tr({ranged_unspendable_internal_key()},or_d(multi_a(2,@A,@C),and_v(v:pkh(@B),after(100))))" use_regtest() @@ -2089,13 +2122,9 @@ def test_import_miniscript_usb_json(use_regtest, cs, way, cap_menu, assert name in story press_select() time.sleep(.2) - goto_home() - pick_menu_item("Settings") - pick_menu_item("Miniscript") - m = cap_menu() - m = [i for i in m if not i.startswith("Import")] - assert len(m) == 1 - assert m[0] == name + msc = settings_get("miniscript", []) + assert len(msc) == 1 + assert msc[0][0] == name @pytest.mark.parametrize("config", [ @@ -2140,9 +2169,8 @@ def test_unique_name(clear_miniscript, use_regtest, offer_minsc_import, pick_menu_item("Settings") pick_menu_item("Miniscript") m = cap_menu() - m = [i for i in m if not i.startswith("Import")] - assert len(m) == 1 assert m[0] == name + assert m[1] == "Import" # completely different wallet but with the same name (USB) yd = json.dumps({"name": name, "desc": y}) @@ -2154,9 +2182,8 @@ def test_unique_name(clear_miniscript, use_regtest, offer_minsc_import, pick_menu_item("Settings") pick_menu_item("Miniscript") m = cap_menu() - m = [i for i in m if not i.startswith("Import")] - assert len(m) == 1 assert m[0] == name + assert m[1] == "Import" goto_home() fname = f"{name}.txt" diff --git a/testing/test_multisig.py b/testing/test_multisig.py index f007eab8..bceefed2 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -84,13 +84,6 @@ def bitcoind_p2sh(bitcoind): 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 @@ -140,196 +133,64 @@ def make_multisig(dev, sim_execfile): 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): - 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) - - 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') - - if not fname: - fname = (name or "ms_wal.txt") + ".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(.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 import_ms_wallet(dev, make_multisig, offer_minsc_import, press_select, + is_q1, request, need_keypress, usb_miniscript_get, + settings_set, sim_root_dir, import_miniscript): def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, - keys=None, do_import=True, derivs=None, descriptor=False, + keys=None, do_import=True, derivs=None, int_ext_desc=False, dev_key=False, way=None, bip67=True, - chain="XTN", return_desc=False): - # param: bip67 if false, only usable together with descriptor=True - if not bip67: - assert descriptor, "needs descriptor=True" + chain="XTN"): keys = keys or make_multisig(M, N, unique=unique, dev_key=dev_key, deriv=common or (derivs[0] if derivs else None), netcode=chain) - name = name or f'test-{M}-{N}' + + if addr_fmt is None: + addr_fmt = "p2wsh" + + 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: + config = desc.serialize(int_ext=True) + else: + config = desc.serialize() + + if name: + config = json.dumps({"name": name, "desc": config}) if not do_import: - return keys + return keys, config - 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: - 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) + title, story = import_miniscript(data=config, way=way) - assert 'Create new multisig' in story \ + assert 'Create new miniscript wallet' 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 addr_fmt.upper() in story assert f'Policy: {M} of {N}\n' in story + name = story.split("\n\n")[1].split("\n")[-1].strip() 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 + # below raises if miniscript wallet not enrolled + usb_miniscript_get(name) return keys @@ -337,63 +198,39 @@ def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select, @pytest.mark.parametrize('N', [ 3, 15]) -def test_ms_import_variations(N, make_multisig, offer_ms_import, press_cancel, is_q1): +def test_ms_import_variations(N, offer_minsc_import, press_cancel, is_q1, get_cc_key): # all the different ways... - keys = make_multisig(N, N) - + my_key = get_cc_key(path="").replace("/<0;1>/*", "") + keys = [BIP32Node.from_master_secret(os.urandom(32), "XTN").hwif() for _ in range(N-1)] + keys = [my_key] + keys # 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) + k0 = ','.join(keys) + title, story = offer_minsc_import(f"sh(multi({N},{k0}))") 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) - + k1 = ','.join(keys[1:]) with pytest.raises(BaseException) as ee: - title, story = offer_ms_import(config) - assert 'my key not included' in str(ee.value) - + title, story = offer_minsc_import(f"wsh(sortedmulti({N-1},{k1}))") + assert "My key 0F056943 missing in descriptor" in str(ee.value) + desc0 = f"wsh(sortedmulti({N},{k0}))" # 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) + title, story = offer_minsc_import(json.dumps({"name": name, "desc": desc0})) 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) + name = 'A' * 21 with pytest.raises(BaseException) as ee: - title, story = offer_ms_import(config) - assert '20 long' in str(ee.value) + title, story = offer_minsc_import(json.dumps({"name": name, "desc": desc0})) + assert 'name > 20' 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(): - if af == "p2tr": continue - 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, @@ -466,7 +303,7 @@ 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] + make_redeem_args['path_mapper'] = lambda cosigner: [HARD(45), is_change, idx] script, pubkeys, xfp_paths = make_redeem(M, keys, bip67=bip67, **make_redeem_args) @@ -493,47 +330,27 @@ def make_ms_address(M, keys, idx=0, is_change=0, addr_fmt=AF_P2SH, testnet=1, @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, chain="XTN", **make_redeem_args): +def test_ms_show_addr(dev, cap_story, press_select, bitcoind, is_q1, + usb_miniscript_addr, usb_miniscript_get): + def doit(name, idx=0, change=False): # 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 - ) + got_addr = usb_miniscript_addr(name, idx, change) 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 + assert got_addr == addr_from_display_format(story.split("\n\n")[0]) press_select() - # check expected addr was generated based on my math - addr_vs_path(got_addr, addr_fmt=addr_fmt, script=scr, chain=chain) - - # also check against bitcoind - core_addr, core_scr = bitcoind_p2sh(M, pubkeys, addr_fmt) - assert B2A(scr) == core_scr - assert core_addr == got_addr + # check against bitcoind + desc_obj = usb_miniscript_get(name) + ext_a, int_a = bitcoind.supply_wallet.deriveaddresses(desc_obj["desc"], [idx, idx]) + if change: + assert int_a[0] == got_addr + else: + assert ext_a[0] == got_addr return doit @@ -541,39 +358,35 @@ def test_ms_show_addr(dev, cap_story, press_select, addr_vs_path, bitcoind_p2sh, @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): +def test_import_ranges(m_of_n, use_regtest, addr_fmt, clear_miniscript, import_ms_wallet, + usb_miniscript_addr, test_ms_show_addr): use_regtest() M, N = m_of_n - keys = import_ms_wallet(M, N, addr_fmt, accept=1) + wname = "my_rand_wal" + import_ms_wallet(M, N, addr_fmt, name=wname, accept=True) #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, chain="XRT") + test_ms_show_addr(wname) finally: - clear_ms() + clear_miniscript() @pytest.mark.bitcoind @pytest.mark.ms_danger -def test_violate_bip67(clear_ms, use_regtest, import_ms_wallet, +def test_violate_bip67(clear_miniscript, 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() + clear_miniscript() 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], @@ -584,20 +397,17 @@ def test_violate_bip67(clear_ms, use_regtest, import_ms_wallet, with pytest.raises(Exception) as e: try_sign(psbt) - assert 'BIP-67' in e.value.args[0] + assert 'spk mismatch' 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, +def test_violate_import_order_multi(has_change, clear_miniscript, import_ms_wallet, fake_ms_txn, try_sign, test_ms_show_addr, sim_root_dir): - clear_ms() + clear_miniscript() M, N = 3, 5 - keys = import_ms_wallet(M, N, accept=True, descriptor=True, bip67=False) + keys = import_ms_wallet(M, N, accept=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 [], @@ -608,132 +418,148 @@ def test_violate_import_order_multi(has_change, clear_ms, import_ms_wallet, 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() + assert "spk mismatch" in e.value.args[0] @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 +@pytest.mark.parametrize('desc_type', ['multi', 'sortedmulti' ]) +def test_zero_depth(dev, clear_miniscript, use_regtest, addr_fmt, offer_minsc_import, + make_multisig, bitcoind, desc_type, settings_set, press_select, + goto_home, pick_menu_item, load_export, goto_address_explorer, + cap_story, need_keypress, try_sign): - M, N = 1, 2 - keys = make_multisig(M, N, unique=99) + settings_set("chain", "XRT") + ms_name = "zero_depth" + clear_miniscript() + bitcoind.delete_wallet_files(pattern="zero_depth_s") + bitcoind.delete_wallet_files(pattern="zero_depth_wo") + # create multiple bitcoin wallets (N-1) as one signer is CC + cosig = bitcoind.create_wallet(wallet_name="zero_depth_s", disable_private_keys=False, + blank=False, passphrase=None, avoid_reuse=False, + descriptors=True) + cosig.keypoolrefill(100) + descs = cosig.listdescriptors()["descriptors"] + target_desc = None + for desc in descs: + if desc["desc"].startswith("wpkh(") and desc["internal"] is False: + target_desc = desc["desc"] + core_desc, checksum = target_desc.split("#") + # remove wpkh(....) + core_key = core_desc[5:-1] + my_master_xpub = dev.send_recv(CCProtocolPacker.get_xpub("m"), timeout=None) + my_xfp = dev.master_fingerprint + my_xfp = xfp2str(my_xfp).lower() # if any letters - lower them + my_data = f"[{my_xfp}]{my_master_xpub}/0/*" + # watch only wallet where multisig descriptor will be imported + wo = bitcoind.create_wallet( + wallet_name="zero_depth_wo", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) - # 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) + if addr_fmt == 'p2wsh': + tmplt = "wsh(%s)" + af = "bech32" + elif addr_fmt == "p2sh-p2wsh": + tmplt = "sh(wsh(%s))" + af = "p2sh-segwit" + else: + assert addr_fmt == "p2sh" + tmplt = "sh(%s)" + af = "legacy" - 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] + inner = "%s(2,%s)" % (desc_type, ",".join([core_key, my_data])) + desc = tmplt % inner + desc_info = wo.getdescriptorinfo(desc) + desc_w_checksum = desc_info["descriptor"] # with checksum + + + title, story = offer_minsc_import(json.dumps({"desc": desc_w_checksum, "name": ms_name})) + assert "Create new miniscript wallet?" in story + press_select() + + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item(ms_name) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", 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 = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"], obj + + goto_address_explorer() + pick_menu_item(ms_name) + time.sleep(.1) + _, story = cap_story() + ea = [i.replace("\x02", "") for i in story.split("\n") if i and i.startswith("\x02")] + need_keypress("0") # change + time.sleep(.1) + _, story = cap_story() + ia = [i.replace("\x02", "") for i in story.split("\n") if i and i.startswith("\x02")] + + # check both external and internal + eabc, iabc = wo.deriveaddresses(core_desc_object[0]["desc"], [0, 9]) + for i in range(10): + assert eabc[i] == ea[i] + assert iabc[i] == ia[i] + + multi_addr = wo.getnewaddress("", af) + dest_addr = bitcoind.supply_wallet.getnewaddress("") + bitcoind.supply_wallet.sendtoaddress(multi_addr, 2) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress("")) + # create funded PSBT + psbt_resp = wo.walletcreatefundedpsbt([], [{dest_addr: 1.2}], 0, + {"fee_rate": 2, "change_type": af}) + psbt = psbt_resp.get("psbt") + + _, updated = try_sign(base64.b64decode(psbt), finalize=False) + signed = cosig.walletprocesspsbt(b64encode(updated).decode('ascii'), True, "ALL")["psbt"] + + # finalize and send + rr = bitcoind.supply_wallet.finalizepsbt(signed, True) + 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 - 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, +def test_bad_common_prefix(use_regtest, clear_miniscript, import_ms_wallet, test_ms_show_addr): - # give some incorrect path values as the common prefix derivation - + # assuming MAX_PATH_DEPTH==12 + cpp = "m/1/2/3/4/5/6/7/8/9/10/11/12/13" + clear_miniscript() 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) + keys = import_ms_wallet(M, N, accept=True, common=cpp) + import pdb;pdb.set_trace() + assert 'origin too deep' in str(ee) @pytest.mark.parametrize("desc", ["multi", "sortedmulti"]) -def test_import_detail(desc, clear_ms, import_ms_wallet, need_keypress, +def test_import_detail(desc, clear_miniscript, 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) + keys = import_ms_wallet(M, N, bip67=bip67) time.sleep(.2) title, story = cap_story() assert f'{M} of {N}' in story + + # TODO emitting no warning here if desc == "multi": assert "WARNING" in story assert "BIP-67 disabled" in story @@ -741,16 +567,12 @@ def test_import_detail(desc, clear_ms, import_ms_wallet, need_keypress, assert "WARNING" not in story assert "BIP-67 disabled" not in story + assert f'{M} of {N}' 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 @@ -775,7 +597,7 @@ def test_export_airgap(acct_num, goto_home, cap_story, pick_menu_item, cap_menu, goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') + pick_menu_item('Miniscript') pick_menu_item('Export XPUB') time.sleep(.1) @@ -812,6 +634,7 @@ def test_export_airgap(acct_num, goto_home, cap_story, pick_menu_item, cap_menu, expect = e.subkey_for_path("m/45'") assert expect.hwif() == n.hwif() + assert rv["p2sh_key_exp"] == f"[{rv['xfp']}/45h]{n.hwif()}" for name, deriv in [ ('p2sh_p2wsh', f"m/48h/{int(testnet)}h/{acct_num}h/1h"), @@ -827,35 +650,36 @@ def test_export_airgap(acct_num, goto_home, cap_story, pick_menu_item, cap_menu, assert n.node.index & 0xff == int(deriv[-2]) expect = e.subkey_for_path(deriv) assert expect.hwif() == n.hwif() + assert rv[name+"_key_exp"] == f"[{rv['xfp']}/{deriv.replace('m/', '')}]{n.hwif()}" - # TODO add tests for descriptor template @pytest.mark.parametrize('N', [ 3, 15]) @pytest.mark.parametrize('vdisk', [True, False]) def test_import_ux(N, vdisk, goto_home, cap_story, pick_menu_item, - need_keypress, microsd_path, make_multisig, + need_keypress, microsd_path, get_cc_key, virtdisk_path, is_q1, press_cancel, press_select): # test menu-based UX for importing wallet file from SD M = N-1 - keys = make_multisig(M, N) + keys = [BIP32Node.from_master_secret(os.urandom(32)).hwif() for _ in range(M)] + keys.append(get_cc_key("", "")) name = 'named-%d' % random.randint(10000,99999) - config = f'policy: {M} of {N}\n' - config += '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) + config = {"name": name, "desc": f"wsh(sortedmulti({M},{','.join(keys)}))"} if vdisk: fname = virtdisk_path(f'ms-{name}.txt') else: fname = microsd_path(f'ms-{name}.txt') + with open(fname, 'wt') as fp: - fp.write(config) + fp.write(json.dumps(config)) try: goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') - pick_menu_item('Import from File') - time.sleep(0.5) + pick_menu_item('Miniscript') + pick_menu_item('Import') + time.sleep(0.1) _, story = cap_story() if vdisk: if "(2) to import from Virtual Disk" not in story: @@ -863,7 +687,7 @@ def test_import_ux(N, vdisk, goto_home, cap_story, pick_menu_item, else: need_keypress("2") else: - if "(1) to import multisig wallet file from SD Card" in story: + if "(1) to import miniscript wallet file from SD Card" in story: need_keypress("1") time.sleep(.1) @@ -872,7 +696,7 @@ def test_import_ux(N, vdisk, goto_home, cap_story, pick_menu_item, time.sleep(.1) _, story = cap_story() - assert 'Create new multisig' in story + assert 'Create new miniscript' in story assert name in story, 'didnt infer wallet name from filename' assert f'Policy: {M} of {N}\n' in story @@ -884,107 +708,16 @@ def test_import_ux(N, vdisk, goto_home, cap_story, pick_menu_item, try: os.unlink(fname) except: pass -@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, - microsd_path, import_ms_wallet, addr_fmt, clear_ms, way, load_export, is_q1, - press_cancel): - - # create a wallet, export to SD card, check file created. - # - checks some values for derivation path, assuming MAX_PATH_DEPTH==12 - - clear_ms() - - name = 'ex-test-%d' % random.randint(10000,99999) - 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') - pick_menu_item('Multisig Wallets') - - menu = cap_menu() - item = [i for i in menu if name in i][0] - pick_menu_item(item) - - pick_menu_item('Coldcard Export') - contents = load_export(way or "sd", label="Coldcard multisig setup", is_json=False) - if way == "qr": - # QR code still displayed on screen - press_select() - - got = set() - for ln in io.StringIO(contents).readlines(): - ln = ln.strip() - if '#' in ln: - assert ln[0] == '#' - continue - if not ln: - continue - - assert ':' in ln - label, value = ln.split(': ') - - if label == 'Name': - assert value == name - got.add(label) - elif label == 'Policy': - assert value == f'{M} of {N}' - got.add(label) - elif label == 'Derivation': - assert value == (comm_prefix or "m/45h") - got.add(label) - elif label == 'Format': - assert value == addr_fmt.upper() - assert addr_fmt != 'p2sh' - got.add(label) - else: - assert len(label) == 8, label - xfp = swab32(int(label, 16)) - got.add(xfp) - assert xfp in [x for x,_,_ in keys] - n = BIP32Node.from_wallet_key(value) - - if 'Format' not in got: - assert addr_fmt == 'p2sh' - got.add('Format') - - assert len(got) == 4 + N - - # test delete while we're here - time.sleep(.1) - press_cancel() - if way in ("sd", None, "vdisk"): - press_cancel() - pick_menu_item('Delete') - - time.sleep(.2) - title, story = cap_story() - where = title if is_q1 else story - assert 'you SURE' in where - assert name in story - - press_select() - time.sleep(.1) - menu = cap_menu() - assert not [i for i in menu if name in i] - assert '(none setup yet)' in menu - @pytest.mark.parametrize('N', [ 3, 15]) -def test_overflow(N, import_ms_wallet, clear_ms, press_select, cap_story, mk_num, is_q1): +def test_overflow(N, import_ms_wallet, clear_miniscript, press_select, cap_story, mk_num, is_q1): - clear_ms() + clear_miniscript() M = N - name = 'a'*20 # longest possible + name = 'a'*19 # longest possible for count in range(1, 10): - keys = import_ms_wallet(M, N, name=name, addr_fmt='p2wsh', unique=count, accept=0, - common="m/45h/0h/34h") - - time.sleep(.1) - press_select() + keys = import_ms_wallet(M, N, name=f"{name}{count}", addr_fmt='p2wsh', unique=count, + accept=True, common="m/45h/0h/34h") time.sleep(.2) title, story = cap_story() @@ -994,79 +727,50 @@ def test_overflow(N, import_ms_wallet, clear_ms, press_select, cap_story, mk_num assert 'No space left' in story break - if mk_num >= 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" + assert count == 9 # unlimited now press_select() - clear_ms() + clear_miniscript() -@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, +def test_import_dup_safe(N, clear_miniscript, make_multisig, offer_minsc_import, need_keypress, cap_story, goto_home, pick_menu_item, - cap_menu, is_q1, press_select, OK): + cap_menu, is_q1, press_select, OK, settings_get): # import wallet, rename it, (check that indicated, works), attempt same w/ addr fmt different M = N - clear_ms() + clear_miniscript() 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 make_named(name, af='sh', m=M): + k = ','.join('[%s/45h]%s' % (xfp2str(xfp), sk.hwif()) for xfp, m, sk in keys) + desc_obj = {"name": name, "desc": f"{af}(sortedmulti({m},{k}))"} + return json.dumps(desc_obj) 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') + pick_menu_item('Miniscript') menu = cap_menu() - assert f'{M}/{N}: {name}' in menu - # depending if NFC enabled or not, and if Q (has QR) or whether EDGE - assert (len(menu) - num_wallets) in [6, 7, 8, 9] + assert name in menu + assert len(settings_get("miniscript")) == num_wallets - title, story = offer_ms_import(make_named('xxx-orig')) - assert 'Create new multisig wallet' in story + import pdb;pdb.set_trace() + title, story = offer_minsc_import(make_named('xxx-orig')) + assert 'Create new miniscript 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')) + title, story = offer_minsc_import(make_named('xxx-new')) assert 'update name only' in story.lower() assert 'xxx-new' in story @@ -1075,8 +779,8 @@ def test_import_dup_safe(N, clear_ms, make_multisig, offer_ms_import, assert N < 15, 'cant make more, no space' - newer = make_named('xxx-newer', 'p2wsh') - title, story = offer_ms_import(newer) + newer = make_named('xxx-newer', 'wsh') + title, story = offer_minsc_import(newer) assert 'update name only' not in story.lower() assert 'address type' in story.lower() assert 'will NOT replace it' in story @@ -1091,7 +795,7 @@ def test_import_dup_safe(N, clear_ms, make_multisig, offer_ms_import, # TODO # repeat last one, should still be two for keys in ['yn', 'n']: - title, story = offer_ms_import(newer) + title, story = offer_minsc_import(newer) assert 'Duplicate wallet' in story assert f'{OK} to approve' not in story assert 'xxx-newer' in story @@ -1101,14 +805,14 @@ def test_import_dup_safe(N, clear_ms, make_multisig, offer_ms_import, has_name('xxx-newer', 2) - clear_ms() + clear_miniscript() @pytest.mark.parametrize('N', [ 5]) -def test_import_dup_diff_xpub(N, clear_ms, make_multisig, offer_ms_import, +def test_import_dup_diff_xpub(N, clear_miniscript, 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() + clear_miniscript() M = N keys = make_multisig(M, N) @@ -1140,13 +844,13 @@ def test_import_dup_diff_xpub(N, clear_ms, make_multisig, offer_ms_import, assert 'xxx-new' in story assert 'xpubs' in story - clear_ms() + clear_miniscript() @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, +def test_import_dup_xfp_fails(m_of_n, use_regtest, addr_fmt, clear_miniscript, make_multisig, import_ms_wallet, test_ms_show_addr): M, N = m_of_n @@ -1165,39 +869,13 @@ def test_import_dup_xfp_fails(m_of_n, use_regtest, addr_fmt, clear_ms, #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, +def make_myself_wallet(dev, set_bip39_pw, offer_minsc_import, press_select, clear_miniscript, 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): + def doit(M, addr_fmt="p2wsh", do_import=True, desc="sortedmulti"): passwords = ['Me', 'Myself', 'And I', ''] @@ -1230,16 +908,22 @@ def make_myself_wallet(dev, set_bip39_pw, offer_ms_import, press_select, clear_m if do_import: # render as a file for import - config = f"name: Myself-{M}\npolicy: {M} / 4\n\n" + msc = {"name": f"Myself-{M}"} + kk = ','.join('[%s/45h]%s' % (xfp2str(xfp), sk.hwif()) for xfp, _, sk in keys) - if addr_fmt: - config += f'format: {addr_fmt.upper()}\n' + if addr_fmt == "p2wsh": + d = f"wsh({desc}({M},{kk}))" + elif addr_fmt == "p2sh-p2wsh": + d = f"sh(wsh({desc}({M},{kk})))" + elif addr_fmt == "p2sh": + d = f"sh({desc}({M},{kk}))" + else: + raise ValueError("Unknown address format: " + addr_fmt) - config += '\n'.join('%s: %s' % (xfp2str(xfp), sk.hwif()) for xfp, _, sk in keys) - #print(config) - - title, story = offer_ms_import(config) - #print(story) + msc["desc"] = d + config = json.dumps(msc) + title, story = offer_minsc_import(config) + assert "Create new miniscript wallet" in story # don't care if update or create; accept it. time.sleep(.1) @@ -1250,7 +934,7 @@ def make_myself_wallet(dev, set_bip39_pw, offer_ms_import, press_select, clear_m print(f"--- switch to another leg of MS: {idx} ---") xfp = set_bip39_pw(passwords[idx]) if do_import: - offer_ms_import(config) + offer_minsc_import(config) time.sleep(.1) press_select() assert xfp == keys[idx][0] @@ -1269,10 +953,10 @@ def fake_ms_txn(pytestconfig): # - but has UTXO's to match needs from struct import pack - def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None, segwit_in=False, + def doit(num_ins, num_outs, M, keys, fee=10000, outvals=None, inp_addr_fmt="p2wsh", 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): + violate_script_key_order=False, path_mapper=None, netcode="XTN"): psbt = BasicPSBT() if psbt_v2 is None: @@ -1303,14 +987,25 @@ def fake_ms_txn(pytestconfig): psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)] psbt.outputs = [BasicPSBTOutput(idx=i) for i in range(num_outs)] + if netcode == "XTN": + net = 1 + elif netcode == "XRT": + net = 2 + else: + net = 0 + + af = unmap_addr_fmt[inp_addr_fmt] + segwit_in = af in [AF_P2WSH, AF_P2WSH_P2SH] for i in range(num_ins): # make a fake txn to supply each of the inputs # - each input is 1BTC - # addr where the fake money will be stored. addr, scriptPubKey, script, details = make_ms_address(M, keys, idx=i, bip67=bip67, - violate_script_key_order=violate_script_key_order, path_mapper=path_mapper) + violate_script_key_order=violate_script_key_order, path_mapper=path_mapper, + addr_fmt=af, + testnet=net) + print(i, script.hex()) # lots of supporting details needed for p2sh inputs if segwit_in: psbt.inputs[i].witness_script = script @@ -1410,38 +1105,39 @@ def fake_ms_txn(pytestconfig): @pytest.mark.veryslow @pytest.mark.unfinalized -@pytest.mark.parametrize('addr_fmt', [AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH]) +@pytest.mark.parametrize('addr_fmt', ["p2wsh", "p2sh-p2wsh", "p2sh"]) @pytest.mark.parametrize('num_ins', [2, 15]) -@pytest.mark.parametrize('incl_xpubs', [False, True, 'no-import']) +# @pytest.mark.parametrize('incl_xpubs', [False, True, 'no-import']) # TODO not implemented yet @pytest.mark.parametrize('transport', ['usb', 'sd']) @pytest.mark.parametrize('has_change', [True, False]) @pytest.mark.parametrize('M_N', [(2, 3), (5, 15)]) -@pytest.mark.parametrize('desc', ["multi", "sortedmulti"]) -def test_ms_sign_simple(M_N, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, import_ms_wallet, +@pytest.mark.parametrize('desc', ["sortedmulti", "multi"]) +def test_ms_sign_simple(M_N, num_ins, dev, addr_fmt, clear_miniscript, import_ms_wallet, addr_vs_path, fake_ms_txn, try_sign, try_sign_microsd, transport, has_change, settings_set, desc, sim_root_dir): M, N = M_N num_outs = num_ins-1 - descriptor, bip67 = (True, False) if desc == "multi" else (False, True) + bip67 = False if desc == "multi" else True - # trust PSBT if we're doing "no-import" case - settings_set('pms', 2 if (incl_xpubs == 'no-import') else 0) + # TODO + # # trust PSBT if we're doing "no-import" case + # settings_set('pms', 2 if (incl_xpubs == 'no-import') else 0) - clear_ms() + clear_miniscript() - if incl_xpubs != "no-import": - do_import = True - else: - do_import = False - if not bip67: - raise pytest.skip("cannot import unsorted multisig from PSBT") + # if incl_xpubs != "no-import": + # do_import = True + # else: + # do_import = False + # if not bip67: + # raise pytest.skip("cannot import unsorted multisig from PSBT") - keys = import_ms_wallet(M, N, name='cli-test', accept=True, addr_fmt=addr_fmt, - do_import=do_import, descriptor=descriptor, bip67=bip67) + keys = import_ms_wallet(M, N, name='ms-sign-simple', accept=True, addr_fmt=addr_fmt, + do_import=True, bip67=bip67) - psbt = fake_ms_txn(num_ins, num_outs, M, keys, incl_xpubs=incl_xpubs, - outstyles=ADDR_STYLES_MS, change_outputs=[1] if has_change else [], - bip67=bip67) + psbt = fake_ms_txn(num_ins, num_outs, M, keys, inp_addr_fmt=addr_fmt, #incl_xpubs=incl_xpubs, + outstyles=[addr_fmt], change_outputs=[1] if has_change else [], + bip67=bip67, netcode="XRT") with open(f'{sim_root_dir}/debug/last.psbt', 'wb') as f: f.write(psbt) @@ -1454,33 +1150,33 @@ def test_ms_sign_simple(M_N, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, impor @pytest.mark.unfinalized @pytest.mark.bitcoind @pytest.mark.parametrize('num_ins', [ 15 ]) -@pytest.mark.parametrize('M', [ 2, 4, 1]) -@pytest.mark.parametrize('segwit', [True, False]) -@pytest.mark.parametrize('incl_xpubs', [ True, False ]) -def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev, clear_ms, - fake_ms_txn, try_sign, incl_xpubs, bitcoind, sim_root_dir): +@pytest.mark.parametrize('M', [ 2, 4]) +@pytest.mark.parametrize('inp_af', ["p2wsh", "p2sh-p2wsh", "p2sh"]) +# @pytest.mark.parametrize('incl_xpubs', [ True, False ]) # TODO +def test_ms_sign_myself(M, use_regtest, make_myself_wallet, inp_af, num_ins, dev, + clear_miniscript, fake_ms_txn, try_sign, bitcoind, sim_root_dir): - # IMPORTANT: wont work if you start simulator with --ms flag. Use no args + # IMPORTANT: won't work if you start simulator with --ms flag. Use no args all_out_styles = [af for af in unmap_addr_fmt.keys() if af != "p2tr"] num_outs = len(all_out_styles) - clear_ms() + clear_miniscript() use_regtest() # create a wallet, with 3 bip39 pw's - keys, select_wallet = make_myself_wallet(M, do_import=(not incl_xpubs)) + keys, select_wallet = make_myself_wallet(M, addr_fmt=inp_af, do_import=True) #do_import=(not incl_xpubs)) N = len(keys) assert M<=N - psbt = fake_ms_txn(num_ins, num_outs, M, keys, segwit_in=segwit, incl_xpubs=incl_xpubs, - outstyles=all_out_styles, change_outputs=list(range(1,num_outs))) + psbt = fake_ms_txn(num_ins, num_outs, M, keys, inp_addr_fmt=inp_af, #incl_xpubs=incl_xpubs, + outstyles=[inp_af], change_outputs=list(range(1,num_outs))) with open(f'{sim_root_dir}/debug/myself-before.psbt', 'w') as f: f.write(b64encode(psbt).decode()) for idx in range(M): select_wallet(idx) - _, updated = try_sign(psbt, accept_ms_import=incl_xpubs) + _, updated = try_sign(psbt) #, accept_ms_import=incl_xpubs) with open(f'{sim_root_dir}/debug/myself-after.psbt', 'w') as f: f.write(b64encode(updated).decode()) assert updated != psbt @@ -1493,38 +1189,9 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev # should be fully signed now. anal = bitcoind.rpc.analyzepsbt(b64encode(psbt).decode('ascii')) - try: - assert not any(inp.get('missing') for inp in anal['inputs']), "missing sigs: %r" % anal - assert all(inp['next'] in {'finalizer','updater'} for inp in anal['inputs']), "other issue: %r" % anal - except: - # XXX seems to be a bug in analyzepsbt function ... not fully studied - with open(f'{sim_root_dir}/debug/analyzed.txt', 'wt') as f: - pprint(anal, stream=f) + assert not any(inp.get('missing') for inp in anal['inputs']), "missing sigs: %r" % anal + assert all(inp['next'] in {'finalizer','updater'} for inp in anal['inputs']), "other issue: %r" % anal - decode = bitcoind.rpc.decodepsbt(b64encode(psbt).decode('ascii')) - with open(f'{sim_root_dir}/debug/decoded.txt', 'wt') as f: - pprint(decode, stream=f) - - if M==N or segwit: - # as observed, bug not trigged, so raise if it *does* happen - raise - else: - print("ignoring bug in bitcoind") - - if 0: - # why doesn't this work? - # TODO this does NOT work only if parameter segwit is True - # TODO I have debuged bitcoin core to see why we're still in updater phase, not in desired finalizer - # relevant comment from core code: - # When we're taking our information from a witness UTXO, we can't verify it is actually data from - # the output being spent. This is safe in case a witness signature is produced (which includes this - # information directly in the hash), but not for non-witness signatures. Remember that we require - # a witness signature in this situation. - # - # In our case, witness signature was not produced (but was required) - rv = bitcoind.rpc.finalizepsbt(b64encode(aft.as_bytes()).decode('ascii'), True) - _, txn, is_complete = b64decode(rv.get('psbt', '')), rv.get('hex'), rv['complete'] - assert is_complete @pytest.mark.parametrize('addr_fmt', ['p2wsh', 'p2sh-p2wsh']) @pytest.mark.parametrize('acct_num', [None, 4321]) @@ -1532,7 +1199,7 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev @pytest.mark.parametrize('way', ["sd", "qr"]) @pytest.mark.parametrize('incl_self', [True, False, None]) def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu_item, - need_keypress, microsd_path, set_bip39_pw, clear_ms, enter_number, + need_keypress, microsd_path, set_bip39_pw, clear_miniscript, enter_number, get_settings, load_export, is_q1, press_select, press_cancel, cap_screen, way, scan_a_qr, skip_if_useless_way, incl_self): # test UX and math for bip45 export @@ -1543,7 +1210,7 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu for fn in glob(microsd_path('ccxp-*.json')): assert fn os.unlink(fn) - clear_ms() + clear_miniscript() for idx in range(N - int(incl_self is None)): if not idx and (incl_self is True): @@ -1554,7 +1221,7 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu goto_home() time.sleep(0.1) pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') + pick_menu_item('Miniscript') pick_menu_item('Export XPUB') time.sleep(.05) press_select() @@ -1578,7 +1245,7 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') + pick_menu_item('Miniscript') pick_menu_item('Create Airgapped') if is_q1: time.sleep(.1) @@ -1605,16 +1272,6 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu assert 0, addr_fmt if way == "qr": - # JSON but wrong - _, parts = split_qrs('{"json": "but wrong","missing": "important data"}', - 'J', max_version=20) - for p in parts: - scan_a_qr(p) - - time.sleep(1) - scr = cap_screen() - assert f"Missing value: xfp" in scr # missing xfp - # need to scan json XPUBs here for i, fname in enumerate(glob(microsd_path('ccxp-*.json'))): with open(fname, 'r') as f: @@ -1652,32 +1309,32 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu title, story = cap_story() if incl_self is not False: - assert "Create new multisig" in story + assert "Create new miniscript" in story press_select() - # we use clear_ms fixture at the begining of each test + # we use clear_miniscript fixture at the begining of each test # new multisig wallet is first menu item press_select() - pick_menu_item("Coldcard Export") - impf, fname = load_export("sd", label="Coldcard multisig setup", is_json=False, + pick_menu_item("Descriptors") + pick_menu_item("Export") + impf, fname = load_export("sd", label="Miniscript", is_json=False, ret_fname=True) cc_fname = microsd_path(fname) - assert f'Policy: {M} of {N}' in impf - if addr_fmt != 'p2sh': - assert f'Format: {addr_fmt.upper()}' in impf + strt = "wsh(sortedmulti" if addr_fmt == 'p2wsh' else "sh(wsh(sortedmulti(" + strt += str(M) press_select() press_select() - clear_ms() + clear_miniscript() # test re-importing the wallet from export file goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') - pick_menu_item('Import from File') + pick_menu_item('Miniscript') + pick_menu_item('Import') time.sleep(0.5) _, story = cap_story() - if "Press (1) to import multisig wallet file from SD Card" in story: + if "Press (1) to import miniscript" in story: need_keypress("1") time.sleep(.05) @@ -1685,12 +1342,8 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu time.sleep(.05) title, story = cap_story() - assert "Create new multisig" in story + assert "Create new miniscript" in story assert f"Policy: {M} of {N}" in story - if acct_num is None: - assert ("Varies (%d)" % N) in story - else: - assert f"/{acct_num}h/" in story need_keypress('1') time.sleep(.1) @@ -1699,7 +1352,7 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu else: # own wallet not included in the mix, can only export resulting descriptor - desc = load_export(way, label="Descriptor multisig setup", is_json=False, sig_check=False) + desc = load_export(way, label="Miniscript", is_json=False, sig_check=False) desc = desc.strip() do = MultisigDescriptor.parse(desc) assert do.M == M @@ -1727,7 +1380,7 @@ def test_make_airgapped(addr_fmt, acct_num, M_N, goto_home, cap_story, pick_menu @pytest.mark.bitcoind @pytest.mark.parametrize('addr_style', ["legacy", "p2sh-segwit", "bech32"]) @pytest.mark.parametrize('cc_sign_first', [True, False]) -def test_bitcoind_cosigning(cc_sign_first, dev, bitcoind, import_ms_wallet, clear_ms, try_sign, +def test_bitcoind_cosigning(cc_sign_first, dev, bitcoind, import_ms_wallet, clear_miniscript, try_sign, press_cancel, addr_style, use_regtest, is_q1, sim_root_dir): # Make a P2SH wallet with local bitcoind as a co-signer (and simulator) # - send an receive various @@ -1775,7 +1428,7 @@ def test_bitcoind_cosigning(cc_sign_first, dev, bitcoind, import_ms_wallet, clea M,N=2,2 - clear_ms() + clear_miniscript() import_ms_wallet(M, N, keys=keys, accept=1, name="core-cosign", addr_fmt=addr_fmt_names[addr_fmt], derivs=[bc_deriv, "m/45h"]) @@ -1878,14 +1531,14 @@ def test_bitcoind_cosigning(cc_sign_first, dev, bitcoind, import_ms_wallet, clea @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, +def test_ms_sign_bitrot(num_ins, dev, addr_fmt, clear_miniscript, 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() + clear_miniscript() keys = import_ms_wallet(M, N, accept=1, addr_fmt=out_style) # given script, corrupt it a little or a lot @@ -1932,7 +1585,7 @@ def test_ms_sign_bitrot(num_ins, dev, addr_fmt, clear_ms, incl_xpubs, import_ms_ @pytest.mark.parametrize('out_style', ['p2wsh']) @pytest.mark.parametrize('pk_num', range(4)) @pytest.mark.parametrize('case', ['pubkey', 'path']) -def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xpubs, make_multisig, +def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_miniscript, incl_xpubs, make_multisig, addr_vs_path, fake_ms_txn, start_sign, end_sign, out_style, cap_story, sim_root_dir): @@ -1940,7 +1593,7 @@ def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xp N = 3 num_outs = 2 - clear_ms() + clear_miniscript() keys = make_multisig(M, N) @@ -1988,7 +1641,7 @@ def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xp @pytest.mark.parametrize('N', [ 3, 15]) @pytest.mark.parametrize('xderiv', [ None, 'any', 'unknown', '*', '', 'none']) -def test_ms_import_nopath(N, xderiv, make_multisig, clear_ms, offer_ms_import): +def test_ms_import_nopath(N, xderiv, make_multisig, clear_miniscript, offer_ms_import): # try various synonyms for unknown/any derivation styles keys = make_multisig(N, N, deriv="m/48h/0h/0h/1h/0", unique=1) @@ -2007,7 +1660,7 @@ def test_ms_import_nopath(N, xderiv, make_multisig, clear_ms, offer_ms_import): @pytest.mark.parametrize('N', [ 15]) @pytest.mark.parametrize('M', [ 1, 15]) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) -def test_ms_import_many_derivs(M, N, way, make_multisig, clear_ms, offer_ms_import, press_select, +def test_ms_import_many_derivs(M, N, way, make_multisig, clear_miniscript, offer_ms_import, press_select, pick_menu_item, cap_story, microsd_path, virtdisk_path, nfc_read_text, goto_home, load_export, is_q1, need_keypress, press_cancel): # try config file with different derivation paths given, including None @@ -2083,20 +1736,19 @@ def test_ms_import_many_derivs(M, N, way, make_multisig, clear_ms, offer_ms_impo dd = co['derivation'] assert (dd in derivs) or (dd == actual) or ("42069h" in dd) or (dd == 'm') - clear_ms() + clear_miniscript() @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, +def test_danger_warning(request, clear_miniscript, 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() + clear_miniscript() M,N = 2,3 - keys = import_ms_wallet(M, N, accept=1, descriptor=descriptor, addr_fmt="p2wsh") + keys = import_ms_wallet(M, N, accept=True, 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: @@ -2117,12 +1769,12 @@ def test_danger_warning(request, descriptor, clear_ms, import_ms_wallet, cap_sto @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, +def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_miniscript, 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, cap_screen_qr, press_cancel, press_right): - clear_ms() + clear_miniscript() M, N = M_N wal_name = f"ax{M}-{N}-{addr_fmt}" @@ -2139,14 +1791,11 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, 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) + bip67 = False + keys = import_ms_wallet(M, N, accept=True, keys=keys, name=wal_name, + derivs=derivs, addr_fmt=text_a_fmt, bip67=bip67) goto_home() pick_menu_item("Address Explorer") @@ -2154,12 +1803,7 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, 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}") + pick_menu_item(wal_name) time.sleep(.5) title, story = cap_story() @@ -2216,7 +1860,7 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, path_mapper=path_mapper, bip67=bip67) assert int(subpath.split('/')[-1]) == idx - assert int(subpath.split('/')[-2]) == chng_idx + # assert int(subpath.split('/')[-2]) == chng_idx #print('../0/%s => \n %s' % (idx, B2A(script))) addr = addr_from_display_format(addr) @@ -2225,69 +1869,86 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, def test_dup_ms_wallet_bug(goto_home, pick_menu_item, press_select, import_ms_wallet, - clear_ms, is_q1): + clear_miniscript, is_q1): M = 2 N = 3 deriv = ["m/48h/1h/0h/69h/1"]*N fmts = [ 'p2wsh', 'p2sh-p2wsh'] - clear_ms() + clear_miniscript() for n, ty in enumerate(fmts): - import_ms_wallet(M, N, name=f'name-{n}', accept=1, derivs=deriv, addr_fmt=ty) + import_ms_wallet(M, N, name=f'name-{n}', accept=True, derivs=deriv, addr_fmt=ty) goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') + pick_menu_item('Miniscript') # drill down to second one time.sleep(.1) - pick_menu_item('2/3: name-1') + pick_menu_item('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('name-0') pick_menu_item('Delete') press_select() - clear_ms() + clear_miniscript() @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('addr_fmt', ["p2wsh", "p2sh-p2wsh", "p2sh"]) @pytest.mark.parametrize('int_ext_desc', [True, False]) +@pytest.mark.parametrize('json_wrapped', [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() + press_select, clear_miniscript, cap_story, microsd_path, virtdisk_path, + nfc_read_text, load_export, is_q1, desc, sim_root_dir, skip_if_useless_way, + json_wrapped): + skip_if_useless_way(way) 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() + if (way == "nfc") and (M == N == 15): + raise pytest.skip("too big for simulated NFC") + + clear_miniscript() goto_home() - pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') - press_select() # only one enrolled multisig - choose it + + name = None + if json_wrapped: + # descriptor wrapped in JSON with name key + name = "aaa" + + import_ms_wallet( + M, N, addr_fmt=addr_fmt, accept=True, way=way, name=name, + int_ext_desc=int_ext_desc, bip67=False if desc == "multi" else True, + ) + with open(f'{sim_root_dir}/debug/last-ms.txt', 'r') as f: + desc_import = f.read().strip() + + if json_wrapped: + desc_obj = json.loads(desc_import) + desc_import = desc_obj["desc"] + pick_menu_item(name) + else: + 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) + contents = load_export(way, label="Miniscript", 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 + # needs bitcoin core client at least on 29.0 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 + else: + # we always export with multipath + assert normalized.split("#")[0] == desc_import.split("#")[0].replace("/0/*", "/<0;1>/*") starts_with = MULTI_FMT_TO_SCRIPT[addr_fmt].split("%")[0] assert normalized.startswith(starts_with) assert f"{desc}(" in desc_export @@ -2300,23 +1961,21 @@ def test_import_descriptor(M_N, addr_fmt, int_ext_desc, way, import_ms_wallet, g @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, +def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_miniscript, 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, skip_if_useless_way): skip_if_useless_way(way) use_regtest() - clear_ms() + clear_miniscript() 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 @@ -2336,9 +1995,9 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke 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) + clear_miniscript() + import_ms_wallet(M, N, accept=True, keys=keys, name=wal_name, derivs=derivs, + addr_fmt=text_a_fmt, bip67=bip67) goto_home() pick_menu_item("Address Explorer") @@ -2346,10 +2005,7 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke 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 + assert wal_name in m pick_menu_item(wal_name) time.sleep(0.2) @@ -2373,30 +2029,25 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke addr_cont = contents.strip() goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') + pick_menu_item('Miniscript') 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, + contents, exp_fname = load_export(way, label="Bitcoin Core miniscript", 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) + contents = load_export(way, label="Bitcoin Core miniscript", 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"] + desc_core = core_desc_object[0]["desc"] - if descriptor: - assert f"({desc}(" in desc_export + assert f"({desc}(" in desc_core if way == "nfc": end_idx = start_idx + 9 @@ -2415,7 +2066,8 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke cc_addrs = addr_cont.split("\n")[1:] part_addr_index = 1 - bitcoind_addrs = bitcoind.deriveaddresses(desc_export, addr_range) + ea, ia = bitcoind.deriveaddresses(desc_core, addr_range) + bitcoind_addrs = ia if change else ea for idx, cc_item in enumerate(cc_addrs): cc_item = cc_item.split(",") address = cc_item[part_addr_index] @@ -2425,11 +2077,11 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke @pytest.mark.bitcoind -def test_legacy_multisig_witness_utxo_in_psbt(bitcoind, use_regtest, clear_ms, microsd_wipe, goto_home, need_keypress, +def test_legacy_multisig_witness_utxo_in_psbt(bitcoind, use_regtest, clear_miniscript, 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() + clear_miniscript() microsd_wipe() M,N = 2,2 cosigner = bitcoind.create_wallet(wallet_name=f"bitcoind--signer-wit-utxo", disable_private_keys=False, blank=False, @@ -2537,15 +2189,21 @@ def test_legacy_multisig_witness_utxo_in_psbt(bitcoind, use_regtest, clear_ms, m def get_cc_key(dev): def doit(path, subderiv=None): # cc device key - master_xfp_str = struct.pack('/*'}" + if subderiv is None: + cc_key = cc_key + "/<0;1>/*" + + if not path: + return cc_key + + master_xfp_str = struct.pack('/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"), + ("need multipath", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"), ("All keys must be ranged", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#9h02aqg5"), - ("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"), + ("need multipath", "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"), ("wrong pubkey", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), - ("xpub depth", "wsh(sortedmulti(2,[0f056943/0h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"), - ("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"), + ("wrong pubkey", "wsh(sortedmulti(2,[0f056943/0h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"), + ("All keys must be ranged", "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"), ("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, +def test_exotic_descriptors(desc, clear_miniscript, 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() + clear_miniscript() msg, desc = desc name = "exotic.txt" if os.path.exists(microsd_path(name)): @@ -2953,22 +2611,22 @@ def test_exotic_descriptors(desc, clear_ms, goto_home, need_keypress, pick_menu_ f.write(desc + "\n") goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') - pick_menu_item('Import from File') + pick_menu_item('Miniscript') + pick_menu_item('Import') time.sleep(0.1) _, story = cap_story() - if "Press (1) to import multisig wallet file from SD Card" in story: + if "Press (1) to import miniscript 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 "Failed to import miniscript" 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() +def test_ms_wallet_ordering(clear_miniscript, import_ms_wallet, try_sign_microsd, fake_ms_txn): + clear_miniscript() all_out_styles = list(unmap_addr_fmt.keys()) index = all_out_styles.index("p2sh-p2wsh") all_out_styles[index] = "p2wsh-p2sh" @@ -2991,9 +2649,9 @@ def test_ms_wallet_ordering(clear_ms, import_ms_wallet, try_sign_microsd, fake_m @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, +def test_ms_xpub_ordering(descriptor, m_n, clear_miniscript, make_multisig, import_ms_wallet, try_sign_microsd, fake_ms_txn): - clear_ms() + clear_miniscript() M, N = m_n all_out_styles = list(unmap_addr_fmt.keys()) index = all_out_styles.index("p2sh-p2wsh") @@ -3019,7 +2677,7 @@ def test_ms_xpub_ordering(descriptor, m_n, clear_ms, make_multisig, import_ms_wa @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, +def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear_miniscript, make_multisig, import_ms_wallet, goto_home, pick_menu_item, cap_menu, nfc_read_text, microsd_path, cap_story, need_keypress, load_export, desc): @@ -3027,7 +2685,7 @@ def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear def choose_multisig_wallet(): goto_home() pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') + pick_menu_item('Miniscript') menu = cap_menu() pick_menu_item(menu[0]) @@ -3042,37 +2700,23 @@ def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear 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, + clear_miniscript() + import_ms_wallet(M, N, accept=True, keys=keys, name=wal_name, + derivs=None if cmn_pth_from_root else derivs, + addr_fmt=text_a_fmt, 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) + contents = load_export(way, label="Miniscript", 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) + core_desc_text = load_export(way, label="Bitcoin Core miniscript", is_json=False) # remove junk text = core_desc_text.replace("importdescriptors ", "").strip() @@ -3081,44 +2725,24 @@ def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear 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() + assert core_desc_object[0]["desc"] == bare_desc + clear_miniscript() def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, - clear_ms, goto_home, cap_menu, pick_menu_item, + clear_miniscript, goto_home, cap_menu, pick_menu_item, need_keypress, import_ms_wallet): - clear_ms() + clear_miniscript() use_regtest() # cannot import XPUBS when testnet/regtest enabled with pytest.raises(Exception): - import_ms_wallet(3, 3, addr_fmt="p2wsh", accept=1, descriptor=True, chain="BTC") + import_ms_wallet(3, 3, addr_fmt="p2wsh", accept=True, chain="BTC") - import_ms_wallet(2, 2, addr_fmt="p2wsh", accept=1, descriptor=True, chain="XTN") + import_ms_wallet(2, 2, addr_fmt="p2wsh", accept=True, chain="XTN") # assert that wallets created at XRT always store XTN anywas (key_chain) res = settings_get("multisig") assert len(res) == 1 @@ -3175,111 +2799,102 @@ def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, "))"), ]) def test_same_key_account_based_multisig(goto_home, need_keypress, pick_menu_item, cap_story, - clear_ms, microsd_path, load_export, desc, - offer_ms_import): - clear_ms() - try: - _, story = offer_ms_import(desc) - except Exception as e: - assert "my key included more than once" in str(e) + clear_miniscript, microsd_path, load_export, desc, + offer_minsc_import): + clear_miniscript() + _, story = offer_minsc_import(desc) + # this is allowed now + assert "Create new miniscript wallet" in story -def test_multisig_name_validation(microsd_path, offer_ms_import): - with open("data/multisig/export-p2wsh-myself.txt", "r") as f: +def test_multisig_name_validation(microsd_path, offer_minsc_import): + with open("data/multisig/desc-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) + offer_minsc_import(json.dumps({"name": "eê", "desc": config}), 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) + offer_minsc_import(json.dumps({"name": "eee\teee", "desc": config}), 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() +# def test_multisig_deriv_path_migration(settings_set, clear_miniscript, 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_miniscript() +# +# 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, +def test_scan_any_qr(fpath, is_q1, scan_a_qr, clear_miniscript, goto_home, pick_menu_item, cap_story, press_cancel): if not is_q1: pytest.skip("No QR support for Mk4") - clear_ms() + clear_miniscript() goto_home() pick_menu_item("Scan Any QR Code") @@ -3295,73 +2910,22 @@ def test_scan_any_qr(fpath, is_q1, scan_a_qr, clear_ms, goto_home, 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): - # 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 from 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 + assert "Create new miniscript wallet?" in story press_cancel() @pytest.mark.parametrize("desc", ["multi", "sortedmulti"]) -@pytest.mark.parametrize("data", [ +@pytest.mark.parametrize("data,af", [ # (out_style, amount, is_change) - [("p2wsh", 1000000, 0)] * 99, - [("p2sh", 1000000, 1)] * 33, - [("p2wsh-p2sh", 1000000, 1)] * 18 + [("p2wsh", 50000000, 0)] * 12, - [("p2sh", 1000000, 1), ("p2wsh-p2sh", 50000000, 0), ("p2wsh", 800000, 1)] * 14, + # change can only be of the same address type as imported wallet + ([("p2wsh", 1000000, 0)] * 99, "p2wsh"), + ([("p2sh", 1000000, 1)] * 33, "p2sh"), + ([("p2wsh-p2sh", 1000000, 1)] * 18 + [("p2wsh", 50000000, 0)] * 12, "p2sh-p2wsh"), + ([("p2sh", 1000000, 0), ("p2wsh-p2sh", 50000000, 0), ("p2wsh", 800000, 1)] * 14, "p2wsh"), ]) -def test_txout_explorer(data, clear_ms, import_ms_wallet, fake_ms_txn, - start_sign, txout_explorer, desc, pytestconfig): +def test_txout_explorer(data, af, desc, clear_miniscript, import_ms_wallet, fake_ms_txn, + start_sign, txout_explorer, 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 - keys = import_ms_wallet(2, 3, name='ms-test', accept=True, - descriptor=descriptor, bip67=bip67) outstyles = [] outvals = [] @@ -3373,55 +2937,38 @@ def test_txout_explorer(data, clear_ms, import_ms_wallet, fake_ms_txn, if is_change: change_outputs.append(i) + clear_miniscript() + M, N = 2, 3 + bip67 = True if desc == "multi" else False + keys = import_ms_wallet(2, 3, name='ms-test', accept=True, bip67=bip67, addr_fmt=af) + inp_amount = sum(outvals) + 100000 # 100k sat fee - psbt = fake_ms_txn(1, len(data), M, keys, outstyles=outstyles, + psbt = fake_ms_txn(1, len(data), M, keys, outstyles=outstyles, inp_addr_fmt=af, outvals=outvals, change_outputs=change_outputs, input_amount=inp_amount, psbt_v2=pytestconfig.getoption('psbt2'), bip67=bip67) 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() @pytest.mark.parametrize("order", list(itertools.product([True, False], repeat=2))) -def test_import_duplicate_shuffled_keys(clear_ms, make_multisig, import_ms_wallet, +def test_import_duplicate_shuffled_keys(clear_miniscript, 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() + clear_miniscript() 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) + import_ms_wallet(M, N, addr_fmt="p2wsh", name="ms0", accept=True, keys=keys, 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) + import_ms_wallet(M, N, addr_fmt="p2wsh", name="ms1", accept=True, keys=keys, bip67=B) + time.sleep(.1) title, story = cap_story() assert 'Duplicate wallet' in story @@ -3433,10 +2980,10 @@ def test_import_duplicate_shuffled_keys(clear_ms, make_multisig, import_ms_walle @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, +def test_multi_sortedmulti_duplicate(clear_miniscript, make_multisig, import_ms_wallet, OK, + cap_story, press_cancel, int_ext, offer_minsc_import, settings_set): - clear_ms() + clear_miniscript() M, N = 3, 5 wname = "ms001" fstr = "m/48h/1h/0h/2h/{idx}" @@ -3451,7 +2998,7 @@ def test_multi_sortedmulti_duplicate(clear_ms, make_multisig, import_ms_wallet, 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) + title, story = offer_minsc_import(ser_desc) assert 'Duplicate wallet' in story assert f'{OK} to approve' not in story assert "BIP-67 clash" in story @@ -3461,13 +3008,13 @@ def test_multi_sortedmulti_duplicate(clear_ms, make_multisig, import_ms_wallet, @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, +def test_import_multisig_usb_json(use_regtest, cs, way, cap_menu, clear_miniscript, pick_menu_item, goto_home, need_keypress, - offer_ms_import, bitcoind, microsd_path, - virtdisk_path, import_multisig): + offer_minsc_import, bitcoind, microsd_path, + virtdisk_path, import_miniscript): name = "my_ms_wal" use_regtest() - clear_ms() + clear_miniscript() with open("data/multisig/desc-p2wsh-myself.txt", "r") as f: desc = f.read().strip() @@ -3480,7 +3027,7 @@ def test_import_multisig_usb_json(use_regtest, cs, way, cap_menu, clear_ms, data = None fname = None if way == "usb": - title, story = offer_ms_import(val) + title, story = offer_minsc_import(val) else: if way in ["nfc", "qr"]: data = val @@ -3494,15 +3041,15 @@ def test_import_multisig_usb_json(use_regtest, cs, way, cap_menu, clear_ms, with open(fpath, "w") as f: f.write(val) - title, story = import_multisig(fname=fname, way=way, data=data) + title, story = import_miniscript(fname=fname, way=way, data=data) - assert "Create new multisig wallet?" in story + assert "Create new miniscript wallet?" in story assert name in story need_keypress("y") time.sleep(.2) goto_home() pick_menu_item("Settings") - pick_menu_item("Multisig Wallets") + pick_menu_item("Miniscript") m = cap_menu() assert name in m[0] @@ -3530,19 +3077,18 @@ def test_import_multisig_usb_json(use_regtest, cs, way, cap_menu, clear_ms, {"name": "ab", "desc": None, "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} ), ]) -def test_json_import_failures(err, config, offer_ms_import): +def test_json_import_failures(err, config, offer_minsc_import): with pytest.raises(Exception) as e: - offer_ms_import(json.dumps(config)) + offer_minsc_import(json.dumps(config)) assert err in e.value.args[0] -@pytest.mark.parametrize("desc", [True, False]) -def test_root_keys_import(desc, import_ms_wallet, clear_ms, goto_address_explorer, +def test_root_keys_import(import_ms_wallet, clear_miniscript, goto_address_explorer, pick_menu_item, cap_story, cap_menu): - clear_ms() + clear_miniscript() M, N = 2, 3 keys = import_ms_wallet(M, N, "p2wsh", accept=True, name="root", - common="m", descriptor=desc) + common="m") # just xfp + internal/external + index target_der_paths = [f"[{xfp2str(tup[0])}/0/0]" for tup in keys] @@ -3556,13 +3102,13 @@ def test_root_keys_import(desc, import_ms_wallet, clear_ms, goto_address_explore @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, +def test_cc_root_key(import_ms_wallet, bitcoind, use_regtest, clear_miniscript, microsd_wipe, goto_home, + pick_menu_item, cap_story, press_select, need_keypress, offer_minsc_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() + clear_miniscript() microsd_wipe() M, N = 2, 2 cosigner = bitcoind.create_wallet(wallet_name=f"bds", disable_private_keys=False, blank=False, @@ -3598,20 +3144,22 @@ def test_cc_root_key(import_ms_wallet, bitcoind, use_regtest, clear_ms, microsd_ desc_info = ms.getdescriptorinfo(desc) desc_w_checksum = desc_info["descriptor"] # with checksum - title, story = offer_ms_import(desc_w_checksum) + name = "cc_root_key" + title, story = offer_minsc_import(json.dumps({"name": name, "desc": desc_w_checksum})) - assert "Create new multisig wallet?" in story - assert f"All {N} co-signers must approve spends" in story + assert "Create new miniscript wallet?" in story + assert name 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') + pick_menu_item('Miniscript') 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 = load_export("sd", label="Bitcoin Core miniscript", is_json=False) text = text.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[") @@ -3654,11 +3202,12 @@ def test_cc_root_key(import_ms_wallet, bitcoind, use_regtest, clear_ms, microsd_ 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 + pick_menu_item(name) + # TODO + # _, 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) @@ -3668,28 +3217,28 @@ def test_cc_root_key(import_ms_wallet, bitcoind, use_regtest, clear_ms, microsd_ 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) + # TODO + # 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 + # 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, +def test_multisig_nfc_qr_finalization(way, clear_miniscript, 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() + clear_miniscript() 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) + keys = import_ms_wallet(M, N, addr_fmt="p2wsh", name=wname, accept=True) psbt = fake_ms_txn(2, 2, M, keys, outstyles=ADDR_STYLES_MS, change_outputs=[0]) @@ -3707,8 +3256,8 @@ def test_multisig_nfc_qr_finalization(way, clear_ms, make_multisig, import_ms_wa @pytest.mark.parametrize("has_orig", [False, True]) -def test_originless_keys(get_cc_key, bitcoin_core_signer, bitcoind, offer_ms_import, - pick_menu_item, load_export, goto_home, cap_menu, clear_ms, +def test_originless_keys(get_cc_key, bitcoin_core_signer, bitcoind, offer_minsc_import, + pick_menu_item, load_export, goto_home, cap_menu, clear_miniscript, use_regtest, press_select, start_sign, end_sign, cap_story, has_orig, need_keypress): # can be both: @@ -3716,7 +3265,7 @@ def test_originless_keys(get_cc_key, bitcoin_core_signer, bitcoind, offer_ms_imp # b.) ranged xpub with its fp -> [xpub1_fp]xpub1/<0;1>/* use_regtest() - clear_ms() + clear_miniscript() af = "bech32" name = "originless_multlisig" @@ -3733,7 +3282,7 @@ def test_originless_keys(get_cc_key, bitcoin_core_signer, bitcoind, offer_ms_imp desc = tmplt.replace("@0", cc_key) desc = desc.replace("@1", originless_ck) to_import = {"desc": desc, "name": name} - offer_ms_import(json.dumps(to_import)) + offer_minsc_import(json.dumps(to_import)) press_select() wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, @@ -3741,13 +3290,11 @@ def test_originless_keys(get_cc_key, bitcoin_core_signer, bitcoind, offer_ms_imp goto_home() pick_menu_item("Settings") - pick_menu_item("Multisig Wallets") - menu = cap_menu() - assert menu[0] == f"2/2: {name}" - pick_menu_item(menu[0]) # pick imported descriptor miniscript wallet + pick_menu_item("Miniscript") + pick_menu_item(name) # pick imported descriptor miniscript wallet pick_menu_item("Descriptors") pick_menu_item("Bitcoin Core") - text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False) + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False) text = text.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[") diff --git a/testing/test_nfc.py b/testing/test_nfc.py index 3fcae191..bdb616e7 100644 --- a/testing/test_nfc.py +++ b/testing/test_nfc.py @@ -454,7 +454,7 @@ def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove, cap_story, cap_screen, has_qwerty, way, try_sign_microsd, try_sign_nfc, scan_a_qr, need_keypress, press_select, goto_home, multisig, fake_ms_txn, import_ms_wallet, - clear_ms, try_sign_bbqr): + clear_miniscript, try_sign_bbqr): # check the NFC push Tx feature, validating the URL's it makes # - not the UX # - 100 outs => 5000 or so @@ -463,7 +463,7 @@ def test_nfc_pushtx(num_outs, chain, enable_nfc, settings_set, settings_remove, from base64 import urlsafe_b64decode from urllib.parse import urlsplit, parse_qsl, unquote - clear_ms() + clear_miniscript() settings_set('chain', chain) enable_nfc() diff --git a/testing/test_ownership.py b/testing/test_ownership.py index 200849e1..6eb40f09 100644 --- a/testing/test_ownership.py +++ b/testing/test_ownership.py @@ -58,7 +58,7 @@ def test_negative(addr_fmt, testnet, sim_exec): @pytest.mark.parametrize('from_empty', [ True, False] ) def test_positive(addr_fmt, offset, subaccount, chain, from_empty, change_idx, sim_exec, wipe_cache, make_myself_wallet, use_testnet, goto_home, pick_menu_item, - enter_number, press_cancel, settings_set, import_ms_wallet, clear_ms + enter_number, press_cancel, settings_set, import_ms_wallet, clear_miniscript ): from bech32 import encode as bech32_encode @@ -86,7 +86,7 @@ def test_positive(addr_fmt, offset, subaccount, chain, from_empty, change_idx, M, N = 1, 3 expect_name = f'search-test-{addr_fmt}' - clear_ms() + clear_miniscript() keys = import_ms_wallet(M, N, name=expect_name, accept=1, addr_fmt=addr_fmt_names[addr_fmt]) # iffy: no cosigner index in this wallet, so indicated that w/ path_mapper @@ -103,7 +103,7 @@ def test_positive(addr_fmt, offset, subaccount, chain, from_empty, change_idx, elif addr_fmt == AF_P2WPKH_P2SH: menu_item = expect_name = 'P2SH-Segwit' path = "m/49h/{ct}h/{acc}h" - clear_ms() + clear_miniscript() elif addr_fmt == AF_P2WPKH: menu_item = expect_name = 'Segwit P2WPKH' path = "m/84h/{ct}h/{acc}h" @@ -174,7 +174,7 @@ def test_ux(valid, netcode, method, sim_exec, wipe_cache, make_myself_wallet, use_testnet, goto_home, pick_menu_item, press_cancel, press_select, settings_set, is_q1, nfc_write, need_keypress, cap_screen, cap_story, load_shared_mod, scan_a_qr, skip_if_useless_way, - sign_msg_from_address, multisig, import_ms_wallet, clear_ms, verify_qr_address, + sign_msg_from_address, multisig, import_ms_wallet, clear_miniscript, verify_qr_address, src_root_dir, sim_root_dir ): skip_if_useless_way(method) @@ -188,7 +188,7 @@ def test_ux(valid, netcode, method, M, N = 2, 3 expect_name = f'own_ux_test' - clear_ms() + clear_miniscript() keys = import_ms_wallet(M, N, AF_P2WSH, name=expect_name, accept=1) # iffy: no cosigner index in this wallet, so indicated that w/ path_mapper @@ -271,7 +271,7 @@ def test_ux(valid, netcode, method, @pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "Taproot P2TR", "ms0", "msc0", "msc2"]) def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explorer, - pick_menu_item, need_keypress, sim_exec, clear_ms, + pick_menu_item, need_keypress, sim_exec, clear_miniscript, import_ms_wallet, press_select, goto_home, nfc_write, load_shared_mod, load_export_and_verify_signature, cap_story, load_export, offer_minsc_import, is_q1, @@ -281,7 +281,7 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo settings_set('accts', []) if af == "ms0": - clear_ms() + clear_miniscript() import_ms_wallet(2, 3, name=af) press_select() # accept ms import elif "msc" in af: diff --git a/testing/test_se2.py b/testing/test_se2.py index b0c88cae..0351f814 100644 --- a/testing/test_se2.py +++ b/testing/test_se2.py @@ -515,7 +515,7 @@ def test_ux_countdown_choices(subchoice, expect, xflags, new_trick_pin, new_pin_ # ( 'Blank Coldcard', 'freshly wiped Coldcard', TC_WIPE|TC_BLANK_WALLET, 0 ), ]) def test_ux_duress_choices(with_wipe, subchoice, expect, xflags, xargs, words12, - reset_seed_words, repl, clear_all_tricks, import_ms_wallet, get_setting, clear_ms, + reset_seed_words, repl, clear_all_tricks, import_ms_wallet, get_setting, clear_miniscript, new_trick_pin, new_pin_confirmed, cap_menu, pick_menu_item, cap_story, need_keypress, press_select, press_cancel, seed_story_to_words, is_q1, set_seed_words, stop_after_activated=False, @@ -529,7 +529,7 @@ def test_ux_duress_choices(with_wipe, subchoice, expect, xflags, xargs, words12, xargs += 1000 # import multisig - clear_ms() + clear_miniscript() import_ms_wallet(2, 2, dev_key=words12) press_select() time.sleep(.1) @@ -879,7 +879,7 @@ def build_duress_wallets(request, seed_vault=False): # fixtures I need in test_ux_duress_choices args = {f: request.getfixturevalue(f) - for f in ['reset_seed_words', 'repl', 'clear_all_tricks', 'new_trick_pin', 'clear_ms', + for f in ['reset_seed_words', 'repl', 'clear_all_tricks', 'new_trick_pin', 'clear_miniscript', 'import_ms_wallet', 'get_setting', 'press_select', 'press_cancel', 'is_q1', 'new_pin_confirmed', 'cap_menu', 'pick_menu_item', 'cap_story', 'need_keypress', 'seed_story_to_words', 'set_seed_words']} diff --git a/testing/test_sign.py b/testing/test_sign.py index b7edb300..8c382e61 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -479,7 +479,7 @@ def test_sign_p2sh_p2wpkh(match_key, use_regtest, start_sign, end_sign, bitcoind @pytest.mark.bitcoind @pytest.mark.unfinalized def test_sign_p2sh_example(set_master_key, use_regtest, sim_execfile, start_sign, end_sign, - decode_psbt_with_bitcoind, offer_ms_import, press_select, clear_ms, + decode_psbt_with_bitcoind, offer_ms_import, press_select, clear_miniscript, sim_root_dir): # Use the private key given in BIP 174 and do similar signing # as the examples. @@ -504,7 +504,7 @@ def test_sign_p2sh_example(set_master_key, use_regtest, sim_execfile, start_sign xfp = '4F6A0CD9' config += f'{xfp}: {n1}\n{xfp}: {n2}\n' - clear_ms() + clear_miniscript() offer_ms_import(config) time.sleep(.1) press_select() @@ -3161,9 +3161,9 @@ def test_txout_explorer_op_return(finalize, data, fake_txn, start_sign, cap_stor def test_low_R_grinding(dev, goto_home, microsd_path, press_select, offer_ms_import, - cap_story, try_sign, reset_seed_words, clear_ms): + cap_story, try_sign, reset_seed_words, clear_miniscript): reset_seed_words() - clear_ms() + clear_miniscript() desc = "sh(sortedmulti(2,[6ba6cfd0/45h]tpubD9429UXFGCTKJ9NdiNK4rC5ygqSUkginycYHccqSg5gkmyQ7PZRHNjk99M6a6Y3NY8ctEUUJvCu6iCCui8Ju3xrHRu3Ez1CKB4ZFoRZDdP9/0/*,[747b698e/45h]tpubD97nVL37v5tWyMf9ofh5rznwhh1593WMRg6FT4o6MRJkKWANtwAMHYLrcJFsFmPfYbY1TE1LLQ4KBb84LBPt1ubvFwoosvMkcWJtMwvXgSc/0/*,[7bb026be/45h]tpubD9ArfXowvGHnuECKdGXVKDMfZVGdephVWg8fWGWStH3VKHzT4ph3A4ZcgXWqFu1F5xGTfxncmrnf3sLC86dup2a8Kx7z3xQ3AgeNTQeFxPa/0/*,[0f056943/45h]tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n/0/*))#up0sw2xp" # PSBT created via fake_ms_txn, grinded in test_ms_sign_myself psbt_fname = "myself-72sig.psbt" diff --git a/testing/test_teleport.py b/testing/test_teleport.py index a6bee1ad..b6f897b0 100644 --- a/testing/test_teleport.py +++ b/testing/test_teleport.py @@ -420,7 +420,7 @@ def test_tx_wrong_pub(rx_start, tx_start, cap_menu, enter_complex, pick_menu_ite @pytest.mark.unfinalized @pytest.mark.parametrize('M', [2, 4]) -def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, dev, clear_ms, settings_set, +def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, dev, clear_miniscript, settings_set, fake_ms_txn, try_sign, bitcoind, cap_story, need_keypress, cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, ndef_parse_txn_psbt, press_nfc, nfc_read, settings_get, @@ -430,7 +430,7 @@ def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, dev, clear_ms, set all_out_styles = [af for af in unmap_addr_fmt.keys() if af != "p2tr"] num_outs = len(all_out_styles) - clear_ms() + clear_miniscript() use_regtest() # create a wallet, with 3 bip39 pw's @@ -534,7 +534,7 @@ def test_teleport_ms_sign(M, use_regtest, make_myself_wallet, dev, clear_ms, set assert got_txn -def test_teleport_big_ms(make_myself_wallet, clear_ms, fake_ms_txn, try_sign, cap_story, +def test_teleport_big_ms(make_myself_wallet, clear_miniscript, fake_ms_txn, try_sign, cap_story, need_keypress, cap_menu, pick_menu_item, grab_payload, rx_complete, press_select, ndef_parse_txn_psbt, set_master_key, goto_home, press_nfc, nfc_read, settings_get, settings_set, open_microsd, import_ms_wallet, @@ -542,7 +542,7 @@ def test_teleport_big_ms(make_myself_wallet, clear_ms, fake_ms_txn, try_sign, ca # define lots of wallets and do teleport from SD disk - clear_ms() + clear_miniscript() M, N = 2, 15 for i in range(5): keys = import_ms_wallet(M, N, name=f'ms{i}-test', unique=(i*73), accept=True, @@ -806,7 +806,7 @@ def test_teleport_miniscript_sign(dev, taproot, policy, get_cc_key, bitcoind, us pick_menu_item(name) pick_menu_item("Descriptors") pick_menu_item("Bitcoin Core") - text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False) text = text.replace("importdescriptors ", "").strip() # remove junk r1 = text.find("[")