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 8e788913..51960b17 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 9ce811e2..d0b0b370 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):
@@ -408,7 +383,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
@@ -416,28 +391,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 65dbb69b..90ed2b0c 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -1435,7 +1435,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 '''\
@@ -1451,6 +1451,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
@@ -1509,14 +1544,32 @@ def usb_show_address(addr_format, subpath):
return active_request.address
-class NewEnrollRequest(UserAuthorizedAction):
- def __init__(self, ms):
+class MiniscriptDeleteRequest(UserAuthorizedAction):
+ def __init__(self, msc):
super().__init__()
- self.wallet = ms
- # 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, bsms_index=None):
+ super().__init__()
+ self.wallet = msc
+ self.bsms_index = bsms_index
+
+ async def interact(self):
+ from wallet import WalletOutOfSpace
ms = self.wallet
try:
@@ -1527,22 +1580,42 @@ class NewEnrollRequest(UserAuthorizedAction):
self.refused = True
await ux_dramatic_pause("Refused.", 2)
- except MultisigOutOfSpace:
+ 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)
+
+ 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...') # needed
+ dis.fullscreen('Wait...')
dis.busy_bar(True)
try:
@@ -1564,9 +1637,19 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
# this call will raise on parsing errors, so let them rise up
# and be shown on screen/over usb
- ms = MultisigWallet.from_file(config, name=name)
+ if miniscript is None:
+ # autodetect
+ try:
+ msc = MiniScriptWallet.from_file(config, name=name)
+ except AssertionError:
+ msc = MultisigWallet.from_file(config, name=name)
- UserAuthorizedAction.active_request = NewEnrollRequest(ms)
+ 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
@@ -1577,9 +1660,9 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
from ux import the_ux
the_ux.push(UserAuthorizedAction.active_request)
finally:
- # always finish busy bar
dis.busy_bar(False)
+
class FirmwareUpgradeRequest(UserAuthorizedAction):
def __init__(self, hdr, length, hdr_check=False, psram_offset=None):
super().__init__()
diff --git a/shared/backups.py b/shared/backups.py
index f83a2b41..f61b1709 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 4b9c6f7d..b76de121 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
@@ -409,6 +408,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 6903e37c..e425d2a6 100644
--- a/shared/decoders.py
+++ b/shared/decoders.py
@@ -214,6 +214,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 9d9d7f34..9d65175d 100644
--- a/shared/descriptor.py
+++ b/shared/descriptor.py
@@ -1,262 +1,464 @@
-# (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
- @staticmethod
- def checksum_check(desc_w_checksum , csum_required=False):
- try:
- desc, checksum = desc_w_checksum.split("#")
- except ValueError:
- if csum_required:
- raise ValueError("Missing descriptor checksum")
- return desc_w_checksum, None
- calc_checksum = descriptor_checksum(desc)
- if calc_checksum != checksum:
- raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
- return desc, checksum
+ if taproot:
+ if self.key:
+ self.key.taproot = True
+ for k in self.keys:
+ k.taproot = taproot
- @staticmethod
- def parse_key_orig_info(key):
- # 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
+ 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.")
- @staticmethod
- def parse_key_derivation_info(key):
- 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>" + "/" + "*"
+ def validate(self):
+ from glob import settings
+ if self.miniscript:
+ if self.is_basic_multisig:
+ assert len(self.keys) <= MAX_SIGNERS
else:
- key_str = key_str + "/" + "/".join(["1", "*"] if internal else ["0", "*"])
- result.append(key_str.replace("'", "h"))
- return result
+ assert len(self.keys) <= 20
+ self.miniscript.verify()
+ if self.miniscript.type != "B":
+ raise DescriptorException("Top level miniscript should be 'B'")
- def _serialize(self, internal=False, int_ext=False):
- """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)
+ 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"
- def serialize(self, internal=False, int_ext=False):
- """Serialize with checksum"""
- return append_checksum(self._serialize(internal=internal, int_ext=int_ext))
+ 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
- @classmethod
- def parse(cls, desc_w_checksum):
- # 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(")")
+ assert has_mine != 0, 'My key %s missing in descriptor.' % xfp2str(my_xfp).upper()
- # native segwit
- elif desc.startswith("wpkh("):
- addr_fmt = AF_P2WPKH
- tmp_desc = desc.replace("wpkh(", "")
- tmp_desc = tmp_desc.rstrip(")")
+ def storage_policy(self):
+ if self.tapscript:
+ return self.tapscript.policy
- elif desc.startswith("tr("):
- addr_fmt = AF_P2TR
- tmp_desc = desc.replace("tr(", "")
- tmp_desc = tmp_desc.rstrip(")")
+ 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
- # wrapped segwit
- elif desc.startswith("sh(wpkh("):
- addr_fmt = AF_P2WPKH_P2SH
- tmp_desc = desc.replace("sh(wpkh(", "")
- tmp_desc = tmp_desc.rstrip("))")
+ 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:
- raise ValueError("Unsupported descriptor. Supported: pkh(, wpkh(, sh(wpkh( and tr(.")
+ return b"\x00\x14" + ngu.hash.hash160(self.key.node.pubkey())
- 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)
+ 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
+ """Quick method to guess whether this is a descriptor"""
try:
temp = parse_desc_str(desc_str)
except:
@@ -273,142 +475,193 @@ class Descriptor:
return True
return False
- def bitcoin_core_serialize(self, external_label=None):
+ @staticmethod
+ def checksum_check(desc_w_checksum, csum_required=False):
+ try:
+ desc, checksum = desc_w_checksum.split("#")
+ except ValueError:
+ if csum_required:
+ raise ValueError("Missing descriptor checksum")
+ return desc_w_checksum, None
+ calc_checksum = descriptor_checksum(desc)
+ if calc_checksum != checksum:
+ raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
+ return desc, checksum
+
+ @classmethod
+ def from_string(cls, desc, 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
+
+ @classmethod
+ def read_from(cls, s, taproot=False):
+ start = s.read(7)
+ sh = False
+ wsh = False
+ wpkh = False
+ is_miniscript = True
+ internal_key = None
+ tapscript = None
+ if start.startswith(b"tr("):
+ is_miniscript = False # miniscript vs. tapscript (that can contain miniscripts in tree)
+ taproot = True
+ s.seek(-4, 1)
+ internal_key = Key.parse(s) # internal key is a must
+ internal_key.taproot = True
+ sep = s.read(1)
+ if sep == b")":
+ s.seek(-1, 1)
+ else:
+ assert sep == b","
+ tapscript = Tapscript.read_from(s)
+ elif start.startswith(b"sh(wsh("):
+ sh = True
+ wsh = True
+ elif start.startswith(b"wsh("):
+ sh = False
+ wsh = True
+ s.seek(-3, 1)
+ elif start.startswith(b"sh(wpkh"):
+ is_miniscript = False
+ sh = True
+ wpkh = True
+ assert s.read(1) == b"("
+ elif start.startswith(b"wpkh("):
+ is_miniscript = False
+ wpkh = True
+ s.seek(-2, 1)
+ elif start.startswith(b"pkh("):
+ is_miniscript = False
+ s.seek(-3, 1)
+ elif start.startswith(b"sh("):
+ sh = True
+ wsh = False
+ s.seek(-4, 1)
+ else:
+ raise ValueError("Invalid descriptor")
+
+ if is_miniscript:
+ miniscript = Miniscript.read_from(s)
+ miniscript.is_sane(taproot=False)
+ key = internal_key
+ nbrackets = int(sh) + int(wsh)
+ elif taproot:
+ miniscript = None
+ key = internal_key
+ nbrackets = 1
+ else:
+ miniscript = None
+ key = Key.parse(s)
+ nbrackets = 1 + int(sh)
+
+ end = s.read(nbrackets)
+ if end != b")" * nbrackets:
+ raise ValueError("Invalid descriptor")
+ o = cls(miniscript, sh=sh, wsh=wsh, key=key, wpkh=wpkh,
+ taproot=taproot, tapscript=tapscript)
+ o.validate()
+ return o
+
+ def to_string(self, external=True, internal=True, checksum=True):
+ if self.taproot:
+ desc = "tr(%s" % self.key.to_string(external, internal)
+ if self.tapscript:
+ desc += ","
+ tree = self.tapscript.to_string(external, internal)
+ desc += tree
+
+ desc = desc + ")"
+ return append_checksum(desc)
+
+ if self.miniscript is not None:
+ res = self.miniscript.to_string(external, internal)
+ if self.wsh:
+ res = "wsh(%s)" % res
+ else:
+ if self.wpkh:
+ res = "wpkh(%s)" % self.key.to_string(external, internal)
+ else:
+ res = "pkh(%s)" % self.key.to_string(external, internal)
+ if self.sh:
+ res = "sh(%s)" % res
+
+ if checksum:
+ res = append_checksum(res)
+ return res
+
+ def bitcoin_core_serialize(self):
# this will become legacy one day
# instead use <0;1> descriptor format
res = []
- for internal in [False, True]:
+ for external, internal in [(True, False), (False, True)]:
desc_obj = {
- "desc": self.serialize(internal=internal),
+ "desc": self.to_string(external, internal),
"active": True,
"timestamp": "now",
"internal": internal,
"range": [0, 100],
}
- if internal is False and external_label:
- desc_obj["label"] = external_label
res.append(desc_obj)
return res
-
-class MultisigDescriptor(Descriptor):
- # only supprt with key derivation info
- # only xpubs
- # can be extended when needed
- __slots__ = (
- "M",
- "N",
- "keys",
- "addr_fmt",
- "is_sorted" # whether to use sortedmulti() or multi()
- )
-
- def __init__(self, M, N, keys, addr_fmt, is_sorted=True):
- self.M = M
- self.N = N
- self.is_sorted = is_sorted
- super().__init__(keys, addr_fmt)
-
- @classmethod
- def parse(cls, desc_w_checksum):
- # remove garbage
- desc_w_checksum = parse_desc_str(desc_w_checksum)
- # check correct checksum
- desc, checksum = cls.checksum_check(desc_w_checksum)
- is_sorted = "sortedmulti(" in desc
- rplc = "sortedmulti(" if is_sorted else "multi("
-
- # wrapped segwit
- if desc.startswith("sh(wsh("+rplc):
- addr_fmt = AF_P2WSH_P2SH
- tmp_desc = desc.replace("sh(wsh("+rplc, "")
- tmp_desc = tmp_desc.rstrip(")))")
-
- # native segwit
- elif desc.startswith("wsh("+rplc):
- addr_fmt = AF_P2WSH
- tmp_desc = desc.replace("wsh("+rplc, "")
- tmp_desc = tmp_desc.rstrip("))")
-
- # legacy
- elif desc.startswith("sh("+rplc):
- addr_fmt = AF_P2SH
- tmp_desc = desc.replace("sh("+rplc, "")
- tmp_desc = tmp_desc.rstrip("))")
-
- else:
- raise ValueError("Unsupported descriptor. Supported: sh(), sh(wsh()), wsh().")
-
- 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, is_sorted=is_sorted)
-
- def _serialize(self, internal=False, int_ext=False):
- """Serialize without checksum"""
- desc_base = MULTI_FMT_TO_SCRIPT[self.addr_fmt]
- _type = "sortedmulti" if self.is_sorted else "multi"
- _type += "(%s)"
- desc_base = desc_base % _type
- 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):
+ # TODO not enabled
"""Serialize in pretty and human-readable format"""
- _type = "sortedmulti" if self.is_sorted else "multi"
+ inner_ident = 1
res = "# Coldcard descriptor export\n"
- if self.is_sorted:
- res += "# order of keys in the descriptor does not matter, will be sorted before creating script (BIP-67)\n"
- else:
- res += ("# !!! DANGER: order of keys in descriptor MUST be preserved. "
- "Correct order of keys is required to compose valid redeem/witness script.\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("+_type+"(\n%s\n))"
+ res += "sh(sortedmulti(\n%s\n))"
# native segwit
elif self.addr_fmt == AF_P2WSH:
res += "# native segwit - p2wsh\n"
- res += "wsh("+_type+"(\n%s\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(" + _type + "(\n%s\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 e259274d..c04c49ea 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 e14745a0..3bcd9b93 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 f0bb1620..ed3456e0 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, get_filesize
+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, get_filesize
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, ux_enter_number, OK, X
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 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, NonDefaultMenuItem
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'
@@ -107,7 +106,7 @@ def make_redeem_script(M, nodes, subkey_idx, bip67=True):
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
@@ -122,19 +121,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', bip67=True):
- self.storage_idx = -1
+ def __init__(self, name, m_of_n, xpubs, addr_fmt=AF_P2SH, chain_type=None, bip67=True):
+ 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
@@ -163,17 +163,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
@@ -239,14 +235,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:
@@ -343,57 +351,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:
@@ -454,12 +411,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
@@ -472,7 +429,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'
@@ -501,6 +458,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
@@ -572,7 +558,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):
@@ -683,7 +669,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
@@ -696,21 +684,35 @@ 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, desc.is_sorted
+
+ return None, addr_fmt, xpubs, has_mine, M, N, # TODO multi/sortedmulti
def to_descriptor(self):
- return MultisigDescriptor(
- M=self.M, N=self.N,
- keys=self.xpubs,
- addr_fmt=self.addr_fmt,
- is_sorted=self.bip67,
- )
+ keys = [
+ Key.from_cc_data(xfp, deriv, xpub)
+ for xfp, deriv, xpub in self.xpubs
+ ]
+ # TODO does not need to be sorted multi now
+ 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):
@@ -731,8 +733,10 @@ 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 MultisigDescriptor.is_descriptor(config):
+ 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, bip67 = cls.from_descriptor(config)
if not bip67 and not settings.get("unsort_ms", 0):
# BIP-67 disabled, but unsort_ms not allowed - raise
@@ -775,83 +779,6 @@ class MultisigWallet(WalletABC):
return cls(name, (M, N), xpubs, addr_fmt=addr_fmt,
chain_type=expect_chain, bip67=bip67)
- @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(' ', '_')
@@ -956,7 +883,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):
@@ -969,9 +896,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:
@@ -1043,8 +971,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)
@@ -1054,7 +983,7 @@ class MultisigWallet(WalletABC):
name = 'PSBT-%d-of-%d' % (M, N)
# this will always create sortedmulti multisig (BIP-67)
# because BIP-174 came years after wide spread acceptance of BIP-67 policy
- ms = cls(name, (M, N), xpubs, 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 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
@@ -1078,7 +1007,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
@@ -1182,7 +1113,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()
@@ -1215,7 +1146,7 @@ Press (1) to see extended public keys, '''.format(M=M, N=N, name=self.name, exp=
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)
@@ -1343,8 +1274,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():
@@ -1357,6 +1291,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))
@@ -1451,7 +1386,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)
@@ -1516,6 +1451,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, ok=OK, x=X)
@@ -1534,9 +1471,10 @@ P2WSH:
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):
@@ -1663,7 +1601,7 @@ async def ms_coordinator_file(af_str, my_xfp, chain, slot_b=None):
# 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
@@ -1763,9 +1701,9 @@ async def ondevice_multisig_create(mode='p2wsh', addr_fmt=AF_P2WSH, is_qr=False)
ms = MultisigWallet(name, (M, N), xpubs, chain_type=chain.ctype, addr_fmt=addr_fmt)
if num_mine:
- from auth import NewEnrollRequest, UserAuthorizedAction
+ from auth import NewMiniscriptEnrollRequest, UserAuthorizedAction
- UserAuthorizedAction.active_request = NewEnrollRequest(ms)
+ UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(ms)
# menu item case: add to stack
from ux import the_ux
@@ -1818,7 +1756,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))
@@ -1865,7 +1803,7 @@ async def import_multisig(*a):
if 'pub' in ln:
return True
- fn = await file_picker(suffix=['.txt', '.json'], 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 2a7c57e1..4fef11bf 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
@@ -659,33 +658,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
- # multi( catches both multi( and sortedmulti(
- if 'pub' in msg or "multi(" 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
@@ -883,4 +855,78 @@ 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
+ # TODO this should be Descriptor.is_descriptor() ?
+ 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 6151c2c0..0b9f1aa0 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
@@ -76,7 +77,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 3d48168e..0d8e7b0b 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 (BIP-67 optional from now - depending on imported descriptor)
- # - 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 (BIP-67 optional from now - depending on imported descriptor)
+ # - 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:
@@ -2332,7 +2414,7 @@ class psbtObject(psbtProxy):
r = result[1:33]
s = result[33:65]
der_sig = ser_sig_der(r, s, inp.sighash)
- inp.part_sig[pk] = sig
+ inp.part_sig[pk] = der_sig
# memory cleanup
del result, r, s
@@ -2638,8 +2720,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:]
def encode_seed_qr(words):
diff --git a/shared/ux_q1.py b/shared/ux_q1.py
index ba55f838..e8a32e3f 100644
--- a/shared/ux_q1.py
+++ b/shared/ux_q1.py
@@ -934,12 +934,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 02b543de..eb141e65 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.4.0
+VERSION_STRING = 6.4.0X
# keep near top, because defined default target (all)
include shared.mk
diff --git a/stm32/Q1-Makefile b/stm32/Q1-Makefile
index 323e8d43..89899144 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.3.0Q
+VERSION_STRING = 1.3.0QX
# Remove this closer to shipping.
#$(warning "Forcing debug build")
diff --git a/testing/conftest.py b/testing/conftest.py
index 0c14000c..077bf643 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -625,6 +625,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)
@@ -1577,6 +1583,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)
@@ -2247,7 +2256,8 @@ def dev_core_import_object(dev):
ders = [
("m/44h/1h/0h", AF_CLASSIC),
("m/49h/1h/0h", AF_P2WPKH_P2SH),
- ("m/84h/1h/0h", AF_P2WPKH)
+ ("m/84h/1h/0h", AF_P2WPKH),
+ ("m/86h/1h/0h", AF_P2TR),
]
descriptors = []
for idx, (path, addr_format) in enumerate(ders):
@@ -2275,6 +2285,7 @@ from test_ephemeral import generate_ephemeral_words, import_ephemeral_xprv, goto
from test_ephemeral import ephemeral_seed_disabled_ui, restore_main_seed, confirm_tmp_seed
from test_ephemeral import verify_ephemeral_secret_ui, get_identity_story, get_seed_value_ux, seed_vault_enable
from test_multisig import import_ms_wallet, make_multisig, offer_ms_import, fake_ms_txn
+from test_miniscript import offer_minsc_import
from test_multisig import make_ms_address, clear_ms, make_myself_wallet, import_multisig
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
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 18d19c9e..b23e1823 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 495869d6..d71babc6 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 02a13323..2b57e72c 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, itertools, re
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
from charcodes import KEY_QR
@@ -100,11 +98,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:
@@ -498,7 +496,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)
@@ -531,7 +529,7 @@ 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)
@@ -555,7 +553,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()
@@ -1052,7 +1050,7 @@ 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)
+ # depending if NFC enabled or not, and if Q (has QR) or whether EDGE
assert (len(menu) - num_wallets) in [6, 7, 8]
title, story = offer_ms_import(make_named('xxx-orig'))
@@ -2002,43 +2000,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'):
@@ -2223,8 +2185,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
@@ -2239,6 +2202,7 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu,
path_mapper=path_mapper, bip67=bip67)
assert int(subpath.split('/')[-1]) == idx
+ assert int(subpath.split('/')[-2]) == chng_idx
#print('../0/%s => \n %s' % (idx, B2A(script)))
start, end = detruncate_address(addr)
@@ -2422,12 +2386,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
@@ -2803,17 +2889,16 @@ def test_bitcoind_MofN_tutorial(m_n, desc_type, clear_ms, goto_home, need_keypre
@pytest.mark.parametrize("desc", [
+ # 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,
@@ -2895,7 +2980,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', [(2, 3), (3, 5), (15, 15)])
@pytest.mark.parametrize('desc', ["multi", "sortedmulti"])
@pytest.mark.parametrize('addr_fmt', [AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH])
def test_multisig_descriptor_export(M_N, way, addr_fmt, cmn_pth_from_root, clear_ms, make_multisig,
@@ -2987,6 +3072,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 88dd2b60..531d7d3e 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: