From 991965ac60a284ef90f09976790867d89ec4d8d1 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 09:15:10 -0400 Subject: [PATCH 01/15] Token diff --- releases/Next-ChangeLog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index e812525d..859dad07 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -5,6 +5,7 @@ This lists the new changes that have not yet been published in a normal release. # Shared Improvements - Both Mk4 and Q - Bugfix: Trying to set custom URL for NFC push transaction caused yikes +- Edge branch reworked to support Q and Mk4 at same time (still a separate binary) # Mk4 Specific Changes From e1ff15bab4af7454005a55b18ec5763a539d67ac Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 09:42:03 -0400 Subject: [PATCH 02/15] edits --- graphics/mono/space.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphics/mono/space.txt b/graphics/mono/space.txt index cddef349..48fca73e 100644 --- a/graphics/mono/space.txt +++ b/graphics/mono/space.txt @@ -1,2 +1,4 @@ x x +x x +x x xxxxxxxxx From 98db85f2e27cc9bc967206673c4c61bf68de7aed Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 09:47:45 -0400 Subject: [PATCH 03/15] restored --- graphics/mono/space.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/graphics/mono/space.txt b/graphics/mono/space.txt index 48fca73e..cddef349 100644 --- a/graphics/mono/space.txt +++ b/graphics/mono/space.txt @@ -1,4 +1,2 @@ x x -x x -x x xxxxxxxxx From eef1f6d56102888e906f5ea9890210cb5d9f1616 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 09:49:39 -0400 Subject: [PATCH 04/15] switch to space in word menu --- releases/Next-ChangeLog.md | 2 ++ shared/display.py | 6 ++++-- stm32/COLDCARD_MK4/nmi.c | 13 +++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 stm32/COLDCARD_MK4/nmi.c diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 859dad07..8076f5ea 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -14,6 +14,8 @@ This lists the new changes that have not yet been published in a normal release. - Bugfix: Fix yikes displaying BIP-85 WIF when both NFC and VDisk are OFF - Bugfix: Fix inability to export change addresses when both NFC and Vdisk id OFF +- Bugfix: In BIP-39 words menu, show space character rather than Nokia-style placeholder + which could be confused for an underscore. # Q Specific Changes diff --git a/shared/display.py b/shared/display.py index 536d6250..bf819683 100644 --- a/shared/display.py +++ b/shared/display.py @@ -269,8 +269,10 @@ class Display: else: self.text(x, y, msg) - if msg[0] == ' ' and space_indicators: - self.icon(x-2, y+11, 'space', invert=is_sel) + # LATER: removed because caused confusion w/ underscore + #if msg[0] == ' ' and space_indicators: + # see also graphics/mono/space.txt + #self.icon(x-2, y+9, 'space', invert=is_sel) if is_checked: self.icon(108, y, 'selected', invert=is_sel) diff --git a/stm32/COLDCARD_MK4/nmi.c b/stm32/COLDCARD_MK4/nmi.c new file mode 100644 index 00000000..cf45fcd5 --- /dev/null +++ b/stm32/COLDCARD_MK4/nmi.c @@ -0,0 +1,13 @@ +// +// (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC. +// +// nmi.c - handle a NMI errors (at least the flash sources for them) and try to recover. +// +#include "py/mphal.h" + +// replace stub from stm32_it.c +void NMI_Handler(void) { + printf("NMI\n"); +} + +// EOF From 89405e819a3edfc5d08e185630f62caf9a96b3a4 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 09:51:02 -0400 Subject: [PATCH 05/15] mistake --- stm32/COLDCARD_MK4/nmi.c | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 stm32/COLDCARD_MK4/nmi.c diff --git a/stm32/COLDCARD_MK4/nmi.c b/stm32/COLDCARD_MK4/nmi.c deleted file mode 100644 index cf45fcd5..00000000 --- a/stm32/COLDCARD_MK4/nmi.c +++ /dev/null @@ -1,13 +0,0 @@ -// -// (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC. -// -// nmi.c - handle a NMI errors (at least the flash sources for them) and try to recover. -// -#include "py/mphal.h" - -// replace stub from stm32_it.c -void NMI_Handler(void) { - printf("NMI\n"); -} - -// EOF From a798e96de03c2ca8e2b818df7680104c296757f6 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 4 Jul 2024 14:55:04 +0200 Subject: [PATCH 06/15] ui: newline in visualize bip21 --- shared/ux_q1.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/ux_q1.py b/shared/ux_q1.py index 9cde5fb3..dab48b45 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -1061,8 +1061,9 @@ async def ux_visualize_bip21(proto, addr, args): if args: msg += 'And values for: ' + ', '.join(args) + msg += "\n" - msg += 'Press (1) to verify ownership.' + msg += '\nPress (1) to verify ownership.' ch = await ux_show_story(msg, title="Payment Address", escape='1') From d2920d1c6003b5ecaefc9881f7521ea782094d76 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Fri, 7 Jun 2024 17:50:15 +0200 Subject: [PATCH 07/15] taproot singlesig --- docs/limitations.md | 3 +- docs/taproot.md | 65 ++ external/ckcc-protocol | 2 +- external/libngu | 2 +- shared/actions.py | 21 +- shared/address_explorer.py | 12 +- shared/auth.py | 6 +- shared/chains.py | 55 +- shared/descriptor.py | 10 +- shared/export.py | 26 +- shared/paper.py | 66 +- shared/psbt.py | 796 ++++++++++++++---- shared/serializations.py | 8 +- shared/utils.py | 35 +- shared/wallet.py | 10 +- testing/bip32.py | 47 +- testing/conftest.py | 53 +- testing/constants.py | 7 +- testing/data/taproot/in_internal_key_len.psbt | Bin 0 -> 207 bytes testing/data/taproot/in_key_pth_sig_len.psbt | 1 + testing/data/taproot/in_key_pth_sig_len1.psbt | 1 + .../data/taproot/in_leaf_script_cb_len.psbt | 1 + .../data/taproot/in_leaf_script_cb_len1.psbt | 1 + .../data/taproot/in_script_sig_key_len.psbt | 1 + .../data/taproot/in_script_sig_sig_len.psbt | 1 + .../data/taproot/in_script_sig_sig_len1.psbt | 1 + testing/data/taproot/in_tr_deriv_key_len.psbt | 1 + testing/helpers.py | 8 +- testing/psbt.py | 52 +- testing/test_addr.py | 96 ++- testing/test_address_explorer.py | 148 +++- testing/test_export.py | 45 +- testing/test_hsm.py | 5 +- testing/test_multisig.py | 3 +- testing/test_ownership.py | 75 +- testing/test_paper.py | 97 ++- testing/test_sign.py | 408 +++++++-- testing/test_unit.py | 8 +- testing/txn.py | 50 +- 39 files changed, 1756 insertions(+), 471 deletions(-) create mode 100644 docs/taproot.md create mode 100644 testing/data/taproot/in_internal_key_len.psbt create mode 100644 testing/data/taproot/in_key_pth_sig_len.psbt create mode 100644 testing/data/taproot/in_key_pth_sig_len1.psbt create mode 100644 testing/data/taproot/in_leaf_script_cb_len.psbt create mode 100644 testing/data/taproot/in_leaf_script_cb_len1.psbt create mode 100644 testing/data/taproot/in_script_sig_key_len.psbt create mode 100644 testing/data/taproot/in_script_sig_sig_len.psbt create mode 100644 testing/data/taproot/in_script_sig_sig_len1.psbt create mode 100644 testing/data/taproot/in_tr_deriv_key_len.psbt diff --git a/docs/limitations.md b/docs/limitations.md index 37fa2c30..ead97f74 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -122,7 +122,8 @@ We will summarize transaction outputs as "change" back into same wallet, however - `p2wsh-p2sh`: _redeemScript_ (which is: `0x00 + 0x20 + sha256(witnessScript)`), and _witnessScript_ (which contains the multisig script) - `p2wsh`: only _witnessScript_ (which contains the actual multisig script) - + - `p2tr`(keypath singlesig): no _redeemScript_, no _witnessScript_ and output key MUST commit to an unspendable script path as follows `Q = P + int(hashTapTweak(bytes(P)))G` + - `p2tr`(scriptpath multisig): _taproot_merkle_root_ and _leaf_script_ more info in docs/taproot.md # Derivation Paths diff --git a/docs/taproot.md b/docs/taproot.md new file mode 100644 index 00000000..b45841fa --- /dev/null +++ b/docs/taproot.md @@ -0,0 +1,65 @@ +# Taproot + +**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 Tapscript ([BIP-0342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki)) support. + +## Output script (a.k.a address) generation + +If the spending conditions do not require a script path, the output key MUST commit to an unspendable script path. +`Q = P + int(hashTapTweak(bytes(P)))G` a.k.a internal key MUST be tweaked by `TapTweak` tagged hash of itself. If +the spending conditions require script path, internal key MUST be tweaked by `TapTweak` tagged hash of tree merkle root. + +Addresses in `Address Explorer` for `p2tr` are generated with above-mentioned methods. Outputs `scriptPubkeys` in PSBT +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 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 + +There are few methods to provide/generate provably unspendable internal key, if users wish to only use script path +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(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. + + `tr(r=@, sortedmulti_a(MofN))` + +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(2,@0,@1))` + + +## Limitations + +### Tapscript Limitations + +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. + +### PSBT Requirements + +PSBT provider MUST provide following Taproot specific input fields in PSBT: +1. `PSBT_IN_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes. +2. `PSBT_IN_TAP_INTERNAL_KEY` MUST match internal key provided in `PSBT_IN_TAP_BIP32_DERIVATION` +3. `PSBT_IN_TAP_MERKLE_ROOT` MUST be empty if there is no script path. Otherwise it MUST match what Coldcard can calculate from registered descriptor. +4. `PSBT_IN_TAP_LEAF_SCRIPT` MUST be specified if there is a script path. Currently MUST be of length 1 (only one script allowed) + +PSBT provider MUST provide following Taproot specific output fields in PSBT: +1. `PSBT_OUT_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes. +2. `PSBT_OUT_TAP_INTERNAL_KEY` must match internal key provided in `PSBT_OUT_TAP_BIP32_DERIVATION` +3. `PSBT_OUT_TAP_TREE` with depth, leaf version and script defined. Currently only one script is allowed. \ No newline at end of file diff --git a/external/ckcc-protocol b/external/ckcc-protocol index a6d901f9..cad2722d 160000 --- a/external/ckcc-protocol +++ b/external/ckcc-protocol @@ -1 +1 @@ -Subproject commit a6d901f9fca50755835eca895586ca74d0ca81ed +Subproject commit cad2722d1433dbb956e4457eac9ea01e1c77abbe diff --git a/external/libngu b/external/libngu index 356b9137..2537f158 160000 --- a/external/libngu +++ b/external/libngu @@ -1 +1 @@ -Subproject commit 356b9137cf7ddf5de66ec4cdc0a4d757b2e42790 +Subproject commit 2537f1581d0bad2c16fa391bc6fade328450a217 diff --git a/shared/actions.py b/shared/actions.py index d4bbb861..3f0d0927 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -16,7 +16,7 @@ from export import make_json_wallet, make_summary_file, make_descriptor_wallet_e from export import make_bitcoin_core_wallet, generate_wasabi_wallet, generate_generic_export from export import generate_unchained_export, generate_electrum_wallet from files import CardSlot, CardMissingError, needs_microsd -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from glob import settings from pincodes import pa from menu import start_chooser, MenuSystem, MenuItem @@ -1014,7 +1014,7 @@ async def export_xpub(label, _2, item): path = "m" addr_fmt = AF_CLASSIC else: - remap = {44:0, 49:1, 84:2}[mode] + remap = {44:0, 49:1, 84:2,86:3}[mode] _, path, addr_fmt = chains.CommonDerivations[remap] path = path.format(account='{acct}', coin_type=chain.b44_cointype, change=0, idx=0)[:-4] @@ -1095,7 +1095,7 @@ def ss_descriptor_export_story(addition="", background="", acct=True): async def ss_descriptor_skeleton(_0, _1, item): # Export of descriptor data (wallet) int_ext, addition, f_pattern = None, "", "descriptor.txt" - allowed_af = [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH] + allowed_af = [AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2TR] if item.arg: int_ext, allowed_af, ll, f_pattern = item.arg addition = " for " + ll @@ -1572,9 +1572,18 @@ async def file_picker(suffix=None, min_size=1, max_size=1000000, taster=None, # ignore subdirs continue - if suffix and not fn.lower().endswith(suffix): - # wrong suffix - continue + if suffix: + if isinstance(suffix, list): + for sfx in suffix: + if fn.lower().endswith(sfx): + break + else: + # wrong suffix + continue + else: + if not fn.lower().endswith(suffix): + # wrong suffix + continue if fn[0] == '.': continue diff --git a/shared/address_explorer.py b/shared/address_explorer.py index c5a8570d..b523a3d8 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -8,7 +8,7 @@ import chains, stash, version from ux import ux_show_story, the_ux, ux_enter_bip32_index from ux import export_prompt_builder, import_export_prompt_decode from menu import MenuSystem, MenuItem -from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from multisig import MultisigWallet from uasyncio import sleep_ms from uhashlib import sha256 @@ -41,6 +41,7 @@ class KeypathMenu(MenuSystem): MenuItem("m/44h/⋯", f=self.deeper), MenuItem("m/49h/⋯", f=self.deeper), MenuItem("m/84h/⋯", f=self.deeper), + MenuItem("m/86h/⋯", f=self.deeper), MenuItem("m/0/{idx}", menu=self.done), MenuItem("m/{idx}", menu=self.done), MenuItem("m", f=self.done), @@ -67,7 +68,7 @@ class KeypathMenu(MenuSystem): pl = p[0:p.rfind('/')].rfind('/') else: self.prefix = p # displayed on mk4 only - pl = len(p)-2 + pl = len(p)-2 for mi in items: mi.arg = mi.label mi.label = '⋯'+mi.label[pl:] @@ -112,9 +113,8 @@ class PickAddrFmtMenu(MenuSystem): def __init__(self, path, parent): self.parent = parent items = [ - MenuItem(addr_fmt_label(AF_CLASSIC), f=self.done, arg=(path, AF_CLASSIC)), - MenuItem(addr_fmt_label(AF_P2WPKH), f=self.done, arg=(path, AF_P2WPKH)), - MenuItem(addr_fmt_label(AF_P2WPKH_P2SH), f=self.done, arg=(path, AF_P2WPKH_P2SH)), + MenuItem(addr_fmt_label(af), f=self.done, arg=(path, af)) + for af in [AF_CLASSIC, AF_P2WPKH, AF_P2TR, AF_P2WPKH_P2SH] ] super().__init__(items) if path.startswith("m/84h"): @@ -494,7 +494,7 @@ async def make_address_summary_file(path, addr_fmt, ms_wallet, account_num, dis.progress_sofar(idx, count or 1) sig_nice = None - if not ms_wallet: + if not ms_wallet and addr_fmt != AF_P2TR: derive = path.format(account=account_num, change=change, idx=start) # first addr sig_nice = write_sig_file([(h.digest(), fname)], derive, addr_fmt) diff --git a/shared/auth.py b/shared/auth.py index 221a7443..07f9e96f 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -8,7 +8,7 @@ from ubinascii import b2a_base64, a2b_base64 from ubinascii import hexlify as b2a_hex from ubinascii import unhexlify as a2b_hex from uhashlib import sha256 -from public_constants import MSG_SIGNING_MAX_LENGTH, SUPPORTED_ADDR_FORMATS +from public_constants import MSG_SIGNING_MAX_LENGTH, SUPPORTED_ADDR_FORMATS, AF_P2TR from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, AF_P2WPKH, AF_P2WPKH_P2SH from public_constants import STXN_FLAGS_MASK, STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED from sffile import SFFile @@ -310,6 +310,10 @@ class ApproveMessageSign(UserAuthorizedAction): self.addr_fmt = parse_addr_fmt_str(addr_fmt) self.approved_cb = approved_cb + # temporary - no p2tr support + if self.addr_fmt == AF_P2TR: + raise ValueError("Unsupported address format: 'p2tr'") + from glob import dis dis.fullscreen('Wait...') diff --git a/shared/chains.py b/shared/chains.py index 86a9fbf7..d9c34aea 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -8,6 +8,8 @@ from ubinascii import hexlify as b2a_hex from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR from public_constants import AF_P2SH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH from public_constants import AFC_PUBKEY, AFC_SEGWIT, AFC_BECH32, AFC_SCRIPT +from public_constants import TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK +from serializations import hash160, ser_compact_size, disassemble, ser_string from serializations import hash160, ser_compact_size, disassemble from ucollections import namedtuple from opcodes import OP_RETURN, OP_1, OP_16 @@ -26,6 +28,27 @@ Slip132Version = namedtuple('Slip132Version', ('pub', 'priv', 'hint')) # - from # - also electrum source: electrum/lib/constants.py +def taptweak(internal_key, tweak=None): + # 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." + actual_tweak = internal_key if tweak is None else internal_key + tweak + tweak = ngu.secp256k1.tagged_sha256(b"TapTweak", actual_tweak) + xo_pubkey = ngu.secp256k1.xonly_pubkey(internal_key) + xo_pubkey_tweaked = xo_pubkey.tweak_add(tweak) + return xo_pubkey_tweaked.to_bytes() + +def tapscript_serialize(script, leaf_version=TAPROOT_LEAF_TAPSCRIPT): + # leaf version is only 7 msb + lv = leaf_version % TAPROOT_LEAF_MASK + 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: curve = 'secp256k1' @@ -110,23 +133,30 @@ class ChainsBase: # - works only with single-key addresses assert not addr_fmt & AFC_SCRIPT - keyhash = ngu.hash.hash160(pubkey) - if addr_fmt == AF_CLASSIC: - script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC' - elif addr_fmt == AF_P2WPKH_P2SH: - redeem_script = b'\x00\x14' + keyhash - scripthash = ngu.hash.hash160(redeem_script) - script = b'\xA9\x14' + scripthash + b'\x87' - elif addr_fmt == AF_P2WPKH: - script = b'\x00\x14' + keyhash + if addr_fmt == AF_P2TR: + assert len(pubkey) == 32 # internal + script = b'\x51\x20' + taptweak(pubkey) else: - raise ValueError('bad address template: %s' % addr_fmt) + keyhash = ngu.hash.hash160(pubkey) + if addr_fmt == AF_CLASSIC: + script = b'\x76\xA9\x14' + keyhash + b'\x88\xAC' + elif addr_fmt == AF_P2WPKH_P2SH: + redeem_script = b'\x00\x14' + keyhash + scripthash = ngu.hash.hash160(redeem_script) + script = b'\xA9\x14' + scripthash + b'\x87' + elif addr_fmt == AF_P2WPKH: + script = b'\x00\x14' + keyhash + else: + raise ValueError('bad address template: %s' % addr_fmt) return cls.render_address(script) @classmethod def address(cls, node, addr_fmt): # return a human-readable, properly formatted address + if addr_fmt == AF_P2TR: + xo_pk = node.pubkey()[1:] + return ngu.codecs.segwit_encode(cls.bech32_hrp, 1, taptweak(xo_pk)) if addr_fmt == AF_CLASSIC: # olde fashioned P2PKH @@ -295,6 +325,7 @@ class BitcoinMain(ChainsBase): AF_P2WPKH: Slip132Version(0x04b24746, 0x04b2430c, 'z'), AF_P2WSH_P2SH: Slip132Version(0x0295b43f, 0x0295b005, 'Y'), AF_P2WSH: Slip132Version(0x02aa7ed3, 0x02aa7a99, 'Z'), + AF_P2TR: Slip132Version(0x0488B21E, 0x0488ADE4, 'x'), } bech32_hrp = 'bc' @@ -316,6 +347,7 @@ class BitcoinTestnet(BitcoinMain): AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'), AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'), AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'), + AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'), } bech32_hrp = 'tb' @@ -338,6 +370,7 @@ class BitcoinRegtest(BitcoinMain): AF_P2WPKH: Slip132Version(0x045f1cf6, 0x045f18bc, 'v'), AF_P2WSH_P2SH: Slip132Version(0x024289ef, 0x024285b5, 'U'), AF_P2WSH: Slip132Version(0x02575483, 0x02575048, 'V'), + AF_P2TR: Slip132Version(0x043587cf, 0x04358394, 't'), } bech32_hrp = 'bcrt' @@ -399,6 +432,8 @@ CommonDerivations = [ AF_P2WPKH_P2SH ), # generates 3xxx/2xxx p2sh-looking addresses ( 'BIP-84 (Native Segwit P2WPKH)', "m/84h/{coin_type}h/{account}h/{change}/{idx}", AF_P2WPKH ), # generates bc1 bech32 addresses + ('BIP-86 (Taproot Segwit P2TR)', "m/86h/{coin_type}h/{account}h/{change}/{idx}", + AF_P2TR), # generates bc1p bech32m addresses ] diff --git a/shared/descriptor.py b/shared/descriptor.py index c76dbff1..c6bb136a 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -4,7 +4,7 @@ # # 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 +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)", @@ -19,6 +19,7 @@ MULTI_FMT_TO_SCRIPT = { } SINGLE_FMT_TO_SCRIPT = { + AF_P2TR: "tr(%s)", AF_P2WPKH: "wpkh(%s)", AF_CLASSIC: "pkh(%s)", AF_P2WPKH_P2SH: "sh(wpkh(%s))", @@ -227,6 +228,11 @@ class Descriptor: tmp_desc = desc.replace("wpkh(", "") tmp_desc = tmp_desc.rstrip(")") + elif desc.startswith("tr("): + addr_fmt = AF_P2TR + tmp_desc = desc.replace("tr(", "") + tmp_desc = tmp_desc.rstrip(")") + # wrapped segwit elif desc.startswith("sh(wpkh("): addr_fmt = AF_P2WPKH_P2SH @@ -234,7 +240,7 @@ class Descriptor: tmp_desc = tmp_desc.rstrip("))") else: - raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh(.") + raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh( and tr(.") koi, key = cls.parse_key_orig_info(tmp_desc) if key[0:4] not in ["tpub", "xpub"]: diff --git a/shared/export.py b/shared/export.py index b7d861f9..a48c6faa 100644 --- a/shared/export.py +++ b/shared/export.py @@ -9,7 +9,7 @@ from utils import xfp2str, swab32, chunk_writer from ux import ux_show_story from glob import settings from auth import write_sig_file -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, AF_P2TR from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR from ownership import OWNERSHIP @@ -103,7 +103,7 @@ be needed for different systems. node = sv.derive_path(hard_sub, register=False) yield ("%s => %s\n" % (hard_sub, chain.serialize_public(node))) - if show_slip132 and addr_fmt != AF_CLASSIC and (addr_fmt in chain.slip132): + if show_slip132 and addr_fmt not in (AF_CLASSIC, AF_P2TR) and (addr_fmt in chain.slip132): yield ("%s => %s ##SLIP-132##\n" % ( hard_sub, chain.serialize_public(node, addr_fmt))) @@ -160,8 +160,10 @@ async def write_text_file(fname_pattern, body, title, derive, addr_fmt): with open(fname, 'wb') as fd: chunk_writer(fd, body) - h = ngu.hash.sha256s(body.encode()) - sig_nice = write_sig_file([(h, fname)], derive, addr_fmt) + sig_nice = None + if addr_fmt != AF_P2TR: + h = ngu.hash.sha256s(body.encode()) + sig_nice = write_sig_file([(h, fname)], derive, addr_fmt) except CardMissingError: await needs_microsd() @@ -170,8 +172,9 @@ async def write_text_file(fname_pattern, body, title, derive, addr_fmt): await ux_show_story('Failed to write!\n\n\n'+str(e)) return - msg = '%s file written:\n\n%s\n\n%s signature file written:\n\n%s' % (title, nice, title, - sig_nice) + msg = '%s file written:\n\n%s' % (title, nice) + if sig_nice: + msg += '\n\n%s signature file written:\n\n%s' % (title, sig_nice) await ux_show_story(msg) async def make_summary_file(fname_pattern='public.txt'): @@ -364,8 +367,10 @@ def generate_generic_export(account_num=0): ( 'bip44', "m/44h/{ct}h/{acc}h", AF_CLASSIC, 'p2pkh', False ), ( 'bip49', "m/49h/{ct}h/{acc}h", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False ), # was "p2wpkh-p2sh" ( 'bip84', "m/84h/{ct}h/{acc}h", AF_P2WPKH, 'p2wpkh', False ), + ('bip86', "m/86h/{ct}h/{acc}h", AF_P2TR, 'p2tr', False), ( 'bip48_1', "m/48h/{ct}h/{acc}h/1h", AF_P2WSH_P2SH, 'p2sh-p2wsh', True ), ( 'bip48_2', "m/48h/{ct}h/{acc}h/2h", AF_P2WSH, 'p2wsh', True ), + ('bip48_3', "m/48h/{ct}h/{acc}h/3h", AF_P2TR, 'p2tr', True ), ( 'bip45', "m/45h", AF_P2SH, 'p2sh', True ), ]: if fmt == AF_P2SH and account_num: @@ -375,7 +380,7 @@ def generate_generic_export(account_num=0): node = sv.derive_path(dd) xfp = xfp2str(swab32(node.my_fp())) xp = chain.serialize_public(node, AF_CLASSIC) - zp = chain.serialize_public(node, fmt) if fmt != AF_CLASSIC else None + zp = chain.serialize_public(node, fmt) if fmt not in (AF_CLASSIC, AF_P2TR) else None if is_ms: desc = multisig_descriptor_template(xp, dd, master_xfp_str, fmt) else: @@ -520,13 +525,16 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int mode = 84 elif addr_type == AF_P2WPKH_P2SH: mode = 49 + elif addr_type == AF_P2TR: + mode = 86 else: raise ValueError(addr_type) OWNERSHIP.note_wallet_used(addr_type, account_num) - derive = "m/{mode}h/{coin_type}h/{account}h".format(mode=mode, - account=account_num, coin_type=chain.b44_cointype) + derive = "m/{mode}h/{coin_type}h/{account}h".format( + mode=mode, 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) diff --git a/shared/paper.py b/shared/paper.py index 952b667f..8358827a 100644 --- a/shared/paper.py +++ b/shared/paper.py @@ -3,14 +3,15 @@ # # paper.py - generate paper wallets, based on random values (not linked to wallet) # -import ujson +import ujson, ngu, chains from ubinascii import hexlify as b2a_hex from utils import imported -from public_constants import AF_CLASSIC, AF_P2WPKH +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2TR from ux import ux_show_story, ux_dramatic_pause from files import CardSlot, CardMissingError, needs_microsd from actions import file_picker from menu import MenuSystem, MenuItem +from stash import blank_object background_msg = '''\ Coldcard will pick a random private key (which has no relation to your seed words), \ @@ -29,10 +30,6 @@ can still be made. Visit the Coldcard website to get some interesting templates. SECP256K1_ORDER = b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xba\xae\xdc\xe6\xaf\x48\xa0\x3b\xbf\xd2\x5e\x8c\xd0\x36\x41\x41" -# Aprox. time of this feature release (Nov 20/2019) so no need to scan -# blockchain earlier than this during "importmulti" -FEATURE_RELEASE_TIME = const(1574277000) - # These very-specific text values are matched on the Coldcard; cannot be changed. class placeholders: addr = b'ADDRESS_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX' # 37 long @@ -51,6 +48,12 @@ class PaperWalletMaker: self.my_menu = my_menu self.template_fn = None self.is_segwit = False + self.is_taproot = False + + def atype(self): + if self.is_taproot: return 2, 'Taproot P2TR' + if self.is_segwit: return 1, 'Segwit P2WPKH' + return 0, 'Classic P2PKH' async def pick_template(self, *a): fn = await file_picker(suffix='.pdf', min_size=20000, taster=template_taster, @@ -62,17 +65,17 @@ class PaperWalletMaker: def addr_format_chooser(self, *a): # simple bool choice def set(idx, text): - self.is_segwit = bool(idx) + self.is_segwit = idx == 1 + self.is_taproot = idx == 2 self.update_menu() - return int(self.is_segwit), ['Classic P2PKH', 'Segwit P2WPKH'], set + return self.atype()[0], ['Classic P2PKH', 'Segwit P2WPKH', 'Taproot P2TR'], set def update_menu(self): # Reconstruct the menu contents based on our state. self.my_menu.replace_items([ MenuItem("Don't make PDF" if not self.template_fn else 'Making PDF', f=self.pick_template), - MenuItem('Classic P2PKH' if not self.is_segwit else 'Segwit P2WPKH', - chooser=self.addr_format_chooser), + MenuItem(self.atype()[1], chooser=self.addr_format_chooser), MenuItem('Use Dice', f=self.use_dice), MenuItem('GENERATE WALLET', f=self.doit), ], keep_position=True) @@ -82,12 +85,6 @@ class PaperWalletMaker: from glob import dis, VD try: - import ngu - from auth import write_sig_file - from chains import current_chain - from serializations import hash160 - from stash import blank_object - if not have_key: # get some random bytes await ux_dramatic_pause("Picking key...", 2) @@ -104,12 +101,16 @@ class PaperWalletMaker: dis.fullscreen("Rendering...") # make payment address - digest = hash160(pubkey) - ch = current_chain() + ch = chains.current_chain() if self.is_segwit: - addr = ngu.codecs.segwit_encode(ch.bech32_hrp, 0, digest) + af = AF_P2WPKH + elif self.is_taproot: + af = AF_P2TR + pubkey = pubkey[1:] else: - addr = ngu.codecs.b58_encode(ch.b58_addr + digest) + af = AF_CLASSIC + + addr = ch.pubkey_to_address(pubkey, af) wif = ngu.codecs.b58_encode(ch.b58_privkey + privkey + b'\x01') @@ -164,8 +165,11 @@ class PaperWalletMaker: else: nice_pdf = '' - nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename, - addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC) + nice_sig = None + if af != AF_P2TR: + from auth import write_sig_file + nice_sig = write_sig_file(sig_cont, pk=privkey, sig_name=basename, + addr_fmt=AF_P2WPKH if self.is_segwit else AF_CLASSIC) # Half-hearted attempt to cleanup secrets-contaminated memory # - better would be force user to reboot @@ -185,7 +189,8 @@ class PaperWalletMaker: story = "Done! Created file(s):\n\n%s" % nice_txt if nice_pdf: story += "\n\n%s" % nice_pdf - story += "\n\n%s" % nice_sig + if nice_sig: + story += "\n\n%s" % nice_sig await ux_show_story(story) async def use_dice(self, *a): @@ -214,10 +219,17 @@ class PaperWalletMaker: fp.write('Bitcoin Core command:\n\n') # new hotness: output descriptors - desc = ('wpkh(%s)' if self.is_segwit else 'pkh(%s)') % wif - multi = ujson.dumps(dict(timestamp=FEATURE_RELEASE_TIME, desc=append_checksum(desc))) - fp.write(" bitcoin-cli importmulti '[%s]'\n\n" % multi) - fp.write('# OR (more compatible, but slower)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif) + if self.is_taproot: + desc = 'tr(%s)' + elif self.is_segwit: + desc = 'wpkh(%s)' + else: + desc = 'pkh(%s)' + desc = desc % wif + descriptor = ujson.dumps(dict(timestamp="now", desc=append_checksum(desc))) + fp.write(" bitcoin-cli importdescriptors '[%s]'\n\n" % descriptor) + if not self.is_taproot: + fp.write('# OR (only supported with legacy wallets)\n\n bitcoin-cli importprivkey "%s"\n\n' % wif) if qr_addr and qr_wif: fp.write('\n\n--- QR Codes --- (requires UTF-8, unicode, white background)\n\n\n\n') diff --git a/shared/psbt.py b/shared/psbt.py index aed0e705..903b4918 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -4,19 +4,20 @@ # from ustruct import unpack_from, unpack, pack from ubinascii import hexlify as b2a_hex -from utils import xfp2str, B2A, keypath_to_str, problem_file_line +from utils import xfp2str, B2A, keypath_to_str, validate_derivation_path_length from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str import stash, gc, history, sys, ngu, ckcc, chains from uhashlib import sha256 from uio import BytesIO from sffile import SizerFile -from multisig import MultisigWallet, disassemble_multisig, disassemble_multisig_mn +from chains import taptweak, tapleaf_hash +from multisig import MultisigWallet, disassemble_multisig_mn 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, COutPoint +from serializations import ser_compact_size, deser_compact_size, hash160 +from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint from serializations import ser_sig_der, uint256_from_str, ser_push_data from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY -from serializations import ALL_SIGHASH_FLAGS +from serializations import ALL_SIGHASH_FLAGS, SIGHASH_DEFAULT from glob import settings from public_constants import ( @@ -24,13 +25,19 @@ from public_constants import ( PSBT_IN_PARTIAL_SIG, PSBT_IN_SIGHASH_TYPE, PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_BIP32_DERIVATION, PSBT_IN_FINAL_SCRIPTSIG, PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT, - PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_SCRIPT, PSBT_OUT_AMOUNT, PSBT_GLOBAL_VERSION, + PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_TAP_BIP32_DERIVATION, PSBT_OUT_TAP_INTERNAL_KEY, + PSBT_IN_TAP_BIP32_DERIVATION, PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_TAP_KEY_SIG, PSBT_OUT_TAP_TREE, + PSBT_IN_TAP_MERKLE_ROOT, PSBT_IN_TAP_LEAF_SCRIPT, PSBT_IN_TAP_SCRIPT_SIG, + TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK, + PSBT_OUT_SCRIPT, PSBT_OUT_AMOUNT, PSBT_GLOBAL_VERSION, PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_INPUT_COUNT, PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS ) +psbt_tmp256 = bytearray(256) + # PSBT proprietary keytype PSBT_PROPRIETARY = const(0xFC) @@ -239,10 +246,21 @@ class psbtProxy: elif isinstance(val, list): # for subpaths lists (LE32 ints) - assert ktype in (PSBT_IN_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION) - out_fd.write(ser_compact_size(len(val) * 4)) - for i in val: - out_fd.write(pack(' [xfp, *path] - # - will be single entry for non-p2sh ins and outs - - if not self.subpaths: + def parse_taproot_subpaths(self, my_xfp, warnings): + if not self.taproot_subpaths: return 0 - if self.num_our_keys != None: - # already been here once - return self.num_our_keys + num_ours = 0 + for xonly_pk in self.taproot_subpaths: + assert len(xonly_pk) == 32 # "PSBT_IN_TAP_BIP32_DERIVATION xonly-pubkey length != 32" + + pos, length = self.taproot_subpaths[xonly_pk] + end_pos = pos + length + self.fd.seek(pos) + leaf_hash_len = deser_compact_size(self.fd) + leaf_hashes = [] + for _ in range(leaf_hash_len): + leaf_hashes.append(self.fd.read(32)) + + curr_pos = self.fd.tell() + to_read = end_pos - curr_pos + # internal key is allowed to go from master + # unspendable path can be just a bare xonly pubkey + allow_master = True if not leaf_hashes else False + validate_derivation_path_length(to_read, allow_master=allow_master) + v = self.fd.read(to_read) + here = list(unpack_from('<%dI' % (to_read // 4), v)) + # Tricky & Useful: if xfp of zero is observed in file, assume that's a + # placeholder for my XFP value. Replace on the fly. Great when master + # XFP is unknown because PSBT built from derived XPUB only. Also privacy. + if here[0] == 0: + here[0] = my_xfp + if not any(True for k, _ in warnings if 'XFP' in k): + warnings.append(('Zero XFP', + 'Assuming XFP of zero should be replaced by correct XFP')) + # update in place + self.taproot_subpaths[xonly_pk] = [leaf_hashes] + here + if here[0] == my_xfp: + num_ours += 1 + + return num_ours + + def parse_non_taproot_subpaths(self, my_xfp, warnings): + if not self.subpaths: + return 0 num_ours = 0 for pk in self.subpaths: @@ -274,28 +321,21 @@ class psbtProxy: assert pk[0] in {0x02, 0x03}, "uncompressed pubkey" vl = self.subpaths[pk][1] - - # force them to use a derived key, never the master - assert vl >= 8, 'too short key path' - assert (vl % 4) == 0, 'corrupt key path' - assert (vl//4) <= MAX_PATH_DEPTH, 'too deep' - + validate_derivation_path_length(vl) # promote to a list of ints v = self.get(self.subpaths[pk]) here = list(unpack_from('<%dI' % (vl//4), v)) - - # Tricky & Useful: if xfp of zero is observed in file, assume that's a + # Tricky & Useful: if xfp of zero is observed in file, assume that's a # placeholder for my XFP value. Replace on the fly. Great when master # XFP is unknown because PSBT built from derived XPUB only. Also privacy. if here[0] == 0: here[0] = my_xfp if not any(True for k,_ in warnings if 'XFP' in k): warnings.append(('Zero XFP', - 'Assuming XFP of zero should be replaced by correct XFP')) + 'Assuming XFP of zero should be replaced by correct XFP')) # update in place self.subpaths[pk] = here - if here[0] == my_xfp: num_ours += 1 else: @@ -303,24 +343,43 @@ class psbtProxy: # or an input we're not supposed to be able to sign... and that's okay. pass - self.num_our_keys = num_ours return num_ours + def parse_subpaths(self, my_xfp, warnings): + # Reformat self.subpaths and self.taproot_subpaths into a more useful form for us; return # of them + # that are ours (and track that as self.num_our_keys) + # - works in-place, on self.subpaths and self.taproot_subpaths + # - creates dictionary: pubkey => [xfp, *path] (self.subpaths) + # - creates dictionary: pubkey => [leaf_hash_list, xfp, *path] (self.taproot_subpaths) + # - will be single entry for non-p2sh ins and outs + if self.num_our_keys != None: + # already been here once + return self.num_our_keys + + num_our = self.parse_non_taproot_subpaths(my_xfp, warnings) + num_our_taproot = self.parse_taproot_subpaths(my_xfp, warnings) + + self.num_our_keys = num_our + num_our_taproot + return self.num_our_keys # Track details of each output of PSBT # class psbtOutputProxy(psbtProxy): - no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT } + no_keys = { PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT, PSBT_OUT_TAP_INTERNAL_KEY, PSBT_OUT_TAP_TREE } blank_flds = ('unknown', 'subpaths', 'redeem_script', 'witness_script', - 'is_change', 'num_our_keys', 'amount', 'script', 'attestation') + 'is_change', 'num_our_keys', 'amount', 'script', 'attestation', + 'taproot_internal_key', 'taproot_subpaths', 'taproot_tree') def __init__(self, fd, idx): super().__init__() # things we track - #self.subpaths = None # a dictionary if non-empty + #self.subpaths = None # a dictionary if non-empty + #self.taproot_subpaths = None # a dictionary if non-empty + #self.taproot_internal_key = None + #self.taproot_tree = None #self.redeem_script = None #self.witness_script = None #self.script = None @@ -331,6 +390,23 @@ class psbtOutputProxy(psbtProxy): self.parse(fd) + def parse_taproot_tree(self): + if not self.taproot_tree: + return + length = self.taproot_tree[1] + + res = [] + while length: + tree = BytesIO(self.get(self.taproot_tree)) + depth = tree.read(1) + leaf_version = tree.read(1)[0] + assert (leaf_version & ~TAPROOT_LEAF_MASK) == 0 + script_len, nb = deser_compact_size(tree, ret_num_bytes=True) + script = tree.read(script_len) + res.append((depth, leaf_version, script)) + length -= (2 + nb + script_len) + + return res def store(self, kt, key, val): # do not forget that key[0] includes kt (type) @@ -354,6 +430,14 @@ class psbtOutputProxy(psbtProxy): # prop key for attestation does not have keydata because the # value is a recoverable signature (already contains pubkey) self.attestation = self.get(val) + elif kt == PSBT_OUT_TAP_INTERNAL_KEY: + self.taproot_internal_key = val + elif kt == PSBT_OUT_TAP_BIP32_DERIVATION: + if not self.taproot_subpaths: + self.taproot_subpaths = {} + self.taproot_subpaths[key[1:]] = val + elif kt == PSBT_OUT_TAP_TREE: + self.taproot_tree = val else: self.unknown = self.unknown or {} if key in self.unknown: @@ -374,6 +458,16 @@ class psbtOutputProxy(psbtProxy): if self.witness_script: wr(PSBT_OUT_WITNESS_SCRIPT, self.witness_script) + if self.taproot_internal_key: + wr(PSBT_OUT_TAP_INTERNAL_KEY, self.taproot_internal_key) + + if self.taproot_subpaths: + for k in self.taproot_subpaths: + wr(PSBT_OUT_TAP_BIP32_DERIVATION, self.taproot_subpaths[k], k) + + if self.taproot_tree: + wr(PSBT_OUT_TAP_TREE, self.taproot_tree) + if is_v2: wr(PSBT_OUT_SCRIPT, self.script) wr(PSBT_OUT_AMOUNT, self.amount) @@ -396,6 +490,9 @@ class psbtOutputProxy(psbtProxy): # - full key derivation and validation is done during signing, and critical. # - we raise fraud alarms, since these are not innocent errors # + if self.taproot_internal_key: + assert self.taproot_internal_key[1] == 32 # "PSBT_OUT_TAP_INTERNAL_KEY length != 32" + num_ours = self.parse_subpaths(my_xfp, parent.warnings) if num_ours == 0: @@ -406,9 +503,11 @@ class psbtOutputProxy(psbtProxy): # - must match expected address for this output, coming from unsigned txn addr_type, addr_or_pubkey, is_segwit = txo.get_address() - if len(self.subpaths) == 1: + if self.subpaths and len(self.subpaths) == 1: # p2pk, p2pkh, p2wpkh cases expect_pubkey, = self.subpaths.keys() + elif self.taproot_subpaths and len(self.taproot_subpaths) == 1: + expect_pubkey, = self.taproot_subpaths.keys() else: # p2wsh/p2sh cases need full set of pubkeys, and therefore redeem script expect_pubkey = None @@ -521,6 +620,11 @@ class psbtOutputProxy(psbtProxy): # input is hash160 of a single public key assert len(addr_or_pubkey) == 20 expect_pkh = hash160(expect_pubkey) + elif addr_type == "p2tr": + if expect_pubkey is None and len(self.taproot_subpaths) > 1: + expect_pkh = None + else: + expect_pkh = taptweak(expect_pubkey) else: # we don't know how to "solve" this type of input return @@ -540,15 +644,18 @@ class psbtInputProxy(psbtProxy): short_values = { PSBT_IN_SIGHASH_TYPE } # only part-sigs have a key to be stored. - no_keys = { PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE, - PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG, - PSBT_IN_FINAL_SCRIPTWITNESS } + no_keys = {PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE, + PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_FINAL_SCRIPTSIG, + PSBT_IN_FINAL_SCRIPTWITNESS,PSBT_IN_TAP_KEY_SIG, + PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_TAP_MERKLE_ROOT} blank_flds = ( 'unknown', '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', 'previous_txid', - 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime' + 'required_key', 'scriptSig', 'amount', 'scriptCode', 'previous_txid', + 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', 'taproot_key_sig', + 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', "use_keypath", "subpaths", + "taproot_subpaths", "taproot_internal_key", "part_sig" ) def __init__(self, fd, idx): @@ -556,9 +663,9 @@ class psbtInputProxy(psbtProxy): #self.utxo = None #self.witness_utxo = None - self.part_sig = {} + # self.part_sig = {} #self.sighash = None - self.subpaths = {} # will typically be non-empty for all inputs + # self.subpaths = {} # will be empty if taproot #self.redeem_script = None #self.witness_script = None @@ -578,8 +685,12 @@ class psbtInputProxy(psbtProxy): #self.amount = None #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 + # self.taproot_key_sig = None # will be empty if non-taproot + # self.taproot_merkle_root = None # will be empty if non-taproot + # self.taproot_script_sigs = None # will be empty if non-taproot + # self.taproot_scripts = None # will be empty if non-taproot #self.previous_txid = None #self.prevout_idx = None @@ -589,6 +700,32 @@ class psbtInputProxy(psbtProxy): self.parse(fd) + def parse_taproot_script_sigs(self): + # not needed at this point as we do not support tapscript + # parsing this field without actual tapscript support is just a waste of memory + parsed_taproot_script_sigs = {} + for key in self.taproot_script_sigs: + assert len(key) == 64 # "PSBT_IN_TAP_SCRIPT_SIG key length != 64" + assert self.taproot_script_sigs[key][1] in (64, 65) # "PSBT_IN_TAP_SCRIPT_SIG signature length != 64 or 65" + xonly, script_hash = key[:32], key[32:] + parsed_taproot_script_sigs[(xonly, script_hash)] = self.get(self.taproot_script_sigs[key]) + self.taproot_script_sigs = parsed_taproot_script_sigs + + def parse_taproot_scripts(self): + # not needed at this point as we do not support tapscript + # parsing this field without actual tapscript support is just a waste of memory + parsed_taproot_scripts = {} + for key in self.taproot_scripts: + assert len(key) > 32 # "PSBT_IN_TAP_LEAF_SCRIPT control block is too short" + assert (len(key) - 1) % 32 == 0 # "PSBT_IN_TAP_LEAF_SCRIPT control block is not valid" + script = self.get(self.taproot_scripts[key]) + assert len(script) != 0 # "PSBT_IN_TAP_LEAF_SCRIPT cannot be empty" + leaf_script = (script[:-1], int(script[-1])) + if leaf_script not in self.taproot_scripts: + parsed_taproot_scripts[leaf_script] = set() + parsed_taproot_scripts[leaf_script].add(key) + self.taproot_scripts = parsed_taproot_scripts + def has_relative_timelock(self, txin): # https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki SEQUENCE_LOCKTIME_DISABLE_FLAG = (1 << 31) @@ -624,6 +761,15 @@ class psbtInputProxy(psbtProxy): if self.redeem_script: assert self.redeem_script[1] >= 22 + if self.taproot_internal_key: + assert self.taproot_internal_key[1] == 32 # "PSBT_IN_TAP_INTERNAL_KEY length != 32" + + if self.taproot_script_sigs: + self.parse_taproot_script_sigs() + + if self.taproot_scripts: + self.parse_taproot_scripts() + # require path for each addr, check some are ours # rework the pubkey => subpath mapping @@ -635,11 +781,19 @@ class psbtInputProxy(psbtProxy): # - seems harmless if they fool us into thinking already signed; we do nothing # - 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)) + self.fully_signed = len(self.part_sig) >= len(self.subpaths) else: # No signatures at all yet for this input (typical non multisig) self.fully_signed = False + if self.taproot_key_sig: + assert self.taproot_key_sig[1] in (64, 65) # "PSBT_IN_TAP_KEY_SIG length != 64 or 65" + if self.taproot_key_sig[1] == 65: + taproot_sig = self.get(self.taproot_key_sig) + if self.sighash: + assert taproot_sig[64] == self.sighash # "PSBT_IN_SIGHASH_TYPE != PSBT_IN_TAP_KEY_SIG[64]" + self.fully_signed = True + if self.utxo: # Important: they might be trying to trick us with an un-related # funding transaction (UTXO) that does not match the input signature we're making @@ -655,7 +809,7 @@ class psbtInputProxy(psbtProxy): def handle_none_sighash(self): if self.sighash is None: - self.sighash = SIGHASH_ALL + self.sighash = SIGHASH_DEFAULT if self.taproot_subpaths else SIGHASH_ALL def has_utxo(self): # do we have a copy of the corresponding UTXO? @@ -713,17 +867,15 @@ class psbtInputProxy(psbtProxy): return utxo - def determine_my_signing_key(self, my_idx, utxo, my_xfp, psbt): # See what it takes to sign this particular input # - type of script # - which pubkey needed # - scriptSig value # - also validates redeem_script when present - self.amount = utxo.nValue - if not self.subpaths or self.fully_signed: + if (not self.subpaths and not self.taproot_subpaths) or self.fully_signed: # without xfp+path we will not be able to sign this input # - okay if fully signed # - okay if payjoin or other multi-signer (not multisig) txn @@ -799,6 +951,22 @@ class psbtInputProxy(psbtProxy): # none of the pubkeys provided hashes to that address raise FatalPSBTIssue('Input #%d: pubkey vs. address wrong' % my_idx) + elif addr_type == 'p2tr': + pubkey = addr_or_pubkey + merkle_root = None if self.taproot_merkle_root is None else self.get(self.taproot_merkle_root) + if len(self.taproot_subpaths) == 1: + # keyspend without a script path + assert merkle_root is None, "merkle_root should not be defined for simple keyspend" + xonly_pubkey, lhs_path = list(self.taproot_subpaths.items())[0] + lhs, path = lhs_path[0], lhs_path[1:] # meh - should be a tuple + assert not lhs, "LeafHashes have to be empty for internal key" + if path[0] == my_xfp: + output_key = taptweak(xonly_pubkey) + if output_key == pubkey: + which_key = xonly_pubkey + else: + which_key = None + elif addr_type == 'p2pk': # input is single public key (less common) self.scriptSig = utxo.scriptPubKey @@ -849,7 +1017,7 @@ class psbtInputProxy(psbtProxy): self.required_key = which_key - if self.is_segwit: + if self.is_segwit and addr_type != 'p2tr': if ('pkh' in addr_type): # This comment from : # @@ -881,8 +1049,12 @@ class psbtInputProxy(psbtProxy): elif kt == PSBT_IN_WITNESS_UTXO: self.witness_utxo = val elif kt == PSBT_IN_PARTIAL_SIG: + if self.part_sig is None: + self.part_sig = {} self.part_sig[key[1:]] = val elif kt == PSBT_IN_BIP32_DERIVATION: + if self.subpaths is None: + self.subpaths = {} self.subpaths[key[1:]] = val elif kt == PSBT_IN_REDEEM_SCRIPT: self.redeem_script = val @@ -890,6 +1062,24 @@ class psbtInputProxy(psbtProxy): self.witness_script = val elif kt == PSBT_IN_SIGHASH_TYPE: self.sighash = unpack(' leaf hashes + if path[1] == my_xfp: + in_paths.append(path[2:]) if not in_paths: # We aren't adding any signatures? Can happen but we're going to be @@ -1600,35 +1817,58 @@ class psbtObject(psbtProxy): idx_max = max(i[-1]&0x7fffffff for i in in_paths) + 200 hard_pattern = hard_bits(in_paths[0]) + def check_output_path(path): + if len(path) != path_len: + iss = "has wrong path length (%d not %d)" % (len(path), path_len) + elif hard_bits(path) != hard_pattern: + iss = "has different hardening pattern" + elif path[0:len(path_prefix)] != path_prefix: + iss = "goes to diff path prefix" + elif (path[-2] & 0x7fffffff) not in {0, 1}: + iss = "2nd last component not 0 or 1" + elif (path[-1] & 0x7fffffff) > idx_max: + iss = "last component beyond reasonable gap" + else: + # looks OK + iss = None + return iss + + def problem_fmt_str(nout, iss, path): + return "Output#%d: %s: %s not %s/{0~1}%s/{0~%d}%s expected" % ( + nout, + iss, + keypath_to_str(path, skip=0), + keypath_to_str(path_prefix, skip=0), + "'" if hard_pattern[-2] else "", + idx_max, + "'" if hard_pattern[-1] else "", + ) + probs = [] for nout, out in enumerate(self.outputs): if not out.is_change: continue # it's a change output, okay if a p2sh change; we're looking at paths - for path in out.subpaths.values(): - if path[0] != my_xfp: continue # possible in p2sh case - - path = path[1:] - if len(path) != path_len: - iss = "has wrong path length (%d not %d)" % (len(path), path_len) - elif hard_bits(path) != hard_pattern: - iss = "has different hardening pattern" - elif path[0:len(path_prefix)] != path_prefix: - iss = "goes to diff path prefix" - elif (path[-2]&0x7fffffff) not in {0, 1}: - iss = "2nd last component not 0 or 1" - elif (path[-1]&0x7fffffff) > idx_max: - iss = "last component beyond reasonable gap" - else: - # looks ok - continue - - probs.append("Output#%d: %s: %s not %s/{0~1}%s/{0~%d}%s expected" - % (nout, iss, keypath_to_str(path, skip=0), - keypath_to_str(path_prefix, skip=0), - "'" if hard_pattern[-2] else "", - idx_max, "'" if hard_pattern[-1] else "", - )) - break + if out.subpaths: + for path in out.subpaths.values(): + if path[0] != my_xfp: + # possible in p2sh case + continue + path = path[1:] + iss = check_output_path(path) + if iss is None: + continue + probs.append(problem_fmt_str(nout, iss, path)) + break + if out.taproot_subpaths: + for path in out.taproot_subpaths.values(): + if path[1] != my_xfp: + continue + path = path[2:] + iss = check_output_path(path) + if iss is None: + continue + probs.append(problem_fmt_str(nout, iss, path)) + break for p in probs: self.warnings.append(('Troublesome Change Outs', p)) @@ -1720,16 +1960,20 @@ class psbtObject(psbtProxy): return self.total_value_in - self.total_value_out def consider_keys(self): - # check we posess the right keys for the inputs + # check we possess the right keys for the inputs cnt = sum(1 for i in self.inputs if i.num_our_keys) if cnt: return # collect a list of XFP's given in file that aren't ours others = set() for inp in self.inputs: - if not inp.subpaths: continue - for path in inp.subpaths.values(): - others.add(path[0]) + if inp.subpaths: + for path in inp.subpaths.values(): + others.add(path[0]) + if inp.taproot_subpaths: + for path in inp.taproot_subpaths.values(): + # xfp is on index 1, on index 0 -> leaf hashes + others.add(path[1]) if not others: # Can happen w/ Electrum in watch-mode on XPUB. It doesn't know XFP and @@ -1845,21 +2089,36 @@ class psbtObject(psbtProxy): oup = self.outputs[out_idx] good = 0 - for pubkey, subpath in oup.subpaths.items(): - if subpath[0] != self.my_xfp: - # for multisig, will be N paths, and exactly one will - # be our key. For single-signer, should always be my XFP - continue - - # derive actual pubkey from private - skp = keypath_to_str(subpath) - node = sv.derive_path(skp) + if oup.subpaths: + for pubkey, subpath in oup.subpaths.items(): + if subpath[0] != self.my_xfp: + # for multisig, will be N paths, and exactly one will + # be our key. For single-signer, should always be my XFP + continue - # check the pubkey of this BIP-32 node - if pubkey == node.pubkey(): - good += 1 + # derive actual pubkey from private + skp = keypath_to_str(subpath) + node = sv.derive_path(skp) - OWNERSHIP.note_subpath_used(subpath) + # check the pubkey of this BIP-32 node + if pubkey == node.pubkey(): + good += 1 + + if oup.taproot_subpaths: + for xonly_pk, val in oup.taproot_subpaths.items(): + leaf_hashes, subpath = val[0], val[1:] + if subpath[0] != self.my_xfp: + # for multisig, will be N paths, and exactly one will + # be our key. For single-signer, should always be my XFP + continue + + # derive actual pubkey from private + skp = keypath_to_str(subpath) + node = sv.derive_path(skp) + + # check the pubkey of this BIP-32 node + if xonly_pk == node.pubkey()[1:]: + good += 1 if not good: raise FraudulentChangeOutput(out_idx, @@ -1891,47 +2150,90 @@ class psbtObject(psbtProxy): continue txi.scriptSig = inp.scriptSig - assert txi.scriptSig, "no scriptsig?" - + schnorrsig = False + tr_sh = [] inp.handle_none_sighash() - if inp.is_multisig: + 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 - skp = keypath_to_str(inp.subpaths[which_key]) + if inp.taproot_subpaths: # this can be set to False even if we haev script ready, but can send keypath + # tapscript + schnorrsig = True + 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: + 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 - else: - raise AssertionError("Input #%d needs pubkey I dont have" % in_idx) + to_sign.append(node) + if len(which_key) == 32 and pu[1:] == which_key: + # get the script + 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(): + 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 which_key in inp.subpaths, 'unk key' + assert not inp.part_sig, "already done??" + assert not inp.taproot_key_sig, "already done taproot??" - if inp.subpaths[which_key][0] != self.my_xfp: + if inp.subpaths and inp.subpaths.get(which_key) and inp.subpaths[which_key][0] == self.my_xfp: + skp = keypath_to_str(inp.subpaths[which_key]) + # get node required + node = sv.derive_path(skp, register=False) + # expensive test, but works... and important + pu = node.pubkey() + elif inp.taproot_subpaths and inp.taproot_subpaths.get(which_key) \ + and inp.taproot_subpaths[which_key][1] == self.my_xfp: + + skp = keypath_to_str(inp.taproot_subpaths[which_key][1:]) # ignore leaf hashes + # get node required + node = sv.derive_path(skp, register=False) + # expensive test, but works... and important + pu = node.pubkey()[1:] + schnorrsig = True + else: # we don't have the key for this subkey # (redundant, required_key wouldn't be set) continue - # get node required - skp = keypath_to_str(inp.subpaths[which_key]) - node = sv.derive_path(skp, register=False) - - # expensive test, but works... and important - pu = node.pubkey() assert pu == which_key, \ "Path (%s) led to wrong pubkey for input#%d"%(skp, in_idx) + to_sign.append(node) + # track wallet usage - OWNERSHIP.note_subpath_used(inp.subpaths[which_key]) + if schnorrsig: + OWNERSHIP.note_subpath_used(inp.taproot_subpaths[which_key]) + else: + OWNERSHIP.note_subpath_used(inp.subpaths[which_key]) if sv.deltamode: # Current user is actually a thug with a slightly wrong PIN, so we @@ -1944,56 +2246,105 @@ class psbtObject(psbtProxy): digest = self.make_txn_sighash(in_idx, txi, inp.sighash) else: # Hash the inputs and such in totally new ways, based on BIP-143 - digest = self.make_txn_segwit_sighash(in_idx, txi, - inp.amount, inp.scriptCode, inp.sighash) + if not inp.taproot_subpaths: + digest = self.make_txn_segwit_sighash(in_idx, txi, inp.amount, inp.scriptCode, inp.sighash) + 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 = {} - #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')) + if not inp.part_sig: + inp.part_sig = {} - # Do the ACTUAL signature ... finally!!! + 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() - # 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() + # print("privkey %s" % b2a_hex(sk).decode('ascii')) + # print(" pubkey %s" % b2a_hex(pk).decode('ascii')) + # print(" digest %s" % b2a_hex(digest).decode('ascii')) - # convert signature to DER format - #assert len(result) == 65 - r = result[1:33] - s = result[33:65] - der_sig = ser_sig_der(r, s, inp.sighash) + # 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 - if len(der_sig) <= 71: - # odds of needing retry: just under 50% I think - break + 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(20): + result = ngu.secp256k1.sign(sk, digest, retry).to_bytes() - # private key no longer required - stash.blank_object(pk) - stash.blank_object(node) - del pk, node, pu, skp + # 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) - inp.added_sig = (which_key, der_sig) + if len(sig) <= 71: + # odds of needing retry: just under 50% I think + break - # 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 + # add to psbt + inp.part_sig[pk] = sig + # memory cleanup + del result, r, s - success.add(in_idx) + # private key no longer required + stash.blank_object(sk) + stash.blank_object(node) + del sk, node - if self.is_v2: - self.set_modifiable_flag(inp) + # 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 - # memory cleanup - del result, r, s + success.add(in_idx) + gc.collect() - gc.collect() + if self.is_v2: + self.set_modifiable_flag(inp) # done. dis.progress_bar_show(1) @@ -2084,6 +2435,111 @@ class psbtObject(psbtProxy): # double SHA256 return ngu.hash.sha256s(rv.digest()) + def make_txn_taproot_sighash(self, input_index, hash_type=SIGHASH_DEFAULT, scriptpath=False, script=None, + codeseparator_pos=-1, annex=None, leaf_ver=TAPROOT_LEAF_TAPSCRIPT): + # BIP-341 + fd = self.fd + old_pos = fd.tell() + + out_type = SIGHASH_ALL if (hash_type == 0) else (hash_type & 3) + in_type = hash_type & SIGHASH_ANYONECANPAY + + if not self.hashValues and in_type != SIGHASH_ANYONECANPAY: + hashPrevouts = sha256() + hashSequence = sha256() + hashValues = sha256() + hashScriptPubKeys = sha256() + # input side + for in_idx, txi in self.input_iter(): + hashPrevouts.update(txi.prevout.serialize()) + hashSequence.update(pack(" @@ -2179,8 +2635,9 @@ class psbtObject(psbtProxy): if inp.is_multisig: # but we can't combine/finalize multisig stuff, so will never't be 'final' 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 return signed == self.num_inputs @@ -2223,10 +2680,11 @@ class psbtObject(psbtProxy): 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) @@ -2253,14 +2711,22 @@ class psbtObject(psbtProxy): for in_idx, wit in self.input_witness_iter(): inp = self.inputs[in_idx] - if inp.is_segwit and inp.added_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' - pubkey, der_sig = inp.added_sig - assert pubkey[0] in {0x02, 0x03} and len(pubkey) == 33, "bad v0 pubkey" - wit.scriptWitness.stack = [der_sig, pubkey] + # TODO tapscript can also be non multisig, we are not able to finalize that - yet + if inp.taproot_key_sig: + # segwit v1 (taproot) + # can be 65 bytes if sighash != SIGHASH_DEFAULT (0x00) + assert len(inp.taproot_key_sig) in (64, 65) + wit.scriptWitness.stack = [inp.taproot_key_sig] + else: + # segwit v0 + 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] fd.write(wit.serialize()) diff --git a/shared/serializations.py b/shared/serializations.py index 33de3454..d89859bb 100755 --- a/shared/serializations.py +++ b/shared/serializations.py @@ -16,7 +16,6 @@ ser_*, deser_*: functions that handle serialization/deserialization """ from ubinascii import hexlify as b2a_hex -from ubinascii import unhexlify as a2b_hex import ustruct as struct import ngu from opcodes import * @@ -30,6 +29,7 @@ hash160 = ngu.hash.hash160 def bytes_to_hex_str(s): return str(b2a_hex(s), 'ascii') +SIGHASH_DEFAULT = const(0) # in taproot meaning same as SIGHASH_ALL (over whole TX) SIGHASH_ALL = const(1) SIGHASH_NONE = const(2) SIGHASH_SINGLE = const(3) @@ -37,6 +37,7 @@ SIGHASH_ANYONECANPAY = const(0x80) # list containing all flags that we support signing for ALL_SIGHASH_FLAGS = [ + SIGHASH_DEFAULT, SIGHASH_ALL, SIGHASH_NONE, SIGHASH_SINGLE, @@ -367,6 +368,11 @@ class CTxOut(object): # aka. P2WPKH return 'p2pkh', self.scriptPubKey[2:2+20], True + if len(self.scriptPubKey) == 34 and \ + self.scriptPubKey[0] == 81 and self.scriptPubKey[1] == 32: + # aka. P2TR + return 'p2tr', self.scriptPubKey[2:2+32], True + if len(self.scriptPubKey) == 34 and \ self.scriptPubKey[0] == 0 and self.scriptPubKey[1] == 32: # aka. P2WSH diff --git a/shared/utils.py b/shared/utils.py index 060b2e40..aa106eba 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -2,12 +2,14 @@ # # utils.py - Misc utils. My favourite kind of source file. # -import gc, sys, ustruct, ngu, chains, ure, time +import gc, sys, ustruct, ngu, chains, ure, time, version, uos, uio from ubinascii import unhexlify as a2b_hex from ubinascii import hexlify as b2a_hex from ubinascii import a2b_base64, b2a_base64 from uhashlib import sha256 -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +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') @@ -91,7 +93,6 @@ def pop_count(i): def get_filesize(fn): # like os.path.getsize() - import uos try: return uos.stat(fn)[6] except OSError: @@ -220,8 +221,6 @@ def to_ascii_printable(s, strip=False): def problem_file_line(exc): # return a string of just the filename.py and line number where # an exception occured. Best used on AssertionError. - import uio, sys, ure - tmp = uio.StringIO() sys.print_exception(exc, tmp) lines = tmp.getvalue().split('\n')[-3:] @@ -251,7 +250,6 @@ def cleanup_deriv_path(bin_path, allow_star=False): # - assume 'm' prefix, so '34' becomes 'm/34', etc # - do not assume /// is m/0/0/0 # - if allow_star, then final position can be * or *h (wildcard) - import ure from public_constants import MAX_PATH_DEPTH s = to_ascii_printable(bin_path, strip=True).lower() @@ -345,6 +343,13 @@ def match_deriv_path(patterns, path): return False +def validate_derivation_path_length(length, allow_master=False): + # force them to use a derived key, never the master + if not allow_master: + assert length >= 4, 'too short key path' + assert (length % 4) == 0, 'corrupt key path' + assert (length // 4) <= MAX_PATH_DEPTH, 'too deep' + class DecodeStreamer: def __init__(self): self.runt = bytearray() @@ -431,7 +436,7 @@ def clean_shutdown(style=0): # wipe SPI flash and shutdown (wiping main memory) # - mk4: SPI flash not used, but NFC may hold data (PSRAM cleared by bootrom) # - bootrom wipes every byte of SRAM, so no need to repeat here - import callgate, version, uasyncio + import callgate, uasyncio # save if anything pending from glob import settings @@ -507,9 +512,7 @@ def word_wrap(ln, w): def parse_addr_fmt_str(addr_fmt): # accepts strings and also integers if already parsed - from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH - - if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC]: + if addr_fmt in [AF_P2WPKH_P2SH, AF_P2WPKH, AF_CLASSIC, AF_P2TR]: return addr_fmt addr_fmt = addr_fmt.lower() @@ -519,9 +522,10 @@ def parse_addr_fmt_str(addr_fmt): return AF_CLASSIC elif addr_fmt == "p2wpkh": return AF_P2WPKH + elif addr_fmt == "p2tr": + return AF_P2TR else: - raise ValueError("Invalid address format: '%s'\n\n" - "Choose from p2pkh, p2wpkh, p2sh-p2wpkh." % addr_fmt) + raise ValueError("Unsupported address format: '%s'" % addr_fmt) def parse_extended_key(ln, private=False): # read an xpub/ypub/etc and return BIP-32 node and what chain it's on. @@ -563,7 +567,10 @@ def addr_fmt_label(addr_fmt): return { AF_CLASSIC: "Classic P2PKH", AF_P2WPKH_P2SH: "P2SH-Segwit", - AF_P2WPKH: "Segwit P2WPKH" + AF_P2WPKH: "Segwit P2WPKH", + AF_P2TR: "Taproot P2TR", + AF_P2WSH: "Segwit P2WSH", + AF_P2WSH_P2SH: "P2SH-P2WSH" }[addr_fmt] @@ -620,8 +627,6 @@ def url_decode(u): # - equiv to urllib.parse.unquote_plus # - ure.sub is missing, so not being clever here. # - give up on syntax errors, and return unchanged - import ure - u = u.replace('+', ' ') while 1: pos = u.find('%') diff --git a/shared/wallet.py b/shared/wallet.py index 016b8a32..e1621549 100644 --- a/shared/wallet.py +++ b/shared/wallet.py @@ -4,7 +4,7 @@ # import chains from descriptor import Descriptor -from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from stash import SensitiveValues MAX_BIP32_IDX = (2 ** 31) - 1 @@ -40,8 +40,10 @@ class MasterSingleSigWallet(WalletABC): # Construct a wallet based on current master secret, and chain. # - path is optional, and then we use standard path for addr_fmt # - path can be overriden when we come here via address explorer - - if addr_fmt == AF_P2WPKH: + if addr_fmt == AF_P2TR: + n = 'Taproot P2TR' + prefix = path or 'm/86h/{coin_type}h/{account}h' + elif addr_fmt == AF_P2WPKH: n = 'Segwit P2WPKH' prefix = path or 'm/84h/{coin_type}h/{account}h' elif addr_fmt == AF_CLASSIC: @@ -66,7 +68,6 @@ class MasterSingleSigWallet(WalletABC): if self.chain.ctype == 'XRT': n += ' (Regtest)' - self.name = n self.addr_fmt = addr_fmt @@ -82,7 +83,6 @@ class MasterSingleSigWallet(WalletABC): self._path = p - def yield_addresses(self, start_idx, count, change_idx=None): # Render a range of addresses. Slow to start, since accesses SE in general # - if count==1, don't derive any subkey, just do path. diff --git a/testing/bip32.py b/testing/bip32.py index fe10bd75..e09cf087 100644 --- a/testing/bip32.py +++ b/testing/bip32.py @@ -6,8 +6,9 @@ from io import BytesIO try: from pysecp256k1 import ( ec_seckey_verify, ec_pubkey_create, ec_pubkey_serialize, ec_pubkey_parse, - ec_seckey_tweak_add, ec_pubkey_tweak_add, + ec_seckey_tweak_add, ec_pubkey_tweak_add, tagged_sha256 ) + from pysecp256k1.extrakeys import xonly_pubkey_from_pubkey, xonly_pubkey_serialize, xonly_pubkey_tweak_add except ImportError: import ecdsa SECP256k1 = ecdsa.curves.SECP256k1 @@ -119,6 +120,10 @@ class PrivateKey(object): tweaked = ec_seckey_tweak_add(self.k, tweak32) return PrivateKey(sec_exp=tweaked) + def address(self, compressed: bool = True, chain: str = "BTC", + addr_fmt: str = "p2wpkh") -> str: + return self.K.address(compressed, chain, addr_fmt) + @classmethod def from_wif(cls, wif_str: str) -> "PrivateKey": """ @@ -193,8 +198,17 @@ class PublicKey(object): return self.K.to_string(encoding="compressed" if compressed else "uncompressed") def tweak_add(self, tweak32: bytes) -> "PublicKey": + assert len(tweak32) == 32 return PublicKey(pub_key=ec_pubkey_tweak_add(self.K, tweak32)) + def taptweak(self, tweak32: bytes = None) -> "bytes": + xonly_key, _ = xonly_pubkey_from_pubkey(self.K) + tweak = tweak32 or xonly_pubkey_serialize(xonly_key) + tweak = tagged_sha256(b"TapTweak", tweak) + tweaked_pubkey = xonly_pubkey_tweak_add(xonly_key, tweak) + tweaked_xonly_pubkey, parity = xonly_pubkey_from_pubkey(tweaked_pubkey) + return xonly_pubkey_serialize(tweaked_xonly_pubkey) + @classmethod def parse(cls, key_bytes: bytes) -> "PublicKey": """ @@ -227,7 +241,7 @@ class PublicKey(object): """ return hash160(self.sec(compressed=compressed)) - def address(self, compressed: bool = True, testnet: bool = False, + def address(self, compressed: bool = True, chain: str = "BTC", addr_fmt: str = "p2wpkh") -> str: """ Generates bitcoin address from public key. @@ -240,18 +254,33 @@ class PublicKey(object): 3. p2wpkh (default) :return: bitcoin address """ + if chain == "BTC": + hrp = "bc" + pkh_prefix = b"\x00" + sh_prefix = b"\x05" + else: + pkh_prefix = b"\x6f" + sh_prefix = b"\xc4" + if chain == "XRT": + hrp = "bcrt" + elif chain == "XTN": + hrp = "tb" + else: + assert False + + if addr_fmt == "p2tr": + tweaked_xonly = self.taptweak() + return bech32.encode(hrp=hrp, witver=1, witprog=tweaked_xonly) + h160 = self.h160(compressed=compressed) if addr_fmt == "p2pkh": - prefix = b"\x6f" if testnet else b"\x00" - return encode_base58_checksum(prefix + h160) + return encode_base58_checksum(pkh_prefix + h160) elif addr_fmt == "p2wpkh": - hrp = "tb" if testnet else "bc" return bech32.encode(hrp=hrp, witver=0, witprog=h160) elif addr_fmt == "p2sh-p2wpkh": scr = b"\x00\x14" + h160 # witversion 0 + pubkey hash h160 = hash160(scr) - prefix = b"\xc4" if testnet else b"\x05" - return encode_base58_checksum(prefix + h160) + return encode_base58_checksum(sh_prefix + h160) raise ValueError("Unsupported address type.") @@ -730,9 +759,9 @@ class BIP32Node: def hash160(self, compressed=True): return self.node.public_key.h160(compressed) - def address(self, compressed=True, netcode="XTN", addr_fmt="p2pkh"): + def address(self, compressed=True, chain="XTN", addr_fmt="p2pkh"): return self.node.public_key.address(compressed, addr_fmt=addr_fmt, - testnet=False if netcode == "BTC" else True) + chain=chain) def sec(self, compressed=True): return self.node.public_key.sec(compressed) diff --git a/testing/conftest.py b/testing/conftest.py index 17dfd24c..844cbf59 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,9 +1,9 @@ # (c) Copyright 2020 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, functools, io, math, pdb +import pytest, time, sys, random, re, ndef, os, glob, hashlib, json, functools, io, math, bech32, pdb from subprocess import check_output from ckcc.protocol import CCProtocolPacker -from helpers import B2A, U2SAT, hash160 +from helpers import B2A, U2SAT, hash160, taptweak from base58 import decode_base58_checksum from bip32 import BIP32Node from msg import verify_message @@ -285,26 +285,30 @@ def addr_vs_path(master_xpub): from bip32 import BIP32Node from ckcc_protocol.constants import AF_CLASSIC, AFC_PUBKEY, AF_P2WPKH, AFC_SCRIPT from ckcc_protocol.constants import AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH - from bech32 import bech32_decode, convertbits, Encoding + from bech32 import bech32_decode, convertbits, decode, Encoding from hashlib import sha256 - def doit(given_addr, path=None, addr_fmt=None, script=None, testnet=True): + def doit(given_addr, path=None, addr_fmt=None, script=None, chain="XTN"): if not script: try: # prefer using xpub if we can mk = BIP32Node.from_wallet_key(master_xpub) - if not testnet: - mk._netcode = "BTC" - sk = mk.subkey_for_path(path[2:]) + mk._netcode = chain + sk = mk.subkey_for_path(path) except: mk = BIP32Node.from_wallet_key(simulator_fixed_tprv) - if not testnet: - mk._netcode = "BTC" - sk = mk.subkey_for_path(path[2:]) + mk._netcode = chain + sk = mk.subkey_for_path(path) - if addr_fmt in {None, AF_CLASSIC}: + if addr_fmt == AF_P2TR: + tweaked_xonly = taptweak(sk.sec()[1:]) + decoded = decode(given_addr[:2], given_addr) + assert not given_addr.startswith("bcrt") # regtest + assert tweaked_xonly == bytes(decoded[1]) + + elif addr_fmt in {None, AF_CLASSIC}: # easy - assert sk.address(netcode="XTN" if testnet else "BTC") == given_addr + assert sk.address(chain=chain) == given_addr elif addr_fmt & AFC_PUBKEY: @@ -352,7 +356,6 @@ def addr_vs_path(master_xpub): return doit - @pytest.fixture(scope='module') def capture_enabled(sim_eval): # need to have sim_display imported early, see unix/frozen-modules/ckcc @@ -2173,6 +2176,30 @@ def txout_explorer(cap_story, press_cancel, need_keypress, is_q1): return doit +@pytest.fixture +def validate_address(): + # Check whether an address is covered by the given subkey + def doit(addr, sk): + if addr[0] in '1mn': + chain = "XTN" if addr[0] != "1" else "BTC" + assert addr == sk.address(addr_fmt="p2pkh", chain=chain) + elif addr[0:4] in {'bc1q', 'tb1q'}: + chain = "XTN" if addr[0:4] != 'bc1q' else "BTC" + assert addr == sk.address(addr_fmt="p2wpkh", chain=chain) + elif addr[0:6] == "bcrt1q": + assert addr == sk.address(addr_fmt="p2wpkh", chain="XRT") + elif addr[0:4] in {'bc1p', 'tb1p'}: + chain = "XTN" if addr[0:4] != 'bc1p' else "BTC" + assert addr == sk.address(addr_fmt="p2tr", chain=chain) + elif addr[0:6] == "bcrt1p": + assert addr == sk.address(addr_fmt="p2tr", chain="XRT") + elif addr[0] in '23': + chain = "XTN" if addr[0] != '3' else "BTC" + assert addr == sk.address(addr_fmt="p2sh-p2wpkh", chain=chain) + else: + raise ValueError(addr) + return doit + @pytest.fixture def skip_if_useless_way(is_q1, nfc_disabled): diff --git a/testing/constants.py b/testing/constants.py index 2a619229..490eef6c 100644 --- a/testing/constants.py +++ b/testing/constants.py @@ -25,9 +25,11 @@ unmap_addr_fmt = { 'p2wsh': AF_P2WSH, 'p2wsh-p2sh': AF_P2WSH_P2SH, 'p2sh-p2wsh': AF_P2WSH_P2SH, + "p2tr": AF_P2TR, } msg_sign_unmap_addr_fmt = { + 'p2tr': AF_P2TR, # not supported for msg signign tho 'p2pkh': AF_CLASSIC, 'p2wpkh': AF_P2WPKH, 'p2sh-p2wpkh': AF_P2WPKH_P2SH, @@ -35,6 +37,7 @@ msg_sign_unmap_addr_fmt = { } addr_fmt_names = { + AF_P2TR: 'p2tr', AF_CLASSIC: 'p2pkh', AF_P2SH: 'p2sh', AF_P2WPKH: 'p2wpkh', @@ -45,10 +48,10 @@ addr_fmt_names = { # all possible addr types, including multisig/scripts -ADDR_STYLES = ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh'] +ADDR_STYLES = ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh', 'p2tr'] # single-signer -ADDR_STYLES_SINGLE = ['p2wpkh', 'p2pkh', 'p2wpkh-p2sh'] +ADDR_STYLES_SINGLE = ['p2wpkh', 'p2pkh', 'p2wpkh-p2sh', 'p2tr'] # multi signer ADDR_STYLES_MS = ['p2sh', 'p2wsh', 'p2wsh-p2sh'] diff --git a/testing/data/taproot/in_internal_key_len.psbt b/testing/data/taproot/in_internal_key_len.psbt new file mode 100644 index 0000000000000000000000000000000000000000..141da152a3d5f564732d91df403ac0bf649764cf GIT binary patch literal 207 zcmXRYPAd7&$WX|{z`($$UgEWDzk&Kc-keWX)80LLzp>+ekbz`KL`3tm*=Z`9oPcT= z82$qRQ$-CUm?Op@(!Bh!_s#hqtUQz6cS#9IZ<^{RcP=5DMGK?=C|z|p^S-pg1LJFg zY06WpxsJ@Y33>t6%E+kA@QD?yOes(yN=N7GR-MoC&1>~~m)Uw#sUB;K200| literal 0 HcmV?d00001 diff --git a/testing/data/taproot/in_key_pth_sig_len.psbt b/testing/data/taproot/in_key_pth_sig_len.psbt new file mode 100644 index 00000000..1ea3c554 --- /dev/null +++ b/testing/data/taproot/in_key_pth_sig_len.psbt @@ -0,0 +1 @@ +70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a075701133f173bb3d36c074afb716fec6307a069a2e450b995f3c82785945ab8df0e24260dcd703b0cbf34de399184a9481ac2b3586db6601f026a77f7e4938481bc3475000000 \ No newline at end of file diff --git a/testing/data/taproot/in_key_pth_sig_len1.psbt b/testing/data/taproot/in_key_pth_sig_len1.psbt new file mode 100644 index 00000000..a9a6bc8a --- /dev/null +++ b/testing/data/taproot/in_key_pth_sig_len1.psbt @@ -0,0 +1 @@ +70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a0757011342173bb3d36c074afb716fec6307a069a2e450b995f3c82785945ab8df0e24260dcd703b0cbf34de399184a9481ac2b3586db6601f026a77f7e4938481bc34751701aa000000 \ No newline at end of file diff --git a/testing/data/taproot/in_leaf_script_cb_len.psbt b/testing/data/taproot/in_leaf_script_cb_len.psbt new file mode 100644 index 00000000..4108d0ba --- /dev/null +++ b/testing/data/taproot/in_leaf_script_cb_len.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6926315c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac06f7d62059e9497a1a4a267569d9876da60101aff38e3529b9b939ce7f91ae970115f2e490af7cc45c4f78511f36057ce5c5a5c56325a29fb44dfc203f356e1f80023202cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2acc00000 \ No newline at end of file diff --git a/testing/data/taproot/in_leaf_script_cb_len1.psbt b/testing/data/taproot/in_leaf_script_cb_len1.psbt new file mode 100644 index 00000000..7de51589 --- /dev/null +++ b/testing/data/taproot/in_leaf_script_cb_len1.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6926115c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac06f7d62059e9497a1a4a267569d9876da60101aff38e3529b9b939ce7f91ae970115f2e490af7cc45c4f78511f36057ce5c5a5c56325a29fb44dfc203f356e123202cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2acc00000 \ No newline at end of file diff --git a/testing/data/taproot/in_script_sig_key_len.psbt b/testing/data/taproot/in_script_sig_key_len.psbt new file mode 100644 index 00000000..0cc68869 --- /dev/null +++ b/testing/data/taproot/in_script_sig_key_len.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b6924214022cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b094089756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd43cb0000 \ No newline at end of file diff --git a/testing/data/taproot/in_script_sig_sig_len.psbt b/testing/data/taproot/in_script_sig_sig_len.psbt new file mode 100644 index 00000000..ba6c4daf --- /dev/null +++ b/testing/data/taproot/in_script_sig_sig_len.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b69241142cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b094289756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd43cb01010000 \ No newline at end of file diff --git a/testing/data/taproot/in_script_sig_sig_len1.psbt b/testing/data/taproot/in_script_sig_sig_len1.psbt new file mode 100644 index 00000000..76c68695 --- /dev/null +++ b/testing/data/taproot/in_script_sig_sig_len1.psbt @@ -0,0 +1 @@ +70736274ff01005e02000000019bd48765230bf9a72e662001f972556e54f0c6f97feb56bcb5600d817f6995260100000000ffffffff0148e6052a01000000225120030da4fce4f7db28c2cb2951631e003713856597fe963882cb500e68112cca63000000000001012b00f2052a01000000225120c2247efbfd92ac47f6f40b8d42d169175a19fa9fa10e4a25d7f35eb4dd85b69241142cb13ac68248de806aa6a3659cf3c03eb6821d09c8114a4e868febde865bb6d2cd970e15f53fc0c82f950fd560ffa919b76172be017368a89913af074f400b093f89756aa3739ccc689ec0fcf3a360be32cc0b59b16e93a1e8bb4605726b2ca7a3ff706c4176649632b2cc68e1f912b8a578e3719ce7710885c7a966f49bcd430000 \ No newline at end of file diff --git a/testing/data/taproot/in_tr_deriv_key_len.psbt b/testing/data/taproot/in_tr_deriv_key_len.psbt new file mode 100644 index 00000000..0ad32972 --- /dev/null +++ b/testing/data/taproot/in_tr_deriv_key_len.psbt @@ -0,0 +1 @@ +70736274ff010071020000000127744ababf3027fe0d6cf23a96eee2efb188ef52301954585883e69b6624b2420000000000ffffffff02787c01000000000016001483a7e34bd99ff03a4962ef8a1a101bb295461ece606b042a010000001600147ac369df1b20e033d6116623957b0ac49f3c52e8000000000001012b00f2052a010000002251205a2c2cf5b52cf31f83ad2e8da63ff03183ecd8f609c7510ae8a48e03910a0757221602fe349064c98d6e2a853fa3c9b12bd8b304a19c195c60efa7ee2393046d3fa2321900772b2da75600008001000080000000800100000000000000000000 \ No newline at end of file diff --git a/testing/helpers.py b/testing/helpers.py index 2e76e7f2..8e17f4bd 100644 --- a/testing/helpers.py +++ b/testing/helpers.py @@ -24,13 +24,15 @@ def prandom(count): return bytes(random.randint(0, 255) for i in range(count)) def taptweak(internal_key, tweak=None): - tweak = internal_key if tweak is None else internal_key + tweak assert len(internal_key) == 32, "not xonly-pubkey (len!=32)" + if tweak is not None: + assert len(tweak) == 32, "tweak (len!=32)" + tweak = internal_key if tweak is None else internal_key + tweak xonly_pubkey = xonly_pubkey_parse(internal_key) tweak = tagged_sha256(b"TapTweak", tweak) tweaked_pubkey = xonly_pubkey_tweak_add(xonly_pubkey, tweak) - tweaked_xonnly_pubkey, parity = xonly_pubkey_from_pubkey(tweaked_pubkey) - return xonly_pubkey_serialize(tweaked_xonnly_pubkey) + tweaked_xonly_pubkey, parity = xonly_pubkey_from_pubkey(tweaked_pubkey) + return xonly_pubkey_serialize(tweaked_xonly_pubkey) def fake_dest_addr(style='p2pkh'): # Make a plausible output address, but it's random garbage. Cant use for change outs diff --git a/testing/psbt.py b/testing/psbt.py index 13035658..6d39ca33 100644 --- a/testing/psbt.py +++ b/testing/psbt.py @@ -123,6 +123,9 @@ class BasicPSBTInput(PSBTSection): self.taproot_bip32_paths = {} self.taproot_internal_key = None self.taproot_key_sig = None + self.taproot_merkle_root = None + self.taproot_scripts = {} + self.taproot_script_sigs = {} self.redeem_script = None self.witness_script = None self.previous_txid = None # v2 @@ -147,6 +150,9 @@ class BasicPSBTInput(PSBTSection): a.taproot_key_sig == b.taproot_key_sig and \ a.taproot_bip32_paths == b.taproot_bip32_paths and \ a.taproot_internal_key == b.taproot_internal_key and \ + a.taproot_merkle_root == b.taproot_merkle_root and \ + a.taproot_scripts == b.taproot_scripts and \ + a.taproot_script_sigs == b.taproot_script_sigs and \ sorted(a.part_sigs.keys()) == sorted(b.part_sigs.keys()) and \ a.previous_txid == b.previous_txid and \ a.prevout_idx == b.prevout_idx and \ @@ -189,7 +195,7 @@ class BasicPSBTInput(PSBTSection): self.others[kt] = val elif kt == PSBT_IN_TAP_BIP32_DERIVATION: self.taproot_bip32_paths[key] = val - elif kt == PSBT_OUT_TAP_INTERNAL_KEY: + elif kt == PSBT_IN_TAP_INTERNAL_KEY: self.taproot_internal_key = val elif kt == PSBT_IN_TAP_KEY_SIG: self.taproot_key_sig = val @@ -203,6 +209,21 @@ class BasicPSBTInput(PSBTSection): self.req_time_locktime = struct.unpack(" 32, "PSBT_IN_TAP_LEAF_SCRIPT control block is too short" + assert (len(key) - 1) % 32 == 0, "PSBT_IN_TAP_LEAF_SCRIPT control block is not valid" + assert len(val) != 0, "PSBT_IN_TAP_LEAF_SCRIPT cannot be empty" + leaf_script = (val[:-1], int(val[-1])) + if leaf_script not in self.taproot_scripts: + self.taproot_scripts[leaf_script] = set() + self.taproot_scripts[leaf_script].add(key) + elif kt == PSBT_IN_TAP_MERKLE_ROOT: + self.taproot_merkle_root = val else: self.unknown[bytes([kt]) + key] = val @@ -236,6 +257,16 @@ class BasicPSBTInput(PSBTSection): if self.taproot_key_sig: wr(PSBT_IN_TAP_KEY_SIG, self.taproot_key_sig) + if self.taproot_merkle_root: + wr(PSBT_IN_TAP_MERKLE_ROOT, self.taproot_merkle_root) + if self.taproot_scripts: + for (script, leaf_ver), control_blocks in self.taproot_scripts.items(): + for control_block in control_blocks: + wr(PSBT_IN_TAP_LEAF_SCRIPT, script + struct.pack("B", leaf_ver), control_block) + if self.taproot_script_sigs: + for (xonly, leaf_hash), sig in self.taproot_script_sigs.items(): + wr(PSBT_IN_TAP_SCRIPT_SIG, sig, xonly + leaf_hash) + if v2: if self.previous_txid is not None: wr(PSBT_IN_PREVIOUS_TXID, self.previous_txid) @@ -267,6 +298,7 @@ class BasicPSBTOutput(PSBTSection): self.bip32_paths = {} self.taproot_bip32_paths = {} self.taproot_internal_key = None + self.taproot_tree = None self.script = None # v2 self.amount = None # v2 self.proprietary = {} @@ -282,6 +314,7 @@ class BasicPSBTOutput(PSBTSection): a.taproot_bip32_paths == b.taproot_bip32_paths and \ a.taproot_internal_key == b.taproot_internal_key and \ a.proprietary == b.proprietary and \ + a.taproot_tree == b.taproot_tree and \ a.unknown == b.unknown def parse_kv(self, kt, key, val): @@ -297,6 +330,18 @@ class BasicPSBTOutput(PSBTSection): self.taproot_bip32_paths[key] = val elif kt == PSBT_OUT_TAP_INTERNAL_KEY: self.taproot_internal_key = val + elif kt == PSBT_OUT_TAP_TREE: + res = [] + reader = io.BytesIO(val) + while True: + depth = reader.read(1) + if not depth: + break + leaf_version = reader.read(1)[0] + script_len = deser_compact_size(reader) + script = reader.read(script_len) + res.append((depth[0], leaf_version, script)) + self.taproot_tree = res elif kt == PSBT_OUT_SCRIPT: self.script = val elif kt == PSBT_OUT_AMOUNT: @@ -319,6 +364,11 @@ class BasicPSBTOutput(PSBTSection): wr(PSBT_OUT_TAP_BIP32_DERIVATION, self.taproot_bip32_paths[k], k) if self.taproot_internal_key: wr(PSBT_OUT_TAP_INTERNAL_KEY, self.taproot_internal_key) + if self.taproot_tree: + res = b'' + for depth, leaf_version, script in self.taproot_tree: + res += bytes([depth, leaf_version]) + ser_compact_size(len(script)) + script + wr(PSBT_OUT_TAP_TREE, res) if v2 and self.script is not None: wr(PSBT_OUT_SCRIPT, self.script) if v2 and self.amount is not None: diff --git a/testing/test_addr.py b/testing/test_addr.py index a874bb59..95256dc7 100644 --- a/testing/test_addr.py +++ b/testing/test_addr.py @@ -12,7 +12,7 @@ from charcodes import KEY_QR from constants import msg_sign_unmap_addr_fmt @pytest.mark.parametrize('path', [ 'm', "m/1/2", "m/1'/100'"]) -@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH ]) +@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) def test_show_addr_usb(dev, press_select, addr_vs_path, path, addr_fmt, is_simulator): addr = dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None) @@ -27,7 +27,7 @@ def test_show_addr_usb(dev, press_select, addr_vs_path, path, addr_fmt, is_simul @pytest.mark.qrcode @pytest.mark.parametrize('path', [ 'm', "m/1/2", "m/1'/100'", "m/0h/500h"]) -@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH ]) +@pytest.mark.parametrize('addr_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) def test_show_addr_displayed(dev, need_keypress, addr_vs_path, path, addr_fmt, cap_story, cap_screen_qr, qr_quality_check, press_cancel, is_q1): @@ -60,25 +60,40 @@ def test_show_addr_displayed(dev, need_keypress, addr_vs_path, path, addr_fmt, assert qr == addr or qr == addr.upper() @pytest.mark.bitcoind -def test_addr_vs_bitcoind(use_regtest, press_select, dev, bitcoind_d_sim_sign): +@pytest.mark.parametrize("addr_fmt", [ + (AF_CLASSIC, "legacy"), + (AF_P2WPKH_P2SH, "p2sh-segwit"), + (AF_P2WPKH, "bech32"), + (AF_P2TR, "bech32m") +]) +def test_addr_vs_bitcoind(addr_fmt, use_regtest, press_select, dev, bitcoind_d_sim_sign): # check our p2wpkh wrapped in p2sh is right use_regtest() + addr_fmt, addr_fmt_bitcoind = addr_fmt for i in range(5): - core_addr = bitcoind_d_sim_sign.getnewaddress(f"{i}-addr", "p2sh-segwit") - assert core_addr[0] == '2' + core_addr = bitcoind_d_sim_sign.getnewaddress(f"{i}-addr", addr_fmt_bitcoind) resp = bitcoind_d_sim_sign.getaddressinfo(core_addr) - assert resp['embedded']['iswitness'] == True - assert resp['isscript'] == True + assert resp["ismine"] is True + if addr_fmt in (AF_P2TR, AF_P2WPKH): + wit_ver = resp["witness_version"] + if addr_fmt == AF_P2TR: + assert wit_ver == 1 + else: + assert wit_ver == 0 + assert resp["iswitness"] is True + if addr_fmt == AF_P2WPKH_P2SH: + assert resp['embedded']['iswitness'] is True + assert resp['isscript'] is True + assert resp['embedded']['witness_version'] == 0 path = resp['hdkeypath'] - addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2WPKH_P2SH), timeout=None) + addr = dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None) press_select() assert addr == core_addr @pytest.mark.parametrize("body_err", [ - ("m\np2wsh", "Invalid address format: 'p2wsh'"), - ("m\np2sh-p2wsh", "Invalid address format: 'p2sh-p2wsh'"), - ("m\np2tr", "Invalid address format: 'p2tr'"), + ("m\np2wsh", "Unsupported address format: 'p2wsh'"), + ("m\np2sh-p2wsh", "Unsupported address format: 'p2sh-p2wsh'"), ("m/0/0/0/0/0/0/0/0/0/0/0/0/0\np2pkh", "too deep"), ("m/0/0/0/0/0/q/0/0/0\np2pkh", "invalid characters"), ]) @@ -94,7 +109,7 @@ def test_show_addr_nfc_invalid(body_err, goto_home, pick_menu_item, nfc_write_te assert err in story @pytest.mark.parametrize("path", ["m/84'/0'/0'/300/0", "m/800h/0h", "m/0/0/0/0/1/1/1"]) -@pytest.mark.parametrize("str_addr_fmt", ["p2pkh", "", "p2wpkh", "p2wpkh-p2sh", "p2sh-p2wpkh"]) +@pytest.mark.parametrize("str_addr_fmt", ["p2pkh", "", "p2wpkh", "p2wpkh-p2sh", "p2sh-p2wpkh", "p2tr"]) def test_show_addr_nfc(path, str_addr_fmt, nfc_write_text, nfc_read_text, pick_menu_item, goto_home, cap_story, press_nfc, addr_vs_path, press_select, is_q1, cap_screen): @@ -142,4 +157,59 @@ def test_show_addr_nfc(path, str_addr_fmt, nfc_write_text, nfc_read_text, pick_m assert story_addr == addr addr_vs_path(addr, path, addr_fmt) -# EOF +def test_bip86(dev, set_seed_words, use_mainnet, need_keypress): + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + set_seed_words(mnemonic) + use_mainnet() + + path = "m/86'/0'/0'" + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + # xprv = "xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk" + xpub = "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ" + assert xp == xpub + + # Account 0, first receiving + path = "m/86'/0'/0'/0/0" + addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2TR), timeout=None) + need_keypress('y') + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + + # xprv = "xprvA449goEeU9okwCzzZaxiy475EQGQzBkc65su82nXEvcwzfSskb2hAt2WymrjyRL6kpbVTGL3cKtp9herYXSjjQ1j4stsXXiRF7kXkCacK3T" + xpub = "xpub6H3W6JmYJXN49h5TfcVjLC3onS6uPeUTTJoVvRC8oG9vsTn2J8LwigLzq5tHbrwAzH9DGo6ThGUdWsqce8dGfwHVBxSbixjDADGGdzF7t2B" + # internal_key = "cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115" + # output_key = "a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c" + # scriptPubKey = "5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c" + address = "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" + assert xp == xpub + assert addr == address + + # Account 0, second receiving + path = "m/86'/0'/0'/0/1" + addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2TR), timeout=None) + need_keypress('y') + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + # xprv = "xxprvA449goEeU9okyiF1LmKiDaTgeXvmh87DVyRd35VPbsSop8n8uALpbtrUhUXByPFKK7C2yuqrB1FrhiDkEMC4RGmA5KTwsE1aB5jRu9zHsuQ" + xpub = "xpub6H3W6JmYJXN4CCKUSnriaiQRCZmG6aq4sCMDqTu1ACyngw7HShf59hAxYjXgKDuuHThVEUzdHrc3aXCr9kfvQvZPit5dnD3K9xVRBzjK3rX" + # internal_key = "83dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145" + # output_key = "a82f29944d65b86ae6b5e5cc75e294ead6c59391a1edc5e016e3498c67fc7bbb" + # scriptPubKey = "5120a82f29944d65b86ae6b5e5cc75e294ead6c59391a1edc5e016e3498c67fc7bbb" + address = "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh" + assert xp == xpub + assert addr == address + + # Account 0, first change + path = "m/86'/0'/0'/1/0" + addr = dev.send_recv(CCProtocolPacker.show_address(path, AF_P2TR), timeout=None) + need_keypress('y') + xp = dev.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + # xprv = "xprvA3Ln3Gt3aphvUgzgEDT8vE2cYqb4PjFfpmbiFKphxLg1FjXQpkAk5M1ZKDY15bmCAHA35jTiawbFuwGtbDZogKF1WfjwxML4gK7WfYW5JRP" + xpub = "xpub6GL8SnQwRCGDhB59LEz9HMyM6sRYoByXBzXK3iEKWgCz8XrZNHUzd9L3AUBELW5NzA7dEFvMas1F84TuPH3xqdUA5tumaGWFgihJzWytXe3" + # internal_key = "399f1b2f4393f29a18c937859c5dd8a77350103157eb880f02e8c08214277cef" + # output_key = "882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc" + # scriptPubKey = "5120882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc" + address = "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7" + assert xp == xpub + assert addr == address + +# EOF \ No newline at end of file diff --git a/testing/test_address_explorer.py b/testing/test_address_explorer.py index 0b825441..cec70117 100644 --- a/testing/test_address_explorer.py +++ b/testing/test_address_explorer.py @@ -24,9 +24,10 @@ def mk_common_derivations(): # Removed in v4.1.3: ( "m/{change}/{idx}", AF_CLASSIC ), #( "m/{account}'/{change}'/{idx}'", AF_CLASSIC ), #( "m/{account}'/{change}'/{idx}'", AF_P2WPKH ), - ( "m/44h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_CLASSIC ), - ( "m/49h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH_P2SH ), - ( "m/84h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH ) + ("m/44h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_CLASSIC), + ("m/49h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH_P2SH), + ("m/84h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2WPKH), + ("m/86h/{coin_type}h/{account}h/{change}/{idx}".replace('{coin_type}', coin_type), AF_P2TR), ] return doit @@ -57,32 +58,14 @@ def parse_display_screen(cap_story, is_mark3): return d return doit -@pytest.fixture -def validate_address(): - # Check whether an address is covered by the given subkey - def doit(addr, sk): - if addr[0] in '1mn': - assert addr == sk.address() - elif addr[0:3] in { 'bc1', 'tb1' }: - h20 = sk.hash160() - assert addr == bech32.encode(addr[0:2], 0, h20) - elif addr[0:5] == "bcrt1": - h20 = sk.hash160() - assert addr == bech32.encode(addr[0:4], 0, h20) - elif addr[0] in '23': - h20 = hash160(b'\x00\x14' + sk.hash160()) - assert h20 == decode_base58_checksum(addr)[1:] - else: - raise ValueError(addr) - return doit @pytest.fixture def generate_addresses_file(goto_address_explorer, need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_text, load_export_and_verify_signature, - press_select, press_nfc): + press_select, press_nfc, load_export): # Generates the address file through the simulator, reads the file and # returns a list of tuples of the form (subpath, address) - def doit(start_idx=0, way="sd", change=False, is_custom_single=False): + def doit(start_idx=0, way="sd", change=False, is_custom_single=False, is_p2tr=False): expected_qty = 250 if way != "nfc" else 10 if (start_idx + expected_qty) > MAX_BIP32_IDX: expected_qty = (MAX_BIP32_IDX - start_idx) + 1 @@ -92,7 +75,8 @@ def generate_addresses_file(goto_address_explorer, need_keypress, cap_story, mic if change and not is_custom_single: need_keypress("0") if way == "sd": - need_keypress('1') + if "Press (1)" in story: + need_keypress('1') elif way == "vdisk": if "save to Virtual Disk" not in story: raise pytest.skip("Vdisk disabled") @@ -112,7 +96,12 @@ def generate_addresses_file(goto_address_explorer, need_keypress, cap_story, mic time.sleep(.5) # always long enough to write the file? title, body = cap_story() - contents, sig_addr = load_export_and_verify_signature(body, way, label="Address summary") + if is_p2tr: + # p2tr - no signature file + contents = load_export(way, label="Address summary", is_json=False, sig_check=False) + sig_addr = None + else: + contents, sig_addr = load_export_and_verify_signature(body, way, label="Address summary") addr_dump = io.StringIO(contents) cc = csv.reader(addr_dump) hdr = next(cc) @@ -120,7 +109,8 @@ def generate_addresses_file(goto_address_explorer, need_keypress, cap_story, mic for n, (idx, addr, deriv) in enumerate(cc, start=start_idx): assert int(idx) == n if n == start_idx: - assert sig_addr == addr + if sig_addr: + assert sig_addr == addr if not is_custom_single: assert ('/%s' % idx) in deriv @@ -272,7 +262,7 @@ def test_address_display(goto_address_explorer, parse_display_screen, mk_common_ press_cancel() # back -@pytest.mark.parametrize('click_idx', ["Classic P2PKH", "P2SH-Segwit", "Segwit P2WPKH"]) +@pytest.mark.parametrize('click_idx', ["Classic P2PKH", "P2SH-Segwit", "Segwit P2WPKH", 'Taproot P2TR']) @pytest.mark.parametrize("change", [True, False]) @pytest.mark.parametrize("start_idx", [MAX_BIP32_IDX, 80965, 0]) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc"]) @@ -289,7 +279,8 @@ def test_dump_addresses(way, change, generate_addresses_file, mk_common_derivati set_addr_exp_start_idx(start_idx) pick_menu_item(click_idx) # Generate the addresses file and get each line in a list - for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx, change=change): + is_p2tr = click_idx == 'Taproot P2TR' + for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx, change=change, is_p2tr=is_p2tr): # derive the subkey and validate the corresponding address assert subpath.split("/")[-2] == "1" if change else "0" sk = node_prv.subkey_for_path(subpath) @@ -336,7 +327,7 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, # derive index=0 address assert '{account}' in path - subpath = path.format(account=account_num, change=0, idx=start_idx) # e.g. "m/44'/1'/X'/0/0" + subpath = path.format(account=account_num, change=0, idx=start_idx, is_p2tr=addr_format==AF_P2TR) # e.g. "m/44'/1'/X'/0/0" sk = node_prv.subkey_for_path(subpath) # capture full index=0 address from display screen & validate it @@ -357,7 +348,7 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, assert expected_addr.startswith(start) assert expected_addr.endswith(end) - for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx): + for subpath, addr in generate_addresses_file(way=way, start_idx=start_idx,is_p2tr=addr_format==AF_P2TR): assert subpath.split('/')[-3] == str(account_num)+"h" sk = node_prv.subkey_for_path(subpath) validate_address(addr, sk) @@ -378,7 +369,7 @@ def test_account_menu(way, account_num, sim_execfile, pick_menu_item, ("m/1/2/3/4/5", MAX_BIP32_IDX), ("m/1h/2h/3h/4h/5h", 0), ]) -@pytest.mark.parametrize('which_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH ]) +@pytest.mark.parametrize('which_fmt', [ AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR]) def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_address_explorer, need_keypress, cap_menu, parse_display_screen, validate_address, cap_screen_qr, qr_quality_check, nfc_read_text, get_setting, @@ -445,12 +436,14 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad m = cap_menu() assert m[0] == 'Classic P2PKH' assert m[1] == 'Segwit P2WPKH' - assert m[2] == 'P2SH-Segwit' - + assert m[2] == 'Taproot P2TR' + assert m[3] == 'P2SH-Segwit' + fmts = { AF_CLASSIC: 'Classic P2PKH', AF_P2WPKH: 'Segwit P2WPKH', AF_P2WPKH_P2SH: 'P2SH-Segwit', + AF_P2TR: 'Taproot P2TR', } pick_menu_item(fmts[which_fmt]) @@ -475,7 +468,7 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad need_keypress(KEY_QR if is_q1 else '4') qr = cap_screen_qr().decode('ascii') - if which_fmt == AF_P2WPKH: + if which_fmt in (AF_P2WPKH, AF_P2TR): assert qr == addr.upper() else: assert qr == addr @@ -497,7 +490,7 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad # remove QR from screen press_cancel() - addr_gen = generate_addresses_file(change=False, is_custom_single=True) + addr_gen = generate_addresses_file(change=False, is_custom_single=True, is_p2tr=which_fmt == AF_P2TR) f_path, f_addr = next(addr_gen) assert f_path == path assert f_addr == addr @@ -526,7 +519,7 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad need_keypress(KEY_QR if is_q1 else '4') for i in range(n): qr = cap_screen_qr().decode('ascii') - if which_fmt == AF_P2WPKH: + if which_fmt in (AF_P2WPKH, AF_P2TR): qr = qr.lower() qr_addr_list.append(qr) need_keypress(KEY_RIGHT if is_q1 else "9") @@ -539,11 +532,92 @@ def test_custom_path(path_sidx, which_fmt, addr_vs_path, pick_menu_item, goto_ad assert sorted(qr_addr_list) == sorted(addr_dict.values()) - addr_gen = generate_addresses_file(start_idx=start_idx, change=False) + addr_gen = generate_addresses_file(start_idx=start_idx, change=False, is_p2tr=which_fmt==AF_P2TR) assert addr_dict == {p: a for i,(p, a) in enumerate(addr_gen) if i < n} # check the rest of file export for p, a in addr_gen: addr_vs_path(a, p, addr_fmt=which_fmt) + +@pytest.mark.bitcoind +@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC, AF_P2TR]) +@pytest.mark.parametrize("acct_num", [None, "999"]) +def test_bitcoind_descriptor_address(addr_fmt, acct_num, bitcoind, goto_home, pick_menu_item, cap_story, + use_regtest, need_keypress, microsd_path, generate_addresses_file, + bitcoind_d_wallet_w_sk, load_export, settings_set, cap_menu, + goto_address_explorer, press_cancel, press_select, enter_number): + # export single sig descriptors (external, internal) + # export addressses from address explorer + # derive addresses from descriptor with bitcoind + # compare bitcoind derived addressses with those exported from address explorer + bitcoind = bitcoind_d_wallet_w_sk + use_regtest() + goto_home() + pick_menu_item("Advanced/Tools") + pick_menu_item("Export Wallet") + pick_menu_item("Descriptor") + time.sleep(.1) + _, story = cap_story() + assert "This saves a ranged xpub descriptor" in story + assert "Press (1) to enter a non-zero account number" in story + assert "sensitive--in terms of privacy" in story + assert "not compromise your funds directly" in story + + if isinstance(acct_num, str): + need_keypress("1") # chosse account number + for ch in acct_num: + need_keypress(ch) # input num + press_select() # confirm selection + else: + press_select() # confirm story + + time.sleep(.1) + _, story = cap_story() + assert "press (1) to export receiving and change descriptors separately" in story + need_keypress("1") + + sig_check = True + if addr_fmt == AF_P2WPKH: + menu_item = "Segwit P2WPKH" + desc_prefix = "wpkh(" + elif addr_fmt == AF_P2WPKH_P2SH: + menu_item = "P2SH-Segwit" + desc_prefix = "sh(wpkh(" + elif addr_fmt == AF_P2TR: + menu_item = "Taproot P2TR" + desc_prefix = "tr(" + sig_check = False + else: + # addr_fmt == AF_CLASSIC: + menu_item = "Classic P2PKH" + desc_prefix = "pkh(" + + pick_menu_item(menu_item) + contents = load_export("sd", label="Descriptor", is_json=False, addr_fmt=addr_fmt, + sig_check=sig_check) + descriptors = contents.strip() + ext_desc, int_desc = descriptors.split("\n") + assert ext_desc.startswith(desc_prefix) + assert int_desc.startswith(desc_prefix) + + # check both external and internal + for chng in [False, True]: + goto_address_explorer() + if acct_num: + menu = cap_menu() + # can be "Account number" or "Account: N" + mi = [m for m in menu if "Account" in m] + assert len(mi) == 1 + pick_menu_item(mi[0]) + enter_number(acct_num) + + desc = int_desc if chng else ext_desc + settings_set("axi", 0) + pick_menu_item(menu_item) + cc_addrs_gen = generate_addresses_file(change=chng, is_p2tr=addr_fmt == AF_P2TR) + cc_addrs = [addr for deriv, addr in cc_addrs_gen] + bitcoind_addrs = bitcoind.deriveaddresses(desc, [0, 249]) + assert cc_addrs == bitcoind_addrs + # EOF diff --git a/testing/test_export.py b/testing/test_export.py index d3d8a153..e74cd2f2 100644 --- a/testing/test_export.py +++ b/testing/test_export.py @@ -305,7 +305,7 @@ def test_export_electrum(way, dev, mode, acct_num, pick_menu_item, goto_home, ca @pytest.mark.parametrize('acct_num', [ None, '99', '1236']) @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc", "qr"]) -@pytest.mark.parametrize('testnet', [True, False]) +@pytest.mark.parametrize('chain', ["BTC", "XTN"]) @pytest.mark.parametrize('app', [ # no need to run them all - just name check differs ("Generic JSON", "Generic Export"), @@ -317,12 +317,12 @@ def test_export_electrum(way, dev, mode, acct_num, pick_menu_item, goto_home, ca ]) def test_export_coldcard(way, dev, acct_num, app, pick_menu_item, goto_home, cap_story, need_keypress, microsd_path, nfc_read_json, virtdisk_path, addr_vs_path, enter_number, - load_export, testnet, use_mainnet, press_select, + load_export, chain, use_mainnet, press_select, skip_if_useless_way, expect_acctnum_captured): skip_if_useless_way(way) - if not testnet: + if chain == "BTC": use_mainnet() export_mi, app_f_name = app @@ -377,8 +377,8 @@ def test_export_coldcard(way, dev, acct_num, app, pick_menu_item, goto_home, cap addr = v.get('first', None) if fn == 'bip44': - assert first.address(netcode="XTN" if testnet else "BTC") == v['first'] - addr_vs_path(addr, v['deriv'] + '/0/0', AF_CLASSIC, testnet=testnet) + assert first.address(chain=chain) == v['first'] + addr_vs_path(addr, v['deriv'] + '/0/0', AF_CLASSIC, chain=chain) elif ('bip48_' in fn) or (fn == 'bip45'): # multisig: cant do addrs assert addr == None @@ -389,11 +389,11 @@ def test_export_coldcard(way, dev, acct_num, app, pick_menu_item, goto_home, cap h20 = first.hash160() if fn == 'bip84': assert addr == bech32.encode(addr[0:2], 0, h20) - addr_vs_path(addr, v['deriv'] + '/0/0', AF_P2WPKH, testnet=testnet) + addr_vs_path(addr, v['deriv'] + '/0/0', AF_P2WPKH, chain=chain) elif fn == 'bip49': # don't have test logic for verifying these addrs # - need to make script, and bleh - assert first.address(addr_fmt="p2sh-p2wpkh", netcode="XTN" if testnet else "BTC") == v['first'] + assert first.address(addr_fmt="p2sh-p2wpkh", chain=chain) == v['first'] else: assert False @@ -455,15 +455,14 @@ def test_export_unchained(way, dev, pick_menu_item, goto_home, cap_story, need_k @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc", "qr"]) -@pytest.mark.parametrize('testnet', [True, False]) +@pytest.mark.parametrize('chain', ["BTC", "XTN"]) def test_export_public_txt(way, dev, pick_menu_item, goto_home, press_select, microsd_path, - addr_vs_path, virtdisk_path, nfc_read_text, cap_story, use_mainnet, - load_export, testnet, skip_if_useless_way): + addr_vs_path, virtdisk_path, nfc_read_text, cap_story, use_testnet, + load_export, chain, skip_if_useless_way): # test UX and values produced. skip_if_useless_way(way) - if not testnet: - use_mainnet() + use_testnet(chain == "XTN") goto_home() pick_menu_item('Advanced/Tools') pick_menu_item('File Management') @@ -481,7 +480,7 @@ def test_export_public_txt(way, dev, pick_menu_item, goto_home, press_select, mi xfp = xfp2str(simulator_fixed_xfp).upper() - ek = simulator_fixed_tprv if testnet else simulator_fixed_xprv + ek = simulator_fixed_tprv if chain == "XTN" else simulator_fixed_xprv root = BIP32Node.from_wallet_key(ek) for ln in fp: @@ -508,14 +507,16 @@ def test_export_public_txt(way, dev, pick_menu_item, goto_home, press_select, mi if not f: if rhs[0] in '1mn': f = AF_CLASSIC - elif rhs[0:3] in ['tb1', "bc1"]: + elif rhs[0:4] in ['tb1q', "bc1q"]: f = AF_P2WPKH + elif rhs[0:4] in ['tb1p', "bc1p"]: + f = AF_P2TR elif rhs[0] in '23': f = AF_P2WPKH_P2SH else: raise ValueError(rhs) - addr_vs_path(rhs, path=lhs, addr_fmt=f, testnet=testnet) + addr_vs_path(rhs, path=lhs, addr_fmt=f, chain=chain) @pytest.mark.qrcode @@ -538,6 +539,8 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home is_xfp = False if '-84' in m: expect = "m/84h/0h/{acct}h" + elif '86' in m and 'P2TR' in m: + expect = "m/86h/0h/{acct}h" elif '-44' in m: expect = "m/44h/0h/{acct}h" elif '49' in m: @@ -603,7 +606,7 @@ def test_export_xpub(use_nfc, acct_num, dev, cap_menu, pick_menu_item, goto_home @pytest.mark.parametrize("chain", ["BTC", "XTN", "XRT"]) @pytest.mark.parametrize("way", ["sd", "vdisk", "nfc", "qr"]) -@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC]) +@pytest.mark.parametrize("addr_fmt", [AF_P2WPKH, AF_P2WPKH_P2SH, AF_CLASSIC, AF_P2TR]) @pytest.mark.parametrize("acct_num", [None, 0, 1, (2 ** 31) - 1]) @pytest.mark.parametrize("int_ext", [True, False]) def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home, @@ -651,6 +654,10 @@ def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home, menu_item = "P2SH-Segwit" desc_prefix = "sh(wpkh(" bip44_purpose = 49 + elif addr_fmt == AF_P2TR: + menu_item = "Taproot P2TR" + desc_prefix = "tr(" + bip44_purpose = 86 else: # addr_fmt == AF_CLASSIC: menu_item = "Classic P2PKH" @@ -662,7 +669,11 @@ def test_generic_descriptor_export(chain, addr_fmt, acct_num, goto_home, expect_acctnum_captured(acct_num) - contents = load_export(way, label="Descriptor", is_json=False, addr_fmt=addr_fmt) + sig_check = True + if addr_fmt == AF_P2TR: + sig_check = False + contents = load_export(way, label="Descriptor", is_json=False, addr_fmt=addr_fmt, + sig_check=sig_check) descriptor = contents.strip() if int_ext is False: diff --git a/testing/test_hsm.py b/testing/test_hsm.py index 81583559..fc9ac937 100644 --- a/testing/test_hsm.py +++ b/testing/test_hsm.py @@ -594,7 +594,7 @@ def test_whitelist_single(dev, start_hsm, tweak_rule, attempt_psbt, fake_txn, wi start_hsm(policy) # try all addr types - for style in ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh']: + for style in ['p2wpkh', 'p2wsh', 'p2sh', 'p2pkh', 'p2wsh-p2sh', 'p2wpkh-p2sh', 'p2tr']: dests = [] psbt = fake_txn(1, 2, dev.master_xpub, outstyles=[style, 'p2wpkh'], @@ -1537,7 +1537,8 @@ def test_op_return_output_local(op_return_data, start_hsm, attempt_psbt, fake_tx def test_op_return_output_bitcoind(op_return_data, start_hsm, attempt_psbt, bitcoind_d_sim_watch, bitcoind, hsm_reset): cc = bitcoind_d_sim_watch dest_address = cc.getnewaddress() - bitcoind.supply_wallet.generatetoaddress(101, dest_address) + bitcoind.supply_wallet.sendtoaddress(dest_address, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) psbt = cc.walletcreatefundedpsbt([], [{dest_address: 1.0}, {"data": op_return_data.hex()}], 0, {"fee_rate": 20})["psbt"] policy = DICT(rules=[dict(max_amount=10)]) start_hsm(policy) diff --git a/testing/test_multisig.py b/testing/test_multisig.py index b64e5f52..4d26a52f 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -358,6 +358,7 @@ def test_ms_import_variations(N, make_multisig, offer_ms_import, press_cancel, i # the different addr formats for af in unmap_addr_fmt.keys(): + if af == "p2tr": continue config = f'format: {af}\n' config += '\n'.join(sk.hwif(as_private=False) for xfp,m,sk in keys) title, story = offer_ms_import(config) @@ -1385,7 +1386,7 @@ def test_ms_sign_myself(M, use_regtest, make_myself_wallet, segwit, num_ins, dev # IMPORTANT: wont work if you start simulator with --ms flag. Use no args - all_out_styles = list(unmap_addr_fmt.keys()) + all_out_styles = [af for af in unmap_addr_fmt.keys() if af != "p2tr"] num_outs = len(all_out_styles) clear_ms() diff --git a/testing/test_ownership.py b/testing/test_ownership.py index 181c9f57..29fc3595 100644 --- a/testing/test_ownership.py +++ b/testing/test_ownership.py @@ -5,9 +5,9 @@ import pytest, time, io, csv from txn import fake_address from base58 import encode_base58_checksum -from helpers import hash160 +from helpers import hash160, taptweak from bip32 import BIP32Node -from constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from constants import simulator_fixed_xprv, simulator_fixed_tprv, addr_fmt_names @pytest.fixture @@ -23,7 +23,7 @@ def wipe_cache(sim_exec): [14, 8, 26, 1, 7, 19] ''' @pytest.mark.parametrize('addr_fmt', [ - AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH + AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR ]) @pytest.mark.parametrize('testnet', [ False, True] ) def test_negative(addr_fmt, testnet, sim_exec): @@ -36,24 +36,26 @@ def test_negative(addr_fmt, testnet, sim_exec): assert 'Explained' in lst -@pytest.mark.parametrize('addr_fmt, testnet', [ - (AF_CLASSIC, True), - (AF_CLASSIC, False), - (AF_P2WPKH, True), - (AF_P2WPKH, False), - (AF_P2WPKH_P2SH, True), - (AF_P2WPKH_P2SH, False), +@pytest.mark.parametrize('addr_fmt, chain', [ + (AF_CLASSIC, "XTN"), + (AF_CLASSIC, "BTC"), + (AF_P2WPKH, "XTN"), + (AF_P2WPKH, "BTC"), + (AF_P2WPKH_P2SH, "XTN"), + (AF_P2WPKH_P2SH, "BTC"), + (AF_P2TR, "XTN"), + (AF_P2TR, "BTC"), # multisig - testnet only - (AF_P2WSH, True), - (AF_P2SH, True), - (AF_P2WSH_P2SH,True), + (AF_P2WSH, "XTN"), + (AF_P2SH, "XTN"), + (AF_P2WSH_P2SH, "XTN"), ]) @pytest.mark.parametrize('offset', [ 3, 760] ) @pytest.mark.parametrize('subaccount', [ 0, 34] ) @pytest.mark.parametrize('change_idx', [ 0, 1] ) @pytest.mark.parametrize('from_empty', [ True, False] ) -def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, +def test_positive(addr_fmt, offset, subaccount, chain, from_empty, change_idx, sim_exec, wipe_cache, make_myself_wallet, use_testnet, goto_home, pick_menu_item, enter_number, press_cancel, settings_set, import_ms_wallet, clear_ms ): @@ -61,17 +63,23 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, # API/Unit test, limited UX - if not testnet and addr_fmt in { AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH }: - # multisig jigs assume testnet - raise pytest.skip('testnet only') + if chain == "BTC": + use_testnet(False) + testnet = False + if addr_fmt in { AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH }: + # multisig jigs assume testnet + raise pytest.skip('testnet only') + + coin_type = 0 + if chain == "XTN": + use_testnet(True) + coin_type = 1 + testnet = True - use_testnet(testnet) if from_empty: wipe_cache() # very different codepaths settings_set('accts', []) - coin_type = 1 if testnet else 0 - if addr_fmt in { AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH }: from test_multisig import make_ms_address, HARD M, N = 1, 3 @@ -99,6 +107,9 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, elif addr_fmt == AF_P2WPKH: menu_item = expect_name = 'Segwit P2WPKH' path = "m/84h/{ct}h/{acc}h" + elif addr_fmt == AF_P2TR: + menu_item = expect_name = 'Taproot P2TR' + path = "m/86h/{ct}h/{acc}h" else: raise ValueError(addr_fmt) @@ -108,14 +119,18 @@ def test_positive(addr_fmt, offset, subaccount, testnet, from_empty, change_idx, # see addr_vs_path mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) - sk = mk.subkey_for_path(path[2:].replace('h', "'")) + sk = mk.subkey_for_path(path) if addr_fmt == AF_CLASSIC: - addr = sk.address(netcode="XTN" if testnet else "BTC") + addr = sk.address(chain=chain) elif addr_fmt == AF_P2WPKH_P2SH: pkh = sk.hash160() digest = hash160(b'\x00\x14' + pkh) addr = encode_base58_checksum(bytes([196 if testnet else 5]) + digest) + elif addr_fmt == AF_P2TR: + from bech32 import encode + tweked_xonly = taptweak(sk.sec()[1:]) + addr = encode("tb" if testnet else "bc", 1, tweked_xonly) else: pkh = sk.hash160() addr = bech32_encode('tb' if testnet else 'bc', 0, pkh) @@ -166,7 +181,7 @@ def test_ux(valid, testnet, method, mk = BIP32Node.from_wallet_key(simulator_fixed_tprv if testnet else simulator_fixed_xprv) path = "m/44h/{ct}h/{acc}h/0/3".format(acc=0, ct=(1 if testnet else 0)) sk = mk.subkey_for_path(path) - addr = sk.address(netcode="XTN" if testnet else "BTC") + addr = sk.address(chain="XTN" if testnet else "BTC") else: addr = fake_address(addr_fmt, testnet) @@ -220,19 +235,19 @@ def test_ux(valid, testnet, method, assert 'Searched ' in story assert 'candidates without finding a match' in story -@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "ms0"]) +@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "Taproot P2TR", "ms0"]) def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explorer, pick_menu_item, need_keypress, sim_exec, clear_ms, import_ms_wallet, press_select, goto_home, nfc_write, load_shared_mod, load_export_and_verify_signature, - cap_story): + cap_story, load_export): goto_home() wipe_cache() settings_set('accts', []) if af == "ms0": clear_ms() - import_ms_wallet(2,3, name=af) + import_ms_wallet(2, 3, name=af) press_select() # accept ms import goto_address_explorer() @@ -249,7 +264,13 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo return # multisig addresses are blanked title, body = cap_story() - contents, sig_addr = load_export_and_verify_signature(body, "sd", label="Address summary") + if af == "Taproot P2TR": + # p2tr - no signature file + contents = load_export("sd", label="Address summary", is_json=False, sig_check=False) + sig_addr = None + else: + contents, sig_addr = load_export_and_verify_signature(body, "sd", label="Address summary") + addr_dump = io.StringIO(contents) cc = csv.reader(addr_dump) hdr = next(cc) diff --git a/testing/test_paper.py b/testing/test_paper.py index 571f2467..5d7321ff 100644 --- a/testing/test_paper.py +++ b/testing/test_paper.py @@ -6,19 +6,19 @@ # This module can and should be run with `-l` and without it. # -import pytest, time, os, shutil, re, random +import pytest, time, os, shutil, re, random, json from binascii import a2b_hex from hashlib import sha256 from bip32 import PrivateKey from ckcc_protocol.constants import * -@pytest.mark.parametrize('mode', ["classic", 'segwit']) +@pytest.mark.parametrize('mode', ["classic", 'segwit', 'taproot']) @pytest.mark.parametrize('pdf', [False, True]) -@pytest.mark.parametrize('netcode', ["XTN", "BTC"]) +@pytest.mark.parametrize('netcode', ["XRT", "BTC", "XTN"]) def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, cap_story, need_keypress, microsd_path, verify_detached_signature_file, settings_set, - press_select): + press_select, validate_address, bitcoind): # test UX and operation of the 'bitcoin core' wallet export mx = "Don't make PDF" @@ -26,10 +26,7 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, goto_home() pick_menu_item('Advanced/Tools') - try: - pick_menu_item('Paper Wallets') - except: - raise pytest.skip('Feature absent') + pick_menu_item('Paper Wallets') time.sleep(0.1) title, story = cap_story() @@ -45,6 +42,11 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, pick_menu_item('Segwit P2WPKH') time.sleep(0.5) + if mode == 'taproot': + pick_menu_item('Classic P2PKH') + pick_menu_item('Taproot P2TR') + time.sleep(0.5) + if pdf: assert mx in cap_menu() shutil.copy('../docs/paperwallet.pdf', microsd_path('paperwallet.pdf')) @@ -58,7 +60,7 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, time.sleep(0.1) title, story = cap_story() - if "Press (1) to save paper wallet file to SD Card" in story: + if "Press (1)" in story: need_keypress("1") time.sleep(0.2) title, story = cap_story() @@ -68,20 +70,32 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, story = [i for i in story.split('\n') if i] sig_file = story[-1] if not pdf: - fname = story[-2] - fnames = [fname] + if mode == "taproot": + fname = story[-1] + else: + fname = story[-2] + fnames = [fname] else: - fname = story[-3] - pdf_name = story[-2] - fnames = [fname, pdf_name] + if mode == "taproot": + fname = story[-2] + pdf_name = story[-1] + else: + fname = story[-3] + pdf_name = story[-2] + fnames = [fname, pdf_name] assert pdf_name.endswith('.pdf') assert fname.endswith('.txt') - assert sig_file.endswith(".sig") - verify_detached_signature_file(fnames, sig_file, "sd", - addr_fmt=AF_CLASSIC if mode == "classic" else AF_P2WPKH) + if mode != 'taproot': + assert sig_file.endswith(".sig") + verify_detached_signature_file(fnames, sig_file, "sd", + addr_fmt=AF_CLASSIC if mode == "classic" else AF_P2WPKH) path = microsd_path(fname) + _wif = None + _sk = None + _addr = None + _idesc = None with open(path, 'rt') as fp: hdr = None for ln in fp: @@ -98,27 +112,46 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, val = ln.strip() if 'Deposit address' in hdr: assert val == fname.split('.', 1)[0].split('-', 1)[0] - txt_addr = val - addr = val + _addr = val elif hdr == 'Private key:': # for QR case - assert val == wif + assert val == _wif elif 'Private key' in hdr and 'WIF=Wallet' in hdr: - wif = val - k1 = PrivateKey.from_wif(val) + _wif = val elif 'Private key' in hdr and 'Hex, 32 bytes' in hdr: - k2 = PrivateKey(sec_exp=a2b_hex(val)) + _sk = val elif 'Bitcoin Core command': - assert wif in val - assert 'importmulti' in val or 'importprivkey' in val + assert _wif in val + if 'importdescriptors' in val: + _idesc = val + assert 'importprivkey' in val or 'importdescriptors' in val else: print(f'{hdr} => {val}') raise ValueError(hdr) - assert k1.K.sec() == k2.K.sec() - assert addr == k1.K.address(addr_fmt="p2wpkh" if mode == "segwit" else "p2pkh", - testnet=True if netcode == "XTN" else False) + if netcode != "XRT": + from bip32 import PrivateKey + k1 = PrivateKey.from_wif(_wif) + k2 = PrivateKey.parse(a2b_hex(_sk)) + assert k1 == k2 + validate_address(_addr, k1) + else: + if mode == "segwit": + assert _addr.startswith("bcrt1q") + elif mode == "taproot": + assert _addr.startswith("bcrt1p") + else: + assert _addr[0] in "mn" - os.unlink(path) + # bitcoind on regtest + conn = bitcoind.create_wallet(wallet_name="paper", disable_private_keys=False, blank=True, + passphrase=None, avoid_reuse=False, descriptors=True) + desc_obj_s, desc_obj_e = _idesc.find("["), _idesc.find("]") + 1 + desc_obj = json.loads(_idesc[desc_obj_s:desc_obj_e]) + desc = desc_obj[0]["desc"] + res = conn.importdescriptors(desc_obj) + assert res[0]["success"] + assert _addr == conn.deriveaddresses(desc)[0] + bitcoind.delete_wallet_files() if not pdf: return @@ -126,8 +159,8 @@ def test_generate(mode, pdf, netcode, dev, cap_menu, pick_menu_item, goto_home, with open(path, 'rb') as fp: d = fp.read() - assert wif.encode('ascii') in d - assert txt_addr.encode('ascii') in d + assert _wif.encode('ascii') in d + assert _addr.encode('ascii') in d os.unlink(path) @@ -276,7 +309,7 @@ def test_dice_generate(rolls, testnet, dev, cap_menu, pick_menu_item, goto_home, val, = hx k2 = PrivateKey(sec_exp=a2b_hex(val)) - assert addr == k2.K.address(testnet=testnet, addr_fmt="p2pkh") + assert addr == k2.K.address(chain="XTN" if testnet else "BTC", addr_fmt="p2pkh") assert val == sha256(rolls.encode('ascii')).hexdigest() diff --git a/testing/test_sign.py b/testing/test_sign.py index 99226adc..4c58ce1b 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -12,7 +12,7 @@ from pprint import pprint, pformat from decimal import Decimal from base64 import b64encode, b64decode from base58 import encode_base58_checksum -from helpers import B2A, U2SAT, prandom, fake_dest_addr, make_change_addr, parse_change_back +from helpers import B2A, fake_dest_addr, parse_change_back from helpers import xfp2str, seconds2human_readable, hash160 from msg import verify_message from bip32 import BIP32Node @@ -133,8 +133,8 @@ def test_psbt_proxy_parsing(fn, sim_execfile, sim_exec): assert oo == rb @pytest.mark.unfinalized -def test_speed_test(dev, fake_txn, is_mark3, is_mark4, start_sign, end_sign, - press_select): +@pytest.mark.parametrize("taproot", [True, False]) +def test_speed_test(dev, taproot, fake_txn, is_mark3, is_mark4, start_sign, end_sign, press_select): # measure time to sign a larger txn if is_mark4: # Mk4: expect @@ -149,7 +149,10 @@ def test_speed_test(dev, fake_txn, is_mark3, is_mark4, start_sign, end_sign, num_in = 9 num_out = 100 - psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=True) + if taproot: + psbt = fake_txn(num_in, num_out, dev.master_xpub, taproot_in=True) + else: + psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=True) open('debug/speed.psbt', 'wb').write(psbt) dt = time.time() @@ -191,8 +194,9 @@ if 0: @pytest.mark.bitcoind @pytest.mark.veryslow @pytest.mark.parametrize('segwit', [True, False]) -def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, - start_sign, end_sign, dev, segwit, accept = True): +@pytest.mark.parametrize('taproot', [True, False]) +def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, is_mark3, is_mark4, + start_sign, end_sign, dev, segwit, taproot, accept=True): # try a bunch of different bigger sized txns # - important to test on real device, due to it's limited memory @@ -209,7 +213,8 @@ def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, num_in = 250 num_out = 2000 - psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=segwit, outstyles=ADDR_STYLES) + psbt = fake_txn(num_in, num_out, dev.master_xpub, segwit_in=segwit, + taproot_in=taproot, outstyles=ADDR_STYLES) open('debug/last.psbt', 'wb').write(psbt) @@ -262,11 +267,13 @@ def test_io_size(request, use_regtest, decode_with_bitcoind, fake_txn, @pytest.mark.bitcoind @pytest.mark.parametrize('num_ins', [ 2, 7, 15 ]) @pytest.mark.parametrize('segwit', [True, False]) -def test_real_signing(fake_txn, use_regtest, try_sign, dev, num_ins, segwit, decode_with_bitcoind): +@pytest.mark.parametrize('taproot', [True, False]) +def test_real_signing(fake_txn, use_regtest, try_sign, dev, num_ins, segwit,taproot, + decode_with_bitcoind): # create a TXN using actual addresses that are correct for DUT xp = dev.master_xpub - psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit) + psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit, taproot_in=taproot) open('debug/real-%d.psbt' % num_ins, 'wb').write(psbt) _, txn = try_sign(psbt, accept=True, finalize=True) @@ -908,16 +915,17 @@ def test_network_fee_unlimited(fake_txn, start_sign, end_sign, dev, settings_set @pytest.mark.parametrize('num_outs', [ 2, 7, 15 ]) @pytest.mark.parametrize('act_outs', [ 2, 1, -1]) @pytest.mark.parametrize('segwit', [True, False]) +@pytest.mark.parametrize('taproot', [True, False]) @pytest.mark.parametrize('add_xpub', [True, False]) @pytest.mark.parametrize('out_style', ADDR_STYLES_SINGLE) @pytest.mark.parametrize('visualized', [0, STXN_VISUALIZE, STXN_VISUALIZE|STXN_SIGNED]) def test_change_outs(fake_txn, start_sign, end_sign, cap_story, dev, num_outs, master_xpub, - act_outs, segwit, out_style, visualized, add_xpub, num_ins=3): + act_outs, segwit, taproot, out_style, visualized, add_xpub, num_ins=3): # create a TXN which has change outputs, which shouldn't be shown to user, and also not fail. xp = dev.master_xpub couts = num_outs if act_outs == -1 else num_ins-act_outs - psbt = fake_txn(num_ins, num_outs, xp, segwit_in=segwit, + psbt = fake_txn(num_ins, num_outs, xp, segwit_in=segwit, taproot_in=taproot, outstyles=[out_style], change_outputs=range(couts), add_xpub=add_xpub) open('debug/change.psbt', 'wb').write(psbt) @@ -1209,8 +1217,10 @@ def hist_count(sim_exec): @pytest.mark.parametrize('num_utxo', [9, 100]) @pytest.mark.parametrize('segwit_in', [False, True]) -def test_bip143_attack_data_capture(num_utxo, segwit_in, try_sign, fake_txn, settings_set, - settings_get, cap_story, sim_exec, hist_count): +@pytest.mark.parametrize('taproot_in', [False, True]) +def test_bip143_attack_data_capture(num_utxo, segwit_in, taproot_in, try_sign, fake_txn, + settings_set, settings_get, cap_story, sim_exec, + hist_count): # cleanup prev runs, if very first time thru sim_exec('import history; history.OutptValueCache.clear()') @@ -1219,12 +1229,13 @@ def test_bip143_attack_data_capture(num_utxo, segwit_in, try_sign, fake_txn, set # make a txn, capture the outputs of that as inputs for another txn psbt = fake_txn(1, num_utxo+3, segwit_in=segwit_in, change_outputs=range(num_utxo+2), - outstyles=(['p2wpkh']*num_utxo) + ['p2wpkh-p2sh', 'p2pkh']) + taproot_in=taproot_in, + outstyles=(['p2wpkh']*num_utxo) + ['p2wpkh-p2sh', 'p2pkh']) _, txn = try_sign(psbt, accept=True, finalize=True) open('debug/funding.psbt', 'wb').write(psbt) - num_inp_utxo = (1 if segwit_in else 0) + num_inp_utxo = (1 if (segwit_in or taproot_in) else 0) time.sleep(.1) title, story = cap_story() @@ -1268,12 +1279,15 @@ def test_bip143_attack_data_capture(num_utxo, segwit_in, try_sign, fake_txn, set @pytest.mark.parametrize('segwit', [False, True]) +@pytest.mark.parametrize('taproot', [False, True]) @pytest.mark.parametrize('num_ins', [1, 17]) -def test_txid_calc(num_ins, fake_txn, try_sign, dev, segwit, decode_with_bitcoind, cap_story): +@pytest.mark.parametrize('num_outs', [1, 17]) +def test_txid_calc(num_ins, fake_txn, try_sign, dev, segwit, decode_with_bitcoind, + cap_story, taproot, num_outs): # verify correct txid for transactions is being calculated xp = dev.master_xpub - psbt = fake_txn(num_ins, 1, xp, segwit_in=segwit) + psbt = fake_txn(num_ins, num_outs, xp, segwit_in=segwit, taproot_in=taproot) _, txn = try_sign(psbt, accept=True, finalize=True) @@ -1320,7 +1334,7 @@ def test_sdcard_signing(encoding, num_outs, del_after, partial, try_sign_microsd pp = psbt.inputs[0].bip32_paths[pk] psbt.inputs[0].bip32_paths[pk] = b'what' + pp[4:] - psbt = fake_txn(2, num_outs, xp, segwit_in=True, psbt_hacker=hack) + psbt = fake_txn(3, num_outs, xp, segwit_in=True, taproot_in=True, psbt_hacker=hack) _, txn, txid = try_sign_microsd(psbt, finalize=not partial, encoding=encoding, del_after=del_after) @@ -1352,7 +1366,8 @@ def test_payjoin_signing(num_ins, num_outs, fake_txn, try_sign, start_sign, end_ txn = end_sign(True, finalize=False) @pytest.mark.parametrize('segwit', [False, True]) -def test_fully_unsigned(fake_txn, try_sign, segwit): +@pytest.mark.parametrize('taproot', [False, True]) +def test_fully_unsigned(fake_txn, try_sign, segwit, taproot): # A PSBT which is unsigned but all inputs lack keypaths @@ -1360,8 +1375,9 @@ def test_fully_unsigned(fake_txn, try_sign, segwit): # change all inputs to be "not ours" ... but with utxo details for i in psbt.inputs: i.bip32_paths.clear() + i.taproot_bip32_paths.clear() - psbt = fake_txn(7, 2, segwit_in=segwit, psbt_hacker=hack) + psbt = fake_txn(7, 2, segwit_in=segwit, taproot_in=taproot, psbt_hacker=hack) with pytest.raises(CCProtoError) as ee: orig, result = try_sign(psbt, accept=True) @@ -1369,7 +1385,8 @@ def test_fully_unsigned(fake_txn, try_sign, segwit): assert 'does not contain any key path information' in str(ee) @pytest.mark.parametrize('segwit', [False, True]) -def test_wrong_xfp(fake_txn, try_sign, segwit): +@pytest.mark.parametrize('taproot', [False, True]) +def test_wrong_xfp(fake_txn, try_sign, segwit, taproot): # A PSBT which is unsigned and doesn't involve our XFP value @@ -1380,8 +1397,10 @@ def test_wrong_xfp(fake_txn, try_sign, segwit): for i in psbt.inputs: for pubkey in i.bip32_paths: i.bip32_paths[pubkey] = wrong_xfp + i.bip32_paths[pubkey][4:] + for xonly_pubkey in i.taproot_bip32_paths: + i.taproot_bip32_paths[xonly_pubkey] = b"\x00" + wrong_xfp + i.taproot_bip32_paths[xonly_pubkey][5:] - psbt = fake_txn(7, 2, segwit_in=segwit, psbt_hacker=hack) + psbt = fake_txn(7, 2, segwit_in=segwit, taproot_in=taproot, psbt_hacker=hack) with pytest.raises(CCProtoError) as ee: orig, result = try_sign(psbt, accept=True) @@ -1390,7 +1409,8 @@ def test_wrong_xfp(fake_txn, try_sign, segwit): assert 'found 12345678' in str(ee) @pytest.mark.parametrize('segwit', [False, True]) -def test_wrong_xfp_multi(fake_txn, try_sign, segwit): +@pytest.mark.parametrize('taproot', [False, True]) +def test_wrong_xfp_multi(fake_txn, try_sign, segwit, taproot): # A PSBT which is unsigned and doesn't involve our XFP value # - but multiple wrong XFP values @@ -1404,8 +1424,12 @@ def test_wrong_xfp_multi(fake_txn, try_sign, segwit): here = struct.pack(' Date: Wed, 12 Jun 2024 16:32:30 +0200 Subject: [PATCH 08/15] miniscript/tapscript; BSMS; show multisig/miniscript addresses in exports --- cli/signit.py | 2 +- docs/miniscript.md | 27 + shared/actions.py | 8 + shared/address_explorer.py | 76 +- shared/auth.py | 172 +- shared/backups.py | 2 +- shared/bsms.py | 1092 +++++++++++++ shared/chains.py | 8 +- shared/decoders.py | 8 +- shared/desc_utils.py | 519 +++++++ shared/descriptor.py | 890 +++++++---- shared/display.py | 8 +- shared/export.py | 97 +- shared/flow.py | 4 + shared/hsm.py | 47 +- shared/manifest.py | 3 + shared/miniscript.py | 1878 ++++++++++++++++++++++ shared/multisig.py | 334 ++-- shared/nfc.py | 100 +- shared/nvstore.py | 3 +- shared/opcodes.py | 3 +- shared/ownership.py | 30 +- shared/psbt.py | 160 +- shared/serializations.py | 8 +- shared/usb.py | 80 +- shared/utils.py | 90 +- shared/ux_q1.py | 5 +- shared/version.py | 3 + shared/wallet.py | 129 +- stm32/MK4-Makefile | 2 +- stm32/Q1-Makefile | 2 +- testing/conftest.py | 111 +- testing/descriptor.py | 468 ++++++ testing/devtest/clear_seed.py | 1 + testing/devtest/wipe_miniscript.py | 13 + testing/test_address_explorer.py | 8 +- testing/test_bsms.py | 1654 ++++++++++++++++++++ testing/test_decoders.py | 29 +- testing/test_export.py | 89 +- testing/test_hsm.py | 143 +- testing/test_miniscript.py | 2327 ++++++++++++++++++++++++++++ testing/test_multisig.py | 434 +++--- testing/test_ownership.py | 26 +- testing/test_sign.py | 4 +- 44 files changed, 10056 insertions(+), 1041 deletions(-) create mode 100644 docs/miniscript.md create mode 100644 shared/bsms.py create mode 100644 shared/desc_utils.py create mode 100644 shared/miniscript.py create mode 100644 testing/descriptor.py create mode 100644 testing/devtest/wipe_miniscript.py create mode 100644 testing/test_bsms.py create mode 100644 testing/test_miniscript.py diff --git a/cli/signit.py b/cli/signit.py index fd7bc0b1..ec100c9c 100755 --- a/cli/signit.py +++ b/cli/signit.py @@ -319,7 +319,7 @@ def doit(keydir, outfn=None, build_dir=None, high_water=False, pubkey_num=pubkey_num, timestamp=timestamp(backdate) ) - assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH, hdr.firmware_length + assert FW_MIN_LENGTH <= hdr.firmware_length <= FW_MAX_LENGTH_MK4, hdr.firmware_length if hw_compat & MK_3_OK: # actual file length limited by size of SPI flash area reserved to txn data/uploads diff --git a/docs/miniscript.md b/docs/miniscript.md new file mode 100644 index 00000000..a8a618c9 --- /dev/null +++ b/docs/miniscript.md @@ -0,0 +1,27 @@ +# 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 +* export extended keys to participate in miniscript: + * `Advanced/Tools` -> `Export Wallet` -> `Generic JSON` + * `Settings` -> `Multisig Wallets` -> `Export XPUB` + +## 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 change indexes in subderivation has to be different) +* subderivation may be omitted during the import - default `<0;1>/*` 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` \ No newline at end of file diff --git a/shared/actions.py b/shared/actions.py index 3f0d0927..217a3592 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -872,6 +872,14 @@ async def start_login_sequence(): # is early in boot process print("XFP save failed: %s" % exc) + # Version warning before HSM is offered + if version.is_edge and not ckcc.is_simulator(): + await ux_show_story( + "This preview version of firmware has not yet been qualified and " + "tested to the same standard as normal Coinkite products." + "\n\nIt is recommended only for developers and early adopters for experimental use. " + "DO NOT use for large Bitcoin amounts.", title="Edge Version") + dis.draw_status(xfp=settings.get('xfp')) # If HSM policy file is available, offer to start that, diff --git a/shared/address_explorer.py b/shared/address_explorer.py index b523a3d8..ecfcfa62 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -10,25 +10,15 @@ from ux import export_prompt_builder, import_export_prompt_decode from menu import MenuSystem, MenuItem from public_constants import AFC_BECH32, AFC_BECH32M, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from multisig import MultisigWallet +from miniscript import MiniScriptWallet from uasyncio import sleep_ms 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, censor_address +from utils import addr_fmt_label, truncate_address from charcodes import KEY_QR, KEY_NFC, KEY_PAGE_UP, KEY_PAGE_DOWN, KEY_HOME, KEY_LEFT, KEY_RIGHT from charcodes import KEY_CANCEL -def truncate_address(addr): - # Truncates address to width of screen, replacing middle chars - if not version.has_qwerty: - # - 16 chars screen width - # - but 2 lost at left (menu arrow, corner arrow) - # - want to show not truncated on right side - return addr[0:6] + '⋯' + addr[-6:] - else: - # tons of space on Q1 - return addr[0:12] + '⋯' + addr[-12:] class KeypathMenu(MenuSystem): def __init__(self, path=None, nl=0): @@ -213,7 +203,11 @@ class AddressListMenu(MenuSystem): # 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)) @@ -245,10 +239,10 @@ class AddressListMenu(MenuSystem): 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 - settings.put('axi', item.label) # update last clicked address - await self.show_n_addresses(None, None, ms_wallet) + 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, msc_wallet.addr_fmt, msc_wallet) async def make_custom(self, *a): # picking a custom derivation path: makes a tree of menus, with chance @@ -280,7 +274,7 @@ Press (3) if you really understand and accept these risks. start = self.start - def make_msg(change=0): + def make_msg(change=0, start=start, n=n): # Build message and CTA about export, plus the actual addresses. if n: msg = "Addresses %d⋯%d:\n\n" % (start, min(start + n - 1, MAX_BIP32_IDX)) @@ -293,21 +287,7 @@ Press (3) if you really understand and accept these risks. dis.fullscreen('Wait...') if ms_wallet: - # IMPORTANT safety feature: never show complete address - # but show enough they can verify addrs shown elsewhere. - # - makes a redeem script - # - converts into addr - # - assumes 0/0 is first address. - for idx, addr, paths, script in ms_wallet.yield_addresses(start, n, change): - addrs.append(censor_address(addr)) - - if idx == 0 and ms_wallet.N <= 4: - msg += '\n'.join(paths) + '\n =>\n' - else: - msg += '⋯/%d/%d =>\n' % (change, idx) - - msg += truncate_address(addr) + '\n\n' - dis.progress_sofar(idx-start+1, n) + msg, addrs = ms_wallet.make_addresses_msg(msg, start, n, change) else: # single-signer wallets @@ -328,7 +308,7 @@ Press (3) if you really understand and accept these risks. no_qr=bool(ms_wallet), key0=k0, force_prompt=True) if version.has_qwerty: - escape += KEY_LEFT+KEY_RIGHT+KEY_HOME+KEY_PAGE_UP+KEY_PAGE_DOWN + escape += KEY_LEFT+KEY_RIGHT+KEY_HOME+KEY_PAGE_UP+KEY_PAGE_DOWN+KEY_QR else: escape += "79" @@ -342,8 +322,8 @@ Press (3) if you really understand and accept these risks. return msg, addrs, escape - msg, addrs, escape = make_msg() change = 0 + msg, addrs, escape = make_msg(change, start) while 1: ch = await ux_show_story(msg, escape=escape) @@ -365,14 +345,9 @@ Press (3) if you really understand and accept these risks. elif choice == KEY_QR: # switch into a mode that shows them as QR codes - if ms_wallet: - # requires not multisig - continue - from ux import show_qr_codes is_alnum = bool(addr_fmt & (AFC_BECH32 | AFC_BECH32M)) await show_qr_codes(addrs, is_alnum, start) - continue elif NFC and (choice == KEY_NFC): @@ -406,7 +381,7 @@ Press (3) if you really understand and accept these risks. else: continue # 3 in non-NFC mode - msg, addrs, escape = make_msg(change) + msg, addrs, escape = make_msg(change, start) def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, change=0): # Produce CSV file contents as a generator @@ -414,28 +389,13 @@ def generate_address_csv(path, addr_fmt, ms_wallet, account_num, n, start=0, cha from ownership import OWNERSHIP if ms_wallet: - # For multisig, include redeem script and derivation for each signer - yield '"' + '","'.join(['Index', 'Payment Address', 'Redeem Script'] - + ['Derivation (%d of %d)' % (i+1, ms_wallet.N) for i in range(ms_wallet.N)] - ) + '"\n' - if (start == 0) and (n > 100) and change in (0, 1): saver = OWNERSHIP.saver(ms_wallet, change, start) else: saver = None - for (idx, addr, derivs, script) in ms_wallet.yield_addresses(start, n, change_idx=change): - if saver: - saver(addr) - - # policy choice: never provide a complete multisig address to user. - addr = censor_address(addr) - - ln = '%d,"%s","%s","' % (idx, addr, b2a_hex(script).decode()) - ln += '","'.join(derivs) - ln += '"\n' - - yield ln + for line in ms_wallet.generate_address_csv(start, n, change): + yield line if saver: saver(None) # close file diff --git a/shared/auth.py b/shared/auth.py index 07f9e96f..507b9869 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -3,7 +3,7 @@ # Operations that require user authorization, like our core features: signing messages # and signing bitcoin transactions. # -import stash, ure, ux, chains, sys, gc, uio, version, ngu +import stash, ure, ux, chains, sys, gc, uio, version, ngu, ujson from ubinascii import b2a_base64, a2b_base64 from ubinascii import hexlify as b2a_hex from ubinascii import unhexlify as a2b_hex @@ -1430,7 +1430,7 @@ class ShowP2SHAddress(ShowAddressBase): # calculate all the pubkeys involved. self.subpath_help = ms.validate_script(witdeem_script, xfp_paths=xfp_paths) - self.address = ms.chain.p2sh_address(addr_fmt, witdeem_script) + self.address = chains.current_chain().p2sh_address(addr_fmt, witdeem_script) def get_msg(self): return '''\ @@ -1446,6 +1446,41 @@ Paths: {sp}'''.format(addr=self.address, name=self.ms.name, M=self.ms.M, N=self.ms.N, sp='\n\n'.join(self.subpath_help)) + +class ShowMiniscriptAddress(ShowAddressBase): + + def setup(self, msc, change, idx): + self.msc = msc + self.change = change + self.idx = idx + + d = self.msc.desc.derive(None, change=change).derive(idx) + self.address = chains.current_chain().render_address(d.script_pubkey()) + self.addr_fmt = self.msc.addr_fmt + + def get_msg(self): + return '''\ +{addr} +Wallet: + {name} + +Index: + {idx} + +Change: + {change}'''.format(addr=self.address, name=self.msc.name, idx=self.idx, change=bool(self.change)) + + +def start_show_miniscript_address(msc, change, index): + UserAuthorizedAction.check_busy(ShowAddressBase) + UserAuthorizedAction.active_request = ShowMiniscriptAddress(msc, change, index) + + # kill any menu stack, and put our thing at the top + abort_and_goto(UserAuthorizedAction.active_request) + + # provide the value back to attached desktop + return UserAuthorizedAction.active_request.address + def start_show_p2sh_address(M, N, addr_format, xfp_paths, witdeem_script): # Show P2SH address to user, also returns it. # - first need to find appropriate multisig wallet associated @@ -1504,26 +1539,47 @@ def usb_show_address(addr_format, subpath): return active_request.address -class NewEnrollRequest(UserAuthorizedAction): - def __init__(self, ms, auto_export=False): +class MiniscriptDeleteRequest(UserAuthorizedAction): + def __init__(self, msc): super().__init__() - self.wallet = ms - self.auto_export = auto_export - - # self.result ... will be re-serialized xpub + self.wallet = msc async def interact(self): - from multisig import MultisigOutOfSpace + from miniscript import miniscript_delete + await miniscript_delete(self.wallet) + self.done() + + +def maybe_delete_miniscript(msc): + UserAuthorizedAction.cleanup() + UserAuthorizedAction.active_request = MiniscriptDeleteRequest(msc) + + # kill any menu stack, and put our thing at the top + abort_and_goto(UserAuthorizedAction.active_request) + +class NewMiniscriptEnrollRequest(UserAuthorizedAction): + def __init__(self, msc, auto_export=False, bsms_index=None): + super().__init__() + self.wallet = msc + self.auto_export = auto_export + self.bsms_index = bsms_index + + async def interact(self): + from wallet import WalletOutOfSpace ms = self.wallet try: ch = await ms.confirm_import() - if ch == 'y': + if ch in 'y' + KEY_ENTER: + if self.bsms_index is not None: + # remove signer round 2 from settings after multisig import is approved by user + 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: @@ -1531,39 +1587,89 @@ class NewEnrollRequest(UserAuthorizedAction): 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 - self.pop_menu() + 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 + from multisig import MultisigMenu + while 1: + top = the_ux.top_of_stack() + if not top: break + if not isinstance(top, MultisigMenu): + the_ux.pop() + continue + break + restore_menu() + else: + self.pop_menu() -def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False): - # Offer to import (enroll) a new multisig wallet. Allow reject by user. + +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/miniscript wallet. Allow reject by user. + from glob import dis from multisig import MultisigWallet + from miniscript import MiniScriptWallet UserAuthorizedAction.cleanup() + dis.fullscreen('Wait...') + dis.busy_bar(True) - if sf_len: - with SFFile(TXN_INPUT_OFFSET, length=sf_len) as fd: - config = fd.read(sf_len).decode() + try: + if sf_len: + with SFFile(TXN_INPUT_OFFSET, length=sf_len) as fd: + config = fd.read(sf_len).decode() - # 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) + try: + j_conf = ujson.loads(config) + assert "desc" in j_conf, "'desc' key required" + config = j_conf["desc"] + assert isinstance(config, str), "'desc' value not a str" + assert config, "'desc' empty" - UserAuthorizedAction.active_request = NewEnrollRequest(ms) + if "name" in j_conf: + # name from json has preference over filenames and desc checksum + name = j_conf["name"] + assert isinstance(name, str), "'name' value not a str" + assert len(name) >= 2, "'name' too short" + assert len(name) <= 40, "'name' too long (max 40)" + except ValueError: pass + + # this call will raise on parsing errors, so let them rise up + # and be shown on screen/over usb + if miniscript is None: + # autodetect + try: + msc = MiniScriptWallet.from_file(config, name=name) + except AssertionError: + msc = MultisigWallet.from_file(config, name=name) + + elif miniscript: + msc = MiniScriptWallet.from_file(config, name=name) + else: + msc = MultisigWallet.from_file(config, name=name) + + UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc, bsms_index=bsms_index) + + if ux_reset: + # for USB case, and import from PSBT + # kill any menu stack, and put our thing at the top + abort_and_goto(UserAuthorizedAction.active_request) + else: + # menu item case: add to stack + from ux import the_ux + the_ux.push(UserAuthorizedAction.active_request) + + except Exception as e: + raise + finally: + dis.busy_bar(False) - if ux_reset: - # for USB case, and import from PSBT - # kill any menu stack, and put our thing at the top - abort_and_goto(UserAuthorizedAction.active_request) - else: - # menu item case: add to stack - from ux import the_ux - the_ux.push(UserAuthorizedAction.active_request) class FirmwareUpgradeRequest(UserAuthorizedAction): def __init__(self, hdr, length, hdr_check=False, psram_offset=None): diff --git a/shared/backups.py b/shared/backups.py index cd453547..e6eb2c0b 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -280,7 +280,7 @@ async def restore_tmp_from_dict_ll(vals): if not k[:8] == "setting.": continue key = k[8:] - if key in ["multisig"]: + if key in ["multisig", "miniscript"]: # whitelist settings.set(k, v) diff --git a/shared/bsms.py b/shared/bsms.py new file mode 100644 index 00000000..df6e2031 --- /dev/null +++ b/shared/bsms.py @@ -0,0 +1,1092 @@ + +# (c) Copyright 2022 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# bsms.py - Bitcoin Secure Multisig Setup: BIP-129 +# +# For faster testing... +# ./simulator.py --seq 99y3y4y +# +import ngu, os, stash, chains, aes256ctr, version +from ubinascii import b2a_base64, a2b_base64 +from ubinascii import unhexlify as a2b_hex +from ubinascii import hexlify as b2a_hex + +from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_CLASSIC, MAX_SIGNERS +from utils import xfp2str, problem_file_line +from menu import MenuSystem, MenuItem +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, _import_prompt_builder, export_prompt_builder +from descriptor import Descriptor, Key, append_checksum +from miniscript import Sortedmulti, Number +from charcodes import KEY_NFC, KEY_QR + + +BSMS_VERSION = "BSMS 1.0" +ALLOWED_PATH_RESTRICTIONS = "/0/*,/1/*" + +ENCRYPTION_TYPES = { + "1": "STANDARD", + "2": "EXTENDED", + "3": "NO ENCRYPTION" +} + +class RejectAutoCollection(BaseException): + pass + +class BSMSOutOfSpace(RuntimeError): + # should not be a concern on Mk4 and later; just in case, handle well. + pass + +def exceptions_handler(f): + nice_name = " ".join(f.__name__.split("_")).replace("bsms", "BSMS") + async def new_func(*args): + try: + await f(*args) + except BaseException as e: + await ux_show_story(title="FAILURE", msg='%s\n\n%s failed\n%s' % (e, nice_name, problem_file_line(e))) + return new_func + + +def normalize_token(token_hex): + if token_hex[:2] in ["0x", "0X"]: + token_hex = token_hex[2:] # remove 0x prefix + return token_hex + + +def validate_token(token_hex): + if token_hex == "00": + return + try: + int(token_hex, 16) + except: + raise ValueError("Invalid token: %s" % token_hex) + if len(token_hex) not in [16, 32]: + raise ValueError("Invalid token length. Expected 64 or 128 bits (16 or 32 hex characters)") + + +def key_derivation_function(token_hex): + if token_hex == "00": + return + return ngu.hash.pbkdf2_sha512("No SPOF", a2b_hex(token_hex), 2048)[:32] + + +def hmac_key(key): + return ngu.hash.sha256s(key) + + +def msg_auth_code(key, token_hex, data): + msg_str = token_hex + data + msg_bytes = bytes(msg_str, "utf-8") + return ngu.hmac.hmac_sha256(key, msg_bytes) + + +def bsms_decrypt(key, data_bytes): + mac, ciphertext = data_bytes[:32], data_bytes[32:] + iv = mac[:16] + decrypt = aes256ctr.new(key, iv) + decrypted = decrypt.cipher(ciphertext) + try: + plaintext = decrypted.decode() + if not plaintext.startswith("BSMS"): + raise ValueError + return plaintext + except: + # failed decryption + return "" + + +def bsms_encrypt(key, token_hex, data_str): + hmac_k = hmac_key(key) + mac = msg_auth_code(hmac_k, token_hex, data_str) + iv = mac[:16] + encrypt = aes256ctr.new(key, iv) + ciphertext = encrypt.cipher(data_str) + + return mac + ciphertext + + +def signer_data_round1(token_hex, desc_type_key, key_description, sig_bytes=None): + result = "%s\n" % BSMS_VERSION + result += "%s\n" % token_hex + result += "%s\n" % desc_type_key + result += "%s" % key_description + + if sig_bytes: + sig = b2a_base64(sig_bytes).decode().strip() + result += "\n" + sig + + return result + + +def coordinator_data_round2(desc_template, addr, path_restrictions=ALLOWED_PATH_RESTRICTIONS): + result = "%s\n" % BSMS_VERSION + result += "%s\n" % desc_template + result += "%s\n" % path_restrictions + result += "%s" % addr + + return result + + +def token_summary(tokens): + if len(tokens) == 1: + return tokens[0] + + numbered_tokens = ["%d. %s" % (i, token) for i, token in enumerate(tokens, start=1)] + return "\n\n".join(numbered_tokens) + + +def coordinator_summary(M, N, addr_fmt, et, tokens): + addr_fmt_str = "p2wsh" if addr_fmt == AF_P2WSH else "p2sh-p2wsh" + summary = "%d of %d\n\n" % (M, N) + summary += "Address format:\n%s\n\n" % addr_fmt_str + summary += "Encryption type:\n%s\n\n" % ENCRYPTION_TYPES[et] + + if tokens: + summary += "Tokens:\n" + token_summary(tokens) + "\n\n" + + return summary + + +class BSMSSettings: + # keys in settings object + BSMS_SETTINGS = "bsms" + BSMS_SIGNER_SETTINGS = "s" + BSMS_COORD_SETTINGS = "c" + + @classmethod + def save(cls, updated_settings, orig): + try: + updated_settings.save() + except: + # back out change; no longer sure of NVRAM state + try: + updated_settings.set(cls.BSMS_SETTINGS, orig) + updated_settings.save() + except: + pass # give up on recovery + raise BSMSOutOfSpace + + @classmethod + def add(cls, who, value): + from glob import settings + + settings_bsms = settings.get(cls.BSMS_SETTINGS, {}) + orig = settings_bsms.copy() + if who in settings_bsms: + settings_bsms[who].append(value) + else: + settings_bsms[who] = [value] + + settings.set(cls.BSMS_SETTINGS, settings_bsms) + cls.save(settings, orig) + + @classmethod + def delete(cls, who, index): + from glob import settings + + settings_bsms = settings.get(cls.BSMS_SETTINGS, {}) + orig = settings_bsms.copy() + if who in settings_bsms: + try: + settings_bsms[who].pop(index) + settings.set(cls.BSMS_SETTINGS, settings_bsms) + cls.save(settings, orig) + except IndexError: + pass + + @classmethod + def signer_add(cls, token_hex): + cls.add(cls.BSMS_SIGNER_SETTINGS, token_hex) + + @classmethod + def coordinator_add(cls, config_tuple): + cls.add(cls.BSMS_COORD_SETTINGS, config_tuple) + + @classmethod + def signer_delete(cls, index): + cls.delete(cls.BSMS_SIGNER_SETTINGS, index) + + @classmethod + def coordinator_delete(cls, index): + cls.delete(cls.BSMS_COORD_SETTINGS, index) + + @classmethod + def get(cls): + from glob import settings + return settings.get(cls.BSMS_SETTINGS, {}) + + @classmethod + def get_signers(cls): + bsms = cls.get() + return bsms.get(cls.BSMS_SIGNER_SETTINGS, []) + + @classmethod + def get_coordinators(cls): + bsms = cls.get() + return bsms.get(cls.BSMS_COORD_SETTINGS, []) + + +class BSMSMenu(MenuSystem): + @classmethod + def construct(cls): + raise NotImplementedError + + def update_contents(self): + tmp = self.construct() + self.replace_items(tmp) + + +async def user_delete_signer_settings(menu, label, item): + index = item.arg + BSMSSettings.signer_delete(index) + the_ux.pop() + restore_menu() + +async def bsms_signer_detail(menu, label, item): + token_hex = BSMSSettings.get_signers()[item.arg] + # shoulf not raise here, as token is only saved if properly validated + token_dec = str(int(token_hex, 16)) + await ux_show_story("Token HEX:\n%s\n\nToken decimal:\n%s" % (token_hex, token_dec)) + + +async def bsms_coordinator_detail(menu, label, item): + M, N, addr_fmt, et, tokens = BSMSSettings.get_coordinators()[item.arg] + summary = coordinator_summary(M, N, addr_fmt, et, tokens) + await ux_show_story(title="SUMMARY", msg=summary) + + +async def make_bsms_signer_r2_menu(menu, label, item): + index = item.arg + rv = [ + MenuItem('Round 2', f=bsms_signer_round2, arg=index), + MenuItem('Detail', f=bsms_signer_detail, arg=index), + MenuItem('Delete', f=user_delete_signer_settings, arg=index), + ] + return rv + + +class BSMSSignerMenu(BSMSMenu): + @classmethod + def construct(cls): + # Dynamic + rv = [] + signers = BSMSSettings.get_signers() + if signers: + for i, token_hex in enumerate(signers): + label = "%d %s" % (i+1, token_hex[:4]) + rv.append(MenuItem('%s' % label, menu=make_bsms_signer_r2_menu, arg=i)) + rv.append(MenuItem('Round 1', f=bsms_signer_round1)) + + return rv + + +async def user_delete_coordinator_settings(menu, label, item): + index = item.arg + BSMSSettings.coordinator_delete(index) + the_ux.pop() + restore_menu() + + +async def make_bsms_coord_r2_menu(menu, label, item): + index = item.arg + rv = [ + MenuItem('Round 2', f=bsms_coordinator_round2, arg=index), + MenuItem('Detail', f=bsms_coordinator_detail, arg=index), + MenuItem('Delete', f=user_delete_coordinator_settings, arg=index), + ] + return rv + + +class BSMSCoordinatorMenu(BSMSMenu): + @classmethod + def construct(cls): + # Dynamic + rv = [] + coordinators = BSMSSettings.get_coordinators() + if coordinators: + for i, (M, N, addr_fmt, et, tokens) in enumerate(coordinators): + # only p2wsh and p2sh-p2wsh are allowed + if addr_fmt == AF_P2WSH: + af_str = "native" + else: + af_str = "nested" + label = "%d %dof%d_%s_%s" % (i+1, M, N, af_str, et) + rv.append(MenuItem('%s' % label, menu=make_bsms_coord_r2_menu, arg=i)) + rv.append(MenuItem('Create BSMS', f=bsms_coordinator_start)) + + return rv + + +async def make_ms_wallet_bsms_menu(menu, label, item): + from pincodes import pa + + if pa.is_secret_blank(): + await ux_show_story("You must have wallet seed before creating multisig wallets.") + return + + await ux_show_story( +"Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets. " +"On the next screen you choose your role in this process.\n\n" +"WARNING: BSMS is an EXPERIMENTAL and BETA feature which requires supporting implementations " +"on other signing devices to work properly. Please test the final wallet carefully " +"and report any problems to appropriate vendor. Deposit only small test amounts and verify " +"all co-signers can sign transactions before use.") + rv = [ + MenuItem('Signer', menu=make_bsms_signer_menu), + MenuItem('Coordinator', menu=make_bsms_coordinator_menu), + ] + return rv + + +async def make_bsms_signer_menu(menu, label, item): + rv = BSMSSignerMenu.construct() + return BSMSSignerMenu(rv) + + +async def make_bsms_coordinator_menu(menu, label, item): + rv = BSMSCoordinatorMenu.construct() + return BSMSCoordinatorMenu(rv) + + +async def decrypt_nfc_data(key, data): + try: + data_bytes = a2b_hex(data) + data = bsms_decrypt(key, data_bytes) + return data + except: + # will be offered another chance + return + +@exceptions_handler +async def bsms_coordinator_start(*a): + from glob import NFC, dis, settings + xfp = xfp2str(settings.get('xfp', 0)) + # M/N + N = await ux_enter_number('No. of signers?(N)', 15) + assert 2 <= N <= MAX_SIGNERS, "Number of co-signers must be 2-15" + + M = await ux_enter_number("Threshold? (M)", 15) + assert 1 <= M <= N, "M cannot be bigger than N (N=%d)" % N + + ch = await ux_show_story("Default address format is P2WSH.\n\n" + "Press (2) for P2SH-P2WSH instead.", escape='2') + if ch == 'y': + addr_fmt = AF_P2WSH + elif ch == '2': + addr_fmt = AF_P2WSH_P2SH + else: + return + + while 1: + encryption_type = await ux_show_story( + "Choose encryption type. Press (1) for STANDARD encryption, (2) for EXTENDED," + " and (3) for no encryption", escape="123") + + if encryption_type == 'x': return + if encryption_type in "123": + break + + tokens = [] + if encryption_type == "2": + dis.fullscreen('Generating...') + for i in range(N): # each signer different 16 bytes (128bits) nonce/token + tokens.append(b2a_hex(ngu.random.bytes(16)).decode()) + dis.progress_bar_show(i / N) + elif encryption_type == "1": + tokens.append(b2a_hex(ngu.random.bytes(8)).decode()) # all signers same token + + summary = coordinator_summary(M, N, addr_fmt, encryption_type, tokens) + summary += "Press OK to continue, or X to cancel" + ch = await ux_show_story(title="SUMMARY", msg=summary) + if ch != "y": + return + + token_hex = "00" if not tokens else tokens[0] + ch = await ux_show_story("Press (1) to participate as co-signer in this BSMS " + "with current active key [%s] and token '%s'. " + "Press OK to continue normally." % (xfp, token_hex), escape="1") + export_tokens = tokens[:] + if ch == "1": + b4 = len(BSMSSettings.get_signers()) + await bsms_signer_round1(token_hex) + current = BSMSSettings.get_signers() + if len(current) > b4 and token_hex in current: + if encryption_type == "2": + # remove 0th token from the list as we already used that for self + # we do not need this token for export, but still need to store it in settings + export_tokens = tokens[1:] + + force_vdisk = False + title = "BSMS token file(s)" + prompt, escape = export_prompt_builder(title) + if tokens and prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == (KEY_NFC if version.has_qwerty else '3') and tokens: + force_vdisk = None + await NFC.share_text(token_summary(export_tokens)) + elif ch == "2": + force_vdisk = True + elif ch == '1': + force_vdisk = False + else: + return + + msg = "Success. Coordinator round 1 saved." + if tokens and force_vdisk is not None: + dis.fullscreen("Saving...") + f_pattern = "bsms" + f_names = [] + try: + with CardSlot(force_vdisk=force_vdisk) as card: + for i, token in enumerate(export_tokens, start=1): + f_name = "%s_%s.token" % (f_pattern, token[:4]) + fname, nice = card.pick_filename(f_name) + with open(fname, 'wt') as fd: + fd.write(token) + f_names.append(nice) + dis.progress_bar_show(i / len(tokens)) + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n\n' + str(e)) + return + msg = '''%s written.\n\nFiles:\n\n%s''' % (title, "\n\n".join(f_names)) + + BSMSSettings.coordinator_add((M, N, addr_fmt, encryption_type, tokens)) + await ux_show_story(msg) + restore_menu() + + +async def nfc_import_signer_round1_data(N, tkm, et, get_token_func): + from glob import NFC + + all_data = [] + for i in range(N): + token = get_token_func(i) + for attempt in range(2): + prompt = "Share co-signer #%d round-1 data" % (i + 1) + if et == "2": + prompt += " for token starting with %s" % token[:4] + ch = await ux_show_story(prompt) + if ch != "y": + return + + data = await NFC.read_bsms_data() + if et in "12": + encryption_key = key_derivation_function(token) + data = await decrypt_nfc_data(encryption_key, data) + if not data: + fail_msg = "Decryption failed for co-signer #%d" % (i + 1) + if et == "2": + fail_msg += " with token %s" % token[:4] + ch = await ux_show_story( + title="FAILURE", + msg=fail_msg + ". Try again?" if attempt == 0 else fail_msg) # second chance + if ch == "y" and attempt == 0: + continue + else: + return + tkm[token] = encryption_key + + all_data.append(data) + break # exit "second chance" loop + return all_data + +@exceptions_handler +async def bsms_coordinator_round2(menu, label, item): + import version as version_mod + from glob import NFC, dis + from actions import file_picker + from multisig import make_redeem_script + + bsms_settings_index = item.arg + chain = chains.current_chain() + + force_vdisk = False + + # this can be RAM intensive (max 15 F mapped to keys) + # => ((32 + 16) * 15) roughly (actually more with python overhead) + token_key_map = {} + + # choose correct values based on label (index in coordinator bsms settings) + M, N, addr_fmt, et, tokens = BSMSSettings.get_coordinators()[bsms_settings_index] + + def get_token(index): + if len(tokens) == 1 and et == "1": + token = tokens[0] + elif len(tokens) == N and et == "2": + token = tokens[index] + else: + token = "00" + return token + + is_encrypted = et in "12" and tokens + suffix = ".dat" if is_encrypted else ".txt" + mode = "rb" if is_encrypted else "rt" + prompt, escape = _import_prompt_builder("co-signer round 1 files", False, False) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == (KEY_NFC if version_mod.has_qwerty else '3'): + force_vdisk = None + r1_data = await nfc_import_signer_round1_data(N, token_key_map, et, get_token) + else: + if ch == "1": + force_vdisk = False + else: + force_vdisk = True + + if force_vdisk is not None: + # auto-collection attempt + r1_data = [] + try: + f_pattern = "bsms_sr1" + auto_msg = "Press OK to pick co-signer round 1 files manually, or press (1) to attempt auto-collection." + auto_msg += " For auto-collection to succeed all filenames have to start with '%s'" % f_pattern + auto_msg += " and end with extension '%s'." % suffix + if et == "2": # EXTENDED + auto_msg += (" In addition for EXTENDED encryption all files must contain first four characters of" + " respective token. For example '%s_af9f%s'." % (f_pattern, suffix)) + elif et == "3": # NO_ENCRYPTION + auto_msg += (" In addition for NO ENCRYPTION cases, number of files with above mentioned" + " pattern and suffix must equal number of signers (N).") + auto_msg += " If above is not respected auto-collection fails and defaults to manual selection of files." + ch = await ux_show_story(auto_msg, escape="1") + if ch == "x": return # exit + if ch == "y": raise RejectAutoCollection + # try autodiscovery first - if failed - default to manual input + dis.fullscreen("Collecting...") + file_names = [] + with CardSlot(force_vdisk=force_vdisk) as card: + f_list = os.listdir(card.mountpt) + f_list_len = len(f_list) + for i, name in enumerate(f_list, start=1): + if not card.is_dir(name) and f_pattern in name and name.endswith(suffix): + file_names.append(name) + dis.progress_bar_show(i / f_list_len) + file_names_len = len(file_names) + dis.fullscreen("Validating...") + if et == "1": + # can have multiple of these files - we will try to decrypt all that + # have above pattern. Those that fail will be ignored and at the end + # we check if we have correct num of files (num==N) + token = get_token(0) # STANDARD encryption has just one token + encryption_key = key_derivation_function(token) + token_key_map[token] = encryption_key + + with CardSlot(force_vdisk=force_vdisk) as card: + for i, fname in enumerate(file_names, start=1): + with open(card.abs_path(fname), mode) as f: + data = f.read() + data = bsms_decrypt(encryption_key, data) + if not data: + continue + + assert data.startswith("BSMS"), "Failure - not BSMS file?" + r1_data.append(data) + dis.progress_bar_show(i / file_names_len) + + elif et == "2": + with CardSlot(force_vdisk=force_vdisk) as card: + for i in range(N): + token = get_token(i) + for fname in file_names: + if token[:4] in fname: + with open(card.abs_path(fname), mode) as f: + data = f.read() + encryption_key = key_derivation_function(token) + data = bsms_decrypt(encryption_key, data) + + assert data, "Failed to decrypt %s with token %s" % (fname, token) + assert data.startswith("BSMS"), "Failure - not BSMS file?" + token_key_map[token] = encryption_key + r1_data.append(data) + + break + else: + assert False, "haven't find file for token %s" % token + + dis.progress_bar_show(i / N) + else: + assert file_names_len == N, "Need same number of files (%d) as co-signers(N=%d)"\ + % (file_names_len, N) + + with CardSlot(force_vdisk=force_vdisk) as card: + for i, fname in enumerate(file_names, start=1): + with open(card.abs_path(fname), mode) as f: + data = f.read() + assert data.startswith("BSMS"), "Failure - not BSMS file?" + r1_data.append(data) + dis.progress_bar_show(i / file_names_len) + + assert len(r1_data) == N, "No. of signer round 1 data auto-collected "\ + "does not equal number of signers (N)" + except BaseException as e: + if isinstance(e, RejectAutoCollection): + # raised when user manually chooses not to use auto-collection + msg_prefix = "" + else: + msg_prefix = "Auto-collection failed. Defaulting to manual selection of files. " + + # iterate over N and prompt user to choose correct files + for i in range(N): + token = get_token(i) + f_pick_msg = msg_prefix + f_pick_msg += 'Select co-signer #%d file containing round 1 data' % (i + 1) + if et == "2": + f_pick_msg += " for token starting with %s" % token[:4] + f_pick_msg += '. File extension has to be "%s"' % suffix + for attempt in range(2): # two chances to succeed + await ux_show_story(f_pick_msg) + fn = await file_picker(suffix=suffix, min_size=220, max_size=500, + force_vdisk=force_vdisk) + if not fn: return + + dis.fullscreen("Wait...") + with CardSlot(force_vdisk=force_vdisk) as card: + dis.progress_bar_show(0.1) + with open(fn, mode) as fd: + data = fd.read() + dis.progress_bar_show(0.3) + if is_encrypted: + encryption_key = key_derivation_function(token) + dis.progress_bar_show(0.6) + data = bsms_decrypt(encryption_key, data) + if not data: + fail_msg = "Decryption failed for co-signer #%d" % (i + 1) + if et == "2": + fail_msg += " with token %s" % token[:4] + ch = await ux_show_story(title="FAILURE", msg=fail_msg + + (" Try again?" if attempt == 0 else fail_msg)) + + if ch == "y" and attempt == 0: + continue + else: + return + + dis.progress_bar_show(0.9) + token_key_map[token] = encryption_key + + r1_data.append(data) + dis.progress_bar_show(1) + + break # break from "second chance loop" + + if not r1_data: + return + + keys = [] + 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) + i_div_N = (i+1) / N + token = get_token(i) + assert data.startswith(BSMS_VERSION), "Incompatible BSMS version. Need %s got %s" % ( + BSMS_VERSION, data[:9] + ) + version, tok, key_exp, description, sig = data.strip().split("\n") + assert tok == token, "Token mismatch saved %s, received from signer %s" % (token, tok) + 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 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(key) + dis.progress_bar_show(i_div_N / 1) + + dis.fullscreen("Generating...") + 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, ko in enumerate(keys): + ko.node.derive(0, False) # external is always first our coordinating "0/*,1/*" + dis.progress_bar_show(i / N) + + # 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) + + force_vdisk = False + title = "BSMS descriptor template file(s)" + prompt, escape = export_prompt_builder(title) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == KEY_NFC if version_mod.has_qwerty else '3': + if et == "2": + for i, token in enumerate(tokens): + ch = await ux_show_story("Exporting data for co-signer #%d with token %s" + % (i+1, token[:4])) + if ch != "y": + return + data = bsms_encrypt(token_key_map[token], token, r2_data) + await NFC.share_text(b2a_hex(data).decode()) + elif et == "1": + token = get_token(0) + data = bsms_encrypt(token_key_map[token], token, r2_data) + await NFC.share_text(b2a_hex(data).decode()) + else: + await NFC.share_text(r2_data) + await ux_show_story("All done.") + return + elif ch == "2": + force_vdisk = True + elif ch == '1': + force_vdisk = False + else: + return + + def to_export_generator(): + # save memory + if et == "3": # NO_ENCRYPTION + yield None, r2_data + elif et == "1": # STANDARD + token = get_token(0) + yield token, bsms_encrypt(token_key_map[token], token, r2_data) + else: + # EXTENDED + for token in tokens: + yield token, bsms_encrypt(token_key_map[token], token, r2_data) + + dis.fullscreen("Saving...") + mode = "wb" if is_encrypted else "wt" + f_pattern = "bsms_cr2" + f_names = [] + try: + with CardSlot(force_vdisk=force_vdisk) as card: + for i, (token, data) in enumerate(to_export_generator(), start=1): + f_name = "%s%s%s" % (f_pattern, "_" + token[:4] if et == "2" else "", suffix) + fname, nice = card.pick_filename(f_name) + with open(fname, mode) as fd: + fd.write(data) + f_names.append(nice) + dis.progress_bar_show(i / (len(token_key_map) or 1)) + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n\n' + str(e)) + return + msg = '''%s written. Files:\n\n%s''' % (title, "\n\n".join(f_names)) + await ux_show_story(msg) + + +@exceptions_handler +async def bsms_signer_round1(*a): + from glob import dis, NFC, VD, settings + + shortcut = len(a) == 1 + token_int = None + if not shortcut: + prompt = "Press (1) to import token file from SD Card, (2) to input token manually" + prompt += ", (3) for unencrypted BSMS." + escape = "123" + if version.has_qwerty: + prompt += "%s to scan QR. " % KEY_QR + escape += KEY_QR + if NFC is not None: + prompt += " %s to import via NFC" % (KEY_NFC if version.has_qwerty else "(4)") + escape += KEY_NFC if version.has_qwerty else "4" + if VD is not None: + prompt += ", (6) to import from Virtual Disk" + escape += "6" + prompt += "." + + ch = await ux_show_story(prompt, escape=escape) + + if ch == '3': + token_hex = "00" + elif ch in "4"+KEY_NFC: + token_hex = await NFC.read_bsms_token() + elif ch == "2": + prompt = "To input token as hex press (1), as decimal press (2)" + escape = "12" + ch = await ux_show_story(prompt, escape=escape) + if ch == "1": + token_hex = await ux_input_text("", hex_only=True, scan_ok=True, + prompt="Hex Token") + elif ch == "2": + if version.has_qwerty: + token_int = await ux_input_text("", scan_ok=True, prompt="Decimal Token") + else: + token_int = await ux_input_numbers("", lambda: True) + token_hex = hex(int(token_int)) + else: + return + elif ch in "16": + from actions import file_picker + force_vdisk = (ch == '6') + + # pick a likely-looking file. + fn = await file_picker(suffix=".token", min_size=15, max_size=35, + force_vdisk=force_vdisk) + if not fn: return + + with CardSlot(force_vdisk=force_vdisk) as card: + with open(fn, 'rt') as fd: + token_hex = fd.read().strip() + else: + return + else: + token_hex = a[0] + + # will raise, exc catched in decorator, FAILURE msg provided + validate_token(token_hex) + token_hex = normalize_token(token_hex) + is_extended = (len(token_hex) == 32) + entered_msg = "%s\n\nhex:\n%s" % (token_int, token_hex) if token_int else token_hex + + if not shortcut: + ch = await ux_show_story("You have entered token:\n" + entered_msg + "\n\nIs token correct?") + if ch != "y": + return + + xfp = xfp2str(settings.get('xfp', 0)) + chain = chains.current_chain() + ch = await ux_show_story( +"Choose co-signer address format for correct SLIP derivation path. Default is 'unknown' as this " +"information may not be known at this point in BSMS. SLIP agnostic path will be chosen. " +"Press (1) for P2WSH. Press (2) for P2SH-P2WSH. " +"Correct SLIP path is completely unnecessary as descriptors (BIP-0380) are used.", + escape='12') + if ch == 'y': + pth_template = "m/129'/{coin}'/{acct_num}'" + af_str = "" + elif ch == '1': + pth_template = "m/48'/{coin}'/{acct_num}'/2'" + af_str = " P2WSH" + elif ch == '2': + pth_template = "m/48'/{coin}'/{acct_num}'/1'" + af_str = " P2SH-P2WSH" + else: + return + + acct_num = await ux_enter_number('Account Number:', 9999) or 0 + + # textual key description + key_description = "Coldcard signer%s account %d" % (af_str, acct_num) + ch = await ux_show_story( +"Choose key description. To continue with default, generated description: '%s' press OK." +"\n\nPress (1) for custom key description." % key_description, escape="1") + + if ch == "1": + key_description = await ux_input_text("", confirm_exit=False) or "" + + key_description_len = len(key_description) + assert key_description_len <= 80, "Key Description: 80 char max (was %d)" % key_description_len + + dis.fullscreen("Wait...") + + with stash.SensitiveValues() as sv: + dis.progress_bar_show(0.1) + + dd = pth_template.format(coin=chain.b44_cointype, acct_num=acct_num) + node = sv.derive_path(dd) + ext_key = chain.serialize_public(node) + + dis.progress_bar_show(0.25) + + desc_type_key = "[%s%s]%s" % (xfp, dd[1:], ext_key) + msg = signer_data_round1(token_hex, desc_type_key, key_description) + digest = chain.hash_message(msg.encode()) + sk = node.privkey() + sv.register(sk) + + dis.progress_bar_show(0.5) + + sig = ngu.secp256k1.sign(sk, digest, 0).to_bytes() + result_data = signer_data_round1(token_hex, desc_type_key, key_description, sig_bytes=sig) + + dis.progress_bar_show(.75) + + encryption_key = key_derivation_function(token_hex) + if encryption_key: + result_data = bsms_encrypt(encryption_key, token_hex, result_data) + + dis.progress_bar_show(1) + + # export round 1 file + force_vdisk = False + title = "BSMS signer round 1 file" + prompt, escape = export_prompt_builder(title) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + if ch == KEY_NFC if version.has_qwerty else '3': + force_vdisk = None + if isinstance(result_data, bytes): + result_data = b2a_hex(result_data).decode() + await NFC.share_text(result_data) + elif ch == "2": + force_vdisk = True + elif ch == '1': + force_vdisk = False + else: + return + + msg = "Success. Signer round 1 saved." + if force_vdisk is not None: + basename = "bsms_sr1%s" % "_" + token_hex[:4] if is_extended else "bsms_sr1" + f_pattern = basename + ".txt" if encryption_key is None else basename + ".dat" + # choose a filename + try: + with CardSlot(force_vdisk=force_vdisk) as card: + fname, nice = card.pick_filename(f_pattern) + with open(fname, 'wb') as fd: + if isinstance(result_data, str): + result_data = result_data.encode() + fd.write(result_data) + except CardMissingError: + await needs_microsd() + return + except Exception as e: + await ux_show_story('Failed to write!\n\n\n' + str(e)) + return + msg = '''%s written:\n\n%s''' % (title, nice) + BSMSSettings.signer_add(token_hex) + await ux_show_story(msg) + if not shortcut: + restore_menu() + + +@exceptions_handler +async def bsms_signer_round2(menu, label, item): + import version + from glob import NFC, dis, settings + from actions import file_picker + from auth import maybe_enroll_xpub + from multisig import make_redeem_script + + chain = chains.current_chain() + + # or xpub or tpub as we use descriptors (no SLIP132 allowed) + ext_key_prefix = "%spub" % chain.slip132[AF_CLASSIC].hint + force_vdisk = False + + # choose correct values based on label (index in signer bsms settings) + bsms_settings_index = item.arg + token = BSMSSettings.get_signers()[bsms_settings_index] + + decrypt_fail_msg = "Decryption with token %s failed." % token[:4] + is_encrypted = False if token == "00" else True + suffix = ".dat" if is_encrypted else ".txt" + mode = "rb" if is_encrypted else "rt" + + prompt, escape = _import_prompt_builder("descriptor template file", False, False) + if prompt: + ch = await ux_show_story(prompt, escape=escape) + + if ch == KEY_NFC if version.has_qwerty else '3': + force_vdisk = None + desc_template_data = await NFC.read_bsms_data() + + if desc_template_data is None: + return + + if is_encrypted: + data_bytes = a2b_hex(desc_template_data) + encryption_key = key_derivation_function(token) + desc_template_data = bsms_decrypt(encryption_key, data_bytes) + assert desc_template_data, decrypt_fail_msg + else: + if ch == "1": + force_vdisk = False + else: + force_vdisk = True + + if force_vdisk is not None: + fn = await file_picker(suffix=suffix, min_size=200, max_size=10000, + force_vdisk=force_vdisk) + if not fn: return + + with CardSlot(force_vdisk=force_vdisk) as card: + with open(fn, mode) as fd: + desc_template_data = fd.read() + if is_encrypted: + encryption_key = key_derivation_function(token) + desc_template_data = bsms_decrypt(encryption_key, desc_template_data) + assert desc_template_data, decrypt_fail_msg + + dis.fullscreen("Validating...") + assert desc_template_data.startswith(BSMS_VERSION), \ + "Incompatible BSMS version. Need %s got %s" % (BSMS_VERSION, desc_template_data[:9]) + + dis.progress_bar_show(0.05) + version, desc_template, pth_restrictions, addr = desc_template_data.split("\n") + assert pth_restrictions == ALLOWED_PATH_RESTRICTIONS, \ + "Only '%s' allowed as path restrictions. Got %s" % ( + ALLOWED_PATH_RESTRICTIONS, pth_restrictions) + + # if checksum is provided we better verify it + # remove checksum as we need to replace /** + desc_template, csum = Descriptor.checksum_check(desc_template) + desc = desc_template.replace("/**", "/0/*") + + dis.progress_bar_show(0.1) + desc = append_checksum(desc) + + ms_name = "bsms_" + desc[-4:] + + desc_obj = Descriptor.from_string(desc) + desc_obj.legacy_ms_compat() + + dis.progress_bar_show(0.2) + + my_xfp = settings.get('xfp') + my_keys = [] + nodes = [] + progress_counter = 0.2 # last displayed progress + # (desired value after loop - last displayed progress) / N + 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) + + num_my_keys = len(my_keys) + 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) + + with stash.SensitiveValues() as sv: + node = sv.derive_path(my_keys[0].origin.str_derivation()) + ext_key = chain.serialize_public(node) + 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 + 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(M, nodes, 0) # first address + dis.progress_bar_show(0.95) + calc_addr = chain.p2sh_address(desc_obj.addr_fmt, script) + + assert calc_addr == addr, "Address mismatch! Calculated %s, got %s" % (calc_addr, addr) + + dis.progress_bar_show(1) + try: + maybe_enroll_xpub(config=desc, name=ms_name, bsms_index=bsms_settings_index) + # bsms_settings_signer_delete(bsms_settings_index) --> moved to auth.py to only be done if actually approved + except Exception as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + +# EOF \ No newline at end of file diff --git a/shared/chains.py b/shared/chains.py index d9c34aea..73e42c78 100644 --- a/shared/chains.py +++ b/shared/chains.py @@ -10,7 +10,6 @@ from public_constants import AF_P2SH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH from public_constants import AFC_PUBKEY, AFC_SEGWIT, AFC_BECH32, AFC_SCRIPT from public_constants import TAPROOT_LEAF_TAPSCRIPT, TAPROOT_LEAF_MASK from serializations import hash160, ser_compact_size, disassemble, ser_string -from serializations import hash160, ser_compact_size, disassemble from ucollections import namedtuple from opcodes import OP_RETURN, OP_1, OP_16 @@ -405,6 +404,13 @@ def current_chain(): return get_chain(chain) +def current_key_chain(): + c = current_chain() + if c == BitcoinRegtest: + # regtest has same extended keys as testnet + c = BitcoinTestnet + return c + # Overbuilt: will only be testnet and mainchain. AllChains = [BitcoinMain, BitcoinTestnet, BitcoinRegtest] diff --git a/shared/decoders.py b/shared/decoders.py index ec419b35..317189dd 100644 --- a/shared/decoders.py +++ b/shared/decoders.py @@ -187,10 +187,6 @@ def decode_short_text(got): # was something else. pass - # multisig descriptor - if ("sortedmulti(" in got): - return 'multi', (got,) - if ("\n" in got) and ('pub' in got): # legacy multisig import/export format # [0-9a-fA-F]{8}\s*:\s*[xtyYzZuUvV]pub[1-9A-HJ-NP-Za-km-z]{107} @@ -206,6 +202,10 @@ def decode_short_text(got): if c > 1: return 'multi', (got,) + from descriptor import Descriptor + if Descriptor.is_descriptor(got): + return 'minisc', (got,) + # Things with newlines in them are not URL's # - working URLs are not >4k # - might be a story in text, etc. diff --git a/shared/desc_utils.py b/shared/desc_utils.py new file mode 100644 index 00000000..8a198a4f --- /dev/null +++ b/shared/desc_utils.py @@ -0,0 +1,519 @@ +# (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 __eq__(self, other): + return self.psbt_derivation() == other.psbt_derivation() + + def __hash__(self): + return hash(tuple(self.psbt_derivation())) + + 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=None): + self.indexes = indexes + if self.indexes is None: + self.indexes = [[0, 1], WILDCARD] + self.multi_path_index = 0 + else: + self.multi_path_index = None + + @property + def is_int_ext(self): + if self.multi_path_index is not None: + return True + return False + + @property + def is_external(self): + if self.is_int_ext: + return True + elif self.indexes[-2] % 2 == 0: + return True + + return False + + @property + def branches(self): + if self.is_int_ext: + return self.indexes[self.multi_path_index] + else: + return [self.indexes[-2]] + + @classmethod + def from_string(cls, s): + fail_msg = "Cannot use hardened sub derivation path" + if not s: + return cls() + res = [] + mp = 0 + mpi = None + for idx, i in enumerate(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 + inner_split = inner.split(";") + assert len(inner_split) == 2, "wrong multipath" + res.append([int(i) for i in inner_split]) + mp += 1 + mpi = idx + 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)) + + # only one allowed in subderivation + assert mp <= 1, "too many multipaths (%d)" % mp + + if res == [0, WILDCARD]: + obj = cls() + else: + assert len(res) == 2, "Key derivation too long" + assert res[-1] == WILDCARD, "All keys must be ranged" + obj = cls(res) + obj.multi_path_index = mpi + return obj + + def to_string(self, external=True, internal=True): + res = [] + for i in self.indexes: + if isinstance(i, list): + if internal is True and external is False: + i = str(i[1]) + elif internal is False and external is True: + i = str(i[0]) + else: + i = "<%d;%d>" % (i[0], i[1]) + else: + i = str(i) + res.append(i) + return "/".join(res) + + 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, chain_type=None): + self.origin = origin + self.node = node + self.derivation = derivation + self.taproot = taproot + self.chain_type = chain_type + 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() \ + and self.derivation.indexes == other.derivation.indexes + + def __hash__(self): + orig = tuple(self.origin.psbt_derivation()) + der = self.derivation.indexes.copy() + if self.derivation.multi_path_index is not None: + der[self.derivation.multi_path_index] = tuple(der[self.derivation.multi_path_index]) + der = tuple(der) + return hash(orig+der) + + 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, chain_type = cls.parse_key(k) + der = KeyDerivationInfo.from_string(der.decode()) + return cls(node, origin, der, chain_type=chain_type) + + @classmethod + def parse_key(cls, key_str): + chain_type = None + if key_str[1:4].lower() == b"pub": + # extended key + # or xpub or tpub as we use descriptors (SLIP-132 NOT allowed) + hint = key_str[0:1].lower() + if hint == b"x": + chain_type = "BTC" + else: + assert hint == b"t", "no slip" + chain_type = "XTN" + 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, chain_type + + def derive(self, idx=None, change=False): + if isinstance(self.node, bytes): + return self + if isinstance(idx, list): + for i in idx: + mp_i = self.derivation.multi_path_index or 0 + if i in self.derivation.indexes[mp_i]: + idx = i + break + else: + assert False + + elif idx is None: + # derive according to key subderivation if any + if self.derivation is None: + idx = 1 if change else 0 + else: + if self.derivation.multi_path_index is not None: + ext, inter = self.derivation.indexes[self.derivation.multi_path_index] + idx = inter if change else ext + + 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 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, subderiv=True): + key = self.prefix + if isinstance(self.node, ngu.hdnode.HDNode): + key += self.extended_public_key() + if self.derivation and subderiv: + 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): + 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: + subderiv = True + ix = policy.find(ph) + if ix == -1: + break + if policy[ix+ph_len] == "/": + # subderivation is part of the policy + subderiv = False + x = ix + ph_len + substr = policy[x:x+26] # 26 is longest possible subderivation allowed "/<2147483647;2147483646>/*" + mp_start = substr.find("<") + assert mp_start != -1 + mp_end = substr.find(">") + mp = substr[mp_start:mp_end + 1] + _ext, _int = mp[1:-1].split(";") + if external and not internal: + sub = _ext + elif internal and not external: + sub = _int + else: + sub = None + if sub is not None: + policy = policy[:x + mp_start] + sub + policy[x + mp_end + 1:] + + if not isinstance(k, str): + k_str = k.to_string(external, internal, subderiv=subderiv) + else: + k_str = k + if not subderiv: + k_str = "/".join(k_str.split("/")[:-2]) + mp_start = k_str.find("<") + if mp_start != -1: + mp_end = k_str.find(">") + mp = k_str[mp_start:mp_end+1] + ext, int = mp[1:-1].split(";") + if external and not internal: + k_str = k_str.replace(mp, ext) + if internal and not external: + k_str = k_str.replace(mp, int) + + 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 \ No newline at end of file diff --git a/shared/descriptor.py b/shared/descriptor.py index c6bb136a..9d65175d 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -1,362 +1,624 @@ -# (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 +import ngu, chains +from io import BytesIO +from collections import OrderedDict +from binascii import hexlify as b2a_hex +from utils import cleanup_deriv_path, check_xpub, xfp2str +from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR +from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, MAX_SIGNERS, MAX_TR_SIGNERS +from desc_utils import parse_desc_str, append_checksum, descriptor_checksum, Key +from desc_utils import taproot_tree_helper, fill_policy +from miniscript import Miniscript -MULTI_FMT_TO_SCRIPT = { - AF_P2SH: "sh(%s)", - AF_P2WSH_P2SH: "sh(wsh(%s))", - AF_P2WSH: "wsh(%s)", - None: "wsh(%s)", - # hack for tests - "p2sh": "sh(%s)", - "p2sh-p2wsh": "sh(wsh(%s))", - "p2wsh-p2sh": "sh(wsh(%s))", - "p2wsh": "wsh(%s)", -} -SINGLE_FMT_TO_SCRIPT = { - AF_P2TR: "tr(%s)", - AF_P2WPKH: "wpkh(%s)", - AF_CLASSIC: "pkh(%s)", - AF_P2WPKH_P2SH: "sh(wpkh(%s))", - None: "wpkh(%s)", - "p2pkh": "pkh(%s)", - "p2wpkh": "wpkh(%s)", - "p2sh-p2wpkh": "sh(wpkh(%s))", - "p2wpkh-p2sh": "sh(wpkh(%s))", -} - -INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " -CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - -try: - from utils import xfp2str, str2xfp -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 +class Tapscript: + def __init__(self, tree=None, keys=None, policy=None): + self.tree = tree + self.keys = keys + self.policy = policy + self._merkle_root = None - return c + @staticmethod + 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) -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) + @property + def merkle_root(self): + if not self._merkle_root: + self.process_tree() + return self._merkle_root - c = polymod(c, pos & 31) - cls = cls * 3 + (pos >> 5) - clscount += 1 - if clscount == 3: - c = polymod(c, cls) - cls = 0 - clscount = 0 + @staticmethod + def _derive(tree, idx, key_map, change=False): + if isinstance(tree, Miniscript): + return tree.derive(idx, key_map, change=change) + else: + if len(tree) == 1 and isinstance(tree[0], Miniscript): + return tree[0].derive(idx, key_map, change=change) + l, r = tree + return [Tapscript._derive(l, idx, key_map, change=change), + Tapscript._derive(r, idx, key_map, change=change)] - if clscount > 0: - c = polymod(c, cls) - for j in range(0, 8): - c = polymod(c, 0) - c ^= 1 + def derive(self, idx=None, change=False): + derived_keys = OrderedDict() + for k in self.keys: + derived_keys[k] = k.derive(idx, change=change) + tree = Tapscript._derive(self.tree, idx, derived_keys, change=change) + return type(self)(tree, policy=self.policy, keys=list(derived_keys.values())) - rv = '' - for j in range(0, 8): - rv += CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] + def process_tree(self): + info, mr = taproot_tree_helper(self.tree) + self._merkle_root = mr + return info, mr - return rv + @classmethod + 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 -def append_checksum(desc): - return desc + "#" + descriptor_checksum(desc) + depth += 1 + continue + elif p1 == b"}": + depth -= 1 + if depth == 1: + tapscript.append(itmp) + itmp = None + if depth <= 2: + itmp_p = None + continue -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 + 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) + assert num_leafs <= 8, "num_leafs > 8" + ts = cls(tapscript) + ts.parse_policy() + return ts -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,...))" - else: - return None - descriptor_template = descriptor_template % key_exp - return descriptor_template + def parse_policy(self): + self.policy, self.keys = self._parse_policy(self.tree, []) + orig_keys = OrderedDict() + for k in self.keys: + if k.origin not in orig_keys: + orig_keys[k.origin] = [] + orig_keys[k.origin].append(k) + for i, k_lst in enumerate(orig_keys.values()): + subderiv = True if len(k_lst) == 1 else False + self.policy = self.policy.replace(k_lst[0].to_string(subderiv=subderiv), chr(64) + str(i)) + + @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) + + 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) + + 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: + 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 + "}" + + def to_string(self, external=True, internal=True): + return fill_policy(self.policy, self.keys, external, internal) class Descriptor: - __slots__ = ( - "keys", - "addr_fmt", - ) + 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") - def __init__(self, keys, addr_fmt): - self.keys = keys - self.addr_fmt = addr_fmt + 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" + + c = chains.current_key_chain().ctype + for k in to_check: + assert k.chain_type == c, "wrong chain" + 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, c, my_xfp, False) + if is_mine: + has_mine += 1 + + assert has_mine != 0, 'My key %s missing in descriptor.' % xfp2str(my_xfp).upper() + + def storage_policy(self): + if self.tapscript: + return self.tapscript.policy + + s = self.miniscript.to_string() + orig_keys = OrderedDict() + for k in self.keys: + if k.origin not in orig_keys: + orig_keys[k.origin] = [] + orig_keys[k.origin].append(k) + for i, k_lst in enumerate(orig_keys.values()): + subderiv = True if len(k_lst) == 1 else False + s = s.replace(k_lst[0].to_string(subderiv=subderiv), 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): + keys = self.keys + if self.taproot and self.key.origin: + # ignore provably unspendable + keys += [self.key] + + return [ + key.origin.psbt_derivation() + for key in keys + if key.origin + ] + + @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=None, change=False): + if self.taproot: + return type(self)( + None, + self.sh, + self.wsh, + self.key.derive(idx, change=change), + self.wpkh, + self.taproot, + tapscript=self.tapscript.derive(idx, change=change), + ) + if self.miniscript: + return type(self)( + self.miniscript.derive(idx, change=change), + self.sh, + self.wsh, + None, + self.wpkh, + self.taproot, + tapscript=None, + ) + else: + return type(self)( + None, self.sh, self.wsh, + self.key.derive(idx, change=change), + 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): + """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 + if prefix in temp: + # weaker case - needed for JSON wrapped imports + # if descriptor is invalid or unsuitable for our purpose + # we fail later (in parsing) + return True + return False @staticmethod - def checksum_check(desc_w_checksum: str): + def checksum_check(desc_w_checksum, csum_required=False): try: desc, checksum = desc_w_checksum.split("#") except ValueError: - raise ValueError("Missing descriptor checksum") + 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 + @classmethod + def from_string(cls, desc, checksum=False): + desc = parse_desc_str(desc) + desc, cs = 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) + if checksum: + if cs is None: + _, cs = res.to_string().split("#") + return res, cs + return res - @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): - result = [] - for xfp, deriv, xpub in self.keys: - 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>" + "/" + "*" + @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: - 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(")") - - elif desc.startswith("tr("): - addr_fmt = AF_P2TR - tmp_desc = desc.replace("tr(", "") - 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("))") - + 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("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh( and tr(.") + raise ValueError("Invalid descriptor") - 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") + 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) - xpub = cls.parse_key_derivation_info(key) - xfp = str2xfp(koi[:8]) - origin_deriv = "m" + koi[8:] + 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 - return cls(keys=[(xfp, origin_deriv, xpub)], addr_fmt=addr_fmt) + 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 - @classmethod - def is_descriptor(cls, desc_str): - """Method to guess whether this can be a descriptor""" - try: - temp = parse_desc_str(desc_str) - desc, checksum = temp.split("#") - assert desc[-1] == ")" + desc = desc + ")" + return append_checksum(desc) - return True - except: - return False + 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 - def bitcoin_core_serialize(self, external_label=None): + 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", - "keys", - "addr_fmt", - ) - - def __init__(self, M, N, keys, addr_fmt): - self.M = M - self.N = N - super().__init__(keys, addr_fmt) - - @classmethod - def parse(cls, desc_w_checksum: str) -> "MultisigDescriptor": - # 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(")))") - - 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) - - def _serialize(self, internal=False, int_ext=False) -> str: - """Serialize without checksum""" - desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt] - 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" 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: @@ -371,23 +633,35 @@ class MultisigDescriptor(Descriptor): 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" + "# %d of %d (%s)\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" + str(self.M) + ",\n" + 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" + key_str + inner += ("\t" * inner_ident) + key_str else: - inner += "\t" + key_str + ",\n" + inner += ("\t" * inner_ident) + key_str + ",\n" checksum = self.serialize().split("#")[1] - return (res % inner) + "#" + checksum - -# EOF + return (res % inner) + "#" + checksum \ No newline at end of file diff --git a/shared/display.py b/shared/display.py index bf819683..fab563a4 100644 --- a/shared/display.py +++ b/shared/display.py @@ -4,7 +4,7 @@ # import machine, uzlib, ckcc, utime from ssd1306 import SSD1306_SPI -from version import is_devmode +from version import is_devmode, is_edge import framebuf from graphics_mk4 import Graphics @@ -146,6 +146,12 @@ class Display: self.text(-2, 21, 'D', font=FontTiny, invert=1) self.text(-2, 28, 'E', font=FontTiny, invert=1) self.text(-2, 35, 'V', font=FontTiny, invert=1) + elif is_edge: + self.dis.fill_rect(128 - 6, 19, 5, 26, 1) + self.text(-2, 20, 'E', font=FontTiny, invert=1) + self.text(-2, 27, 'D', font=FontTiny, invert=1) + self.text(-2, 33, 'G', font=FontTiny, invert=1) + self.text(-2, 39, 'E', font=FontTiny, invert=1) def fullscreen(self, msg, percent=None, line2=None): # show a simple message "fullscreen". diff --git a/shared/export.py b/shared/export.py index a48c6faa..0b163441 100644 --- a/shared/export.py +++ b/shared/export.py @@ -121,7 +121,8 @@ be needed for different systems. yield ('\n\n') from multisig import MultisigWallet - if MultisigWallet.exists(): + exists, exists_other_chain = MultisigWallet.exists() + if exists: yield '\n# Your Multisig Wallets\n\n' for ms in MultisigWallet.get_all(): @@ -198,10 +199,11 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx # make the data examples = [] - imp_multi, imp_desc = generate_bitcoin_core_wallet(account_num, examples) + imp_multi, imp_desc, imp_desc_tr = generate_bitcoin_core_wallet(account_num, examples) imp_multi = ujson.dumps(imp_multi) imp_desc = ujson.dumps(imp_desc) + imp_desc_tr = ujson.dumps(imp_desc_tr) body = '''\ # Bitcoin Core Wallet Import File @@ -217,7 +219,10 @@ Wallet operates on blockchain: {nb} The following command can be entered after opening Window -> Console in Bitcoin Core, or using bitcoin-cli: -importdescriptors '{imp_desc}' +p2wpkh: + importdescriptors '{imp_desc}' +p2tr: + importdescriptors '{imp_desc_tr}' > **NOTE** If your UTXO was created before generating `importdescriptors` command, you should adjust the value of `timestamp` before executing command in bitcoin core. By default it is set to `now` meaning do not rescan the blockchain. If approximate time of UTXO creation is known - adjust `timestamp` from `now` to UNIX epoch time. @@ -232,13 +237,15 @@ importmulti '{imp_multi}' ## Resulting Addresses (first 3) -'''.format(imp_multi=imp_multi, imp_desc=imp_desc, xfp=xfp, nb=chains.current_chain().name) +'''.format(imp_multi=imp_multi, imp_desc=imp_desc, imp_desc_tr=imp_desc_tr, + xfp=xfp, nb=chains.current_chain().name) body += '\n'.join('%s => %s' % t for t in examples) body += '\n' OWNERSHIP.note_wallet_used(AF_P2WPKH, account_num) + OWNERSHIP.note_wallet_used(AF_P2TR, account_num) ch = chains.current_chain() derive = "84h/{coin_type}h/{account}h".format(account=account_num, coin_type=ch.b44_cointype) @@ -247,44 +254,65 @@ importmulti '{imp_multi}' 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 + from descriptor import Descriptor, Key chain = chains.current_chain() - derive = "84h/{coin_type}h/{account}h".format(account=account_num, - coin_type=chain.b44_cointype) - + derive_v0 = "84h/{coin_type}h/{account}h".format( + account=account_num, coin_type=chain.b44_cointype + ) + derive_v1 = "86h/{coin_type}h/{account}h".format( + account=account_num, coin_type=chain.b44_cointype + ) with stash.SensitiveValues() as sv: - prefix = sv.derive_path(derive) - xpub = chain.serialize_public(prefix) + prefix = sv.derive_path(derive_v0) + xpub_v0 = chain.serialize_public(prefix) for i in range(3): sp = '0/%d' % i node = sv.derive_path(sp, master=prefix) a = chain.address(node, AF_P2WPKH) - example_addrs.append( ('m/%s/%s' % (derive, sp), a) ) + example_addrs.append(('m/%s/%s' % (derive_v0, sp), a)) + + with stash.SensitiveValues() as sv: + prefix = sv.derive_path(derive_v1) + xpub_v1 = chain.serialize_public(prefix) + + for i in range(3): + sp = '0/%d' % i + node = sv.derive_path(sp, master=prefix) + a = chain.address(node, AF_P2TR) + example_addrs.append(('m/%s/%s' % (derive_v1, sp), a)) xfp = settings.get('xfp') - _, 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) + + key1 = Key.from_cc_data(xfp, derive_v1, xpub_v1) + desc_v1 = Descriptor(key=key1) + desc_v1.set_from_addr_fmt(AF_P2TR) OWNERSHIP.note_wallet_used(AF_P2WPKH, account_num) + OWNERSHIP.note_wallet_used(AF_P2TR, account_num) - desc_obj = Descriptor(keys=[(xfp, derive, xpub)], addr_fmt=AF_P2WPKH) # for importmulti imm_list = [ { - 'desc': desc_obj.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_obj.bitcoin_core_serialize() - return imm_list, imd_list + imd_list = desc_v0.bitcoin_core_serialize() + imd_list_v1 = desc_v1.bitcoin_core_serialize() + return imm_list, imd_list, imd_list_v1 + def generate_wasabi_wallet(): # Generate the data for a JSON file which Wasabi can open directly as a new wallet. @@ -350,7 +378,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, multisig_descriptor_template + from descriptor import Descriptor, Key + from desc_utils import multisig_descriptor_template chain = chains.current_chain() master_xfp = settings.get("xfp") @@ -364,14 +393,14 @@ def generate_generic_export(account_num=0): with stash.SensitiveValues() as sv: # each of these paths would have /{change}/{idx} in usage (not hardened) for name, deriv, fmt, atype, is_ms in [ - ( 'bip44', "m/44h/{ct}h/{acc}h", AF_CLASSIC, 'p2pkh', False ), - ( 'bip49', "m/49h/{ct}h/{acc}h", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False ), # was "p2wpkh-p2sh" - ( 'bip84', "m/84h/{ct}h/{acc}h", AF_P2WPKH, 'p2wpkh', False ), + ('bip44', "m/44h/{ct}h/{acc}h", AF_CLASSIC, 'p2pkh', False), + ('bip49', "m/49h/{ct}h/{acc}h", AF_P2WPKH_P2SH, 'p2sh-p2wpkh', False), # was "p2wpkh-p2sh" + ('bip84', "m/84h/{ct}h/{acc}h", AF_P2WPKH, 'p2wpkh', False), ('bip86', "m/86h/{ct}h/{acc}h", AF_P2TR, 'p2tr', False), - ( 'bip48_1', "m/48h/{ct}h/{acc}h/1h", AF_P2WSH_P2SH, 'p2sh-p2wsh', True ), - ( 'bip48_2', "m/48h/{ct}h/{acc}h/2h", AF_P2WSH, 'p2wsh', True ), - ('bip48_3', "m/48h/{ct}h/{acc}h/3h", AF_P2TR, 'p2tr', True ), - ( 'bip45', "m/45h", AF_P2SH, 'p2sh', True ), + ('bip48_1', "m/48h/{ct}h/{acc}h/1h", AF_P2WSH_P2SH, 'p2sh-p2wsh', True), + ('bip48_2', "m/48h/{ct}h/{acc}h/2h", AF_P2WSH, 'p2wsh', True), + ('bip48_3', "m/48h/{ct}h/{acc}h/3h", AF_P2TR, 'p2tr', True), + ('bip45', "m/45h", AF_P2SH, 'p2sh', True), ]: if fmt == AF_P2SH and account_num: continue @@ -384,7 +413,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() OWNERSHIP.note_wallet_used(fmt, account_num) @@ -510,7 +542,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 + from descriptor import Descriptor, Key from glob import dis dis.fullscreen('Generating...') @@ -541,17 +573,20 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int 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 90672729..5e97dac1 100644 --- a/shared/flow.py +++ b/shared/flow.py @@ -10,6 +10,7 @@ from actions import * from choosers import * from mk4 import dev_enable_repl from multisig import make_multisig_menu, import_multisig_nfc +from miniscript import make_miniscript_menu from seed import make_ephemeral_seed_menu, make_seed_vault_menu, start_b39_pw from address_explorer import address_explore from drv_entro import drv_entro_start, password_entry @@ -138,6 +139,8 @@ SettingsMenu = [ MenuItem('Hardware On/Off', menu=HWTogglesMenu), NonDefaultMenuItem('Multisig Wallets', 'multisig', menu=make_multisig_menu, predicate=has_secrets), + NonDefaultMenuItem('Miniscript', 'miniscript', + menu=make_miniscript_menu, predicate=has_secrets), NonDefaultMenuItem('NFC Push Tx', 'ptxurl', menu=pushtx_setup_menu), MenuItem('Display Units', chooser=value_resolution_chooser), MenuItem('Max Network Fee', chooser=max_fee_chooser), @@ -176,6 +179,7 @@ XpubExportMenu = [ # xxxxxxxxxxxxxxxx MenuItem("Segwit (BIP-84)", f=export_xpub, arg=84), MenuItem("Classic (BIP-44)", f=export_xpub, arg=44), + MenuItem("Taproot/P2TR(86)", f=export_xpub, arg=86), MenuItem("P2WPKH/P2SH (49)", f=export_xpub, arg=49), MenuItem("Master XPUB", f=export_xpub, arg=0), MenuItem("Current XFP", f=export_xpub, arg=-1), diff --git a/shared/hsm.py b/shared/hsm.py index db538668..0d56dfea 100644 --- a/shared/hsm.py +++ b/shared/hsm.py @@ -4,16 +4,15 @@ # # Unattended signing of transactions and messages, subject to a set of rules. # -import stash, ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu, version -from sffile import SFFile +import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu from utils import problem_file_line, cleanup_deriv_path, match_deriv_path from pincodes import AE_LONG_SECRET_LEN from stash import blank_object from users import Users, MAX_NUMBER_USERS, calc_local_pincode from public_constants import MAX_USERNAME_LEN from multisig import MultisigWallet +from miniscript import MiniScriptWallet from ubinascii import hexlify as b2a_hex -from ubinascii import unhexlify as a2b_hex from uhashlib import sha256 from ucollections import OrderedDict from files import CardSlot, CardMissingError @@ -88,13 +87,13 @@ def pop_list(j, fld_name, cleanup_fcn=None): else: return [] -def pop_deriv_list(j, fld_name, extra_val=None): +def pop_deriv_list(j, fld_name, extra_vals=None): # expect a list of derivation paths, but also 'any' meaning accept all # - maybe also 'p2sh' as special value # - also, path can have n def cu(s): - if s.lower() == 'any': return s.lower() - if extra_val and s.lower() == extra_val: return s.lower() + if extra_vals and s.lower() in extra_vals: + return s.lower() try: return cleanup_deriv_path(s, allow_star=True) except: @@ -195,7 +194,7 @@ class ApprovalRule: # - users: list of authorized users # - min_users: how many of those are needed to approve # - local_conf: local user must also confirm w/ code - # - wallet: which multisig wallet to restrict to, or '1' for single signer only + # - wallet: which multisig/miniscript wallet to restrict to, or '1' for single signer only # - min_pct_self_transfer: minimum percentage of own input value that must go back to self # - patterns: list of transaction patterns to check for. Valid values: # * EQ_NUM_INS_OUTS: the number of inputs and outputs must be equal @@ -212,6 +211,7 @@ class ApprovalRule: return u self.index = idx+1 + self.ms_type = "multisig" self.per_period = pop_int(j, 'per_period', 0, MAX_SATS) self.max_amount = pop_int(j, 'max_amount', 0, MAX_SATS) self.users = pop_list(j, 'users', check_user) @@ -238,8 +238,11 @@ class ApprovalRule: # if specified, 'wallet' must be an existing multisig wallet's name if self.wallet and self.wallet != '1': - names = [ms.name for ms in MultisigWallet.get_all()] - assert self.wallet in names, "unknown MS wallet: "+self.wallet + ms_names = [ms.name for ms in MultisigWallet.get_all()] + msc_names = [msc.name for msc in MiniScriptWallet.get_all()] + assert self.wallet in (ms_names+msc_names), "unknown wallet: "+self.wallet + if self.wallet in msc_names: + self.ms_type = "miniscript" # patterns must be valid for p in self.patterns: @@ -283,9 +286,9 @@ class ApprovalRule: rv = 'Any amount' if self.wallet == '1': - rv += ' (non multisig)' + rv += ' (singlesig only)' elif self.wallet: - rv += ' from multisig wallet "%s"' % self.wallet + rv += ' from %s wallet "%s"' % (self.ms_type, self.wallet) if self.users: rv += ' may be authorized by ' @@ -328,10 +331,12 @@ class ApprovalRule: # rule limited to one wallet if psbt.active_multisig: # if multisig signing, might need to match specific wallet name - assert self.wallet == psbt.active_multisig.name, 'wrong wallet' + assert self.wallet == psbt.active_multisig.name, 'wrong multisig wallet' + elif psbt.active_miniscript: + assert self.wallet == psbt.active_miniscript.name, 'wrong miniscript wallet' else: # non multisig, but does this rule apply to all wallets or single-singers - assert self.wallet == '1', 'not multisig' + assert self.wallet == '1', 'singlesig only' if self.max_amount is not None: assert total_out <= self.max_amount, 'amount exceeded' @@ -504,9 +509,9 @@ class HSMPolicy: self.warnings_ok = pop_bool(j, 'warnings_ok') # a list of paths we can accept for signing - self.msg_paths = pop_deriv_list(j, 'msg_paths') - self.share_xpubs = pop_deriv_list(j, 'share_xpubs') - self.share_addrs = pop_deriv_list(j, 'share_addrs', 'p2sh') + self.msg_paths = pop_deriv_list(j, 'msg_paths', ['any']) + self.share_xpubs = pop_deriv_list(j, 'share_xpubs', ['any']) + self.share_addrs = pop_deriv_list(j, 'share_addrs', ['p2sh', 'any', 'msas']) # free text shown at top self.notes = pop_string(j, 'notes', 1, 80) @@ -814,12 +819,16 @@ class HSMPolicy: return match_deriv_path(self.share_xpubs, subpath) - def approve_address_share(self, subpath=None, is_p2sh=False): + def approve_address_share(self, subpath=None, is_p2sh=False, miniscript=False): # Are we allowing "show address" requests over USB? if not self.share_addrs: return False + if miniscript: + print("self.share_addrs", self.share_addrs) + return ('msas' in self.share_addrs) + if is_p2sh: return ('p2sh' in self.share_addrs) @@ -894,6 +903,7 @@ class HSMPolicy: # reject anything with warning, probably if psbt.warnings: + print(psbt.warnings) if self.warnings_ok: log.info("Txn has warnings, but policy is to accept anyway.") else: @@ -994,7 +1004,8 @@ def hsm_status_report(): rv['approval_wait'] = True rv['users'] = Users.list() - rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()] + rv['wallets'] = [ms.name for ms in MultisigWallet.get_all()] \ + + [msc.name for msc in MiniScriptWallet.get_all()] rv['chain'] = settings.get('chain', 'BTC') diff --git a/shared/manifest.py b/shared/manifest.py index df9165ac..b569eb3b 100644 --- a/shared/manifest.py +++ b/shared/manifest.py @@ -6,12 +6,14 @@ freeze_as_mpy('', [ 'address_explorer.py', 'auth.py', 'backups.py', + 'bsms.py', 'callgate.py', 'chains.py', 'choosers.py', 'compat7z.py', 'countdowns.py', 'descriptor.py', + 'desc_utils.py', 'dev_helper.py', 'display.py', 'drv_entro.py', @@ -26,6 +28,7 @@ freeze_as_mpy('', [ 'login.py', 'main.py', 'menu.py', + 'miniscript.py', 'multisig.py', 'numpad.py', 'nvstore.py', diff --git a/shared/miniscript.py b/shared/miniscript.py new file mode 100644 index 00000000..607b7bd1 --- /dev/null +++ b/shared/miniscript.py @@ -0,0 +1,1878 @@ +# (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, uio, chains, ure, version +from ucollections import OrderedDict +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 import BaseStorageWallet +from menu import MenuSystem, MenuItem +from ux import ux_show_story, ux_confirm, ux_dramatic_pause +from files import CardSlot, CardMissingError, needs_microsd +from utils import problem_file_line, xfp2str, addr_fmt_label, truncate_address, to_ascii_printable +from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER + + +class MiniscriptException(ValueError): + pass + + +class MiniScriptWallet(BaseStorageWallet): + 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, chain_type=None): + super().__init__(chain_type=chain_type) + 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 = self.desc.keys + if self._keys is not None: + self._keys = [k.to_string() for k in self._keys] + return self._keys + + @property + def key(self): + if not self._key: + self._key = self.desc.key + if self._key is not None: + self._key = self._key.to_string() + return self._key + + @property + def addr_fmt(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 to_descriptor(self): + 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.chain_type, + self.desc.addr_fmt, + key, + keys, + policy, + sh, wsh, wpkh, taproot + ) + + @classmethod + def deserialize(cls, c, idx=-1): + name, ct, 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, + chain_type=ct) + rv.storage_idx = idx + return rv + + def xfp_paths(self): + if self._desc is None: + res = [] + if self._key: + ik = Key.from_string(self.key) + if ik.origin: + res.append(ik.origin.psbt_derivation()) + 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.addr_fmt != addr_fmt: + continue + if rv.matching_subpaths(xfp_paths): + return rv + return None + + def matching_subpaths(self, xfp_paths): + my_xfp_paths = self.xfp_paths() + if len(xfp_paths) != len(my_xfp_paths): + return False + for x in my_xfp_paths: + prefix_len = len(x) + for y in xfp_paths: + if x == y[:prefix_len]: + break + else: + return False + return True + + def subderivation_indexes(self, xfp_paths): + # we already know that they do match + my_xfp_paths = self.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]: + to_derive = tuple(y[prefix_len:]) + res.add(to_derive) + + assert res + if len(res) == 1: + branch, idx = list(res)[0] + else: + branch = [i[0] for i in res] + indexes = set([i[1] for i in res]) + assert len(indexes) == 1 + idx = list(indexes)[0] + + return branch, idx + + def 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, is_duplicate=False): + + s = addr_fmt_label(self.addr_fmt) + "\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 and not is_duplicate: + story += ", OK to approve, X to cancel." + return story + + async def show_detail(self, new_wallet=False, duplicates=None): + title = self.name + story = "" + if duplicates: + title = None + story += "This wallet is a duplicate of already saved wallet %s\n\n" % duplicates[0].name + elif new_wallet: + title = None + story += "Create new miniscript wallet?\n\nWallet Name:\n %s\n\n" % self.name + story += await self._detail(new_wallet, is_duplicate=duplicates) + 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/%s\n\n' % (xfp2str(xfp), deriv, xpub, + key.derivation.to_string()) + return s + + async def show_keys(self): + msg = "" + if self.taproot: + msg = self.taproot_internal_key_detail() + msg += "Taproot tree keys:\n\n" + + orig_keys = OrderedDict() + for k in self.keys: + if isinstance(k, str): + k = Key.from_string(k) + if k.origin not in orig_keys: + orig_keys[k.origin] = [] + orig_keys[k.origin].append(k) + + for idx, k_lst in enumerate(orig_keys.values()): + subderiv = True if len(k_lst) == 1 else False + if idx: + msg += '\n---===---\n\n' + + msg += '@%s:\n %s\n\n' % (idx, k_lst[0].to_string(subderiv=subderiv)) + + await ux_show_story(msg) + + @classmethod + def from_file(cls, config, name=None): + from descriptor import Descriptor + if name is None: + desc_obj, cs = Descriptor.from_string(config.strip(), checksum=True) + name = cs + else: + name = to_ascii_printable(name) + desc_obj = Descriptor.from_string(config.strip()) + assert not desc_obj.is_basic_multisig, "Use Settings -> Multisig Wallets" + wal = cls(desc_obj, name=name, chain_type=desc_obj.keys[0].chain_type) + return wal + + def find_duplicates(self): + matches = [] + name_unique = True + for rv in self.iter_wallets(): + if self.name == rv.name: + name_unique = False + if self.key != rv.key: + continue + if self.policy != rv.policy: + continue + if len(self.keys) != len(rv.keys): + continue + if self.keys != rv.keys: + continue + + matches.append(rv) + + return matches, name_unique + + async def confirm_import(self): + nope, yes = (KEY_CANCEL, KEY_ENTER) if version.has_qwerty else ("x", "y") + dups, name_unique = self.find_duplicates() + if not name_unique: + await ux_show_story(title="FAILED", msg=("Miniscript wallet with name '%s'" + " already exists. All wallets MUST" + " have unique names.") % self.name) + return nope + to_save = await self.show_detail(new_wallet=True, duplicates=dups) + + ch = yes if to_save else nope + if to_save and not dups: + assert self.storage_idx == -1 + self.commit() + await ux_dramatic_pause("Saved.", 2) + + return ch + + def yield_addresses(self, start_idx, count, change=False, scripts=True, change_idx=0): + ch = chains.current_chain() + dd = self.desc.derive(None, change=change) + idx = start_idx + while count: + # make the redeem script, convert into address + d = dd.derive(idx) + addr = ch.render_address(d.script_pubkey()) + + 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, + addr, + [str(k.origin) for k in d.keys], + script, + d.key.serialize(), + str(d.key.origin) if d.key.origin else "") + else: + yield (idx, + addr, + [str(k.origin) for k in d.keys], + script, + None, + None) + + idx += 1 + count -= 1 + + def make_addresses_msg(self, msg, start, n, change=0): + from glob import dis + + addrs = [] + + for i, addr, paths, _, ik, ikp in self.yield_addresses(start, n, + change=bool(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: + change_idx = set([int(p.split("/")[-2]) for p in paths]) + if len(change_idx) == 1: + msg += '.../%d/%d =>\n' % (list(change_idx)[0], i) + else: + msg += '.../%d =>\n' % 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, addr, derivs, script, ik, ikp) in self.yield_addresses(start, n, + change=bool(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 + multipath_rgx = ure.compile(r"<\d+;\d+>") + match = multipath_rgx.search(key) + if match: + mp = match.group(0) + ext, int = mp[1:-1].split(";") + if internal != external: + to_replace = ext if external else int + key = self._key.replace(mp, 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 + from ux import import_export_prompt + + 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" + + if core: + msg = "importdescriptor cmd" + 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: + msg = self.name + 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), + ) + + ch = await import_export_prompt("%s file" % name) + if isinstance(ch, str): + if ch in "3"+KEY_NFC: + await NFC.share_text(res) + elif ch == KEY_QR: + try: + from ux import show_qr_code + await show_qr_code(res, msg=msg) + except: + if version.has_qwerty: + from ux_q1 import show_bbqr_codes + await show_bbqr_codes('U', res, msg) + return + + try: + with CardSlot(**ch) as card: + fname, nice = card.pick_filename(fname_pattern) + + # do actual write + with open(fname, 'w+') as fp: + fp.write(res) + # fp.seek(0) + # contents = fp.read() + # TODO re-enable once we know how to proceed with regards to with which key to sign + # 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_delete(msc): + if not await ux_confirm("Delete miniscript wallet '%s'?\n\nFunds may be impacted." % msc.name): + await ux_dramatic_pause('Aborted.', 3) + return + + msc.delete() + await ux_dramatic_pause('Deleted.', 3) + +async def miniscript_wallet_delete(menu, label, item): + msc = item.arg + + await miniscript_delete(msc) + + from ux import the_ux + # pop stack + the_ux.pop() + + m = the_ux.top_of_stack() + m.update_contents() + +async def miniscript_wallet_detail(menu, label, item): + # show details of single multisig wallet + + msc = item.arg + + return await msc.show_detail() + +async def import_miniscript(*a): + # pick text file from SD card, import as multisig setup file + from actions import file_picker + from glob import dis + from ux import import_export_prompt + + ch = await import_export_prompt("miniscript wallet file", is_import=True) + if isinstance(ch, str): + if ch == KEY_QR: + await import_miniscript_qr() + elif ch == KEY_NFC: + await import_miniscript_nfc() + return + + def possible(filename): + with open(filename, 'rt') as fd: + for ln in fd: + if "sh(" in ln or "wsh(" in ln or "tr(" in ln: + # descriptor import + return True + + fn = await file_picker(suffix=['.txt', '.json'], min_size=100, + taster=possible, **ch) + if not fn: return + + try: + with CardSlot(**ch) as card: + with open(fn, 'rt') as fp: + data = fp.read() + except CardMissingError: + await needs_microsd() + return + + from auth import maybe_enroll_xpub + try: + possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None + maybe_enroll_xpub(config=data, name=possible_name, 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 import_miniscript_qr(*a): + from auth import maybe_enroll_xpub + from ux_q1 import QRScannerInteraction + data = await QRScannerInteraction().scan_text('Scan Miniscript from a QR code') + if not data: + # press pressed CANCEL + return + + try: + maybe_enroll_xpub(config=data, miniscript=True) + except Exception as e: + await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) + +async def miniscript_wallet_export(menu, label, item): + # create a text file with the details; ready for import to next Coldcard + msc = item.arg[0] + kwargs = item.arg[1] + await msc.export_wallet_file(**kwargs) + +async def make_miniscript_wallet_descriptor_menu(menu, label, item): + # descriptor menu + msc = item.arg + if not msc: + return + + rv = [ + MenuItem('Export', f=miniscript_wallet_export, arg=(msc, {"core": False})), + MenuItem('Bitcoin Core', f=miniscript_wallet_export, arg=(msc, {"core": True})), + ] + return rv + +async def make_miniscript_wallet_menu(menu, label, item): + # details, actions on single multisig wallet + msc = MiniScriptWallet.get_by_idx(item.arg) + if not msc: return + + rv = [ + MenuItem('"%s"' % msc.name, f=miniscript_wallet_detail, arg=msc), + MenuItem('View Details', f=miniscript_wallet_detail, arg=msc), + MenuItem('Delete', f=miniscript_wallet_delete, arg=msc), + MenuItem('Descriptors', menu=make_miniscript_wallet_descriptor_menu, arg=msc), + ] + return rv + + +class MiniscriptMenu(MenuSystem): + @classmethod + def construct(cls): + import version + from menu import ShortcutItem + + exists, exists_other_chain = MiniScriptWallet.exists() + if not exists: + rv = [MenuItem(MiniScriptWallet.none_setup_yet(exists_other_chain), f=no_miniscript_yet)] + else: + rv = [] + for msc in MiniScriptWallet.get_all(): + rv.append(MenuItem('%s' % msc.name, + menu=make_miniscript_wallet_menu, + arg=msc.storage_idx)) + from glob import NFC + rv.append(MenuItem('Import', f=import_miniscript)) + rv.append(ShortcutItem(KEY_NFC, predicate=lambda: NFC is not None, + f=import_miniscript_nfc)) + rv.append(ShortcutItem(KEY_QR, predicate=lambda: version.has_qwerty, + f=import_miniscript_qr)) + return rv + + def update_contents(self): + # Reconstruct the list of wallets on this dynamic menu, because + # we added or changed them and are showing that same menu again. + tmp = self.construct() + self.replace_items(tmp) + +async def make_miniscript_menu(*a): + # list of all multisig wallets, and high-level settings/actions + from pincodes import pa + + if pa.is_secret_blank(): + await ux_show_story("You must have wallet seed before creating miniscript wallets.") + return + + rv = MiniscriptMenu.construct() + return MiniscriptMenu(rv) + + +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, None + 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, change=False): + if key_map and key in key_map: + kd = key_map[key] + else: + kd = key.derive(idx, change=change) + return kd + + def derive(self, idx, key_map=None, change=False): + 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, change=change) + else: + arg = arg.derive(idx, change=change) + + 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] \ No newline at end of file diff --git a/shared/multisig.py b/shared/multisig.py index 599b0563..64348c46 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -3,19 +3,23 @@ # multisig.py - support code for multisig signing and p2sh in general. # import stash, chains, ustruct, ure, uio, sys, ngu, uos, ujson, version -from utils import xfp2str, str2xfp, swab32, cleanup_deriv_path, keypath_to_str, to_ascii_printable -from utils import str_to_keypath, problem_file_line, parse_extended_key +from ubinascii import hexlify as b2a_hex +from utils import xfp2str, str2xfp, cleanup_deriv_path, keypath_to_str, to_ascii_printable +from utils import str_to_keypath, problem_file_line, check_xpub, truncate_address from ux import ux_show_story, ux_confirm, ux_dramatic_pause, ux_clear_keys from ux import import_export_prompt, ux_enter_bip32_index, show_qr_code from files import CardSlot, CardMissingError, needs_microsd -from descriptor import MultisigDescriptor, multisig_descriptor_template -from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS -from menu import MenuSystem, MenuItem, ShortcutItem +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, MAX_SIGNERS, AF_P2TR +from menu import MenuSystem, MenuItem from opcodes import OP_CHECKMULTISIG from exceptions import FatalPSBTIssue from glob import settings from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR -from wallet import WalletABC, MAX_BIP32_IDX +from serializations import disassemble +from wallet import BaseStorageWallet, MAX_BIP32_IDX # PSBT Xpub trust policies TRUST_VERIFY = const(0) @@ -23,14 +27,11 @@ TRUST_OFFER = const(1) 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 MAX_SIGNERS == 15 - 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 @@ -42,9 +43,7 @@ def disassemble_multisig(redeem_script): # - only for multisig scripts, not general purpose # - expect OP_1 (pk1) (pk2) (pk3) OP_3 OP_CHECKMULTISIG for 1 of 3 case # - returns M, N, (list of pubkeys) - # - for very unlikely/impossible asserts, dont document reason; otherwise do. - from serializations import disassemble - + # - for very unlikely/impossible asserts, don't document reason; otherwise do. M, N = disassemble_multisig_mn(redeem_script) assert 1 <= M <= N <= MAX_SIGNERS, 'M/N range' assert len(redeem_script) == 1 + (N * 34) + 1 + 1, 'bad len' @@ -106,7 +105,7 @@ def make_redeem_script(M, nodes, subkey_idx): return b''.join(pubkeys) -class MultisigWallet(WalletABC): +class MultisigWallet(BaseStorageWallet): # 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 @@ -121,19 +120,20 @@ class MultisigWallet(WalletABC): (AF_P2SH, 'p2sh'), (AF_P2WSH, 'p2wsh'), (AF_P2WSH_P2SH, 'p2sh-p2wsh'), # preferred + (AF_P2TR, 'p2tr'), (AF_P2WSH_P2SH, 'p2wsh-p2sh'), # obsolete (now an alias) ] # 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'): - self.storage_idx = -1 + def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type=None): + super().__init__(chain_type=chain_type) self.name = name assert len(m_of_n) == 2 self.M, self.N = m_of_n - self.chain_type = chain_type or 'BTC' assert len(xpubs[0]) == 3 self.xpubs = xpubs # list of (xfp(int), deriv, xpub(str)) self.addr_fmt = addr_fmt # address format for wallet @@ -161,17 +161,13 @@ class MultisigWallet(WalletABC): deriv = derivs[0] return deriv + '/%d/%d' % (change_idx, idx) - @property - def chain(self): - return chains.get_chain(self.chain_type) - @classmethod def get_trust_policy(cls): which = settings.get('pms', None) - + exists, _ = cls.exists() if which is None: - which = TRUST_VERIFY if cls.exists() else TRUST_OFFER + which = TRUST_VERIFY if exists else TRUST_OFFER return which @@ -227,14 +223,26 @@ class MultisigWallet(WalletABC): return rv @classmethod - def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmt=None): + def is_correct_chain(cls, o, curr_chain): + if "ch" not in o[-1]: + # mainnet + ch = "BTC" + else: + ch = o[-1]["ch"] + + if ch == curr_chain.ctype: + return True + return False + + @classmethod + 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, []) + c = chains.current_key_chain() for idx, rec in enumerate(lst): - if idx == not_idx: - # ignore one by index + if not cls.is_correct_chain(rec, c): continue if M or N: @@ -331,57 +339,6 @@ class MultisigWallet(WalletABC): 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: @@ -433,12 +390,12 @@ class MultisigWallet(WalletABC): else: raise IndexError # consistency bug - lst = settings.get('multisig', []) + lst = settings.get(self.key_name, []) del lst[self.storage_idx] if lst: - settings.set('multisig', lst) + settings.set(self.key_name, lst) else: - settings.remove_key('multisig') + settings.remove_key(self.key_name) settings.save() self.storage_idx = -1 @@ -451,7 +408,7 @@ class MultisigWallet(WalletABC): 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. - ch = self.chain + ch = chains.current_chain() assert self.addr_fmt, 'no addr fmt known' @@ -480,6 +437,35 @@ class MultisigWallet(WalletABC): idx += 1 count -= 1 + def make_addresses_msg(self, msg, start, n, change=0): + from glob import dis + + addrs = [] + + for idx, addr, paths, script in self.yield_addresses(start, n, change): + if idx == 0 and self.N <= 4: + msg += '\n'.join(paths) + '\n =>\n' + else: + msg += '.../%d/%d =>\n' % (change, idx) + + addrs.append(addr) + msg += truncate_address(addr) + '\n\n' + dis.progress_sofar(idx - start + 1, n) + + return msg, addrs + + def generate_address_csv(self, start, n, change): + yield '"' + '","'.join(['Index', 'Payment Address', + 'Redeem Script (%d of %d)' % (self.M, self.N)] + + (['Derivation'] * self.N)) + '"\n' + + for (idx, addr, derivs, 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' + + 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. # - working from pubkeys in the script, because duplicate XFP can happen @@ -548,7 +534,7 @@ class MultisigWallet(WalletABC): 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): @@ -659,7 +645,9 @@ class MultisigWallet(WalletABC): 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_key_chain().ctype, + my_xfp, cls.disable_checks) + xpubs.append(item) if is_mine: has_mine += 1 @@ -672,20 +660,34 @@ class MultisigWallet(WalletABC): 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_key_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 + + 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, - ) + 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): @@ -706,9 +708,9 @@ class MultisigWallet(WalletABC): # - M of N line (assume N of N if not spec'd) # - xpub: any bip32 serialization we understand, but be consistent # - expect_chain = chains.current_chain().ctype - if "sortedmulti(" in config or MultisigDescriptor.is_descriptor(config): - # assume descriptor, classic config should not contain sortedmulti( and check for checksum separator + expect_chain = chains.current_key_chain().ctype + 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 = cls.from_descriptor(config) else: @@ -747,83 +749,6 @@ class MultisigWallet(WalletABC): # done. have all the parts return cls(name, (M, N), xpubs, addr_fmt=addr_fmt, chain_type=expect_chain) - @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) - def make_fname(self, prefix, suffix='txt'): rv = '%s-%s.%s' % (prefix, self.name, suffix) return rv.replace(' ', '_') @@ -926,7 +851,7 @@ class MultisigWallet(WalletABC): 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): @@ -939,9 +864,10 @@ class MultisigWallet(WalletABC): 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: @@ -1013,8 +939,9 @@ class MultisigWallet(WalletABC): 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) @@ -1022,7 +949,7 @@ class MultisigWallet(WalletABC): 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 @@ -1046,7 +973,9 @@ class MultisigWallet(WalletABC): # 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 @@ -1139,7 +1068,7 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp= 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() @@ -1163,13 +1092,15 @@ Addresses: at=self.render_addr_fmt(self.addr_fmt))) # 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) for idx, (xfp, deriv, xpub) in enumerate(self.xpubs): if idx: msg.write('\n---===---\n\n') msg.write('%s:\n %s\n\n%s\n' % (xfp2str(xfp), deriv, xpub)) - if self.addr_fmt != AF_P2SH: + if self.addr_fmt not in (AF_P2SH, AF_P2TR): # SLIP-132 format [yz]pubs here when not p2sh mode. # - has same info as proper bitcoin serialization, but looks much different node = self.chain.deserialize_node(xpub, AF_P2SH) @@ -1256,8 +1187,11 @@ class MultisigMenu(MenuSystem): def construct(cls): # Dynamic menu with user-defined names of wallets shown - if not MultisigWallet.exists(): - rv = [MenuItem('(none setup yet)', f=no_ms_yet)] + from bsms import make_ms_wallet_bsms_menu + + exists, exists_other_chain = MultisigWallet.exists() + if not exists: + rv = [MenuItem(MultisigWallet.none_setup_yet(exists_other_chain), f=no_ms_yet)] else: rv = [] for ms in MultisigWallet.get_all(): @@ -1270,6 +1204,7 @@ class MultisigMenu(MenuSystem): rv.append(MenuItem('Import via NFC', f=import_multisig_nfc, predicate=bool(NFC), shortcut=KEY_NFC)) rv.append(MenuItem('Export XPUB', f=export_multisig_xpubs)) + rv.append(MenuItem('BSMS (BIP-129)', menu=make_ms_wallet_bsms_menu)) rv.append(MenuItem('Create Airgapped', f=create_ms_step1)) rv.append(MenuItem('Trust PSBT?', f=trust_psbt_menu)) rv.append(MenuItem('Skip Checks?', f=disable_checks_menu)) @@ -1299,7 +1234,7 @@ 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('Delete', f=ms_wallet_delete, arg=ms), @@ -1307,7 +1242,6 @@ async def make_ms_wallet_menu(menu, label, item): MenuItem('Descriptors', menu=make_ms_wallet_descriptor_menu, arg=ms), MenuItem('Electrum Wallet', f=ms_wallet_electrum_export, arg=ms), ] - return rv async def make_ms_wallet_descriptor_menu(menu, label, item): # descriptor menu @@ -1357,7 +1291,7 @@ async def ms_wallet_show_descriptor(menu, label, item): dis.fullscreen("Wait...") 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) @@ -1422,6 +1356,8 @@ P2SH-P2WSH: m/48h/{coin}h/{{acct}}h/1h P2WSH: m/48h/{coin}h/{{acct}}h/2h +P2TR: + m/48h/{coin}h/{{acct}}h/3h OK to continue. X to abort.'''.format(coin=chain.b44_cointype) @@ -1440,9 +1376,10 @@ OK to continue. X to abort.'''.format(coin=chain.b44_cointype) dis.fullscreen('Generating...') todo = [ - ( "m/45h", 'p2sh', AF_P2SH), # iff acct_num == 0 - ( "m/48h/{coin}h/{acct_num}h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH ), - ( "m/48h/{coin}h/{acct_num}h/2h", 'p2wsh', AF_P2WSH ), + ("m/45h", 'p2sh', AF_P2SH), # iff acct_num == 0 + ("m/48h/{coin}h/{acct_num}h/1h", 'p2sh_p2wsh', AF_P2WSH_P2SH), + ("m/48h/{coin}h/{acct_num}h/2h", 'p2wsh', AF_P2WSH), + ("m/48h/{coin}h/{acct_num}h/3h", 'p2tr', AF_P2TR), ] def render(fp): @@ -1532,7 +1469,7 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, force_vdisk= # sigh, OS/filesystem variations file_size = var[1] if len(var) == 2 else get_filesize(full_fname) - if not (0 <= file_size <= 1100): + if not (0 <= file_size <= 1500): # out of range size continue @@ -1550,8 +1487,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 @@ -1634,9 +1572,9 @@ Coldcard multisig setup file and an Electrum wallet file will be created automat 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 @@ -1666,7 +1604,7 @@ 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)) @@ -1713,7 +1651,7 @@ async def import_multisig(*a): if 'pub' in ln: return True - fn = await file_picker(suffix='.txt', min_size=100, max_size=20*200, + fn = await file_picker(suffix=['.txt', '.json'], min_size=100, max_size=350*200, taster=possible, force_vdisk=force_vdisk) if not fn: return diff --git a/shared/nfc.py b/shared/nfc.py index d49c7e7e..61291a6b 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -613,7 +613,6 @@ class NFCHandler: aborted = await n.share_text("NFC is working: %s" % n.get_uid(), allow_enter=False) assert not aborted, "Aborted" - async def share_file(self): # Pick file from SD card and share over NFC... from actions import file_picker @@ -658,32 +657,6 @@ class NFCHandler: else: raise ValueError(ext) - async def import_multisig_nfc(self, *a): - # user is pushing a file downloaded from another CC over NFC - # - would need an NFC app in between for the sneakernet step - # get some data - 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 multisig descriptor.') - 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 @@ -881,4 +854,77 @@ class NFCHandler: return winner + async def read_bsms_token(self): + data = await self.start_nfc_rx() + if not data: + await ux_show_story('Unable to find data expected in NDEF') + return + + winner = None + for urn, msg, meta in ndef.record_parser(data): + msg = bytes(msg).decode().strip() # from memory view + try: + int(msg, 16) + winner = msg + break + except: + pass + + if not winner: + await ux_show_story('Unable to find BSMS token in NDEF data') + return + + return winner + + async def read_bsms_data(self): + data = await self.start_nfc_rx() + if not data: + await ux_show_story('Unable to find data expected in NDEF') + return + + winner = None + for urn, msg, meta in ndef.record_parser(data): + msg = bytes(msg).decode().strip() # from memory view + try: + if "BSMS" in msg: + # unencrypted case + winner = msg + break + elif int(msg[:6], 16): + # encrypted hex case + winner = msg + break + else: + continue + except: + pass + + if not winner: + await ux_show_story('Unable to find BSMS data in NDEF data') + return + + 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 449e469b..7ce261d8 100644 --- a/shared/nvstore.py +++ b/shared/nvstore.py @@ -33,6 +33,7 @@ from utils import call_later_ms # _age = internal verison number for data (see below) # 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 # fee_limit = (int) percentage of tx value allowed as max fee # axi = index of last selected address in explorer @@ -75,7 +76,7 @@ from utils import call_later_ms # terms_ok = customer has signed-off on the terms of sale # settings linked to seed -# LINKED_SETTINGS = ["multisig", "tp", "ovc", "xfp", "xpub", "words"] +# LINKED_SETTINGS = ["multisig","miniscript", "tp", "ovc", "xfp", "xpub", "words"] # settings that does not make sense to copy to temporary secret # LINKED_SETTINGS += ["sd2fa", "usr", "axi", "hsmcmd"] # prelogin settings - do not need to be part of other saved settings diff --git a/shared/opcodes.py b/shared/opcodes.py index d015d173..7224795f 100644 --- a/shared/opcodes.py +++ b/shared/opcodes.py @@ -82,7 +82,7 @@ OP_RETURN = const(106) #OP_RSHIFT = const(153) #OP_BOOLAND = const(154) #OP_BOOLOR = const(155) -#OP_NUMEQUAL = const(156) +OP_NUMEQUAL = const(156) #OP_NUMEQUALVERIFY = const(157) #OP_NUMNOTEQUAL = const(158) #OP_LESSTHAN = const(159) @@ -114,6 +114,7 @@ OP_CHECKMULTISIGVERIFY = const(175) #OP_NOP8 = const(183) #OP_NOP9 = const(184) #OP_NOP10 = const(185) +OP_CHECKSIGADD = const(186) #OP_NULLDATA = const(252) #OP_PUBKEYHASH = const(253) #OP_PUBKEY = const(254) diff --git a/shared/ownership.py b/shared/ownership.py index 2eb6c977..319c9e47 100644 --- a/shared/ownership.py +++ b/shared/ownership.py @@ -7,6 +7,7 @@ from glob import settings from ucollections import namedtuple from ubinascii import hexlify as b2a_hex from exceptions import UnknownAddressExplained +from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH, AF_P2TR # Track many addresses, but in compressed form # - map from random Bech32/Base58 payment address to (wallet) + keypath @@ -49,7 +50,7 @@ class AddressCacheFile: def __init__(self, wallet, change_idx): self.wallet = wallet self.change_idx = change_idx - desc = wallet.to_descriptor().serialize() + desc = wallet.to_descriptor().to_string(internal=False) h = b2a_hex(ngu.hash.sha256d(wallet.chain.ctype + desc)) self.fname = h[0:32] + '-%d.own' % change_idx self.salt = h[32:] @@ -158,8 +159,8 @@ class AddressCacheFile: self.setup(self.change_idx, start_idx) - for idx,here,*_ in self.wallet.yield_addresses(start_idx, count, - change_idx=self.change_idx): + # change_idx is used as flag here + for idx,here,*_ in self.wallet.yield_addresses(start_idx, count, self.change_idx): if here == addr: # Found it! But keep going a little for next time. @@ -207,7 +208,7 @@ class OwnershipCache: # - returns wallet object, and tuple2 of final 2 subpath components # - if you start w/ testnet, we'll follow that from multisig import MultisigWallet - from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH + from miniscript import MiniScriptWallet from glob import dis ch = chains.current_chain() @@ -220,21 +221,28 @@ class OwnershipCache: possibles = [] + msc_exists = MiniScriptWallet.exists()[0] + + if addr_fmt == AF_P2TR and msc_exists: + possibles.extend([w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == AF_P2TR]) + if addr_fmt & AFC_SCRIPT: # multisig or script at least.. must exist already possibles.extend(MultisigWallet.iter_wallets(addr_fmt=addr_fmt)) + msc = [w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == addr_fmt] + possibles.extend(msc) if addr_fmt == AF_P2SH: # might look like P2SH but actually be AF_P2WSH_P2SH possibles.extend(MultisigWallet.iter_wallets(addr_fmt=AF_P2WSH_P2SH)) + msc = [w for w in MiniScriptWallet.iter_wallets() if w.addr_fmt == AF_P2WSH_P2SH] + possibles.extend(msc) # Might be single-sig p2wpkh wrapped in p2sh ... but that was a transition # thing that hopefully is going away, so if they have any multisig wallets, # defined, assume that that's the only p2sh address source. addr_fmt = AF_P2WPKH_P2SH - # TODO: add tapscript and such fancy stuff here - try: # Construct possible single-signer wallets, always at least account=0 case from wallet import MasterSingleSigWallet @@ -252,7 +260,7 @@ class OwnershipCache: if not possibles: # can only happen w/ scripts; for single-signer we have things to check raise UnknownAddressExplained( - "No suitable multisig wallets are currently defined.") + "No suitable multisig/miniscript wallets are currently defined.") # "quick" check first, before doing any generations @@ -314,7 +322,8 @@ class OwnershipCache: msg = addr msg += '\n\nFound in wallet:\n ' + wallet.name - msg += '\nDerivation path:\n ' + wallet.render_path(*subpath) + if hasattr(wallet, "render_path"): + msg += '\nDerivation path:\n ' + wallet.render_path(*subpath) if version.has_qwerty: esc = KEY_QR else: @@ -325,8 +334,9 @@ class OwnershipCache: ch = await ux_show_story(msg, title="Verified Address", escape=esc, hint_icons=KEY_QR) if ch != esc: break - await show_qr_code(addr, is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), - msg=addr) + await show_qr_code(addr, + is_alnum=(wallet.addr_fmt & (AFC_BECH32 | AFC_BECH32M)), + msg=addr) except UnknownAddressExplained as exc: await ux_show_story(addr + '\n\n' + str(exc), title="Unknown Address") diff --git a/shared/psbt.py b/shared/psbt.py index 903b4918..a436e133 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -11,6 +11,7 @@ from uhashlib import sha256 from uio import BytesIO from sffile import SizerFile from chains import taptweak, tapleaf_hash +from miniscript import MiniScriptWallet from multisig import MultisigWallet, disassemble_multisig_mn from exceptions import FatalPSBTIssue, FraudulentChangeOutput from serializations import ser_compact_size, deser_compact_size, hash160 @@ -479,7 +480,7 @@ class psbtOutputProxy(psbtProxy): 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 @@ -552,43 +553,66 @@ class psbtOutputProxy(psbtProxy): expect_pkh = None 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 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 multisig 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) # it cannot be change if it doesn't precisely match our multisig setup - if not active_multisig: + if not active_multisig and not active_miniscript: # - 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 - - # 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 @@ -622,6 +646,16 @@ class psbtOutputProxy(psbtProxy): expect_pkh = hash160(expect_pubkey) elif addr_type == "p2tr": if expect_pubkey is None and len(self.taproot_subpaths) > 1: + if active_miniscript: + try: + active_miniscript.validate_script_pubkey( + b"\x51\x20" + pkh, + [v[1:] for v in self.taproot_subpaths.values() if len(v[1:]) > 1] + ) + 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) @@ -873,6 +907,7 @@ class psbtInputProxy(psbtProxy): # - 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: @@ -883,6 +918,7 @@ class psbtInputProxy(psbtProxy): return self.is_multisig = False + self.is_miniscript = False self.is_p2sh = False which_key = None @@ -931,9 +967,13 @@ class psbtInputProxy(psbtProxy): 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 @@ -965,7 +1005,28 @@ class psbtInputProxy(psbtProxy): if output_key == pubkey: which_key = xonly_pubkey else: - which_key = None + # 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 + if path[0] == my_xfp: + assert merkle_root is not None, "Merkle root not defined" + if not lhs: + output_key = taptweak(xonly_pubkey, merkle_root) + if output_key == pubkey: + which_key = xonly_pubkey + # 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: + internal_key = self.get(self.taproot_internal_key) + output_pubkey = taptweak(internal_key, merkle_root) + if not which_key: + which_key = set() + if pubkey == output_pubkey: + which_key.add(xonly_pubkey) elif addr_type == 'p2pk': # input is single public key (less common) @@ -988,7 +1049,6 @@ class psbtInputProxy(psbtProxy): # - 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() @@ -1010,6 +1070,27 @@ class psbtInputProxy(psbtProxy): 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 len(item[1:]) > 1] + 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, @@ -1215,6 +1296,7 @@ class psbtObject(psbtProxy): # 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 = [] # not a warning just more info about tx @@ -1689,7 +1771,7 @@ class psbtObject(psbtProxy): 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) total_out += txo.nValue if output.is_change: self.num_change_outputs += 1 @@ -1824,8 +1906,8 @@ class psbtObject(psbtProxy): iss = "has different hardening pattern" elif path[0:len(path_prefix)] != path_prefix: iss = "goes to diff path prefix" - elif (path[-2] & 0x7fffffff) not in {0, 1}: - iss = "2nd last component not 0 or 1" + # elif (path[-2] & 0x7fffffff) not in {0, 1}: + # iss = "2nd last component not 0 or 1" elif (path[-1] & 0x7fffffff) > idx_max: iss = "last component beyond reasonable gap" else: @@ -2632,8 +2714,8 @@ class psbtObject(psbtProxy): # 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't be 'final' return False if inp.part_sig and len(inp.part_sig) == len(inp.subpaths): signed += 1 diff --git a/shared/serializations.py b/shared/serializations.py index d89859bb..ce2f34b2 100755 --- a/shared/serializations.py +++ b/shared/serializations.py @@ -57,14 +57,20 @@ def ser_compact_size(l): else: return struct.pack("= 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 + if not version.has_qwerty: + # - 16 chars screen width + # - but 2 lost at left (menu arrow, corner arrow) + # - want to show not truncated on right side + return addr[0:6] + '⋯' + addr[-6:] + else: + # tons of space on Q1 + return addr[0:12] + '⋯' + addr[-12:] # EOF diff --git a/shared/ux_q1.py b/shared/ux_q1.py index dab48b45..9006e5ab 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -906,12 +906,13 @@ class QRScannerInteraction: await ux_visualize_bip21(proto, addr, args) return - if what == "multi": + if what in ("multi", "minisc"): from auth import maybe_enroll_xpub from ux import ux_show_story ms_config, = vals try: - maybe_enroll_xpub(config=ms_config) + maybe_enroll_xpub(config=ms_config, + miniscript=False if what == "multi" else None) except Exception as e: await ux_show_story( 'Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) diff --git a/shared/version.py b/shared/version.py index 1a528751..69d1b855 100644 --- a/shared/version.py +++ b/shared/version.py @@ -122,6 +122,9 @@ def probe_system(): # what firmware signing key did we boot with? are we in dev mode? is_devmode = get_is_devmode() + # newer, edge code in effect? + is_edge = (get_mpy_version()[1][-1] == 'X') + # increase size limits for mk4 from public_constants import MAX_TXN_LEN_MK4, MAX_UPLOAD_LEN_MK4 MAX_UPLOAD_LEN = MAX_UPLOAD_LEN_MK4 diff --git a/shared/wallet.py b/shared/wallet.py index e1621549..dd9d9df9 100644 --- a/shared/wallet.py +++ b/shared/wallet.py @@ -3,12 +3,17 @@ # wallet.py - A place you find UTXO, addresses and descriptors. # import chains -from descriptor import Descriptor +from glob import settings from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from stash import SensitiveValues + MAX_BIP32_IDX = (2 ** 31) - 1 +class WalletOutOfSpace(RuntimeError): + pass + + class WalletABC: # How to make this ABC useful without consuming memory/code space?? # - be more of an "interface" than a base class @@ -126,10 +131,128 @@ class MasterSingleSigWallet(WalletABC): def to_descriptor(self): from glob import settings + from descriptor import Descriptor, Key xfp = settings.get('xfp') xpub = settings.get('xpub') - keys = (xfp, self._path, xpub) - return Descriptor([keys], self.addr_fmt) + d = Descriptor(key=Key.from_cc_data(xfp, self._path, xpub)) + d.set_from_addr_fmt(self.addr_fmt) + return d +class BaseStorageWallet(WalletABC): + key_name = None + + def __init__(self, chain_type=None): + self.storage_idx = -1 + self.chain_type = chain_type or 'BTC' + + @property + def chain(self): + return chains.get_chain(self.chain_type) + + @classmethod + def none_setup_yet(cls, other_chain=False): + return '(none setup yet)' + ("*" if other_chain else "") + + @classmethod + def is_correct_chain(cls, o, curr_chain): + if o[1] is None: + # mainnet + ch = "BTC" + else: + ch = o[1] + + if ch == curr_chain.ctype: + return True + return False + + @classmethod + def exists(cls): + # are there any wallets defined? + exists = False + exists_other_chain = False + c = chains.current_key_chain() + for o in settings.get(cls.key_name, []): + if cls.is_correct_chain(o, c): + exists = True + else: + exists_other_chain = True + + return exists, exists_other_chain + + @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, []) + c = chains.current_key_chain() + + for idx, rec in enumerate(lst): + if cls.is_correct_chain(rec, c): + 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 + # EOF diff --git a/stm32/MK4-Makefile b/stm32/MK4-Makefile index 064811c6..d13a2111 100644 --- a/stm32/MK4-Makefile +++ b/stm32/MK4-Makefile @@ -19,7 +19,7 @@ LATEST_RELEASE = $(shell ls -t1 ../releases/*-mk4-*.dfu | head -1) # Our version for this release. # - caution, the bootrom will not accept version < 3.0.0 -VERSION_STRING = 5.3.2 +VERSION_STRING = 5.3.2X # keep near top, because defined default target (all) include shared.mk diff --git a/stm32/Q1-Makefile b/stm32/Q1-Makefile index cfd39e0e..dcf0660a 100644 --- a/stm32/Q1-Makefile +++ b/stm32/Q1-Makefile @@ -16,7 +16,7 @@ BOOTLOADER_DIR = q1-bootloader LATEST_RELEASE = $(shell ls -t1 ../releases/*-q1-*.dfu | head -1) # Our version for this release. -VERSION_STRING = 1.2.2Q +VERSION_STRING = 1.2.2QX # Remove this closer to shipping. #$(warning "Forcing debug build") diff --git a/testing/conftest.py b/testing/conftest.py index 844cbf59..0f8219bd 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -617,6 +617,12 @@ def get_secrets(sim_execfile): return doit +@pytest.fixture +def clear_miniscript(unit_test): + def doit(): + unit_test('devtest/wipe_miniscript.py') + return doit + @pytest.fixture(scope='module') def press_select(dev, has_qwerty): f = functools.partial(_press_select, dev, has_qwerty) @@ -1336,18 +1342,22 @@ def start_sign(dev): return doit @pytest.fixture -def end_sign(dev, need_keypress): +def end_sign(dev, press_select, press_cancel): from ckcc_protocol.protocol import CCUserRefused def doit(accept=True, in_psbt=None, finalize=False, accept_ms_import=False, expect_txn=True): if accept_ms_import: # XXX would be better to do cap_story here, but that would limit test to simulator - need_keypress('y', timeout=None) + press_select(timeout=None) time.sleep(0.050) if accept != None: - need_keypress('y' if accept else 'x', timeout=None) + time.sleep(.1) + if accept: + press_select(timeout=None) + else: + press_cancel(timeout=None) if accept == False: with pytest.raises(CCUserRefused): @@ -1569,6 +1579,9 @@ def nfc_read(request, needs_nfc): def nfc_write(request, needs_nfc, is_q1): # WRITE data into NFC "chip" def doit_usb(ccfile): + from ckcc.constants import MAX_MSG_LEN + if len(ccfile) >= MAX_MSG_LEN: + pytest.xfail("MAX_MSG_LEN") sim_exec = request.getfixturevalue('sim_exec') press_select = request.getfixturevalue('press_select') rv = sim_exec('list(glob.NFC.big_write(%r))' % ccfile) @@ -1772,7 +1785,7 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_ cap_screen_qr): def doit(way, label, is_json, sig_check=True, addr_fmt=AF_CLASSIC, ret_sig_addr=False, tail_check=None, sd_key=None, vdisk_key=None, nfc_key=None, ret_fname=False, - fpattern=None, qr_key=None): + fpattern=None, qr_key=None, skip_query=False): s_label = None if label == "Address summary": @@ -1784,54 +1797,55 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_ "nfc": nfc_key or (KEY_NFC if is_q1 else "3"), "qr": qr_key or (KEY_QR if is_q1 else "4"), } - time.sleep(0.2) - title, story = cap_story() - if way == "sd": - if f"({key_map['sd']}) to save {s_label if s_label else label} file to SD Card" in story: - need_keypress(key_map['sd']) + if not skip_query: + time.sleep(0.2) + title, story = cap_story() + if way == "sd": + if f"({key_map['sd']}) to save {s_label if s_label else label} file to SD Card" in story: + need_keypress(key_map['sd']) - elif way == "nfc": - if f"{key_map['nfc'] if is_q1 else '(3)'} to share via NFC" not in story: - pytest.skip("NFC disabled") - else: - need_keypress(key_map['nfc']) - time.sleep(0.2) - if is_json: - nfc_export = nfc_read_json() + elif way == "nfc": + if f"{key_map['nfc'] if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") else: - nfc_export = nfc_read_text() + need_keypress(key_map['nfc']) + time.sleep(0.2) + if is_json: + nfc_export = nfc_read_json() + else: + nfc_export = nfc_read_text() + time.sleep(0.3) + press_cancel() # exit NFC animation + return nfc_export + elif way == "qr": + if 'file written' in story: + assert not is_q1 + # mk4 only does QR if fits in normal QR, becaise it can't do BBQr + pytest.skip('no BBQr on Mk4') + + need_keypress(key_map["qr"]) time.sleep(0.3) - press_cancel() # exit NFC animation - return nfc_export - elif way == "qr": - if 'file written' in story: - assert not is_q1 - # mk4 only does QR if fits in normal QR, becaise it can't do BBQr - pytest.skip('no BBQr on Mk4') - - need_keypress(key_map["qr"]) - time.sleep(0.3) - try: - file_type, data = readback_bbqr() - if file_type == "J": - return json.loads(data) - elif file_type == "U": - return data.decode('utf-8') if not isinstance(data, str) else data - else: - raise NotImplementedError - except: - raise - res = cap_screen_qr().decode('ascii') try: - return json.loads(res) + file_type, data = readback_bbqr() + if file_type == "J": + return json.loads(data) + elif file_type == "U": + return data.decode('utf-8') if not isinstance(data, str) else data + else: + raise NotImplementedError except: - return res - else: - # virtual disk - if f"({key_map['vdisk']}) to save to Virtual Disk" not in story: - pytest.skip("Vdisk disabled") + raise + res = cap_screen_qr().decode('ascii') + try: + return json.loads(res) + except: + return res else: - need_keypress(key_map['vdisk']) + # virtual disk + if f"({key_map['vdisk']}) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress(key_map['vdisk']) time.sleep(0.2) title, story = cap_story() @@ -1904,7 +1918,7 @@ def tapsigner_encrypted_backup(microsd_path, virtdisk_path): return doit @pytest.fixture -def choose_by_word_length(need_keypress): +def choose_by_word_length(need_keypress, press_select): # for use in seed XOR menu system def doit(num_words): if num_words == 12: @@ -1912,7 +1926,7 @@ def choose_by_word_length(need_keypress): elif num_words == 18: need_keypress("2") else: - need_keypress("y") + press_select() return doit # workaround: need these fixtures to be global so I can call test from a test @@ -2225,6 +2239,7 @@ from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confir from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn from test_multisig import make_ms_address, clear_ms, make_myself_wallet +from test_miniscript import offer_minsc_import from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed from test_seed_xor import restore_seed_xor from test_ux import pass_word_quiz, word_menu_entry 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/clear_seed.py b/testing/devtest/clear_seed.py index 353efef2..baa50631 100644 --- a/testing/devtest/clear_seed.py +++ b/testing/devtest/clear_seed.py @@ -23,6 +23,7 @@ if not pa.is_secret_blank(): pa.login() assert pa.is_secret_blank() + settings.blank() SettingsObject.master_sv_data = {} SettingsObject.master_nvram_key = None 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 cec70117..6a390ac0 100644 --- a/testing/test_address_explorer.py +++ b/testing/test_address_explorer.py @@ -94,14 +94,16 @@ def generate_addresses_file(goto_address_explorer, need_keypress, cap_story, mic assert len(addresses.split("\n")) == expected_qty raise pytest.xfail("PASSED - different export format for NFC") - time.sleep(.5) # always long enough to write the file? - title, body = cap_story() if is_p2tr: # p2tr - no signature file - contents = load_export(way, label="Address summary", is_json=False, sig_check=False) + contents = load_export(way, label="Address summary", is_json=False, + sig_check=False, skip_query=True) sig_addr = None else: + time.sleep(.5) # always long enough to write the file? + title, body = cap_story() contents, sig_addr = load_export_and_verify_signature(body, way, label="Address summary") + addr_dump = io.StringIO(contents) cc = csv.reader(addr_dump) hdr = next(cc) diff --git a/testing/test_bsms.py b/testing/test_bsms.py new file mode 100644 index 00000000..b6aa08e5 --- /dev/null +++ b/testing/test_bsms.py @@ -0,0 +1,1654 @@ +import sys +sys.path.append("../shared") +import pytest, time, pdb, os, random, hashlib, base64 +from constants import simulator_fixed_tprv +from charcodes import KEY_NFC +from bsms import CoordinatorSession, Signer +from bsms.encryption import key_derivation_function, decrypt, encrypt +from bsms.util import bitcoin_msg, str2path +from bsms.bip32 import PrvKeyNode, PubKeyNode +from bsms.ecdsa import ecdsa_verify, ecdsa_recover +from bsms.address import p2wsh_address, p2sh_p2wsh_address +from descriptor import MultisigDescriptor, append_checksum +from msg import sign_message +from bip32 import BIP32Node + + +BSMS_VERSION = "BSMS 1.0" +ALLOWED_PATH_RESTRICTIONS = "/0/*,/1/*" + + +# keys in settings object +BSMS_SETTINGS = "bsms" +BSMS_SIGNER_SETTINGS = "s" +BSMS_COORD_SETTINGS = "c" + + +et_map = { + "1": "STANDARD", + "2": "EXTENDED", + "3": "NO_ENCRYPTION" +} + +af_map = { + "p2wsh": 14, + "p2sh-p2wsh": 26 +} + + +def coordinator_label(M, N, addr_fmt, et, index=None): + fmt_str = "%dof%d_%s_%s" % (M, N, "native" if addr_fmt == "p2wsh" else "nested", et) + if index: + fmt_str = "%d %s" % (index, fmt_str) + return fmt_str + + +def assert_coord_summary(title, story, M, N, addr_fmt, et): + assert title == "SUMMARY" + assert f"{M} of {N}" in story + assert f"Address format:\n{addr_fmt}" in story + assert f"Encryption type:\n{et_map[et].replace('_', ' ')}" in story + tokens = story.split("\n\n")[3:-1] + if et == "1": + assert len(tokens) == 1 + elif et == "2": + assert len(tokens) == N + else: + assert len(tokens) == 0 + return tokens + +@pytest.fixture +def make_coordinator_round1(settings_remove, settings_get, settings_set, microsd_path, virtdisk_path): + def doit(M, N, addr_fmt, et, way, purge_bsms=True, tokens_only=False): + if purge_bsms: + settings_remove(BSMS_SETTINGS) # clear bsms + bsms = settings_get(BSMS_SETTINGS) or {} + tokens = [] + if et == "1": + tokens = [os.urandom(8).hex()] + elif et == "2": + tokens = [os.urandom(16).hex() for _ in range(N)] + coord_tuple = (M, N, af_map[addr_fmt], et, tokens) + if BSMS_COORD_SETTINGS in bsms: + bsms[BSMS_COORD_SETTINGS].append(coord_tuple) + else: + bsms[BSMS_COORD_SETTINGS] = [coord_tuple] + settings_set(BSMS_SETTINGS, bsms) + if tokens_only: + return tokens + if way == "sd": + path_fn = microsd_path + elif way == "vdisk": + path_fn = virtdisk_path + else: + return tokens + for token_hex in tokens: + basename = "bsms_%s.token" % token_hex[:4] + with open(path_fn(basename), "w") as f: + f.write(token_hex) + return tokens + return doit + + +def bsms_sr1_fname(token, is_extended, suffix, index=None): + fname = "bsms_sr1" + if is_extended: + fname += "_" + token[:4] + else: + if index: # ignores index = 0 + fname += "-" + str(index) + return fname + suffix + + +@pytest.fixture +def make_signer_round1(settings_get, settings_set, settings_remove, microsd_path, virtdisk_path): + def doit(token, way, root_xprv=None, bsms_version=BSMS_VERSION, description=None, purge_bsms=True, + add_to_settings=False, data_only=False, index=None, wrong_sig=False, wrong_encryption=False, slip=False): + is_extended = len(token) == 32 + if purge_bsms: + settings_remove(BSMS_SETTINGS) # clear bsms + if add_to_settings: + bsms = settings_get(BSMS_SETTINGS) or {} + if BSMS_SIGNER_SETTINGS in bsms: + bsms[BSMS_COORD_SETTINGS].append(token) + else: + bsms[BSMS_SIGNER_SETTINGS] = [token] + + if root_xprv: + wk = BIP32Node.from_wallet_key(root_xprv) + else: + wk = BIP32Node.from_master_secret(os.urandom(32), netcode="XTN") + root_xfp = wk.fingerprint().hex() + paths = ["48'/1'/0'/2'", "48'/1'/0'/1'", "0'/1'/0'/0'", "0'", "100'/0'"] + path = random.choice(paths) + sk = wk.subkey_for_path(path) + xpub = sk.hwif(as_private=False) + if slip: + xpub = xpub.replace("tpub", random.choice(["upub", "vpub", "Upub", "Vpub"])) + key_expr = "[%s/%s]%s" % (root_xfp, path, xpub) + data = "%s\n" % bsms_version + data += "%s\n" % token + data += "%s\n" % key_expr + if description is None: + description = "Coldcard Signer %s" % root_xfp + data += "%s" % description + sig = sign_message(bytes(sk.node.private_key), + data.encode()+b"ff" if wrong_sig else data.encode(), + b64=True) + data += "\n%s" % sig + suffix = ".txt" + mode = "wt" + if token != "00": + suffix = ".dat" + mode = "wb" + dkey = key_derivation_function(token) + if wrong_encryption: + wrong = "ffff" + token[4:] + dkey = key_derivation_function(wrong) + data = encrypt(dkey, token, data) + data = bytes.fromhex(data) + if data_only: + return data + if way != "nfc": + if way == "sd": + path_fn = microsd_path + else: + # vdisk + path_fn = virtdisk_path + basename = bsms_sr1_fname(token, is_extended, suffix, index) + with open(path_fn(basename), mode) as f: + f.write(data) + return data + + return doit + + +def ms_address_from_descriptor_bsms(desc_obj: MultisigDescriptor, subpath="0/0", network="XTN"): + testnet = True if network == "XTN" else False + nodes = [ + PubKeyNode.parse(ek).derive_path(str2path(subpath)) + for _, _, ek in desc_obj.keys + ] + secs = [node.sec() for node in nodes] + secs.sort() + if desc_obj.addr_fmt == af_map["p2wsh"]: + address = p2wsh_address(secs, desc_obj.M, sortedmulti=True, testnet=testnet) + else: + address = p2sh_p2wsh_address(secs, desc_obj.M, sortedmulti=True, testnet=testnet) + return address + + +def bsms_cr2_fname(token, is_extended, suffix): + fname = "bsms_cr2" + if is_extended: + fname += "_" + token[:4] + return fname + suffix + + +@pytest.fixture +def make_coordinator_round2(make_coordinator_round1, settings_get, settings_set, microsd_path, virtdisk_path): + def doit(M, N, addr_fmt, et, way, has_ours=True, ours_no=1, path_restrictions=ALLOWED_PATH_RESTRICTIONS, + bsms_version=BSMS_VERSION, sortedmulti=True, wrong_address=False, wrong_encryption=False, + wrong_chain=False, add_checksum=False, wrong_checksum=False): + tokens = make_coordinator_round1(M, N, addr_fmt, et, way=way, purge_bsms=True, tokens_only=True) + range_num = N if has_ours is False else N - ours_no + keys = [] + for _ in range(range_num): + wk = BIP32Node.from_master_secret(os.urandom(32), netcode="BTC" if wrong_chain else "XTN") + root_xfp = wk.fingerprint().hex() + paths = ["48'/1'/0'/2'", "48'/1'/0'/1'", "0'/1'/0'/0'", "0'", "100'/0'"] + path = random.choice(paths) + sk = wk.subkey_for_path(path) + xpub = sk.hwif(as_private=False) + keys.append((root_xfp, "m/" + path, xpub)) + if has_ours: + for _ in range(ours_no): + wk = BIP32Node.from_wallet_key(simulator_fixed_tprv) + root_xfp = wk.fingerprint().hex() + paths = ["48'/1'/0'/2'", "48'/1'/0'/1'", "0'/1'/0'/0'", "0'", "100'/0'"] + path = random.choice(paths) + sk = wk.subkey_for_path(path) + xpub = sk.hwif(as_private=False) + keys.append((root_xfp, "m/" + path, xpub)) + + desc_obj = MultisigDescriptor(M=M, N=N, addr_fmt=af_map[addr_fmt], keys=keys) + desc = desc_obj._serialize(int_ext=True) + wcs = append_checksum(desc).split("#")[-1] + desc = desc.replace("/<0;1>/*", "/**") + if add_checksum: + desc = append_checksum(desc) + elif wrong_checksum: + desc = desc + "#" + wcs + if not sortedmulti: + desc = desc.replace("sortedmulti", "multi") + desc_template = "%s\n" % bsms_version + desc_template += "%s\n" % desc + desc_template += "%s\n" % path_restrictions + if wrong_address: + addr = ms_address_from_descriptor_bsms(desc_obj, subpath="1000/100") + else: + addr = ms_address_from_descriptor_bsms(desc_obj) + desc_template += "%s" % addr + + # create signer artificialy and produce correct descriptor template file + bsms = settings_get(BSMS_SETTINGS) or {} + bsms[BSMS_SIGNER_SETTINGS] = [] # purge + if not tokens: + token = "00" + bsms[BSMS_SIGNER_SETTINGS].append(token) + res = desc_template + else: + token = tokens[0] + # same for STANDARD and EXTENDED --> encrypt + bsms[BSMS_SIGNER_SETTINGS].append(token) + if wrong_encryption: + res = encrypt(key_derivation_function(os.urandom(16).hex()), token, desc_template) + else: + res = encrypt(key_derivation_function(token), token, desc_template) + res = bytes.fromhex(res) + + settings_set(BSMS_SETTINGS, bsms) + if way != "nfc": + if way == "sd": + path_fn = microsd_path + else: + # vdisk + path_fn = virtdisk_path + mode = "wb" if et in ["1", "2"] else "wt" + suffix = ".dat" if et in ["1", "2"] else ".txt" + basename = bsms_cr2_fname(token, et == "2", suffix) + with open(path_fn(basename), mode) as f: + f.write(res) + + return res, token + + return doit + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, + cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, + settings_get, virtdisk_wipe, microsd_wipe, press_select, is_q1): + M, N = M_N + virtdisk_wipe() + microsd_wipe() + settings_remove(BSMS_SETTINGS) # clear bsms + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 1 # nothing should be in menu at this point but round 1 + pick_menu_item('Create BSMS') + # choose number of signers N + for num in str(N): + need_keypress(num) + press_select() + # choose threshold M + for num in str(M): + need_keypress(num) + press_select() + if addr_fmt == "p2wsh": + press_select() + else: + need_keypress("2") + time.sleep(0.1) + title, story = cap_story() + assert story == "Choose encryption type. Press (1) for STANDARD encryption, (2) for EXTENDED, and (3) for no encryption" + need_keypress(encryption_type) + time.sleep(0.1) + title, story = cap_story() + tokens = assert_coord_summary(title, story, M, N, addr_fmt, encryption_type) + press_select() # confirm summary + time.sleep(0.1) + title, story = cap_story() + assert "Press (1) to participate as co-signer in this BSMS" in story + press_select() # continue normally + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "3": + assert story == "Success. Coordinator round 1 saved." + else: + if way == "sd": + if "Press (1) to save BSMS token file(s) to SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + + if f"press {KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + time.sleep(0.2) + bsms_tokens = nfc_read_text() + time.sleep(0.2) + press_select() # exit NFC UI simulation + time.sleep(0.5) + else: + # virtual disk + if "press (2) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + + read_tokens = [] + if way == "nfc" and encryption_type != "3": + read_tokens = bsms_tokens.split("\n\n") + else: + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS token file(s) written' in story + fnames = story.split('\n\n')[2:] + # check token files contains first 4 chars of token + try: + token_start = set([tok.split(" ")[1][:4] for tok in tokens]) + except IndexError: + # only one token - special case without numbering + assert len(tokens) == 1 + token_start = set([tokens[0].split("\n")[1][:4]]) + token_fnames_start = set([fn.replace(".token", "").split("_")[-1].split("-")[0] for fn in fnames]) + assert token_start == token_fnames_start + read_tokens = [] + for fname in fnames: + if way == "vdisk": + path = virtdisk_path(fname) + else: + path = microsd_path(fname) + with open(path, 'rt') as f: + token = f.read().strip() + read_tokens.append(token) + + if encryption_type == "1": + assert len(read_tokens) == 1 + elif encryption_type == "2": + assert len(read_tokens) == N + else: + assert len(tokens) == 0 + + press_select() # confirm success or files written story + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 2 + current_coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert menu[0] == current_coord_menu_item + assert menu[1] == "Create BSMS" + # check correct summary in detail + pick_menu_item(menu[0]) + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 3 + assert menu[0] == "Round 2" + assert menu[1] == "Detail" + assert menu[2] == "Delete" + pick_menu_item("Detail") + time.sleep(0.1) + title, story = cap_story() + assert_coord_summary(title, story, M, N, addr_fmt, encryption_type) + press_select() + # check correct coord tuple saved + bsms_settings = settings_get(BSMS_SETTINGS) + if BSMS_SIGNER_SETTINGS in bsms_settings: + assert bsms_settings[BSMS_SIGNER_SETTINGS] == [] + coord_settings = bsms_settings[BSMS_COORD_SETTINGS] + assert len(coord_settings) == 1 + assert coord_settings[0] == ( + M, N, af_map[addr_fmt], encryption_type, + [tok.split(" ")[-1].replace("Tokens:\n", "") for tok in tokens] if tokens else [] + ) + # delete coordinator settings + pick_menu_item("Delete") + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 1 + assert menu[0] == "Create BSMS" + bsms_settings = settings_get(BSMS_SETTINGS) + coord_settings = bsms_settings[BSMS_COORD_SETTINGS] + assert coord_settings == [] + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_signer_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, cap_menu, + cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, settings_get, + make_coordinator_round1, nfc_write_text, virtdisk_wipe, microsd_wipe, press_select, + is_q1): + M, N = M_N + virtdisk_wipe() + microsd_wipe() + tokens = make_coordinator_round1(M, N, addr_fmt, encryption_type, way) + if encryption_type != "3": + assert tokens + else: + assert tokens == [] + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu = cap_menu() + assert len(menu) == 1 # nothing should be in menu at this point but round 1 + pick_menu_item('Round 1') + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "3": + token = "00" + need_keypress("3") # no token (unencrypted BSMS) + else: + token = random.choice(tokens) + if way == "sd": + if "Press (1) to import token file from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + if f"{KEY_NFC if is_q1 else '(4)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "4") + time.sleep(0.1) + nfc_write_text(token) + time.sleep(0.4) + else: + # virtual disk + if "(6) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("6") + + if way != "nfc": + time.sleep(0.2) + fname = "bsms_%s.token" % token[:4] + pick_menu_item(fname) + + time.sleep(0.1) + title, story = cap_story() + assert "You have entered token:\n%s" % token in story + press_select() + time.sleep(0.1) + _, story = cap_story() + # address format a.k.a. SLIP derivation path - ignore and use SLIP agnostic + assert "Choose co-signer address format for correct SLIP derivation path" in story + press_select() # default + # account number prompt + press_select() + time.sleep(0.1) + _, story = cap_story() + # textual key description + assert "Choose key description" in story + press_select() # default + time.sleep(0.1) + title, story = cap_story() + suffix = ".txt" if encryption_type == "3" else ".dat" + mode = "rt" if encryption_type == "3" else "rb" + if way == "sd": + if "Press (1) to save BSMS signer round 1 file to SD Card" in story: + need_keypress("1") + elif way == "nfc": + if f"press {KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + time.sleep(0.2) + signer_r1 = nfc_read_text() + time.sleep(0.2) + press_select() # exit NFC UI simulation + time.sleep(0.5) + else: + # virtual disk + if "press (2) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + + if way != "nfc": + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS signer round 1 file written' in story + fname = story.split('\n\n')[-1] + assert suffix in fname + if encryption_type == "2": + # check token files contains first 4 chars of token or just 00 + assert token[:4] == fname.split(".")[0][-4:] + if way == "vdisk": + path = virtdisk_path(fname) + else: + path = microsd_path(fname) + with open(path, mode) as f: + signer_r1 = f.read() + + bsms = settings_get(BSMS_SETTINGS) + assert len(bsms[BSMS_SIGNER_SETTINGS]) == 1 + assert bsms[BSMS_SIGNER_SETTINGS][0] == token + + if encryption_type in ["1", "2"]: + # decrypt + if isinstance(signer_r1, bytes): + signer_r1 = signer_r1.hex() + signer_r1 = decrypt(key_derivation_function(token), signer_r1) + + version, tok, key_exp, description, sig = signer_r1.strip().split("\n") + assert version == BSMS_VERSION + assert tok == token + close_index = key_exp.find("]") + assert key_exp[0] == "[" and close_index != -1 + key_orig_info = key_exp[1:close_index] # remove brackets + xpub = key_exp[close_index + 1:] + assert xpub[:4] in ["xpub", "tpub"] + xfp, path = key_orig_info.split("/", 1) + # pycoin xpub check + mk = BIP32Node.from_wallet_key(simulator_fixed_tprv) + sk = mk.subkey_for_path(path) + pycoin_xpub = sk.hwif(as_private=False) + assert xpub == pycoin_xpub + # bsms lib xpub check + mk0 = PrvKeyNode.parse(simulator_fixed_tprv, testnet=True) + sk0 = mk0.derive_path(str2path(path)) + bsms_xpub = sk0.extended_public_key() + assert xpub == bsms_xpub + signed_data = "\n".join([version, tok, key_exp, description]) + # verify msg bsms lib (pure python ecdsa) + signed_digest = bitcoin_msg(signed_data) + decoded_sig = base64.b64decode(sig) + recovered_sec = ecdsa_recover(signed_digest, decoded_sig) + assert ecdsa_verify(signed_digest, decoded_sig, recovered_sec), "Signature invalid" + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +@pytest.mark.parametrize("auto_collect", [True, False]) +def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, clear_ms, goto_home, need_keypress, + cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, + settings_get, make_coordinator_round1, make_signer_round1, nfc_write_text, + virtdisk_wipe, microsd_wipe, pick_menu_item, press_select, is_q1): + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == N and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + M, N = M_N + virtdisk_wipe() + microsd_wipe() + tokens = make_coordinator_round1(M, N, addr_fmt, encryption_type, way=way, tokens_only=True) + all_data = [] + for i in range(N): + token = get_token(i) + index = None + if encryption_type != "2": + index = i + 1 + + all_data.append(make_signer_round1(token, way, purge_bsms=False, index=index)) + + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if way == "sd": + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "vdisk": + if "(2) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + else: + # NFC + if f"{KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + + if way == "nfc": + if auto_collect is True: + pytest.skip("No auto-collection for NFC") + for i, data in enumerate(all_data): + time.sleep(0.1) + title, story = cap_story() + token = get_token(i) + if encryption_type == "2": + expect = "Share co-signer #%d round-1 data for token starting with %s" % (i + 1, token[:4]) + else: + expect = "Share co-signer #%d round-1 data" % (i + 1) + assert expect in story + press_select() + time.sleep(.2) + nfc_write_text(data.hex() if isinstance(data, bytes) else data) + time.sleep(0.3) + else: + suffix = ".txt" if encryption_type == "3" else ".dat" + time.sleep(0.1) + title, story = cap_story() + assert "Press OK to pick co-signer round 1 files manually, or press (1) to attempt auto-collection." in story + assert "For auto-collection to succeed all filenames have to start with 'bsms_sr1'" in story + suffix_target = "and end with extension '%s'" % suffix + assert suffix_target in story + if encryption_type == "2": + assert "In addition for EXTENDED encryption all files must contain first four characters of respective token." in story + elif encryption_type == "3": + assert ("In addition for NO ENCRYPTION cases, number of files with above mentioned" + " pattern and suffix must equal number of signers (N).") in story + assert "If above is not respected auto-collection fails and defaults to manual selection of files." in story + if auto_collect: + need_keypress("1") + else: + press_select() # continue with manual selection + for i, _ in enumerate(all_data, start=1): + token = get_token(i - 1) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % i + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + menu_item = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + pick_menu_item(menu_item) + + time.sleep(0.1) + _, story = cap_story() + if way == "sd": + if "Press (1) to save BSMS descriptor template file(s) to SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + if f"{KEY_NFC if is_q1 else '(3)'} to share via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + else: + # virtual disk + if "(2) to save to Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + + descriptor_templates = [] + if way == "nfc": + # not implemented because of the fake nfc limit + # pytest skip will be raised before we can get here + if encryption_type == "2": + for i, token in enumerate(tokens, start=1): + time.sleep(.1) + title, story = cap_story() + expect = "Exporting data for co-signer #%d with token %s" % (i, token[:4]) + assert expect in story + press_select() + time.sleep(.5) + rv = nfc_read_text() + time.sleep(.5) + descriptor_templates.append(rv) + press_select() # exit animation + + time.sleep(.1) + title, story = cap_story() + assert "All done" in story + press_select() + else: + time.sleep(.5) + rv = nfc_read_text() + time.sleep(.5) + descriptor_templates.append(rv) + press_select() # exit animation + else: + if way == "sd": + path_fn = microsd_path + else: + path_fn = virtdisk_path + time.sleep(0.1) + _, story = cap_story() + assert "BSMS descriptor template file(s) written." in story + fnames = story.split("\n\n")[1:] + if encryption_type == "2": + for fname, token in zip(fnames, tokens): + assert token[:4] in fname + + for fname in fnames: + with open(path_fn(fname), "rt" if encryption_type == "3" else "rb") as f: + desc_temp = f.read() + descriptor_templates.append(desc_temp) + + assert descriptor_templates + if encryption_type == "2": + # each file encrypted with different token/key + templates = set() + for token, desc_template in zip(tokens, descriptor_templates): + plaintext = decrypt( + key_derivation_function(token), + desc_template if isinstance(desc_template, str) else desc_template.hex() + ) + assert plaintext + templates.add(plaintext) + assert len(templates) == 1 + # pick last to be the template + the_template = plaintext + elif encryption_type == "1": + # just one template but encrypted + assert len(descriptor_templates) == 1 + plaintext = decrypt( + key_derivation_function(get_token(0)), + descriptor_templates[0] if isinstance(descriptor_templates[0], str) else descriptor_templates[0].hex() + ) + assert plaintext + the_template = plaintext + else: + assert len(descriptor_templates) == 1 + the_template = descriptor_templates[0] + + version, descriptor, pth_restrictions, addr = the_template.split("\n") + assert version == BSMS_VERSION + try: + MultisigDescriptor.checksum_check(descriptor) + descriptor = descriptor.split("#")[0] + except ValueError: + pass + # replace /** so we can parse it + descriptor = descriptor.replace("/**", "/0/*") + descriptor = append_checksum(descriptor) + desc_obj = MultisigDescriptor.parse(descriptor) + assert len(desc_obj.keys) == N + assert pth_restrictions == ALLOWED_PATH_RESTRICTIONS + # bsms lib test ms address + address = ms_address_from_descriptor_bsms(desc_obj) + assert addr == address + + +@pytest.mark.parametrize("refuse", [True, False]) +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("with_checksum", [True, False]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_signer_round2(refuse, way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, + cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, settings_get, + make_coordinator_round2, nfc_write_text, virtdisk_wipe, microsd_wipe, with_checksum, + press_select, press_cancel, is_q1): + M, N = M_N + clear_ms() + virtdisk_wipe() + microsd_wipe() + desc_template, token = make_coordinator_round2(M, N, addr_fmt, encryption_type, way=way, add_checksum=with_checksum) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu = cap_menu() + assert len(menu) == 2 + assert "Round 1" in menu + menu_item = "1 %s" % token[:4] + assert menu_item in menu + pick_menu_item(menu_item) + menu = cap_menu() + assert len(menu) == 3 + assert "Detail" in menu + assert "Delete" in menu + assert "Round 2" in menu + pick_menu_item("Detail") + time.sleep(0.1) + _, story = cap_story() + assert token in story + assert str(int(token, 16)) in story + press_select() + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if way == "sd": + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "vdisk": + if "(2) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("2") + else: + # NFC + if f"{KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "3") + + if way == "nfc": + time.sleep(0.1) + nfc_write_text(desc_template.hex() if isinstance(desc_template, bytes) else desc_template) + time.sleep(0.3) + else: + suffix = ".txt" if encryption_type == "3" else ".dat" + time.sleep(0.1) + menu_item = bsms_cr2_fname(token, encryption_type == "2", suffix) + pick_menu_item(menu_item) + + time.sleep(0.5) + _, story = cap_story() + assert "Create new multisig wallet?" in story + assert "bsms" in story # part of the name + policy = "Policy: %d of %d" % (M, N) + assert policy in story + assert addr_fmt.upper() in story + ms_wal_name = story.split("\n\n")[1].split("\n")[-1].strip() + ms_wal_menu_item = "%d/%d: %s" % (M, N, ms_wal_name) + if refuse: + press_cancel() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item not in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 NOT removed + assert bsms_settings.get(BSMS_SIGNER_SETTINGS) + else: + press_select() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 removed + assert not bsms_settings.get(BSMS_SIGNER_SETTINGS, None) + + +@pytest.mark.parametrize("token", [ + "f" * 15, + "f" * 17, + "0" * 31, + "0" * 33, +]) +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk", "manual"]) +def test_invalid_token_signer_round1(token, way, pick_menu_item, cap_story, need_keypress, + nfc_write_text, microsd_path, virtdisk_path, goto_home, + press_select, is_q1): + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + pick_menu_item('Round 1') + time.sleep(0.1) + title, story = cap_story() + if way == "manual": + need_keypress("2") # manual + need_keypress("2") # decimal + for num in str(int(token, 16)): + need_keypress(num) + press_select() + else: + if way != "nfc": + token_fname = "error.token" + path_func = virtdisk_path if way == "vdisk" else microsd_path + with open(path_func(token_fname), "w") as f: + f.write(token) + if way == "sd": + if "Press (1) to import token file from SD Card" in story: + need_keypress("1") + # else no prompt if both NFC and vdisk disabled + elif way == "nfc": + if f"{KEY_NFC if is_q1 else '(4)'} to import via NFC" not in story: + pytest.skip("NFC disabled") + else: + need_keypress(KEY_NFC if is_q1 else "4") + time.sleep(0.1) + nfc_write_text(token) + time.sleep(0.4) + else: + # virtual disk + if "(6) to import from Virtual Disk" not in story: + pytest.skip("Vdisk disabled") + else: + need_keypress("6") + + if way != "nfc": + time.sleep(0.2) + pick_menu_item(token_fname) + + time.sleep(0.1) + title, story = cap_story() + assert title == "FAILURE" + assert "BSMS signer round1 failed" in story + assert "Invalid token length. Expected 64 or 128 bits (16 or 32 hex characters)" in story + + +@pytest.mark.parametrize("failure", ["slip", "wrong_sig", "bsms_version"]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +def test_failure_coordinator_round2(encryption_type, make_coordinator_round1, make_signer_round1, microsd_wipe, cap_menu, + virtdisk_wipe, pick_menu_item, press_select, goto_home, cap_story, failure, + need_keypress): + virtdisk_wipe() + microsd_wipe() + + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == 2 and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + if failure == "bsms_version": + kws = {failure: "BSMS 1.1"} + else: + kws = {failure: True} + tokens = make_coordinator_round1(2, 2, "p2wsh", encryption_type, way="sd", tokens_only=True) + for i in range(2): + token = get_token(i) + index = None + if encryption_type != "2": + index = i + 1 + make_signer_round1(token, "sd", purge_bsms=False, index=index, **kws) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(2, 2, "p2wsh", encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + press_select() # continue with manual file selection + suffix = ".txt" if encryption_type == "3" else ".dat" + for i, _ in enumerate(range(2), start=1): + token = get_token(i - 1) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % i + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + menu_item = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + pick_menu_item(menu_item) + time.sleep(0.1) + title, story = cap_story() + assert title == "FAILURE" + assert "BSMS coordinator round2 failed" in story + if failure == "slip": + failure_msg = "no slip" + elif failure == "wrong_sig": + failure_msg = "Recovered key from signature does not equal key provided. Wrong signature?" + else: + failure_msg = "Incompatible BSMS version. Need BSMS 1.0 got BSMS 1.1" + assert failure_msg in story + + +# TODO do this for NFC too when length requirements are lifted from 250 +@pytest.mark.parametrize("encryption_type", ["1", "2"]) +def test_wrong_encryption_coordinator_round2(encryption_type, make_coordinator_round1, make_signer_round1, microsd_wipe, + cap_menu, virtdisk_wipe, pick_menu_item, need_keypress, goto_home, cap_story, + press_cancel, press_select): + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == 2 and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + virtdisk_wipe() + microsd_wipe() + tokens = make_coordinator_round1(2, 2, "p2wsh", encryption_type, way="sd", tokens_only=True) + for i in range(2): + token = get_token(i) + index = None + if encryption_type == "1": + index = i + 1 + make_signer_round1(token, "sd", purge_bsms=False, index=index, wrong_encryption=True) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(2, 2, "p2wsh", encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + press_select() # continue with manual file selection + suffix = ".txt" if encryption_type == "3" else ".dat" + for i, _ in enumerate(range(2), start=1): + for attempt in range(2): + token = get_token(i - 1) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % i + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + menu_item = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + pick_menu_item(menu_item) + time.sleep(0.1) + _, story = cap_story() + expect_story = "Decryption failed for co-signer #%d" % i + if encryption_type == 2: + expect_story += " with token %s" % token[:4] + assert expect_story in story + if attempt == 0: + assert "Try again?" in story + press_select() + else: + assert "Try again?" not in story + press_cancel() + break + break + + +@pytest.mark.parametrize("failure", [ + "wrong_address", "path_restrictions", "bsms_version", "sortedmulti", "has_ours", "ours_no", + "wrong_encryption", "wrong_chain", "wrong_checksum" +]) +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +def test_failure_signer_round2(encryption_type, goto_home, press_select, pick_menu_item, cap_menu, cap_story, + microsd_path, settings_remove, nfc_read_text, virtdisk_path, settings_get, microsd_wipe, + make_coordinator_round2, virtdisk_wipe, failure, need_keypress): + virtdisk_wipe() + microsd_wipe() + if failure == "wrong_address": + kws = {failure: True} + failure_msg = "Address mismatch!" + elif failure == "path_restrictions": + kws = {failure: "5/*,4/*"} + failure_msg = "Only '/0/*,/1/*' allowed as path restrictions." + elif failure == "bsms_version": + kws = {failure: "BSMS 2.0"} + 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(. MUST be sortedmulti." + elif failure == "has_ours": + kws = {failure: False} + failure_msg = "My key 0F056943 missing in descriptor." + elif failure == "ours_no": + kws = {failure: 2} + failure_msg = "Multiple 0F056943 keys in descriptor (2)" + elif failure == "wrong_chain": + kws = {failure: True} + failure_msg = "wrong chain" + elif failure == "wrong_checksum": + kws = {failure: True} + failure_msg = "Wrong checksum" + else: + assert failure == "wrong_encryption" + if encryption_type == "3": + pytest.skip("Cannot test wrong encryption on unencrypted BSMS") + kws = {failure: True} + failure_msg = "Decryption with token {token} failed." + + desc_template, token = make_coordinator_round2(2, 2, "p2wsh", encryption_type, way="sd", **kws) + failure_msg = failure_msg.format(token=token[:4]) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu_item = "1 %s" % token[:4] + pick_menu_item(menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + + suffix = ".txt" if encryption_type == "3" else ".dat" + time.sleep(0.1) + menu_item = bsms_cr2_fname(token, encryption_type == "2", suffix) + pick_menu_item(menu_item) + time.sleep(0.1) + title, story = cap_story() + assert title == "FAILURE" + assert "BSMS signer round2 failed" in story + assert failure_msg in story + + +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +def test_integration_signer(encryption_type, M_N, addr_fmt, clear_ms, microsd_wipe, goto_home, pick_menu_item, cap_story, + press_select, settings_remove, microsd_path, settings_get, cap_menu, use_mainnet, + need_keypress): + # test CC signer full with bsms lib coordinator (test just SD card no need to retest IO paths again - tested above) + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == N and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + M, N = M_N + settings_remove(BSMS_SETTINGS) + use_mainnet() + clear_ms() + microsd_wipe() + coordinator = CoordinatorSession(M, N, addr_fmt, et_map[encryption_type]) + session_data = coordinator.generate_token_key_pairs() + tokens = [x[0] for x in session_data] + cc_token = get_token(0) + other_signers = [] + for i in range(1, N): + other_signers.append(Signer(token=get_token(i), key_description="Other signer %d" % i)) + # ROUND 1 + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + pick_menu_item('Round 1') + time.sleep(0.1) + _, story = cap_story() + if encryption_type == "3": + need_keypress("3") # no token (unencrypted BSMS) + else: + fname = "bsms_%s.token" % cc_token[:4] if cc_token != "00" else "1" + with open(microsd_path(fname), "w") as f: + f.write(cc_token) + if "Press (1) to import token file from SD Card" in story: + need_keypress("1") + time.sleep(0.2) + fname = "bsms_%s.token" % cc_token[:4] + pick_menu_item(fname) + + time.sleep(0.1) + title, story = cap_story() + assert "You have entered token:\n%s" % cc_token in story + press_select() + time.sleep(0.1) + _, story = cap_story() + # address format a.k.a. SLIP derivation path - ignore and use SLIP agnostic + assert "Choose co-signer address format for correct SLIP derivation path" in story + press_select() + # account number prompt + press_select() + time.sleep(0.1) + _, story = cap_story() + # textual key description + assert "Choose key description" in story + press_select() # default + time.sleep(0.1) + title, story = cap_story() + suffix = ".txt" if encryption_type == "3" else ".dat" + mode = "rt" if encryption_type == "3" else "rb" + if "Press (1) to save BSMS signer round 1 file to SD Card" in story: + need_keypress("1") + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS signer round 1 file written' in story + fname = story.split('\n\n')[-1] + assert suffix in fname + path = microsd_path(fname) + with open(path, mode) as f: + signer_r1 = f.read() + + bsms = settings_get(BSMS_SETTINGS) + assert len(bsms[BSMS_SIGNER_SETTINGS]) == 1 + assert bsms[BSMS_SIGNER_SETTINGS][0] == cc_token + + # ROUND 2 + all_r1_data = [signer_r1.hex() if encryption_type != "3" else signer_r1] + for s in other_signers: + all_r1_data.append(s.round_1()) + + descriptor_templates = coordinator.round_2(all_r1_data) + if encryption_type == "2": + assert len(descriptor_templates) == N + for signer, tmplt in zip(other_signers, descriptor_templates[1:]): + signer.round_2(tmplt) + else: + assert len(descriptor_templates) == 1 + for signer in other_signers: + signer.round_2(descriptor_templates[0]) + + cc_desc_template = descriptor_templates[0] # zeroeth as our token is zero too + suffix = ".txt" if encryption_type == "3" else ".dat" + mode = "wt" if encryption_type == "3" else "wb" + fname = bsms_cr2_fname(cc_token, encryption_type == "2", suffix) + with open(microsd_path(fname), mode) as f: + f.write(bytes.fromhex(cc_desc_template) if mode == "wb" else cc_desc_template) + time.sleep(0.1) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Signer') + menu_item = "1 %s" % cc_token[:4] + pick_menu_item(menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + time.sleep(0.1) + menu_item = bsms_cr2_fname(cc_token, encryption_type == "2", suffix) + pick_menu_item(menu_item) + time.sleep(0.1) + title, story = cap_story() + assert "Create new multisig wallet?" in story + assert "bsms" in story # part of the name + policy = "Policy: %d of %d" % (M, N) + assert policy in story + assert addr_fmt.upper() in story + ms_wal_name = story.split("\n\n")[1].split("\n")[-1].strip() + ms_wal_menu_item = "%d/%d: %s" % (M, N, ms_wal_name) + press_select() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 removed + assert not bsms_settings.get(BSMS_SIGNER_SETTINGS, None) + + +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) +@pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) +@pytest.mark.parametrize("cr1_shortcut", [True, False]) +def test_integration_coordinator(encryption_type, M_N, addr_fmt, clear_ms, microsd_wipe, goto_home, pick_menu_item, + cap_story, need_keypress, settings_remove, microsd_path, settings_get, cap_menu, + use_mainnet, cr1_shortcut, press_select): + M, N = M_N + settings_remove(BSMS_SETTINGS) + use_mainnet() + clear_ms() + microsd_wipe() + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 1 # nothing should be in menu at this point but round 1 + pick_menu_item('Create BSMS') + # choose number of signers N + for num in str(N): + need_keypress(num) + press_select() + # choose threshold M + for num in str(M): + need_keypress(num) + press_select() + if addr_fmt == "p2wsh": + press_select() + else: + need_keypress("2") + time.sleep(0.1) + title, story = cap_story() + assert story == "Choose encryption type. Press (1) for STANDARD encryption, (2) for EXTENDED, and (3) for no encryption" + need_keypress(encryption_type) + time.sleep(0.1) + title, story = cap_story() + assert_coord_summary(title, story, M, N, addr_fmt, encryption_type) + press_select() # confirm summary + time.sleep(0.1) + title, story = cap_story() + assert "Press (1) to participate as co-signer in this BSMS" in story + if cr1_shortcut: + _start_idx = 1 + need_keypress("1") + press_select() # slip + press_select() # acct num 0 + press_select() # default textual key description + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to save BSMS signer round 1 file to SD Card" in story: + need_keypress("1") + time.sleep(0.2) + _, story = cap_story() + shortcut_fname = story.split("\n\n")[-1] + press_select() # looking at save sr1 filename + else: + _start_idx = 0 + press_select() # continue normally + + time.sleep(0.1) + title, story = cap_story() + read_tokens = [] + if encryption_type == "3": + assert story == "Success. Coordinator round 1 saved." + else: + if "Press (1) to save BSMS token file(s) to SD Card" in story: + need_keypress("1") + time.sleep(0.2) + _, story = cap_story() + assert 'BSMS token file(s) written' in story + fnames = story.split('\n\n')[2:] + for fname in fnames: + path = microsd_path(fname) + with open(path, 'rt') as f: + tok = f.read().strip() + read_tokens.append(tok) + + all_signers = [] + if encryption_type == "1": + assert len(read_tokens) == 1 + for i in range(_start_idx, N): + all_signers.append(Signer(read_tokens[0], "key %d" % i)) + elif encryption_type == "2": + assert len(read_tokens) == (N - _start_idx) + for i in range(N - _start_idx): + all_signers.append(Signer(read_tokens[i], "key %d" % i)) + else: + assert len(read_tokens) == 0 + for i in range(N - _start_idx): + all_signers.append(Signer("00", "key %d" % i)) + + press_select() # confirm success or files written story + time.sleep(0.1) + menu = cap_menu() + assert len(menu) == 2 + current_coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert menu[0] == current_coord_menu_item + # check correct coord tuple saved + bsms_settings = settings_get(BSMS_SETTINGS) + if BSMS_SIGNER_SETTINGS in bsms_settings: + if cr1_shortcut: + assert len(bsms_settings[BSMS_SIGNER_SETTINGS]) == 1 + shortcut_token = bsms_settings[BSMS_SIGNER_SETTINGS][0] + else: + assert bsms_settings[BSMS_SIGNER_SETTINGS] == [] + shortcut_token = None + coord_settings = bsms_settings[BSMS_COORD_SETTINGS] + assert len(coord_settings) == 1 + if read_tokens: + expect_tokens = [tok.split(" ")[-1] for tok in read_tokens] + if cr1_shortcut and encryption_type == "2": + expect_tokens = [shortcut_token] + expect_tokens + else: + expect_tokens = [] + assert coord_settings[0] == (M, N, af_map[addr_fmt], encryption_type, expect_tokens) + + # ROUND 2 + def get_token(index): + if len(read_tokens) == 1 and encryption_type == "1": + token = read_tokens[0] + elif encryption_type == "2": + token = read_tokens[index] + else: + token = "00" + return token + + all_r1_signer_data = [s.round_1() for s in all_signers] + mode = "wt" if encryption_type == "3" else "wb" + suffix = ".txt" if encryption_type == "3" else ".dat" + for i, data in enumerate(all_r1_signer_data, start=1): + token = get_token(i - 1) + fname = bsms_sr1_fname(token, encryption_type == "2", suffix, i) + with open(microsd_path(fname), mode) as f: + f.write(bytes.fromhex(data) if mode == "wb" else data) + + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + menu = cap_menu() + assert len(menu) == 2 + coord_menu_item = coordinator_label(M, N, addr_fmt, encryption_type, index=1) + assert coord_menu_item in menu + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + press_select() # continue with manual file selection + if cr1_shortcut: + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #1 file containing round 1 data for token starting with %s' % shortcut_token[:4] + else: + expect = 'Select co-signer #1 file containing round 1 data' + assert expect in story + press_select() + pick_menu_item(shortcut_fname) + for i in range(_start_idx, N): + token = get_token(i - _start_idx) + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "2": + expect = 'Select co-signer #%d file containing round 1 data for token starting with %s' % (i + 1, token[:4]) + else: + expect = 'Select co-signer #%d file containing round 1 data' % (i + 1) + expect += '. File extension has to be "%s"' % suffix + assert expect in story + press_select() + fname = bsms_sr1_fname(token, encryption_type == "2", suffix, i + 1 - _start_idx) + pick_menu_item(fname) + + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to save BSMS descriptor template file(s) to SD Card" in story: + need_keypress("1") + time.sleep(0.1) + _, story = cap_story() + assert "BSMS descriptor template file(s) written." in story + fnames = story.split("\n\n")[1:] + if encryption_type == "2": + if cr1_shortcut: + read_tokens = [shortcut_token] + read_tokens + for fname, token in zip(fnames, read_tokens): + assert token[:4] in fname + descriptor_templates = [] + for fname in fnames: + with open(microsd_path(fname), "rt" if encryption_type == "3" else "rb") as f: + desc_temp = f.read() + descriptor_templates.append(desc_temp) + if len(descriptor_templates) == 1: + target = descriptor_templates[0] + if isinstance(target, bytes): + target = target.hex() + for signer in all_signers: + signer.round_2(target) + else: + if cr1_shortcut: + _, descriptor_templates = descriptor_templates[0], descriptor_templates[1:] + for signer, desc_tmplt in zip(all_signers, descriptor_templates): + if isinstance(desc_tmplt, bytes): + desc_tmplt = desc_tmplt.hex() + signer.round_2(desc_tmplt) + if cr1_shortcut: + # still need to add our signer + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + press_select() + pick_menu_item('Signer') + menu_item = "1 %s" % shortcut_token[:4] + pick_menu_item(menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import descriptor template file from SD Card" in story: + need_keypress("1") + time.sleep(0.1) + pick_menu_item(fnames[0]) + time.sleep(0.1) + title, story = cap_story() + assert "Create new multisig wallet?" in story + assert "bsms" in story # part of the name + policy = "Policy: %d of %d" % (M, N) + assert policy in story + assert addr_fmt.upper() in story + ms_wal_name = story.split("\n\n")[1].split("\n")[-1].strip() + ms_wal_menu_item = "%d/%d: %s" % (M, N, ms_wal_name) + press_select() + time.sleep(0.1) + menu = cap_menu() + assert ms_wal_menu_item in menu + bsms_settings = settings_get(BSMS_SETTINGS) + # signer round 2 removed + assert not bsms_settings.get(BSMS_SIGNER_SETTINGS, None) + + + +@pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) +@pytest.mark.parametrize("M_N", [(2, 2), (3, 5), (15, 15)]) +def test_auto_collection_coordinator_r2(encryption_type, M_N, goto_home, need_keypress, pick_menu_item, microsd_wipe, + cap_story, microsd_path,make_coordinator_round1, make_signer_round1, + press_select): + M, N = M_N + microsd_wipe() + + def get_token(index): + if len(tokens) == 1 and encryption_type == "1": + token = tokens[0] + elif len(tokens) == N and encryption_type == "2": + token = tokens[index] + else: + token = "00" + return token + + # add twice as many files with different tokens - should be still able to collect the correct ones + f_pattern = "bsms_sr1" + if encryption_type == "2": + suffix = ".dat" + for i in range(N): + token = os.urandom(16).hex() + s = Signer(token=token, key_description="key%d" % i) + r1 = s.round_1() + fname = "%s_%s%s" % (f_pattern, token[:4], suffix) + with open(microsd_path(fname), "wb") as f: + f.write(bytes.fromhex(r1)) + + elif encryption_type == "1": + suffix = ".dat" + for i in range(N): + token = os.urandom(8).hex() + s = Signer(token=token, key_description="key%d" % i) + r1 = s.round_1() + fname = "%s%s" % (f_pattern, suffix) + with open(microsd_path(fname), "wb") as f: + f.write(bytes.fromhex(r1)) + + else: + suffix = ".txt" + for i in range(N): + s = Signer(token="00", key_description="key%d" % i) + r1 = s.round_1() + fname = "%s%s" % (f_pattern, suffix) + with open(microsd_path(fname), "w") as f: + f.write(r1) + + tokens = make_coordinator_round1(M, N, "p2wsh", encryption_type, way="sd", tokens_only=True) + all_data = [] + for i in range(N): + token = get_token(i) + index = None + if encryption_type == "1": + index = i + 1 + all_data.append(make_signer_round1(token, "sd", purge_bsms=False, index=index)) + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('BSMS (BIP-129)') + title, story = cap_story() + assert "Bitcoin Secure Multisig Setup (BIP-129) is a mechanism to securely create multisig wallets." in story + assert "WARNING: BSMS is an EXPERIMENTAL and BETA feature" in story + press_select() + pick_menu_item('Coordinator') + coord_menu_item = coordinator_label(M, N, "p2wsh", encryption_type, index=1) + pick_menu_item(coord_menu_item) + pick_menu_item("Round 2") + time.sleep(0.1) + _, story = cap_story() + if "Press (1) to import co-signer round 1 files from SD Card" in story: + need_keypress("1") + need_keypress("1") # auto-collection + time.sleep(0.1) + title, story = cap_story() + if encryption_type == "3": + # we need exact number of files for unencrypted as we would have no idea which are part of this multisig setup + assert "Auto-collection failed. Defaulting to manual selection of files." in story + else: + if "Press (1) to save BSMS descriptor template file(s) to SD Card" in story: + # if NFC or Vdisk enabled - but means auto-collection was successful and we are prompted where to + # save the resulting descriptor (coordinator round2 data) + assert True + else: + # NFC and Vdisk disabled, automatically written to SD card - success + assert "BSMS descriptor template file(s) written" in story diff --git a/testing/test_decoders.py b/testing/test_decoders.py index 4faa3886..61bb0627 100644 --- a/testing/test_decoders.py +++ b/testing/test_decoders.py @@ -14,20 +14,24 @@ wordlist = Mnemonic('english').wordlist @pytest.fixture def try_decode(sim_exec): - def doit(arg): + def doit(arg, ): cmd = "from decoders import decode_qr_result; " + \ f"RV.write(repr(decode_qr_result({arg!r})))" result = sim_exec(cmd) - if 'Traceback' in result: raise RuntimeError(result) - if '<' in result: - # objects, like "', "'") + try: + return eval(result) + except SyntaxError: + if '<' in result: + # objects, like "', "'") + return eval(result) + + raise - return eval(result) return doit @pytest.mark.parametrize('fname,expect', [ @@ -145,7 +149,6 @@ def test_urldecode(url, sim_exec): @pytest.mark.parametrize('config', [ - 'wsh(sortedmulti(2,[0f056943/48h/1h/0h/2h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[6ba6cfd0/48h/1h/0h/2h]tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm/0/*,[747b698e/48h/1h/0h/2h]tpubDExj5FnaUnPAn7sHGUeBqD3buoNH5dqmjAT6884vbDpH1iDYWigb7kFo2cA97dc8EHb54u13TRcZxC4kgRS9gc3Ey2xc8c5urytEzTcp3ac/0/*,[7bb026be/48h/1h/0h/2h]tpubDFiuHYSJhNbHcbLJoxWdbjtUcbKR6PvLq53qC1Xq6t93CrRx78W3wcng8vJyQnY3giMJZEgNCRVzTojLb8RqPFpW5Ms2dYpjcJYofN1joyu/0/*))#al5z7mcj', '0f056943: tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP\n6ba6cfd0: tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm', '0f056943: xpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP\n6ba6cfd0: tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm', ' 0F056943 : tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP\n 6BA6CFD0 : tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm', @@ -163,6 +166,18 @@ def test_multisig(config, try_decode): assert ft == "multi" assert vals[0] == config +@pytest.mark.parametrize('desc', [ + 'wsh(sortedmulti(2,[0f056943/48h/1h/0h/2h]tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[6ba6cfd0/48h/1h/0h/2h]tpubDFcrvj5n7gyaxWQkoX69k2Zij4vthiAwvN2uhYjDrE6wktKoQaE7gKVZRiTbYdrAYH1UFPGdzdtWJc6WfR2gFMq6XpxA12gCdQmoQNU9mgm/0/*,[747b698e/48h/1h/0h/2h]tpubDExj5FnaUnPAn7sHGUeBqD3buoNH5dqmjAT6884vbDpH1iDYWigb7kFo2cA97dc8EHb54u13TRcZxC4kgRS9gc3Ey2xc8c5urytEzTcp3ac/0/*,[7bb026be/48h/1h/0h/2h]tpubDFiuHYSJhNbHcbLJoxWdbjtUcbKR6PvLq53qC1Xq6t93CrRx78W3wcng8vJyQnY3giMJZEgNCRVzTojLb8RqPFpW5Ms2dYpjcJYofN1joyu/0/*))#al5z7mcj', + 'wsh(or_d(pk([5155f1fa/44h/1h/0h]tpubDCtts5PqRUpJZaRegaWEGTULHp9XbFVsmrxQ38bAXf291HfmnTuDdeeXgyi59ywvRzaAmE8hiFZMVEv7KyGnH5YVBK3SDK625Huv4uoTsWZ/<0;1>/*),and_v(v:pkh([0f056943/84h/0h/0h]tpubDCx8y86cKonoPyTtj3f9NZLpBYoBNkbAzUdafMHhggjxkhF8Dny2aekWfDafywEMZEQaQjkK9Gxn7aN7usLRUQdYbvDgcnmYRf72khPEouL/<0;1>/*),older(5))))#sraf9nwn', + 'wsh(or_d(pk([d7beb757/44h/1h/0h]tpubDCKMUppLh1DJkSgbp9dmKaMwHyBQwrmLzxgwz8J7obXnFEaWneGyMZymyLra1PBjDyqBUE9JmPVyn33QCgXwkeAniz3LCXXTpw8YFe6edjk/0/*),and_v(v:pkh([0f056943/84h/0h/0h]tpubDCx8y86cKonoPyTtj3f9NZLpBYoBNkbAzUdafMHhggjxkhF8Dny2aekWfDafywEMZEQaQjkK9Gxn7aN7usLRUQdYbvDgcnmYRf72khPEouL/<0;1>/*),older(5))))', + '{"name":"a","desc":"wsh(or_d(pk([d7beb757/44h/1h/0h]tpubDCKMUppLh1DJkSgbp9dmKaMwHyBQwrmLzxgwz8J7obXnFEaWneGyMZymyLra1PBjDyqBUE9JmPVyn33QCgXwkeAniz3LCXXTpw8YFe6edjk/0/*),and_v(v:pkh([0f056943/84h/0h/0h]tpubDCx8y86cKonoPyTtj3f9NZLpBYoBNkbAzUdafMHhggjxkhF8Dny2aekWfDafywEMZEQaQjkK9Gxn7aN7usLRUQdYbvDgcnmYRf72khPEouL/<0;1>/*),older(5))))"}', +]) +def test_miniscript_descriptors(desc, try_decode): + # includes multisig + ft, vals = try_decode(desc) + assert ft == "minisc" + assert vals[0] == desc + @pytest.mark.parametrize('data', [ ('5J9Gfy2FNTw2EpkkQu41S9CTBBVij123kYPkbYAnaQkUHtMuv2Q', False, False), ('L2TgtddYM9ueK2auJVkNaNEF3egMMK1MTMkng5RBAcBWXnCMnxcb', True, False), diff --git a/testing/test_export.py b/testing/test_export.py index e74cd2f2..82450e79 100644 --- a/testing/test_export.py +++ b/testing/test_export.py @@ -4,12 +4,10 @@ # # Start simulator with: simulator.py --eff --set nfc=1 # -import sys -sys.path.append("../shared") -from descriptor import Descriptor -from mnemonic import Mnemonic import pytest, time, os, json, io, bech32 from bip32 import BIP32Node +from descriptor import Descriptor +from mnemonic import Mnemonic from ckcc_protocol.constants import * from helpers import xfp2str, slip132undo from conftest import simulator_fixed_xfp, simulator_fixed_tprv, simulator_fixed_words, simulator_fixed_xprv @@ -85,7 +83,12 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, addrs = [] imm_js = None imd_js = None + imd_js_tr = None + tr = False for ln in fp: + if ln.startswith("p2tr:"): + tr = True + if 'importmulti' in ln: # PLAN: this will become obsolete assert ln.startswith("importmulti '") @@ -93,20 +96,26 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, assert not imm_js, "dup importmulti lines" imm_js = ln[13:-2] elif "importdescriptors '" in ln: + ln = ln.strip() assert ln.startswith("importdescriptors '") - assert ln.endswith("'\n") - assert not imd_js, "dup importdesc lines" - imd_js = ln[19:-2] + if tr: + imd_js_tr = ln[19:-1] + tr = False + else: + imd_js = ln[19:-1] elif '=>' in ln: path, addr = ln.strip().split(' => ', 1) - assert path.startswith(f"m/84h/1h/{acct_num}h/0") - assert addr.startswith('bcrt1q') # TODO here we should differentiate if testnet or smthg sk = BIP32Node.from_wallet_key(simulator_fixed_tprv).subkey_for_path(path) - h20 = sk.hash160() - assert addr == bech32.encode(addr[0:4], 0, h20) # TODO here we should differentiate if testnet or smthg + if path.startswith(f"m/86h/1h/{acct_num}h/0"): + assert addr.startswith('bcrt1p') + assert addr == sk.address(addr_fmt="p2tr", chain="XRT") + else: + assert path.startswith(f"m/84h/1h/{acct_num}h/0") + assert addr.startswith("bcrt1q") + assert addr == sk.address(addr_fmt="p2wpkh", chain="XRT") addrs.append(addr) - assert len(addrs) == 3 + assert len(addrs) == 6 xfp = xfp2str(simulator_fixed_xfp).lower() @@ -140,14 +149,9 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, x = bitcoind_wallet.getaddressinfo(addrs[-1]) pprint(x) assert x['address'] == addrs[-1] - if 'label' in x: - # pre 0.21.? - assert x['label'] == 'testcase' - else: - assert x['labels'] == ['testcase'] - assert x['iswatchonly'] == True - assert x['iswitness'] == True - assert x['hdkeypath'] == f"m/84'/1'/{acct_num}'/0/%d" % (len(addrs)-1) + # assert x['iswatchonly'] == True + assert x['iswitness'] is True + # assert x['hdkeypath'] == f"m/84'/1'/{acct_num}'/0/%d" % (len(addrs)-1) # importdescriptors -- its better assert imd_js @@ -168,26 +172,49 @@ def test_export_core(way, dev, use_regtest, acct_num, pick_menu_item, goto_home, assert expect in desc assert expect+f'/{n}/*' in desc - assert 'label' not in d + res = bitcoind_d_wallet.importdescriptors(obj) + assert res[0]["success"] + assert res[1]["success"] + x = bitcoind_d_wallet.getaddressinfo(addrs[2]) + pprint(x) + assert x['address'] == addrs[2] + assert x['iswatchonly'] == False + assert x['iswitness'] == True + assert x['solvable'] == True + assert x['hdmasterfingerprint'] == xfp2str(dev.master_fingerprint).lower() + assert x['hdkeypath'].replace("'", "h") == f"m/84h/1h/{acct_num}h/0/%d" % 2 + + assert imd_js_tr + obj = json.loads(imd_js_tr) + for n, here in enumerate(obj): + assert here['timestamp'] == 'now' + assert here['internal'] == bool(n) + + d = here['desc'] + desc, chk = d.split('#', 1) + assert len(chk) == 8 + + assert desc.startswith(f'tr([{xfp}/86h/1h/{acct_num}h]') + + expect = BIP32Node.from_wallet_key(simulator_fixed_tprv) \ + .subkey_for_path(f"m/86h/1h/{acct_num}h").hwif() + + assert expect in desc + assert expect + f'/{n}/*' in desc # test against bitcoind -- needs a "descriptor native" wallet res = bitcoind_d_wallet.importdescriptors(obj) assert res[0]["success"] assert res[1]["success"] - core_gen = [] - for i in range(3): - core_gen.append(bitcoind_d_wallet.getnewaddress()) - assert core_gen == addrs x = bitcoind_d_wallet.getaddressinfo(addrs[-1]) pprint(x) assert x['address'] == addrs[-1] - assert x['iswatchonly'] == False - assert x['iswitness'] == True - # assert x['ismine'] == True # TODO we have imported pubkeys - it has no idea if it is ours or solvable - # assert x['solvable'] == True - # assert x['hdmasterfingerprint'] == xfp2str(dev.master_fingerprint).lower() - #assert x['hdkeypath'] == f"m/84'/1'/{acct_num}'/0/%d" % (len(addrs)-1) + assert x['iswatchonly'] is False + assert x['iswitness'] is True + assert x['solvable'] is True + assert x['hdmasterfingerprint'] == xfp2str(dev.master_fingerprint).lower() + assert x['hdkeypath'].replace("'", "h") == f"m/86h/1h/{acct_num}h/0/%d" % 2 @pytest.mark.parametrize('way', ["sd", "vdisk", "nfc", "qr"]) diff --git a/testing/test_hsm.py b/testing/test_hsm.py index fc9ac937..d08929eb 100644 --- a/testing/test_hsm.py +++ b/testing/test_hsm.py @@ -206,7 +206,7 @@ def hsm_reset(dev, sim_exec): # wallets (DICT(rules=[dict(wallet='1')]), - '(non multisig)'), + '(singlesig only)'), # users (DICT(rules=[dict(users=USERS)]), @@ -570,7 +570,7 @@ def test_named_wallets(dev, start_hsm, tweak_rule, make_myself_wallet, hsm_statu # simple p2pkh should fail psbt = fake_txn(1, 2, dev.master_xpub, outvals=[amount, 1E8-amount], change_outputs=[1], fee=0) - attempt_psbt(psbt, "not multisig") + attempt_psbt(psbt, "singlesig only") # but txn w/ multisig wallet should work psbt = fake_ms_txn(1, 2, M, keys, fee=0, outvals=[amount, 1E8-amount], outstyles=['p2wsh'], @@ -579,7 +579,119 @@ def test_named_wallets(dev, start_hsm, tweak_rule, make_myself_wallet, hsm_statu # check ms txn not accepted when rule spec's a single signer tweak_rule(0, dict(wallet='1')) - attempt_psbt(psbt, 'wrong wallet') + attempt_psbt(psbt, 'wrong multisig wallet') + +@pytest.mark.bitcoind +def test_named_wallets_miniscript(dev, start_hsm, tweak_rule, make_myself_wallet, + hsm_status, attempt_psbt, fake_txn, bitcoind, + offer_minsc_import, need_keypress, pick_menu_item, + load_export, goto_home): + stat = hsm_status() + assert not stat.active + + from test_miniscript import CHANGE_BASED_DESCS + for i, desc in enumerate(CHANGE_BASED_DESCS): + name = f"hsm_msc{i}" + xd = json.dumps({"name": name, "desc": desc}) + title, story = offer_minsc_import(xd) + assert "Create new miniscript wallet?" in story + assert name in story + need_keypress("y") + time.sleep(.2) + + core_wallets = [] + for i in range(len(CHANGE_BASED_DESCS)): + name = f"hsm_msc{i}" + 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(name) + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core miniscript", is_json=False, sig_check=False) + text = 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"] + + af = "bech32" + if i > 1: + af = "bech32m" + + addr = wo.getnewaddress("", af) + bitcoind.supply_wallet.sendtoaddress(addr, 1.0) + core_wallets.append(wo) + + # mine above txns + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + for w in core_wallets: + assert len(w.listunspent()) > 0, "nu funds" + + stat = hsm_status() + for i in range(len(CHANGE_BASED_DESCS)): + assert f"hsm_msc{i}" in stat.wallets + + # policy: only allow miniscript 0 + wname = "hsm_msc0" + policy = DICT(share_addrs=["any"], rules=[dict(wallet=wname)]) + + stat = start_hsm(policy) + assert 'Any amount from miniscript wallet' in stat.summary + assert wname in stat.summary + assert 'wallets' not in stat + + # simple p2pkh should fail + psbt = fake_txn(1, 2, outvals=[5E6, 1E8-5E6], change_outputs=[1], fee=0) + attempt_psbt(psbt, "singlesig only") + + # but txn from target miniscript wallet 0 must work + wal0 = core_wallets[0] + psbt_res = wal0.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.2}], 0, {"fee_rate": 20}) + attempt_psbt(base64.b64decode(psbt_res["psbt"])) + + # WRONG + wal2 = core_wallets[2] + psbt_res = wal2.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.2}], 0, {"fee_rate": 18}) + attempt_psbt(base64.b64decode(psbt_res["psbt"]), 'wrong miniscript wallet') + + wal1 = core_wallets[1] + psbt_res = wal1.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.2}], 0, {"fee_rate": 12}) + attempt_psbt(base64.b64decode(psbt_res["psbt"]), 'wrong miniscript wallet') + + # works + psbt_res = wal0.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.3}], 0, {"fee_rate": 15}) + attempt_psbt(base64.b64decode(psbt_res["psbt"])) + + wname = "hsm_msc3" + tweak_rule(0, dict(wallet=wname)) + + # this worked before but now, after tweak, it does not + psbt_res = wal0.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.1}], 0, {"fee_rate": 13}) + attempt_psbt(base64.b64decode(psbt_res["psbt"]), 'wrong miniscript wallet') + + # correct wallet 3 + wal3 = core_wallets[3] + psbt_res = wal3.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.6}], 0, {"fee_rate": 10}) + attempt_psbt(base64.b64decode(psbt_res["psbt"])) + + psbt_res = wal3.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 0.15}], 0, {"fee_rate": 15}) + last_correct = base64.b64decode(psbt_res["psbt"]) + attempt_psbt(last_correct) + + # check ms txn not accepted when rule spec's a single signer + tweak_rule(0, dict(wallet='1')) + attempt_psbt(last_correct, 'wrong miniscript wallet') + + stat = hsm_status() + assert stat.approvals == 4 + assert stat.refusals == 5 @pytest.mark.parametrize('with_whitelist_opts', [ False, True]) def test_whitelist_single(dev, start_hsm, tweak_rule, attempt_psbt, fake_txn, with_whitelist_opts, amount=5E6): @@ -1157,6 +1269,31 @@ def test_show_p2sh_addr(dev, hsm_reset, start_hsm, change_hsm, make_myself_walle M, xfp_paths, scr, addr_fmt=AF_P2WSH)) assert 'Not allowed in HSM mode' in str(ee) +def test_show_miniscript_addr(dev, offer_minsc_import, start_hsm, + change_hsm, need_keypress, clear_miniscript): + clear_miniscript() + from test_miniscript import CHANGE_BASED_DESCS + name = "hsm_msc_msas" + xd = json.dumps({"name": name, "desc": CHANGE_BASED_DESCS[0]}) + title, story = offer_minsc_import(xd) + assert "Create new miniscript wallet?" in story + assert name in story + need_keypress("y") + time.sleep(.2) + + policy = DICT(share_addrs=["any", "p2sh"], rules=[dict(wallet=name)]) + start_hsm(policy) + + with pytest.raises(CCProtoError) as ee: + dev.send_recv(CCProtocolPacker.miniscript_address(name, False, 0)) + assert "Not allowed in HSM mode" in ee.value.args[0] + + # change policy to allow miniscript address show + policy = DICT(share_addrs=["any", "p2sh", "msas"], rules=[dict(wallet=name)]) + change_hsm(policy) + addr = dev.send_recv(CCProtocolPacker.miniscript_address(name, False, 0)) + assert addr[2:4] == "1q" + def test_xpub_sharing(dev, start_hsm, change_hsm, addr_fmt=AF_CLASSIC): # xpub sharing, but only at certain derivations # - note 'm' is always shared diff --git a/testing/test_miniscript.py b/testing/test_miniscript.py new file mode 100644 index 00000000..5032504d --- /dev/null +++ b/testing/test_miniscript.py @@ -0,0 +1,2327 @@ +# (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, os +from ckcc.protocol import CCProtocolPacker +from constants import AF_P2TR +from psbt import BasicPSBT +from charcodes import KEY_QR, KEY_NFC, KEY_RIGHT, KEY_CANCEL +from bbqr import split_qrs + + +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 offer_minsc_import(cap_story, dev): + def doit(config, allow_non_ascii=False): + # upload the file, trigger import + file_len, sha = dev.upload_file(config.encode('utf-8' if allow_non_ascii else 'ascii')) + + open('debug/last-config-msc.txt', 'wt').write(config) + dev.send_recv(CCProtocolPacker.miniscript_enroll(file_len, sha)) + + time.sleep(.2) + title, story = cap_story() + return title, story + + return doit + + +@pytest.fixture +def import_miniscript(goto_home, pick_menu_item, cap_story, need_keypress, + nfc_write_text, press_select, scan_a_qr): + def doit(fname, way="sd", data=None): + goto_home() + pick_menu_item('Settings') + pick_menu_item('Miniscript') + pick_menu_item('Import') + time.sleep(.3) + _, story = cap_story() + if way == "nfc": + if "via NFC" not in story: + pytest.skip("nfc disabled") + + need_keypress(KEY_NFC) + time.sleep(.1) + if isinstance(data, dict): + data = json.dumps(data) + nfc_write_text(data) + time.sleep(1) + return cap_story() + elif way == "qr": + if isinstance(data, dict): + data = json.dumps(data) + + need_keypress(KEY_QR) + try: + scan_a_qr(data) + except: + # always as text - even if it is json + actual_vers, parts = split_qrs(data, 'U', max_version=20) + random.shuffle(parts) + + for p in parts: + scan_a_qr(p) + time.sleep(1) # just so we can watch + + time.sleep(1) + return cap_story() + + if "Press (1) to import miniscript wallet file from SD Card" in story: + # in case Vdisk or NFC is enabled + if way == "sd": + need_keypress("1") + + elif way == "vdisk": + if "ress (2)" not in story: + pytest.xfail(way) + + need_keypress("2") + else: + if way != "sd": + pytest.xfail(way) + + time.sleep(.5) + pick_menu_item(fname) + time.sleep(.1) + return cap_story() + + return doit + +@pytest.fixture +def import_duplicate(import_miniscript, press_cancel, virtdisk_path, microsd_path): + def doit(fname, way="sd", data=None): + new_fpath = None + new_fname = None + path_f = microsd_path + if way == "vdisk": + path_f = virtdisk_path + + title, story = import_miniscript(fname, way, data=data) + if "unique names" in story: + # trying to import duplicate with same name + # cannot get over name uniqueness requirement + # need to duplicate + if way in ["qr", "nfc"]: + data["name"] = data["name"] + "-new" + else: + with open(path_f(fname), "r") as f: + res = f.read() + + basename, ext = fname.split(".", 1) + new_fname = basename + "-new" + "." + ext + new_fpath = path_f(basename+"-new"+"."+ext) + with open(new_fpath, "w") as f: + f.write(res) + + title, story = import_miniscript(new_fname, way, data=data) + + assert "duplicate of already saved wallet" in story + assert "OK to approve" not in story + press_cancel() + + if new_fpath: + os.remove(new_fpath) + + return doit + +@pytest.fixture +def miniscript_descriptors(goto_home, pick_menu_item, need_keypress, cap_story, + microsd_path, is_q1, readback_bbqr, cap_screen_qr): + 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 + if is_q1: + # check QR + need_keypress(KEY_QR) + try: + file_type, data = readback_bbqr() + assert file_type == "U" + data = data.decode() + except: + data = cap_screen_qr().decode('ascii') + + qr_external, qr_internal = data.split("\n") + need_keypress(KEY_CANCEL) + + 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") + if qr_external: + assert qr_external == external + assert qr_internal == internal + return external, internal + return doit + + +@pytest.fixture +def usb_miniscript_get(dev): + def doit(name): + dev.check_mitm() + resp = dev.send_recv(CCProtocolPacker.miniscript_get(name)) + return json.loads(resp) + + return doit + + +@pytest.fixture +def usb_miniscript_delete(dev): + def doit(name): + dev.check_mitm() + dev.send_recv(CCProtocolPacker.miniscript_delete(name)) + + return doit + + +@pytest.fixture +def usb_miniscript_ls(dev): + def doit(): + dev.check_mitm() + resp = dev.send_recv(CCProtocolPacker.miniscript_ls()) + return json.loads(resp) + + return doit + + +@pytest.fixture +def usb_miniscript_addr(dev): + def doit(name, index, change=False): + dev.check_mitm() + resp = dev.send_recv(CCProtocolPacker.miniscript_address(name, change, index)) + return resp + + return doit + + +@pytest.fixture +def get_cc_key(dev): + def doit(path, subderiv=None): + # cc device key + master_xfp_str = struct.pack('/*'}" + return doit + + +@pytest.fixture +def bitcoin_core_signer(bitcoind): + def doit(name="core_signer"): + # core signer + signer = bitcoind.create_wallet(wallet_name=name, 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] + return signer, core_key + return doit + + +@pytest.fixture +def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, + cap_story, load_export, miniscript_descriptors, + usb_miniscript_addr, cap_screen_qr): + 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 + + if way == "qr": + need_keypress(KEY_QR) + cc_addrs = [] + for i in range(10): + cc_addrs.append(cap_screen_qr().decode()) + need_keypress(KEY_RIGHT) + time.sleep(.2) + need_keypress(KEY_CANCEL) + else: + contents = load_export(way, label="Address summary", is_json=False, sig_check=False) + addr_cont = contents.strip() + # time.sleep(5) + + time.sleep(.5) + title, story = cap_story() + assert "(0)" in story + assert "change addresses." in story + need_keypress("0") + time.sleep(5) + title, story = cap_story() + assert "(0)" not in story + assert "change addresses." not in story + + if way == "qr": + need_keypress(KEY_QR) + cc_addrs_change = [] + for i in range(10): + cc_addrs_change.append(cap_screen_qr().decode()) + need_keypress(KEY_RIGHT) + time.sleep(.2) + need_keypress(KEY_CANCEL) + else: + contents_change = load_export(way, label="Address summary", is_json=False, + sig_check=False) + 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 + elif way == 'qr': + addr_range = [0, 9] + 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): + if way == "nfc": + address = cc_item + elif way == "qr": + if cc_item.startswith("BC"): + cc_item = cc_item.lower() + address = cc_item + else: + cc_item = cc_item.split(",") + address = cc_item[part_addr_index] + address = address[1:-1] + assert core[idx] == address + + # check few USB addresses + for i in range(5): + addr = usb_miniscript_addr(cc_minsc_name, i, change=False) + time.sleep(.1) + title, story = cap_story() + assert addr in story + assert addr == bitcoind_addrs[i] + + for i in range(5): + addr = usb_miniscript_addr(cc_minsc_name, i, change=True) + time.sleep(.1) + title, story = cap_story() + assert addr in story + assert addr == bitcoind_addrs_change[i] + + 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("way", ["qr", "nfc", "sd", "vdisk"]) +@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, + pick_menu_item, cap_menu, cap_story, microsd_path, way, + use_regtest, bitcoind, microsd_wipe, load_export, dev, + address_explorer_check, get_cc_key, import_miniscript, + bitcoin_core_signer, import_duplicate, press_select, + virtdisk_path): + 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, core_key0 = bitcoin_core_signer("s0") + + # 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_key0) + else: + desc = desc.replace("@A", cc_key) + desc = desc.replace("@B", core_key0) + + if "@C" in desc: + signer1, core_key1 = bitcoin_core_signer("s1") + desc = desc.replace("@C", core_key1) + + use_regtest() + clear_miniscript() + name = "core-miniscript" + fname = f"{name}.txt" + if way in ["qr", "nfc"]: + data = dict(name=name, desc=desc) + else: + path_f = microsd_path if way == "sd" else virtdisk_path + data = None + fpath = path_f(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) + + _, story = import_miniscript(fname, way=way, data=data) + try: + assert "Create new miniscript wallet?" in story + except: + time.sleep(.2) + _, story = cap_story() + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + import_duplicate(fname, way=way, data=data) + 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(way, 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + 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(way, addr_fmt, wo, "core-miniscript") + + +@pytest.mark.parametrize("addr_fmt", ["bech32", "p2sh-segwit"]) +@pytest.mark.parametrize("way", ["qr", "sd"]) +@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, cap_story, + load_export, goto_home, address_explorer_check, cap_menu, + get_cc_key, import_miniscript, bitcoin_core_signer, + import_duplicate, press_select, way): + 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, core_key = bitcoin_core_signer(f"co-signer-{i}") + 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" + + if way in ["qr", "nfc"]: + fname = None + data = dict(name=name, desc=desc) + else: + fname = f"{name}.txt" + data = None + 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) + _, story = import_miniscript(fname, way=way, data=data) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + import_duplicate(fname, way=way, data=data) + 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(way, 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(pname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + 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(way, addr_fmt, wo, name) + + +@pytest.fixture +def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, + pick_menu_item, goto_home, cap_menu, microsd_path, + use_regtest, get_cc_key, import_miniscript, + bitcoin_core_signer, import_duplicate, press_select, + virtdisk_path): + def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None, + tapscript_threshold=False, add_own_pk=False, same_account=False, way="sd"): + + use_regtest() + bitcoind_signers = [] + bitcoind_signers_xpubs = [] + for i in range(N - 1): + s, core_key = bitcoin_core_signer(f"bitcoind--signer{i}") + s.keypoolrefill(10) + bitcoind_signers.append(s) + bitcoind_signers_xpubs.append(core_key) + + # 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 + press_select() + need_keypress(str(cc_account)) # account + press_select() + xpub_obj = load_export(way, label="Multisig XPUB", is_json=True, sig_check=False) + template = xpub_obj[script_type +"_desc"] + acct_deriv = xpub_obj[script_type + '_deriv'] + + if tapscript_threshold: + me = f"[{xpub_obj['xfp']}/{acct_deriv.replace('m/','')}]{xpub_obj[script_type]}/<0;1>/*" + 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: + if same_account: + cc_key = get_cc_key("m/86h/1h/0h", subderiv="/<2;3>/*") + else: + 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: + if same_account: + ss = [get_cc_key("m/86h/1h/0h", subderiv="/<4;5>/*")] + bitcoind_signers_xpubs + cc_key = get_cc_key("m/86h/1h/0h", subderiv="/<6;7>/*") + else: + ss = [get_cc_key("m/86h/1h/0h")] + bitcoind_signers_xpubs + cc_key = get_cc_key("m/86h/1h/1000h") + + tmplt = f"sortedmulti_a({M},{','.join(ss)})" + 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" + fname = None + if way in ["sd", "vdisk"]: + data = None + fname = f"{name}.txt" + path_f = microsd_path if way == 'sd' else virtdisk_path + with open(path_f(fname), "w") as f: + f.write(desc + "\n") + else: + data = dict(name=name, desc=desc) + + _, story = import_miniscript(fname, way=way, data=data) + assert "Create new miniscript wallet?" in story + assert name 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 + press_select() # approve multisig import + if r == "@": + # unspendable key is generated randomly + # descriptors will differ + with pytest.raises(AssertionError): + import_duplicate(fname, way=way, data=data) + else: + import_duplicate(fname, way=way, data=data) + 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(way, 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("same_acct", [True, False]) +@pytest.mark.parametrize("way", ["qr", "sd"]) +@pytest.mark.parametrize("M_N", [(3,4),(4,5),(5,6)]) +def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, + cap_menu, cap_story, microsd_path, use_regtest, bitcoind, microsd_wipe, + load_export, bitcoind_miniscript, add_pk, same_acct, get_cc_key, + press_select, way): + M, N = M_N + clear_miniscript() + microsd_wipe() + internal_key = None + if same_acct: + # provide internal key with same account derivation (change based derivation) + internal_key = get_cc_key("m/86h/1h/0h", subderiv='/<10;11>/*') + + wo, signers = bitcoind_miniscript(M, N, "p2tr", tapscript_threshold=True, + add_own_pk=add_pk, internal_key=internal_key, + same_account=same_acct, way=way) + 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item("ts_tree.psbt") + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + press_select() + time.sleep(0.1) + title, story = cap_story() + assert title == "PSBT Signed" + fname = [i for i in story.split("\n\n") if ".psbt" in i][0] + 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.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', ["qr", "sd", "vdisk", "nfc"]) +def test_bitcoind_tapscript_address(M_N, clear_miniscript, bitcoind_miniscript, + use_regtest, 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, way=way) + address_explorer_check(way, "bech32m", ms_wo, "minisc") + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("m_n", [(2,2), (3, 5), (32, 32)]) +@pytest.mark.parametrize("way", ["qr", "sd"]) +@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, + pick_menu_item, cap_story, microsd_path, load_export, microsd_wipe, dev, way, + bitcoind_miniscript, clear_miniscript, get_cc_key, press_cancel, press_select): + 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, + way=way + ) + + 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 ? + press_cancel() + time.sleep(0.1) + # CC signing + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + press_select() + 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, get_cc_key, + pick_menu_item, cap_story, goto_home, cap_menu, load_export, + import_miniscript, bitcoin_core_signer, import_duplicate, press_select): + use_regtest() + clear_miniscript() + microsd_wipe() + tmplt = TREE[num_leafs] + bitcoind_signers_xpubs = [] + bitcoind_signers = [] + for i in range(num_leafs): + s, core_key = bitcoin_core_signer(f"bitcoind--signer{i}") + bitcoind_signers.append(s) + 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") + _, story = import_miniscript(fname) + 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 + + press_select() + import_duplicate(fname) + 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + 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, + import_miniscript, load_export, desc, microsd_path, + press_select): + clear_miniscript() + fname = "imdesc.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + _, story = import_miniscript(fname) + press_select() # 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 + press_select() + 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, microsd_path, + cap_story, load_export, get_cc_key, import_miniscript, + bitcoin_core_signer, import_duplicate, press_select): + # 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, core_key = bitcoin_core_signer(f"dup_leafs") + + 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) + + _, story = import_miniscript(fname) + 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 + + press_select() + import_duplicate(fname) + 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + 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, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind, + import_miniscript, use_regtest, import_duplicate, + press_select): + clear_miniscript() + use_regtest() + + 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) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Press (1) to see extended public keys" in story + + press_select() + import_duplicate(fname) + 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + 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 + + +CHANGE_BASED_DESCS = [ + ( + "wsh(" + "or_d(" + "pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*)," + "and_v(" + "v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*)," + "older(5)" + ")" + ")" + ")#aq0kpuae" + ), + ( + "wsh(or_i(" + "and_v(" + "v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*)," + "older(10)" + ")," + "or_d(" + "multi(" + "3," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*" + ")," + "and_v(" + "v:thresh(" + "2," + "pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*)," + "a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*)," + "a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)" + ")," + "older(5)" + ")" + ")" + "))#a4nfkskx" + ), + "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*),and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*),older(5))),or_i(and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*),older(10)),or_d(multi_a(3,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*),and_v(v:thresh(2,pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)),older(5))))})#z5x7409w", + "tr([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<66;67>/*,{or_d(pk([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*),and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*),older(5))),or_i(and_v(v:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2147483646;2147483647>/*),older(10)),or_d(multi_a(3,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<100;101>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<26;27>/*,[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<4;5>/*),and_v(v:thresh(2,pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<20;21>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<104;105>/*),a:pkh([0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<22;23>/*)),older(5))))})#qqcy9jlr", +] + +@pytest.mark.parametrize("desc", CHANGE_BASED_DESCS) +def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind, + import_miniscript, address_explorer_check, use_regtest, + desc, press_select): + clear_miniscript() + use_regtest() + if desc.startswith("tr("): + af = "bech32m" + else: + af = "bech32" + + name = "mini-change" + fname = f"{name}.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + assert fname.split(".")[0] in story + assert "Press (1) to see extended public keys" in story + + press_select() + 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"minsc-change", 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("", af) + assert bitcoind.supply_wallet.sendtoaddress(addr, 49) + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr = wo.getnewaddress("", af) # selfspend + psbt = wo.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] + fname = "msc-change-conso.psbt" + with open(microsd_path(fname), "w") as f: + f.write(psbt) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) + + dest_addr_0 = bitcoind.supply_wallet.getnewaddress() + dest_addr_1 = bitcoind.supply_wallet.getnewaddress() + dest_addr_2 = bitcoind.supply_wallet.getnewaddress() + psbt = wo.walletcreatefundedpsbt( + [], + [{dest_addr_0: 1.0}, {dest_addr_1: 2.56}, {dest_addr_2: 12.99}], + 0, {"fee_rate": 2} + )["psbt"] + fname = "msc-change-send.psbt" + with open(microsd_path(fname), "w") as f: + f.write(psbt) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + fname_psbt = story.split("\n\n")[1] + with open(microsd_path(fname_psbt), "r") as f: + final_psbt = f.read().strip() + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) + assert txn_id + + # check addresses + address_explorer_check("sd", af, wo, "mini-change") + + +def test_same_key_account_based_multisig(goto_home, pick_menu_item, cap_story, + clear_miniscript, microsd_path, load_export, bitcoind, + import_miniscript): + 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) + + _, story = import_miniscript(fname) + 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, pick_menu_item, cap_story, + microsd_path, desc, import_miniscript): + + 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) + + _, story = import_miniscript(fname) + assert "Failed to import" in story + assert "Insane" in story + +def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, + microsd_path, import_miniscript): + 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) + _, story = import_miniscript(fname) + 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("same_acct", [True, False]) +@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, + pick_menu_item, cap_menu, cap_story, microsd_path, + use_regtest, bitcoind, microsd_wipe, load_export, dev, + address_explorer_check, get_cc_key, import_miniscript, + bitcoin_core_signer, same_acct, import_duplicate, press_select): + + # needs bitcoind 26.0 + 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, core_key = bitcoin_core_signer(f"co-signer{i}") + core_keys.append(core_key) + signers.append(signer) + + # cc device key + if same_acct: + cc_key = get_cc_key("86h/1h/0h", subderiv="/<4;5>/*") + cc_key1 = get_cc_key("86h/1h/0h", subderiv="/<6;7>/*") + else: + 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) + + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + import_duplicate(fname) + 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + 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, microsd_path, pick_menu_item, + cap_story, import_miniscript): + clear_miniscript() + fname = "imdesc.txt" + with open(microsd_path(fname), "w") as f: + f.write(desc) + + title, story = import_miniscript(fname) + assert "Failed to import" in story + assert "multi mixin" in story + + +def test_timelock_mixin(): + pass + + +@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, + load_export, microsd_path, use_regtest, clear_miniscript, cc_first, + address_explorer_check, import_miniscript, bitcoin_core_signer, press_select): + + # 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, core_key = bitcoin_core_signer(f"co-signer{i}") + 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() + _, story = import_miniscript(fname) + 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 + press_select() + 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(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(name) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + 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") + + +def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, + clear_miniscript, goto_home, cap_menu, pick_menu_item, + import_miniscript, microsd_path, press_select): + clear_miniscript() + use_regtest() + + x = "wsh(or_d(pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),and_v(v:pkh([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),older(100))))" + z = "wsh(or_d(pk([0f056943/48'/0'/0'/3']xpub6FQgdFZAHcAeDMVe9KxWoLMxziCjscCExzuKJhRSjM71CA9dUDZEGNgPe4S2SsRumCBXeaTBZ5nKz2cMDiK4UEbGkFXNipHLkm46inpjE9D/0/*),and_v(v:pkh([0f056943/48'/0'/0'/2']xpub6FQgdFZAHcAeAhQX2VvQ42CW2fDdKDhgwzhzXuUhWb4yfArmaZXkLbGS9W1UcgHwNxVESCS1b8BK8tgNYEF8cgmc9zkmsE45QSEvbwdp6Kr/0/*),older(100))))" + y = f"tr({H},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" + + fname_btc = "BTC.txt" + fname_xtn = "XTN.txt" + fname_xtn0 = "XTN0.txt" + + for desc, fname in [(x, fname_xtn), (z, fname_btc), (y, fname_xtn0)]: + with open(microsd_path(fname), "w") as f: + f.write(desc) + + # cannot import XPUBS when testnet/regtest enabled + _, story = import_miniscript(fname_btc) + assert "Failed to import" in story + assert "wrong chain" in story + + import_miniscript(fname_xtn) + press_select() + # assert that wallets created at XRT always store XTN anywas (key_chain) + res = settings_get("miniscript") + assert len(res) == 1 + assert res[0][1] == "XTN" + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert fname_xtn.split(".")[0] in m[0] + goto_home() + settings_set("chain", "BTC") + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + # asterisk hints that some wallets are already stored + # but not on current active chain + assert "(none setup yet)*" in m + import_miniscript(fname_btc) + press_select() + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + assert fname_btc.split(".")[0] in m[0] + for mi in m: + assert fname_xtn.split(".")[0] not in mi + + _, story = import_miniscript(fname_xtn) + assert "Failed to import" in story + assert "wrong chain" in story + + settings_set("chain", "XTN") + import_miniscript(fname_xtn0) + press_select() + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert fname_xtn.split(".")[0] in m[0] + assert fname_xtn0.split(".")[0] in m[1] + for mi in m: + assert fname_btc not in mi + + +@pytest.mark.parametrize("taproot_ikspendable", [ + (True, False), (True, True), (False, False) +]) +@pytest.mark.parametrize("minisc", [ + "or_d(pk(@A),and_v(v:pkh(@B),after(100)))", + "or_d(multi(2,@A,@C),and_v(v:pkh(@B),after(100)))", +]) +def test_import_same_policy_same_keys_diff_order(taproot_ikspendable, minisc, + clear_miniscript, use_regtest, + get_cc_key, bitcoin_core_signer, + offer_minsc_import, cap_menu, + bitcoind, pick_menu_item, + press_select): + use_regtest() + clear_miniscript() + taproot, ik_spendable = taproot_ikspendable + if taproot: + minisc = minisc.replace("multi(", "multi_a(") + if ik_spendable: + ik = get_cc_key("84h/1h/100h", subderiv="/0/*") + desc = f"tr({ik},{minisc})" + else: + desc = f"tr({H},{minisc})" + else: + desc = f"wsh({minisc})" + + cc_key0 = get_cc_key("84h/1h/0h", subderiv="/0/*") + signer0, core_key0 = bitcoin_core_signer("s00") + # recevoery path is always B + desc0 = desc.replace("@A", cc_key0) + desc0 = desc0.replace("@B", core_key0) + + if "@C" in desc: + signer1, core_key1 = bitcoin_core_signer("s11") + desc0 = desc0.replace("@C", core_key1) + + # now just change order of the keys (A,B), but same keys same policy + desc1 = desc.replace("@B", cc_key0) + desc1 = desc1.replace("@A", core_key0) + + if "@C" in desc: + desc1 = desc1.replace("@C", core_key1) + + # checksum required if via USB + desc_info = bitcoind.supply_wallet.getdescriptorinfo(desc0) + desc0 = desc_info["descriptor"] # with checksum + desc_info = bitcoind.supply_wallet.getdescriptorinfo(desc1) + desc1 = desc_info["descriptor"] # with checksum + + title, story = offer_minsc_import(desc0) + assert "Create new miniscript wallet?" in story + press_select() + time.sleep(.2) + title, story = offer_minsc_import(desc1) + assert "Create new miniscript wallet?" in story + press_select() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 2 + + +@pytest.mark.parametrize("cs", [True, False]) +@pytest.mark.parametrize("way", ["usb", "nfc", "sd", "vdisk"]) +def test_import_miniscript_usb_json(use_regtest, cs, way, cap_menu, + clear_miniscript, pick_menu_item, + get_cc_key, bitcoin_core_signer, + offer_minsc_import, bitcoind, microsd_path, + virtdisk_path, import_miniscript, goto_home, + press_select): + name = "my_minisc" + minsc = f"tr({H},or_d(multi_a(2,@A,@C),and_v(v:pkh(@B),after(100))))" + use_regtest() + clear_miniscript() + + cc_key = get_cc_key("84h/1h/0h", subderiv="/0/*") + signer0, core_key0 = bitcoin_core_signer("s00") + # recevoery path is always B + desc = minsc.replace("@A", cc_key) + desc = desc.replace("@B", core_key0) + + signer1, core_key1 = bitcoin_core_signer("s11") + desc = desc.replace("@C", core_key1) + + if cs: + desc_info = bitcoind.supply_wallet.getdescriptorinfo(desc) + desc = desc_info["descriptor"] # with checksum + + val = json.dumps({"name": name, "desc": desc}) + + nfc_data = None + fname = "diff_name.txt" # will be ignored as name in the json has preference + if way == "usb": + title, story = offer_minsc_import(val) + else: + if way == "nfc": + nfc_data = val + else: + if way == "sd": + fpath = microsd_path(fname) + else: + fpath = virtdisk_path(fname) + + with open(fpath, "w") as f: + f.write(val) + + title, story = import_miniscript(fname, way, nfc_data) + + assert "Create new miniscript wallet?" in story + assert name in story + press_select() + time.sleep(.2) + goto_home() + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 1 + assert m[0] == name + + +@pytest.mark.parametrize("config", [ + # all dummy data there to satisfy badlen check in usb.py + # missing 'desc' key + {"name": "my_miniscript", "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # name longer than 40 chars + {"name": "a" * 41, "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # name too short + {"name": "a", "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # desc key empty + {"name": "ab", "desc": "", "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # name type + {"name": None, "desc": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, + # desc type + {"name": "ab", "desc": None, "random": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, +]) +def test_json_import_failures(config, offer_minsc_import): + with pytest.raises(Exception): + offer_minsc_import(json.dumps(config)) + + +@pytest.mark.parametrize("way", ["sd", "nfc", "vdisk"]) +@pytest.mark.parametrize("is_json", [True, False]) +def test_unique_name(clear_miniscript, use_regtest, offer_minsc_import, + pick_menu_item, cap_menu, way, goto_home, + microsd_path, virtdisk_path, is_json, + import_miniscript, press_select): + clear_miniscript() + use_regtest() + + name = "my_name" + x = "wsh(or_d(pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),and_v(v:pkh([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),older(100))))" + y = f"tr({H},or_d(pk([30afbe54/48h/1h/0h/3h]tpubDFLVv7cuiLjn3QcsCend5kn3yw5sx6Czazy7hZvdGX61v8pkU95k2Byz9M5jnabzeUg7qWtHYLeKQyCWWAHhUmQQMeZ4Dee2CfGR2TsZqrN/<0;1>/*),and_v(v:pk([0f056943/48h/1h/0h/3h]tpubDF2rnouQaaYrY6CUWTapYkeFEs3h3qrzL4M52ZGoPeU9dkarJMtrw6VF1zJRGuGuAFxYS3kXtavfAwQPTQkU5dyNYpbgxcpftrR8H3U85Ez/<0;1>/*),after(800000))))" + + xd = json.dumps({"name": name, "desc": x}) + title, story = offer_minsc_import(xd) + assert "Create new miniscript wallet?" in story + assert name in story + press_select() + time.sleep(.2) + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 1 + assert m[0] == name + + # completely different wallet but with the same name (USB) + yd = json.dumps({"name": name, "desc": y}) + title, story = offer_minsc_import(yd) + assert title == "FAILED" + assert "MUST have unique names" in story + press_select() + # nothing imported + pick_menu_item("Settings") + pick_menu_item("Miniscript") + m = cap_menu() + m = [i for i in m if not i.startswith("Import")] + assert len(m) == 1 + assert m[0] == name + + goto_home() + fname = f"{name}.txt" + nfc_data = None + if way == "nfc": + if not is_json: + pytest.xfail("impossible") + + nfc_data = yd + else: + if way == "sd": + fpath = microsd_path(fname) + elif way == "vdisk": + fpath = virtdisk_path(fname) + else: + assert False + + with open(fpath, "w") as f: + f.write(yd if is_json else y) + + title, story = import_miniscript(fname=fname, way=way, data=nfc_data) + assert "FAILED" == title + assert "MUST have unique names" in story + + +@pytest.mark.qrcode +def test_usb_workflow(usb_miniscript_get, usb_miniscript_ls, clear_miniscript, + usb_miniscript_addr, usb_miniscript_delete, use_regtest, + reset_seed_words, offer_minsc_import, need_keypress, + cap_story, cap_screen_qr, press_select): + use_regtest() + reset_seed_words() + clear_miniscript() + assert [] == usb_miniscript_ls() + for i, desc in enumerate(CHANGE_BASED_DESCS): + _, story = offer_minsc_import(json.dumps({"name": f"w{i}", "desc": desc})) + assert "Create new miniscript wallet?" in story + press_select() + time.sleep(.2) + + msc_wallets = usb_miniscript_ls() + assert len(msc_wallets) == 4 + assert sorted(msc_wallets) == ["w0", "w1", "w2", "w3"] + + # try to get/delete nonexistent wallet + with pytest.raises(Exception) as err: + usb_miniscript_get("w4") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + with pytest.raises(Exception) as err: + usb_miniscript_delete("w4") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + for i, w in enumerate(msc_wallets): + assert usb_miniscript_get(w)["desc"].split("#")[0] == CHANGE_BASED_DESCS[i].split("#")[0].replace("'", 'h') + + #check random address + addr = usb_miniscript_addr("w0", 55, False) + time.sleep(0.1) + need_keypress('4') + time.sleep(0.1) + qr = cap_screen_qr().decode('ascii') + assert qr == addr.upper() + + usb_miniscript_delete("w3") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w3'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 3 + with pytest.raises(Exception) as err: + usb_miniscript_get("w3") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + usb_miniscript_delete("w2") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w2'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 2 + with pytest.raises(Exception) as err: + usb_miniscript_get("w2") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + usb_miniscript_delete("w1") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w1'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 1 + with pytest.raises(Exception) as err: + usb_miniscript_get("w1") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + usb_miniscript_delete("w0") + time.sleep(.2) + _, story = cap_story() + assert "Delete miniscript wallet" in story + assert "'w0'" in story + press_select() + time.sleep(.2) + assert len(usb_miniscript_ls()) == 0 + with pytest.raises(Exception) as err: + usb_miniscript_get("w0") + assert err.value.args[0] == "Coldcard Error: Miniscript wallet not found" + + +def test_miniscript_name_validation(microsd_path, offer_minsc_import): + for tc in ["weê", "eee\teee"]: + with pytest.raises(Exception) as e: + offer_minsc_import(json.dumps({"name": tc, "desc": CHANGE_BASED_DESCS[0]})) + assert "must be ascii" in e.value.args[0] \ No newline at end of file diff --git a/testing/test_multisig.py b/testing/test_multisig.py index 4d26a52f..495939e4 100644 --- a/testing/test_multisig.py +++ b/testing/test_multisig.py @@ -6,9 +6,6 @@ # # py.test test_multisig.py -m ms_danger --ms-danger # -import sys -sys.path.append("../shared") -from descriptor import MultisigDescriptor, append_checksum, MULTI_FMT_TO_SCRIPT, parse_desc_str import time, pytest, os, random, json, shutil, pdb, io, base64, struct, bech32 from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput from ckcc.protocol import CCProtocolPacker, MAX_TXN_LEN @@ -24,6 +21,7 @@ from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint, uint256_from_st from io import BytesIO from hashlib import sha256 from bbqr import split_qrs +from descriptor import MULTI_FMT_TO_SCRIPT, MultisigDescriptor, parse_desc_str def HARD(n=0): @@ -99,11 +97,11 @@ def make_multisig(dev, sim_execfile): # default is BIP-45: m/45'/... (but no co-signer idx) # - but can provide str format for deriviation, use {idx} for cosigner idx - def doit(M, N, unique=0, deriv=None, dev_key=False): + def doit(M, N, unique=0, deriv=None, dev_key=False, chain="XTN"): keys = [] for i in range(N-1): - pk = BIP32Node.from_master_secret(b'CSW is a fraud %d - %d' % (i, unique), 'XTN') + pk = BIP32Node.from_master_secret(b'CSW is a fraud %d - %d' % (i, unique), chain) xfp = unpack("I', xfp_bytes)[0]) else: - pk = BIP32Node.from_wallet_key(simulator_fixed_tprv) + pk = BIP32Node.from_wallet_key(simulator_fixed_tprv if chain == "XTN" else simulator_fixed_xprv) xfp = simulator_fixed_xfp if not deriv: @@ -168,9 +166,10 @@ def import_ms_wallet(dev, make_multisig, offer_ms_import, press_select, def doit(M, N, addr_fmt=None, name=None, unique=0, accept=False, common=None, keys=None, do_import=True, derivs=None, descriptor=False, - int_ext_desc=False, dev_key=False, way=None): + int_ext_desc=False, dev_key=False, way=None, chain="XTN"): keys = keys or make_multisig(M, N, unique=unique, dev_key=dev_key, - deriv=common or (derivs[0] if derivs else None)) + deriv=common or (derivs[0] if derivs else None), + chain=chain) name = name or f'test-{M}-{N}' if not do_import: @@ -463,7 +462,7 @@ def make_ms_address(M, keys, idx=0, is_change=0, addr_fmt=AF_P2SH, testnet=1, ** @pytest.fixture def test_ms_show_addr(dev, cap_story, press_select, addr_vs_path, bitcoind_p2sh, has_ms_checks, is_q1): - def doit(M, keys, addr_fmt=AF_P2SH, bip45=True, **make_redeem_args): + def doit(M, keys, addr_fmt=AF_P2SH, bip45=True, chain="XTN", **make_redeem_args): # test we are showing addresses correctly # - verifies against bitcoind as well addr_fmt = unmap_addr_fmt.get(addr_fmt, addr_fmt) @@ -495,14 +494,13 @@ def test_ms_show_addr(dev, cap_story, press_select, addr_vs_path, bitcoind_p2sh, press_select() # check expected addr was generated based on my math - addr_vs_path(got_addr, addr_fmt=addr_fmt, script=scr) + addr_vs_path(got_addr, addr_fmt=addr_fmt, script=scr, chain=chain) # also check against bitcoind core_addr, core_scr = bitcoind_p2sh(M, pubkeys, addr_fmt) assert B2A(scr) == core_scr assert core_addr == got_addr - return doit @@ -520,7 +518,7 @@ def test_import_ranges(m_of_n, use_regtest, addr_fmt, clear_ms, import_ms_wallet try: # test an address that should be in that wallet. time.sleep(.1) - test_ms_show_addr(M, keys, addr_fmt=addr_fmt) + test_ms_show_addr(M, keys, addr_fmt=addr_fmt, chain="XRT") finally: clear_ms() @@ -970,8 +968,8 @@ def test_import_dup_safe(N, clear_ms, make_multisig, offer_ms_import, menu = cap_menu() assert f'{M}/{N}: {name}' in menu - # depending if NFC enabled or not, and if Q (has QR) - assert (len(menu) - num_wallets) in [5, 6, 7] + # depending if NFC enabled or not, and if Q (has QR) or whether EDGE + assert (len(menu) - num_wallets) in [5, 6, 7, 8] title, story = offer_ms_import(make_named('xxx-orig')) assert 'Create new multisig wallet' in story @@ -1845,43 +1843,6 @@ def test_ms_change_fraud(case, pk_num, num_ins, dev, addr_fmt, clear_ms, incl_xp assert len(story.split(':')[-1].strip()), story -@pytest.mark.parametrize('repeat', range(2) ) -def test_iss6743(repeat, set_seed_words, sim_execfile, try_sign): - # from SomberNight - psbt_b4 = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae3000008001000080000000800100008000000000030000000000') - # pre 3.2.0 result - psbt_wrong = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100a85d08eef6675803fe2b58dda11a553641080e07da36a2f3e116f1224201931b022071b0ba83ef920d49b520c37993c039d13ae508a1adbd47eb4b329713fcc8baef01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') - # psbt_right = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef63819483045022100ae90a7e4c350389816b03af0af46df59a2f53da04cc95a2abd81c0bbc5950c1d02202f9471d6b0664b7a46e81da62d149f688adc7ba2b3413372d26fa618a8460eba01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') - # changed with with introduction of signature grinding - psbt_right = bytes.fromhex('70736274ff0100520200000001bde05be36069e2e0fe44793c68ad8244bb1a52cc37f152e0fa5b75e40169d7f70000000000fdffffff018b1e000000000000160014ed5180f05c7b1dc980732602c50cda40530e00ad4de11c004f01024289ef0000000000000000007dd565da7ee1cf05c516e89a608968fed4a2450633a00c7b922df66b27afd2e1033a0a4fa4b0a997738ac2f142a395c1f02afcb31d7ffd46a90a0c927a4c411fd704094ef7844f01024289ef0431fcbdcc8000000112d4aaea7292e7870c7eeb3565fa1c1fa8f957fa7c4c24b411d5b4f5710d359a023e63d1e54063525bea286ccb2a0ad7b14560aa31ec4be826afa883141dfe1d53145c9e228d300000800100008000000080010000804f01024289ef04e44b38f1800000014a1960f3a3c86ba355a16a66a548cfb62eeb25663311f7cd662a192896f3777e038cc595159a395e4ec35e477c9523a1512f873e74d303fb03fc9a1503b1ba45271434652fae30000080010000800000008001000080000100df02000000000101d1a321707660769c7f8604d04c9ae2db58cf1ec7a01f4f285cdcbb25ce14bdfd0300000000fdffffff02401f00000000000017a914bfd0b8471a3706c1e17870a4d39b0354bcea57b687c864000000000000160014e608b171d63ec24d9fa252d5c1e45624b14e44700247304402205133eb96df167b895f657cce31c6882840a403013682d9d4651aed2730a7dad502202aaacc045d85d9c711af0c84e7f355cc18bf2f8e6d91774d42ba24de8418a39e012103a58d8eb325abb412eaf927cf11d8b7641c4a468ce412057e47892ca2d13ed6144de11c002202034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381947304402201008b084f53d3064ee381dfb3ff4373b29d6ae765b2af15a4e217e8d5d049c650220576af95d79b8fc686627da8a534141208b225ceb6085cd93fcaffb153ac016ea01010304010000002206034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef638191c5c9e228d3000008001000080000000800100008000000000030000002206039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f1c34652fae300000800100008000000080010000800000000003000000220602ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da60c094ef78400000000030000000104220020a3c65c4e376d82fb3ca45596feee5b08313ad64f38590c1b08bc530d1c0bbfea010569522102ab84641359fa22461b8461515231da63c196614cd22b26e556ed878e30db4da621034211ab0f75c3a307a2f6bf6f09a9e05d3c8edd0ba7a2ac31f432d1045ef6381921039690cf74941da5db291fa8be7348abe3807786732d969eac5d27e0afa909a55f53ae0000') - seed_words = 'all all all all all all all all all all all all' - expect_xfp = swab32(int('5c9e228d', 16)) - assert xfp2str(expect_xfp) == '5c9e228d'.upper() - - # load specific private key - xfp = set_seed_words(seed_words) - assert xfp == expect_xfp - - # check Coldcard derives expected Upub - derivation = "m/48h/1h/0h/1h" # part of devtest/unit_iss6743.py - expect_xpub = 'Upub5SJWbuhs5tM4mkJST69tnpGGaf8dDTqByx3BLSocWFpq5YLh1fky4DQTFGQVG6nCSqZfUiAAeStdxSQteUcfMsWjDkhniZx4GdwpB18Tnbq' - - pub = sim_execfile('devtest/unit_iss6743.py') - assert pub == expect_xpub - - # verify psbt globals section - tp = BasicPSBT().parse(psbt_b4) - (hdr_xpub, hdr_path), = [(v,k) for v,k in tp.xpubs if k[0:4] == pack('\n", "=> ").replace('1/0]\n =>', "1/0 =>") + story = story.replace("=>\n", "=> ").replace('1/0]\n =>', "1/0] =>") else: - story = story.replace("=>\n", "=> ").replace('0/0]\n =>', "0/0 =>") + story = story.replace("=>\n", "=> ").replace('0/0]\n =>', "0/0] =>") maps = [] for ln in story.split('\n'): @@ -2057,8 +2019,9 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, path,chk,addr = ln.split() assert chk == '=>' assert '/' in path + path = path.replace("[", "").replace("]", "") - maps.append( (path, addr) ) + maps.append((path, addr)) if start_idx <= 2147483638: assert len(maps) == 10 @@ -2073,6 +2036,7 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu, path_mapper=path_mapper) assert int(subpath.split('/')[-1]) == idx + assert int(subpath.split('/')[-2]) == chng_idx #print('../0/%s => \n %s' % (idx, B2A(script))) start, end = detruncate_address(addr) @@ -2248,12 +2212,134 @@ def test_bitcoind_ms_address(change, M_N, addr_fmt, clear_ms, goto_home, need_ke bitcoind_addrs = bitcoind.deriveaddresses(desc_export, addr_range) for idx, cc_item in enumerate(cc_addrs): cc_item = cc_item.split(",") - partial_address = cc_item[part_addr_index] - _start, _end = partial_address.split("___") + address = cc_item[part_addr_index] if way != "nfc": - _start, _end = _start[1:], _end[:-1] - assert bitcoind_addrs[idx].startswith(_start) - assert bitcoind_addrs[idx].endswith(_end) + address = address[1:-1] + assert bitcoind_addrs[idx] == address + + +@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, press_select): + def doit(M, N, script_type, cc_account=0, funded=True): + 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 + press_select() + need_keypress(str(cc_account)) # account + press_select() + 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) + desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs)) + + 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" + else: + name = f"core{M}of{N}_legacy.txt" + with open(microsd_path(name), "w") as f: + f.write(desc + "\n") + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + pick_menu_item('Import from File') + time.sleep(0.3) + _, story = cap_story() + if "Press (1) to import multisig wallet file from SD Card" in story: + # in case Vdisk is enabled + need_keypress("1") + time.sleep(0.5) + pick_menu_item(name) + _, story = cap_story() + assert "Create new multisig wallet?" in story + assert name.split(".")[0] in story + assert f"{M} of {N}" in story + if M == N: + assert f"All {N} co-signers must approve spends" in story + else: + assert f"{M} signatures, from {N} possible" in story + if script_type == "p2wsh": + assert "P2WSH" in story + elif script_type == "p2sh": + assert "P2SH" in story + else: + assert "P2SH-P2WSH" in story + assert "Derivation:\n Varies (2)" in story + press_select() # approve multisig import + goto_home() + pick_menu_item('Settings') + pick_menu_item('Multisig Wallets') + menu = cap_menu() + pick_menu_item(menu[0]) # pick imported descriptor multisig wallet + pick_menu_item("Descriptors") + pick_menu_item("Bitcoin Core") + text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False, 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 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 @@ -2368,12 +2454,12 @@ 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("desc_type", ["p2wsh_desc", "p2sh_p2wsh_desc", "p2sh_desc"]) +@pytest.mark.parametrize("script_type", ["p2wsh", "p2sh_p2wsh", "p2sh"]) @pytest.mark.parametrize("sighash", list(SIGHASH_MAP.keys())) @pytest.mark.parametrize("psbt_v2", [True, False]) -def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypress, pick_menu_item, - sighash, cap_menu, cap_story, microsd_path, use_regtest, bitcoind, - microsd_wipe, load_export, settings_set, psbt_v2, is_q1, +def test_bitcoind_MofN_tutorial(m_n, clear_ms, goto_home, need_keypress, pick_menu_item, + sighash, cap_menu, cap_story, microsd_path, use_regtest, bitcoind_multisig, + microsd_wipe, load_export, settings_set, psbt_v2, script_type, finalize_v2_v0_convert, press_select): # 2of2 case here is described in docs with tutorial @@ -2382,122 +2468,20 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre use_regtest() clear_ms() microsd_wipe() - # remova all wallet from datadir - bitcoind.delete_wallet_files(pattern="bitcoind--signer") - bitcoind.delete_wallet_files(pattern="watch_only_") - # create multiple bitcoin wallets (N-1) as one signer is CC - 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(100) - # watch only wallet where multisig descriptor will be imported - bitcoind_watch_only = bitcoind.create_wallet( - wallet_name=f"watch_only_{desc_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 - press_select() - need_keypress("0") # account - press_select() - xpub_obj = load_export("sd", label="Multisig XPUB", is_json=True, sig_check=False) - template = xpub_obj[desc_type] - # 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) - desc = template.replace("M", str(M), 1).replace("...", ",".join(bitcoind_signers_xpubs)) - desc_info = bitcoind_watch_only.getdescriptorinfo(desc) - desc_w_checksum = desc_info["descriptor"] # with checksum - if desc_type == 'p2wsh_desc': - name = f"core{M}of{N}_native.txt" - elif desc_type == "p2sh_p2wsh_desc": - name = f"core{M}of{N}_wrapped.txt" - else: - name = f"core{M}of{N}_legacy.txt" - with open(microsd_path(name), "w") as f: - f.write(desc_w_checksum + "\n") - goto_home() - pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') - pick_menu_item('Import from File') - time.sleep(0.3) - _, story = cap_story() - if "Press (1) to import multisig wallet file from SD Card" in story: - # in case Vdisk is enabled - need_keypress("1") - time.sleep(0.5) - - pick_menu_item(name) - _, story = cap_story() - assert "Create new multisig wallet?" in story - assert name.split(".")[0] in story - assert f"{M} of {N}" in story - if M == N: - assert f"All {N} co-signers must approve spends" in story - else: - assert f"{M} signatures, from {N} possible" in story - if desc_type == "p2wsh_desc": - assert "P2WSH" in story - elif desc_type == "p2sh_desc": - assert "P2SH" in story - else: - assert "P2SH-P2WSH" in story - assert "Derivation:\n Varies (2)" in story - press_select() # approve multisig import - goto_home() - pick_menu_item('Settings') - pick_menu_item('Multisig Wallets') - menu = cap_menu() - pick_menu_item(menu[0]) # pick imported descriptor multisig wallet - pick_menu_item("Descriptors") - pick_menu_item("Bitcoin Core") - text = load_export("sd", label="Bitcoin Core multisig setup", is_json=False, 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 = bitcoind_watch_only.importdescriptors(core_desc_object) - for obj in res: - assert obj["success"], obj - if desc_type == "p2wsh_desc": + # create multisig with N-1 bitcoind signers + CC sim and register it + bitcoind_watch_only, bitcoind_signers = bitcoind_multisig(M, N, script_type) + if script_type == "p2wsh": addr_type = "bech32" - elif desc_type == "p2sh_desc": + elif script_type == "p2sh": addr_type = "legacy" else: addr_type = "p2sh-segwit" - multi_addr = bitcoind_watch_only.getnewaddress("", addr_type) - dest_addr = bitcoind_watch_only.getnewaddress("", addr_type) - if desc_type == "p2wsh_desc": - assert all([addr.startswith("bcrt1q") for addr in [multi_addr, dest_addr]]) - else: - assert all([addr.startswith("2") for addr in [multi_addr, dest_addr]]) - # mine some coins and fund above multisig address - mined = bitcoind_watch_only.generatetoaddress(101, multi_addr) - assert isinstance(mined, list) and len(mined) == 101 + # create funded PSBT all_of_it = bitcoind_watch_only.getbalance() + dest_addr = bitcoind_watch_only.getnewaddress("", addr_type) psbt_resp = bitcoind_watch_only.walletcreatefundedpsbt( - [], [{dest_addr: all_of_it}], 0, {"fee_rate": 20, "change_type": addr_type, + [], [{dest_addr: all_of_it - 1}], 0, {"fee_rate": 20, "change_type": addr_type, "subtractFeeFromOutputs": [0]} ) psbt = psbt_resp.get("psbt") @@ -2516,7 +2500,7 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre po.to_v2() psbt = po.as_b64_str() - name = f"hsc_{M}of{N}_{desc_type}.psbt" + name = f"hsc_{M}of{N}_{script_type}.psbt" with open(microsd_path(name), "w") as f: f.write(psbt) goto_home() @@ -2559,10 +2543,7 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre res = bitcoind_watch_only.sendrawtransaction(tx_hex) assert len(res) == 64 # tx id - # try to sign change - do a consolidation transaction which spends all inputs - addr_a = bitcoind_watch_only.getnewaddress("", addr_type) consolidate = bitcoind_watch_only.getnewaddress("", addr_type) - bitcoind_watch_only.generatetoaddress(1, addr_a) # need to mine above tx balance = bitcoind_watch_only.getbalance() unspent = bitcoind_watch_only.listunspent() psbt_outs = [{consolidate: balance}] @@ -2573,7 +2554,7 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre for idx, i in enumerate(x.inputs): i.sighash = SIGHASH_MAP[sighash] psbt = x.as_b64_str() - name = f"change_{M}of{N}_{desc_type}.psbt" + name = f"change_{M}of{N}_{script_type}.psbt" with open(microsd_path(name), "w") as f: f.write(psbt) goto_home() @@ -2619,21 +2600,20 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre res = bitcoind_watch_only.sendrawtransaction(tx_hex) assert len(res) == 64 # tx id bitcoind_signers[0].generatetoaddress(1, bitcoind_signers[0].getnewaddress()) # mine block - assert len(bitcoind_watch_only.listunspent()) == 2 # (merged all inputs to one + one newly spendable from mining) + assert len(bitcoind_watch_only.listunspent()) == 1 @pytest.mark.parametrize("desc", [ - ("Missing descriptor checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"), + # lack of checksum is now legal + # ("Missing descriptor checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))"), ("Wrong checksum", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M))#gs2fqgl7"), ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/1/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#sj7lxn0l"), - ("Invalid subderivation path - only 0/* or <0;1>/* allowed", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0/*,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#fy9mm8dt"), + ("All keys must be ranged", "wsh(sortedmulti(2,[0f056943/48'/1'/0'/2']tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP/0/0,[c463f778/44'/0'/0']tpubDD8pw7eZ9bUzYUR1LK5wpkA69iy3BpuLxPzsE6FFNdtTnJDySduc1VJdFEhEJQDKjYktznKdJgHwaQDRfQDQJpceDxH22c1ZKUMjrarVs7M/0/*))#9h02aqg5"), + ("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, @@ -2716,7 +2696,7 @@ 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, 15), (2, 2), (3, 5), (15, 15)]) +@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, @@ -2742,8 +2722,10 @@ def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear keys = make_multisig(M, N, unique=1, deriv=None if cmn_pth_from_root else deriv) derivs = [deriv.format(idx=i) for i in range(N)] clear_ms() - import_ms_wallet(M, N, accept=1, keys=keys, name=wal_name, derivs=None if cmn_pth_from_root else derivs, - addr_fmt=text_a_fmt, descriptor=False, common="m/45h" if cmn_pth_from_root else None) + import_ms_wallet(M, N, accept=1, keys=keys, name=wal_name, + derivs=None if cmn_pth_from_root else derivs, + addr_fmt=text_a_fmt, descriptor=True, + common="m/45h" if cmn_pth_from_root else None) # get bare descriptor choose_multisig_wallet() pick_menu_item("Descriptors") @@ -2804,6 +2786,82 @@ def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear clear_ms() +def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, + clear_ms, goto_home, cap_menu, pick_menu_item, + need_keypress, import_ms_wallet): + clear_ms() + use_regtest() + + # cannot import XPUBS when testnet/regtest enabled + with pytest.raises(Exception): + import_ms_wallet(3, 3, addr_fmt="p2wsh", accept=1, descriptor=True, chain="BTC") + + import_ms_wallet(2, 2, addr_fmt="p2wsh", accept=1, descriptor=True, chain="XTN") + # assert that wallets created at XRT always store XTN anywas (key_chain) + res = settings_get("multisig") + assert len(res) == 1 + assert res[0][-1]["ch"] == "XTN" + + goto_home() + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert "2/2:" in m[0] + goto_home() + settings_set("chain", "BTC") + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + # asterisk hints that some wallets are already stored + # but not on current active chain + assert "(none setup yet)*" in m + import_ms_wallet(3, 3, addr_fmt="p2wsh", accept=1, descriptor=True, chain="BTC") + goto_home() + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + assert "3/3:" in m[0] + for mi in m: + assert not mi.startswith("2/2:") + + goto_home() + settings_set("chain", "XTN") + import_ms_wallet(4, 4, addr_fmt="p2wsh", accept=1, descriptor=True, chain="XTN") + pick_menu_item("Settings") + pick_menu_item("Multisig Wallets") + time.sleep(0.1) + m = cap_menu() + assert "(none setup yet)" not in m + assert "2/2:" in m[0] + assert "4/4:" in m[1] + for mi in m: + assert not mi.startswith("3/3:") + + +@pytest.mark.parametrize("desc", [ + ("wsh(sortedmulti(2," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*," + "[0f056943/84'/1'/9']tpubDC7jGaaSE66QBAcX8TUD3JKWari1zmGH4gNyKZcrfq6NwCofKujNF2kyeVXgKshotxw5Yib8UxLrmmCmWd8NVPVTAL8rGfMdc7TsAKqsy6y/<0;1>/*" + "))"), + ("wsh(sortedmulti(2," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*," + "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<2;3>/*" + "))"), +]) +def test_same_key_account_based_multisig(goto_home, need_keypress, pick_menu_item, cap_story, + clear_ms, microsd_path, load_export, desc, + offer_ms_import): + clear_ms() + try: + _, story = offer_ms_import(desc) + except Exception as e: + assert "my key included more than once" in str(e) + + def test_multisig_name_validation(microsd_path, offer_ms_import): with open("data/multisig/export-p2wsh-myself.txt", "r") as f: config = f.read() diff --git a/testing/test_ownership.py b/testing/test_ownership.py index 29fc3595..d7bb05a5 100644 --- a/testing/test_ownership.py +++ b/testing/test_ownership.py @@ -2,7 +2,7 @@ # # Address ownership tests. # -import pytest, time, io, csv +import pytest, time, io, csv, json from txn import fake_address from base58 import encode_base58_checksum from helpers import hash160, taptweak @@ -235,12 +235,12 @@ def test_ux(valid, testnet, method, assert 'Searched ' in story assert 'candidates without finding a match' in story -@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "Taproot P2TR", "ms0"]) +@pytest.mark.parametrize("af", ["P2SH-Segwit", "Segwit P2WPKH", "Classic P2PKH", "Taproot P2TR", "ms0", "msc0", "msc2"]) def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explorer, pick_menu_item, need_keypress, sim_exec, clear_ms, import_ms_wallet, press_select, goto_home, nfc_write, load_shared_mod, load_export_and_verify_signature, - cap_story, load_export): + cap_story, load_export, offer_minsc_import): goto_home() wipe_cache() settings_set('accts', []) @@ -249,6 +249,12 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo clear_ms() import_ms_wallet(2, 3, name=af) press_select() # accept ms import + elif "msc" in af: + from test_miniscript import CHANGE_BASED_DESCS + which = int(af[-1]) + title, story = offer_minsc_import(json.dumps({"name": af, "desc": CHANGE_BASED_DESCS[which]})) + assert "Create new miniscript wallet?" in story + press_select() # accept goto_address_explorer() pick_menu_item(af) @@ -260,23 +266,19 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo lst = eval(lst) assert lst - if af == "ms0": - return # multisig addresses are blanked - title, body = cap_story() - if af == "Taproot P2TR": + if af in ("Taproot P2TR", "ms0", "msc0", "msc2"): # p2tr - no signature file contents = load_export("sd", label="Address summary", is_json=False, sig_check=False) - sig_addr = None else: - contents, sig_addr = load_export_and_verify_signature(body, "sd", label="Address summary") + contents, _ = load_export_and_verify_signature(body, "sd", label="Address summary") addr_dump = io.StringIO(contents) cc = csv.reader(addr_dump) hdr = next(cc) - assert hdr == ['Index', 'Payment Address', 'Derivation'] addr = None - for n, (idx, addr, deriv) in enumerate(cc, start=0): + assert hdr[:2] == ['Index', 'Payment Address'] + for n, (idx, addr, *_) in enumerate(cc, start=0): assert int(idx) == n if idx == 200: addr = addr @@ -300,7 +302,7 @@ def test_address_explorer_saver(af, wipe_cache, settings_set, goto_address_explo assert addr in story assert title == 'Verified Address' assert 'Found in wallet' in story - assert 'Derivation path' in story + # assert 'Derivation path' in story if af == "P2SH-Segwit": assert "P2WPKH-in-P2SH" in story elif af == "Segwit P2WPKH": diff --git a/testing/test_sign.py b/testing/test_sign.py index 4c58ce1b..e9ed28cc 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -1091,8 +1091,8 @@ def test_finalization_vs_bitcoind(match_key, use_regtest, check_against_bitcoind ("45'/1'/0'/1/5", 'diff path prefix'), ("44'/2'/0'/1/5", 'diff path prefix'), ("44'/1'/1'/1/5", 'diff path prefix'), - ("44'/1'/0'/3000/5", '2nd last component'), - ("44'/1'/0'/3/5", '2nd last component'), + # ("44'/1'/0'/3000/5", '2nd last component'), + # ("44'/1'/0'/3/5", '2nd last component'), ]) def test_change_troublesome(dev, start_sign, cap_story, try_path, expect): # NOTE: out#1 is change: From a18938cefdff01024c74c0f1d36e5b166341234f Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Tue, 25 Jun 2024 15:00:53 +0200 Subject: [PATCH 09/15] unspend( & ranged unspendable taproot internal keys --- docs/taproot.md | 20 ++- releases/EdgeChangeLog.md | 44 +++++ releases/History-Edge.md | 54 +++++++ shared/address_explorer.py | 4 +- shared/bsms.py | 6 +- shared/desc_utils.py | 89 +++++++--- shared/descriptor.py | 102 ++++-------- shared/miniscript.py | 104 +++++++----- shared/psbt.py | 6 +- stm32/MK4-Makefile | 2 +- stm32/Q1-Makefile | 2 +- testing/bip32.py | 6 + testing/conftest.py | 23 ++- testing/test_bsms.py | 51 +++--- testing/test_miniscript.py | 321 +++++++++++++++++++++++++++---------- testing/test_sign.py | 14 +- 16 files changed, 581 insertions(+), 267 deletions(-) create mode 100644 releases/EdgeChangeLog.md create mode 100644 releases/History-Edge.md diff --git a/docs/taproot.md b/docs/taproot.md index b45841fa..62d1bc10 100644 --- a/docs/taproot.md +++ b/docs/taproot.md @@ -25,21 +25,31 @@ MUST be generated with above-mentoned methods to be considered change. ## Provably unspendable internal key -There are few methods to provide/generate provably unspendable internal key, if users wish to only use script path -for multisig. +There are few methods to provide/generate provably unspendable internal key, if users wish to only use tapscript script path. -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. +1. **(recommended)** Origin-less extended key serialization with H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs) as BIP-32 key and random chaincode. + + `tr(xpub/<0:1>/*, sortedmulti_a(2,@0,@1))` which is the same thing as `tr(xpub, sortedmulti_a(2,@0,@1))` because `/<0;1>/*` is implied if not derivation path not provided. + +2. **(recommended)** Use `unspend(` [notation](https://gist.github.com/sipa/06c5c844df155d4e5044c2c8cac9c05e#unspendable-keys). Has to be ranged. + + `tr(unspend(77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76)/<0:1>/*, sortedmulti_a(2,@0,@1))` + +3. use **static** provably unspendable internal key H from [BIP-0341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs). `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. +4. 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. `tr(r=@, sortedmulti_a(MofN))` -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. +5. 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(2,@0,@1))` +Option 3. leaks the information that key path spending is not possible and therefore is not recommended privacy-wise. +Options 4. and 5. are problematic to some extent as internal key is static. Use recommended options 1. and 2. if the fact that internal key is unspendable should remain private. + ## Limitations diff --git a/releases/EdgeChangeLog.md b/releases/EdgeChangeLog.md new file mode 100644 index 00000000..4175fa17 --- /dev/null +++ b/releases/EdgeChangeLog.md @@ -0,0 +1,44 @@ +# Change Log + +## Warning: Edge Version + +```diff +- This preview version of firmware has not yet been qualified +- and tested to the same standard as normal Coinkite products. +- It is recommended only for developers and early adopters +- for experimental use. DO NOT use for large Bitcoin amounts. +``` + +This lists the changes in the most recent EDGE firmware, for each hardware platform. + +# Shared Improvements - Both Mk4 and Q + +- New Feature: Ranged provably unspendable keys and `unspend(` support for Taproot descriptors +- New Feature: Address ownership for miniscript and tapscript wallets +- Enhancement: Address explorer simplified UI for tapscript addresses +- Bugfix: Constant `AFC_BECH32M` incorrectly set `AFC_WRAPPED` and `AFC_BECH32`. +- Bugfix: Trying to set custom URL for NFC push transaction caused yikes + + +# Mk4 Specific Changes + +## 5.3.3X - 2024-07-04 + +- Bugfix: Fix yikes displaying BIP-85 WIF when both NFC and VDisk are OFF +- Bugfix: Fix inability to export change addresses when both NFC and Vdisk id OFF +- Bugfix: In BIP-39 words menu, show space character rather than Nokia-style placeholder + which could be confused for an underscore. + + +# Q Specific Changes + +## 1.2.3QX - 2024-07-04 + +- Enhancement: Miniscript and (BB)Qr codes +- Bugfix: Properly clear LCD screen after simple QR code is shown + + + +# Release History + +- [`History-Edge.md`](History-Edge.md) diff --git a/releases/History-Edge.md b/releases/History-Edge.md new file mode 100644 index 00000000..29d1d6a5 --- /dev/null +++ b/releases/History-Edge.md @@ -0,0 +1,54 @@ +## Warning: Edge Version + +```diff +- This preview version of firmware has not yet been qualified +- and tested to the same standard as normal Coinkite products. +- It is recommended only for developers and early adopters +- for experimental use. DO NOT use for large Bitcoin amounts. +``` + +## 6.3.3 + +## 6.2.2X - 2024-01-18 + +- New Feature: Miniscript [USB interface](https://github.com/Coldcard/ckcc-protocol/blob/master/README.md#miniscript) +- New Feature: Named miniscript imports. Wrap descriptor in json + `{"name:"n0", "desc":""}` with `name` key to use this name instead of the + filename. Mostly usefull for USB and NFC imports that have no file, in which case name + was created from descriptor checksum. +- Enhancement: Allow keys with same origin, differentiated only by change index derivation + in miniscript descriptor. +- Enhancement: HSM `wallet` rule enabled for miniscript +- Enhancement: Add `msas` in to the `share_addrs` HSM [rule](https://coldcard.com/docs/hsm/rules/) + to be able to check miniscript addresses in HSM mode. +- Enhancement: HW Accelerated AES CTR for BSMS and passphrase saver +- Bugfix: Do not allow to import duplicate miniscript + wallets (thanks to [AnchorWatch](https://www.anchorwatch.com/)) +- Bugfix: Saving passphrase on SD Card caused a freeze that required reboot + +## 6.2.1X - 2023-10-26 + +- New Feature: Enroll Miniscript wallet via USB (requires ckcc `v1.4.0`) +- New Feature: Temporary Seed from COLDCARD encrypted backup +- Enhancement: Add current temporary seed to Seed Vault from within Seed Vault menu. + If current active temporary seed is not saved yet, `Add current tmp` menu item is + present in Seed Vault menu. +- Reorg: `12 Words` menu option preferred on the top of the menu in all the seed menus +- Enhancement: Mainnet/Testnet separation. Only show wallets for current active chain. +- contains all the changes from the newest stable `5.2.0-mk4` firmware + +## 6.1.0X - 2023-06-20 + +- 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 + +- New Feature: Taproot keyspend & Tapscript multisig `sortedmulti_a` (tree depth = 0) +- New Feature: Support BIP-0129 Bitcoin Secure Multisig Setup (BSMS). + Both Coordinator and Signer roles are supported. +- Enhancement: change Key Origin Information export format in multisig `addresses.csv` according to [BIP-0380](https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions) + `(m=0F056943)/m/48'/1'/0'/2'/0/0` --> `[0F056943/48'/1'/0'/2'/0/0]` +- Bugfix: correct `scriptPubkey` parsing for segwit v1-v16 +- Bugfix: do not infer segwit just by availability of `PSBT_IN_WITNESS_UTXO` in PSBT \ No newline at end of file diff --git a/shared/address_explorer.py b/shared/address_explorer.py index ecfcfa62..48fcfd21 100644 --- a/shared/address_explorer.py +++ b/shared/address_explorer.py @@ -288,7 +288,6 @@ Press (3) if you really understand and accept these risks. if ms_wallet: msg, addrs = ms_wallet.make_addresses_msg(msg, start, n, change) - else: # single-signer wallets from wallet import MasterSingleSigWallet @@ -305,8 +304,7 @@ Press (3) if you really understand and accept these risks. # export options k0 = 'to show change addresses' if allow_change and change == 0 else None export_msg, escape = export_prompt_builder('address summary file', - no_qr=bool(ms_wallet), key0=k0, - force_prompt=True) + key0=k0, force_prompt=True) if version.has_qwerty: escape += KEY_LEFT+KEY_RIGHT+KEY_HOME+KEY_PAGE_UP+KEY_PAGE_DOWN+KEY_QR else: diff --git a/shared/bsms.py b/shared/bsms.py index df6e2031..84666462 100644 --- a/shared/bsms.py +++ b/shared/bsms.py @@ -723,7 +723,7 @@ async def bsms_coordinator_round2(menu, label, item): prompt, escape = export_prompt_builder(title) if prompt: ch = await ux_show_story(prompt, escape=escape) - if ch == KEY_NFC if version_mod.has_qwerty else '3': + if ch == (KEY_NFC if version_mod.has_qwerty else '3'): if et == "2": for i, token in enumerate(tokens): ch = await ux_show_story("Exporting data for co-signer #%d with token %s" @@ -922,7 +922,7 @@ async def bsms_signer_round1(*a): prompt, escape = export_prompt_builder(title) if prompt: ch = await ux_show_story(prompt, escape=escape) - if ch == KEY_NFC if version.has_qwerty else '3': + if ch == (KEY_NFC if version.has_qwerty else '3'): force_vdisk = None if isinstance(result_data, bytes): result_data = b2a_hex(result_data).decode() @@ -986,7 +986,7 @@ async def bsms_signer_round2(menu, label, item): if prompt: ch = await ux_show_story(prompt, escape=escape) - if ch == KEY_NFC if version.has_qwerty else '3': + if ch == (KEY_NFC if version.has_qwerty else '3'): force_vdisk = None desc_template_data = await NFC.read_bsms_data() diff --git a/shared/desc_utils.py b/shared/desc_utils.py index 8a198a4f..4e48a2e0 100644 --- a/shared/desc_utils.py +++ b/shared/desc_utils.py @@ -2,7 +2,7 @@ # # Copyright (c) 2020 Stepan Snigirev MIT License embit/arguments.py # -import ngu, chains +import ngu, chains, ustruct 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 @@ -12,7 +12,7 @@ from serializations import ser_compact_size WILDCARD = "*" -PROVABLY_UNSPENDABLE = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" +PROVABLY_UNSPENDABLE = b'\x02P\x92\x9bt\xc1\xa0IT\xb7\x8bK`5\xe9z^\x07\x8aZ\x0f(\xec\x96\xd5G\xbf\xee\x9a\xce\x80:\xc0' INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" @@ -90,7 +90,7 @@ def multisig_descriptor_template(xpub, path, xfp, addr_fmt): descriptor_template = "sh(sortedmulti(M,%s,...))" elif addr_fmt == AF_P2TR: # provably unspendable BIP-0341 - descriptor_template = "tr(" + PROVABLY_UNSPENDABLE + ",sortedmulti_a(M,%s,...))" + descriptor_template = "tr(" + b2a_hex(PROVABLY_UNSPENDABLE[1:]).decode() + ",sortedmulti_a(M,%s,...))" else: return None descriptor_template = descriptor_template % key_exp @@ -249,20 +249,13 @@ class Key: self.derivation = derivation self.taproot = taproot self.chain_type = chain_type - 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() \ + return self.origin == other.origin \ and self.derivation.indexes == other.derivation.indexes def __hash__(self): - orig = tuple(self.origin.psbt_derivation()) - der = self.derivation.indexes.copy() - if self.derivation.multi_path_index is not None: - der[self.derivation.multi_path_index] = tuple(der[self.derivation.multi_path_index]) - der = tuple(der) - return hash(orig+der) + return hash(self.to_string()) def __len__(self): return 34 - int(self.taproot) # <33:sec> or <32:xonly> @@ -282,6 +275,10 @@ class Key: def parse(cls, s): first = s.read(1) origin = None + if first == b"u": + s.seek(-1, 1) + return Unspend.parse(s) + if first == b"[": prefix, char = read_until(s, b"]") if char != b"]": @@ -324,13 +321,7 @@ class Key: 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) + H = PROVABLY_UNSPENDABLE[1:] if b"r=" in key_str: _, r = key_str.split(b"=") if r == b"@": @@ -381,9 +372,10 @@ class Key: if self.origin: origin = KeyOriginInfo(self.origin.fingerprint, self.origin.derivation + [idx]) else: - origin = KeyOriginInfo(self.node.my_fp(), [idx]) - # empty derivation - derivation = None + fp = ustruct.pack('") + 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) + + node = ngu.hdnode.HDNode().from_chaincode_pubkey(chain_code, + PROVABLY_UNSPENDABLE) + der = KeyDerivationInfo.from_string(der.decode()) + return cls(node, None, der, chain_type=None) + + def to_string(self, external=True, internal=True, subderiv=True): + res = "unspend(%s)" % b2a_hex(self.node.chain_code()).decode() + if self.derivation and subderiv: + res += "/" + self.derivation.to_string(external, internal) + + return res + + @property + def is_provably_unspendable(self): + return True + + def fill_policy(policy, keys, external=True, internal=True): keys_len = len(keys) for i in range(keys_len - 1, -1, -1): @@ -460,7 +503,7 @@ def fill_policy(policy, keys, external=True, internal=True): # subderivation is part of the policy subderiv = False x = ix + ph_len - substr = policy[x:x+26] # 26 is longest possible subderivation allowed "/<2147483647;2147483646>/*" + substr = policy[x:x+26] # 26 is the longest possible subderivation allowed "/<2147483647;2147483646>/*" mp_start = substr.find("<") assert mp_start != -1 mp_end = substr.find(">") diff --git a/shared/descriptor.py b/shared/descriptor.py index 9d65175d..988818aa 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -6,11 +6,11 @@ import ngu, chains from io import BytesIO from collections import OrderedDict from binascii import hexlify as b2a_hex -from utils import cleanup_deriv_path, check_xpub, xfp2str +from utils import cleanup_deriv_path, check_xpub, xfp2str, swab32 from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, MAX_SIGNERS, MAX_TR_SIGNERS from desc_utils import parse_desc_str, append_checksum, descriptor_checksum, Key -from desc_utils import taproot_tree_helper, fill_policy +from desc_utils import taproot_tree_helper, fill_policy, Unspend from miniscript import Miniscript @@ -232,7 +232,7 @@ class Descriptor: 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): + if not self.key.is_provably_unspendable: to_check += [self.key] else: assert self.key is None and self.miniscript, "not miniscript" @@ -282,16 +282,19 @@ class Descriptor: return 25 # OP_DUP OP_HASH160 <20:pkh> OP_EQUALVERIFY OP_CHECKSIG def xfp_paths(self): - keys = self.keys - if self.taproot and self.key.origin: - # ignore provably unspendable - keys += [self.key] + res = [] + if self.taproot: + if self.key.origin: + # spendable internal key + res.append(self.key.origin.psbt_derivation()) + elif not isinstance(self.key.node, bytes): + if self.key.is_provably_unspendable: + res.append([swab32(self.key.node.my_fp())]) - return [ - key.origin.psbt_derivation() - for key in keys - if key.origin - ] + for k in self.keys: + if k.origin: + res.append(k.origin.psbt_derivation()) + return res @property def is_wrapped(self): @@ -505,7 +508,7 @@ class Descriptor: @classmethod def read_from(cls, s, taproot=False): - start = s.read(7) + start = s.read(8) sh = False wsh = False wpkh = False @@ -515,8 +518,8 @@ class Descriptor: 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 + s.seek(-5, 1) + internal_key = Key.parse(s) # internal key is a must - also handles unspend( internal_key.taproot = True sep = s.read(1) if sep == b")": @@ -527,26 +530,26 @@ class Descriptor: elif start.startswith(b"sh(wsh("): sh = True wsh = True + s.seek(-1, 1) elif start.startswith(b"wsh("): sh = False wsh = True - s.seek(-3, 1) - elif start.startswith(b"sh(wpkh"): + s.seek(-4, 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) + s.seek(-3, 1) elif start.startswith(b"pkh("): is_miniscript = False - s.seek(-3, 1) + s.seek(-4, 1) elif start.startswith(b"sh("): sh = True wsh = False - s.seek(-4, 1) + s.seek(-5, 1) else: raise ValueError("Invalid descriptor") @@ -603,65 +606,14 @@ class Descriptor: # this will become legacy one day # instead use <0;1> descriptor format res = [] - for external, internal in [(True, False), (False, True)]: + for external in (True, False): desc_obj = { - "desc": self.to_string(external, internal), + "desc": self.to_string(external, not external), "active": True, "timestamp": "now", - "internal": internal, + "internal": not external, "range": [0, 100], } res.append(desc_obj) return res - - def pretty_serialize(self): - # TODO not enabled - """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 \ No newline at end of file diff --git a/shared/miniscript.py b/shared/miniscript.py index 607b7bd1..e1f6595d 100644 --- a/shared/miniscript.py +++ b/shared/miniscript.py @@ -13,7 +13,7 @@ from wallet import BaseStorageWallet from menu import MenuSystem, MenuItem from ux import ux_show_story, ux_confirm, ux_dramatic_pause from files import CardSlot, CardMissingError, needs_microsd -from utils import problem_file_line, xfp2str, addr_fmt_label, truncate_address, to_ascii_printable +from utils import problem_file_line, xfp2str, addr_fmt_label, truncate_address, to_ascii_printable, swab32 from charcodes import KEY_QR, KEY_CANCEL, KEY_NFC, KEY_ENTER @@ -158,6 +158,10 @@ class MiniScriptWallet(BaseStorageWallet): ik = Key.from_string(self.key) if ik.origin: res.append(ik.origin.psbt_derivation()) + elif not isinstance(ik.node, bytes): + if ik.is_provably_unspendable: + res.append([swab32(ik.node.my_fp())]) + for k in self.keys: k = Key.from_string(k) if k.origin: @@ -232,14 +236,14 @@ class MiniScriptWallet(BaseStorageWallet): def ux_policy(self): if self.taproot and self.policy: - return "Taproot tree keys:\n\n" + self.policy + return "Tapscript:\n\n" + self.policy return self.policy - async def _detail(self, new_wallet=False, is_duplicate=False): + async def _detail(self, new_wallet=False, is_duplicate=False, short=False): s = addr_fmt_label(self.addr_fmt) + "\n\n" if self.taproot: - s += self.taproot_internal_key_detail() + s += self.taproot_internal_key_detail(short=short) s += self.ux_policy() @@ -248,7 +252,7 @@ class MiniScriptWallet(BaseStorageWallet): story += ", OK to approve, X to cancel." return story - async def show_detail(self, new_wallet=False, duplicates=None): + async def show_detail(self, new_wallet=False, duplicates=None, short=False): title = self.name story = "" if duplicates: @@ -257,7 +261,7 @@ class MiniScriptWallet(BaseStorageWallet): elif new_wallet: title = None story += "Create new miniscript wallet?\n\nWallet Name:\n %s\n\n" % self.name - story += await self._detail(new_wallet, is_duplicate=duplicates) + story += await self._detail(new_wallet, is_duplicate=duplicates, short=short) while True: ch = await ux_show_story(story, title=title, escape="1") if ch == "1": @@ -268,13 +272,24 @@ class MiniScriptWallet(BaseStorageWallet): else: return True - def taproot_internal_key_detail(self): + def taproot_internal_key_detail(self, short=False): 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 + note = "provably unspendable" + if short: + s += note + else: + if isinstance(key.node, bytes): + s += b2a_hex(key.node).decode() + s += "\n (%s)" % note + else: + s += self.key + if type(key) is Key: + # it is unspendable, BUT not unspend( + s += "\n (%s)" % note + s += "\n\n" else: xfp, deriv, xpub = key.to_cc_data() s += '%s:\n %s\n\n%s/%s\n\n' % (xfp2str(xfp), deriv, xpub, @@ -373,14 +388,14 @@ class MiniScriptWallet(BaseStorageWallet): if d.tapscript: yield (idx, addr, - [str(k.origin) for k in d.keys], + ["[%s]" % str(k.origin) for k in d.keys], script, d.key.serialize(), str(d.key.origin) if d.key.origin else "") else: yield (idx, addr, - [str(k.origin) for k in d.keys], + ["[%s]" % str(k.origin) for k in d.keys], script, None, None) @@ -393,40 +408,39 @@ class MiniScriptWallet(BaseStorageWallet): addrs = [] - for i, addr, paths, _, ik, ikp in self.yield_addresses(start, n, - change=bool(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: + for idx, addr, paths, _, ik, _ in self.yield_addresses(start, n, + change=bool(change), + scripts=False): + if idx == 0 and len(paths) <= 4 and not ik: msg += '\n'.join(paths) + '\n =>\n' else: change_idx = set([int(p.split("/")[-2]) for p in paths]) if len(change_idx) == 1: - msg += '.../%d/%d =>\n' % (list(change_idx)[0], i) + msg += '.../%d/%d =>\n' % (list(change_idx)[0], idx) else: - msg += '.../%d =>\n' % i + msg += '.../%d =>\n' % idx addrs.append(addr) msg += truncate_address(addr) + '\n\n' - dis.progress_bar_show(i / n) + dis.progress_sofar(idx - start + 1, n) return msg, addrs def generate_address_csv(self, start, n, change): - scr_h = "Taptree" if self.desc.taproot else "Script" + part = [] + if self.taproot: + scr_h = "Taptree" + if self.desc.key.is_provably_unspendable: + part = ["Unspendable Internal Key"] + else: + part = ["Internal Key"] + + else: + scr_h = "Script" + yield '"' + '","'.join( ['Index', 'Payment Address', scr_h] + ['Derivation'] * len(self.keys) - + (["Internal Key"] if self.taproot else []) + + part ) + '"\n' for (idx, addr, derivs, script, ik, ikp) in self.yield_addresses(start, n, change=bool(change)): @@ -434,7 +448,10 @@ class MiniScriptWallet(BaseStorageWallet): ln += '","'.join(derivs) if ik: # internal xonly key with its derivation (if any) - ln += '","%s' % (ikp + b2a_hex(ik).decode()) + if ikp: + ln += '","[%s]%s' % (ikp, b2a_hex(ik).decode()) + else: + ln += '","%s' % (b2a_hex(ik).decode()) ln += '"\n' yield ln @@ -443,20 +460,28 @@ class MiniScriptWallet(BaseStorageWallet): # this will become legacy one day # instead use <0;1> descriptor format res = [] - for external, internal in [(True, False), (False, True)]: + for external in (True, False): desc_obj = { - "desc": self.to_string(external, internal), + "desc": self.to_string(external, not external, unspend_compat=True), "active": True, "timestamp": "now", - "internal": internal, + "internal": not external, "range": [0, 100], } res.append(desc_obj) return res - def to_string(self, external=True, internal=True, checksum=True): + def to_string(self, external=True, internal=True, checksum=True, unspend_compat=False): if self._key: key = self._key + if "unspend(" in key and unspend_compat: + # for bitcoin core that does not support 'unspend(' descriptor notation + # serialize 'unspend(' as classic extended key + k = Key.from_string(self.key) + key = k.extended_public_key() + if k.derivation: + key += "/" + k.derivation.to_string(external, internal) + multipath_rgx = ure.compile(r"<\d+;\d+>") match = multipath_rgx.search(key) if match: @@ -508,7 +533,7 @@ class MiniScriptWallet(BaseStorageWallet): fname_pattern = fname_pattern + ".txt" if core: - msg = "importdescriptor cmd" + msg = "importdescriptors cmd" dis.fullscreen('Wait...') core_obj = self.bitcoin_core_serialize() core_str = ujson.dumps(core_obj) @@ -605,7 +630,7 @@ async def miniscript_wallet_detail(menu, label, item): msc = item.arg - return await msc.show_detail() + return await msc.show_detail(short=True) async def import_miniscript(*a): # pick text file from SD card, import as multisig setup file @@ -851,6 +876,9 @@ class Miniscript: # cannot have same keys in single miniscript forbiden = (Sortedmulti_a, Multi_a) keys = self.keys + # provably unspendable taproot internal key is not covered here + # all other keys (miniscript,tapscript) require key origin info + assert all(k.origin for k in keys), "Key origin info is required" assert len(keys) == len(set(keys)), "Insane" if taproot: forbiden = (Sortedmulti, Multi) diff --git a/shared/psbt.py b/shared/psbt.py index a436e133..d9b5d22b 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -5,7 +5,7 @@ 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 seconds2human_readable, datetime_from_timestamp, datetime_to_str +from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, problem_file_line import stash, gc, history, sys, ngu, ckcc, chains from uhashlib import sha256 from uio import BytesIO @@ -2243,7 +2243,9 @@ class psbtObject(psbtProxy): if inp.taproot_subpaths: # this can be set to False even if we haev script ready, but can send keypath # tapscript schnorrsig = True - xfp_paths = [item[1:] for item in inp.taproot_subpaths.values() if item[0]] + # previously internal keys would be filtered here with if item[0] + # as per BIP-371 first item is leaf hashes which has to be empty for internal key + xfp_paths = [item[1:] for item in inp.taproot_subpaths.values()] int_path = inp.taproot_subpaths[which_key][1:] skp = keypath_to_str(int_path) else: diff --git a/stm32/MK4-Makefile b/stm32/MK4-Makefile index d13a2111..78bb49ca 100644 --- a/stm32/MK4-Makefile +++ b/stm32/MK4-Makefile @@ -19,7 +19,7 @@ LATEST_RELEASE = $(shell ls -t1 ../releases/*-mk4-*.dfu | head -1) # Our version for this release. # - caution, the bootrom will not accept version < 3.0.0 -VERSION_STRING = 5.3.2X +VERSION_STRING = 6.3.3X # keep near top, because defined default target (all) include shared.mk diff --git a/stm32/Q1-Makefile b/stm32/Q1-Makefile index dcf0660a..16a854a2 100644 --- a/stm32/Q1-Makefile +++ b/stm32/Q1-Makefile @@ -16,7 +16,7 @@ BOOTLOADER_DIR = q1-bootloader LATEST_RELEASE = $(shell ls -t1 ../releases/*-q1-*.dfu | head -1) # Our version for this release. -VERSION_STRING = 1.2.2QX +VERSION_STRING = 6.3.3QX # Remove this closer to shipping. #$(warning "Forcing debug build") diff --git a/testing/bip32.py b/testing/bip32.py index e09cf087..d52867dc 100644 --- a/testing/bip32.py +++ b/testing/bip32.py @@ -737,6 +737,12 @@ class BIP32Node: ek = PubKeyNode.parse(extended_key, testnet) return cls(ek, netcode="XTN" if testnet else "BTC") + @classmethod + def from_chaincode_pubkey(cls, chain_code, pubkey, netcode="XTN"): + node = PubKeyNode(pubkey, chain_code, 0, 0, + False if netcode == "BTC" else True) + return cls(node, netcode=netcode) + def subkey_for_path(self, path): path_list = str_to_path(path) node = self.node diff --git a/testing/conftest.py b/testing/conftest.py index 0f8219bd..3cdb835e 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1697,7 +1697,7 @@ def load_shared_mod(): return doit @pytest.fixture -def verify_detached_signature_file(microsd_path, virtdisk_path): +def verify_detached_signature_file(microsd_path, virtdisk_path, garbage_collector): def doit(fnames, sig_fname, way, addr_fmt=None): fpaths = [] for fname in fnames: @@ -1706,6 +1706,7 @@ def verify_detached_signature_file(microsd_path, virtdisk_path): else: path = virtdisk_path(fname) fpaths.append(path) + garbage_collector.append(path) if way == "sd": sig_path = microsd_path(sig_fname) @@ -1746,9 +1747,7 @@ def verify_detached_signature_file(microsd_path, virtdisk_path): assert (hashlib.sha256(contents).digest().hex() + fn_addendum) in msg assert verify_message(address, sig, msg) is True - try: - os.unlink(sig_path) - except: pass + garbage_collector.append(sig_path) return fcontents[0], address return doit @@ -1782,7 +1781,7 @@ def load_export_and_verify_signature(microsd_path, virtdisk_path, verify_detache @pytest.fixture def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_text, nfc_read_json, load_export_and_verify_signature, is_q1, press_cancel, press_select, readback_bbqr, - cap_screen_qr): + cap_screen_qr, garbage_collector): def doit(way, label, is_json, sig_check=True, addr_fmt=AF_CLASSIC, ret_sig_addr=False, tail_check=None, sd_key=None, vdisk_key=None, nfc_key=None, ret_fname=False, fpattern=None, qr_key=None, skip_query=False): @@ -1874,6 +1873,8 @@ def load_export(need_keypress, cap_story, microsd_path, virtdisk_path, nfc_read_ if is_json: export = json.loads(export) + garbage_collector.append(path) + press_select() if ret_sig_addr and sig_addr: @@ -2147,6 +2148,9 @@ def txout_explorer(cap_story, press_cancel, need_keypress, is_q1): elif af in ("p2wpkh", "p2wsh"): target = "bc1q" if chain == "BTC" else "tb1q" assert addr.startswith(target) + elif af == "p2tr": + target = "bc1p" if chain == "BTC" else "tb1p" + assert addr.startswith(target) elif af in ("p2sh", "p2wpkh-p2sh", "p2wsh-p2sh"): target = "3" if chain == "BTC" else "2" assert addr.startswith(target) @@ -2229,6 +2233,15 @@ def skip_if_useless_way(is_q1, nfc_disabled): return doit +@pytest.fixture +def garbage_collector(): + to_remove = [] + yield to_remove + for pth in to_remove: + try: + os.remove(pth) + except: pass + # useful fixtures from test_backup import backup_system from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll diff --git a/testing/test_bsms.py b/testing/test_bsms.py index b6aa08e5..6a296a32 100644 --- a/testing/test_bsms.py +++ b/testing/test_bsms.py @@ -269,11 +269,16 @@ def make_coordinator_round2(make_coordinator_round1, settings_get, settings_set, @pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) -def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, - cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, - settings_get, virtdisk_wipe, microsd_wipe, press_select, is_q1): +def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, + pick_menu_item, cap_menu, cap_story, microsd_path, settings_remove, + nfc_read_text, request, settings_get, microsd_wipe, press_select, is_q1): + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() + M, N = M_N - virtdisk_wipe() + microsd_wipe() settings_remove(BSMS_SETTINGS) # clear bsms goto_home() @@ -419,11 +424,15 @@ def test_coordinator_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_ @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) def test_signer_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, cap_menu, - cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, settings_get, - make_coordinator_round1, nfc_write_text, virtdisk_wipe, microsd_wipe, press_select, + cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, + make_coordinator_round1, nfc_write_text, microsd_wipe, press_select, is_q1): + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() + M, N = M_N - virtdisk_wipe() microsd_wipe() tokens = make_coordinator_round1(M, N, addr_fmt, encryption_type, way) if encryption_type != "3": @@ -572,9 +581,9 @@ def test_signer_round1(way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) @pytest.mark.parametrize("auto_collect", [True, False]) def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, clear_ms, goto_home, need_keypress, - cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, + cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, make_coordinator_round1, make_signer_round1, nfc_write_text, - virtdisk_wipe, microsd_wipe, pick_menu_item, press_select, is_q1): + microsd_wipe, pick_menu_item, press_select, is_q1): def get_token(index): if len(tokens) == 1 and encryption_type == "1": token = tokens[0] @@ -584,8 +593,12 @@ def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, c token = "00" return token + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() + M, N = M_N - virtdisk_wipe() microsd_wipe() tokens = make_coordinator_round1(M, N, addr_fmt, encryption_type, way=way, tokens_only=True) all_data = [] @@ -793,12 +806,15 @@ def test_coordinator_round2(way, encryption_type, M_N, addr_fmt, auto_collect, c @pytest.mark.parametrize("M_N", [(2,2), (3, 5), (15, 15)]) @pytest.mark.parametrize("addr_fmt", ["p2wsh", "p2sh-p2wsh"]) def test_signer_round2(refuse, way, encryption_type, M_N, addr_fmt, clear_ms, goto_home, need_keypress, pick_menu_item, - cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, settings_get, - make_coordinator_round2, nfc_write_text, virtdisk_wipe, microsd_wipe, with_checksum, + cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, request, settings_get, + make_coordinator_round2, nfc_write_text, microsd_wipe, with_checksum, press_select, press_cancel, is_q1): + if way == "vdisk": + virtdisk_wipe = request.getfixturevalue("virtdisk_wipe") + virtdisk_path = request.getfixturevalue("virtdisk_path") + virtdisk_wipe() M, N = M_N clear_ms() - virtdisk_wipe() microsd_wipe() desc_template, token = make_coordinator_round2(M, N, addr_fmt, encryption_type, way=way, add_checksum=with_checksum) goto_home() @@ -950,9 +966,8 @@ def test_invalid_token_signer_round1(token, way, pick_menu_item, cap_story, need @pytest.mark.parametrize("failure", ["slip", "wrong_sig", "bsms_version"]) @pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) def test_failure_coordinator_round2(encryption_type, make_coordinator_round1, make_signer_round1, microsd_wipe, cap_menu, - virtdisk_wipe, pick_menu_item, press_select, goto_home, cap_story, failure, + pick_menu_item, press_select, goto_home, cap_story, failure, need_keypress): - virtdisk_wipe() microsd_wipe() def get_token(index): @@ -1025,7 +1040,7 @@ def test_failure_coordinator_round2(encryption_type, make_coordinator_round1, ma # TODO do this for NFC too when length requirements are lifted from 250 @pytest.mark.parametrize("encryption_type", ["1", "2"]) def test_wrong_encryption_coordinator_round2(encryption_type, make_coordinator_round1, make_signer_round1, microsd_wipe, - cap_menu, virtdisk_wipe, pick_menu_item, need_keypress, goto_home, cap_story, + cap_menu, pick_menu_item, need_keypress, goto_home, cap_story, press_cancel, press_select): def get_token(index): if len(tokens) == 1 and encryption_type == "1": @@ -1036,7 +1051,6 @@ def test_wrong_encryption_coordinator_round2(encryption_type, make_coordinator_r token = "00" return token - virtdisk_wipe() microsd_wipe() tokens = make_coordinator_round1(2, 2, "p2wsh", encryption_type, way="sd", tokens_only=True) for i in range(2): @@ -1103,8 +1117,7 @@ def test_wrong_encryption_coordinator_round2(encryption_type, make_coordinator_r @pytest.mark.parametrize("encryption_type", ["1", "2", "3"]) def test_failure_signer_round2(encryption_type, goto_home, press_select, pick_menu_item, cap_menu, cap_story, microsd_path, settings_remove, nfc_read_text, virtdisk_path, settings_get, microsd_wipe, - make_coordinator_round2, virtdisk_wipe, failure, need_keypress): - virtdisk_wipe() + make_coordinator_round2, failure, need_keypress): microsd_wipe() if failure == "wrong_address": kws = {failure: True} diff --git a/testing/test_miniscript.py b/testing/test_miniscript.py index 5032504d..3ae30c56 100644 --- a/testing/test_miniscript.py +++ b/testing/test_miniscript.py @@ -6,8 +6,9 @@ import pytest, json, time, itertools, struct, random, os from ckcc.protocol import CCProtocolPacker from constants import AF_P2TR from psbt import BasicPSBT -from charcodes import KEY_QR, KEY_NFC, KEY_RIGHT, KEY_CANCEL +from charcodes import KEY_QR, KEY_RIGHT, KEY_CANCEL from bbqr import split_qrs +from bip32 import BIP32Node H = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" # BIP-0341 @@ -25,6 +26,14 @@ TREE = { } +def ranged_unspendable_internal_key(chain_code=32 * b"\x01", subderiv="/<0;1>/*"): + # provide ranged provably unspendable key in serialized extended key format for core to understand it + # core does NOT understand 'unspend(' + pk = b"\x02" + bytes.fromhex(H) + node = BIP32Node.from_chaincode_pubkey(chain_code, pk) + return node.hwif() + subderiv + + @pytest.fixture def offer_minsc_import(cap_story, dev): def doit(config, allow_non_ascii=False): @@ -43,7 +52,7 @@ def offer_minsc_import(cap_story, dev): @pytest.fixture def import_miniscript(goto_home, pick_menu_item, cap_story, need_keypress, - nfc_write_text, press_select, scan_a_qr): + nfc_write_text, press_select, scan_a_qr, press_nfc): def doit(fname, way="sd", data=None): goto_home() pick_menu_item('Settings') @@ -55,7 +64,7 @@ def import_miniscript(goto_home, pick_menu_item, cap_story, need_keypress, if "via NFC" not in story: pytest.skip("nfc disabled") - need_keypress(KEY_NFC) + press_nfc() time.sleep(.1) if isinstance(data, dict): data = json.dumps(data) @@ -111,6 +120,7 @@ def import_duplicate(import_miniscript, press_cancel, virtdisk_path, microsd_pat if way == "vdisk": path_f = virtdisk_path + time.sleep(.2) title, story = import_miniscript(fname, way, data=data) if "unique names" in story: # trying to import duplicate with same name @@ -129,6 +139,7 @@ def import_duplicate(import_miniscript, press_cancel, virtdisk_path, microsd_pat f.write(res) title, story = import_miniscript(new_fname, way, data=data) + time.sleep(.2) assert "duplicate of already saved wallet" in story assert "OK to approve" not in story @@ -141,8 +152,11 @@ def import_duplicate(import_miniscript, press_cancel, virtdisk_path, microsd_pat @pytest.fixture def miniscript_descriptors(goto_home, pick_menu_item, need_keypress, cap_story, - microsd_path, is_q1, readback_bbqr, cap_screen_qr): + microsd_path, is_q1, readback_bbqr, cap_screen_qr, + garbage_collector): + def doit(minsc_name): + qr_external = None goto_home() pick_menu_item("Settings") pick_menu_item("Miniscript") @@ -150,6 +164,7 @@ def miniscript_descriptors(goto_home, pick_menu_item, need_keypress, cap_story, pick_menu_item("Descriptors") pick_menu_item("Export") need_keypress("1") # internal and external separately + time.sleep(.1) if is_q1: # check QR need_keypress(KEY_QR) @@ -163,9 +178,10 @@ def miniscript_descriptors(goto_home, pick_menu_item, need_keypress, cap_story, qr_external, qr_internal = data.split("\n") need_keypress(KEY_CANCEL) - pick_menu_item("Export") - need_keypress("1") # internal and external separately - time.sleep(.2) + 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") @@ -174,13 +190,16 @@ def miniscript_descriptors(goto_home, pick_menu_item, need_keypress, cap_story, assert "Miniscript file written" in story fname = story.split("\n\n")[-1] - with open(microsd_path(fname), "r") as f: + fpath = microsd_path(fname) + garbage_collector.append(fpath) + with open(fpath, "r") as f: cont = f.read() external, internal = cont.split("\n") if qr_external: assert qr_external == external assert qr_internal == internal return external, internal + return doit @@ -265,10 +284,7 @@ def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, 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 + assert "Taproot internal key" not in story if way == "qr": need_keypress(KEY_QR) @@ -281,14 +297,13 @@ def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, else: contents = load_export(way, label="Address summary", is_json=False, sig_check=False) addr_cont = contents.strip() - # time.sleep(5) time.sleep(.5) title, story = cap_story() assert "(0)" in story assert "change addresses." in story need_keypress("0") - time.sleep(5) + time.sleep(.5) title, story = cap_story() assert "(0)" not in story assert "change addresses." not in story @@ -320,7 +335,10 @@ def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, 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] + try: + assert "Internal Key" in cc_addrs_split[0] + except AssertionError: + assert "Unspendable Internal Key" in cc_addrs_split[0] assert "Taptree" in cc_addrs_split[0] else: assert "Internal Key" not in cc_addrs_split[0] @@ -343,6 +361,26 @@ def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, if export_check: cc_external, cc_internal = miniscript_descriptors(cc_minsc_name) + + unspend = "unspend(" + if unspend in cc_external: + assert "unspend(" in cc_internal + netcode = "XTN" if "tpub" in cc_external else "BTC" + # bitcoin core does not recognize unspend( - needs hack + # CC properly exports any imported unspend( for bitcoin core + # as extended key serialization xpub/<0;1>/* + start_idx = cc_external.find(unspend) + assert start_idx != -1 + end_idx = start_idx + len(unspend) + 64 + 1 + uns = cc_external[start_idx: end_idx] + chain_code = bytes.fromhex(uns[len(unspend):-1]) + node = BIP32Node.from_chaincode_pubkey(chain_code, + b"\x02" + bytes.fromhex(H), + netcode=netcode) + ek = node.hwif() + cc_external = cc_external.replace(uns, ek) + cc_internal = cc_internal.replace(uns, ek) + assert cc_external.split("#")[0] == external_desc.split("#")[0].replace("'", "h") assert cc_internal.split("#")[0] == internal_desc.split("#")[0].replace("'", "h") @@ -400,7 +438,8 @@ def test_liana_miniscripts_simple(addr_fmt, recovery, lt_type, minisc, clear_min use_regtest, bitcoind, microsd_wipe, load_export, dev, address_explorer_check, get_cc_key, import_miniscript, bitcoin_core_signer, import_duplicate, press_select, - virtdisk_path): + virtdisk_path, skip_if_useless_way, garbage_collector): + skip_if_useless_way(way) normal_cosign_core = False recovery_cosign_core = False if "multi(" in minisc.split("),", 1)[0]: @@ -445,6 +484,7 @@ def test_liana_miniscripts_simple(addr_fmt, recovery, lt_type, minisc, clear_min use_regtest() clear_miniscript() + goto_home() name = "core-miniscript" fname = f"{name}.txt" if way in ["qr", "nfc"]: @@ -453,11 +493,12 @@ def test_liana_miniscripts_simple(addr_fmt, recovery, lt_type, minisc, clear_min path_f = microsd_path if way == "sd" else virtdisk_path data = None fpath = path_f(fname) + garbage_collector.append(fpath) 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) + passphrase=None, avoid_reuse=False, descriptors=True) _, story = import_miniscript(fname, way=way, data=data) try: @@ -506,8 +547,10 @@ def test_liana_miniscripts_simple(addr_fmt, recovery, lt_type, minisc, clear_min psbt = signer1.walletprocesspsbt(psbt, True, "ALL")["psbt"] name = f"{name}.psbt" - with open(microsd_path(name), "w") as f: + fpath = microsd_path(name) + with open(fpath, "w") as f: f.write(psbt) + garbage_collector.append(fpath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -527,8 +570,10 @@ def test_liana_miniscripts_simple(addr_fmt, recovery, lt_type, minisc, clear_min press_select() fname_psbt = story.split("\n\n")[1] # fname_txn = story.split("\n\n")[3] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) # with open(microsd_path(fname_txn), "r") as f: # final_txn = f.read().strip() res = wo.finalizepsbt(final_psbt) @@ -563,9 +608,12 @@ def test_liana_miniscripts_complex(addr_fmt, minsc, bitcoind, use_regtest, clear microsd_path, pick_menu_item, cap_story, load_export, goto_home, address_explorer_check, cap_menu, get_cc_key, import_miniscript, bitcoin_core_signer, - import_duplicate, press_select, way): + import_duplicate, press_select, way, skip_if_useless_way, + garbage_collector): + skip_if_useless_way(way) use_regtest() clear_miniscript() + goto_home() minsc, to_gen = minsc signer_keys = minsc.count("@") @@ -617,6 +665,8 @@ def test_liana_miniscripts_complex(addr_fmt, minsc, bitcoind, use_regtest, clear with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, passphrase=None, avoid_reuse=False, descriptors=True) _, story = import_miniscript(fname, way=way, data=data) @@ -663,8 +713,10 @@ def test_liana_miniscripts_complex(addr_fmt, minsc, bitcoind, use_regtest, clear psbt = s.walletprocesspsbt(psbt, True, "ALL")["psbt"] pname = f"{name}.psbt" - with open(microsd_path(pname), "w") as f: + ppath = microsd_path(pname) + with open(ppath, "w") as f: f.write(psbt) + garbage_collector.append(ppath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -684,8 +736,10 @@ def test_liana_miniscripts_complex(addr_fmt, minsc, bitcoind, use_regtest, clear press_select() fname_psbt = story.split("\n\n")[1] # fname_txn = story.split("\n\n")[3] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) # with open(microsd_path(fname_txn), "r") as f: # final_txn = f.read().strip() res = wo.finalizepsbt(final_psbt) @@ -714,7 +768,7 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, pick_menu_item, goto_home, cap_menu, microsd_path, use_regtest, get_cc_key, import_miniscript, bitcoin_core_signer, import_duplicate, press_select, - virtdisk_path): + virtdisk_path, garbage_collector): def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, r=None, tapscript_threshold=False, add_own_pk=False, same_account=False, way="sd"): @@ -811,8 +865,10 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, data = None fname = f"{name}.txt" path_f = microsd_path if way == 'sd' else virtdisk_path - with open(path_f(fname), "w") as f: + fpath = path_f(fname) + with open(fpath, "w") as f: f.write(desc + "\n") + garbage_collector.append(fpath) else: data = dict(name=name, desc=desc) @@ -821,7 +877,7 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, assert name in story if script_type == "p2tr": assert "Taproot internal key" in story - assert "Taproot tree keys" in story + assert "Tapscript" in story assert "Press (1) to see extended public keys" in story if script_type == "p2wsh": assert "P2WSH" in story @@ -903,18 +959,21 @@ def bitcoind_miniscript(bitcoind, need_keypress, cap_story, load_export, @pytest.mark.bitcoind @pytest.mark.parametrize("cc_first", [True, False]) @pytest.mark.parametrize("add_pk", [True, False]) -@pytest.mark.parametrize("same_acct", [True, False]) +@pytest.mark.parametrize("same_acct", [None, True, False]) @pytest.mark.parametrize("way", ["qr", "sd"]) @pytest.mark.parametrize("M_N", [(3,4),(4,5),(5,6)]) def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, cap_menu, cap_story, microsd_path, use_regtest, bitcoind, microsd_wipe, load_export, bitcoind_miniscript, add_pk, same_acct, get_cc_key, - press_select, way): + press_select, way, skip_if_useless_way, garbage_collector): + skip_if_useless_way(way) M, N = M_N clear_miniscript() microsd_wipe() internal_key = None - if same_acct: + if same_acct is None: + internal_key = ranged_unspendable_internal_key() + elif same_acct: # provide internal key with same account derivation (change based derivation) internal_key = get_cc_key("m/86h/1h/0h", subderiv='/<10;11>/*') @@ -929,8 +988,12 @@ def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, 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: + + psbt_fpath = microsd_path("ts_tree.psbt") + with open(psbt_fpath, "w") as f: f.write(psbt) + + garbage_collector.append(psbt_fpath) time.sleep(2) goto_home() pick_menu_item("Ready To Sign") @@ -947,8 +1010,10 @@ def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, title, story = cap_story() assert title == "PSBT Signed" fname = [i for i in story.split("\n\n") if ".psbt" in i][0] - with open(microsd_path(fname), "r") as f: + fpath = microsd_path(fname) + with open(fpath, "r") as f: psbt = f.read().strip() + garbage_collector.append(fpath) if cc_first: # we MUST be able to finalize this without anyone else if add pk if not add_pk: @@ -967,14 +1032,23 @@ def test_tapscript(M_N, cc_first, clear_miniscript, goto_home, pick_menu_item, @pytest.mark.parametrize("add_pk", [True, False]) @pytest.mark.parametrize('M_N', [(3, 15), (2, 2), (3, 5)]) @pytest.mark.parametrize('way', ["qr", "sd", "vdisk", "nfc"]) +@pytest.mark.parametrize('internal_type', ["unspend(", "xpub", "static"]) def test_bitcoind_tapscript_address(M_N, clear_miniscript, bitcoind_miniscript, use_regtest, way, csa, address_explorer_check, - add_pk): + add_pk, internal_type, skip_if_useless_way): + skip_if_useless_way(way) use_regtest() clear_miniscript() M, N = M_N + + ik = None # default static + if internal_type == "unspend(": + ik = f"unspend({os.urandom(32).hex()})/<20;21>/*" + elif internal_type == "xpub": + ik = ranged_unspendable_internal_key(os.urandom(32)) + ms_wo, _ = bitcoind_miniscript(M, N, "p2tr", funded=False, tapscript_threshold=csa, - add_own_pk=add_pk, way=way) + add_own_pk=add_pk, way=way, internal_key=ik) address_explorer_check(way, "bech32m", ms_wo, "minisc") @@ -982,10 +1056,19 @@ def test_bitcoind_tapscript_address(M_N, clear_miniscript, bitcoind_miniscript, @pytest.mark.parametrize("cc_first", [True, False]) @pytest.mark.parametrize("m_n", [(2,2), (3, 5), (32, 32)]) @pytest.mark.parametrize("way", ["qr", "sd"]) -@pytest.mark.parametrize("internal_key_spendable", [True, False, "77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76", "@"]) +@pytest.mark.parametrize("internal_key_spendable", [ + True, + False, + "77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76", + "@", + "tpubD6NzVbkrYhZ4WhUnV3cPSoRWGf9AUdG2dvNpsXPiYzuTnxzAxemnbajrATDBWhaAVreZSzoGSe3YbbkY2K267tK3TrRmNiLH2pRBpo8yaWm/<2;3>/*", + "unspend(c72231504cf8c1bbefa55974db4e0cdac781049a9a81a87e7ff5beeb45b34d3d)/<0;1>/*" +]) def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, bitcoind, goto_home, cap_menu, pick_menu_item, cap_story, microsd_path, load_export, microsd_wipe, dev, way, - bitcoind_miniscript, clear_miniscript, get_cc_key, press_cancel, press_select): + bitcoind_miniscript, clear_miniscript, get_cc_key, press_cancel, press_select, + skip_if_useless_way, garbage_collector): + skip_if_useless_way(way) M, N = m_n clear_miniscript() microsd_wipe() @@ -993,10 +1076,13 @@ def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, 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 = "@" + elif isinstance(internal_key_spendable, str): + if len(internal_key_spendable) == 64: + r = internal_key_spendable + else: + internal_key = internal_key_spendable tapscript_wo, bitcoind_signers = bitcoind_miniscript( M, N, "p2tr", internal_key=internal_key, r=r, @@ -1011,8 +1097,12 @@ def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, 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: + + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(psbt) + + garbage_collector.append(fpath) goto_home() # bug in goto_home ? press_cancel() @@ -1036,14 +1126,18 @@ def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, 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: + txn_fpath = microsd_path(signed_txn_fname) + with open(txn_fpath, "r") as f: signed_txn = f.read().strip() + garbage_collector.append(txn_fpath) else: signed_fname = split_story[-1] - with open(microsd_path(signed_fname), "r") as f: + fpath = microsd_path(signed_fname) + with open(fpath, "r") as f: signed_psbt = f.read().strip() + garbage_collector.append(fpath) if cc_first: for signer in bitcoind_signers: signed_psbt = signer.walletprocesspsbt(signed_psbt, True, "DEFAULT", True)["psbt"] @@ -1064,7 +1158,8 @@ def test_tapscript_multisig(cc_first, m_n, internal_key_spendable, use_regtest, def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bitcoind, internal_key_spendable, dev, microsd_path, get_cc_key, pick_menu_item, cap_story, goto_home, cap_menu, load_export, - import_miniscript, bitcoin_core_signer, import_duplicate, press_select): + import_miniscript, bitcoin_core_signer, import_duplicate, + press_select, garbage_collector): use_regtest() clear_miniscript() microsd_wipe() @@ -1095,13 +1190,16 @@ def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bi ) fname = "ts_pk.txt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc + "\n") + + garbage_collector.append(fpath) _, story = import_miniscript(fname) 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 "Tapscript" in story assert "Press (1) to see extended public keys" in story assert "P2TR" in story @@ -1133,9 +1231,12 @@ def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bi 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: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(psbt) + garbage_collector.append(fpath) + goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -1155,8 +1256,11 @@ def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bi press_select() fname_psbt = story.split("\n\n")[1] # fname_txn = story.split("\n\n")[3] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() + + garbage_collector.append(fpath_psbt) # with open(microsd_path(fname_txn), "r") as f: # final_txn = f.read().strip() res = ts.finalizepsbt(final_psbt) @@ -1172,8 +1276,10 @@ def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bi @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(tpubD6NzVbkrYhZ4XB7hZjurMYsPsgNY32QYGZ8YFVU7cy1VBRNoYpKAVuUfqfUFss6BooXRrCeYAdK9av2yFnqWXZaUMJuZdpE9Kuh6gubCVHu/<0;1>/*,{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)))})", + "tr(unspend(b320077905d0954b01a8a328ea08c0ac3b4b066d1240f47a1b2c58651dcda4eb)/<0;1>/*,{{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, import_miniscript, load_export, desc, microsd_path, @@ -1198,8 +1304,8 @@ def test_tapscript_import_export(clear_miniscript, pick_menu_item, cap_story, def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, bitcoind, dev, - goto_home, pick_menu_item, microsd_path, - cap_story, load_export, get_cc_key, import_miniscript, + goto_home, pick_menu_item, microsd_path, import_miniscript, + cap_story, load_export, get_cc_key, garbage_collector, bitcoin_core_signer, import_duplicate, press_select): # works in core - but some discussions are ongoing # https://github.com/bitcoin/bitcoin/issues/27104 @@ -1216,14 +1322,17 @@ def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, tmplt = tmplt % (cc_leaf, cc_leaf) desc = f"tr({core_key},{tmplt})" fname = "dup_leafs.txt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) + _, story = import_miniscript(fname) 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 "Tapscript" in story assert "Press (1) to see extended public keys" in story assert "P2TR" in story @@ -1259,9 +1368,10 @@ def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, 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: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(psbt) - + garbage_collector.append(fpath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -1281,8 +1391,10 @@ def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, press_select() fname_psbt = story.split("\n\n")[1] # fname_txn = story.split("\n\n")[3] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) # with open(microsd_path(fname_txn), "r") as f: # final_txn = f.read().strip() res = ts.finalizepsbt(final_psbt) @@ -1298,7 +1410,7 @@ def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, def test_same_key_account_based_minisc(goto_home, pick_menu_item, cap_story, clear_miniscript, microsd_path, load_export, bitcoind, import_miniscript, use_regtest, import_duplicate, - press_select): + press_select, garbage_collector): clear_miniscript() use_regtest() @@ -1310,8 +1422,10 @@ def test_same_key_account_based_minisc(goto_home, pick_menu_item, cap_story, name = "mini-accounts" fname = f"{name}.txt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) _, story = import_miniscript(fname) assert "Create new miniscript wallet?" in story @@ -1350,9 +1464,10 @@ def test_same_key_account_based_minisc(goto_home, pick_menu_item, cap_story, 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: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(psbt) - + garbage_collector.append(fpath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -1372,8 +1487,10 @@ def test_same_key_account_based_minisc(goto_home, pick_menu_item, cap_story, press_select() fname_psbt = story.split("\n\n")[1] # fname_txn = story.split("\n\n")[3] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) _psbt = BasicPSBT().parse(final_psbt.encode()) assert len(_psbt.inputs[0].part_sigs) == 2 @@ -1434,7 +1551,7 @@ CHANGE_BASED_DESCS = [ def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, clear_miniscript, microsd_path, load_export, bitcoind, import_miniscript, address_explorer_check, use_regtest, - desc, press_select): + desc, press_select, garbage_collector): clear_miniscript() use_regtest() if desc.startswith("tr("): @@ -1444,8 +1561,10 @@ def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, name = "mini-change" fname = f"{name}.txt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) _, story = import_miniscript(fname) assert "Create new miniscript wallet?" in story @@ -1483,9 +1602,10 @@ def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, dest_addr = wo.getnewaddress("", af) # selfspend psbt = wo.walletcreatefundedpsbt([], [{dest_addr: 1.0}], 0, {"fee_rate": 2})["psbt"] fname = "msc-change-conso.psbt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(psbt) - + garbage_collector.append(fpath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -1504,8 +1624,10 @@ def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, assert "Updated PSBT is:" in story press_select() fname_psbt = story.split("\n\n")[1] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) res = wo.finalizepsbt(final_psbt) assert res["complete"] @@ -1526,9 +1648,10 @@ def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, 0, {"fee_rate": 2} )["psbt"] fname = "msc-change-send.psbt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(psbt) - + garbage_collector.append(fpath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -1547,9 +1670,10 @@ def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, assert "Updated PSBT is:" in story press_select() fname_psbt = story.split("\n\n")[1] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() - + garbage_collector.append(fpath_psbt) res = wo.finalizepsbt(final_psbt) assert res["complete"] tx_hex = res["hex"] @@ -1564,7 +1688,7 @@ def test_same_key_change_based_minisc(goto_home, pick_menu_item, cap_story, def test_same_key_account_based_multisig(goto_home, pick_menu_item, cap_story, clear_miniscript, microsd_path, load_export, bitcoind, - import_miniscript): + import_miniscript, garbage_collector): clear_miniscript() desc = ("wsh(sortedmulti(2," "[0f056943/84'/1'/0']tpubDC7jGaaSE66Pn4dgtbAAstde4bCyhSUs4r3P8WhMVvPByvcRrzrwqSvpF9Ghx83Z1LfVugGRrSBko5UEKELCz9HoMv5qKmGq3fqnnbS5E9r/<0;1>/*," @@ -1572,8 +1696,10 @@ def test_same_key_account_based_multisig(goto_home, pick_menu_item, cap_story, "))") name = "multi-accounts" fname = f"{name}.txt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) _, story = import_miniscript(fname) assert "Failed to import" in story @@ -1587,20 +1713,23 @@ def test_same_key_account_based_multisig(goto_home, pick_menu_item, cap_story, "tr(%s,or_d(pk(@A),and_v(v:pkh(@A),older(5))))" % H, ]) def test_insane_miniscript(get_cc_key, pick_menu_item, cap_story, - microsd_path, desc, import_miniscript): + microsd_path, desc, import_miniscript, + garbage_collector): 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: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) _, story = import_miniscript(fname) assert "Failed to import" in story assert "Insane" in story def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, - microsd_path, import_miniscript): + microsd_path, import_miniscript, garbage_collector): leaf_num = 9 scripts = [] for i in range(leaf_num): @@ -1610,8 +1739,10 @@ def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, tree = TREE[leaf_num] % tuple(scripts) desc = f"tr({H},{tree})" fname = "9leafs.txt" - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) _, story = import_miniscript(fname) assert "Failed to import" in story assert "num_leafs > 8" in story @@ -1621,6 +1752,7 @@ def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, @pytest.mark.parametrize("same_acct", [True, False]) @pytest.mark.parametrize("recovery", [True, False]) @pytest.mark.parametrize("leaf2_mine", [True, False]) +@pytest.mark.parametrize("internal_type", ["unspend(", "xpub", "static"]) @pytest.mark.parametrize("minisc", [ "or_d(pk(@A),and_v(v:pkh(@B),locktime(N)))", @@ -1631,10 +1763,11 @@ def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, "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, - pick_menu_item, cap_menu, cap_story, microsd_path, + pick_menu_item, cap_menu, cap_story, microsd_path, internal_type, use_regtest, bitcoind, microsd_wipe, load_export, dev, address_explorer_check, get_cc_key, import_miniscript, - bitcoin_core_signer, same_acct, import_duplicate, press_select): + bitcoin_core_signer, same_acct, import_duplicate, press_select, + garbage_collector): # needs bitcoind 26.0 normal_cosign_core = False @@ -1683,10 +1816,16 @@ def test_minitapscript(leaf2_mine, recovery, lt_type, minisc, clear_miniscript, if "@C" in minisc: minisc = minisc.replace("@C", core_keys[1]) + ik = H + if internal_type == "unspend(": + ik = f"unspend({os.urandom(32).hex()})/<2;3>/*" + elif internal_type == "xpub": + ik = ranged_unspendable_internal_key(os.urandom(32)) + if leaf2_mine: - desc = f"tr({H},{{{minisc},pk({cc_key1})}})" + desc = f"tr({ik},{{{minisc},pk({cc_key1})}})" else: - desc = f"tr({H},{{pk({core_keys[2]}),{minisc}}})" + desc = f"tr({ik},{{pk({core_keys[2]}),{minisc}}})" use_regtest() clear_miniscript() @@ -1696,6 +1835,8 @@ def test_minitapscript(leaf2_mine, recovery, lt_type, minisc, clear_miniscript, with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) + wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, passphrase=None, avoid_reuse=False, descriptors=True) @@ -1741,8 +1882,10 @@ def test_minitapscript(leaf2_mine, recovery, lt_type, minisc, clear_miniscript, psbt = signers[1].walletprocesspsbt(psbt, True, "ALL")["psbt"] name = f"{name}.psbt" - with open(microsd_path(name), "w") as f: + fpath = microsd_path(name) + with open(fpath, "w") as f: f.write(psbt) + garbage_collector.append(fpath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -1762,8 +1905,10 @@ def test_minitapscript(leaf2_mine, recovery, lt_type, minisc, clear_miniscript, press_select() fname_psbt = story.split("\n\n")[1] # fname_txn = story.split("\n\n")[3] + fpath_psbt = microsd_path(fname_psbt) with open(microsd_path(fname_psbt), "r") as f: final_psbt = f.read().strip() + garbage_collector.append(fpath) # with open(microsd_path(fname_txn), "r") as f: # final_txn = f.read().strip() res = wo.finalizepsbt(final_psbt) @@ -1792,11 +1937,13 @@ def test_minitapscript(leaf2_mine, recovery, lt_type, minisc, clear_miniscript, "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, microsd_path, pick_menu_item, - cap_story, import_miniscript): + cap_story, import_miniscript, garbage_collector): clear_miniscript() fname = "imdesc.txt" + fpath = microsd_path(fname) with open(microsd_path(fname), "w") as f: f.write(desc) + garbage_collector.append(fpath) title, story = import_miniscript(fname) assert "Failed to import" in story @@ -1811,7 +1958,8 @@ def test_timelock_mixin(): @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, load_export, microsd_path, use_regtest, clear_miniscript, cc_first, - address_explorer_check, import_miniscript, bitcoin_core_signer, press_select): + address_explorer_check, import_miniscript, bitcoin_core_signer, press_select, + garbage_collector): # check D wrapper u property for segwit v0 and v1 # https://github.com/bitcoin/bitcoin/pull/24906/files @@ -1838,10 +1986,10 @@ def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, ca name = "d_wrapper" fname = f"{name}.txt" - fpath = microsd_path(fname) with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) wo = bitcoind.create_wallet(wallet_name=name, disable_private_keys=True, blank=True, passphrase=None, avoid_reuse=False, descriptors=True) @@ -1898,8 +2046,10 @@ def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, ca to_sign_psbt = psbt name = f"{name}.psbt" - with open(microsd_path(name), "w") as f: + fpath = microsd_path(name) + with open(fpath, "w") as f: f.write(to_sign_psbt) + garbage_collector.append(fpath) goto_home() pick_menu_item("Ready To Sign") time.sleep(.1) @@ -1919,9 +2069,10 @@ def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, ca press_select() fname_psbt = story.split("\n\n")[1] # fname_txn = story.split("\n\n")[3] - with open(microsd_path(fname_psbt), "r") as f: + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() - + garbage_collector.append(fpath_psbt) assert final_psbt != to_sign_psbt # with open(microsd_path(fname_txn), "r") as f: # final_txn = f.read().strip() @@ -1952,7 +2103,7 @@ def test_d_wrapper(addr_fmt, bitcoind, get_cc_key, goto_home, pick_menu_item, ca def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, clear_miniscript, goto_home, cap_menu, pick_menu_item, - import_miniscript, microsd_path, press_select): + import_miniscript, microsd_path, press_select, garbage_collector): clear_miniscript() use_regtest() @@ -1965,8 +2116,10 @@ def test_chain_switching(use_mainnet, use_regtest, settings_get, settings_set, fname_xtn0 = "XTN0.txt" for desc, fname in [(x, fname_xtn), (z, fname_btc), (y, fname_xtn0)]: - with open(microsd_path(fname), "w") as f: + fpath = microsd_path(fname) + with open(fpath, "w") as f: f.write(desc) + garbage_collector.append(fpath) # cannot import XPUBS when testnet/regtest enabled _, story = import_miniscript(fname_btc) diff --git a/testing/test_sign.py b/testing/test_sign.py index e9ed28cc..01a2fb35 100644 --- a/testing/test_sign.py +++ b/testing/test_sign.py @@ -2968,10 +2968,11 @@ def test_sorting_outputs_by_size(fake_txn, start_sign, cap_story, use_testnet, @pytest.mark.parametrize("chain", ["BTC", "XTN"]) @pytest.mark.parametrize("data", [ # (out_style, amount, is_change) + [("p2tr", 999999, 1)] + [("p2tr", 888888, 0)] * 12, [("p2pkh", 1000000, 0)] * 99, - [("p2wpkh", 1000000, 1),("p2wpkh-p2sh", 800000, 1)] * 27, + [("p2wpkh", 1000000, 1),("p2wpkh-p2sh", 800000, 1), ("p2tr", 600000, 1)] * 27, [("p2pkh", 1000000, 1)] * 11 + [("p2wpkh", 50000000, 0)] * 16, - [("p2pkh", 1000000, 1), ("p2wpkh", 50000000, 0), ("p2wpkh-p2sh", 800000, 1)] * 11, + [("p2pkh", 1000000, 1), ("p2wpkh", 50000000, 0), ("p2wpkh-p2sh", 800000, 1), ("p2tr", 100000, 0)] * 11, ]) def test_txout_explorer(psbtv2, chain, data, fake_txn, start_sign, settings_set, txout_explorer, cap_story): @@ -3058,8 +3059,7 @@ def test_taproot_keyspend(use_regtest, bitcoind_d_sim_watch, start_sign, end_sig title, story = cap_story() assert title == 'OK TO SEND?' assert "Consolidating" in story # self-spend - assert "1 ins - fee" in story # one input - assert "2 outs" in story # two outputs + assert " 1 input\n 2 outputs" in story addrs = story.split("\n\n")[3].split("\n")[-2:] assert len(addrs) == 2 for addr in addrs: @@ -3120,8 +3120,7 @@ def test_taproot_keyspend(use_regtest, bitcoind_d_sim_watch, start_sign, end_sig title, story = cap_story() assert title == 'OK TO SEND?' assert "Consolidating" in story # self-spend - assert "2 ins - fee" in story # five inputs - assert "3 outs" in story # one output + assert " 2 inputs\n 3 outputs" in story press_select() # confirm signing time.sleep(0.1) title, story = cap_story() @@ -3170,8 +3169,7 @@ def test_taproot_keyspend(use_regtest, bitcoind_d_sim_watch, start_sign, end_sig title, story = cap_story() assert title == 'OK TO SEND?' assert "Consolidating" in story # self-spend - assert "3 ins - fee" in story # five inputs - assert "1 outs" in story # one output + assert " 3 inputs\n 1 output" in story press_select() # confirm signing time.sleep(0.1) title, story = cap_story() From bf67c5ad7ec0658deecce391adc417734ef703b4 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 10:58:22 -0400 Subject: [PATCH 10/15] updated --- stm32/COLDCARD_MK4/file_time.c | 6 +++--- stm32/COLDCARD_Q1/file_time.c | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/stm32/COLDCARD_MK4/file_time.c b/stm32/COLDCARD_MK4/file_time.c index b514c276..6526886c 100644 --- a/stm32/COLDCARD_MK4/file_time.c +++ b/stm32/COLDCARD_MK4/file_time.c @@ -2,12 +2,12 @@ // // AUTO-generated. // -// built: 2024-06-25 -// version: 5.3.2 +// built: 2024-07-04 +// version: 6.3.3X // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x58d92860UL; + return 0x58e43060UL; } diff --git a/stm32/COLDCARD_Q1/file_time.c b/stm32/COLDCARD_Q1/file_time.c index 40410770..6e81847f 100644 --- a/stm32/COLDCARD_Q1/file_time.c +++ b/stm32/COLDCARD_Q1/file_time.c @@ -2,12 +2,12 @@ // // AUTO-generated. // -// built: 2024-06-03 -// version: 1.2.2Q +// built: 2024-07-04 +// version: 6.3.3QX // #include // this overrides ports/stm32/fatfs_port.c uint32_t get_fattime(void) { - return 0x58c30840UL; + return 0x58e43060UL; } From 47754d1785f7fb26b571e4c23a2848051cb29f46 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 11:00:05 -0400 Subject: [PATCH 11/15] Signed for q1 release. --- releases/signatures.txt | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index 45c510e9..7fb700d9 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -2,11 +2,15 @@ Hash: SHA256 95eff9e044cdb6b3d00961ae72d450684d5441c6a3661ab550a3c3aa0882e754 README.md -f7dd14df39d1c9ef6add499774356e7caf04ad0d6614f4a47ee0f441b5c40cb4 Next-ChangeLog.md +d9b9bc738a6e06e5c8a897696e2d7a0a4fe0fc483bfda38efa7e7df4beb08b70 Next-ChangeLog.md 0a8e2b5692b1cdd1b65c6db2c970c2653d2369ba06ffc9cfa7cf85de96ac1292 History-Q.md 188d34016671eeb2d0b3f0acaa87a08bb0c6cc6d6cc6b2ba0815f92c1a41ae21 History-Mk4.md c8ad43b4e3f9d77777026da6d1210c6fc5cfe435bcfcd241c0f67c9392ad7b82 History-Mk3.md +599b6a47a23bcdc66eb5286fa8800d204a99c6901f5588a20ca301ec3252c1df History-Edge.md +44b952a557618695f58582fbe5ac8061bb0c1b9e77fc88931a62d9bafb85789e EdgeChangeLog.md 59293211e80a1deceb0149a2fa3821935177dd77482f9d414f9d64a4552b45c4 ChangeLog.md +9a2c5ef80a6f8212caa3b455e203da3549a79b08b473113662cf80fff587566a 2024-07-04T1459-v6.3.3QX-q1-coldcard.dfu +5656e9004101895d2ff95c5e6f97f2114b1576a8b66ad2a4a93d529ad09fc6db 2024-07-04T1459-v6.3.3QX-q1-coldcard-factory.dfu a990cc94066486a37071c011cd85a29caed433cb4ca3f1c4dce7f715ef81dc3c 2024-06-26T1741-v5.3.2-mk4-coldcard.dfu 218d17069d05c0ec2829e5629c5216121028d15b145c31b552e2f52daa7bf172 2024-06-26T1741-v5.3.2-mk4-coldcard-factory.dfu b87505b407b0477e2d15f71cfb20645ac55ac5b7c74493d25a2c9c97e807b2b3 2024-06-26T1739-v1.2.2Q-q1-coldcard.dfu @@ -84,12 +88,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmZ8Ur4ACgkQo6MbrVoq -WxDahAf6A+fG56VacNEP9GVzeR3OlOk22rvEkYxqJnCl6vmcBEfKRGwI3VT/29p+ -Y43XbPEap8Xc7jR++uraB2a70TWIWWmjoAGmWzIUNMG1HiuepDkhQy/M8EBmPRpQ -Q/ec4f/fmKSkiRYsB9Kv5Zo0bPA6F+r9ypDzuAooq+tqRo4liVGX9jXDH5Mf5/LU -m+mHuwKvLmPmpmiWRqmOZTtLH1JNQGoxpFxlN39bjP0od1N522LpKPUAr/PnMp4P -1UUlAnBnoLo+AqFOQt+/RunPBD3GVSlgIp8qLwkP5Kn8iVOVpnZo3yOFvgmws6XH -1T9loSQmIAaeR5C+AH+yeAUZG66aYg== -=mrYn +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmaGuPUACgkQo6MbrVoq +WxCI1AgAlu3Ar7aS+XQZsAXK8GaSsnSlY0CcwQIn3fy0VIScG+Mt9xewKdTSNyDq +UcTaV+dTDdBEBYwGZLBETR2mSaOV9/QaFUTjmac9eeABLb6FKygq9L/rlNoXOqdq +8WUZF5RcAOcFSGapcog/60dnRJtLfzQGfgb4w+vic6quoHvpXYVS7r73yZncpFzT +/+gTdE5ySWIaBeQgBdCPZbU3Gk3L1Binql5Y5Tmkn+3C2zvZRWcWyBTg10zmjVje +5zF8HanIjV+PE4me2zPyTPpwYOLEtt6/4Zbvp4vJ9GVGt4wVpxLhgPVPvZhuWZzq +vOSSHwe8MmmgQbLCCDALeD1SImuI4A== +=h+pU -----END PGP SIGNATURE----- From f7f41fe6e372ae8f390b847bd3204131261e2c58 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 11:00:10 -0400 Subject: [PATCH 12/15] New release: 2024-07-04T1500-v6.3.3QX From e7f8a1a71e231dd0a8348aa154c87a537e0e51cf Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 11:01:28 -0400 Subject: [PATCH 13/15] Signed for mk4 release. --- releases/signatures.txt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/releases/signatures.txt b/releases/signatures.txt index 7fb700d9..2a034e19 100644 --- a/releases/signatures.txt +++ b/releases/signatures.txt @@ -9,6 +9,8 @@ c8ad43b4e3f9d77777026da6d1210c6fc5cfe435bcfcd241c0f67c9392ad7b82 History-Mk3.md 599b6a47a23bcdc66eb5286fa8800d204a99c6901f5588a20ca301ec3252c1df History-Edge.md 44b952a557618695f58582fbe5ac8061bb0c1b9e77fc88931a62d9bafb85789e EdgeChangeLog.md 59293211e80a1deceb0149a2fa3821935177dd77482f9d414f9d64a4552b45c4 ChangeLog.md +ddf5ce1ef1ee2e6ba2922b333213d0cb939a2658b294c0f24c0e489de3fe7c75 2024-07-04T1501-v6.3.3X-mk4-coldcard.dfu +cf5f30f24b735e549b5c00054c1843feb5961b88c88645687121fde4abbf89b8 2024-07-04T1501-v6.3.3X-mk4-coldcard-factory.dfu 9a2c5ef80a6f8212caa3b455e203da3549a79b08b473113662cf80fff587566a 2024-07-04T1459-v6.3.3QX-q1-coldcard.dfu 5656e9004101895d2ff95c5e6f97f2114b1576a8b66ad2a4a93d529ad09fc6db 2024-07-04T1459-v6.3.3QX-q1-coldcard-factory.dfu a990cc94066486a37071c011cd85a29caed433cb4ca3f1c4dce7f715ef81dc3c 2024-06-26T1741-v5.3.2-mk4-coldcard.dfu @@ -88,12 +90,12 @@ f05bc8dfed047c0c0abe5ed60621d2d14899b10717221c4af0942d96a1754f33 2021-03-29T192 bea27f263b524a66b3ed0a58c16805e98be0d7c3db20c2f7aab3238f2c6a6995 2019-12-19T1623-v3.0.6-coldcard.dfu -----BEGIN PGP SIGNATURE----- -iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmaGuPUACgkQo6MbrVoq -WxCI1AgAlu3Ar7aS+XQZsAXK8GaSsnSlY0CcwQIn3fy0VIScG+Mt9xewKdTSNyDq -UcTaV+dTDdBEBYwGZLBETR2mSaOV9/QaFUTjmac9eeABLb6FKygq9L/rlNoXOqdq -8WUZF5RcAOcFSGapcog/60dnRJtLfzQGfgb4w+vic6quoHvpXYVS7r73yZncpFzT -/+gTdE5ySWIaBeQgBdCPZbU3Gk3L1Binql5Y5Tmkn+3C2zvZRWcWyBTg10zmjVje -5zF8HanIjV+PE4me2zPyTPpwYOLEtt6/4Zbvp4vJ9GVGt4wVpxLhgPVPvZhuWZzq -vOSSHwe8MmmgQbLCCDALeD1SImuI4A== -=h+pU +iQEzBAEBCAAdFiEERYl3mt/BTzMnU06oo6MbrVoqWxAFAmaGuUgACgkQo6MbrVoq +WxCFewf9ECjMAVPhoLAu2iGIOH9YD6vHNmOG8fT2DXxE9jZjPoukRZMAP/jiLlpq +7kybTf/G9g1KB3QmatN8YQanpIli72t94EbBw1tkll2yumjyDt5zZ3GoTGCGVMYm +DVA8sgUUqc+lXhURgRffL6ZZLx9Bwo5HvrbtYYWTntLOdAT9ynejpAbUmwwZncSJ +BnvpMrkih1Z3zQeFaNPOASkCbipBVwc90zVef4LsOzMwew2rjaE6m70VgNTQ3mAF +WV+A+4NqlQ7Loq/ObRTgyKZCUOtl076wngVQNfl047nPFMlbQuIO3wx8odKqthyn +hfWQ7qArjp7D3aIoZXJUlamVjJcZJQ== +=qRTP -----END PGP SIGNATURE----- From 5b2772a4b04d80661612df45315b5c60a399e279 Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Thu, 4 Jul 2024 11:01:33 -0400 Subject: [PATCH 14/15] New release: 2024-07-04T1501-v6.3.3X From bb87cdd59a9cc7e36b0b505cf76f624ea104cfb7 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 4 Jul 2024 17:37:31 +0200 Subject: [PATCH 15/15] correct edge versions --- releases/EdgeChangeLog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/releases/EdgeChangeLog.md b/releases/EdgeChangeLog.md index 4175fa17..5eee3e49 100644 --- a/releases/EdgeChangeLog.md +++ b/releases/EdgeChangeLog.md @@ -22,7 +22,7 @@ This lists the changes in the most recent EDGE firmware, for each hardware platf # Mk4 Specific Changes -## 5.3.3X - 2024-07-04 +## 6.3.3X - 2024-07-04 - Bugfix: Fix yikes displaying BIP-85 WIF when both NFC and VDisk are OFF - Bugfix: Fix inability to export change addresses when both NFC and Vdisk id OFF @@ -32,7 +32,7 @@ This lists the changes in the most recent EDGE firmware, for each hardware platf # Q Specific Changes -## 1.2.3QX - 2024-07-04 +## 6.3.3QX - 2024-07-04 - Enhancement: Miniscript and (BB)Qr codes - Bugfix: Properly clear LCD screen after simple QR code is shown