miniscript/tapscript; BSMS; show multisig/miniscript addresses in exports

This commit is contained in:
scgbckbone 2024-06-12 16:32:30 +02:00
parent 427cf89975
commit b1fe5e194d
44 changed files with 9928 additions and 854 deletions

View File

@ -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

27
docs/miniscript.md Normal file
View File

@ -0,0 +1,27 @@
# Miniscript
**COLDCARD<sup>&reg;</sup>** 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` -> `<name>` -> `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 `<name>` 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`

View File

@ -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,

View File

@ -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

View File

@ -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__()

View File

@ -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)

1092
shared/bsms.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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]

View File

@ -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.

519
shared/desc_utils.py Normal file
View File

@ -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 <x;y> 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

View File

@ -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('<I', xfp)).decode().upper()
def str2xfp(txt):
# Inverse of xfp2str
return struct.unpack('<I', a2b_hex(txt))[0]
class DescriptorException(ValueError):
pass
class WrongCheckSumError(Exception):
pass
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
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

View File

@ -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".

View File

@ -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)

View File

@ -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),

View File

@ -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')

View File

@ -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',

1878
shared/miniscript.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -57,14 +57,20 @@ def ser_compact_size(l):
else:
return struct.pack("<BQ", 255, l)
def deser_compact_size(f):
def deser_compact_size(f, ret_num_bytes=False):
nit = struct.unpack("<B", f.read(1))[0]
num_bytes = 1
if nit == 253:
nit = struct.unpack("<H", f.read(2))[0]
num_bytes += 2
elif nit == 254:
nit = struct.unpack("<I", f.read(4))[0]
num_bytes += 4
elif nit == 255:
nit = struct.unpack("<Q", f.read(8))[0]
num_bytes += 8
if ret_num_bytes:
return nit, num_bytes
return nit
def deser_string(f):

View File

@ -53,7 +53,7 @@ HSM_WHITELIST = frozenset({
'blkc', 'hsts', # report status values
'stok', 'smok', # completion check: sign txn or msg
'xpub', 'msck', # quick status checks
'p2sh', 'show', # limited by HSM policy
'p2sh', 'show', 'msas', # limited by HSM policy
'user', # auth HSM user, other user cmds not allowed
'gslr', # read storage locker; hsm mode only, limited usage
})
@ -483,7 +483,7 @@ class USBHandler:
file_len, file_sha = unpack_from('<I32s', args)
if file_sha != self.file_checksum.digest():
return b'err_Checksum'
assert 100 < file_len <= (20*200), "badlen"
assert 100 < file_len <= (32*200), "badlen"
# Start an UX interaction, return immediately here
from auth import maybe_enroll_xpub
@ -491,6 +491,82 @@ class USBHandler:
return None
if cmd == 'mins':
# Enroll new xpubkey to be involved in miniscript.
# - descriptor text config file must already be uploaded
file_len, file_sha = unpack_from('<I32s', args)
if file_sha != self.file_checksum.digest():
return b'err_Checksum'
assert 100 < file_len <= (100 * 200), "badlen"
# Start an UX interaction, return immediately here
from auth import maybe_enroll_xpub
maybe_enroll_xpub(sf_len=file_len, ux_reset=True, miniscript=True)
return None
if cmd == "msls":
# list all registered miniscript wallet names
assert self.encrypted_req, 'must encrypt'
from miniscript import MiniScriptWallet
wallets = [w.name for w in MiniScriptWallet.iter_wallets()]
import ujson
return b'asci' + ujson.dumps(wallets)
if cmd == "msdl":
# delete miniscript wallet by its name (unique id)
assert self.encrypted_req, 'must encrypt'
from miniscript import MiniScriptWallet
assert len(args) < 40, "len args"
for w in MiniScriptWallet.iter_wallets():
if w.name == str(args, 'ascii'):
break
else:
return b'err_Miniscript wallet not found'
from auth import maybe_delete_miniscript
maybe_delete_miniscript(w)
return None
if cmd == "msgt":
# takes name and returns descriptor + name json
assert self.encrypted_req, 'must encrypt'
from miniscript import MiniScriptWallet
assert len(args) < 40, "len args"
for w in MiniScriptWallet.iter_wallets():
if w.name == str(args, 'ascii'):
import ujson
return b'asci' + ujson.dumps({"name": w.name, "desc": w.to_string()})
return b'err_Miniscript wallet not found'
if cmd == "msas":
# get miniscript address based on int/ext index
assert self.encrypted_req, 'must encrypt'
if hsm_active and not hsm_active.approve_address_share(miniscript=True):
raise HSMDenied
from miniscript import MiniScriptWallet
change, idx, = unpack_from('<II', args)
assert change in (0, 1), "change not bool"
assert 0 <= idx < (2 ** 31), "child idx"
name = args[8:]
msc = None
for w in MiniScriptWallet.iter_wallets():
if w.name == str(name, 'ascii'):
msc = w
break
else:
return b'err_Miniscript wallet not found'
from auth import start_show_miniscript_address
return b'asci' + start_show_miniscript_address(msc, change, idx)
if cmd == 'msck':
# Quick check to test if we have a wallet already installed.
from multisig import MultisigWallet

View File

@ -700,11 +700,91 @@ def decode_bip21_text(got):
raise ValueError('not bip-21')
def censor_address(addr):
# We don't like to show the user multisig addresses because we cannot be certain
# they are valid and could actually be signed. And yet, dont blank too many
# spots or else an attacker could grind out a suitable replacement.
return addr[0:12] + '___' + addr[12+3:]
def check_xpub(xfp, xpub, deriv, expect_chain, my_xfp, disable_checks=False):
# Shared code: consider an xpub for inclusion into a wallet
# return T if it's our own key and parsed details in form (xfp, deriv, xpub)
# - deriv can be None, and in very limited cases can recover derivation path
# - could enforce all same depth, and/or all depth >= 1, but
# seems like more restrictive than needed, so "m" is allowed
import stash
from public_constants import AF_P2SH
try:
# Note: addr fmt detected here via SLIP-132 isn't useful
node, chain, _ = parse_extended_key(xpub)
except:
raise AssertionError('unable to parse xpub')
try:
assert node.privkey() == None # 'no privkeys plz'
except ValueError:
pass
if expect_chain == "XRT":
# HACK but there is no difference extended_keys - just bech32 hrp
assert chain.ctype == "XTN"
else:
assert chain.ctype == expect_chain, 'wrong chain'
depth = node.depth()
if depth == 1:
if not xfp:
# allow a shortcut: zero/omit xfp => use observed parent value
xfp = swab32(node.parent_fp())
else:
# generally cannot check fingerprint values, but if we can, do so.
if not disable_checks:
assert swab32(node.parent_fp()) == xfp, 'xfp depth=1 wrong'
assert xfp, 'need fingerprint' # happens if bare xpub given
# In most cases, we cannot verify the derivation path because it's hardened
# and we know none of the private keys involved.
if depth == 1:
# but derivation is implied at depth==1
kn, is_hard = node.child_number()
if is_hard: kn |= 0x80000000
guess = keypath_to_str([kn], skip=0)
if deriv:
if not disable_checks:
assert guess == deriv, '%s != %s' % (guess, deriv)
else:
deriv = guess # reachable? doubt it
assert deriv, 'empty deriv' # or force to be 'm'?
assert deriv[0] == 'm'
# path length of derivation given needs to match xpub's depth
if not disable_checks:
p_len = deriv.count('/')
assert p_len == depth, 'deriv %d != %d xpub depth (xfp=%s)' % (
p_len, depth, xfp2str(xfp))
if xfp == my_xfp:
# its supposed to be my key, so I should be able to generate pubkey
# - might indicate collision on xfp value between co-signers,
# and that's not supported
with stash.SensitiveValues() as sv:
chk_node = sv.derive_path(deriv)
assert node.pubkey() == chk_node.pubkey(), \
"[%s/%s] wrong pubkey" % (xfp2str(xfp), deriv[2:])
# serialize xpub w/ BIP-32 standard now.
# - this has effect of stripping SLIP-132 confusion away
return xfp == my_xfp, (xfp, deriv, chain.serialize_public(node, AF_P2SH))
def truncate_address(addr):
# Truncates address to width of screen, replacing middle chars
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):

View File

@ -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)))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

468
testing/descriptor.py Normal file
View File

@ -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('<I', xfp)).decode().upper()
def str2xfp(txt):
# Inverse of xfp2str
return struct.unpack('<I', a2b_hex(txt))[0]
class WrongCheckSumError(Exception):
pass
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
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

View File

@ -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

View File

@ -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()

View File

@ -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)

1654
testing/test_bsms.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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 "<HexStreamer..."
result = result.replace('<', "'").replace('>', "'")
try:
return eval(result)
except SyntaxError:
if '<' in result:
# objects, like "<HexStreamer..."
result = result.replace('<', "'").replace('>', "'")
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),

View File

@ -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"])

View File

@ -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

2327
testing/test_miniscript.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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", pk.fingerprint())[0]
@ -126,7 +124,7 @@ def make_multisig(dev, sim_execfile):
xfp_bytes = pk.fingerprint()
xfp = swab32(struct.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 <https://github.com/spesmilo/electrum/issues/6743#issuecomment-729965813>
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('<I', expect_xfp)]
assert expect_xpub == encode_base58_checksum(hdr_xpub)
assert derivation == path_to_str(unpack('<%dI' % (len(hdr_path) // 4),hdr_path))
# sign a multisig, with xpubs in globals
_, out_psbt = try_sign(psbt_b4, accept=True, accept_ms_import=True)
assert out_psbt != psbt_wrong
assert out_psbt == psbt_right
open('debug/i6.psbt', 'wt').write(out_psbt.hex())
@pytest.mark.parametrize('N', [ 3, 15])
@pytest.mark.parametrize('xderiv', [ None, 'any', 'unknown', '*', '', 'none'])
def test_ms_import_nopath(N, xderiv, make_multisig, clear_ms, offer_ms_import):
@ -2210,11 +2171,12 @@ def test_ms_addr_explorer(change, M_N, addr_fmt, start_idx, clear_ms, cap_menu,
# once change is selected - do not offer this option again
assert "change addresses." not in story
assert "(0)" not in story
# unwrap text a bit
if change:
story = story.replace("=>\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()

View File

@ -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":

View File

@ -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: