From 594690d1870373888f09d1dcc0b11332520690fb Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Fri, 3 Feb 2023 03:50:13 +0100 Subject: [PATCH] Miniscript --- docs/miniscript.md | 24 + docs/taproot.md | 21 +- releases/EdgeChangeLog.md | 4 +- shared/actions.py | 2 +- shared/address_explorer.py | 86 +- shared/auth.py | 30 +- shared/bsms.py | 65 +- shared/chains.py | 11 +- shared/desc_utils.py | 443 ++++++ shared/descriptor.py | 917 ++++++++----- shared/export.py | 37 +- shared/flow.py | 2 + shared/manifest.py | 3 + shared/miniscript.py | 1807 +++++++++++++++++++++++++ shared/multisig.py | 469 ++----- shared/nfc.py | 48 +- shared/nvstore.py | 1 + shared/psbt.py | 449 +++--- shared/utils.py | 90 +- shared/wallet_base.py | 97 ++ stm32/version.mk | 2 +- testing/conftest.py | 6 + testing/data/taproot/taptree-sig.psbt | 1 - testing/data/taproot/taptree.psbt | 1 - testing/descriptor.py | 468 +++++++ testing/devtest/wipe_miniscript.py | 13 + testing/test_address_explorer.py | 4 +- testing/test_bsms.py | 12 +- testing/test_miniscript.py | 1685 +++++++++++++++++++++++ testing/test_multisig.py | 250 +--- testing/test_sign.py | 10 - testing/test_ux.py | 16 +- 32 files changed, 5707 insertions(+), 1367 deletions(-) create mode 100644 docs/miniscript.md create mode 100644 shared/desc_utils.py create mode 100644 shared/miniscript.py create mode 100644 shared/wallet_base.py delete mode 100644 testing/data/taproot/taptree-sig.psbt delete mode 100644 testing/data/taproot/taptree.psbt create mode 100644 testing/descriptor.py create mode 100644 testing/devtest/wipe_miniscript.py create mode 100644 testing/test_miniscript.py diff --git a/docs/miniscript.md b/docs/miniscript.md new file mode 100644 index 00000000..5a90e171 --- /dev/null +++ b/docs/miniscript.md @@ -0,0 +1,24 @@ +# Miniscript + +**COLDCARD®** Mk4 experimental `EDGE` versions +support Miniscript and MiniTapscript. + +## Import/Export + +* `Settings` -> `Miniscript` -> `Import from file` +* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) allowed for import +* `Settings` -> `Miniscript` -> `` -> `Descriptors` +* only [descriptors](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki) are exported + +## Address Explorer + +Same as with basic multisig. After miniscript wallet is imported, +item with `` is added to `Address Explorer` menu. + + +## Limitations +* no duplicate keys in miniscript (at least account in origin derivation has to be different) +* subderivation must be `<0;1>/*` (may be omitted during the import - is implied) +* only keys with key origin info `[xfp/p/a/t/h]xpub` +* maximum number of keys allowed in segwit v0 miniscript is 20 +* check MiniTapscript limitations in `docs/taproot.md` diff --git a/docs/taproot.md b/docs/taproot.md index 42bd5bfd..e2bb4d88 100644 --- a/docs/taproot.md +++ b/docs/taproot.md @@ -1,10 +1,9 @@ # Taproot -**COLDCARD®** Mk4 experimental `EDGE` versions will +**COLDCARD®** Mk4 experimental `EDGE` versions support Schnorr signatures ([BIP-0340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)), Taproot ([BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)) -and very limited Tapscript ([BIP-0342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki)) support. -Tapscript support will get more versatile with future iterations. +and Tapscript ([BIP-0342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki)) support. ## Output script (a.k.a address) generation @@ -18,7 +17,11 @@ MUST be generated with above-mentoned methods to be considered change. ## Allowed descriptors 1. Single signature wallet without script path: `tr(key)` -2. Tapscript multisig with internal key and one sortedmulti multisig script: `tr(internal_key, sortedmulti_a(MofN))` +2. Tapscript multisig with internal key and up to 8 leaf scripts: + * `tr(internal_key, sortedmulti_a(2,@0,@1))` + * `tr(internal_key, pk(@0))` + * `tr(internal_key, {sortedmulti_a(2,@0,@1),pk(@2)})` + * `tr(internal_key, {or_d(pk(@0),and_v(v:pkh(@1),older(1000))),pk(@2)})` ## Provably unspendable internal key @@ -27,7 +30,7 @@ for multisig. 1. use provably unspendable internal key H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs). This way is leaking the information that key path spending is not possible and therefore not recommended privacy-wise. - `tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0, sortedmulti_a(MofN))` + `tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0, sortedmulti_a(2,@0,@1))` 2. use COLDCARD specific placeholder `@` to let HWW pick a fresh integer r in the range 0...n-1 uniformly at random and use `H + rG` as internal key. COLDCARD will not store r and therefore user is not able to prove to other party how the key was generated and whether it is actually unspendable. @@ -35,16 +38,16 @@ for multisig. 3. pick a fresh integer r in the range 0...n-1 uniformly at random yourself and provide that in the descriptor. COLDCARD generates internal key with `H + rG`. It is possible to prove to other party that this internal key does not have a known discrete logarithm with respect to G by revealing r to a verifier who can then reconstruct how the internal key was created. - `tr(r=77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76, sortedmulti_a(MofN))` + `tr(r=77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76, sortedmulti_a(2,@0,@1))` ## Limitations ### Tapscript Limitations -In current version only `TREE` of depth 0 is allowed. Meaning that only one leaf script is allowed -and tagged hash of this single leaf script is also a merkle root. Only allowed script (for now) is `sortedmulti_a`. -Taproot multisig currently has artificial limit of max 32 signers (M=N=32). +In current version only `TREE` of max depth 4 is allowed (max 8 leaf script allowed). +Taproot single leaf multisig has artificial limit of max 32 signers (M=N=32). +Number of keys in taptree is limited to 32. If Coldcard can sign by both key path and script path - key path has precedence. diff --git a/releases/EdgeChangeLog.md b/releases/EdgeChangeLog.md index d87d7531..914a3f6e 100644 --- a/releases/EdgeChangeLog.md +++ b/releases/EdgeChangeLog.md @@ -7,7 +7,9 @@ - for experimental use. DO NOT use for large Bitcoin amounts. ``` -## 6.0.1X - 2023-0?-?? +## 6.1.0X - 2023-06-19 +- New Feature: Miniscript and MiniTapscript support (`docs/miniscript.md`) +- Enhancement: Tapscript up to 8 leafs - Address explorer display refined slightly (cosmetic) ## 6.0.0X - 2023-05-12 diff --git a/shared/actions.py b/shared/actions.py index 7d444eeb..bcc84364 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -769,7 +769,7 @@ async def start_login_sequence(): # from ux import idle_logout from glob import dis - import callgate + import callgate, version if version.mk_num < 4: # Block very obsolete versions. diff --git a/shared/address_explorer.py b/shared/address_explorer.py index 081dee11..d528f798 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -9,19 +9,14 @@ from menu import MenuSystem, MenuItem from public_constants import AFC_BECH32, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from multisig import MultisigWallet +from miniscript import MiniScriptWallet from uasyncio import sleep_ms +from ucollections import OrderedDict from uhashlib import sha256 -from ubinascii import hexlify as b2a_hex from glob import settings from auth import write_sig_file -from utils import addr_fmt_label +from utils import addr_fmt_label, truncate_address -def truncate_address(addr): - # Truncates address to width of screen, replacing middle chars - # - 16 chars screen width - # - but 2 lost at left (menu arrow, corner arrow) - # - want to show not truncated on right side - return addr[0:5] + '⋯' + addr[-6:] class KeypathMenu(MenuSystem): def __init__(self, path=None, nl=0): @@ -201,7 +196,11 @@ async def render(self): # if they have MS wallets, add those next for ms in MultisigWallet.iter_wallets(): if not ms.addr_fmt: continue - items.append(MenuItem(ms.name, f=self.pick_multisig, arg=ms)) + items.append(MenuItem(ms.name, f=self.pick_miniscript, arg=ms)) + + # if they have miniscript wallets, add those next + for msc in MiniScriptWallet.iter_wallets(): + items.append(MenuItem(msc.name, f=self.pick_miniscript, arg=msc)) else: items.append(MenuItem("Account: %d" % self.account_num, f=self.change_account)) @@ -223,10 +222,10 @@ async def pick_single(self, _1, _2, item): settings.put('axi', axi) # update last clicked address await self.show_n_addresses(path, addr_fmt, None) - async def pick_multisig(self, _1, _2, item): - ms_wallet = item.arg + async def pick_miniscript(self, _1, _2, item): + msc_wallet = item.arg settings.put('axi', item.label) # update last clicked address - await self.show_n_addresses(None, None, ms_wallet) + await self.show_n_addresses(None, None, msc_wallet) async def make_custom(self, *a): # picking a custom derivation path: makes a tree of menus, with chance @@ -256,7 +255,15 @@ async def show_n_addresses(self, path, addr_fmt, ms_wallet, start=0, n=10, allow from glob import dis, NFC, VD import version - def make_msg(change=0): + # speed up UI (do not recalculate addresses in while loop) + # cache can only have 2 values external, internal (0,1) + _cache = OrderedDict() + + def make_msg(change=0, start=start, n=n): + nonlocal _cache + if (change, start, n) in _cache: + return _cache[(change, start, n)] + export_msg = "Press (1) to save Address summary file to SD Card." if version.has_fatram and not ms_wallet: export_msg += " Press (2) to view QR Codes." @@ -288,26 +295,8 @@ def make_msg(change=0): # but show enough they can verify addrs shown elsewhere. # - makes a redeem script # - converts into addr - # - assumes 0/0 is first address. - for (i, paths, addr, script, ik, ikp) in ms_wallet.yield_addresses(start, n, change_idx=change): - if i == 0 and ik: - msg += "Taproot internal key:\n\n" - if ikp: - msg += ikp + "\n\n" - else: - msg += '%s (provably unspendable)\n\n' % ik - - if ms_wallet.N <= 4: - msg += "Taproot tree keys:\n\n" - - if i == 0 and ms_wallet.N <= 4: - msg += '\n'.join(paths) + '\n =>\n' - else: - msg += '.../%d/%d =>\n' % (change, i) - - addrs.append(addr) - msg += truncate_address(addr) + '\n\n' - dis.progress_bar_show(i/n) + # - assumes <0;1>/0 is first address. + msg, addrs = ms_wallet.make_addresses_msg(msg, start, n, change) else: # single-singer wallets @@ -329,10 +318,15 @@ def make_msg(change=0): if n > 1: msg += "Press (9) to see next group, (7) to go back. X to quit." + if len(_cache) < 4: + _cache[(change, start, n)] = (msg, addrs) + else: + # LIFO + _cache = OrderedDict(list(_cache.items())[:-1]) return msg, addrs - msg, addrs = make_msg() change = 0 + msg, addrs = make_msg(change, start) while 1: ch = await ux_show_story(msg, escape='1234679') @@ -380,31 +374,13 @@ def make_msg(change=0): else: continue # 3 in non-NFC mode - msg, addrs = make_msg(change) + msg, addrs = make_msg(change, start, n) def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, change=0): # Produce CSV file contents as a generator - if ms_wallet: - # For multisig, include redeem script and derivation for each signer - yield '"' + '","'.join(['Index', 'Payment Address', - '%s (%d of %d)' % ( - "Leaf Script" if ms_wallet.internal_key else "Redeem Script", - ms_wallet.M, ms_wallet.N - )] - + (['Derivation'] * ms_wallet.N) - + ["Taproot Internal Key"] if ms_wallet.internal_key else [] - ) + '"\n' - - for (idx, derivs, addr, script, ik, ikp) in ms_wallet.yield_addresses(start, n, change_idx=change): - ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode()) - ln += '","'.join(derivs) - if ik: - # internal xonly key with its derivation (if any) - ln += '","%s' % (ikp + ik) - ln += '"\n' - - yield ln + for line in ms_wallet.generate_address_csv(start, n, change): + yield line return diff --git a/shared/auth.py b/shared/auth.py index 9f660b48..9f059938 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1391,17 +1391,15 @@ def usb_show_address(addr_format, subpath): return active_request.address -class NewEnrollRequest(UserAuthorizedAction): - def __init__(self, ms, auto_export=False, bsms_index=None): +class NewMiniscriptEnrollRequest(UserAuthorizedAction): + def __init__(self, msc, auto_export=False, bsms_index=None): super().__init__() - self.wallet = ms + self.wallet = msc self.auto_export = auto_export self.bsms_index = bsms_index - # self.result ... will be re-serialized xpub - async def interact(self): - from multisig import MultisigOutOfSpace + from wallet_base import WalletOutOfSpace ms = self.wallet try: @@ -1413,9 +1411,9 @@ async def interact(self): from bsms import BSMSSettings BSMSSettings.signer_delete(self.bsms_index) if self.auto_export: - # save cosigner details now too - await ms.export_wallet_file('created on', - "\n\nImport that file onto the other Coldcards involved with this multisig wallet.") + # save cosigner details now too + await ms.export_wallet_file('created on', + "\n\nImport that file onto the other Coldcards involved with this multisig wallet.") await ms.export_electrum() else: @@ -1423,13 +1421,13 @@ async def interact(self): self.refused = True await ux_dramatic_pause("Refused.", 2) - except MultisigOutOfSpace: + except WalletOutOfSpace: return await self.failure('No space left') except BaseException as exc: self.failed = "Exception" sys.print_exception(exc) finally: - UserAuthorizedAction.cleanup() # because no results to store + UserAuthorizedAction.cleanup() # because no results to store if self.bsms_index is not None: # bsms special case, get him back to multisig menu from ux import the_ux, restore_menu @@ -1445,9 +1443,10 @@ async def interact(self): else: self.pop_menu() -def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None): +def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None, miniscript=False): # Offer to import (enroll) a new multisig wallet. Allow reject by user. from multisig import MultisigWallet + from miniscript import MiniScriptWallet UserAuthorizedAction.cleanup() @@ -1457,9 +1456,12 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_ # this call will raise on parsing errors, so let them rise up # and be shown on screen/over usb - ms = MultisigWallet.from_file(config, name=name) + if miniscript: + msc = MiniScriptWallet.from_file(config, name=name) + else: + msc = MultisigWallet.from_file(config, name=name) - UserAuthorizedAction.active_request = NewEnrollRequest(ms, bsms_index=bsms_index) + UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc, bsms_index=bsms_index) if ux_reset: # for USB case, and import from PSBT diff --git a/shared/bsms.py b/shared/bsms.py index 4182be8f..41da8b80 100644 --- a/shared/bsms.py +++ b/shared/bsms.py @@ -16,7 +16,8 @@ from files import CardSlot, CardMissingError, needs_microsd from ux import ux_show_story, ux_enter_number, restore_menu, ux_input_numbers, ux_input_text from ux import the_ux -from descriptor import MultisigDescriptor, append_checksum +from descriptor import Descriptor, Key, append_checksum +from miniscript import Sortedmulti, Number BSMS_VERSION = "BSMS 1.0" @@ -500,8 +501,6 @@ async def bsms_coordinator_round2(menu, label, item): bsms_settings_index = item.arg chain = chains.current_chain() - # or xpub or tpub as we use descriptors (no SLIP-132 allowed) - ext_key_prefix = "%spub" % chain.slip132[AF_CLASSIC].hint force_vdisk = False # this can be RAM intensive (max 15 F mapped to keys) @@ -675,7 +674,6 @@ def get_token(index): return keys = [] - nodes = [] dis.fullscreen("Validating...") for i, data in enumerate(r1_data): # divided in the loop with number of in-loop occurences of 'dis.progress_bar_show' (currently 5) @@ -686,38 +684,34 @@ def get_token(index): ) version, tok, key_exp, description, sig = data.strip().split("\n") assert tok == token, "Token mismatch saved %s, received from signer %s" % (token, tok) - koi, ext_key = MultisigDescriptor.parse_key_orig_info(key_exp) - dis.progress_bar_show(i_div_N / 5) - xfp_str, derivation = koi[:8], "m" + koi[8:] - assert ext_key.startswith(ext_key_prefix), "Expected %s, got %s" % ( - ext_key_prefix, ext_key[:4] - ) - node = ngu.hdnode.HDNode() - node.deserialize(ext_key) + key = Key.from_string(key_exp) dis.progress_bar_show(i_div_N / 4) msg = signer_data_round1(token, key_exp, description) digest = chain.hash_message(msg.encode()) dis.progress_bar_show(i_div_N / 3) _, recovered_pk = chains.verify_recover_pubkey(a2b_base64(sig), digest) - assert node.pubkey() == recovered_pk, "Recovered key from signature does not equal key provided. Wrong signature?" + assert key.node.pubkey() == recovered_pk, "Recovered key from signature does not equal key provided. Wrong signature?" dis.progress_bar_show(i_div_N / 2) - keys.append((xfp_str, derivation, ext_key)) - nodes.append(node) + keys.append(key) dis.progress_bar_show(i_div_N / 1) dis.fullscreen("Generating...") - desc_obj = MultisigDescriptor(M=M, N=N, keys=keys, addr_fmt=addr_fmt) - desc = desc_obj._serialize(int_ext=True) + miniscript = Sortedmulti(Number(M), *keys) + desc_obj = Descriptor(miniscript=miniscript) + desc_obj.set_from_addr_fmt(addr_fmt) + desc = desc_obj.to_string(checksum=False) desc = desc.replace("<0;1>/*", "**") if not is_encrypted: # append checksum for unencrypted BSMS desc = append_checksum(desc) - for i, node in enumerate(nodes): - node.derive(0, False) # external is always first our coordinating "0/*,1/*" + for i, ko in enumerate(keys): + ko.node.derive(0, False) # external is always first our coordinating "0/*,1/*" dis.progress_bar_show(i / N) - script = make_redeem_script(M, nodes, 0) # first address + # TODO this can be done with .script_pubkey + script = make_redeem_script(M, [k.node for k in keys], 0) # first address addr = chain.p2sh_address(addr_fmt, script) + # == r2_data = coordinator_data_round2(desc, addr) dis.progress_bar_show(1) @@ -1027,8 +1021,7 @@ async def bsms_signer_round2(menu, label, item): # if checksum is provided we better verify it # remove checksum as we need to replace /** - desc_template, csum = MultisigDescriptor.checksum_check(desc_template) - + desc_template, csum = Descriptor.checksum_check(desc_template) desc = desc_template.replace("/**", "/0/*") dis.progress_bar_show(0.1) @@ -1036,8 +1029,8 @@ async def bsms_signer_round2(menu, label, item): ms_name = "bsms_" + desc[-4:] - # will raise ValueError if not "sortedmulti" descriptor script type - desc_obj = MultisigDescriptor.parse(desc) + desc_obj = Descriptor.from_string(desc) + desc_obj.legacy_ms_compat() dis.progress_bar_show(0.2) @@ -1046,15 +1039,11 @@ async def bsms_signer_round2(menu, label, item): nodes = [] progress_counter = 0.2 # last displayed progress # (desired value after loop - last displayed progress) / N - progress_chunk = (0.5 - progress_counter) / desc_obj.N - for xfp, deriv_path, ext_key in desc_obj.keys: - assert ext_key.startswith(ext_key_prefix), \ - "Expected %s, got %s" % (ext_key_prefix, ext_key[:4]) - node = ngu.hdnode.HDNode() - node.deserialize(ext_key) - if xfp == my_xfp: - my_keys.append((deriv_path, ext_key)) - nodes.append(node) + progress_chunk = (0.5 - progress_counter) / len(desc_obj.miniscript.keys) + for key in desc_obj.keys: + if key.origin.cc_fp == my_xfp: + my_keys.append(key) + nodes.append(key.node) progress_counter += progress_chunk dis.progress_bar_show(progress_counter) @@ -1062,24 +1051,24 @@ async def bsms_signer_round2(menu, label, item): assert num_my_keys <= 1, "Multiple %s keys in descriptor (%d)" % (xfp2str(my_xfp), num_my_keys) assert num_my_keys == 1, "My key %s missing in descriptor." % xfp2str(my_xfp) - deriv_path, desc_ext_key = my_keys[0] with stash.SensitiveValues() as sv: - node = sv.derive_path(deriv_path) + node = sv.derive_path(my_keys[0].origin.str_derivation()) ext_key = chain.serialize_public(node) - assert ext_key == desc_ext_key, "My key %s missing in descriptor." % ext_key + assert ext_key == my_keys[0].extended_public_key(), "My key %s missing in descriptor." % ext_key dis.progress_bar_show(0.55) # check address is correct progress_counter = 0.55 # last displayed progress # (desired value after loop - last displayed progress) / N - progress_chunk = (0.9 - progress_counter) / desc_obj.N + M, N = desc_obj.miniscript.m_n() + progress_chunk = (0.9 - progress_counter) / N for node in nodes: node.derive(0, False) # external is always first in our allowed path restrictions progress_counter += progress_chunk dis.progress_bar_show(progress_counter) - script = make_redeem_script(desc_obj.M, nodes, 0) # first address + script = make_redeem_script(M, nodes, 0) # first address dis.progress_bar_show(0.95) calc_addr = chain.p2sh_address(desc_obj.addr_fmt, script) diff --git a/shared/chains.py b/shared/chains.py index 1c56db2d..0b9017bd 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -38,11 +38,14 @@ def taptweak(internal_key, tweak=None): xo_pubkey_tweaked = xo_pubkey.tweak_add(tweak) return xo_pubkey_tweaked.to_bytes() -def tapleaf_hash(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT): - # Tapleaf hash requires one to provide version, version consists - # of 7 msb +def tapscript_serialize(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT): + # leaf version is only 7 msb lv = leaf_version % TAPROOT_LEAF_MASK - return ngu.secp256k1.tagged_sha256(b"TapLeaf", bytes([lv]) + ser_string(script)) + return bytes([lv]) + ser_string(script) + +def tapleaf_hash(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT): + return ngu.secp256k1.tagged_sha256(b"TapLeaf", + tapscript_serialize(script, leaf_version)) class ChainsBase: diff --git a/shared/desc_utils.py b/shared/desc_utils.py new file mode 100644 index 00000000..708a8ef6 --- /dev/null +++ b/shared/desc_utils.py @@ -0,0 +1,443 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# Copyright (c) 2020 Stepan Snigirev MIT License embit/arguments.py +# +import ngu, chains +from io import BytesIO +from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_CLASSIC, AF_P2TR +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 +from serializations import ser_compact_size + + +WILDCARD = "*" +PROVABLY_UNSPENDABLE = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + +INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def polymod(c, val): + c0 = c >> 35 + c = ((c & 0x7ffffffff) << 5) ^ val + if (c0 & 1): + c ^= 0xf5dee51989 + if (c0 & 2): + c ^= 0xa9fdca3312 + if (c0 & 4): + c ^= 0x1bab10e32d + if (c0 & 8): + c ^= 0x3706b1677a + if (c0 & 16): + c ^= 0x644d626ffd + + return c + +def descriptor_checksum(desc): + c = 1 + cls = 0 + clscount = 0 + for ch in desc: + pos = INPUT_CHARSET.find(ch) + if pos == -1: + raise ValueError(ch) + + c = polymod(c, pos & 31) + cls = cls * 3 + (pos >> 5) + clscount += 1 + if clscount == 3: + c = polymod(c, cls) + cls = 0 + clscount = 0 + + if clscount > 0: + c = polymod(c, cls) + for j in range(0, 8): + c = polymod(c, 0) + c ^= 1 + + rv = '' + for j in range(0, 8): + rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] + + return rv + +def append_checksum(desc): + return desc + "#" + descriptor_checksum(desc) + + +def parse_desc_str(string): + """Remove comments, empty lines and strip line. Produce single line string""" + res = "" + for l in string.split("\n"): + strip_l = l.strip() + if not strip_l: + continue + if strip_l.startswith("#"): + continue + res += strip_l + 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(" + PROVABLY_UNSPENDABLE + ",sortedmulti_a(M,%s,...))" + else: + return None + descriptor_template = descriptor_template % key_exp + return descriptor_template + + +def read_until(s, chars=b",)(#"): + # TODO potential infinite loop + # what is the longest possible element? (proly some raw( but that is unsupported) + # + res = b"" + chunk = b"" + char = None + while True: + chunk = s.read(1) + if len(chunk) == 0: + return res, None + if chunk in chars: + return res, chunk + res += chunk + return res, None + + +class KeyOriginInfo: + def __init__(self, fingerprint: bytes, derivation: list): + self.fingerprint = fingerprint + self.derivation = derivation + self.cc_fp = swab32(int(b2a_hex(self.fingerprint).decode(), 16)) + + def str_derivation(self): + return keypath_to_str(self.derivation, prefix='m/', skip=0) + + def psbt_derivation(self): + res = [self.cc_fp] + for i in self.derivation: + res.append(i) + return res + + @classmethod + def from_string(cls, s: str): + arr = s.split("/") + xfp = a2b_hex(arr[0]) + assert len(xfp) == 4 + arr[0] = "m" + path = "/".join(arr) + derivation = str_to_keypath(xfp, path)[1:] # ignoring xfp here, already stored + return cls(xfp, derivation) + + def __str__(self): + return "%s/%s" % (b2a_hex(self.fingerprint).decode(), + keypath_to_str(self.derivation, prefix='', skip=0).replace("'", "h")) + + +class KeyDerivationInfo: + def __init__(self, indexes=[[0, 1], WILDCARD]): + self.indexes = indexes + self.validate() + + @property + def is_int_ext(self): + if isinstance(self.indexes[0], list): + return True + return False + + @property + def is_external(self): + if self.is_int_ext or self.indexes[0] == 0: + return True + return False + + def validate(self): + # COLDCARD specific restrictions + assert len(self.indexes) == 2, "Key derivation too long" + assert self.indexes[0] in (0, 1) or self.indexes[0] == [0, 1], "Invalid key derivation - non standard" + assert self.indexes[1] == WILDCARD, "Key derivation missing wildcard (*)" + + @property + def branches(self): + if self.is_int_ext: + return self.indexes[0] + else: + return [self.indexes[0]] + + @classmethod + def from_string(cls, s): + fail_msg = "Cannot use hardened sub derivation path" + if not s: + return cls() + res = [] + for i in s.split("/"): + start_i = i.find("<") + if start_i != -1: + end_i = s.find(">") + assert end_i + inner = s[start_i+1:end_i] + assert ";" in inner + res.append([int(i) for i in inner.split(";")]) + else: + if i == WILDCARD: + res.append(WILDCARD) + else: + assert "'" not in i, fail_msg + assert "h" not in i, fail_msg + res.append(int(i)) + return cls(res) + + def to_string(self, external=True, internal=True): + if internal is True and external is False: + return "1/*" + elif internal is False and external is True: + return "0/*" + else: + return "<0;1>/*" + + def to_int_list(self, branch_idx, idx): + assert branch_idx in self.indexes[0] + return [branch_idx, idx] + + +class Key: + def __init__(self, node, origin, derivation=None, taproot=False): + self.origin = origin + self.node = node + self.derivation = derivation + self.taproot = taproot + if not isinstance(self.node, bytes): + assert self.origin, "Key origin info is required" + + def __eq__(self, other): + return self.origin.psbt_derivation() == other.origin.psbt_derivation() + + def __hash__(self): + return hash(tuple(self.origin.psbt_derivation())) + + def __len__(self): + return 34 - int(self.taproot) # <33:sec> or <32:xonly> + + @property + def fingerprint(self): + return self.origin.fingerprint + + def serialize(self): + return self.key_bytes() + + def compile(self): + d = self.serialize() + return ser_compact_size(len(d)) + d + + @classmethod + def parse(cls, s): + first = s.read(1) + origin = None + if first == b"[": + prefix, char = read_until(s, b"]") + if char != b"]": + raise ValueError("Invalid key - missing ] in key origin info") + origin = KeyOriginInfo.from_string(prefix.decode()) + else: + s.seek(-1, 1) + k, char = read_until(s, b",)/") + der = b"" + if char == b"/": + der, char = read_until(s, b"<,)") + if char == b"<": + der += b"<" + branch, char = read_until(s, b">") + if char is None: + raise ValueError("Failed reading the key, missing >") + der += branch + b">" + rest, char = read_until(s, b",)") + der += rest + if char is not None: + s.seek(-1, 1) + # parse key + node = cls.parse_key(k) + der = KeyDerivationInfo.from_string(der.decode()) + return cls(node, origin, der) + + @classmethod + def parse_key(cls, key_str): + # or xpub or tpub as we use descriptors (SLIP-132 NOT allowed) + if key_str[1:4].lower() == b"pub": + ext_key_prefix = b"%spub" % chains.current_chain().slip132[AF_CLASSIC].hint + # extended key + assert key_str.startswith(ext_key_prefix), ext_key_prefix.decode() + " required" + node = ngu.hdnode.HDNode() + node.deserialize(key_str) + else: + # only unspendable keys can be bare pubkeys - for now + # TODO + # if b"unspend(" in key_str: + # node = ngu.hdnode.HDNode() + # chain_code = key_str.replace(b"unspend(", b"").replace(b")", b"") + # node.chaincode = a2b_hex(chain_code) + # node.pubkey = a2b_hex("02" + PROVABLY_UNSPENDABLE) + H = a2b_hex(PROVABLY_UNSPENDABLE) + if b"r=" in key_str: + _, r = key_str.split(b"=") + if r == b"@": + # pick a fresh integer r in the range 0...n-1 uniformly at random and use H + rG + kp = ngu.secp256k1.keypair() + else: + # H + rG where r is provided from user + r = a2b_hex(r) + assert len(r) == 32, "r != 32" + kp = ngu.secp256k1.keypair(r) + + H_xo = ngu.secp256k1.xonly_pubkey(H) + + node = H_xo.tweak_add(kp.xonly_pubkey().to_bytes()).to_bytes() + + elif a2b_hex(key_str) == H: + node = H + else: + node = a2b_hex(key_str) + + assert len(node) == 32, "invalid pk %d %s" % (len(node), node) + + return node + + def derive(self, idx=0): + if isinstance(self.node, bytes): + return self + new_node = self.node.copy() + new_node.derive(idx, False) + if self.origin: + origin = KeyOriginInfo(self.origin.fingerprint, self.origin.derivation + [idx]) + else: + origin = KeyOriginInfo(self.node.my_fp(), [idx]) + # empty derivation + derivation = None + return type(self)(new_node, origin, derivation, taproot=self.taproot) + + @classmethod + def read_from(cls, s, taproot=False): + return cls.parse(s) + + @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()) + + 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)) + + @property + def can_derive(self): + return True if self.derivation else False + + @property + def is_extended(self): + return True + + @property + def is_wildcard(self): + return True + + @property + def is_provably_unspendable(self): + if isinstance(self.node, bytes): + return True + return False + + @property + def prefix(self): + if self.origin: + return "[%s]" % self.origin + return "" + + def key_bytes(self): + kb = self.node + if not isinstance(kb, bytes): + kb = self.node.pubkey() + if self.taproot: + if len(kb) == 33: + kb = kb[1:] + assert len(kb) == 32 + return kb + + def extended_public_key(self): + return chains.current_chain().serialize_public(self.node) + + def to_string(self, external=True, internal=True): + key = self.prefix + if isinstance(self.node, ngu.hdnode.HDNode): + key += self.extended_public_key() + if self.derivation: + key += "/" + self.derivation.to_string(external, internal) + else: + key += b2a_hex(self.node).decode() + + return key + + @classmethod + def from_string(cls, s): + s = BytesIO(s.encode()) + return cls.parse(s) + + +def fill_policy(policy, keys, external=True, internal=True): + policy = policy + keys_len = len(keys) + for i in range(keys_len - 1, -1, -1): + k = keys[i] + ph = "@%d" % i + ph_len = len(ph) + while True: + ix = policy.find(ph) + if ix == -1: + break + if not isinstance(k, str): + k_str = k.to_string(external, internal) + else: + k_str = k + if external and not internal: + k_str = k_str.replace("<0;1>", "0") + if internal and not external: + k_str = k_str.replace("<0;1>", "1") + + x = policy[ix:ix + ph_len] + assert x == ph + policy = policy[:ix] + k_str + policy[ix + ph_len:] + return policy + + +def taproot_tree_helper(scripts): + from miniscript import Miniscript + + if isinstance(scripts, Miniscript): + script = scripts.compile() + assert isinstance(script, bytes) + h = ngu.secp256k1.tagged_sha256(b"TapLeaf", chains.tapscript_serialize(script)) + return [(chains.TAPROOT_LEAF_TAPSCRIPT, script, bytes())], h + if len(scripts) == 1: + return taproot_tree_helper(scripts[0]) + + split_pos = len(scripts) // 2 + left, left_h = taproot_tree_helper(scripts[0:split_pos]) + right, right_h = taproot_tree_helper(scripts[split_pos:]) + left = [(version, script, control + right_h) for version, script, control in left] + right = [(version, script, control + left_h) for version, script, control in right] + if right_h < left_h: + right_h, left_h = left_h, right_h + h = ngu.secp256k1.tagged_sha256(b"TapBranch", left_h + right_h) + return left + right, h diff --git a/shared/descriptor.py b/shared/descriptor.py index d0db1013..d09cb886 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -1,271 +1,447 @@ -# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -# descriptor.py - Bitcoin Core's descriptors and their specialized checksums. +# Copyright (c) 2020 Stepan Snigirev MIT License embit/descriptor.py # -# Based on: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp -# -from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2TR - -MULTI_FMT_TO_SCRIPT = { - AF_P2SH: "sh(%s)", - AF_P2WSH_P2SH: "sh(wsh(%s))", - AF_P2WSH: "wsh(%s)", - AF_P2TR: "tr(%s)", - None: "wsh(%s)", - # hack for tests - "p2sh": "sh(%s)", - "p2sh-p2wsh": "sh(wsh(%s))", - "p2wsh-p2sh": "sh(wsh(%s))", - "p2wsh": "wsh(%s)", - "p2tr": "tr(%s)" -} - -SINGLE_FMT_TO_SCRIPT = { - AF_P2WPKH: "wpkh(%s)", - AF_CLASSIC: "pkh(%s)", - AF_P2WPKH_P2SH: "sh(wpkh(%s))", - AF_P2TR: "tr(%s)", - None: "wpkh(%s)", - "p2pkh": "pkh(%s)", - "p2wpkh": "wpkh(%s)", - "p2sh-p2wpkh": "sh(wpkh(%s))", - "p2wpkh-p2sh": "sh(wpkh(%s))", - "p2tr": "tr(%s)", -} - -PROVABLY_UNSPENDABLE = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" -INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " -CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - -try: - import ngu - from utils import xfp2str, str2xfp - from ubinascii import unhexlify as a2b_hex - from ubinascii import hexlify as b2a_hex -except ModuleNotFoundError: - import struct - from binascii import unhexlify as a2b_hex - from binascii import hexlify as b2a_hex - # assuming not micro python - def xfp2str(xfp): - # Standardized way to show an xpub's fingerprint... it's a 4-byte string - # and not really an integer. Used to show as '0x%08x' but that's wrong endian. - return b2a_hex(struct.pack('> 35 - c = ((c & 0x7ffffffff) << 5) ^ val - if (c0 & 1): - c ^= 0xf5dee51989 - if (c0 & 2): - c ^= 0xa9fdca3312 - if (c0 & 4): - c ^= 0x1bab10e32d - if (c0 & 8): - c ^= 0x3706b1677a - if (c0 & 16): - c ^= 0x644d626ffd - - return c - -def descriptor_checksum(desc): - c = 1 - cls = 0 - clscount = 0 - for ch in desc: - pos = INPUT_CHARSET.find(ch) - if pos == -1: - raise ValueError(ch) - - c = polymod(c, pos & 31) - cls = cls * 3 + (pos >> 5) - clscount += 1 - if clscount == 3: - c = polymod(c, cls) - cls = 0 - clscount = 0 - - if clscount > 0: - c = polymod(c, cls) - for j in range(0, 8): - c = polymod(c, 0) - c ^= 1 - - rv = '' - for j in range(0, 8): - rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] - - return rv - -def append_checksum(desc): - return desc + "#" + descriptor_checksum(desc) - - -def parse_desc_str(string): - """Remove comments, empty lines and strip line. Produce single line string""" - res = "" - for l in string.split("\n"): - strip_l = l.strip() - if not strip_l: - continue - if strip_l.startswith("#"): - continue - res += strip_l - 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(" + PROVABLY_UNSPENDABLE + ",sortedmulti_a(M,%s,...))" - else: - return None - descriptor_template = descriptor_template % key_exp - return descriptor_template - - -class Descriptor: - __slots__ = ( - "keys", - "addr_fmt", - ) - - def __init__(self, keys, addr_fmt): +class Tapscript: + def __init__(self, tree=None, keys=None, policy=None): + self.tree = tree self.keys = keys - self.addr_fmt = addr_fmt + self.policy = policy + self._merkle_root = None @staticmethod - def checksum_check(desc_w_checksum: str, csum_required=False): - try: - desc, checksum = desc_w_checksum.split("#") - except ValueError: - if csum_required: - raise ValueError("Missing descriptor checksum") - return desc_w_checksum, None - - calc_checksum = descriptor_checksum(desc) - if calc_checksum != checksum: - raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum)) - return desc, checksum + def iter_leaves(tree): + if isinstance(tree, Miniscript): + yield tree + else: + assert isinstance(tree, list) + for lv in tree: + yield from Tapscript.iter_leaves(lv) - @staticmethod - def parse_key_orig_info(key: str): - # key origin info is required for our MultisigWallet - close_index = key.find("]") - if key[0] != "[" or close_index == -1: - raise ValueError("Key origin info is required for %s" % (key)) - key_orig_info = key[1:close_index] # remove brackets - key = key[close_index + 1:] - assert "/" in key_orig_info, "Malformed key derivation info" - return key_orig_info, key + @property + def merkle_root(self): + if not self._merkle_root: + self.process_tree() + return self._merkle_root @staticmethod - def parse_key_derivation_info(key: str): - invalid_subderiv_msg = "Invalid subderivation path - only 0/* or <0;1>/* allowed" - slash_split = key.split("/") - assert len(slash_split) > 1, invalid_subderiv_msg - if all(["h" not in elem and "'" not in elem for elem in slash_split[1:]]): - assert slash_split[-1] == "*", invalid_subderiv_msg - assert slash_split[-2] in ["0", "<0;1>", "<1;0>"], invalid_subderiv_msg - assert len(slash_split[1:]) == 2, invalid_subderiv_msg - return slash_split[0] + def _derive(tree, idx, key_map): + if isinstance(tree, Miniscript): + return tree.derive(idx, key_map) else: - raise ValueError("Cannot use hardened sub derivation path") - - def checksum(self): - return descriptor_checksum(self._serialize()) - - def serialize_keys(self, internal=False, int_ext=False, keys=None): - to_do = keys if keys is not None else self.keys - result = [] - for xfp, deriv, xpub in to_do: - if deriv[0] == "m": - # get rid of 'm' - deriv = deriv[1:] - elif deriv[0] != "/": - # input "84'/0'/0'" would lack slash separtor with xfp - deriv = "/" + deriv - if not isinstance(xfp, str): - xfp = xfp2str(xfp) - koi = xfp + deriv - # normalize xpub to use h for hardened instead of ' - key_str = "[%s]%s" % (koi.lower(), xpub) - if int_ext: - key_str = key_str + "/" + "<0;1>" + "/" + "*" - else: - key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"]) - result.append(key_str.replace("'", "h")) - return result - - def _serialize(self, internal=False, int_ext=False) -> str: - """Serialize without checksum""" - assert len(self.keys) == 1, "Multiple keys for single signature script" - desc_base = SINGLE_FMT_TO_SCRIPT[self.addr_fmt] - inner = self.serialize_keys(internal=internal, int_ext=int_ext)[0] - return desc_base % (inner) - - def serialize(self, internal=False, int_ext=False) -> str: - """Serialize with checksum""" - return append_checksum(self._serialize(internal=internal, int_ext=int_ext)) + if len(tree) == 1 and isinstance(tree[0], Miniscript): + return tree[0].derive(idx, key_map) + l, r = tree + return [Tapscript._derive(l, idx, key_map), + Tapscript._derive(r, idx, key_map)] + + def derive(self, idx=0): + from collections import OrderedDict + derived_keys = OrderedDict() + for k in self.keys: + derived_keys[k] = k.derive(idx) + tree = Tapscript._derive(self.tree, idx, derived_keys) + return type(self)(tree, policy=self.policy, keys=list(derived_keys.values())) + + def process_tree(self): + info, mr = taproot_tree_helper(self.tree) + self._merkle_root = mr + return info, mr @classmethod - def parse(cls, desc_w_checksum: str) -> "Descriptor": - # remove garbage - desc_w_checksum = parse_desc_str(desc_w_checksum) - # check correct checksum - desc, checksum = cls.checksum_check(desc_w_checksum) - # legacy - if desc.startswith("pkh("): - addr_fmt = AF_CLASSIC - tmp_desc = desc.replace("pkh(", "") - tmp_desc = tmp_desc.rstrip(")") + def read_from(cls, s): + num_leafs = 0 + depth = 0 + tapscript = [] + p0 = s.read(1) + if p0 != b"{": + # depth zero + s.seek(-1, 1) + alone = Miniscript.read_from(s, taproot=True) + alone.is_sane(taproot=True) + alone.verify() + tapscript.append(alone) + num_leafs += 1 + else: + assert p0 == b"{" + depth += 1 + itmp = None + itmp_p = None + while True: + p1 = s.read(1) + if p1 == b'': + break + elif p1 == b")": + s.seek(-1, 1) + break + elif p1 == b",": + continue + elif p1 == b"{": + if itmp is None: + itmp = [] + else: + if itmp_p: + itmp[itmp_p].append([]) + else: + itmp.append(([])) + itmp_p = -1 + + depth += 1 + continue + elif p1 == b"}": + depth -= 1 + if depth == 1: + tapscript.append(itmp) + itmp = None + + if depth <= 2: + itmp_p = None + continue + + s.seek(-1, 1) + item = Miniscript.read_from(s, taproot=True) + item.is_sane(taproot=True) + item.verify() + num_leafs += 1 + if itmp is None: + tapscript.append(item) + else: + if itmp_p and depth == 4: + itmp[itmp_p][itmp_p].append(item) + elif itmp_p: + itmp[itmp_p].append(item) + else: + itmp.append(item) - # native segwit - elif desc.startswith("wpkh("): - addr_fmt = AF_P2WPKH - tmp_desc = desc.replace("wpkh(", "") - tmp_desc = tmp_desc.rstrip(")") + assert num_leafs <= 8, "num_leafs > 8" + ts = cls(tapscript) + ts.parse_policy() + return ts - # wrapped segwit - elif desc.startswith("sh(wpkh("): - addr_fmt = AF_P2WPKH_P2SH - tmp_desc = desc.replace("sh(wpkh(", "") - tmp_desc = tmp_desc.rstrip("))") + def parse_policy(self): + self.policy, self.keys = self._parse_policy(self.tree, []) - # wrapped segwit - elif desc.startswith("tr("): - addr_fmt = AF_P2TR - tmp_desc = desc.replace("tr(", "") - tmp_desc = tmp_desc.rstrip(")") + @staticmethod + def _parse_policy(tree, all_keys): + if isinstance(tree, Miniscript): + keys, leaf_str = tree.keys, tree.to_string() + for k in keys: + if k not in all_keys: + all_keys.append(k) + k_str = k.to_string() + k_idx = all_keys.index(k) + leaf_str = leaf_str.replace(k_str, chr(64) + str(k_idx)) + return leaf_str, all_keys + else: + assert isinstance(tree, list) + if len(tree) == 1 and isinstance(tree[0], Miniscript): + keys, leaf_str = tree[0].keys, tree[0].to_string() + for k in keys: + if k not in all_keys: + all_keys.append(k) + k_str = k.to_string() + k_idx = all_keys.index(k) + leaf_str = leaf_str.replace(k_str, chr(64) + str(k_idx)) + return leaf_str, all_keys + else: + l, r = tree + ll, all_keys = Tapscript._parse_policy(l, all_keys) + rr, all_keys = Tapscript._parse_policy(r, all_keys) + return "{" + ll + "," + rr + "}", all_keys + @staticmethod + def script_tree(tree): + if isinstance(tree, Miniscript): + return b2a_hex(chains.tapscript_serialize(tree.compile())).decode() else: - raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh(.") + assert isinstance(tree, list) + if len(tree) == 1 and isinstance(tree[0], Miniscript): + return b2a_hex(chains.tapscript_serialize(tree[0].compile())).decode() + else: + l, r = tree + ll = Tapscript.script_tree(l) + rr = Tapscript.script_tree(r) + return "{" + ll + "," + rr + "}" - koi, key = cls.parse_key_orig_info(tmp_desc) - if key[0:4] not in ["tpub", "xpub"]: - raise ValueError("Only extended public keys are supported") + def to_string(self, external=True, internal=True): + return fill_policy(self.policy, self.keys, external, internal) - xpub = cls.parse_key_derivation_info(key) - xfp = str2xfp(koi[:8]) - origin_deriv = "m" + koi[8:] - return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt) +class Descriptor: + def __init__(self, miniscript=None, sh=False, wsh=True, key=None, wpkh=True, + taproot=False, tapscript=None): + if key is None and miniscript is None: + raise DescriptorException("Provide either miniscript or a key") + + self.sh = sh + self.wsh = wsh + self.key = key + self.miniscript = miniscript + self.wpkh = wpkh + self.taproot = taproot + self.tapscript = tapscript + + if taproot: + if self.key: + self.key.taproot = True + for k in self.keys: + k.taproot = taproot + + def legacy_ms_compat(self): + if not (self.is_sortedmulti and self.addr_fmt in (AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH)): + raise ValueError("Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. " + "MUST be sortedmulti.") + + def validate(self): + from glob import settings + if self.miniscript: + if self.is_basic_multisig: + assert len(self.keys) <= MAX_SIGNERS + else: + assert len(self.keys) <= 20 + self.miniscript.verify() + if self.miniscript.type != "B": + raise DescriptorException("Top level miniscript should be 'B'") + + has_mine = 0 + my_xfp = settings.get('xfp') + to_check = self.keys.copy() + if self.tapscript: + assert len(self.keys) <= MAX_TR_SIGNERS + assert self.key # internal key (would fail during parse) + if not isinstance(self.key.node, bytes): + to_check += [self.key] + else: + assert self.key is None and self.miniscript, "not miniscript" + + for k in to_check: + xfp = k.origin.cc_fp + deriv = k.origin.str_derivation() + xpub = k.extended_public_key() + deriv = cleanup_deriv_path(deriv) + is_mine, _ = check_xpub(xfp, xpub, deriv, chains.current_chain().ctype, + my_xfp, False) + if is_mine: + has_mine += 1 + + assert has_mine != 0, 'my key not included' + + def storage_policy(self): + if self.tapscript: + return self.tapscript.policy + + s = self.miniscript.to_string() + for i, k in enumerate(self.keys): + s = s.replace(k.to_string(), chr(64) + str(i)) + return s + + def ux_policy(self): + if self.tapscript: + return "Taproot tree keys:\n\n" + self.tapscript.policy + + return self.storage_policy() + + @property + def script_len(self): + if self.taproot: + return 34 # OP_1 <32:xonly> + if self.miniscript: + return len(self.miniscript) + if self.wpkh: + return 22 # 00 <20:pkh> + return 25 # OP_DUP OP_HASH160 <20:pkh> OP_EQUALVERIFY OP_CHECKSIG + + def xfp_paths(self): + return [ + key.origin.psbt_derivation() + for key in self.keys + if key.origin + ] + + @property + def is_wildcard(self): + return any([key.is_wildcard for key in self.keys]) + + @property + def is_wrapped(self): + return self.sh and self.is_segwit + + @property + def is_legacy(self): + return not (self.is_segwit or self.is_taproot) + + @property + def is_segwit(self): + return (self.wsh and self.miniscript) or (self.wpkh and self.key) or self.taproot + + @property + def is_pkh(self): + return self.key is not None and not self.taproot + + @property + def is_taproot(self): + return self.taproot + + @property + def is_basic_multisig(self): + return self.miniscript and self.miniscript.NAME in ["multi", "sortedmulti"] + + @property + def is_sortedmulti(self): + return self.is_basic_multisig and self.miniscript.NAME == "sortedmulti" + + @property + def keys(self): + if self.tapscript: + return self.tapscript.keys + elif self.key: + return [self.key] + return self.miniscript.keys + + @property + def addr_fmt(self): + if self.sh and not self.wsh: + af = AF_P2SH + elif self.wsh and not self.sh: + af = AF_P2WSH + elif self.sh and self.wsh: + af = AF_P2WSH_P2SH + elif self.taproot: + af = AF_P2TR + elif self.sh and self.wpkh: + af = AF_P2WPKH_P2SH + elif self.wpkh and not self.sh: + af = AF_P2WPKH + else: + af = AF_CLASSIC + return af + + def set_from_addr_fmt(self, addr_fmt): + self.taproot = False + self.wsh = False + self.wpkh = False + self.sh = False + if addr_fmt == AF_P2TR: + self.taproot = True + assert self.key + elif addr_fmt == AF_P2WPKH: + self.wpkh = True + self.miniscript = None + assert self.key + elif addr_fmt == AF_P2WPKH_P2SH: + self.wpkh = True + self.sh = True + self.miniscript = None + assert self.key + elif addr_fmt == AF_P2SH: + self.sh = True + assert self.miniscript + assert not self.key + elif addr_fmt == AF_P2WSH: + self.wsh = True + assert self.miniscript + assert not self.key + elif addr_fmt == AF_P2WSH_P2SH: + self.wsh = True + self.sh = True + assert self.miniscript + assert not self.key + else: + # AF_CLASSIC + assert self.key + assert not self.miniscript + + def scriptpubkey_type(self): + if self.is_taproot: + return "p2tr" + if self.sh: + return "p2sh" + if self.is_pkh: + if self.is_legacy: + return "p2pkh" + if self.is_segwit: + return "p2wpkh" + else: + return "p2wsh" + + def derive(self, idx=0): + if self.taproot: + return type(self)( + None, + self.sh, + self.wsh, + self.key.derive(idx), + self.wpkh, + self.taproot, + tapscript=self.tapscript.derive(idx), + ) + if self.miniscript: + return type(self)( + self.miniscript.derive(idx), + self.sh, + self.wsh, + None, + self.wpkh, + self.taproot, + tapscript=None, + ) + else: + return type(self)( + None, self.sh, self.wsh, self.key.derive(idx), self.wpkh, + self.taproot, tapscript=None + ) + + def witness_script(self): + if self.wsh and self.miniscript is not None: + return self.miniscript.compile() + + def redeem_script(self): + if not self.sh: + return None + if self.miniscript: + if self.wsh: + return b"\x00\x20" + ngu.hash.sha256s(self.miniscript.compile()) + else: + return self.miniscript.compile() + + else: + return b"\x00\x14" + ngu.hash.hash160(self.key.node.pubkey()) + + def script_pubkey(self): + if self.taproot: + tweak = None + if self.tapscript: + tweak = self.tapscript.merkle_root + output_pubkey = chains.taptweak(self.key.serialize(), tweak) + return b"\x51\x20" + output_pubkey + if self.sh: + return b"\xa9\x14" + ngu.hash.hash160(self.redeem_script()) + b"\x87" + if self.wsh: + return b"\x00\x20" + ngu.hash.sha256s(self.witness_script()) + if self.miniscript: + return self.miniscript.compile() + if self.wpkh: + return b"\x00\x14" + ngu.hash.hash160(self.key.serialize()) + return b"\x76\xa9\x14" + ngu.hash.hash160(self.key.serialize()) + b"\x88\xac" @classmethod def is_descriptor(cls, desc_str): @@ -281,148 +457,144 @@ def is_descriptor(cls, desc_str): return True return False - def bitcoin_core_serialize(self, external_label=None): + @staticmethod + def checksum_check(desc_w_checksum, csum_required=False): + try: + desc, checksum = desc_w_checksum.split("#") + except ValueError: + if csum_required: + raise ValueError("Missing descriptor checksum") + return desc_w_checksum, None + calc_checksum = descriptor_checksum(desc) + if calc_checksum != checksum: + raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum)) + return desc, checksum + + @classmethod + def from_string(cls, desc): + desc = parse_desc_str(desc) + desc, checksum = cls.checksum_check(desc) + s = BytesIO(desc.encode()) + res = cls.read_from(s) + left = s.read() + if len(left) > 0: + raise ValueError("Unexpected characters after descriptor: %r" % left) + return res + + @classmethod + def read_from(cls, s, taproot=False): + start = s.read(7) + sh = False + wsh = False + wpkh = False + is_miniscript = True + internal_key = None + tapscript = None + if start.startswith(b"tr("): + is_miniscript = False # miniscript vs. tapscript (that can contain miniscripts in tree) + taproot = True + s.seek(-4, 1) + internal_key = Key.parse(s) # internal key is a must + internal_key.taproot = True + sep = s.read(1) + if sep == b")": + s.seek(-1, 1) + else: + assert sep == b"," + tapscript = Tapscript.read_from(s) + elif start.startswith(b"sh(wsh("): + sh = True + wsh = True + elif start.startswith(b"wsh("): + sh = False + wsh = True + s.seek(-3, 1) + elif start.startswith(b"sh(wpkh"): + is_miniscript = False + sh = True + wpkh = True + assert s.read(1) == b"(" + elif start.startswith(b"wpkh("): + is_miniscript = False + wpkh = True + s.seek(-2, 1) + elif start.startswith(b"pkh("): + is_miniscript = False + s.seek(-3, 1) + elif start.startswith(b"sh("): + sh = True + wsh = False + s.seek(-4, 1) + else: + raise ValueError("Invalid descriptor") + + if is_miniscript: + miniscript = Miniscript.read_from(s) + miniscript.is_sane(taproot=False) + key = internal_key + nbrackets = int(sh) + int(wsh) + elif taproot: + miniscript = None + key = internal_key + nbrackets = 1 + else: + miniscript = None + key = Key.parse(s) + nbrackets = 1 + int(sh) + + end = s.read(nbrackets) + if end != b")" * nbrackets: + raise ValueError("Invalid descriptor") + o = cls(miniscript, sh=sh, wsh=wsh, key=key, wpkh=wpkh, + taproot=taproot, tapscript=tapscript) + o.validate() + return o + + def to_string(self, external=True, internal=True, checksum=True): + if self.taproot: + desc = "tr(%s" % self.key.to_string(external, internal) + if self.tapscript: + desc += "," + tree = self.tapscript.to_string(external, internal) + desc += tree + + desc = desc + ")" + return append_checksum(desc) + + if self.miniscript is not None: + res = self.miniscript.to_string(external, internal) + if self.wsh: + res = "wsh(%s)" % res + else: + if self.wpkh: + res = "wpkh(%s)" % self.key.to_string(external, internal) + else: + res = "pkh(%s)" % self.key.to_string(external, internal) + if self.sh: + res = "sh(%s)" % res + + if checksum: + res = append_checksum(res) + return res + + def bitcoin_core_serialize(self): # this will become legacy one day # instead use <0;1> descriptor format res = [] - for internal in [False, True]: + for external, internal in [(True, False), (False, True)]: desc_obj = { - "desc": self.serialize(internal=internal), + "desc": self.to_string(external, internal), "active": True, "timestamp": "now", "internal": internal, "range": [0, 100], } - if internal is False and external_label: - desc_obj["label"] = external_label res.append(desc_obj) return res - -class MultisigDescriptor(Descriptor): - # only supprt with key derivation info - # only xpubs - # can be extended when needed - __slots__ = ( - "M", - "N", - "internal_key", - "keys", - "addr_fmt", - ) - - def __init__(self, M, N, keys, addr_fmt, internal_key=None): - self.M = M - self.N = N - self.internal_key = internal_key - super().__init__(keys, addr_fmt) - - @classmethod - def parse(cls, desc_w_checksum: str) -> "MultisigDescriptor": - internal_key = None # taproot - # remove garbage - desc_w_checksum = parse_desc_str(desc_w_checksum) - # check correct checksum - desc, checksum = cls.checksum_check(desc_w_checksum) - # legacy - if desc.startswith("sh(sortedmulti("): - addr_fmt = AF_P2SH - tmp_desc = desc.replace("sh(sortedmulti(", "") - tmp_desc = tmp_desc.rstrip("))") - - # native segwit - elif desc.startswith("wsh(sortedmulti("): - addr_fmt = AF_P2WSH - tmp_desc = desc.replace("wsh(sortedmulti(", "") - tmp_desc = tmp_desc.rstrip("))") - - # wrapped segwit - elif desc.startswith("sh(wsh(sortedmulti("): - addr_fmt = AF_P2WSH_P2SH - tmp_desc = desc.replace("sh(wsh(sortedmulti(", "") - tmp_desc = tmp_desc.rstrip(")))") - - elif desc.startswith("tr("): - addr_fmt = AF_P2TR - tmp_desc = desc.replace("tr(", "") - tmp_desc = tmp_desc.rstrip(")") - internal_key, tmp_desc = tmp_desc.split(",", 1) - assert tmp_desc.startswith("sortedmulti_a("), "Only one sortedmulti_a allowed" - tmp_desc = tmp_desc.replace("sortedmulti_a(", "") - tmp_desc = tmp_desc.rstrip(")") - - try: - koi, key = cls.parse_key_orig_info(internal_key) - if key[0:4] not in ["tpub", "xpub"]: - raise ValueError("Only extended public keys are supported") - xpub = cls.parse_key_derivation_info(key) - xfp = str2xfp(koi[:8]) - origin_deriv = "m" + koi[8:] - internal_key = (xfp, origin_deriv, xpub) - except ValueError: - # https://github.com/BlockstreamResearch/secp256k1-zkp/blob/11af7015de624b010424273be3d91f117f172c82/src/modules/rangeproof/main_impl.h#L16 - # H = lift_x(0x0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) - if internal_key == PROVABLY_UNSPENDABLE: - # unspendable H as defined in BIP-0341 - pass - else: - assert "r=" in internal_key - _, r = internal_key.split("=") - if r == "@": - # pick a fresh integer r in the range 0...n-1 uniformly at random and use H + rG - kp = ngu.secp256k1.keypair() - else: - # H + rG where r is provided from user - r = a2b_hex(r) - assert len(r) == 32, "r != 32" - kp = ngu.secp256k1.keypair(r) - - H = a2b_hex(PROVABLY_UNSPENDABLE) - H_xo = ngu.secp256k1.xonly_pubkey(H) - internal_key = H_xo.tweak_add(kp.xonly_pubkey().to_bytes()) - internal_key = b2a_hex(internal_key.to_bytes()).decode() - - else: - raise ValueError("Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. All have to be sortedmulti.") - - splitted = tmp_desc.split(",") - M, keys = int(splitted[0]), splitted[1:] - N = int(len(keys)) - if M > N: - raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N)) - - res_keys = [] - for key in keys: - koi, key = cls.parse_key_orig_info(key) - if key[0:4] not in ["tpub", "xpub"]: - raise ValueError("Only extended public keys are supported") - - xpub = cls.parse_key_derivation_info(key) - xfp = str2xfp(koi[:8]) - origin_deriv = "m" + koi[8:] - res_keys.append((xfp, origin_deriv, xpub)) - - return cls(M=M, N=N, keys=res_keys, addr_fmt=addr_fmt, internal_key=internal_key) - - def _serialize(self, internal=False, int_ext=False) -> str: - """Serialize without checksum""" - desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt] - if self.addr_fmt == AF_P2TR: - if isinstance(self.internal_key, str): - desc_base = desc_base % (self.internal_key + ",sortedmulti_a(%s)") - else: - ik_ser = self.serialize_keys(keys=[self.internal_key])[0] - desc_base = desc_base % (ik_ser + ",sortedmulti_a(%s)") - else: - desc_base = desc_base % "sortedmulti(%s)" - assert len(self.keys) == self.N - inner = str(self.M) + "," + ",".join( - self.serialize_keys(internal=internal, int_ext=int_ext)) - - return desc_base % inner - - def pretty_serialize(self) -> str: + def pretty_serialize(self): + # TODO not enabled """Serialize in pretty and human-readable format""" inner_ident = 1 res = "# Coldcard descriptor export\n" @@ -471,5 +643,4 @@ def pretty_serialize(self) -> str: checksum = self.serialize().split("#")[1] return (res % inner) + "#" + checksum - # EOF diff --git a/shared/export.py b/shared/export.py index 1a050643..2fc0b28d 100644 --- a/shared/export.py +++ b/shared/export.py @@ -8,7 +8,6 @@ from utils import xfp2str, swab32, export_prompt_builder, chunk_writer from ux import ux_show_story from glob import settings -from descriptor import Descriptor, multisig_descriptor_template from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2TR, AF_P2SH from auth import write_sig_file @@ -234,6 +233,7 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx def generate_bitcoin_core_wallet(account_num, example_addrs): # Generate the data for an RPC command to import keys into Bitcoin Core # - yields dicts for json purposes + from descriptor import Descriptor, Key chain = chains.current_chain() @@ -261,22 +261,24 @@ def generate_bitcoin_core_wallet(account_num, example_addrs): example_addrs.append(('m/%s/%s' % (derive_v1, sp), a)) xfp = settings.get('xfp') - txt_xfp = xfp2str(xfp).lower() - _, vers, _ = version.get_mpy_version() + key0 = Key.from_cc_data(xfp, derive_v0, xpub_v0) + desc_v0 = Descriptor(key=key0) + desc_v0.set_from_addr_fmt(AF_P2WPKH) - desc_v0 = Descriptor(keys=[(xfp, derive_v0, xpub_v0)], addr_fmt=AF_P2WPKH) - desc_v1 = Descriptor(keys=[(xfp, derive_v1, xpub_v1)], addr_fmt=AF_P2TR) + key1 = Key.from_cc_data(xfp, derive_v1, xpub_v1) + desc_v1 = Descriptor(key=key1) + desc_v1.set_from_addr_fmt(AF_P2TR) # for importmulti imm_list = [ { - 'desc': desc_v0.serialize(internal=internal), + 'desc': desc_v0.to_string(external, internal), 'range': [0, 1000], 'timestamp': 'now', 'internal': internal, 'keypool': True, 'watchonly': True } - for internal in [False, True] + for external, internal in [(True, False), (False, True)] ] # for importdescriptors imd_list = desc_v0.bitcoin_core_serialize() @@ -345,6 +347,8 @@ 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") @@ -378,7 +382,10 @@ def generate_generic_export(account_num=0): if is_ms: desc = multisig_descriptor_template(xp, dd, master_xfp_str, fmt) else: - desc = Descriptor(keys=[(master_xfp, dd, xp)], addr_fmt=fmt).serialize(int_ext=True) + key = Key.from_cc_data(master_xfp, dd, xp) + desc_obj = Descriptor(key=key) + desc_obj.set_from_addr_fmt(fmt) + desc = desc_obj.to_string() rv[name] = OrderedDict(name=atype, xfp=xfp, @@ -495,6 +502,7 @@ async def make_json_wallet(label, func, fname_pattern='new-wallet.json'): async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int_ext=True, fname_pattern="descriptor.txt"): + from descriptor import Descriptor, Key from glob import dis dis.fullscreen('Generating...') @@ -515,24 +523,27 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int raise ValueError(addr_type) derive = "m/{mode}'/{coin_type}'/{account}'".format(mode=mode, - account=account_num, coin_type=chain.b44_cointype) + account=account_num, + coin_type=chain.b44_cointype) dis.progress_bar_show(0.2) with stash.SensitiveValues() as sv: dis.progress_bar_show(0.3) xpub = chain.serialize_public(sv.derive_path(derive)) dis.progress_bar_show(0.7) - desc = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=addr_type) + key = Key.from_cc_data(xfp, derive, xpub) + desc = Descriptor(key=key) + desc.set_from_addr_fmt(addr_type) dis.progress_bar_show(0.8) if int_ext: # with <0;1> notation - body = desc.serialize(int_ext=True) + body = desc.to_string() else: # external descriptor # internal descriptor body = "%s\n%s" % ( - desc.serialize(internal=False), - desc.serialize(internal=True), + desc.to_string(internal=False), + desc.to_string(external=False), ) dis.progress_bar_show(1) diff --git a/shared/flow.py b/shared/flow.py index 43223a9f..abb4623e 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -9,6 +9,7 @@ from actions import * from choosers import * from multisig import make_multisig_menu, import_multisig_nfc +from miniscript import make_miniscript_menu from seed import make_ephemeral_seed_menu from address_explorer import address_explore from users import make_users_menu @@ -127,6 +128,7 @@ def se2_and_real_secret(): MenuItem('Login Settings', menu=LoginPrefsMenu), MenuItem('Hardware On/Off', menu=HWTogglesMenu), MenuItem('Multisig Wallets', menu=make_multisig_menu), + MenuItem('Miniscript', menu=make_miniscript_menu), MenuItem('Display Units', chooser=value_resolution_chooser), MenuItem('Max Network Fee', chooser=max_fee_chooser), MenuItem('Idle Timeout', chooser=idle_timeout_chooser), diff --git a/shared/manifest.py b/shared/manifest.py index b8c67edc..55fa96c4 100644 --- a/shared/manifest.py +++ b/shared/manifest.py @@ -13,6 +13,7 @@ 'compat7z.py', 'countdowns.py', 'descriptor.py', + 'desc_utils.py', 'dev_helper.py', 'display.py', 'drv_entro.py', @@ -29,6 +30,7 @@ 'main.py', 'mempad.py', 'menu.py', + 'miniscript.py', 'multisig.py', 'numpad.py', 'nvstore.py', @@ -43,6 +45,7 @@ 'seed.py', 'selftest.py', 'serializations.py', + 'wallet_base.py', 'sffile.py', 'sram2.py', 'ssd1306.py', diff --git a/shared/miniscript.py b/shared/miniscript.py new file mode 100644 index 00000000..653c2188 --- /dev/null +++ b/shared/miniscript.py @@ -0,0 +1,1807 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# Copyright (c) 2020 Stepan Snigirev MIT License embit/miniscript.py +# +import ngu, ujson, chains, uio +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, fill_policy, append_checksum +from public_constants import MAX_TR_SIGNERS +from wallet_base import BaseWallet +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, export_prompt_builder, xfp2str +from utils import addr_fmt_label, truncate_address + + +class MiniscriptException(ValueError): + pass + + +class MiniScriptWallet(BaseWallet): + key_name = "miniscript" + + def __init__(self, desc=None, policy=None, keys=None, key=None, + af=None, name=None, taproot=False, sh=False, wsh=False, + wpkh=False): + super().__init__() + self._policy = policy + self._keys = keys + self._key = key + self._af = af + self._taproot = taproot + self._sh = sh + self._wsh = wsh + self._wpkh = wpkh + self._desc = desc + self.name = name + + @property + def policy(self): + if not self._policy: + self._policy = self.desc.storage_policy() + return self._policy + + @property + def keys(self): + if not self._keys: + self._keys = [k.to_string() for k in self.desc.keys] + return self._keys + + @property + def key(self): + if not self._key: + self._key = self.desc.key.to_string() + return self._key + + @property + def af(self): + if not self._af: + self._af = self.desc.addr_fmt + return self._af + + @property + def taproot(self): + if not self._taproot: + self._taproot = self.desc.taproot + return self._taproot + + @property + def sh(self): + if not self._sh: + self._sh = self.desc.sh + return self._sh + + @property + def wsh(self): + if not self._wsh: + self._wsh = self.desc.wsh + return self._wsh + + @property + def wpkh(self): + if not self._wpkh: + self._wpkh = self.desc.wpkh + return self._wpkh + + @property + def desc(self): + if self._desc is None: + from descriptor import Descriptor, Tapscript + + ts = None + ms = None + key = None + if self._key: + key = Key.from_string(self._key) + + filled_policy = fill_policy(self.policy, self.keys) + if self._taproot and self._policy: + # tapscript + ts = Tapscript.read_from(uio.BytesIO(filled_policy)) + elif self._policy: + # miniscript + ms = Miniscript.read_from(uio.BytesIO(filled_policy)) + self._desc = Descriptor(key=key, tapscript=ts, miniscript=ms, + taproot=self._taproot, sh=self._sh, + wsh=self._wsh, wpkh=self._wpkh) + self._desc.set_from_addr_fmt(self._af) + return self._desc + + def serialize(self): + policy = None + key = None + if self.desc.key: + key = self.desc.key.to_string() + + keys = [k.to_string() for k in self.desc.keys] + if self.desc.tapscript or self.desc.miniscript: + policy = self.desc.storage_policy() + + sh = self.desc.sh + wsh = self.desc.wsh + wpkh = self.desc.wpkh + taproot = self.desc.taproot + return ( + self.name, + self.desc.addr_fmt, + key, + keys, + policy, + sh, wsh, wpkh, taproot + ) + + @classmethod + def deserialize(cls, c, idx=-1): + name, af, key, keys, policy, sh, wsh, wpkh, taproot = c + rv = cls(name=name, key=key, keys=keys, policy=policy, af=af, + taproot=taproot, sh=sh, wsh=wsh, wpkh=wpkh) + rv.storage_idx = idx + return rv + + def xfp_paths(self): + if self._desc is None: + res = [] + for k in self.keys: + k = Key.from_string(k) + if k.origin: + res.append(k.origin.psbt_derivation()) + return res + return self.desc.xfp_paths() + + @classmethod + def find_match(cls, xfp_paths, addr_fmt=None): + for rv in cls.iter_wallets(): + if addr_fmt is not None: + if rv.af != addr_fmt: + continue + if rv.matching_subpaths(xfp_paths): + return rv + return None + + def matching_subpaths(self, xfp_paths): + my_xfp_paths = self.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 knwo that thy do match + my_xfp_paths = self.desc.xfp_paths() + res = set() + for x in my_xfp_paths: + prefix_len = len(x) + for y in xfp_paths: + if x == y[:prefix_len]: + derivation = y + break + else: + assert False + + to_derive = tuple(derivation[prefix_len:]) + res.add(to_derive) + assert len(res) == 1, "subderivation differ" + branch, idx = list(res)[0] + return branch, idx + + 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(self, redeem_script, xfp_paths, script_pubkey=None): + derived_desc = self.derive_desc(xfp_paths) + assert derived_desc.miniscript.compile() == redeem_script, "script mismatch" + if script_pubkey: + assert script_pubkey == derived_desc.script_pubkey(), "spk mismatch" + 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 + + def ux_policy(self): + if self.taproot and self.policy: + return "Taproot tree keys:\n\n" + self.policy + return self.policy + + async def _detail(self, new_wallet=False): + + s = addr_fmt_label(self.af) + "\n\n" + if self.taproot: + s += self.taproot_internal_key_detail() + + s += self.ux_policy() + + story = s + "\n\nPress (1) to see extended public keys" + if new_wallet: + story += ", OK to approve, X to cancel." + return story + + async def show_detail(self, new_wallet=False): + title = self.name + story = "" + if new_wallet: + title = None + story += "Create new miniscript wallet?\n\nWallet Name:\n %s\n\n" % self.name + story += await self._detail(new_wallet) + 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 + + def taproot_internal_key_detail(self): + if self.taproot: + key = Key.from_string(self.key) + s = "Taproot internal key:\n\n" + if key.is_provably_unspendable: + unspend = b2a_hex(key.node).decode() + s += "%s (provably unspendable)\n\n" % unspend + else: + xfp, deriv, xpub = key.to_cc_data() + s += '%s:\n %s\n\n%s\n\n' % (xfp2str(xfp), deriv, xpub) + return s + + async def show_keys(self): + msg = "" + if self.taproot: + msg = self.taproot_internal_key_detail() + + msg += "Taproot tree keys:\n\n" + for idx, k in enumerate(self.keys): + if idx: + msg += '\n---===---\n\n' + + msg += '@%s:\n %s\n\n' % (idx, k) + + await ux_show_story(msg) + + @classmethod + def from_file(cls, config, name=None): + from descriptor import Descriptor + desc_obj = Descriptor.from_string(config.strip()) + assert not desc_obj.is_basic_multisig, "Use Settings -> Multisig Wallets" + wal = cls(desc_obj, name=name) + return wal + + async def confirm_import(self): + to_save = await self.show_detail(new_wallet=True) + ch = "y" if to_save else "x" + if to_save: + assert self.storage_idx == -1 + self.commit() + await ux_dramatic_pause("Saved.", 2) + + return ch + + def yield_addresses(self, start_idx, count, change_idx=0, scripts=True): + ch = chains.current_chain() + + dd = self.desc.derive(change_idx) + idx = start_idx + while count: + # make the redeem script, convert into address + d = dd.derive(idx) + addr = ch.render_address(d.script_pubkey()) + addr = addr[0:12] + '___' + addr[12+3:] + + script = "" + if scripts: + if d.tapscript: + script = d.tapscript.script_tree(d.tapscript.tree) + else: + script = b2a_hex(ser_string(d.miniscript.compile())).decode() + + if d.tapscript: + yield (idx, + [str(k.origin) for k in d.keys], + addr, + script, + d.key.serialize(), + str(d.key.origin) if d.key.origin else "") + else: + yield (idx, + [str(k.origin) for k in d.keys], + addr, + script, + None, + None) + + idx += 1 + count -= 1 + + def make_addresses_msg(self, msg, start, n, change=0): + from glob import dis + + addrs = [] + + for i, paths, addr, _, ik, ikp in self.yield_addresses(start, n, + change_idx=change, + scripts=False): + if i == 0 and ik: + ik = b2a_hex(ik).decode() + msg += "Taproot internal key:\n\n" + if ikp: + msg += ikp + "\n" + ik + "\n\n" + else: + msg += '%s (provably unspendable)\n\n' % ik + + if len(paths) <= 4: + msg += "Taproot tree keys:\n\n" + + if i == 0 and len(paths) <= 4 and not ik: + msg += '\n'.join(paths) + '\n =>\n' + else: + msg += '.../%d/%d =>\n' % (change, i) + + addrs.append(addr) + msg += truncate_address(addr) + '\n\n' + dis.progress_bar_show(i / n) + + return msg, addrs + + def generate_address_csv(self, start, n, change): + scr_h = "Taptree" if self.desc.taproot else "Script" + yield '"' + '","'.join( + ['Index', 'Payment Address', scr_h] + ['Derivation'] * len(self.keys) + + (["Internal Key"] if self.taproot else []) + ) + '"\n' + for (idx, derivs, addr, script, ik, ikp) in self.yield_addresses(start, n, + change_idx=change): + ln = '%d,"%s","%s","' % (idx, addr, script) + ln += '","'.join(derivs) + if ik: + # internal xonly key with its derivation (if any) + ln += '","%s' % (ikp + b2a_hex(ik).decode()) + ln += '"\n' + + yield ln + + def bitcoin_core_serialize(self): + # this will become legacy one day + # instead use <0;1> descriptor format + res = [] + for external, internal in [(True, False), (False, True)]: + desc_obj = { + "desc": self.to_string(external, internal), + "active": True, + "timestamp": "now", + "internal": internal, + "range": [0, 100], + } + res.append(desc_obj) + return res + + def to_string(self, external=True, internal=True, checksum=True): + if self._key: + key = self._key + if internal != external: + to_replace = "0" if external else "1" + key = self._key.replace("<0;1>", to_replace) + if self._taproot: + desc = "tr(%s" % key + if self.policy: + desc += "," + tree = fill_policy(self._policy, self._keys, + external, internal) + desc += tree + + res = desc + ")" + + elif self._policy: + res = fill_policy(self._policy, self._keys, + external, internal) + if self._wsh: + res = "wsh(%s)" % res + else: + if self._wpkh: + res = "wpkh(%s)" % self._key + else: + res = "pkh(%s)" % self._key + + if self._sh: + res = "sh(%s)" % res + + if checksum: + res = append_checksum(res) + return res + + async def export_wallet_file(self, mode="exported from", extra_msg=None, descriptor=False, + core=False, desc_pretty=True): + from glob import NFC, dis + + if core: + name = "Bitcoin Core miniscript" + fname_pattern = 'bitcoin-core-%s' % self.name + else: + name = "Miniscript" + fname_pattern = 'minsc-%s' % self.name + + fname_pattern = fname_pattern + ".txt" + + force_vdisk = False + prompt, escape = export_prompt_builder("%s file" % name) + + if core: + dis.fullscreen('Wait...') + core_obj = self.bitcoin_core_serialize() + core_str = ujson.dumps(core_obj) + res = "importdescriptors '%s'\n" % core_str + # elif desc_pretty: + # pass TODO + else: + int_ext = True + ch = await ux_show_story( + "To export receiving and change descriptors in one descriptor (<0;1> notation) press OK, " + "press (1) to export receiving and change descriptors separately.", escape='1') + if ch == "1": + int_ext = False + elif ch != "y": + return + + dis.fullscreen('Wait...') + if int_ext: + res = self.to_string() + else: + res = "%s\n%s" % ( + self.to_string(internal=False), + self.to_string(external=False), + ) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == "3": + await NFC.share_text(res) + return + elif ch == "1": + force_vdisk = False + elif ch == "2": + force_vdisk = True + else: + return + + try: + with CardSlot(force_vdisk=force_vdisk) 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 + # 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 + +async def no_miniscript_yet(*a): + await ux_show_story("You don't have any miniscript wallets yet.") + + +async def miniscript_wallet_delete(menu, label, item): + msc = item.arg + + # delete + if not await ux_confirm("Delete this miniscript wallet?\n\nFunds may be impacted."): + await ux_dramatic_pause('Aborted.', 3) + return + + msc.delete() + await ux_dramatic_pause('Deleted.', 3) + + 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 glob import VD, dis + + force_vdisk = False + if VD: + prompt = "Press (1) to import miniscript wallet file from SD Card" + escape = "1" + if VD is not None: + prompt += ", press (2) to import from Virtual Disk" + escape += "2" + prompt += "." + ch = await ux_show_story(prompt, escape=escape) + if ch == "1": + force_vdisk=False + elif ch == "2": + force_vdisk = True + else: + 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('Pick miniscript wallet file to import (.txt)', suffix='.txt', min_size=100, + taster=possible, force_vdisk=force_vdisk) + if not fn: return + + try: + with CardSlot(force_vdisk=force_vdisk) as card: + with open(fn, 'rt') as fp: + data = fp.read() + except CardMissingError: + await needs_microsd() + return + + dis.fullscreen('Wait...') + from auth import maybe_enroll_xpub + try: + possible_name = (fn.split('/')[-1].split('.'))[0] + maybe_enroll_xpub(config=data, name=possible_name, miniscript=True) + except BaseException as e: + await ux_show_story('Failed to import.\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(title="ERROR", msg="Failed to import miniscript. %s" % str(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})), + ] + 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 + +async def delete_all(*a): + ch = await ux_show_story("Delete all stored miniscript wallets?") + if ch == "y": + MiniScriptWallet.delete_all() + from ux import the_ux + m = the_ux.top_of_stack() + m.update_contents() + +class MiniscriptMenu(MenuSystem): + @classmethod + def construct(cls): + from multisig import export_multisig_xpubs + + if not MiniScriptWallet.exists(): + rv = [MenuItem('(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 from File', f=import_miniscript)) + rv.append(MenuItem('Import via NFC', f=import_miniscript_nfc, + predicate=lambda: NFC is not None)) + rv.append(MenuItem('Export XPUB', f=export_multisig_xpubs)) + rv.append(MenuItem("DELETE ALL", f=delete_all)) + + 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) + + +class Number: + def __init__(self, num): + self.num = num + + @classmethod + def read_from(cls, s, taproot=False): + num = 0 + char = s.read(1) + while char in b"0123456789": + num = 10 * num + int(char.decode()) + char = s.read(1) + s.seek(-1, 1) + return cls(num) + + def compile(self): + if self.num == 0: + return b"\x00" + if self.num <= 16: + return bytes([80 + self.num]) + b = self.num.to_bytes(32, "little").rstrip(b"\x00") + if b[-1] >= 128: + b += b"\x00" + return bytes([len(b)]) + b + + def __len__(self): + return len(self.compile()) + + def to_string(self, *args, **kwargs): + return "%d" % self.num + + +class KeyHash(Key): + @classmethod + def parse_key(cls, k: bytes, *args, **kwargs): + # convert to string + kd = k.decode() + # raw 20-byte hash + if len(kd) == 40: + return kd + return super().parse_key(k, *args, **kwargs) + + def serialize(self, *args, **kwargs): + if self.taproot: + return ngu.hash.hash160(self.node.pubkey()[1:33]) + return ngu.hash.hash160(self.node.pubkey()) + + def __len__(self): + return 21 # <20:pkh> + + def compile(self): + d = self.serialize() + return ser_compact_size(len(d)) + d + + +class Raw: + def __init__(self, raw): + if len(raw) != self.LEN * 2: + raise ValueError("Invalid raw element length: %d" % len(raw)) + self.raw = a2b_hex(raw) + + @classmethod + def read_from(cls, s, taproot=False): + return cls(s.read(2 * cls.LEN).decode()) + + def to_string(self, *args, **kwargs): + return b2a_hex(self.raw).decode() + + def compile(self): + return ser_compact_size(len(self.raw)) + self.raw + + def __len__(self): + return len(ser_compact_size(self.LEN)) + self.LEN + + +class Raw32(Raw): + LEN = 32 + def __len__(self): + return 33 + + +class Raw20(Raw): + LEN = 20 + def __len__(self): + return 21 + + +class Miniscript: + def __init__(self, *args, **kwargs): + self.args = args + self.taproot = kwargs.get("taproot", False) + + def compile(self): + return self.inner_compile() + + def verify(self): + for arg in self.args: + if isinstance(arg, Miniscript): + arg.verify() + + @property + def keys(self): + return sum( + [arg.keys for arg in self.args if isinstance(arg, Miniscript)], + [k for k in self.args if isinstance(k, Key) or isinstance(k, KeyHash)], + ) + + def is_sane(self, taproot=False): + err = "multi mixin" + # cannot have same keys in single miniscript + forbiden = (Sortedmulti_a, Multi_a) + keys = self.keys + assert len(keys) == len(set(keys)), "Insane" + if taproot: + forbiden = (Sortedmulti, Multi) + + assert type(self) not in forbiden, err + + for arg in self.args: + assert type(arg) not in forbiden, err + if isinstance(arg, Miniscript): + arg.is_sane(taproot=taproot) + + + @staticmethod + def key_derive(key, idx, key_map=None): + if key_map and key in key_map: + kd = key_map[key] + else: + kd = key.derive(idx) + return kd + + def derive(self, idx, key_map=None): + args = [] + for arg in self.args: + if hasattr(arg, "derive"): + if isinstance(arg, Key) or isinstance(arg, KeyHash): + arg = self.key_derive(arg, idx, key_map) + else: + arg = arg.derive(idx) + + args.append(arg) + return type(self)(*args) + + @property + def properties(self): + return self.PROPS + + @property + def type(self): + return self.TYPE + + @classmethod + def read_from(cls, s, taproot=False): + op, char = read_until(s, b"(") + op = op.decode() + wrappers = "" + if ":" in op: + wrappers, op = op.split(":") + if char != b"(": + raise MiniscriptException("Missing operator") + if op not in OPERATOR_NAMES: + raise MiniscriptException("Unknown operator '%s'" % op) + # number of arguments, classes of arguments, compile function, type, validity checker + MiniscriptCls = OPERATORS[OPERATOR_NAMES.index(op)] + args = MiniscriptCls.read_arguments(s, taproot=taproot) + miniscript = MiniscriptCls(*args, taproot=taproot) + for w in reversed(wrappers): + if w not in WRAPPER_NAMES: + raise MiniscriptException("Unknown wrapper %s" % w) + WrapperCls = WRAPPERS[WRAPPER_NAMES.index(w)] + miniscript = WrapperCls(miniscript, taproot=taproot) + return miniscript + + @classmethod + def read_arguments(cls, s, taproot=False): + args = [] + if cls.NARGS is None: + if type(cls.ARGCLS) == tuple: + firstcls, nextcls = cls.ARGCLS + else: + firstcls, nextcls = cls.ARGCLS, cls.ARGCLS + + args.append(firstcls.read_from(s, taproot=taproot)) + while True: + char = s.read(1) + if char == b",": + args.append(nextcls.read_from(s, taproot=taproot)) + elif char == b")": + break + else: + raise MiniscriptException( + "Expected , or ), got: %s" % (char + s.read()) + ) + else: + for i in range(cls.NARGS): + args.append(cls.ARGCLS.read_from(s, taproot=taproot)) + if i < cls.NARGS - 1: + char = s.read(1) + if char != b",": + raise MiniscriptException("Missing arguments, %s" % char) + char = s.read(1) + if char != b")": + raise MiniscriptException("Expected ) got %s" % (char + s.read())) + return args + + def to_string(self, external=True, internal=True): + # meh + res = type(self).NAME + "(" + res += ",".join([ + arg.to_string(external, internal) + for arg in self.args + ]) + res += ")" + return res + + def __len__(self): + """Length of the compiled script, override this if you know the length""" + return len(self.compile()) + + def len_args(self): + return sum([len(arg) for arg in self.args]) + +########### Known fragments (miniscript operators) ############## + + +class OneArg(Miniscript): + NARGS = 1 + # small handy functions + @property + def arg(self): + return self.args[0] + + @property + def carg(self): + return self.arg.compile() + + +class PkK(OneArg): + # + NAME = "pk_k" + ARGCLS = Key + TYPE = "K" + PROPS = "ondu" + + def inner_compile(self): + return self.carg + + def __len__(self): + return self.len_args() + + +class PkH(OneArg): + # DUP HASH160 EQUALVERIFY + NAME = "pk_h" + ARGCLS = KeyHash + TYPE = "K" + PROPS = "ndu" + + def inner_compile(self): + return b"\x76\xa9" + self.carg + b"\x88" + + def __len__(self): + return self.len_args() + 3 + +class Older(OneArg): + # CHECKSEQUENCEVERIFY + NAME = "older" + ARGCLS = Number + TYPE = "B" + PROPS = "z" + + def inner_compile(self): + return self.carg + b"\xb2" + + def verify(self): + super().verify() + if (self.arg.num < 1) or (self.arg.num >= 0x80000000): + raise MiniscriptException( + "%s should have an argument in range [1, 0x80000000)" % self.NAME + ) + + def __len__(self): + return self.len_args() + 1 + +class After(Older): + # CHECKLOCKTIMEVERIFY + NAME = "after" + + def inner_compile(self): + return self.carg + b"\xb1" + + +class Sha256(OneArg): + # SIZE <32> EQUALVERIFY SHA256 EQUAL + NAME = "sha256" + ARGCLS = Raw32 + TYPE = "B" + PROPS = "ondu" + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xa8" + self.carg + b"\x87" + + def __len__(self): + return self.len_args() + 6 + +class Hash256(Sha256): + # SIZE <32> EQUALVERIFY HASH256 EQUAL + NAME = "hash256" + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xaa" + self.carg + b"\x87" + + +class Ripemd160(Sha256): + # SIZE <32> EQUALVERIFY RIPEMD160 EQUAL + NAME = "ripemd160" + ARGCLS = Raw20 + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xa6" + self.carg + b"\x87" + + +class Hash160(Ripemd160): + # SIZE <32> EQUALVERIFY HASH160 EQUAL + NAME = "hash160" + + def inner_compile(self): + return b"\x82" + Number(32).compile() + b"\x88\xa9" + self.carg + b"\x87" + + +class AndOr(Miniscript): + # [X] NOTIF [Z] ELSE [Y] ENDIF + NAME = "andor" + NARGS = 3 + ARGCLS = Miniscript + + @property + def type(self): + # type same as Y/Z + return self.args[1].type + + def verify(self): + # requires: X is Bdu; Y and Z are both B, K, or V + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("andor: X should be 'B'") + px = self.args[0].properties + if "d" not in px and "u" not in px: + raise MiniscriptException("andor: X should be 'du'") + if self.args[1].type != self.args[2].type: + raise MiniscriptException("andor: Y and Z should have the same types") + if self.args[1].type not in "BKV": + raise MiniscriptException("andor: Y and Z should be B K or V") + + @property + def properties(self): + # props: z=zXzYzZ; o=zXoYoZ or oXzYzZ; u=uYuZ; d=dZ + props = "" + px, py, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in py and "z" in pz: + props += "z" + if ("z" in px and "o" in py and "o" in pz) or ( + "o" in px and "z" in py and "z" in pz + ): + props += "o" + if "u" in py and "u" in pz: + props += "u" + if "d" in pz: + props += "d" + return props + + def inner_compile(self): + return ( + self.args[0].compile() + + b"\x64" + + self.args[2].compile() + + b"\x67" + + self.args[1].compile() + + b"\x68" + ) + + def __len__(self): + return self.len_args() + 3 + +class AndV(Miniscript): + # [X] [Y] + NAME = "and_v" + NARGS = 2 + ARGCLS = Miniscript + + def inner_compile(self): + return self.args[0].compile() + self.args[1].compile() + + def __len__(self): + return self.len_args() + + def verify(self): + # X is V; Y is B, K, or V + super().verify() + if self.args[0].type != "V": + raise MiniscriptException("and_v: X should be 'V'") + if self.args[1].type not in "BKV": + raise MiniscriptException("and_v: Y should be B K or V") + + @property + def type(self): + # same as Y + return self.args[1].type + + @property + def properties(self): + # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; u=uY + px, py = [arg.properties for arg in self.args] + props = "" + if "z" in px and "z" in py: + props += "z" + if ("z" in px and "o" in py) or ("z" in py and "o" in px): + props += "o" + if "n" in px or ("z" in px and "n" in py): + props += "n" + if "u" in py: + props += "u" + return props + + +class AndB(Miniscript): + # [X] [Y] BOOLAND + NAME = "and_b" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "B" + + def inner_compile(self): + return self.args[0].compile() + self.args[1].compile() + b"\x9a" + + def __len__(self): + return self.len_args() + 1 + + def verify(self): + # X is B; Y is W + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("and_b: X should be B") + if self.args[1].type != "W": + raise MiniscriptException("and_b: Y should be W") + + @property + def properties(self): + # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; d=dXdY; u + px, py = [arg.properties for arg in self.args] + props = "" + if "z" in px and "z" in py: + props += "z" + if ("z" in px and "o" in py) or ("z" in py and "o" in px): + props += "o" + if "n" in px or ("z" in px and "n" in py): + props += "n" + if "d" in px and "d" in py: + props += "d" + props += "u" + return props + + +class AndN(Miniscript): + # [X] NOTIF 0 ELSE [Y] ENDIF + # andor(X,Y,0) + NAME = "and_n" + NARGS = 2 + ARGCLS = Miniscript + + def inner_compile(self): + return ( + self.args[0].compile() + + b"\x64" + + Number(0).compile() + + b"\x67" + + self.args[1].compile() + + b"\x68" + ) + + def __len__(self): + return self.len_args() + 4 + + @property + def type(self): + # type same as Y/Z + return self.args[1].type + + def verify(self): + # requires: X is Bdu; Y and Z are both B, K, or V + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("and_n: X should be 'B'") + px = self.args[0].properties + if "d" not in px and "u" not in px: + raise MiniscriptException("and_n: X should be 'du'") + if self.args[1].type != "B": + raise MiniscriptException("and_n: Y should be B") + + @property + def properties(self): + # props: z=zXzYzZ; o=zXoYoZ or oXzYzZ; u=uYuZ; d=dZ + props = "" + px, py = [arg.properties for arg in self.args] + pz = "zud" + if "z" in px and "z" in py and "z" in pz: + props += "z" + if ("z" in px and "o" in py and "o" in pz) or ( + "o" in px and "z" in py and "z" in pz + ): + props += "o" + if "u" in py and "u" in pz: + props += "u" + if "d" in pz: + props += "d" + return props + + +class OrB(Miniscript): + # [X] [Z] BOOLOR + NAME = "or_b" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "B" + + def inner_compile(self): + return self.args[0].compile() + self.args[1].compile() + b"\x9b" + + def __len__(self): + return self.len_args() + 1 + + def verify(self): + # X is Bd; Z is Wd + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("or_b: X should be B") + if "d" not in self.args[0].properties: + raise MiniscriptException("or_b: X should be d") + if self.args[1].type != "W": + raise MiniscriptException("or_b: Z should be W") + if "d" not in self.args[1].properties: + raise MiniscriptException("or_b: Z should be d") + + @property + def properties(self): + # z=zXzZ; o=zXoZ or zZoX; d; u + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "z" + if ("z" in px and "o" in pz) or ("z" in pz and "o" in px): + props += "o" + props += "du" + return props + + +class OrC(Miniscript): + # [X] NOTIF [Z] ENDIF + NAME = "or_c" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "V" + + def inner_compile(self): + return self.args[0].compile() + b"\x64" + self.args[1].compile() + b"\x68" + + def __len__(self): + return self.len_args() + 2 + + def verify(self): + # X is Bdu; Z is V + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("or_c: X should be B") + if self.args[1].type != "V": + raise MiniscriptException("or_c: Z should be V") + px = self.args[0].properties + if "d" not in px or "u" not in px: + raise MiniscriptException("or_c: X should be du") + + @property + def properties(self): + # z=zXzZ; o=oXzZ + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "z" + if "o" in px and "z" in pz: + props += "o" + return props + + +class OrD(Miniscript): + # [X] IFDUP NOTIF [Z] ENDIF + NAME = "or_d" + NARGS = 2 + ARGCLS = Miniscript + TYPE = "B" + + def inner_compile(self): + return self.args[0].compile() + b"\x73\x64" + self.args[1].compile() + b"\x68" + + def __len__(self): + return self.len_args() + 3 + + def verify(self): + # X is Bdu; Z is B + super().verify() + if self.args[0].type != "B": + raise MiniscriptException("or_d: X should be B") + if self.args[1].type != "B": + raise MiniscriptException("or_d: Z should be B") + px = self.args[0].properties + if "d" not in px or "u" not in px: + raise MiniscriptException("or_d: X should be du") + + @property + def properties(self): + # z=zXzZ; o=oXzZ; d=dZ; u=uZ + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "z" + if "o" in px and "z" in pz: + props += "o" + if "d" in pz: + props += "d" + if "u" in pz: + props += "u" + return props + + +class OrI(Miniscript): + # IF [X] ELSE [Z] ENDIF + NAME = "or_i" + NARGS = 2 + ARGCLS = Miniscript + + def inner_compile(self): + return ( + b"\x63" + + self.args[0].compile() + + b"\x67" + + self.args[1].compile() + + b"\x68" + ) + + def __len__(self): + return self.len_args() + 3 + + def verify(self): + # both are B, K, or V + super().verify() + if self.args[0].type != self.args[1].type: + raise MiniscriptException("or_i: X and Z should be the same type") + if self.args[0].type not in "BKV": + raise MiniscriptException("or_i: X and Z should be B K or V") + + @property + def type(self): + return self.args[0].type + + @property + def properties(self): + # o=zXzZ; u=uXuZ; d=dX or dZ + props = "" + px, pz = [arg.properties for arg in self.args] + if "z" in px and "z" in pz: + props += "o" + if "u" in px and "u" in pz: + props += "u" + if "d" in px or "d" in pz: + props += "d" + return props + + +class Thresh(Miniscript): + # [X1] [X2] ADD ... [Xn] ADD ... EQUAL + NAME = "thresh" + NARGS = None + ARGCLS = (Number, Miniscript) + TYPE = "B" + + def inner_compile(self): + return ( + self.args[1].compile() + + b"".join([arg.compile()+b"\x93" for arg in self.args[2:]]) + + self.args[0].compile() + + b"\x87" + ) + + def __len__(self): + return self.len_args() + len(self.args) - 1 + + def verify(self): + # 1 <= k <= n; X1 is Bdu; others are Wdu + super().verify() + if self.args[0].num < 1 or self.args[0].num >= len(self.args): + raise MiniscriptException( + "thresh: Invalid k! Should be 1 <= k <= %d, got %d" + % (len(self.args) - 1, self.args[0].num) + ) + if self.args[1].type != "B": + raise MiniscriptException("thresh: X1 should be B") + px = self.args[1].properties + if "d" not in px or "u" not in px: + raise MiniscriptException("thresh: X1 should be du") + for i, arg in enumerate(self.args[2:]): + if arg.type != "W": + raise MiniscriptException("thresh: X%d should be W" % (i + 1)) + p = arg.properties + if "d" not in p or "u" not in p: + raise MiniscriptException("thresh: X%d should be du" % (i + 1)) + + @property + def properties(self): + # z=all are z; o=all are z except one is o; d; u + props = "" + parr = [arg.properties for arg in self.args[1:]] + zarr = ["z" for p in parr if "z" in p] + if len(zarr) == len(parr): + props += "z" + noz = [p for p in parr if "z" not in p] + if len(noz) == 1 and "o" in noz[0]: + props += "o" + props += "du" + return props + + +class Multi(Miniscript): + # ... CHECKMULTISIG + NAME = "multi" + NARGS = None + ARGCLS = (Number, Key) + TYPE = "B" + PROPS = "ndu" + N_MAX = 20 + + def inner_compile(self): + return ( + b"".join([arg.compile() for arg in self.args]) + + Number(len(self.args) - 1).compile() + + b"\xae" + ) + + def __len__(self): + return self.len_args() + 2 + + def m_n(self): + return self.args[0].num, len(self.args[1:]) + + def verify(self): + super().verify() + N = (len(self.args) - 1) + assert N <= self.N_MAX, 'M/N range' + M = self.args[0].num + if M < 1 or M > N: + raise ValueError( + "M must be <= N: 1 <= M <= %d, got %d" % ((len(self.args) - 1), self.args[0].num) + ) + + +class Sortedmulti(Multi): + # ... CHECKMULTISIG + NAME = "sortedmulti" + + def inner_compile(self): + return ( + self.args[0].compile() + + b"".join(sorted([arg.compile() for arg in self.args[1:]])) + + Number(len(self.args) - 1).compile() + + b"\xae" + ) + +class Multi_a(Multi): + # CHECKSIG CHECKSIGADD ... CHECKSIGADD EQUALVERIFY + NAME = "multi_a" + PROPS = "du" + N_MAX = MAX_TR_SIGNERS + + def inner_compile(self): + from opcodes import OP_CHECKSIGADD, OP_NUMEQUAL, OP_CHECKSIG + script = b"" + for i, key in enumerate(self.args[1:]): + script += key.compile() + if i == 0: + script += bytes([OP_CHECKSIG]) + else: + script += bytes([OP_CHECKSIGADD]) + script += self.args[0].compile() # M (threshold) + script += bytes([OP_NUMEQUAL]) + return script + + def __len__(self): + # len(M) + len(k0) ... + len(kN) + len(keys) + 1 + return self.len_args() + len(self.args) + + +class Sortedmulti_a(Multi_a): + # CHECKSIG CHECKSIGADD ... CHECKSIGADD EQUALVERIFY + NAME = "sortedmulti_a" + + def inner_compile(self): + from opcodes import OP_CHECKSIGADD, OP_NUMEQUAL, OP_CHECKSIG + script = b"" + for i, key in enumerate(sorted([arg.compile() for arg in self.args[1:]])): + script += key + if i == 0: + script += bytes([OP_CHECKSIG]) + else: + script += bytes([OP_CHECKSIGADD]) + script += self.args[0].compile() # M (threshold) + script += bytes([OP_NUMEQUAL]) + return script + + +class Pk(OneArg): + # CHECKSIG + NAME = "pk" + ARGCLS = Key + TYPE = "B" + PROPS = "ondu" + + def inner_compile(self): + return self.carg + b"\xac" + + def __len__(self): + return self.len_args() + 1 + + +class Pkh(OneArg): + # DUP HASH160 EQUALVERIFY CHECKSIG + NAME = "pkh" + ARGCLS = KeyHash + TYPE = "B" + PROPS = "ndu" + + def inner_compile(self): + return b"\x76\xa9" + self.carg + b"\x88\xac" + + def __len__(self): + return self.len_args() + 4 + + +OPERATORS = [ + PkK, + PkH, + Older, + After, + Sha256, + Hash256, + Ripemd160, + Hash160, + AndOr, + AndV, + AndB, + AndN, + OrB, + OrC, + OrD, + OrI, + Thresh, + Multi, + Sortedmulti, + Multi_a, + Sortedmulti_a, + Pk, + Pkh, +] +OPERATOR_NAMES = [cls.NAME for cls in OPERATORS] + + +class Wrapper(OneArg): + ARGCLS = Miniscript + + @property + def op(self): + return type(self).__name__.lower() + + def to_string(self, *args, **kwargs): + # more wrappers follow + if isinstance(self.arg, Wrapper): + return self.op + self.arg.to_string(*args, **kwargs) + # we are the last wrapper + return self.op + ":" + self.arg.to_string(*args, **kwargs) + + +class A(Wrapper): + # TOALTSTACK [X] FROMALTSTACK + TYPE = "W" + + def inner_compile(self): + return b"\x6b" + self.carg + b"\x6c" + + def __len__(self): + return len(self.arg) + 2 + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("a: X should be B") + + @property + def properties(self): + props = "" + px = self.arg.properties + if "d" in px: + props += "d" + if "u" in px: + props += "u" + return props + + +class S(Wrapper): + # SWAP [X] + TYPE = "W" + + def inner_compile(self): + return b"\x7c" + self.carg + + def __len__(self): + return len(self.arg) + 1 + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("s: X should be B") + if "o" not in self.arg.properties: + raise MiniscriptException("s: X should be o") + + @property + def properties(self): + props = "" + px = self.arg.properties + if "d" in px: + props += "d" + if "u" in px: + props += "u" + return props + + +class C(Wrapper): + # [X] CHECKSIG + TYPE = "B" + + def inner_compile(self): + return self.carg + b"\xac" + + def __len__(self): + return len(self.arg) + 1 + + def verify(self): + super().verify() + if self.arg.type != "K": + raise MiniscriptException("c: X should be K") + + @property + def properties(self): + props = "" + px = self.arg.properties + for p in ["o", "n", "d"]: + if p in px: + props += p + props += "u" + return props + + +class T(Wrapper): + # [X] 1 + TYPE = "B" + + def inner_compile(self): + return self.carg + Number(1).compile() + + def __len__(self): + return len(self.arg) + 1 + + @property + def properties(self): + # z=zXzY; o=zXoY or zYoX; n=nX or zXnY; u=uY + px = self.arg.properties + py = "zu" + props = "" + if "z" in px and "z" in py: + props += "z" + if ("z" in px and "o" in py) or ("z" in py and "o" in px): + props += "o" + if "n" in px or ("z" in px and "n" in py): + props += "n" + if "u" in py: + props += "u" + return props + + +class D(Wrapper): + # DUP IF [X] ENDIF + TYPE = "B" + + def inner_compile(self): + return b"\x76\x63" + self.carg + b"\x68" + + def __len__(self): + return len(self.arg) + 3 + + def verify(self): + super().verify() + if self.arg.type != "V": + raise MiniscriptException("d: X should be V") + if "z" not in self.arg.properties: + raise MiniscriptException("d: X should be z") + + @property + def properties(self): + # https://github.com/bitcoin/bitcoin/pull/24906 + if self.taproot: + props = "ndu" + else: + props = "nd" + px = self.arg.properties + if "z" in px: + props += "o" + return props + + +class V(Wrapper): + # [X] VERIFY (or VERIFY version of last opcode in [X]) + TYPE = "V" + + def inner_compile(self): + """Checks last check code and makes it verify""" + if self.carg[-1] in [0xAC, 0xAE, 0x9C, 0x87]: + return self.carg[:-1] + bytes([self.carg[-1] + 1]) + return self.carg + b"\x69" + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("v: X should be B") + + @property + def properties(self): + props = "" + px = self.arg.properties + for p in ["z", "o", "n"]: + if p in px: + props += p + return props + + +class J(Wrapper): + # SIZE 0NOTEQUAL IF [X] ENDIF + TYPE = "B" + + def inner_compile(self): + return b"\x82\x92\x63" + self.carg + b"\x68" + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("j: X should be B") + if "n" not in self.arg.properties: + raise MiniscriptException("j: X should be n") + + @property + def properties(self): + props = "nd" + px = self.arg.properties + for p in ["o", "u"]: + if p in px: + props += p + return props + + +class N(Wrapper): + # [X] 0NOTEQUAL + TYPE = "B" + + def inner_compile(self): + return self.carg + b"\x92" + + def __len__(self): + return len(self.arg) + 1 + + def verify(self): + super().verify() + if self.arg.type != "B": + raise MiniscriptException("n: X should be B") + + @property + def properties(self): + props = "u" + px = self.arg.properties + for p in ["z", "o", "n", "d"]: + if p in px: + props += p + return props + + +class L(Wrapper): + # IF 0 ELSE [X] ENDIF + TYPE = "B" + + def inner_compile(self): + return b"\x63" + Number(0).compile() + b"\x67" + self.carg + b"\x68" + + def __len__(self): + return len(self.arg) + 4 + + def verify(self): + # both are B, K, or V + super().verify() + if self.arg.type != "B": + raise MiniscriptException("or_i: X and Z should be the same type") + + @property + def properties(self): + # o=zXzZ; u=uXuZ; d=dX or dZ + props = "d" + pz = self.arg.properties + if "z" in pz: + props += "o" + if "u" in pz: + props += "u" + return props + + +class U(L): + # IF [X] ELSE 0 ENDIF + def inner_compile(self): + return b"\x63" + self.carg + b"\x67" + Number(0).compile() + b"\x68" + + def __len__(self): + return len(self.arg) + 4 + + +WRAPPERS = [A, S, C, T, D, V, J, N, L, U] +WRAPPER_NAMES = [w.__name__.lower() for w in WRAPPERS] diff --git a/shared/multisig.py b/shared/multisig.py index 263a40a5..87b428cd 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -3,19 +3,21 @@ # multisig.py - support code for multisig signing and p2sh in general. # import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson -from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str -from utils import str_to_keypath, problem_file_line, export_prompt_builder, parse_extended_key +from ubinascii import hexlify as b2a_hex +from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, truncate_address +from utils import str_to_keypath, problem_file_line, export_prompt_builder, check_xpub from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys, ux_enter_bip32_index from files import CardSlot, CardMissingError, needs_microsd -from descriptor import MultisigDescriptor, multisig_descriptor_template -from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS -from public_constants import MAX_TR_SIGNERS, AF_P2TR +from descriptor import Descriptor +from miniscript import Key, Sortedmulti, Number +from desc_utils import multisig_descriptor_template +from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, AF_P2TR +from public_constants import MAX_SIGNERS from menu import MenuSystem, MenuItem from opcodes import OP_CHECKMULTISIG, OP_CHECKSIG, OP_NUMEQUAL, OP_CHECKSIGADD from exceptions import FatalPSBTIssue from glob import settings -from ubinascii import unhexlify as a2b_hex -from ubinascii import hexlify as b2a_hex +from wallet_base import BaseWallet from serializations import disassemble @@ -25,56 +27,17 @@ TRUST_PSBT = const(2) -class MultisigOutOfSpace(RuntimeError): - pass - def disassemble_multisig_mn(redeem_script): # Pull out just M and N from script. Simple, faster, no memory. - assert redeem_script[-1] == OP_CHECKMULTISIG, 'need CHECKMULTISIG' + if redeem_script[-1] != OP_CHECKMULTISIG: + return None, None M = redeem_script[0] - 80 N = redeem_script[-2] - 80 return M, N - -def disassemble_multisig_mn_tr(script): - # Pull out just M and N from taproot script. - # - more validation is done in following steps - assert script[-1] == OP_NUMEQUAL, 'need OP_NUMEQUAL' - num_cs = 0 - num_csa = 0 - - gen = disassemble(script) - while True: - try: - bt = next(gen) - except StopIteration: - break - if bt[1] == OP_CHECKSIG: - num_cs += 1 - elif bt[1] == OP_CHECKSIGADD: - num_csa += 1 - elif bt[0]: - if isinstance(bt[0], int): - last = next(gen)[1] - assert last == OP_NUMEQUAL - M = bt[0] - else: - if len(bt[0]) == 32: - # xonly pubkey - continue - else: - last = next(gen)[1] - assert last == OP_NUMEQUAL - assert len(bt[0]) == 1, "M>32" - M = ustruct.unpack("B", bt[0])[0] - - assert M - N = num_cs + num_csa - 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 @@ -144,44 +107,7 @@ def make_redeem_script(M, nodes, subkey_idx): return b''.join(pubkeys) -def make_redeem_script_tr(M, nodes, subkey_idx): - # Take a list of BIP-32 nodes, and derive Nth subkey (subkey_idx) and make - # a taproot M-of-N redeem script for that. Always applies BIP-67 sorting. - # - tapscript multisig does not use OP_CHECKMULTISIG and therefore limit is - # much higher (998 of 999 was demonstrated) - # - for now, MAX_TR_SIGNERS is 32, but this is artificial limit for tapscript - # and could be something bigger - - N = len(nodes) - assert 1 <= M <= N <= MAX_TR_SIGNERS - - pubkeys = [] - for n in nodes: - copy = n.copy() - copy.derive(subkey_idx, False) - # 0x20 = 32 = len(pubkey) = OP_PUSHDATA(32) - pubkeys.append(b'\x20' + copy.pubkey()[1:]) - del copy - - pubkeys.sort() - - script = b'' - for i, pk in enumerate(pubkeys): - script += pk - if i == 0: - script += bytes([OP_CHECKSIG]) - else: - script += bytes([OP_CHECKSIGADD]) - - if M <= 16: - script += bytes([80 + M, OP_NUMEQUAL]) - else: - assert M < 128 - script += bytes([0x01, M, OP_NUMEQUAL]) - - return script - -class MultisigWallet: +class MultisigWallet(BaseWallet): # Capture the info we need to store long-term in order to participate in a # multisig wallet as a co-signer. # - can be saved to nvram @@ -202,10 +128,10 @@ class MultisigWallet: # optional: user can short-circuit many checks (system wide, one power-cycle only) disable_checks = False + key_name = "multisig" - def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type='BTC', internal_key=None): - self.storage_idx = -1 - + def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type='BTC'): + super().__init__() self.name = name assert len(m_of_n) == 2 self.M, self.N = m_of_n @@ -213,7 +139,6 @@ def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type='BTC', inte assert len(xpubs[0]) == 3 self.xpubs = xpubs # list of (xfp(int), deriv, xpub(str)) self.addr_fmt = addr_fmt # address format for wallet - self.internal_key = internal_key # calc useful cache value: numeric xfp+subpath, with lookup self.xfp_paths = {} @@ -265,9 +190,6 @@ def serialize(self): opts['d'] = pp xp = [(a, pp.index(deriv),c) for a,deriv,c in self.xpubs] - if self.internal_key is not None: - opts["ik"] = self.internal_key - return (self.name, (self.M, self.N), xp, opts) @classmethod @@ -275,10 +197,6 @@ def deserialize(cls, vals, idx=-1): # take json object, make instance. name, m_of_n, xpubs, opts = vals - internal_key = None - if "ik" in opts and opts["ik"]: - internal_key = opts["ik"] - if len(xpubs[0]) == 2: # promote from old format to new: assume common prefix is the derivation # for all of them @@ -295,22 +213,18 @@ def deserialize(cls, vals, idx=-1): xpubs = [(a, derivs[b], c) for a,b,c in xpubs] rv = cls(name, m_of_n, xpubs, addr_fmt=opts.get('ft', AF_P2SH), - chain_type=opts.get('ch', 'BTC'), internal_key=internal_key) + chain_type=opts.get('ch', 'BTC')) rv.storage_idx = idx return rv @classmethod - def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmt=None): + def iter_wallets(cls, M=None, N=None, addr_fmt=None): # yield MS wallets we know about, that match at least right M,N if known. # - this is only place we should be searching this list, please!! - lst = settings.get('multisig', []) + lst = settings.get(cls.key_name, []) for idx, rec in enumerate(lst): - if idx == not_idx: - # ignore one by index - continue - if M or N: # peek at M/N has_m, has_n = tuple(rec[1]) @@ -405,57 +319,6 @@ def quick_check(cls, M, N, xfp_xor): return False - @classmethod - def get_all(cls): - # return them all, as a generator - return cls.iter_wallets() - - @classmethod - def exists(cls): - # are there any wallets defined? - return bool(settings.get('multisig', False)) - - @classmethod - def get_by_idx(cls, nth): - # instance from index number (used in menu) - lst = settings.get('multisig', []) - try: - obj = lst[nth] - except IndexError: - return None - - return cls.deserialize(obj, nth) - - def commit(self): - # data to save - # - important that this fails immediately when nvram overflows - obj = self.serialize() - - v = settings.get('multisig', []) - orig = v.copy() - if not v or self.storage_idx == -1: - # create - self.storage_idx = len(v) - v.append(obj) - else: - # update in place - v[self.storage_idx] = obj - - settings.set('multisig', v) - - # save now, rather than in background, so we can recover - # from out-of-space situation - try: - settings.save() - except: - # back out change; no longer sure of NVRAM state - try: - settings.set('multisig', orig) - settings.save() - except: pass # give up on recovery - - raise MultisigOutOfSpace - def has_similar(self): # check if we already have a saved duplicate to this proposed wallet # - return (name_change, diff_items, count_similar) where: @@ -507,9 +370,9 @@ def delete(self): else: raise IndexError # consistency bug - lst = settings.get('multisig', []) + lst = settings.get(self.key_name, []) del lst[self.storage_idx] - settings.set('multisig', lst) + settings.set(self.key_name, lst) settings.save() self.storage_idx = -1 @@ -523,7 +386,7 @@ def yield_addresses(self, start_idx, count, change_idx=0): # Assuming a suffix of /0/0 on the defined prefix's, yield # possible deposit addresses for this wallet. Never show # user the resulting addresses because we cannot be certain - # they are valid and could be signed. And yet, dont blank too many + # they are valid and could be signed. And yet, don't blank too many # spots or else an attacker could grid out a suitable replacement. ch = self.chain @@ -541,97 +404,47 @@ def yield_addresses(self, start_idx, count, change_idx=0): nodes.append(node) paths.append(path) - internal = None - internal_key = "" - internal_path = "" - if self.internal_key and isinstance(self.internal_key, tuple): - xfp, deriv, xpub = self.internal_key - internal = ch.deserialize_node(xpub, AF_P2SH) - internal.derive(change_idx, False) - internal_path = "[%s/%s/%d/{idx}]" % (xfp2str(xfp), deriv, change_idx) - idx = start_idx while count: - if self.internal_key is None: - # make the redeem script, convert into address - script = make_redeem_script(self.M, nodes, idx) - addr = ch.p2sh_address(self.addr_fmt, script) - else: - # p2tr - script = make_redeem_script_tr(self.M, nodes, idx) - # leaf hash is also a merkle root in tree of depth 0 (only allowed now) - aka taptweak - leaf_hash = chains.tapleaf_hash(script) - - if isinstance(self.internal_key, str): - internal_key_bytes = a2b_hex(self.internal_key) - internal_key = self.internal_key - else: - internal.derive(idx, False) - internal_key_bytes = internal.pubkey()[1:] - internal_key = b2a_hex(internal_key_bytes).decode() - - output_key = chains.taptweak(internal_key_bytes, leaf_hash) - addr = ch.render_address(b'\x51\x20' + output_key) + # make the redeem script, convert into address + script = make_redeem_script(self.M, nodes, idx) + addr = ch.p2sh_address(self.addr_fmt, script) addr = addr[0:12] + '___' + addr[12+3:] - yield idx, [p.format(idx=idx) for p in paths], addr, script, internal_key, internal_path.format(idx=idx) + yield idx, [p.format(idx=idx) for p in paths], addr, script idx += 1 count -= 1 - def validate_tr_internal_key(self, taproot_subpaths): - ch = chains.current_chain() - internal_key = None - xfp_deriv = None + def make_addresses_msg(self, msg, start, n, change=0): + from glob import dis - for key, lhs_path in taproot_subpaths.items(): - if not lhs_path[0]: - internal_key = key - xfp_deriv = lhs_path[1:] - break - else: - assert False, "Internal key missing in taproot subpaths" + addrs = [] - if len(xfp_deriv) < 2: - assert a2b_hex(self.internal_key) == internal_key - else: - node = ch.deserialize_node(self.internal_key[2], AF_P2SH) - change_idx, idx = xfp_deriv[-2], xfp_deriv[-1] - node.derive(change_idx, False) - node.derive(idx, False) - assert node.pubkey()[1:] == internal_key + for (i, paths, addr, script) in self.yield_addresses(start, n, change_idx=change): + if i == 0 and self.N <= 4: + msg += '\n'.join(paths) + '\n =>\n' + else: + msg += '.../%d/%d =>\n' % (change, i) - return internal_key + addrs.append(addr) + msg += truncate_address(addr) + '\n\n' + dis.progress_bar_show(i / n) - def make_multisig_tr(self, taproot_subpaths): - # Make the redeem script for leafs - ch = chains.current_chain() - index = None - nodes = [] - for xfp, deriv, xpub in self.xpubs: - # load bip32 node for each cosigner - node = ch.deserialize_node(xpub, AF_P2SH) - for xo, lhs_path in taproot_subpaths.items(): - lhs, pth = lhs_path[0], lhs_path[1:] - # ignore internal key - does not have lhs (leaf hashes) - if xfp == pth[0] and lhs: - path = pth - break - else: - assert False + return msg, addrs - change_idx, idx = path[-2], path[-1] - if index is not None: - assert index == idx - else: - index = idx + def generate_address_csv(self, start, n, change): + yield '"' + '","'.join(['Index', 'Payment Address', + 'Redeem Script (%d of %d)' % (self.M, self.N)] + + (['Derivation'] * self.N)) + '"\n' - node.derive(change_idx, False) - nodes.append(node) + for (idx, derivs, addr, script) in self.yield_addresses(start, n, change_idx=change): + ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode()) + ln += '","'.join(derivs) + ln += '"\n' - # this assumes we have same index for all keys - return make_redeem_script_tr(self.M, nodes, index) + yield ln def validate_script(self, redeem_script, subpaths=None, xfp_paths=None): # Check we can generate all pubkeys in the redeem script, raise on errors. @@ -702,7 +515,7 @@ def validate_script(self, redeem_script, subpaths=None, xfp_paths=None): found_pk = node.pubkey() # Document path(s) used. Not sure this is useful info to user tho. - # - Do not show what we can't verify: we don't really know the hardeneded + # - Do not show what we can't verify: we don't really know the hardened # part of the path from fingerprint to here. here = '[%s]' % xfp2str(xfp) if dp != len(path): @@ -814,7 +627,9 @@ def from_simple_text(cls, lines): continue # deserialize, update list and lots of checks - is_mine = cls.check_xpub(xfp, value, deriv, chains.current_chain().ctype, my_xfp, xpubs) + is_mine, item = check_xpub(xfp, value, deriv, chains.current_chain().ctype, + my_xfp, cls.disable_checks) + xpubs.append(item) if is_mine: has_mine += 1 @@ -827,22 +642,34 @@ def from_descriptor(cls, descriptor: str): my_xfp = settings.get('xfp') xpubs = [] - desc = MultisigDescriptor.parse(descriptor) - for xfp, deriv, xpub in desc.keys: + descriptor = Descriptor.from_string(descriptor) + descriptor.legacy_ms_compat() # raises + addr_fmt = descriptor.addr_fmt + + M, N = descriptor.miniscript.m_n() + for key in descriptor.miniscript.keys: + assert key.derivation.is_external, "Invalid subderivation path - only 0/* or <0;1>/* allowed" + xfp = key.origin.cc_fp + deriv = key.origin.str_derivation() + xpub = key.extended_public_key() deriv = cleanup_deriv_path(deriv) - is_mine = cls.check_xpub(xfp, xpub, deriv, chains.current_chain().ctype, my_xfp, xpubs) + is_mine, item = check_xpub(xfp, xpub, deriv, chains.current_chain().ctype, + my_xfp, cls.disable_checks) + xpubs.append(item) if is_mine: has_mine += 1 - return None, desc.addr_fmt, xpubs, has_mine, desc.M, desc.N, desc.internal_key + return None, addr_fmt, xpubs, has_mine, M, N def to_descriptor(self): - return MultisigDescriptor( - M=self.M, N=self.N, - keys=self.xpubs, - addr_fmt=self.addr_fmt, - internal_key=self.internal_key, - ) + keys = [ + Key.from_cc_data(xfp, deriv, xpub) + for xfp, deriv, xpub in self.xpubs + ] + miniscript = Sortedmulti(Number(self.M), *keys) + desc = Descriptor(miniscript=miniscript) + desc.set_from_addr_fmt(self.addr_fmt) + return desc @classmethod def from_file(cls, config, name=None): @@ -864,13 +691,12 @@ def from_file(cls, config, name=None): # - xpub: any bip32 serialization we understand, but be consistent # expect_chain = chains.current_chain().ctype - if MultisigDescriptor.is_descriptor(config): - # assume descriptor + if Descriptor.is_descriptor(config): + # assume descriptor, classic config should not contain sertedmulti( and check for checksum separator # ignore name - _, addr_fmt, xpubs, has_mine, M, N, internal_key = cls.from_descriptor(config) + _, addr_fmt, xpubs, has_mine, M, N = cls.from_descriptor(config) else: # oldschool - internal_key = None lines = [line for line in config.split('\n') if line] # remove empty lines parsed_name, addr_fmt, xpubs, has_mine, M, N = cls.from_simple_text(lines) if parsed_name: @@ -893,12 +719,7 @@ def from_file(cls, config, name=None): except: raise AssertionError('name must be ascii, 1..20 long') - assert N == len(xpubs), 'wrong # of xpubs, expect %d' % N - if addr_fmt != AF_P2TR: - assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range' - # there is no difference between script and keypath in taproot (huge privacy win) - assert addr_fmt & AFC_SCRIPT, 'script style addr fmt' # check we're included... do not insert ourselves, even tho we # have enough info, simply because other signers need to know my xpubkey anyway @@ -906,84 +727,7 @@ def from_file(cls, config, name=None): assert has_mine == 1, 'my key included more than once' # done. have all the parts - return cls(name, (M, N), xpubs, addr_fmt=addr_fmt, chain_type=expect_chain, internal_key=internal_key) - - @classmethod - def check_xpub(cls, xfp, xpub, deriv, expect_chain, my_xfp, xpubs): - # Shared code: consider an xpub for inclusion into a wallet, if ok, append - # to list: xpubs with a tuple: (xfp, deriv, xpub) - # return T if it's our own key - # - deriv can be None, and in very limited cases can recover derivation path - # - could enforce all same depth, and/or all depth >= 1, but - # seems like more restrictive than needed, so "m" is allowed - - 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 cls.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 cls.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 cls.disable_checks: - p_len = deriv.count('/') - assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % ( - p_len, depth, xfp2str(xfp)) - - if xfp == my_xfp: - # its 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 - xpubs.append((xfp, deriv, chain.serialize_public(node, AF_P2SH))) - - return (xfp == my_xfp) + return cls(name, (M, N), xpubs, addr_fmt=addr_fmt, chain_type=expect_chain) def make_fname(self, prefix, suffix='txt'): rv = '%s-%s.%s' % (prefix, self.name, suffix) @@ -1084,7 +828,7 @@ async def export_wallet_file(self, mode="exported from", extra_msg=None, descrip await needs_microsd() return except Exception as e: - await ux_show_story('Failed to write!\n\n\n'+str(e)) + await ux_show_story('Failed to write!\n\n%s\n%s' % (e, problem_file_line(e))) return def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc_pretty=True): @@ -1097,9 +841,10 @@ def render_export(self, fp, hdr_comment=None, descriptor=False, core=False, desc print("importdescriptors '%s'\n" % core_str, file=fp) else: if desc_pretty: - desc = desc_obj.pretty_serialize() + # TODO pretty serialize + desc = desc_obj.to_string(internal=False) else: - desc = desc_obj.serialize() + desc = desc_obj.to_string(internal=False) print("%s\n" % desc, file=fp) else: if hdr_comment: @@ -1171,8 +916,9 @@ def import_from_psbt(cls, M, N, xpubs_list): 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 = cls.check_xpub(xfp, xpub, keypath_to_str(path, skip=0), - expect_chain, my_xfp, xpubs) + 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) @@ -1180,7 +926,7 @@ def import_from_psbt(cls, M, N, xpubs_list): assert has_mine == 1 # 'my key not included' name = 'PSBT-%d-of-%d' % (M, N) - ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH) + ms = cls(name, (M, N), xpubs, chain_type=expect_chain, addr_fmt=addr_fmt or AF_P2SH) # TODO why legacy # may just keep just 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 @@ -1204,7 +950,9 @@ def validate_psbt_xpubs(self, xpubs_list): # cleanup and normalize xpub tmp = [] - self.check_xpub(xfp, xpub, keypath_to_str(path, skip=0), self.chain_type, 0, tmp) + is_mine, item = check_xpub(xfp, xpub, keypath_to_str(path, skip=0), + self.chain_type, 0, self.disable_checks) + tmp.append(item) (_, deriv, xpub_reserialized) = tmp[0] assert deriv # because given as arg @@ -1297,7 +1045,7 @@ async def confirm_import(self): continue if ch == 'y' and not is_dup: - # save to nvram, may raise MultisigOutOfSpace + # save to nvram, may raise WalletOutOfSpace if name_change: name_change.delete() @@ -1320,16 +1068,9 @@ async def show_detail(self, verbose=True): {at}\n\n'''.format(M=self.M, N=self.N, ctype=self.chain_type, at=self.render_addr_fmt(self.addr_fmt))) - if self.internal_key: - msg.write("Taproot internal key:\n\n") - if isinstance(self.internal_key, tuple): - xfp, deriv, xpub = self.internal_key - msg.write('%s:\n %s\n\n%s\n\n' % (xfp2str(xfp), deriv, xpub)) - else: - msg.write('%s (provably unspendable)\n\n' % self.internal_key) - - msg.write("Taproot tree keys:\n\n") - + # concern: the order of keys here is non-deterministic + # or order is taken from descriptor order (multi) but we do not support it + # or order is determined by BIP (sortedmulti) # concern: the order of keys here is non-deterministic for idx, (xfp, deriv, xpub) in enumerate(self.xpubs): if idx: @@ -1466,19 +1207,11 @@ async def make_ms_wallet_menu(menu, label, item): ms = MultisigWallet.get_by_idx(item.arg) if not ms: return - rv = [ + return [ MenuItem('"%s"' % ms.name, f=ms_wallet_detail, arg=ms), MenuItem('View Details', f=ms_wallet_detail, arg=ms), MenuItem('Descriptors', menu=make_ms_wallet_descriptor_menu, arg=ms), MenuItem('Delete', f=ms_wallet_delete, arg=ms), - ] - if ms.internal_key: - # internal key is defined -> Taproot - # do not provide legacy CC export or electrum export - # only descriptor export allowed (bitcoind object or plain descriptor) - return rv - - return rv + [ MenuItem('Coldcard Export', f=ms_wallet_ckcc_export, arg=(ms, {})), MenuItem('Electrum Wallet', f=ms_wallet_electrum_export, arg=ms), ] @@ -1527,7 +1260,7 @@ async def ms_wallet_ckcc_export(menu, label, item): async def ms_wallet_show_descriptor(menu, label, item): ms = item.arg desc = ms.to_descriptor() - desc_str = desc.serialize() + desc_str = desc.to_string(internal=False) ch = await ux_show_story("Press (1) to export in pretty human readable format.\n\n" + desc_str, escape="1") if ch == "1": await ms.export_wallet_file(descriptor=True, desc_pretty=True) @@ -1591,6 +1324,8 @@ async def export_multisig_xpubs(*a): m/48'/{coin}'/acct'/1' P2WSH: m/48'/{coin}'/acct'/2' +P2TR: + m/48'/{coin}'/acct'/3' OK to continue. X to abort.'''.format(coin=chain.b44_cointype) @@ -1716,8 +1451,9 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk= assert deriv == vals[mode+'_deriv'], "wrong derivation: %s != %s"%( deriv, vals[mode+'_deriv']) - is_mine = MultisigWallet.check_xpub(xfp, ln, deriv, - chain.ctype, my_xfp, xpubs) + is_mine, item = check_xpub(xfp, ln, deriv, chain.ctype, + my_xfp, MultisigWallet.disable_checks) + xpubs.append(item) if is_mine: has_mine += 1 @@ -1800,9 +1536,9 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk= name = 'CC-%d-of-%d' % (M, N) ms = MultisigWallet(name, (M, N), xpubs, chain_type=chain.ctype, addr_fmt=addr_fmt) - from auth import NewEnrollRequest, UserAuthorizedAction + from auth import NewMiniscriptEnrollRequest, UserAuthorizedAction - UserAuthorizedAction.active_request = NewEnrollRequest(ms, auto_export=True) + UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(ms, auto_export=True) # menu item case: add to stack from ux import the_ux @@ -1832,14 +1568,14 @@ async def import_multisig_nfc(*a): from glob import NFC # this menu option should not be available if NFC is disabled try: - return await NFC.import_multisig_nfc() + return await NFC.import_miniscript_nfc(legacy_multisig=True) except Exception as e: await ux_show_story(title="ERROR", msg="Failed to import multisig. %s" % str(e)) async def import_multisig(*a): # pick text file from SD card, import as multisig setup file from actions import file_picker - from glob import VD + from glob import VD, dis force_vdisk = False if VD: @@ -1878,6 +1614,7 @@ def possible(filename): await needs_microsd() return + dis.fullscreen('Wait...') from auth import maybe_enroll_xpub try: possible_name = (fn.split('/')[-1].split('.'))[0] diff --git a/shared/nfc.py b/shared/nfc.py index 0939b42f..4ecb48de 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -535,32 +535,6 @@ def is_suitable(fname): else: raise ValueError(ctype) - 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 - data = await self.start_nfc_rx() - if not data: return - - winner = None - for urn, msg, meta in ndef.record_parser(data): - if len(msg) < 70: continue - msg = bytes(msg).decode() # from memory view - if 'pub' in msg or 'sortedmulti(' in msg: - winner = msg - break - - if not winner: - await ux_show_story('Unable to find data expected in NDEF') - return - - 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): data = await self.start_nfc_rx() if not data: return @@ -789,4 +763,26 @@ async def read_bsms_data(self): return winner + async def import_miniscript_nfc(self, legacy_multisig=False): + data = await self.start_nfc_rx() + if not data: return + + winner = None + for urn, msg, meta in ndef.record_parser(data): + if len(msg) < 70: continue + msg = bytes(msg).decode() # from memory view + if 'pub' in msg: + winner = msg + break + + if not winner: + await ux_show_story('Unable to find miniscript descriptor expected in NDEF') + return + + from auth import maybe_enroll_xpub + try: + maybe_enroll_xpub(config=winner, miniscript=not legacy_multisig) + except Exception as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + # EOF diff --git a/shared/nvstore.py b/shared/nvstore.py index 8110c73c..e27915af 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -39,6 +39,7 @@ # terms_ok = customer has signed-off on the terms of sale # tested = selftest has been completed successfully # multisig = list of defined multisig wallets (complex) +# miniscript = list of defined miniscript wallets (complex) # pms = trust/import/distrust xpubs found in PSBT files # axi = index of last selected address in explorer # lgto = (minutes) how long to wait for Login Countdown feature [pre v4.0.2] diff --git a/shared/psbt.py b/shared/psbt.py index bbe049c6..253711a3 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -4,18 +4,19 @@ # from ustruct import unpack_from, unpack, pack from ubinascii import hexlify as b2a_hex -from utils import xfp2str, B2A, keypath_to_str, validate_derivation_path_length +from utils import xfp2str, B2A, keypath_to_str, validate_derivation_path_length, problem_file_line import stash, gc, history, sys, ngu, ckcc from uhashlib import sha256 from uio import BytesIO from chains import taptweak, tapleaf_hash from sffile import SizerFile from sram2 import psbt_tmp256 -from multisig import MultisigWallet, disassemble_multisig_mn, disassemble_multisig_mn_tr +from multisig import MultisigWallet, disassemble_multisig_mn +from miniscript import MiniScriptWallet from exceptions import FatalPSBTIssue, FraudulentChangeOutput -from serializations import ser_compact_size, deser_compact_size, hash160, hash256 -from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, ser_uint256 -from serializations import ser_sig_der, uint256_from_str, ser_push_data, uint256_from_str +from serializations import ser_compact_size, deser_compact_size, hash160 +from serializations import CTxIn, CTxInWitness, CTxOut, ser_string +from serializations import ser_sig_der, ser_push_data, uint256_from_str from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY, SIGHASH_DEFAULT from serializations import ALL_SIGHASH_FLAGS from glob import settings @@ -458,7 +459,7 @@ def serialize(self, out_fd, my_idx): for k, v in self.unknown.items(): wr(k[0], v, k[1:]) - def validate(self, out_idx, txo, my_xfp, active_multisig, parent): + def validate(self, out_idx, txo, my_xfp, active_multisig, active_miniscript, parent): # Do things make sense for this output? # NOTE: We might think it's a change output just because the PSBT @@ -512,9 +513,25 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent): witness_script = self.get(self.witness_script) if self.witness_script else None if not redeem_script and not witness_script: - # Perhaps an omission, so let's not call fraud on it - # But definately required, else we don't know what script we're sending to. - raise FatalPSBTIssue("Missing redeem/witness script for output #%d" % out_idx) + if active_miniscript: + # TODO + # this should be also acceptable for any other script type, we do not need + # redeem/witness script + # scriptPubkey can be compared against script that we build - if exact match change + # if not not change - definitely not FatalPSBTIssue + # + # without this I cannot sign with liana as they do not provide witness/redeem + try: + active_miniscript.validate_script_pubkey(txo.scriptPubKey, + list(self.subpaths.values())) + self.is_change = True + return + except Exception as e: + raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e) + else: + # Perhaps an omission, so let's not call fraud on it + # But definately required, else we don't know what script we're sending to. + raise FatalPSBTIssue("Missing redeem/witness script for output #%d" % out_idx) if not is_segwit and redeem_script and \ len(redeem_script) == 22 and \ @@ -525,37 +542,43 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent): expect_pkh = hash160(expect_pubkey) else: - # Multisig change output, for wallet we're supposed to be a part of. - # - our key must be part of it - # - must look like input side redeem script (same fingerprints) - # - assert M/N structure of output to match any inputs we have signed in PSBT! - # - assert all provided pubkeys are in redeem script, not just ours - # - we get all of that by re-constructing the script from our wallet details - - # it cannot be change if it doesn't precisely match our multisig setup - if not active_multisig: - # - might be a p2sh output for another wallet that isn't us + if not active_multisig and not active_miniscript: + # - might be output for another wallet that isn't us # - not fraud, just an output with more details than we need. - self.is_change = False - return - - if MultisigWallet.disable_checks: - # Without validation, we have to assume all outputs - # will be taken from us, and are not really change. - self.is_change = False + self.is_change =False return - - # redeem script must be exactly what we expect - # - pubkeys will be reconstructed from derived paths here - # - BIP-45, BIP-67 rules applied - # - p2sh-p2wsh needs witness script here, not redeem script value - # - if details provided in output section, must our match multisig wallet - try: - active_multisig.validate_script(witness_script or redeem_script, - subpaths=self.subpaths) - except BaseException as exc: - raise FraudulentChangeOutput(out_idx, - "P2WSH or P2SH change output script: %s" % exc) + if active_multisig: + # Multisig change output, for wallet we're supposed to be a part of. + # - our key must be part of it + # - must look like input side redeem script (same fingerprints) + # - assert M/N structure of output to match any inputs we have signed in PSBT! + # - assert all provided pubkeys are in redeem script, not just ours + # - we get all of that by re-constructing the script from our wallet details + if MultisigWallet.disable_checks: + # Without validation, we have to assume all outputs + # will be taken from us, and are not really change. + self.is_change = False + return + # redeem script must be exactly what we expect + # - pubkeys will be reconstructed from derived paths here + # - BIP-45, BIP-67 rules applied + # - p2sh-p2wsh needs witness script here, not redeem script value + # - if details provided in output section, must our match multisig wallet + try: + active_multisig.validate_script(witness_script or redeem_script, + subpaths=self.subpaths) + except BaseException as exc: + raise FraudulentChangeOutput(out_idx, + "P2WSH or P2SH change output script: %s" % exc) + else: + # active miniscript + try: + active_miniscript.validate_script(witness_script or redeem_script, + list(self.subpaths.values()), + script_pubkey=txo.scriptPubKey) + except BaseException as exc: + raise FraudulentChangeOutput(out_idx, + "P2WSH or P2SH change output script: %s" % exc) if is_segwit: # p2wsh case @@ -589,30 +612,17 @@ def validate(self, out_idx, txo, my_xfp, active_multisig, parent): expect_pkh = hash160(expect_pubkey) elif addr_type == "p2tr": if expect_pubkey is None and len(self.taproot_subpaths) > 1: - # tapscript - if not active_multisig: - # - might be a p2sh output for another wallet that isn't us - # - not fraud, just an output with more details than we need. - self.is_change = False - return - - if MultisigWallet.disable_checks: - # Without validation, we have to assume all outputs - # will be taken from us, and are not really change. - self.is_change = False - return - - internal_key = active_multisig.validate_tr_internal_key(self.taproot_subpaths) - if internal_key != self.get(self.taproot_internal_key): - raise FraudulentChangeOutput(out_idx, "Internal key from PSBT does not match registered key") - parsed_tree = self.parse_taproot_tree() - assert len(parsed_tree) == 1, "Taproot tree too complex" - depth, leaf_version, script = parsed_tree[0] - target = active_multisig.make_multisig_tr(self.taproot_subpaths) - if target != script: - raise FraudulentChangeOutput(out_idx, "Taproot leaf script does not match") - h = tapleaf_hash(script, leaf_version) - expect_pkh = taptweak(internal_key, h) + if active_miniscript: + try: + active_miniscript.validate_script_pubkey( + b"\x51\x20" + pkh, + [v[1:] for v in self.taproot_subpaths.values() if v[0]] + ) + self.is_change = True + return + except Exception as e: + raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e) + expect_pkh = None else: expect_pkh = taptweak(expect_pubkey) else: @@ -640,8 +650,8 @@ class psbtInputProxy(psbtProxy): blank_flds = ('unknown', 'part_sig', 'subpaths', 'taproot_subpaths', 'taproot_internal_key', 'utxo', 'witness_utxo', 'sighash', 'redeem_script', 'witness_script', 'fully_signed', 'is_segwit', 'is_multisig', 'is_p2sh', - 'num_our_keys', 'required_key', 'scriptSig', 'amount', 'scriptCode', 'added_sig', 'taproot_key_sig', - 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', "tapscript") + 'num_our_keys', 'required_key', 'scriptSig', 'amount', 'scriptCode', 'taproot_key_sig', + 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', "use_keypath") def __init__(self, fd, idx): super().__init__() @@ -671,7 +681,6 @@ def __init__(self, fd, idx): #self.scriptCode = None # only expected for segwit inputs # after signing, we'll have a signature to add to output PSBT - # self.added_sig = None # self.taproot_subpaths = {} # will be empty if non-taproot # self.taproot_internal_key = None # will be empty if non-taproot @@ -738,8 +747,6 @@ def validate(self, idx, txin, my_xfp, parent): # - could also look at pubkey needed vs. sig provided # - could consider structure of MofN in p2sh cases self.fully_signed = len(self.part_sig) >= len(self.subpaths) - elif self.taproot_script_sigs: - self.fully_signed = len(self.taproot_script_sigs) >= len(self.taproot_subpaths) else: # No signatures at all yet for this input (typical non multisig) self.fully_signed = False @@ -833,7 +840,7 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): # - which pubkey needed # - scriptSig value # - also validates redeem_script when present - + merkle_root = None self.amount = utxo.nValue if (not self.subpaths and not self.taproot_subpaths) or self.fully_signed: @@ -844,6 +851,7 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): return self.is_multisig = False + self.is_miniscript = False self.is_p2sh = False which_key = None @@ -893,9 +901,13 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): self.is_segwit = True else: # multiple keys involved, we probably can't do the finalize step - self.is_multisig = True + M, N = disassemble_multisig_mn(redeem_script) + if M is None and N is None: + self.is_miniscript = True + else: + self.is_multisig = True - if self.witness_script and not self.is_segwit and self.is_multisig: + if self.witness_script and not self.is_segwit and (self.is_miniscript or self.is_multisig): # bugfix addr_type = 'p2sh-p2wsh' self.is_segwit = True @@ -928,7 +940,8 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): if output_key == pubkey: which_key = xonly_pubkey else: - # tapscript + # tapscript (is always miniscript wallet) + self.is_miniscript = True for xonly_pubkey, lhs_path in self.taproot_subpaths.items(): lhs, path = lhs_path[0], lhs_path[1:] # meh - should be a tuple # ignore keys that does not have correct xfp specified in PSBT @@ -938,14 +951,11 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): output_key = taptweak(xonly_pubkey, merkle_root) if output_key == pubkey: which_key = xonly_pubkey - self.tapscript = False - self.is_multisig = False # if we find a possibiity to spend keypath (internal_key) - we do keypath # even though script path is available + self.use_keypath = True break else: - self.tapscript = True - self.is_multisig = True # can be multisig but we need to check the script internal_key = self.get(self.taproot_internal_key) output_pubkey = taptweak(internal_key, merkle_root) if not which_key: @@ -953,41 +963,6 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): if pubkey == output_pubkey: which_key.add(xonly_pubkey) - if which_key: - # for now we only support depth 0 - one script - assert len(self.taproot_scripts) == 1, "Multiple tapleafs" - script, leaf_ver = list(self.taproot_scripts.keys())[0] - M, N = disassemble_multisig_mn_tr(script) - xfp_paths = [val[1:] for val in self.taproot_subpaths.values() if val[0]] # filter out internal - xfp_paths.sort() - - if not psbt.active_multisig: - # search for multisig wallet - wal = MultisigWallet.find_match(M, N, xfp_paths) - if not wal: - raise FatalPSBTIssue('Unknown multisig wallet') - - psbt.active_multisig = wal - else: - # check consistent w/ already selected wallet - psbt.active_multisig.assert_matching(M, N, xfp_paths) - - internal_key = psbt.active_multisig.validate_tr_internal_key(self.taproot_subpaths) - if internal_key != self.get(self.taproot_internal_key): - raise FraudulentChangeOutput(my_idx, "Internal key from PSBT does not match registered key") - # now that we have active multisig, we can just build the script and check if equal - # not sure if it is more expensive than what 'validate_script' does - target = psbt.active_multisig.make_multisig_tr(self.taproot_subpaths) - if target != script: - raise FatalPSBTIssue('Input #%d: %s' % (my_idx, "Script does not match registered multisig descriptor")) - # as we only allow one script (tree depth 0) - meaning our script is also merkle root (when hashed) - # EXTREMELY IMPORTANT merkle root needs to be verified so that we are sure that we know all the - # possible scripts in it - otherwise we can get rugged by unknown scriptpath - do not trust PSBT with merkle root - if tapleaf_hash(script, leaf_ver) != merkle_root: - raise FatalPSBTIssue('Input #%d: %s' % (my_idx, "Merkle root does not match")) - self.required_key = which_key - return - elif addr_type == 'p2pk': # input is single public key (less common) self.scriptSig = utxo.scriptPubKey @@ -1010,7 +985,6 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): # - check it's the right M/N to match redeem script #print("redeem: %s" % b2a_hex(redeem_script)) - M, N = disassemble_multisig_mn(redeem_script) xfp_paths = list(self.subpaths.values()) xfp_paths.sort() @@ -1032,6 +1006,27 @@ def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): sys.print_exception(exc) raise FatalPSBTIssue('Input #%d: %s' % (my_idx, exc)) + if self.is_miniscript and which_key: + try: + xfp_paths = [item[1:] for item in self.taproot_subpaths.values() if item[0]] + except AttributeError: + xfp_paths = list(self.subpaths.values()) + + xfp_paths.sort() + if not psbt.active_miniscript: + wal = MiniScriptWallet.find_match(xfp_paths) + if not wal: + raise FatalPSBTIssue('Unknown miniscript wallet') + psbt.active_miniscript = wal + + assert psbt.active_miniscript + try: + # contains PSBT merkle root verification + psbt.active_miniscript.validate_script_pubkey(utxo.scriptPubKey, + xfp_paths, merkle_root) + except BaseException as e: + raise FatalPSBTIssue('Input #%d: %s\n\n' % (my_idx, e) + problem_file_line(e)) + if not which_key and DEBUG: print("no key: input #%d: type=%s segwit=%d a_or_pk=%s scriptPubKey=%s" % ( my_idx, addr_type, self.is_segwit or 0, @@ -1123,10 +1118,6 @@ def serialize(self, out_fd, my_idx): for pk in self.part_sig: wr(PSBT_IN_PARTIAL_SIG, self.part_sig[pk], pk) - if self.added_sig: - pubkey, sig = self.added_sig - wr(PSBT_IN_PARTIAL_SIG, sig, pubkey) - if self.taproot_key_sig: wr(PSBT_IN_TAP_KEY_SIG, self.taproot_key_sig) @@ -1215,6 +1206,7 @@ def __init__(self): # this points to a MS wallet, during operation # - we are only supporting a single multisig wallet during signing self.active_multisig = None + self.active_miniscript = None self.warnings = [] @@ -1487,7 +1479,7 @@ def consider_outputs(self): for idx, txo in self.output_iter(): output = self.outputs[idx] # perform output validation - output.validate(idx, txo, self.my_xfp, self.active_multisig, self) + output.validate(idx, txo, self.my_xfp, self.active_multisig, self.active_miniscript, self) if output.is_change: self.num_change_outputs += 1 @@ -1929,43 +1921,56 @@ def sign_it(self): txi.scriptSig = inp.scriptSig schnorrsig = False - taproot_script = None - leaf_ver = None + tr_sh = [] inp.handle_none_sighash() - if inp.is_multisig or inp.tapscript: + to_sign = [] + if isinstance(inp.required_key, set) and (inp.is_multisig or inp.is_miniscript): # need to consider a set of possible keys, since xfp may not be unique for which_key in inp.required_key: # get node required - if inp.tapscript: # this can be set to False even if we haev script ready, but can send keypath + if inp.taproot_subpaths: # this can be set to False even if we haev script ready, but can send keypath # tapscript schnorrsig = True - skp = keypath_to_str(inp.taproot_subpaths[which_key][1:]) + xfp_paths = [item[1:] for item in inp.taproot_subpaths.values() if item[0]] + int_path = inp.taproot_subpaths[which_key][1:] + skp = keypath_to_str(int_path) else: - skp = keypath_to_str(inp.subpaths[which_key]) + xfp_paths = list(inp.subpaths.values()) + int_path = inp.subpaths[which_key] + skp = keypath_to_str(int_path) node = sv.derive_path(skp, register=False) # expensive test, but works... and important pu = node.pubkey() if pu == which_key: - break + to_sign.append(node) if len(which_key) == 32 and pu[1:] == which_key: # get the script - # only one script supported and already verified + inner_tr_sh = [] + assert self.active_miniscript + der_d = self.active_miniscript.derive_desc(xfp_paths) for (script, lv), cb in inp.taproot_scripts.items(): - if which_key in script: - taproot_script = script - leaf_ver = lv - assert leaf_ver == TAPROOT_LEAF_TAPSCRIPT, "tapleaf ver" - break - break - else: - raise AssertionError("Input #%d needs pubkey I dont have" % in_idx) + target_leaf = None + # always exact check/match the script, if we would generate such + for leaf in der_d.tapscript.iter_leaves(der_d.tapscript.tree): + sc = leaf.compile() + if sc == script: + target_leaf = leaf + break + else: + continue + + if which_key in [k.key_bytes() for k in target_leaf.keys]: + inner_tr_sh.append((script, lv)) + + to_sign.append(node) + tr_sh.append(inner_tr_sh) else: # single pubkey <=> single key which_key = inp.required_key - assert not inp.added_sig, "already done??" + assert not inp.part_sig, "already done??" assert not inp.taproot_key_sig, "already done taproot??" if inp.subpaths and inp.subpaths.get(which_key) and inp.subpaths[which_key][0] == self.my_xfp: @@ -1989,6 +1994,7 @@ def sign_it(self): continue assert pu == which_key, "Path (%s) led to wrong pubkey for input#%d"%(skp, in_idx) + to_sign.append(node) if sv.deltamode: # Current user is actually a thug with a slightly wrong PIN, so we @@ -2003,89 +2009,100 @@ def sign_it(self): # Hash the inputs and such in totally new ways, based on BIP-143 if not inp.taproot_subpaths: digest = self.make_txn_segwit_sighash(in_idx, txi, inp.amount, inp.scriptCode, inp.sighash) - elif taproot_script: - digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash, scriptpath=True, - script=taproot_script, leaf_ver=leaf_ver) + elif tr_sh: + pass # later ( else: digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash) # The precious private key we need - pk = node.privkey() + if not inp.taproot_script_sigs: + inp.taproot_script_sigs = {} + + if not inp.part_sig: + inp.part_sig = {} + + for i, node in enumerate(to_sign): + sk = node.privkey() + kp = ngu.secp256k1.keypair(sk) + pk = node.pubkey() + xonly_pk = kp.xonly_pubkey().to_bytes() + + #print("privkey %s" % b2a_hex(sk).decode('ascii')) + #print(" pubkey %s" % b2a_hex(pk).decode('ascii')) + #print(" digest %s" % b2a_hex(digest).decode('ascii')) + + # Do the ACTUAL signature ... finally!!! + if schnorrsig: + if tr_sh: + # in tapscript keys are not tweaked, just sign with the key in the script + for taproot_script, leaf_ver in tr_sh[i]: + _key = (xonly_pk, tapleaf_hash(taproot_script, leaf_ver)) + if _key in inp.taproot_script_sigs: + continue + + digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash, + scriptpath=True, + script=taproot_script, leaf_ver=leaf_ver) + sig = ngu.secp256k1.sign_schnorr(sk, digest, ngu.random.bytes(32)) + if inp.sighash != SIGHASH_DEFAULT: + sig += bytes([inp.sighash]) + # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by + # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed + inp.taproot_script_sigs[_key] = sig + else: + # BIP 341 states: "If the spending conditions do not require a script path, + # the output key should commit to an unspendable script path instead of having no script path. + # This can be achieved by computing the output key point as Q = P + int(hashTapTweak(bytes(P)))G." + internal_key = xonly_pk + tweak = internal_key + if inp.taproot_merkle_root is not None: + # we have a script path but internal key is spendable by us + # merkle root needs to be added to tweak with internal key + # merkle root was already verified against registered script in determine_my_signing_key + tweak += self.get(inp.taproot_merkle_root) + tweak = ngu.secp256k1.tagged_sha256(b"TapTweak", tweak) + kpt = kp.xonly_tweak_add(tweak) + sig = ngu.secp256k1.sign_schnorr(kpt, digest, ngu.random.bytes(32)) + if inp.sighash != SIGHASH_DEFAULT: + sig += bytes([inp.sighash]) + # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by + # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed + inp.taproot_key_sig = sig + else: + # We need to grind sometimes to get a positive R + # value that will encode (after DER) into a shorter string. + # - saves on miner's fee (which might be expected/required) + # - blends in with Bitcoin Core signatures which do this + for retry in range(10): + result = ngu.secp256k1.sign(sk, digest, retry).to_bytes() + + # convert signature to DER format + #assert len(result) == 65 + r = result[1:33] + s = result[33:65] + sig = ser_sig_der(r, s, inp.sighash) + + if len(sig) <= 71: + # odds of needing retry: just under 50% I think + break - #print("privkey %s" % b2a_hex(pk).decode('ascii')) - #print(" pubkey %s" % b2a_hex(which_key).decode('ascii')) - #print(" digest %s" % b2a_hex(digest).decode('ascii')) + # add to psbt + inp.part_sig[pk] = sig + # memory cleanup + del result, r, s - # Do the ACTUAL signature ... finally!!! - if schnorrsig: - if taproot_script: - # in tapscript keys are not tweaked, just sign with the key in the script - sig = ngu.secp256k1.sign_schnorr(pk, digest, ngu.random.bytes(32)) - else: - # BIP 341 states: "If the spending conditions do not require a script path, - # the output key should commit to an unspendable script path instead of having no script path. - # This can be achieved by computing the output key point as Q = P + int(hashTapTweak(bytes(P)))G." - kp = ngu.secp256k1.keypair(pk) - internal_key = kp.xonly_pubkey().to_bytes() - tweak = internal_key - if inp.taproot_merkle_root is not None: - # we have a script path but internal key is spendable by us - # merkle root needs to be added to tweak with internal key - # merkle root was already verified against registered script in determine_my_signing_key - tweak += self.get(inp.taproot_merkle_root) - tweak = ngu.secp256k1.tagged_sha256(b"TapTweak", tweak) - kpt = kp.xonly_tweak_add(tweak) - sig = ngu.secp256k1.sign_schnorr(kpt, digest, ngu.random.bytes(32)) - else: - # We need to grind sometimes to get a positive R - # value that will encode (after DER) into a shorter string. - # - saves on miner's fee (which might be expected/required) - # - blends in with Bitcoin Core signatures which do this - for retry in range(10): - result = ngu.secp256k1.sign(pk, digest, retry).to_bytes() - - # convert signature to DER format - #assert len(result) == 65 - r = result[1:33] - s = result[33:65] - sig = ser_sig_der(r, s, inp.sighash) - - if len(sig) <= 71: - # odds of needing retry: just under 50% I think - break - - # memory cleanup - del result, r, s - - # private key no longer required - stash.blank_object(pk) - stash.blank_object(node) - del pk, node, pu, skp - - if schnorrsig: - if inp.sighash != SIGHASH_DEFAULT: - sig += bytes([inp.sighash]) - # in the common case of SIGHASH_DEFAULT, encoded as ''0x00'', a space optimization MUST be made by - # ''omitting'' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed - if taproot_script: - if not inp.taproot_script_sigs: - inp.taproot_script_sigs = {} - # in current implementation only one leaf script is allowed and that is a also a merkle root - # save cpu cycles by not hashing cript again - # merkle root was already verified - inp.taproot_script_sigs[(which_key, self.get(inp.taproot_merkle_root))] = sig - else: - inp.taproot_key_sig = sig - else: - inp.added_sig = (which_key, sig) + # private key no longer required + stash.blank_object(sk) + stash.blank_object(node) + del sk, node - # Could remove sighash from input object - it is not required, takes space, - # and is already in signature or is implicit by not being part of the - # signature (taproot SIGHASH_DEFAULT) - ## inp.sighash = None + # Could remove sighash from input object - it is not required, takes space, + # and is already in signature or is implicit by not being part of the + # signature (taproot SIGHASH_DEFAULT) + ## inp.sighash = None - success.add(in_idx) - gc.collect() + success.add(in_idx) + gc.collect() # done. dis.progress_bar_show(1) @@ -2348,10 +2365,11 @@ def is_complete(self): # plus we added some signatures for inp in self.inputs: - if inp.is_multisig: - # but we can't combine/finalize multisig stuff, so will never't be 'final' + if inp.is_multisig or (inp.is_miniscript and not inp.use_keypath): + # but we can't combine/finalize multisig/miniscript stuff, so will never be 'final' + # we could tho return False - if inp.added_sig: + if inp.part_sig and len(inp.part_sig) == len(inp.subpaths): signed += 1 if inp.taproot_key_sig: signed += 1 @@ -2396,10 +2414,11 @@ def finalize(self, fd): else: # insert the new signature(s), assuming fully signed txn. - assert inp.added_sig, 'No signature on input #%d'%in_idx + assert inp.part_sig, 'No signature on input #%d' % in_idx + assert len(inp.part_sig) < 2, 'More signatures on input #%d' % in_idx assert not inp.is_multisig, 'Multisig PSBT combine not supported' - pubkey, der_sig = inp.added_sig + pubkey, der_sig = list(inp.part_sig.items())[0] s = b'' s += ser_push_data(der_sig) @@ -2426,7 +2445,7 @@ def finalize(self, fd): for in_idx, wit in self.input_witness_iter(): inp = self.inputs[in_idx] - if inp.is_segwit and (inp.added_sig or inp.taproot_key_sig): + if inp.is_segwit and (inp.part_sig or inp.taproot_key_sig): # put in new sig: wit is a CTxInWitness assert not wit.scriptWitness.stack, 'replacing non-empty?' assert not inp.is_multisig, 'Multisig PSBT combine not supported' @@ -2438,7 +2457,7 @@ def finalize(self, fd): wit.scriptWitness.stack = [inp.taproot_key_sig] else: # segwit v0 - pubkey, der_sig = inp.added_sig + pubkey, der_sig = list(inp.part_sig.items())[0] assert pubkey[0] in {0x02, 0x03} and len(pubkey) == 33, "bad v0 pubkey" wit.scriptWitness.stack = [der_sig, pubkey] diff --git a/shared/utils.py b/shared/utils.py index e5196921..edcee369 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -8,6 +8,7 @@ from ubinascii import a2b_base64, b2a_base64 from uhashlib import sha256 from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR, MAX_PATH_DEPTH +from public_constants import AF_P2WSH, AF_P2WSH_P2SH B2A = lambda x: str(b2a_hex(x), 'ascii') @@ -237,7 +238,7 @@ def cleanup_deriv_path(bin_path, allow_star=False): # - star or star' can be last only (checked by regex above) assert p == '*' or p == "*'", "bad wildcard" continue - if p[-1] == "'": + if p[-1] in "'h": p = p[0:-1] try: ip = int(p, 10) @@ -263,7 +264,7 @@ def str_to_keypath(xfp, path): if i == 'm': continue if not i: continue # trailing or duplicated slashes - if i[-1] == "'": + if i[-1] in "'h": here = int(i[:-1]) | 0x80000000 else: here = int(i) @@ -544,7 +545,90 @@ def addr_fmt_label(addr_fmt): AF_CLASSIC: "Classic P2PKH", AF_P2WPKH_P2SH: "P2SH-Segwit", AF_P2WPKH: "Segwit P2WPKH", - AF_P2TR: "Taproot P2TR" + AF_P2TR: "Taproot P2TR", + AF_P2WSH: "Segwit P2WSH", + AF_P2WSH_P2SH: "P2SH-P2WSH" }[addr_fmt] +def check_xpub(xfp, xpub, deriv, expect_chain, my_xfp, disable_checks=False): + # Shared code: consider an xpub for inclusion into a wallet + # return T if it's our own key and parsed details in form (xfp, deriv, xpub) + # - deriv can be None, and in very limited cases can recover derivation path + # - could enforce all same depth, and/or all depth >= 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('/') + assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % ( + p_len, depth, xfp2str(xfp)) + + if xfp == my_xfp: + # its 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 truncate_address(addr): + # Truncates address to width of screen, replacing middle chars + # - 16 chars screen width + # - but 2 lost at left (menu arrow, corner arrow) + # - want to show not truncated on right side + return addr[0:5] + '⋯' + addr[-6:] + # EOF diff --git a/shared/wallet_base.py b/shared/wallet_base.py new file mode 100644 index 00000000..5c7f2fe5 --- /dev/null +++ b/shared/wallet_base.py @@ -0,0 +1,97 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +from glob import settings + + +class WalletOutOfSpace(RuntimeError): + pass + + +class BaseWallet: + key_name = None + + def __init__(self): + self.storage_idx = -1 + + @classmethod + def delete_all(cls): + settings.set(cls.key_name, []) + settings.save() + + @classmethod + def exists(cls): + # are there any wallets defined? + return bool(settings.get(cls.key_name, False)) + + @classmethod + def get_all(cls): + # return them all, as a generator + return cls.iter_wallets() + + @classmethod + def iter_wallets(cls): + # - this is only place we should be searching this list, please!! + lst = settings.get(cls.key_name, []) + + 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, []) + try: + obj = lst[nth] + except IndexError: + return None + + return cls.deserialize(obj, nth) + + def commit(self): + # data to save + # - important that this fails immediately when nvram overflows + obj = self.serialize() + + v = settings.get(self.key_name, []) + orig = v.copy() + if not v or self.storage_idx == -1: + # create + self.storage_idx = len(v) + v.append(obj) + else: + # update in place + v[self.storage_idx] = obj + + settings.set(self.key_name, v) + + # save now, rather than in background, so we can recover + # from out-of-space situation + try: + settings.save() + except: + # back out change; no longer sure of NVRAM state + try: + settings.set(self.key_name, orig) + settings.save() + except: pass # give up on recovery + + raise WalletOutOfSpace + + def delete(self): + # 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, []) + try: + del lst[self.storage_idx] + settings.set(self.key_name, lst) + settings.save() + except IndexError: pass + self.storage_idx = -1 \ No newline at end of file diff --git a/stm32/version.mk b/stm32/version.mk index 49d8bf20..317fbcf8 100644 --- a/stm32/version.mk +++ b/stm32/version.mk @@ -1,4 +1,4 @@ # Our version for this release. -VERSION_STRING = 6.0.0X +VERSION_STRING = 6.1.0X diff --git a/testing/conftest.py b/testing/conftest.py index 968f0207..001e4469 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -551,6 +551,12 @@ def doit(): return doit +@pytest.fixture +def clear_miniscript(unit_test): + def doit(): + unit_test('devtest/wipe_miniscript.py') + return doit + @pytest.fixture def goto_home(cap_menu, need_keypress, pick_menu_item): diff --git a/testing/data/taproot/taptree-sig.psbt b/testing/data/taproot/taptree-sig.psbt deleted file mode 100644 index 22d4eb7e..00000000 --- a/testing/data/taproot/taptree-sig.psbt +++ /dev/null @@ -1 +0,0 @@ -cHNidP8BAH0CAAAAAXCtYK/Jg/Kn0evenkQsuGtUTaOfTmQP1axdYqPIAJqDAQAAAAD9////AgBlzR0AAAAAFgAU44C1sBNuPWFXAMF2LJ8Y+z7QPo7ooEIGAQAAACJRIL7amYi5oY4P2rhBuIb0F+q8vU/IpFNJ/pgq/NtPy29FAAAAAAABASsAERAkAQAAACJRIBRPGMzNnIiPQgDc622mBl/oRZ+5qdPtvc7W9FwwWUCJQRQEqesy8gLnwS/oHt51S5Ni/ySorUlKlW1zbLjt+bBXy7LFv1ArSq3brG4ha9Tt9fB7KFf2CCWJDvvjEW3uKsV0QA3mYPQruz0SJLs2dofFrvC/eumAYEzzIt48aPKnUWzAsTD4L9pBBa6UcFjqJU7ieYHboRtqr3PLSAMziPOxosBBFJcm/yTqiHOuwTjige3GCLv9q/fwLtpX+Luh8GaHanQrlmncr/fHLFbs5RrP5Ro5cILPwxkTJTuuJ3rc3yKGMk9Aj5w4nABvS4ws+isUI+m4HYQPu4EPDfJI5WK19yC5lD4xJMNTGc0LiUQ4qb4LSawCWZLfup3FAdoCknWIeweeCEEUshraB5oSdASIFPn5VLFRgq6hxQl+nIJCCmfG739E3URmzrzqnb0JPsyE8NmbfnXATNJaT+wfLxr+ZYuYX2Kn3ECkOczkRZ73NhoWKDepJ/f2BD6k4gtYnqLeNq0E2dl2G6HTEpJ2KUehfJfnPie/9cfewyWgDOlt7yOJrUL3dcb9QRS/4UYIFq2TdB5PE+1EPgl8DrO3A4WxUvPbWKuJquiH/3j7aDv332LI52Kw4zHEGW7RMejMUhWudaE1v0L9VykyQIkiXVBEkl/N8lugTk3CNHCRoMw+RUJl5YK/fpMu/+NB8Rwtl1pkphFnlTY99RTPc8Je3N++7d287/fyhOR6Q41iFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJZp3K/3xyxW7OUaz+UaOXCCz8MZEyU7rid63N8ihjJPfieaID7vsPE/UemxTS+uAw8IcupeHlTihcizFSFt6FJHIASp6zLyAufBL+ge3nVLk2L/JKitSUqVbXNsuO35sFfLrCAsuLl0CXEn/5p7TnlIJ0NsTO3P4Dimx4ZeckdnOP6bSbpSnMBiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wLLFv1ArSq3brG4ha9Tt9fB7KFf2CCWJDvvjEW3uKsV0fieaID7vsPE/UemxTS+uAw8IcupeHlTihcizFSFt6FJHICy4uXQJcSf/mntOeUgnQ2xM7c/gOKbHhl5yR2c4/ptJrCCXJv8k6ohzrsE44oHtxgi7/av38C7aV/i7ofBmh2p0K7pSnMBiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wHj7aDv332LI52Kw4zHEGW7RMejMUhWudaE1v0L9Vyky87vGhaOKk7JWbn43qyCtq+j2sGUhIgsbJi1rYD41zKFHICy4uXQJcSf/mntOeUgnQ2xM7c/gOKbHhl5yR2c4/ptJrCCyGtoHmhJ0BIgU+flUsVGCrqHFCX6cgkIKZ8bvf0TdRLpSnMBiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wGbOvOqdvQk+zITw2Zt+dcBM0lpP7B8vGv5li5hfYqfc87vGhaOKk7JWbn43qyCtq+j2sGUhIgsbJi1rYD41zKFHICy4uXQJcSf/mntOeUgnQ2xM7c/gOKbHhl5yR2c4/ptJrCC/4UYIFq2TdB5PE+1EPgl8DrO3A4WxUvPbWKuJquiH/7pSnMAhFgSp6zLyAufBL+ge3nVLk2L/JKitSUqVbXNsuO35sFfLOQGyxb9QK0qt26xuIWvU7fXweyhX9ggliQ774xFt7irFdMuQUXosAACAAQAAgAAAAIAAAAAAAAAAACEWLLi5dAlxJ/+ae055SCdDbEztz+A4pseGXnJHZzj+m0mZBGbOvOqdvQk+zITw2Zt+dcBM0lpP7B8vGv5li5hfYqfcePtoO/ffYsjnYrDjMcQZbtEx6MxSFa51oTW/Qv1XKTKWadyv98csVuzlGs/lGjlwgs/DGRMlO64netzfIoYyT7LFv1ArSq3brG4ha9Tt9fB7KFf2CCWJDvvjEW3uKsV0DwVpQ1YAAIABAACAAAAAgAAAAAAAAAAAIRZQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAUAfEYeXSEWlyb/JOqIc67BOOKB7cYIu/2r9/Au2lf4u6HwZodqdCs5AZZp3K/3xyxW7OUaz+UaOXCCz8MZEyU7rid63N8ihjJPp12pLCwAAIABAACAAAAAgAAAAAAAAAAAIRayGtoHmhJ0BIgU+flUsVGCrqHFCX6cgkIKZ8bvf0TdRDkBZs686p29CT7MhPDZm351wEzSWk/sHy8a/mWLmF9ip9x/L/9hLAAAgAEAAIAAAACAAAAAAAAAAAAhFr/hRggWrZN0Hk8T7UQ+CXwOs7cDhbFS89tYq4mq6If/OQF4+2g7999iyOdisOMxxBlu0THozFIVrnWhNb9C/VcpMjv58AQsAACAAQAAgAAAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARggCM5vOK0IT/iF72pMSbuiKIM0yLTefvcJE7T8MOJ0A6IAAAEFIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAAQb9JAECwEYgFxUG0nCGUhS67eywTHhElEYIVWDkaciTsRctm8dFTxSsIPrTeFGLn0GWTJhFiwnUoY15eJ0OEFSLbNtKtcQSFCZnulKcAsBGIBcVBtJwhlIUuu3ssEx4RJRGCFVg5GnIk7EXLZvHRU8UrCDMkuf8aTFXRP5eBumofc9DobHwllG0wnx5W86DfGyP0rpSnALARiAF2GXbeCtAQAQQU8GVjBz5Vg2G2G3K0vlQbceK6PTqEawgFxUG0nCGUhS67eywTHhElEYIVWDkaciTsRctm8dFTxS6UpwCwEYgA5NpVpnP8kJDKUCKJZG0VzBzqLiJmzN9PFBOoIQjfHKsIBcVBtJwhlIUuu3ssEx4RJRGCFVg5GnIk7EXLZvHRU8UulKcIQcDk2lWmc/yQkMpQIolkbRXMHOouImbM308UE6ghCN8cjkB4PUSMmqD+cGiyHWuaVEgjPkTunviWef38tBTjgfHJ1enXaksLAAAgAEAAIAAAACAAQAAAAAAAAAhBwXYZdt4K0BABBBTwZWMHPlWDYbYbcrS+VBtx4ro9OoROQGzJA5PrOQf1jlEUC+0BGR/v73TTif3gCTZWeTuTxUXGMuQUXosAACAAQAAgAAAAIABAAAAAAAAACEHFxUG0nCGUhS67eywTHhElEYIVWDkaciTsRctm8dFTxSZBJkZz6pEhD479e05HmGKOd5enlqcUoUVDTbFUc2KFGvqrDAoH8uwhb77zJow71ably5mZ4QFVFdugIUV2FrewCGzJA5PrOQf1jlEUC+0BGR/v73TTif3gCTZWeTuTxUXGOD1EjJqg/nBosh1rmlRIIz5E7p74lnn9/LQU44HxydXDwVpQ1YAAIABAACAAAAAgAEAAAAAAAAAIQdQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAUAfEYeXSEHzJLn/GkxV0T+XgbpqH3PQ6Gx8JZRtMJ8eVvOg3xsj9I5AawwKB/LsIW++8yaMO9Wm5cuZmeEBVRXboCFFdha3sAhO/nwBCwAAIABAACAAAAAgAEAAAAAAAAAIQf603hRi59BlkyYRYsJ1KGNeXidDhBUi2zbSrXEEhQmZzkBmRnPqkSEPjv17TkeYYo53l6eWpxShRUNNsVRzYoUa+p/L/9hLAAAgAEAAIAAAACAAQAAAAAAAAAA \ No newline at end of file diff --git a/testing/data/taproot/taptree.psbt b/testing/data/taproot/taptree.psbt deleted file mode 100644 index 84a7888c..00000000 --- a/testing/data/taproot/taptree.psbt +++ /dev/null @@ -1 +0,0 @@ -cHNidP8BAH0CAAAAATRSx8l5jEdlK7PDrv9FKkd3yX8OyL+3q7i46OF1I+SYAAAAAAD9////AuigQgYBAAAAIlEgzgvB8TOIbKhPtjItPbWr4ZRI0EFhDjUvJYeiBDq1KAIAZc0dAAAAABYAFIHEZO+fINs8l5eZbJKvKhg4R3XgAAAAAAABASsAERAkAQAAACJRIDZNe42ZSwNdpIVOvfe9stnfyhDcw/OAqVdlYA5hm4ejYhXAUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsCmJBBSwiQV/cNnJSFK6zag6Bw7QAdrp4RlCtIcv2S8815E4dUY7uHdtamvShLCdni8DHJnJ0IBIsjr+sCkD4WrRyAsuLl0CXEn/5p7TnlIJ0NsTO3P4Dimx4ZeckdnOP6bSawgO8Cugk5RPvgCM8vTUjl5XDTC3rbMNcjv+LOa23r1Kw66UpzAYhXAUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsC/2Hp41WqnghHHyTpG+qbcxyQj9+qg3qukrUWZ2l7XZQi04+oJTfvwbYAbAIgmzidzm/6hSNK4wleM3s0c+h4fRyAsuLl0CXEn/5p7TnlIJ0NsTO3P4Dimx4ZeckdnOP6bSawgXUYVV5m7Pqe/Hnq06DvxOxR+ez7AW/Xv0pQTNICW7Lu6UpzAYhXAUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAGzJkBXyhtaVshUIQYNPNLNlw4U8Uf6UPDumEOCoavGV5E4dUY7uHdtamvShLCdni8DHJnJ0IBIsjr+sCkD4WrRyAsuLl0CXEn/5p7TnlIJ0NsTO3P4Dimx4ZeckdnOP6bSawgoiAtZY2fRrVuFoerZoio3m+idIxm62wPkqgJPfTbPPK6UpzAYhXAUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAhrlgwKbL5ilGsyoFKK0/KuteX0vG1Qfz2wIFfYY79tAi04+oJTfvwbYAbAIgmzidzm/6hSNK4wleM3s0c+h4fRyAsuLl0CXEn/5p7TnlIJ0NsTO3P4Dimx4ZeckdnOP6bSawgy8DAppzRAw04sm7mfWpoaCP51NHKJiV55gvaE3Dk/Pi6UpzAIRYsuLl0CXEn/5p7TnlIJ0NsTO3P4Dimx4ZeckdnOP6bSZkEBsyZAV8obWlbIVCEGDTzSzZcOFPFH+lDw7phDgqGrxkhrlgwKbL5ilGsyoFKK0/KuteX0vG1Qfz2wIFfYY79tKYkEFLCJBX9w2clIUrrNqDoHDtAB2unhGUK0hy/ZLzzv9h6eNVqp4IRx8k6Rvqm3MckI/fqoN6rpK1Fmdpe12UPBWlDVgAAgAEAAIAAAACAAAAAAAAAAAAhFjvAroJOUT74AjPL01I5eVw0wt62zDXI7/izmtt69SsOOQEGzJkBXyhtaVshUIQYNPNLNlw4U8Uf6UPDumEOCoavGdwkg0IsAACAAQAAgAAAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFl1GFVeZuz6nvx56tOg78TsUfns+wFv179KUEzSAluy7OQEhrlgwKbL5ilGsyoFKK0/KuteX0vG1Qfz2wIFfYY79tOQEoQcsAACAAQAAgAAAAIAAAAAAAAAAACEWoiAtZY2fRrVuFoerZoio3m+idIxm62wPkqgJPfTbPPI5AaYkEFLCJBX9w2clIUrrNqDoHDtAB2unhGUK0hy/ZLzzQ3+TnCwAAIABAACAAAAAgAAAAAAAAAAAIRbLwMCmnNEDDTiybuZ9amhoI/nU0comJXnmC9oTcOT8+DkBv9h6eNVqp4IRx8k6Rvqm3MckI/fqoN6rpK1Fmdpe12UiMZ0BLAAAgAEAAIAAAACAAAAAAAAAAAABFyBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAEYIBIxU9smALh9PYQuK8FjFVoa3rTJlqki5X/xVZyuVlHfAAEFIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAAQb9JAECwEYgFxUG0nCGUhS67eywTHhElEYIVWDkaciTsRctm8dFTxSsIGWwFK4a2mVpWaiSEbKGa+qgc621cUDAyq8z7jl1/Rr/ulKcAsBGIBcVBtJwhlIUuu3ssEx4RJRGCFVg5GnIk7EXLZvHRU8UrCCkGUSfsG3Bhm8qqMczJGhskbmir5YVgOvUcKo2GZUgILpSnALARiAXFQbScIZSFLrt7LBMeESURghVYORpyJOxFy2bx0VPFKwgOHR3a1LdfnJl719Xjhk8hz44Q6PArFv6Raiv+0K/dfe6UpwCwEYgFxUG0nCGUhS67eywTHhElEYIVWDkaciTsRctm8dFTxSsIDpJU9/zQxMXCqM/Xa2SBHFZzdCBURwhD41iRxNa5CY6ulKcIQcXFQbScIZSFLrt7LBMeESURghVYORpyJOxFy2bx0VPFJkEAhPs9ZAPrB4fAO6JSLbqwi8N2wpPYwPq/5228azCC75KHaSaH/hCp85ENV4mt9MO2shLQEiLWGYeV7CDbIh9/GlcXrqp0zIkkVaRMYjumAlyBcturyHfUEIStFWzXQ1P9CkNihRwZOA096mTgVjsczJ05nomore9YJ4wIkNgsmAPBWlDVgAAgAEAAIAAAACAAQAAAAAAAAAhBzh0d2tS3X5yZe9fV44ZPIc+OEOjwKxb+kWor/tCv3X3OQFKHaSaH/hCp85ENV4mt9MO2shLQEiLWGYeV7CDbIh9/Nwkg0IsAACAAQAAgAAAAIABAAAAAAAAACEHOklT3/NDExcKoz9drZIEcVnN0IFRHCEPjWJHE1rkJjo5AQIT7PWQD6weHwDuiUi26sIvDdsKT2MD6v+dtvGswgu+Q3+TnCwAAIABAACAAAAAgAEAAAAAAAAAIQdQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAUAfEYeXSEHZbAUrhraZWlZqJIRsoZr6qBzrbVxQMDKrzPuOXX9Gv85AWlcXrqp0zIkkVaRMYjumAlyBcturyHfUEIStFWzXQ1PIjGdASwAAIABAACAAAAAgAEAAAAAAAAAIQekGUSfsG3Bhm8qqMczJGhskbmir5YVgOvUcKo2GZUgIDkB9CkNihRwZOA096mTgVjsczJ05nomore9YJ4wIkNgsmDkBKEHLAAAgAEAAIAAAACAAQAAAAAAAAAAAA== \ No newline at end of file diff --git a/testing/descriptor.py b/testing/descriptor.py new file mode 100644 index 00000000..ca9bb791 --- /dev/null +++ b/testing/descriptor.py @@ -0,0 +1,468 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# descriptor.py - Bitcoin Core's descriptors and their specialized checksums. +# +import struct +from binascii import unhexlify as a2b_hex +from binascii import hexlify as b2a_hex +from constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2TR + +MULTI_FMT_TO_SCRIPT = { + AF_P2SH: "sh(%s)", + AF_P2WSH_P2SH: "sh(wsh(%s))", + AF_P2WSH: "wsh(%s)", + AF_P2TR: "tr(%s)", + None: "wsh(%s)", + # hack for tests + "p2sh": "sh(%s)", + "p2sh-p2wsh": "sh(wsh(%s))", + "p2wsh-p2sh": "sh(wsh(%s))", + "p2wsh": "wsh(%s)", + "p2tr": "tr(%s)" +} + +SINGLE_FMT_TO_SCRIPT = { + AF_P2WPKH: "wpkh(%s)", + AF_CLASSIC: "pkh(%s)", + AF_P2WPKH_P2SH: "sh(wpkh(%s))", + AF_P2TR: "tr(%s)", + None: "wpkh(%s)", + "p2pkh": "pkh(%s)", + "p2wpkh": "wpkh(%s)", + "p2sh-p2wpkh": "sh(wpkh(%s))", + "p2wpkh-p2sh": "sh(wpkh(%s))", + "p2tr": "tr(%s)", +} + +PROVABLY_UNSPENDABLE = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" +INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def xfp2str(xfp): + # Standardized way to show an xpub's fingerprint... it's a 4-byte string + # and not really an integer. Used to show as '0x%08x' but that's wrong endian. + return b2a_hex(struct.pack('> 35 + c = ((c & 0x7ffffffff) << 5) ^ val + if (c0 & 1): + c ^= 0xf5dee51989 + if (c0 & 2): + c ^= 0xa9fdca3312 + if (c0 & 4): + c ^= 0x1bab10e32d + if (c0 & 8): + c ^= 0x3706b1677a + if (c0 & 16): + c ^= 0x644d626ffd + + return c + +def descriptor_checksum(desc): + c = 1 + cls = 0 + clscount = 0 + for ch in desc: + pos = INPUT_CHARSET.find(ch) + if pos == -1: + raise ValueError(ch) + + c = polymod(c, pos & 31) + cls = cls * 3 + (pos >> 5) + clscount += 1 + if clscount == 3: + c = polymod(c, cls) + cls = 0 + clscount = 0 + + if clscount > 0: + c = polymod(c, cls) + for j in range(0, 8): + c = polymod(c, 0) + c ^= 1 + + rv = '' + for j in range(0, 8): + rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] + + return rv + +def append_checksum(desc): + return desc + "#" + descriptor_checksum(desc) + + +def parse_desc_str(string): + """Remove comments, empty lines and strip line. Produce single line string""" + res = "" + for l in string.split("\n"): + strip_l = l.strip() + if not strip_l: + continue + if strip_l.startswith("#"): + continue + res += strip_l + 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(" + PROVABLY_UNSPENDABLE + ",sortedmulti_a(M,%s,...))" + else: + return None + descriptor_template = descriptor_template % key_exp + return descriptor_template + + +class Descriptor: + __slots__ = ( + "keys", + "addr_fmt", + ) + + def __init__(self, keys, addr_fmt): + self.keys = keys + self.addr_fmt = addr_fmt + + @staticmethod + def checksum_check(desc_w_checksum: str, csum_required=False): + try: + desc, checksum = desc_w_checksum.split("#") + except ValueError: + if csum_required: + raise ValueError("Missing descriptor checksum") + return desc_w_checksum, None + + calc_checksum = descriptor_checksum(desc) + if calc_checksum != checksum: + raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum)) + return desc, checksum + + @staticmethod + def parse_key_orig_info(key: str): + # key origin info is required for our MultisigWallet + close_index = key.find("]") + if key[0] != "[" or close_index == -1: + raise ValueError("Key origin info is required for %s" % (key)) + key_orig_info = key[1:close_index] # remove brackets + key = key[close_index + 1:] + assert "/" in key_orig_info, "Malformed key derivation info" + return key_orig_info, key + + @staticmethod + def parse_key_derivation_info(key: str): + invalid_subderiv_msg = "Invalid subderivation path - only 0/* or <0;1>/* allowed" + slash_split = key.split("/") + assert len(slash_split) > 1, invalid_subderiv_msg + if all(["h" not in elem and "'" not in elem for elem in slash_split[1:]]): + assert slash_split[-1] == "*", invalid_subderiv_msg + assert slash_split[-2] in ["0", "<0;1>", "<1;0>"], invalid_subderiv_msg + assert len(slash_split[1:]) == 2, invalid_subderiv_msg + return slash_split[0] + else: + raise ValueError("Cannot use hardened sub derivation path") + + def checksum(self): + return descriptor_checksum(self._serialize()) + + def serialize_keys(self, internal=False, int_ext=False, keys=None): + to_do = keys if keys is not None else self.keys + result = [] + for xfp, deriv, xpub in to_do: + if deriv[0] == "m": + # get rid of 'm' + deriv = deriv[1:] + elif deriv[0] != "/": + # input "84'/0'/0'" would lack slash separtor with xfp + deriv = "/" + deriv + if not isinstance(xfp, str): + xfp = xfp2str(xfp) + koi = xfp + deriv + # normalize xpub to use h for hardened instead of ' + key_str = "[%s]%s" % (koi.lower(), xpub) + if int_ext: + key_str = key_str + "/" + "<0;1>" + "/" + "*" + else: + key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"]) + result.append(key_str.replace("'", "h")) + return result + + def _serialize(self, internal=False, int_ext=False) -> str: + """Serialize without checksum""" + assert len(self.keys) == 1, "Multiple keys for single signature script" + desc_base = SINGLE_FMT_TO_SCRIPT[self.addr_fmt] + inner = self.serialize_keys(internal=internal, int_ext=int_ext)[0] + return desc_base % (inner) + + def serialize(self, internal=False, int_ext=False) -> str: + """Serialize with checksum""" + return append_checksum(self._serialize(internal=internal, int_ext=int_ext)) + + @classmethod + def parse(cls, desc_w_checksum: str) -> "Descriptor": + # remove garbage + desc_w_checksum = parse_desc_str(desc_w_checksum) + # check correct checksum + desc, checksum = cls.checksum_check(desc_w_checksum) + # legacy + if desc.startswith("pkh("): + addr_fmt = AF_CLASSIC + tmp_desc = desc.replace("pkh(", "") + tmp_desc = tmp_desc.rstrip(")") + + # native segwit + elif desc.startswith("wpkh("): + addr_fmt = AF_P2WPKH + tmp_desc = desc.replace("wpkh(", "") + tmp_desc = tmp_desc.rstrip(")") + + # wrapped segwit + elif desc.startswith("sh(wpkh("): + addr_fmt = AF_P2WPKH_P2SH + tmp_desc = desc.replace("sh(wpkh(", "") + tmp_desc = tmp_desc.rstrip("))") + + # wrapped segwit + elif desc.startswith("tr("): + addr_fmt = AF_P2TR + tmp_desc = desc.replace("tr(", "") + tmp_desc = tmp_desc.rstrip(")") + + else: + raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh(.") + + koi, key = cls.parse_key_orig_info(tmp_desc) + if key[0:4] not in ["tpub", "xpub"]: + raise ValueError("Only extended public keys are supported") + + xpub = cls.parse_key_derivation_info(key) + xfp = str2xfp(koi[:8]) + origin_deriv = "m" + koi[8:] + + return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt) + + @classmethod + def is_descriptor(cls, desc_str): + """Quick method to guess whether this is a descriptor""" + try: + temp = parse_desc_str(desc_str) + except: + return False + + for prefix in ("pk(", "pkh(", "wpkh(", "tr(", "addr(", "raw(", "rawtr(", "combo(", + "sh(", "wsh(", "multi(", "sortedmulti(", "multi_a(", "sortedmulti_a("): + if temp.startswith(prefix): + return True + return False + + def bitcoin_core_serialize(self, external_label=None): + # this will become legacy one day + # instead use <0;1> descriptor format + res = [] + for internal in [False, True]: + desc_obj = { + "desc": self.serialize(internal=internal), + "active": True, + "timestamp": "now", + "internal": internal, + "range": [0, 100], + } + if internal is False and external_label: + desc_obj["label"] = external_label + res.append(desc_obj) + + return res + + +class MultisigDescriptor(Descriptor): + # only supprt with key derivation info + # only xpubs + # can be extended when needed + __slots__ = ( + "M", + "N", + "internal_key", + "keys", + "addr_fmt", + ) + + def __init__(self, M, N, keys, addr_fmt, internal_key=None): + self.M = M + self.N = N + self.internal_key = internal_key + super().__init__(keys, addr_fmt) + + @classmethod + def parse(cls, desc_w_checksum: str) -> "MultisigDescriptor": + internal_key = None # taproot + # remove garbage + desc_w_checksum = parse_desc_str(desc_w_checksum) + # check correct checksum + desc, checksum = cls.checksum_check(desc_w_checksum) + # legacy + if desc.startswith("sh(sortedmulti("): + addr_fmt = AF_P2SH + tmp_desc = desc.replace("sh(sortedmulti(", "") + tmp_desc = tmp_desc.rstrip("))") + + # native segwit + elif desc.startswith("wsh(sortedmulti("): + addr_fmt = AF_P2WSH + tmp_desc = desc.replace("wsh(sortedmulti(", "") + tmp_desc = tmp_desc.rstrip("))") + + # wrapped segwit + elif desc.startswith("sh(wsh(sortedmulti("): + addr_fmt = AF_P2WSH_P2SH + tmp_desc = desc.replace("sh(wsh(sortedmulti(", "") + tmp_desc = tmp_desc.rstrip(")))") + + elif desc.startswith("tr("): + addr_fmt = AF_P2TR + tmp_desc = desc.replace("tr(", "") + tmp_desc = tmp_desc.rstrip(")") + internal_key, tmp_desc = tmp_desc.split(",", 1) + assert tmp_desc.startswith("sortedmulti_a("), "Only one sortedmulti_a allowed" + tmp_desc = tmp_desc.replace("sortedmulti_a(", "") + tmp_desc = tmp_desc.rstrip(")") + + try: + koi, key = cls.parse_key_orig_info(internal_key) + if key[0:4] not in ["tpub", "xpub"]: + raise ValueError("Only extended public keys are supported") + xpub = cls.parse_key_derivation_info(key) + xfp = str2xfp(koi[:8]) + origin_deriv = "m" + koi[8:] + internal_key = (xfp, origin_deriv, xpub) + except ValueError: + # https://github.com/BlockstreamResearch/secp256k1-zkp/blob/11af7015de624b010424273be3d91f117f172c82/src/modules/rangeproof/main_impl.h#L16 + # H = lift_x(0x0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) + # if internal_key == PROVABLY_UNSPENDABLE: + # # unspendable H as defined in BIP-0341 + # pass + # else: + # assert "r=" in internal_key + # _, r = internal_key.split("=") + # if r == "@": + # # pick a fresh integer r in the range 0...n-1 uniformly at random and use H + rG + # kp = ngu.secp256k1.keypair() + # else: + # # H + rG where r is provided from user + # r = a2b_hex(r) + # assert len(r) == 32, "r != 32" + # kp = ngu.secp256k1.keypair(r) + # + # H = a2b_hex(PROVABLY_UNSPENDABLE) + # H_xo = ngu.secp256k1.xonly_pubkey(H) + # internal_key = H_xo.tweak_add(kp.xonly_pubkey().to_bytes()) + # internal_key = b2a_hex(internal_key.to_bytes()).decode() + pass + + else: + raise ValueError("Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. All have to be sortedmulti.") + + splitted = tmp_desc.split(",") + M, keys = int(splitted[0]), splitted[1:] + N = int(len(keys)) + if M > N: + raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N)) + + res_keys = [] + for key in keys: + koi, key = cls.parse_key_orig_info(key) + if key[0:4] not in ["tpub", "xpub"]: + raise ValueError("Only extended public keys are supported") + + xpub = cls.parse_key_derivation_info(key) + xfp = str2xfp(koi[:8]) + origin_deriv = "m" + koi[8:] + res_keys.append((xfp, origin_deriv, xpub)) + + return cls(M=M, N=N, keys=res_keys, addr_fmt=addr_fmt, internal_key=internal_key) + + def _serialize(self, internal=False, int_ext=False) -> str: + """Serialize without checksum""" + desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt] + if self.addr_fmt == AF_P2TR: + if isinstance(self.internal_key, str): + desc_base = desc_base % (self.internal_key + ",sortedmulti_a(%s)") + else: + ik_ser = self.serialize_keys(keys=[self.internal_key])[0] + desc_base = desc_base % (ik_ser + ",sortedmulti_a(%s)") + else: + desc_base = desc_base % "sortedmulti(%s)" + assert len(self.keys) == self.N + inner = str(self.M) + "," + ",".join( + self.serialize_keys(internal=internal, int_ext=int_ext)) + + return desc_base % inner + + def pretty_serialize(self): + """Serialize in pretty and human-readable format""" + inner_ident = 1 + res = "# Coldcard descriptor export\n" + res += "# order of keys in the descriptor does not matter, will be sorted before creating script (BIP-67)\n" + if self.addr_fmt == AF_P2SH: + res += "# bare multisig - p2sh\n" + res += "sh(sortedmulti(\n%s\n))" + # native segwit + elif self.addr_fmt == AF_P2WSH: + res += "# native segwit - p2wsh\n" + res += "wsh(sortedmulti(\n%s\n))" + + # wrapped segwit + elif self.addr_fmt == AF_P2WSH_P2SH: + res += "# wrapped segwit - p2sh-p2wsh\n" + res += "sh(wsh(sortedmulti(\n%s\n)))" + + elif self.addr_fmt == AF_P2TR: + inner_ident = 2 + res += "# taproot multisig - p2tr\n" + res += "tr(\n" + if isinstance(self.internal_key, str): + res += "\t" + "# internal key (provably unspendable)\n" + res += "\t" + self.internal_key + ",\n" + res += "\t" + "sortedmulti_a(\n%s\n))" + else: + ik_ser = self.serialize_keys(keys=[self.internal_key])[0] + res += "\t" + "# internal key\n" + res += "\t" + ik_ser + ",\n" + res += "\t" + "sortedmulti_a(\n%s\n))" + else: + raise ValueError("Malformed descriptor") + + assert len(self.keys) == self.N + inner = ("\t" * inner_ident) + "# %d of %d (%s)\n" % ( + self.M, self.N, + "requires all participants to sign" if self.M == self.N else "threshold") + inner += ("\t" * inner_ident) + str(self.M) + ",\n" + ser_keys = self.serialize_keys() + for i, key_str in enumerate(ser_keys, start=1): + if i == self.N: + inner += ("\t" * inner_ident) + key_str + else: + inner += ("\t" * inner_ident) + key_str + ",\n" + + checksum = self.serialize().split("#")[1] + + return (res % inner) + "#" + checksum + +# EOF \ No newline at end of file diff --git a/testing/devtest/wipe_miniscript.py b/testing/devtest/wipe_miniscript.py new file mode 100644 index 00000000..4fa8e646 --- /dev/null +++ b/testing/devtest/wipe_miniscript.py @@ -0,0 +1,13 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# quickly clear all miniscript wallets installed +from glob import settings +from ux import restore_menu + +if settings.get('miniscript'): + del settings.current['miniscript'] + settings.save() + + print("cleared miniscript") + +restore_menu() \ No newline at end of file diff --git a/testing/test_address_explorer.py b/testing/test_address_explorer.py index f4d84a78..358805e6 100644 --- a/testing/test_address_explorer.py +++ b/testing/test_address_explorer.py @@ -175,7 +175,7 @@ def test_stub_menu(sim_execfile, goto_address_explorer, need_keypress, cap_menu, validate_address(expected_addr, sk) # validate that stub is correct - [start, end] = m[_id].split('-') + [start, end] = m[_id][1:6], m[_id][7:] assert expected_addr.startswith(start) assert expected_addr.endswith(end) @@ -317,7 +317,7 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, goto_addre validate_address(expected_addr, sk) # validate that stub is correct - [start, end] = m[_id].split('-') + [start, end] = m[_id][1:6], m[_id][7:] assert expected_addr.startswith(start) assert expected_addr.endswith(end) diff --git a/testing/test_bsms.py b/testing/test_bsms.py index a06ff73b..466884d4 100644 --- a/testing/test_bsms.py +++ b/testing/test_bsms.py @@ -683,7 +683,8 @@ def get_token(index): assert expect in story nfc_write_text(data.hex() if isinstance(data, bytes) else data) time.sleep(0.1) - # TODO pytest.skip here as length of data is more than 250 + need_keypress("y") # exit animation + time.sleep(0.2) else: suffix = ".txt" if encryption_type == "3" else ".dat" time.sleep(0.1) @@ -860,7 +861,8 @@ def test_signer_round2(refuse, way, encryption_type, M_N, addr_fmt, clear_ms, go time.sleep(0.1) nfc_write_text(desc_template.hex() if isinstance(desc_template, bytes) else desc_template) time.sleep(0.1) - # TODO pytest.skip here as length of data is more than 250 + need_keypress("y") # exit animation + time.sleep(0.2) else: suffix = ".txt" if encryption_type == "3" else ".dat" time.sleep(0.1) @@ -1033,7 +1035,7 @@ def get_token(index): assert title == "FAILURE" assert "BSMS coordinator round2 failed" in story if failure == "slip": - failure_msg = "Expected tpub" + failure_msg = "tpub required" elif failure == "wrong_sig": failure_msg = "Recovered key from signature does not equal key provided. Wrong signature?" else: @@ -1135,7 +1137,7 @@ def test_failure_signer_round2(encryption_type, goto_home, need_keypress, pick_m failure_msg = "Incompatible BSMS version. Need BSMS 1.0 got BSMS 2.0" elif failure == "sortedmulti": kws = {failure: False} - failure_msg = "Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. All have to be sortedmulti." + failure_msg = "Unsupported descriptor. Supported: sh(, sh(wsh(, wsh(. MUST be sortedmulti." elif failure == "has_ours": kws = {failure: False} failure_msg = "My key 0F056943 missing in descriptor." @@ -1144,7 +1146,7 @@ def test_failure_signer_round2(encryption_type, goto_home, need_keypress, pick_m failure_msg = "Multiple 0F056943 keys in descriptor (2)" elif failure == "wrong_chain": kws = {failure: True} - failure_msg = "Expected tpub" + failure_msg = "tpub required" elif failure == "wrong_checksum": kws = {failure: True} failure_msg = "Wrong checksum" diff --git a/testing/test_miniscript.py b/testing/test_miniscript.py new file mode 100644 index 00000000..dbacf1fa --- /dev/null +++ b/testing/test_miniscript.py @@ -0,0 +1,1685 @@ +# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# Miniscript-related tests. +# +import pytest, json, time, itertools, struct, random +from ckcc.protocol import CCProtocolPacker +from constants import AF_P2TR +from psbt import BasicPSBT + + +H = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" # BIP-0341 +TREE = { + 1: '%s', + 2: '{%s,%s}', + 3: random.choice(['{{%s,%s},%s}','{%s,{%s,%s}}']), + 4: '{{%s,%s},{%s,%s}}', + 5: random.choice(['{{%s,%s},{%s,{%s,%s}}}', '{{{%s,%s},%s},{%s,%s}}']), + 6: '{{%s,{%s,%s}},{{%s,%s},%s}}', + 7: '{{%s,{%s,%s}},{%s,{%s,{%s,%s}}}}', + 8: '{{{%s,%s},{%s,%s}},{{%s,%s},{%s,%s}}}', + # more than MAX (4) for test purposes + 9: '{{{%s{%s,%s}},{%s,%s}},{{%s,%s},{%s,%s}}}' +} + +@pytest.fixture +def miniscript_descriptors(goto_home, pick_menu_item, need_keypress, cap_story, + microsd_path): + def doit(minsc_name): + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item(minsc_name) + pick_menu_item("Descriptors") + pick_menu_item("Export") + need_keypress("1") # internal and external separately + time.sleep(.2) + title, story = cap_story() + if "Press (1)" in story: + need_keypress("1") + time.sleep(.2) + title, story = cap_story() + + assert "Miniscript file written" in story + fname = story.split("\n\n")[-1] + with open(microsd_path(fname), "r") as f: + cont = f.read() + external, internal = cont.split("\n") + return external, internal + return doit + + +@pytest.fixture +def get_cc_key(dev): + def doit(path, int_ext=False): + # cc device key + master_xfp_str = struct.pack('/*' if int_ext else '/0/*'}" + return doit + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("addr_fmt", ["bech32", "p2sh-segwit"]) +@pytest.mark.parametrize("lt_type", ["older", "after"]) # this is actually not generated by liana (liana is relative only) +@pytest.mark.parametrize("recovery", [True, False]) +# @pytest.mark.parametrize("lt_val", ["time", "block"]) TODO hard to test timebased +@pytest.mark.parametrize("minisc", [ + "or_d(pk(@A),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:pk(@B),locktime(N)))", # this is actually not generated by liana + + "or_d(multi(2,@A,@C),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:multi(2,@B,@C),locktime(N)))", +]) +def test_liana_miniscripts_simple(addr_fmt, recovery, lt_type, minisc, clear_miniscript, goto_home, + need_keypress, pick_menu_item, cap_menu, cap_story, microsd_path, + use_regtest, bitcoind, microsd_wipe, load_export, dev, + address_explorer_check, get_cc_key): + normal_cosign_core = False + recovery_cosign_core = False + if "multi(" in minisc.split("),", 1)[0]: + normal_cosign_core = True + if "multi(" in minisc.split("),", 1)[-1]: + recovery_cosign_core = True + + if lt_type == "older": + sequence = 5 + locktime = 0 + # 101 blocks are mined by default + to_replace = "older(5)" + else: + sequence = None + locktime = 105 + to_replace = "after(105)" + + minisc = minisc.replace("locktime(N)", to_replace) + + if addr_fmt == "bech32": + desc = f"wsh({minisc})" + else: + desc = f"sh(wsh({minisc}))" + + # core signer + signer0 = bitcoind.create_wallet(wallet_name="co-signer", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + target_desc = "" + bitcoind_descriptors = signer0.listdescriptors()["descriptors"] + for d in bitcoind_descriptors: + if d["desc"].startswith("pkh(") and d["internal"] is False: + target_desc = d["desc"] + break + core_desc, checksum = target_desc.split("#") + core_key = core_desc[4:-1] + + # cc device key + cc_key = get_cc_key("84h/0h/0h") + + if recovery: + # recevoery path is always B + desc = desc.replace("@B", cc_key) + desc = desc.replace("@A", core_key) + else: + desc = desc.replace("@A", cc_key) + desc = desc.replace("@B", core_key) + + if "@C" in desc: + signer1 = bitcoind.create_wallet(wallet_name="co-signer1", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + target_desc = "" + bitcoind_descriptors = signer1.listdescriptors()["descriptors"] + for d in bitcoind_descriptors: + if d["desc"].startswith("pkh(") and d["internal"] is False: + target_desc = d["desc"] + break + core_desc, checksum = target_desc.split("#") + core_key1 = core_desc[4:-1] + desc = desc.replace("@C", core_key1) + + use_regtest() + clear_miniscript() + name = "core-miniscript" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.3) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + need_keypress("y") + menu = cap_menu() + assert menu[0] == name + 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 miniscript", is_json=False, sig_check=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) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + addr = wo.getnewaddress("", addr_fmt) + addr_dest = wo.getnewaddress("", addr_fmt) # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + all_of_it = wo.getbalance() + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + if recovery and sequence: + inp["sequence"] = sequence + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: all_of_it - 1}], + locktime if recovery else 0, + {"fee_rate": 20, "change_type": addr_fmt, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + if normal_cosign_core or recovery_cosign_core: + psbt = signer1.walletprocesspsbt(psbt, True, "ALL")["psbt"] + + name = f"{name}.psbt" + with open(microsd_path(name), "w") as f: + f.write(psbt) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.5) + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert "OK TO SEND?" in title + assert "Consolidating" in story + need_keypress("y") # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + need_keypress("y") + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + if recovery: + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' if sequence else "non-final" + bitcoind.supply_wallet.generatetoaddress(6, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + else: + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check("sd", addr_fmt, wo, "core-miniscript") + + +@pytest.mark.parametrize("addr_fmt", ["bech32", "p2sh-segwit"]) +@pytest.mark.parametrize("minsc", [ + ("or_i(and_v(v:pkh($0),older(10)),or_d(multi(3,@A,@B,@C),and_v(v:thresh(2,pkh($1),a:pkh($2),a:pkh($3)),older(5))))", 0), + ("or_i(and_v(v:pkh(@A),older(10)),or_d(multi(3,$0,$1,$2),and_v(v:thresh(2,pkh($3),a:pkh($4),a:pkh($5)),older(5))))", 10), + ("or_i(and_v(v:pkh($0),older(10)),or_d(multi(3,$1,$2,$3),and_v(v:thresh(2,pkh(@A),a:pkh(@B),a:pkh($4)),older(5))))", 5), +]) +def test_liana_miniscripts_complex(addr_fmt, minsc, bitcoind, use_regtest, clear_miniscript, + microsd_path, pick_menu_item, need_keypress, cap_story, + load_export, goto_home, address_explorer_check, cap_menu, + get_cc_key): + use_regtest() + clear_miniscript() + + minsc, to_gen = minsc + signer_keys = minsc.count("@") + bsigners = signer_keys - 1 + random_keys = minsc.count("$") + bitcoind_signers = [] + for i in range(random_keys + bsigners): + s = bitcoind.create_wallet(wallet_name=f"co-signer-{i}", disable_private_keys=False, + blank=False, passphrase=None, avoid_reuse=False, descriptors=True) + target_desc = "" + bitcoind_descriptors = s.listdescriptors()["descriptors"] + for d in bitcoind_descriptors: + if d["desc"].startswith("pkh(") and d["internal"] is False: + target_desc = d["desc"] + break + core_desc, checksum = target_desc.split("#") + core_key = core_desc[4:-1] + bitcoind_signers.append((s, core_key)) + + cc_key = get_cc_key("m/84h/1h/0h") + minsc = minsc.replace("@A", cc_key) + + use_signers = [] + if bsigners == 2: + for ph, (s, key) in zip(["@B", "@C"], bitcoind_signers[:2]): + use_signers.append(s) + minsc = minsc.replace(ph, key) + for i, (s, key) in enumerate(bitcoind_signers[2:]): + ph = f"${i}" + minsc = minsc.replace(ph, key) + elif bsigners == 1: + use_signers.append(bitcoind_signers[0][0]) + minsc = minsc.replace("@B", bitcoind_signers[0][1]) + for i, (s, key) in enumerate(bitcoind_signers[1:]): + ph = f"${i}" + minsc = minsc.replace(ph, key) + elif bsigners == 0: + for i, (s, key) in enumerate(bitcoind_signers): + ph = f"${i}" + minsc = minsc.replace(ph, key) + else: + assert False + + if addr_fmt == "bech32": + desc = f"wsh({minsc})" + else: + desc = f"sh(wsh({minsc}))" + + name = "cmplx-miniscript" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.3) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + need_keypress("y") + menu = cap_menu() + assert menu[0] == name + 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 miniscript", is_json=False, sig_check=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) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + addr = wo.getnewaddress("", addr_fmt) + addr_dest = wo.getnewaddress("", addr_fmt) # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + if to_gen: + inp["sequence"] = to_gen + + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: 1}], + 0, + {"fee_rate": 20, "change_type": addr_fmt, "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + # cosingers signing first + for s in use_signers: + psbt = s.walletprocesspsbt(psbt, True, "ALL")["psbt"] + + pname = f"{name}.psbt" + with open(microsd_path(pname), "w") as f: + f.write(psbt) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.5) + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + time.sleep(0.1) + pick_menu_item(pname) + time.sleep(0.1) + title, story = cap_story() + assert "OK TO SEND?" in title + assert "Consolidating" in story + need_keypress("y") # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + need_keypress("y") + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + if to_gen: + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + bitcoind.supply_wallet.generatetoaddress(to_gen, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + else: + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check("sd", addr_fmt, wo, name) + + +@pytest.fixture +def bitcoind_miniscript(bitcoind, bitcoind_d_sim_watch, need_keypress, cap_story, load_export, + pick_menu_item, goto_home, cap_menu, microsd_path, use_regtest, get_cc_key): + def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None, + tapscript_threshold=False, add_own_pk=False): + use_regtest() + bitcoind_signers = [ + bitcoind.create_wallet(wallet_name=f"bitcoind--signer{i}", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + for i in range(N - 1) + ] + for signer in bitcoind_signers: + signer.keypoolrefill(10) + # watch only wallet where multisig descriptor will be imported + ms = bitcoind.create_wallet( + wallet_name=f"watch_only_{script_type}_{M}of{N}", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('Export XPUB') + time.sleep(0.5) + title, story = cap_story() + assert "extended public keys (XPUB) you would need to join a multisig wallet" in story + need_keypress("y") + need_keypress(str(cc_account)) # account + need_keypress("y") + xpub_obj = load_export("sd", label="Multisig XPUB", is_json=True, sig_check=False) + template = xpub_obj[script_type +"_desc"] + # get keys from bitcoind signers + bitcoind_signers_xpubs = [] + for signer in bitcoind_signers: + target_desc = "" + bitcoind_descriptors = signer.listdescriptors()["descriptors"] + for desc in bitcoind_descriptors: + if desc["desc"].startswith("pkh(") and desc["internal"] is False: + target_desc = desc["desc"] + core_desc, checksum = target_desc.split("#") + # remove pkh(....) + core_key = core_desc[4:-1] + bitcoind_signers_xpubs.append(core_key) + + if tapscript_threshold: + me = f"[{xpub_obj['xfp']}/{xpub_obj[script_type + '_deriv'].replace('m/','')}]{xpub_obj[script_type]}/0/*" + signers_xp = [me] + bitcoind_signers_xpubs + assert len(signers_xp) == N + desc = f"tr({H},%s)" + if internal_key: + desc = desc.replace(H, internal_key) + elif r: + desc = desc.replace(H, f"r={r}") + + scripts = [] + for c in itertools.combinations(signers_xp, M): + tmplt = f"sortedmulti_a({M},{','.join(c)})" + scripts.append(tmplt) + + if len(scripts) > 8: + while True: + # just some of them but at least one has to have my key + x = random.sample(scripts, 8) + if any(me in s for s in x): + scripts = x + break + + if add_own_pk: + if len(scripts) < 8: + cc_key = get_cc_key("m/86h/1h/1000h") + cc_pk_leaf = f"pk({cc_key})" + scripts.append(cc_pk_leaf) + else: + pytest.skip("Scripts full") + + temp = TREE[len(scripts)] + temp = temp % tuple(scripts) + + desc = desc % temp + + else: + if add_own_pk: + ss = [get_cc_key("m/86h/1h/0h")] + bitcoind_signers_xpubs + tmplt = f"sortedmulti_a({M},{','.join(ss)})" + cc_key = get_cc_key("m/86h/1h/1000h") + cc_pk_leaf = f"pk({cc_key})" + desc = f"tr({H},{{{tmplt},{cc_pk_leaf}}})" + else: + desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs)) + + if internal_key: + desc = desc.replace(H, internal_key) + elif r: + desc = desc.replace(H, f"r={r}") + + name = "minisc.txt" + with open(microsd_path(name), "w") as f: + f.write(desc + "\n") + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + need_keypress("y") + pick_menu_item(name) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + assert name.split(".")[0] in story + if script_type == "p2tr": + assert "Taproot internal key" in story + assert "Taproot tree keys" in story + assert "Press (1) to see extended public keys" in story + if script_type == "p2wsh": + assert "P2WSH" in story + elif script_type == "p2sh": + assert "P2SH" in story + elif script_type == "p2tr": + assert "P2TR" in story + else: + assert "P2SH-P2WSH" in story + # assert "Derivation:\n Varies (2)" in story + need_keypress("y") # approve multisig import + goto_home() + pick_menu_item('Settings') + 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 miniscript", is_json=False, sig_check=False) + text = text.replace("importdescriptors ", "").strip() + # remove junk + r1 = text.find("[") + r2 = text.find("]", -1, 0) + text = text[r1: r2] + core_desc_object = json.loads(text) + # import descriptors to watch only wallet + res = ms.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + if r and r != "@": + from pysecp256k1.extrakeys import keypair_create, keypair_xonly_pub, xonly_pubkey_parse + from pysecp256k1.extrakeys import xonly_pubkey_tweak_add, xonly_pubkey_serialize, xonly_pubkey_from_pubkey + H_xo = xonly_pubkey_parse(bytes.fromhex(H)) + r_bytes = bytes.fromhex(r) + kp = keypair_create(r_bytes) + kp_xo, kp_parity = keypair_xonly_pub(kp) + pk = xonly_pubkey_tweak_add(H_xo, xonly_pubkey_serialize(kp_xo)) + xo, xo_parity = xonly_pubkey_from_pubkey(pk) + internal_key_bytes = xonly_pubkey_serialize(xo) + internal_key_hex = internal_key_bytes.hex() + assert internal_key_hex in core_desc_object[0]["desc"] + assert internal_key_hex in core_desc_object[1]["desc"] + + if funded: + if script_type == "p2wsh": + addr_type = "bech32" + elif script_type == "p2tr": + addr_type = "bech32m" + elif script_type == "p2sh": + addr_type = "legacy" + else: + addr_type = "p2sh-segwit" + + addr = ms.getnewaddress("", addr_type) + if script_type == "p2wsh": + sw = "bcrt1q" + elif script_type == "p2tr": + sw = "bcrt1p" + else: + sw = "2" + assert addr.startswith(sw) + # get some coins and fund above multisig address + bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + + return ms, bitcoind_signers + + return doit + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("add_pk", [True, False]) +@pytest.mark.parametrize("M_N", [(3,4),(4,5),(5,6)]) +def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, need_keypress, pick_menu_item, + cap_menu, cap_story, microsd_path, use_regtest, bitcoind, microsd_wipe, + load_export, bitcoind_miniscript, add_pk): + M, N = M_N + clear_miniscript() + microsd_wipe() + wo, signers = bitcoind_miniscript(M, N, "p2tr", tapscript_threshold=True, add_own_pk=add_pk) + addr = wo.getnewaddress("", "bech32m") + bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + conso_addr = wo.getnewaddress("conso", "bech32m") + psbt = wo.walletcreatefundedpsbt([], [{conso_addr:25}], 0, {"fee_rate": 2})["psbt"] + if not cc_first: + for s in signers[0:M-1]: + psbt = s.walletprocesspsbt(psbt, True, "DEFAULT")["psbt"] + with open(microsd_path("ts_tree.psbt"), "w") as f: + f.write(psbt) + time.sleep(2) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.2) + title, story = cap_story() + assert title == "OK TO SEND?" + need_keypress("y") + time.sleep(0.1) + title, story = cap_story() + assert title == "PSBT Signed" + fname = story.split("\n\n")[-1] + with open(microsd_path(fname), "r") as f: + psbt = f.read().strip() + if cc_first: + # we MUST be able to finalize this without anyone else if add pk + if not add_pk: + for s in signers[0:M-1]: + psbt = s.walletprocesspsbt(psbt, True, "DEFAULT")["psbt"] + res = wo.finalizepsbt(psbt) + assert res["complete"] is True + accept_res = wo.testmempoolaccept([res["hex"]])[0] + assert accept_res["allowed"] is True + txid = wo.sendrawtransaction(res["hex"]) + assert len(txid) == 64 + + +@pytest.fixture +def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, + cap_story, load_export, miniscript_descriptors): + def doit(way, addr_fmt, wallet, cc_minsc_name, export_check=True): + goto_home() + pick_menu_item("Address Explorer") + need_keypress('4') # warning + m = cap_menu() + wal_name = m[-1] + pick_menu_item(wal_name) + + title, story = cap_story() + if addr_fmt == "bech32m": + assert "Taproot internal key" in story + else: + assert "Taproot internal key" not in story + + contents = load_export(way, label="Address summary", is_json=False, sig_check=False, vdisk_key="4") + addr_cont = contents.strip() + + time.sleep(5) + title, story = cap_story() + assert "Press (6)" in story + assert "change addresses." in story + need_keypress("6") + time.sleep(5) + title, story = cap_story() + assert "Press (6)" not in story + assert "change addresses." not in story + + contents_change = load_export(way, label="Address summary", is_json=False, sig_check=False, vdisk_key="4") + addr_cont_change = contents_change.strip() + + if way == "nfc": + addr_range = [0, 9] + cc_addrs = addr_cont.split("\n") + cc_addrs_change = addr_cont_change.split("\n") + part_addr_index = 0 + else: + addr_range = [0, 249] + cc_addrs_split = addr_cont.split("\n") + cc_addrs_split_change = addr_cont_change.split("\n") + # header is different for taproot + if addr_fmt == "bech32m": + assert "Internal Key" in cc_addrs_split[0] + assert "Taptree" in cc_addrs_split[0] + else: + assert "Internal Key" not in cc_addrs_split[0] + assert "Taptree" not in cc_addrs_split[0] + + cc_addrs = cc_addrs_split[1:] + cc_addrs_change = cc_addrs_split_change[1:] + part_addr_index = 1 + + time.sleep(2) + + internal_desc = None + external_desc = None + descriptors = wallet.listdescriptors()["descriptors"] + for desc in descriptors: + if desc["internal"]: + internal_desc = desc["desc"] + else: + external_desc = desc["desc"] + + if export_check: + cc_external, cc_internal = miniscript_descriptors(cc_minsc_name) + assert cc_external.split("#")[0] == external_desc.split("#")[0].replace("'", "h") + assert cc_internal.split("#")[0] == internal_desc.split("#")[0].replace("'", "h") + + bitcoind_addrs = wallet.deriveaddresses(external_desc, addr_range) + bitcoind_addrs_change = wallet.deriveaddresses(internal_desc, addr_range) + + for cc, core in [(cc_addrs, bitcoind_addrs), (cc_addrs_change, bitcoind_addrs_change)]: + for idx, cc_item in enumerate(cc): + cc_item = cc_item.split(",") + partial_address = cc_item[part_addr_index] + _start, _end = partial_address.split("___") + if way != "nfc": + _start, _end = _start[1:], _end[:-1] + assert core[idx].startswith(_start) + assert core[idx].endswith(_end) + + return doit + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("csa", [True, False]) +@pytest.mark.parametrize("add_pk", [True, False]) +@pytest.mark.parametrize('M_N', [(3, 15), (2, 2), (3, 5)]) +@pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) +def test_bitcoind_tapscript_address(M_N, clear_miniscript, goto_home, need_keypress, + pick_menu_item, cap_menu, cap_story, make_multisig, + import_ms_wallet, microsd_path, bitcoind_miniscript, + use_regtest, load_export, way, csa, address_explorer_check, + add_pk): + use_regtest() + clear_miniscript() + M, N = M_N + ms_wo, _ = bitcoind_miniscript(M, N, "p2tr", funded=False, tapscript_threshold=csa, + add_own_pk=add_pk) + address_explorer_check(way, "bech32m", ms_wo, "minisc", export_check=False) + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("m_n", [(2,2), (3, 5), (32, 32)]) +@pytest.mark.parametrize("internal_key_spendable", [True, False, "77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76", "@"]) +def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, bitcoind, goto_home, cap_menu, + need_keypress, pick_menu_item, cap_story, microsd_path, load_export, microsd_wipe, dev, + bitcoind_miniscript, clear_miniscript, get_cc_key): + M, N = m_n + clear_miniscript() + microsd_wipe() + internal_key = None + r = None + if internal_key_spendable is True: + internal_key = get_cc_key("86h/0h/3h") + elif isinstance(internal_key_spendable, str) and len(internal_key_spendable) == 64: + r = internal_key_spendable + elif internal_key_spendable == "@": + r = "@" + + tapscript_wo, bitcoind_signers = bitcoind_miniscript(M, N, "p2tr", internal_key=internal_key, r=r) + + dest_addr = tapscript_wo.getnewaddress("", "bech32m") + psbt = tapscript_wo.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 20})["psbt"] + fname = "tapscript.psbt" + if not cc_first: + # bitcoind cosigner sigs first + for i in range(M - 1): + signer = bitcoind_signers[i] + psbt = signer.walletprocesspsbt(psbt, True, "DEFAULT", True)["psbt"] + with open(microsd_path(fname), "w") as f: + f.write(psbt) + goto_home() + # bug in goto_home ? + need_keypress("x") + time.sleep(0.1) + # CC signing + need_keypress("y") + time.sleep(0.1) + title, story = cap_story() + if "Choose" in story: + need_keypress("y") + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + need_keypress("y") + time.sleep(0.1) + title, story = cap_story() + split_story = story.split("\n\n") + cc_tx_id = None + if "(ready for broadcast)" in story: + signed_fname = split_story[1] + signed_txn_fname = split_story[-2] + cc_tx_id = split_story[-1].split("\n")[-1] + with open(microsd_path(signed_txn_fname), "r") as f: + signed_txn = f.read().strip() + else: + signed_fname = split_story[-1] + + with open(microsd_path(signed_fname), "r") as f: + signed_psbt = f.read().strip() + + if cc_first: + for signer in bitcoind_signers: + signed_psbt = signer.walletprocesspsbt(signed_psbt, True, "DEFAULT", True)["psbt"] + res = tapscript_wo.finalizepsbt(signed_psbt, True) + assert res['complete'] + tx_hex = res["hex"] + res = bitcoind.supply_wallet.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + if cc_tx_id: + assert tx_hex == signed_txn + assert txn_id == cc_tx_id + assert len(txn_id) == 64 + + +@pytest.mark.parametrize("num_leafs", [1, 2, 5, 8]) +@pytest.mark.parametrize("internal_key_spendable", [True, False]) +def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bitcoind, + internal_key_spendable, dev, microsd_path, need_keypress, get_cc_key, + pick_menu_item, cap_story, goto_home, cap_menu, load_export): + use_regtest() + clear_miniscript() + microsd_wipe() + tmplt = TREE[num_leafs] + bitcoind_signers = [ + bitcoind.create_wallet(wallet_name=f"bitcoind--signer{i}", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + for i in range(num_leafs) + ] + bitcoind_signers_xpubs = [] + for signer in bitcoind_signers: + target_desc = "" + bitcoind_descriptors = signer.listdescriptors()["descriptors"] + for desc in bitcoind_descriptors: + if desc["desc"].startswith("pkh(") and desc["internal"] is False: + target_desc = desc["desc"] + core_desc, checksum = target_desc.split("#") + # remove pkh(....) + core_key = core_desc[4:-1] + bitcoind_signers_xpubs.append(core_key) + + bitcoin_signer_leafs = [f"pk({k})" for k in bitcoind_signers_xpubs] + + cc_key = get_cc_key("86h/0h/100h") + cc_leaf = f"pk({cc_key})" + + if internal_key_spendable: + desc = f"tr({cc_key},{tmplt % (*bitcoin_signer_leafs,)})" + else: + internal_key = bitcoind_signers_xpubs[0] + leafs = bitcoin_signer_leafs[1:] + [cc_leaf] + random.shuffle(leafs) + desc = f"tr({internal_key},{tmplt % (*leafs,)})" + + ts = bitcoind.create_wallet( + wallet_name=f"watch_only_pk_ts", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + + fname = "ts_pk.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc + "\n") + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Taproot internal key" in story + assert "Taproot tree keys" in story + assert "Press (1) to see extended public keys" in story + assert "P2TR" in story + + need_keypress("y") + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + menu = cap_menu() + pick_menu_item(menu[0]) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=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 = ts.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + addr = ts.getnewaddress("", "bech32m") + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = ts.getnewaddress("", "bech32m") # selfspend + psbt = ts.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "ts_pk.psbt" + with open(microsd_path(fname), "w") as f: + f.write(psbt) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.5) + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert "OK TO SEND?" in title + assert "Consolidating" in story + need_keypress("y") # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + need_keypress("y") + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = ts.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = ts.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + +@pytest.mark.parametrize("desc", [ + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti_a(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})#tpm3afjn", + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)}})", + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},sortedmulti_a(2,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)})", + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[b7fe820c/48'/1'/0'/3']tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/0/*),sortedmulti_a(2,[0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*,[30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*)},or_d(pk([0f056943/48'/1'/0'/3']tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/0/*),and_v(v:pkh([30afbe54/48'/1'/0'/3']tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/0/*),older(500)))})", +]) +def test_tapscript_import_export(clear_miniscript, pick_menu_item, cap_story, need_keypress, + goto_home, load_export, desc, microsd_path): + clear_miniscript() + goto_home() + fname = "imdesc.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import from File") + time.sleep(0.1) + title, story = cap_story() + if "Press (1)" in story: + need_keypress("1") + need_keypress("y") + pick_menu_item(fname) + need_keypress("y") # approve miniscript import + pick_menu_item(fname.split(".")[0]) + pick_menu_item("Descriptors") + pick_menu_item("Export") + time.sleep(.1) + title, story = cap_story() + assert "(<0;1> notation) press OK" in story + need_keypress("y") + contents = load_export("sd", label="Miniscript", is_json=False, addr_fmt=AF_P2TR, + sig_check=False) + descriptor = contents.strip() + assert desc.split("#")[0].replace("<0;1>/*", "0/*").replace("'", "h") == descriptor.split("#")[0].replace("<0;1>/*", "0/*").replace("'", "h") + + +def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, bitcoind, dev, + goto_home, pick_menu_item, need_keypress, microsd_path, + cap_story, load_export, get_cc_key): + # works in core - but some discussions are ongoing + # https://github.com/bitcoin/bitcoin/issues/27104 + # CC also allows this for now... (experimental branch) + use_regtest() + clear_miniscript() + microsd_wipe() + ss = bitcoind.create_wallet(wallet_name=f"dup_leafs", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + target_desc = "" + bitcoind_descriptors = ss.listdescriptors()["descriptors"] + for desc in bitcoind_descriptors: + if desc["desc"].startswith("pkh(") and desc["internal"] is False: + target_desc = desc["desc"] + core_desc, checksum = target_desc.split("#") + # remove pkh(....) + core_key = core_desc[4:-1] + + cc_key = get_cc_key("86h/0h/100h") + cc_leaf = f"pk({cc_key})" + + tmplt = TREE[2] + tmplt = tmplt % (cc_leaf, cc_leaf) + desc = f"tr({core_key},{tmplt})" + fname = "dup_leafs.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import from File") + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Taproot internal key" in story + assert "Taproot tree keys" in story + assert "Press (1) to see extended public keys" in story + assert "P2TR" in story + + need_keypress("y") + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item(fname.split(".")[0]) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=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) + # wo wallet + ts = bitcoind.create_wallet( + wallet_name=f"dup_leafs_wo", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + # import descriptors to watch only wallet + res = ts.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + addr = ts.getnewaddress("", "bech32m") + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = ts.getnewaddress("", "bech32m") # selfspend + psbt = ts.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "ts_pk.psbt" + with open(microsd_path(fname), "w") as f: + f.write(psbt) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.5) + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert "OK TO SEND?" in title + assert "Consolidating" in story + need_keypress("y") # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + need_keypress("y") + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = ts.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = ts.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + +def test_same_key_account_based_minisc(goto_home, need_keypress, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind): + clear_miniscript() + desc = ("wsh(" + "or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*)," + "and_v(" + "v:pkh([0f056943/84'/1'/9']tpubDC7jGaaSE66QBAcX8TUD3JKWari1zmGH4gNyKZcrfq6NwCofKujNF2kyeVXgKshotxw5Yib8UxLrmmCmWd8NVPVTAL8rGfMdc7TsAKqsy6y/<0;1>/*)," + "older(5))))#qmwvph5c") + name = "mini-accounts" + fname = f"{name}.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import from File") + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Press (1) to see extended public keys" in story + + need_keypress("y") + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item(fname.split(".")[0]) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=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) + # wo wallet + wo = bitcoind.create_wallet( + wallet_name=f"multi-account", disable_private_keys=True, + blank=True, passphrase=None, avoid_reuse=False, descriptors=True + ) + # import descriptors to watch only wallet + res = wo.importdescriptors(core_desc_object) + assert res[0]["success"] + assert res[1]["success"] + + addr = wo.getnewaddress("", "bech32") + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = wo.getnewaddress("", "bech32") # selfspend + psbt = wo.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "multi-acct.psbt" + with open(microsd_path(fname), "w") as f: + f.write(psbt) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.5) + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert "OK TO SEND?" in title + assert "Consolidating" in story + need_keypress("y") # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + need_keypress("y") + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + + _psbt = BasicPSBT().parse(final_psbt.encode()) + assert len(_psbt.inputs[0].part_sigs) == 2 + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + +def test_same_key_account_based_multisig(goto_home, need_keypress, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind): + # but still imported as miniscript - even tho it is basic multisig that can be imported legacy path + clear_miniscript() + desc = ("wsh(sortedmulti(2," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*," + "[0f056943/84'/1'/9']tpubDC7jGaaSE66QBAcX8TUD3JKWari1zmGH4gNyKZcrfq6NwCofKujNF2kyeVXgKshotxw5Yib8UxLrmmCmWd8NVPVTAL8rGfMdc7TsAKqsy6y/<0;1>/*" + "))") + name = "multi-accounts" + fname = f"{name}.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import from File") + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Failed to import" in story + assert "Use Settings -> Multisig Wallets" in story + + +@pytest.mark.parametrize("desc", [ + "wsh(or_d(pk(@A),and_v(v:pkh(@A),older(5))))", + "tr(%s,multi_a(2,@A,@A))" % H, + "tr(%s,{sortedmulti_a(2,@A,@A),pk(@A)})" % H, + "tr(%s,or_d(pk(@A),and_v(v:pkh(@A),older(5))))" % H, +]) +def test_insane_miniscript(get_cc_key, goto_home, pick_menu_item, need_keypress, cap_story, + microsd_path, desc): + + cc_key = get_cc_key("84h/0h/0h") + desc = desc.replace("@A", cc_key) + fname = "insane.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import from File") + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Failed to import" in story + assert "Insane" in story + +def test_tapscript_depth(get_cc_key, goto_home, pick_menu_item, need_keypress, cap_story, + microsd_path): + leaf_num = 9 + scripts = [] + for i in range(leaf_num): + k = get_cc_key(f"84h/0h/{i}h") + scripts.append(f"pk({k})") + + tree = TREE[leaf_num] % tuple(scripts) + desc = f"tr({H},{tree})" + fname = "9leafs.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import from File") + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Failed to import" in story + assert "num_leafs > 8" in story + +@pytest.mark.bitcoind +@pytest.mark.parametrize("lt_type", ["older", "after"]) +@pytest.mark.parametrize("recovery", [True, False]) +@pytest.mark.parametrize("leaf2_mine", [True, False]) +@pytest.mark.parametrize("minisc", [ + "or_d(pk(@A),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:pk(@B),locktime(N)))", + + "or_d(multi_a(2,@A,@C),and_v(v:pkh(@B),locktime(N)))", + + "or_d(pk(@A),and_v(v:multi_a(2,@B,@C),locktime(N)))", +]) +def test_minitapscript(leaf2_mine, recovery, lt_type, minisc, clear_miniscript, goto_home, + need_keypress, pick_menu_item, cap_menu, cap_story, microsd_path, + use_regtest, bitcoind, microsd_wipe, load_export, dev, + address_explorer_check, get_cc_key): + + # needs this bitcoind branch https://github.com/bitcoin/bitcoin/pull/27255 + normal_cosign_core = False + recovery_cosign_core = False + if "multi_a(" in minisc.split("),", 1)[0]: + normal_cosign_core = True + if "multi_a(" in minisc.split("),", 1)[-1]: + recovery_cosign_core = True + + if lt_type == "older": + sequence = 5 + locktime = 0 + # 101 blocks are mined by default + to_replace = "older(5)" + else: + sequence = None + locktime = 105 + to_replace = "after(105)" + + minisc = minisc.replace("locktime(N)", to_replace) + + core_keys = [] + signers = [] + for i in range(3): + # core signers + signer = bitcoind.create_wallet(wallet_name=f"co-signer{i}", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + target_desc = "" + bitcoind_descriptors = signer.listdescriptors()["descriptors"] + for d in bitcoind_descriptors: + if d["desc"].startswith("pkh(") and d["internal"] is False: + target_desc = d["desc"] + break + core_desc, checksum = target_desc.split("#") + core_key = core_desc[4:-1] + core_keys.append(core_key) + signers.append(signer) + + # cc device key + cc_key = get_cc_key("86h/1h/0h") + cc_key1 = get_cc_key("86h/1h/1h") + + if recovery: + # recevoery path is always B + minisc = minisc.replace("@B", cc_key) + minisc = minisc.replace("@A", core_keys[0]) + else: + minisc = minisc.replace("@A", cc_key) + minisc = minisc.replace("@B", core_keys[0]) + + if "@C" in minisc: + minisc = minisc.replace("@C", core_keys[1]) + + if leaf2_mine: + desc = f"tr({H},{{{minisc},pk({cc_key1})}})" + else: + desc = f"tr({H},{{pk({core_keys[2]}),{minisc}}})" + + use_regtest() + clear_miniscript() + name = "minitapscript" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.3) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + need_keypress("y") + menu = cap_menu() + assert menu[0] == name + 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 miniscript", is_json=False, sig_check=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) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + addr = wo.getnewaddress("", "bech32m") + addr_dest = wo.getnewaddress("", "bech32m") # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + all_of_it = wo.getbalance() + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + if recovery and sequence and not leaf2_mine: + inp["sequence"] = sequence + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: all_of_it - 1}], + locktime if (recovery and not leaf2_mine) else 0, + {"fee_rate": 20, "change_type": "bech32m", "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + if (normal_cosign_core or recovery_cosign_core) and not leaf2_mine: + psbt = signers[1].walletprocesspsbt(psbt, True, "ALL")["psbt"] + + name = f"{name}.psbt" + with open(microsd_path(name), "w") as f: + f.write(psbt) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.5) + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert "OK TO SEND?" in title + assert "Consolidating" in story + need_keypress("y") # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + need_keypress("y") + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + if recovery and not leaf2_mine: + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' if sequence else "non-final" + bitcoind.supply_wallet.generatetoaddress(6, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + else: + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check("sd", "bech32m", wo, "minitapscript") + +@pytest.mark.parametrize("desc", [ + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{{sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*),sortedmulti(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)},sortedmulti(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*)})", + "wsh(sortedmulti_a(2,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*))", + "sh(wsh(or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:multi_a(2,[b7fe820c/48h/1h/0h/3h]tpubDFdQ1sNV53TbogAMPEd2egY5NXfbdKD1Mnr2iBrJrcwRHJbKC7tuuUMHT8SSHJ2VEKdCf5WYBMfevvWCnyJV53gYUT2wFyxEV8SuUTedBp7/<0;1>/*,[0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),older(500)))))", +]) +def test_multi_mixin(desc, clear_miniscript, goto_home, microsd_path, pick_menu_item, + cap_story, need_keypress): + clear_miniscript() + goto_home() + fname = "imdesc.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + pick_menu_item("Settings") + pick_menu_item("Miniscript") + pick_menu_item("Import from File") + time.sleep(0.1) + title, story = cap_story() + if "Press (1)" in story: + need_keypress("1") + need_keypress("y") + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert "Failed to import" in story + assert "multi mixin" in story + + +@pytest.mark.parametrize("addr_fmt", ["bech32", "bech32m"]) +@pytest.mark.parametrize("cc_first", [True, False]) +def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, cap_story, cap_menu, + need_keypress, load_export, microsd_path, use_regtest, clear_miniscript, cc_first, + address_explorer_check): + + # check D wrapper u property for segwit v0 and v1 + # https://github.com/bitcoin/bitcoin/pull/24906/files + minsc = "thresh(3,c:pk_k(@A),sc:pk_k(@B),sc:pk_k(@C),sdv:older(5))" + + core_keys = [] + signers = [] + for i in range(2): + # core signers + signer = bitcoind.create_wallet(wallet_name=f"co-signer{i}", disable_private_keys=False, blank=False, + passphrase=None, avoid_reuse=False, descriptors=True) + target_desc = "" + bitcoind_descriptors = signer.listdescriptors()["descriptors"] + for d in bitcoind_descriptors: + if d["desc"].startswith("pkh(") and d["internal"] is False: + target_desc = d["desc"] + break + core_desc, checksum = target_desc.split("#") + core_key = core_desc[4:-1] + core_keys.append(core_key) + signers.append(signer) + + cc_key = get_cc_key(f"{84 if addr_fmt == 'bech32' else 86}h/1h/0h") + + minsc = minsc.replace("@A", cc_key) + minsc = minsc.replace("@B", core_keys[0]) + minsc = minsc.replace("@C", core_keys[1]) + + if addr_fmt == "bech32": + desc = f"wsh({minsc})" + else: + desc = f"tr({H},{minsc})" + + name = "d_wrapper" + fname = f"{name}.txt" + + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + + clear_miniscript() + use_regtest() + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.3) + need_keypress("y") + pick_menu_item(fname) + _, story = cap_story() + if addr_fmt == "bech32": + assert "Failed to import" in story + assert "thresh: X3 should be du" in story + return + + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + need_keypress("y") + menu = cap_menu() + assert menu[0] == name + 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 miniscript", is_json=False, sig_check=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) + res = wo.importdescriptors(core_desc_object) + for obj in res: + assert obj["success"] + + addr = wo.getnewaddress("", addr_fmt) # self-spend + addr_dest = wo.getnewaddress("", addr_fmt) # self-spend + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + all_of_it = wo.getbalance() + unspent = wo.listunspent() + assert len(unspent) == 1 + inp = {"txid": unspent[0]["txid"], "vout": unspent[0]["vout"]} + inp["sequence"] = 5 + psbt_resp = wo.walletcreatefundedpsbt( + [inp], + [{addr_dest: all_of_it - 1}], + 0, + {"fee_rate": 20, "change_type": addr_fmt}, + ) + psbt = psbt_resp.get("psbt") + + if not cc_first: + to_sign_psbt_o = signers[0].walletprocesspsbt(psbt, True) + to_sign_psbt = to_sign_psbt_o["psbt"] + assert to_sign_psbt != psbt + else: + to_sign_psbt = psbt + + name = f"{name}.psbt" + with open(microsd_path(name), "w") as f: + f.write(to_sign_psbt) + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(0.5) + title, story = cap_story() + if "Choose PSBT file to be signed" in story: + need_keypress("y") + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert "OK TO SEND?" in title + assert "Consolidating" in story + need_keypress("y") # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + need_keypress("y") + fname_psbt = story.split("\n\n")[1] + # fname_txn = story.split("\n\n")[3] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + + assert final_psbt != to_sign_psbt + # with open(microsd_path(fname_txn), "r") as f: + # final_txn = f.read().strip() + + if cc_first: + done_o = signers[0].walletprocesspsbt(final_psbt, True) + done = done_o["psbt"] + else: + done = final_psbt + + res = wo.finalizepsbt(done) + assert res["complete"] + tx_hex = res["hex"] + # assert tx_hex == final_txn + res = wo.testmempoolaccept([tx_hex]) + assert not res[0]["allowed"] + assert res[0]["reject-reason"] == 'non-BIP68-final' + bitcoind.supply_wallet.generatetoaddress(6, bitcoind.supply_wallet.getnewaddress()) + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # check addresses + address_explorer_check("sd", addr_fmt, wo, "d_wrapper") diff --git a/testing/test_multisig.py b/testing/test_multisig.py index ef94f998..db282457 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -6,14 +6,10 @@ # # py.test test_multisig.py -m ms_danger --ms-danger # -import sys -sys.path.append("../shared") -from descriptor import MultisigDescriptor, MULTI_FMT_TO_SCRIPT, parse_desc_str import time, pytest, os, random, json, shutil, pdb, io, base64 from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput from ckcc.protocol import CCProtocolPacker, MAX_TXN_LEN from pprint import pprint -from base64 import b64encode, b64decode from helpers import B2A, fake_dest_addr, swab32, xfp2str from helpers import str_to_path, slip132undo from struct import unpack, pack @@ -22,6 +18,7 @@ from pycoin.tx import Tx from io import BytesIO from hashlib import sha256 +from descriptor import MULTI_FMT_TO_SCRIPT, MultisigDescriptor, parse_desc_str def HARD(n=0): @@ -157,7 +154,7 @@ def doit(config): def import_ms_wallet(dev, make_multisig, offer_ms_import, need_keypress): def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, keys=None, do_import=True, derivs=None, - descriptor=False, int_ext_desc=False, internal_key=None): + descriptor=False, int_ext_desc=False): keys = keys or make_multisig(M, N, unique=unique, deriv=common or (derivs[0] if derivs else None)) name = name or f'test-{M}-{N}' @@ -173,10 +170,7 @@ def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, ke assert len(derivs) == N key_list = [(xfp, derivs[idx], dd.hwif(as_private=False)) for idx, (xfp, m, dd) in enumerate(keys)] - if addr_fmt == "p2tr" and internal_key is None: - internal_key = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" - addr_fmt = AF_P2TR - desc = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=addr_fmt, internal_key=internal_key) + desc = MultisigDescriptor(M=M, N=N, keys=key_list, addr_fmt=addr_fmt) if int_ext_desc: desc_str = desc.serialize(int_ext=True) else: @@ -1275,11 +1269,11 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev 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))) - open(f'debug/myself-before.psbt', 'w').write(b64encode(psbt).decode()) + open(f'debug/myself-before.psbt', 'w').write(base64.b64encode(psbt).decode()) for idx in range(M): select_wallet(idx) _, updated = try_sign(psbt, accept_ms_import=(incl_xpubs and (idx==0))) - open(f'debug/myself-after.psbt', 'w').write(b64encode(updated).decode()) + open(f'debug/myself-after.psbt', 'w').write(base64.b64encode(updated).decode()) assert updated != psbt aft = BasicPSBT().parse(updated) @@ -1289,14 +1283,14 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev psbt = aft.as_bytes() # should be fully signed now. - anal = bitcoind.rpc.analyzepsbt(b64encode(psbt).decode('ascii')) + anal = bitcoind.rpc.analyzepsbt(base64.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 pprint(anal, stream=open('debug/analyzed.txt', 'wt')) - decode = bitcoind.rpc.decodepsbt(b64encode(psbt).decode('ascii')) + decode = bitcoind.rpc.decodepsbt(base64.b64encode(psbt).decode('ascii')) pprint(decode, stream=open('debug/decoded.txt', 'wt')) if M==N or segwit: @@ -1563,7 +1557,7 @@ def mapper(cosigner_idx): resp = bitcoind.supply_wallet.walletprocesspsbt(resp["psbt"]) # assert resp['changepos'] == -1 - psbt = b64decode(resp['psbt']) + psbt = base64.b64decode(resp['psbt']) open('debug/funded.psbt', 'wb').write(psbt) @@ -1585,11 +1579,11 @@ def mapper(cosigner_idx): if cc_sign_first: # cc signed first - bitcoind is now second - rr = bitcoind.supply_wallet.walletprocesspsbt(b64encode(updated).decode('ascii'), True, "ALL") + rr = bitcoind.supply_wallet.walletprocesspsbt(base64.b64encode(updated).decode('ascii'), True, "ALL") assert rr["complete"] both_signed = rr["psbt"] else: - both_signed = b64encode(updated).decode('ascii') + both_signed = base64.b64encode(updated).decode('ascii') # finalize and send rr = bitcoind.supply_wallet.finalizepsbt(both_signed, True) @@ -1863,8 +1857,6 @@ def test_ms_addr_explorer(descriptor, change, M, N, addr_fmt, make_multisig, cle title, story = cap_story() assert "Press (6)" in story assert "change addresses." in story - assert "Taproot internal key" not in story - assert "Taproot tree keys" not in story if change: need_keypress("6") time.sleep(0.2) @@ -1893,15 +1885,15 @@ def test_ms_addr_explorer(descriptor, change, M, N, addr_fmt, make_multisig, cle chng_idx = 1 if change else 0 path_mapper = lambda co_idx: str_to_path(derivs[co_idx]) + [chng_idx, idx] - expect, pubkey, script, _ = make_ms_address(M, keys, idx=idx, addr_fmt=addr_fmt, + expect, pubkey, script, _ = make_ms_address(M, keys, idx=idx, addr_fmt=addr_fmt, path_mapper=path_mapper) assert int(subpath.split('/')[-1][0]) == idx assert int(subpath.split('/')[-2]) == chng_idx #print('../0/%s => \n %s' % (idx, B2A(script))) - trunc = expect[0:8] + "-" + expect[-7:] - assert trunc == addr + assert addr[:5] == expect[:5] + assert addr[-6:] == expect[-6:] def test_dup_ms_wallet_bug(goto_home, pick_menu_item, need_keypress, import_ms_wallet, clear_ms, M=2, N=3): @@ -2009,12 +2001,6 @@ def test_bitcoind_ms_address(change, descriptor, M_N, addr_fmt, clear_ms, goto_h title, story = cap_story() assert "Press (6)" in story assert "change addresses." in story - if addr_fmt == AF_P2TR: - assert "Taproot internal key" in story - assert "Taproot tree keys" in story - else: - assert "Taproot internal key" not in story - assert "Taproot tree keys" not in story if change: need_keypress("6") time.sleep(0.2) @@ -2068,9 +2054,8 @@ def test_bitcoind_ms_address(change, descriptor, M_N, addr_fmt, clear_ms, goto_h @pytest.fixture def bitcoind_multisig(bitcoind, bitcoind_d_sim_watch, need_keypress, cap_story, load_export, pick_menu_item, goto_home, cap_menu, microsd_path, use_regtest): - def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None): + def doit(M, N, script_type, cc_account=0, funded=True): use_regtest() - H = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" # BIP-0341 bitcoind_signers = [ bitcoind.create_wallet(wallet_name=f"bitcoind--signer{i}", disable_private_keys=False, blank=False, passphrase=None, avoid_reuse=False, descriptors=True) @@ -2108,17 +2093,11 @@ def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None core_key = core_desc[4:-1] bitcoind_signers_xpubs.append(core_key) desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs)) - if internal_key: - desc = desc.replace(H, internal_key) - elif r: - desc = desc.replace(H, f"r={r}") if script_type == 'p2wsh': name = f"core{M}of{N}_native.txt" elif script_type == "p2sh_p2wsh": name = f"core{M}of{N}_wrapped.txt" - elif script_type == "p2tr": - name = f"core{M}of{N}_taproot.txt" else: name = f"core{M}of{N}_legacy.txt" with open(microsd_path(name), "w") as f: @@ -2147,8 +2126,6 @@ def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None assert "P2WSH" in story elif script_type == "p2sh": assert "P2SH" in story - elif script_type == "p2tr": - assert "P2TR" in story else: assert "P2SH-P2WSH" in story assert "Derivation:\n Varies (2)" in story @@ -2172,20 +2149,6 @@ def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None assert res[0]["success"] assert res[1]["success"] - if r and r != "@": - from pysecp256k1.extrakeys import keypair_create, keypair_xonly_pub, xonly_pubkey_parse - from pysecp256k1.extrakeys import xonly_pubkey_tweak_add, xonly_pubkey_serialize, xonly_pubkey_from_pubkey - H_xo = xonly_pubkey_parse(bytes.fromhex(H)) - r_bytes = bytes.fromhex(r) - kp = keypair_create(r_bytes) - kp_xo, kp_parity = keypair_xonly_pub(kp) - pk = xonly_pubkey_tweak_add(H_xo, xonly_pubkey_serialize(kp_xo)) - xo, xo_parity = xonly_pubkey_from_pubkey(pk) - internal_key_bytes = xonly_pubkey_serialize(xo) - internal_key_hex = internal_key_bytes.hex() - assert internal_key_hex in core_desc_object[0]["desc"] - assert internal_key_hex in core_desc_object[1]["desc"] - if funded: if script_type == "p2wsh": addr_type = "bech32" @@ -2319,7 +2282,7 @@ def test_legacy_multisig_witness_utxo_in_psbt(bitcoind, use_regtest, clear_ms, m @pytest.mark.bitcoind @pytest.mark.parametrize("m_n", [(2,2), (3, 5), (15, 15)]) -@pytest.mark.parametrize("script_type", ["p2wsh", "p2sh_p2wsh", "p2sh", "p2tr"]) +@pytest.mark.parametrize("script_type", ["p2wsh", "p2sh_p2wsh", "p2sh"]) @pytest.mark.parametrize("sighash", list(SIGHASH_MAP.keys())) def test_bitcoind_MofN_tutorial(m_n, script_type, clear_ms, goto_home, need_keypress, pick_menu_item, sighash, cap_menu, cap_story, microsd_path, use_regtest, bitcoind, @@ -2468,174 +2431,16 @@ def test_bitcoind_MofN_tutorial(m_n, script_type, clear_ms, goto_home, need_keyp assert len(bitcoind_watch_only.listunspent()) == 1 -@pytest.mark.bitcoind -@pytest.mark.parametrize("cc_first", [True, False]) -@pytest.mark.parametrize("m_n", [(2,2), (3, 5), (32, 32)]) -@pytest.mark.parametrize("internal_key_spendable", [True, False, "77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76", "@"]) -def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, bitcoind, goto_home, cap_menu, clear_ms, - need_keypress, pick_menu_item, cap_story, microsd_path, load_export, microsd_wipe, dev, - bitcoind_multisig): - M, N = m_n - clear_ms() - microsd_wipe() - internal_key = None - r = None - if internal_key_spendable is True: - path = "86h/0h/3h" - master_xfp_str = pack('/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"), - ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"), + ("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"), ("Key origin info is required", "wsh(sortedmulti(2,tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#ypuy22nw"), - ("Malformed key derivation info", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), - ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#gs2fqgl6"), - ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"), + ("xpub depth", "wsh(sortedmulti(2,[0f056943]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#nhjvt4wd"), + ("Key derivation too long", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0))#s487stua"), ("Cannot use hardened sub derivation path", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0'/*))#3w6hpha3"), - ("Unsupported descriptor", "wsh(multi(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))#t2zpj2eu"), - ("Unsupported descriptor", "pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)#ml40v0wf"), ("M must be <= N", "wsh(sortedmulti(3,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#uueddtsy"), ]) def test_exotic_descriptors(desc, clear_ms, goto_home, need_keypress, pick_menu_item, cap_menu, cap_story, make_multisig, @@ -2714,8 +2519,8 @@ def test_ms_xpub_ordering(descriptor, m_n, clear_ms, make_multisig, import_ms_wa @pytest.mark.parametrize('cmn_pth_from_root', [True, False]) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) -@pytest.mark.parametrize('M_N', [(3, 5), (2, 3), (15, 15), (32, 32)]) -@pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_P2TR]) +@pytest.mark.parametrize('M_N', [(3, 5), (2, 3), (15, 15)]) +@pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH]) def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear_ms, make_multisig, import_ms_wallet, goto_home, pick_menu_item, cap_menu, nfc_read_text, microsd_path, cap_story, need_keypress, @@ -2733,7 +2538,6 @@ def choose_multisig_wallet(): dd = { AF_P2WSH: ("m/48'/1'/0'/2'/{idx}", 'p2wsh'), - AF_P2TR: ("m/48'/1'/0'/3'/{idx}", 'p2tr'), AF_P2SH: ("m/45'/{idx}", 'p2sh'), AF_P2WSH_P2SH: ("m/48'/1'/0'/1'/{idx}", 'p2sh-p2wsh'), } @@ -2742,16 +2546,10 @@ def choose_multisig_wallet(): derivs = [deriv.format(idx=i) for i in range(N)] clear_ms() - try: - 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/45'" if cmn_pth_from_root else None) - except Exception as e: - assert addr_fmt != AF_P2TR - assert M == N == 32 - assert str(e) == 'Coldcard Error: M/N range' - return + 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/45'" if cmn_pth_from_root else None) # get bare descriptor choose_multisig_wallet() diff --git a/testing/test_sign.py b/testing/test_sign.py index 0abef31a..47c15176 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -2316,14 +2316,4 @@ def test_invalid_output_tapproot_psbt(fake_txn, start_sign, cap_story, dev): # error messages are disabled to save some space - problem file line is still included # assert "PSBT_IN_TAP_BIP32_DERIVATION xonly-pubkey length != 32" in story - -def test_taproot_too_complex(try_sign): - # tree of depth 2 - not allowed yet - for _file in ["data/taproot/taptree.psbt", "data/taproot/taptree-sig.psbt"]: - with open(_file, "r") as f: - psbt = base64.b64decode(f.read()) - with pytest.raises(Exception) as e: - try_sign(psbt) - assert e.value.args[0] == "Coldcard Error: Invalid PSBT" - # EOF diff --git a/testing/test_ux.py b/testing/test_ux.py index b7769986..dddadcc3 100644 --- a/testing/test_ux.py +++ b/testing/test_ux.py @@ -34,8 +34,18 @@ def test_home_menu(capture_enabled, cap_menu, cap_story, cap_screen, need_keypre # check 4 lines of menu are shown right scr = cap_screen() - chk = '\n'.join(m[0:5]) - assert scr == chk + maybe_edge = [] + scr_menu_items = [] + for item in scr.split("\n"): + if len(item) == 1: + maybe_edge.append(item) + else: + scr_menu_items.append(item) + + if maybe_edge: + assert "EDGE" == "".join(maybe_edge) + + assert scr_menu_items == m[:len(scr_menu_items)] # pick first item, expect a story need_keypress('0') @@ -873,7 +883,7 @@ def test_menu_wrapping(goto_home, pick_menu_item, cap_story, need_keypress, cap_ assert "Change Main PIN" in menu need_keypress("x") # back to settings - on login settings # now go over top and back to Login settings from bottom - for i in range(9): + for i in range(10): need_keypress(UP) need_keypress("y") menu = cap_menu()