miniscript/tapscript; BSMS; show multisig/miniscript addresses in exports
This commit is contained in:
parent
427cf89975
commit
b1fe5e194d
@ -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
27
docs/miniscript.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Miniscript
|
||||
|
||||
**COLDCARD<sup>®</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`
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
113
shared/auth.py
113
shared/auth.py
@ -1435,7 +1435,7 @@ class ShowP2SHAddress(ShowAddressBase):
|
||||
# calculate all the pubkeys involved.
|
||||
self.subpath_help = ms.validate_script(witdeem_script, xfp_paths=xfp_paths)
|
||||
|
||||
self.address = ms.chain.p2sh_address(addr_fmt, witdeem_script)
|
||||
self.address = chains.current_chain().p2sh_address(addr_fmt, witdeem_script)
|
||||
|
||||
def get_msg(self):
|
||||
return '''\
|
||||
@ -1451,6 +1451,41 @@ Paths:
|
||||
{sp}'''.format(addr=self.address, name=self.ms.name,
|
||||
M=self.ms.M, N=self.ms.N, sp='\n\n'.join(self.subpath_help))
|
||||
|
||||
|
||||
class ShowMiniscriptAddress(ShowAddressBase):
|
||||
|
||||
def setup(self, msc, change, idx):
|
||||
self.msc = msc
|
||||
self.change = change
|
||||
self.idx = idx
|
||||
|
||||
d = self.msc.desc.derive(None, change=change).derive(idx)
|
||||
self.address = chains.current_chain().render_address(d.script_pubkey())
|
||||
self.addr_fmt = self.msc.addr_fmt
|
||||
|
||||
def get_msg(self):
|
||||
return '''\
|
||||
{addr}
|
||||
Wallet:
|
||||
{name}
|
||||
|
||||
Index:
|
||||
{idx}
|
||||
|
||||
Change:
|
||||
{change}'''.format(addr=self.address, name=self.msc.name, idx=self.idx, change=bool(self.change))
|
||||
|
||||
|
||||
def start_show_miniscript_address(msc, change, index):
|
||||
UserAuthorizedAction.check_busy(ShowAddressBase)
|
||||
UserAuthorizedAction.active_request = ShowMiniscriptAddress(msc, change, index)
|
||||
|
||||
# kill any menu stack, and put our thing at the top
|
||||
abort_and_goto(UserAuthorizedAction.active_request)
|
||||
|
||||
# provide the value back to attached desktop
|
||||
return UserAuthorizedAction.active_request.address
|
||||
|
||||
def start_show_p2sh_address(M, N, addr_format, xfp_paths, witdeem_script):
|
||||
# Show P2SH address to user, also returns it.
|
||||
# - first need to find appropriate multisig wallet associated
|
||||
@ -1509,14 +1544,32 @@ def usb_show_address(addr_format, subpath):
|
||||
return active_request.address
|
||||
|
||||
|
||||
class NewEnrollRequest(UserAuthorizedAction):
|
||||
def __init__(self, ms):
|
||||
class MiniscriptDeleteRequest(UserAuthorizedAction):
|
||||
def __init__(self, msc):
|
||||
super().__init__()
|
||||
self.wallet = ms
|
||||
# self.result ... will be re-serialized xpub
|
||||
self.wallet = msc
|
||||
|
||||
async def interact(self):
|
||||
from multisig import MultisigOutOfSpace
|
||||
from miniscript import miniscript_delete
|
||||
await miniscript_delete(self.wallet)
|
||||
self.done()
|
||||
|
||||
|
||||
def maybe_delete_miniscript(msc):
|
||||
UserAuthorizedAction.cleanup()
|
||||
UserAuthorizedAction.active_request = MiniscriptDeleteRequest(msc)
|
||||
|
||||
# kill any menu stack, and put our thing at the top
|
||||
abort_and_goto(UserAuthorizedAction.active_request)
|
||||
|
||||
class NewMiniscriptEnrollRequest(UserAuthorizedAction):
|
||||
def __init__(self, msc, bsms_index=None):
|
||||
super().__init__()
|
||||
self.wallet = msc
|
||||
self.bsms_index = bsms_index
|
||||
|
||||
async def interact(self):
|
||||
from wallet import WalletOutOfSpace
|
||||
|
||||
ms = self.wallet
|
||||
try:
|
||||
@ -1527,22 +1580,42 @@ class NewEnrollRequest(UserAuthorizedAction):
|
||||
self.refused = True
|
||||
await ux_dramatic_pause("Refused.", 2)
|
||||
|
||||
except MultisigOutOfSpace:
|
||||
if self.bsms_index is not None:
|
||||
# remove signer round 2 from settings after multisig import is approved by user
|
||||
from bsms import BSMSSettings
|
||||
BSMSSettings.signer_delete(self.bsms_index)
|
||||
|
||||
except WalletOutOfSpace:
|
||||
return await self.failure('No space left')
|
||||
except BaseException as exc:
|
||||
self.failed = "Exception"
|
||||
sys.print_exception(exc)
|
||||
finally:
|
||||
UserAuthorizedAction.cleanup() # because no results to store
|
||||
self.pop_menu()
|
||||
UserAuthorizedAction.cleanup() # because no results to store
|
||||
if self.bsms_index is not None:
|
||||
# bsms special case, get him back to multisig menu
|
||||
from ux import the_ux, restore_menu
|
||||
from multisig import MultisigMenu
|
||||
while 1:
|
||||
top = the_ux.top_of_stack()
|
||||
if not top: break
|
||||
if not isinstance(top, MultisigMenu):
|
||||
the_ux.pop()
|
||||
continue
|
||||
break
|
||||
restore_menu()
|
||||
else:
|
||||
self.pop_menu()
|
||||
|
||||
def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
|
||||
# Offer to import (enroll) a new multisig wallet. Allow reject by user.
|
||||
|
||||
def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None, miniscript=False):
|
||||
# Offer to import (enroll) a new multisig/miniscript wallet. Allow reject by user.
|
||||
from glob import dis
|
||||
from multisig import MultisigWallet
|
||||
from miniscript import MiniScriptWallet
|
||||
|
||||
UserAuthorizedAction.cleanup()
|
||||
dis.fullscreen('Wait...') # needed
|
||||
dis.fullscreen('Wait...')
|
||||
dis.busy_bar(True)
|
||||
|
||||
try:
|
||||
@ -1564,9 +1637,19 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
|
||||
|
||||
# this call will raise on parsing errors, so let them rise up
|
||||
# and be shown on screen/over usb
|
||||
ms = MultisigWallet.from_file(config, name=name)
|
||||
if miniscript is None:
|
||||
# autodetect
|
||||
try:
|
||||
msc = MiniScriptWallet.from_file(config, name=name)
|
||||
except AssertionError:
|
||||
msc = MultisigWallet.from_file(config, name=name)
|
||||
|
||||
UserAuthorizedAction.active_request = NewEnrollRequest(ms)
|
||||
elif miniscript:
|
||||
msc = MiniScriptWallet.from_file(config, name=name)
|
||||
else:
|
||||
msc = MultisigWallet.from_file(config, name=name)
|
||||
|
||||
UserAuthorizedAction.active_request = NewMiniscriptEnrollRequest(msc, bsms_index=bsms_index)
|
||||
|
||||
if ux_reset:
|
||||
# for USB case, and import from PSBT
|
||||
@ -1577,9 +1660,9 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False):
|
||||
from ux import the_ux
|
||||
the_ux.push(UserAuthorizedAction.active_request)
|
||||
finally:
|
||||
# always finish busy bar
|
||||
dis.busy_bar(False)
|
||||
|
||||
|
||||
class FirmwareUpgradeRequest(UserAuthorizedAction):
|
||||
def __init__(self, hdr, length, hdr_check=False, psram_offset=None):
|
||||
super().__init__()
|
||||
|
||||
@ -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
1092
shared/bsms.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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]
|
||||
|
||||
|
||||
@ -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
519
shared/desc_utils.py
Normal 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
|
||||
@ -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
|
||||
@ -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".
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
1878
shared/miniscript.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
|
||||
102
shared/nfc.py
102
shared/nfc.py
@ -613,7 +613,6 @@ class NFCHandler:
|
||||
aborted = await n.share_text("NFC is working: %s" % n.get_uid(), allow_enter=False)
|
||||
assert not aborted, "Aborted"
|
||||
|
||||
|
||||
async def share_file(self):
|
||||
# Pick file from SD card and share over NFC...
|
||||
from actions import file_picker
|
||||
@ -659,33 +658,6 @@ class NFCHandler:
|
||||
else:
|
||||
raise ValueError(ext)
|
||||
|
||||
async def import_multisig_nfc(self, *a):
|
||||
# user is pushing a file downloaded from another CC over NFC
|
||||
# - would need an NFC app in between for the sneakernet step
|
||||
# get some data
|
||||
data = await self.start_nfc_rx()
|
||||
if not data: return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
if len(msg) < 70: continue
|
||||
msg = bytes(msg).decode() # from memory view
|
||||
# multi( catches both multi( and sortedmulti(
|
||||
if 'pub' in msg or "multi(" in msg:
|
||||
winner = msg
|
||||
break
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find multisig descriptor.')
|
||||
return
|
||||
|
||||
from auth import maybe_enroll_xpub
|
||||
try:
|
||||
maybe_enroll_xpub(config=winner)
|
||||
except Exception as e:
|
||||
#import sys; sys.print_exception(e)
|
||||
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
|
||||
async def import_ephemeral_seed_words_nfc(self, *a):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data: return
|
||||
@ -883,4 +855,78 @@ class NFCHandler:
|
||||
|
||||
return winner
|
||||
|
||||
async def read_bsms_token(self):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data:
|
||||
await ux_show_story('Unable to find data expected in NDEF')
|
||||
return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
msg = bytes(msg).decode().strip() # from memory view
|
||||
try:
|
||||
int(msg, 16)
|
||||
winner = msg
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find BSMS token in NDEF data')
|
||||
return
|
||||
|
||||
return winner
|
||||
|
||||
async def read_bsms_data(self):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data:
|
||||
await ux_show_story('Unable to find data expected in NDEF')
|
||||
return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
msg = bytes(msg).decode().strip() # from memory view
|
||||
try:
|
||||
if "BSMS" in msg:
|
||||
# unencrypted case
|
||||
winner = msg
|
||||
break
|
||||
elif int(msg[:6], 16):
|
||||
# encrypted hex case
|
||||
winner = msg
|
||||
break
|
||||
else:
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find BSMS data in NDEF data')
|
||||
return
|
||||
|
||||
return winner
|
||||
|
||||
async def import_miniscript_nfc(self, legacy_multisig=False):
|
||||
data = await self.start_nfc_rx()
|
||||
if not data: return
|
||||
|
||||
winner = None
|
||||
for urn, msg, meta in ndef.record_parser(data):
|
||||
if len(msg) < 70: continue
|
||||
msg = bytes(msg).decode() # from memory view
|
||||
# TODO this should be Descriptor.is_descriptor() ?
|
||||
if 'pub' in msg:
|
||||
winner = msg
|
||||
break
|
||||
|
||||
if not winner:
|
||||
await ux_show_story('Unable to find miniscript descriptor expected in NDEF')
|
||||
return
|
||||
|
||||
from auth import maybe_enroll_xpub
|
||||
try:
|
||||
maybe_enroll_xpub(config=winner, miniscript=not legacy_multisig)
|
||||
except Exception as e:
|
||||
await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e)))
|
||||
|
||||
# EOF
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
162
shared/psbt.py
162
shared/psbt.py
@ -11,6 +11,7 @@ from uhashlib import sha256
|
||||
from uio import BytesIO
|
||||
from sffile import SizerFile
|
||||
from chains import taptweak, tapleaf_hash
|
||||
from miniscript import MiniScriptWallet
|
||||
from multisig import MultisigWallet, disassemble_multisig_mn
|
||||
from exceptions import FatalPSBTIssue, FraudulentChangeOutput
|
||||
from serializations import ser_compact_size, deser_compact_size, hash160
|
||||
@ -479,7 +480,7 @@ class psbtOutputProxy(psbtProxy):
|
||||
for k, v in self.unknown.items():
|
||||
wr(k[0], v, k[1:])
|
||||
|
||||
def validate(self, out_idx, txo, my_xfp, active_multisig, parent):
|
||||
def validate(self, out_idx, txo, my_xfp, active_multisig, active_miniscript, parent):
|
||||
# Do things make sense for this output?
|
||||
|
||||
# NOTE: We might think it's a change output just because the PSBT
|
||||
@ -552,43 +553,66 @@ class psbtOutputProxy(psbtProxy):
|
||||
expect_pkh = None
|
||||
|
||||
else:
|
||||
# Multisig change output, for wallet we're supposed to be a part of.
|
||||
# - our key must be part of it
|
||||
# - must look like input side redeem script (same fingerprints)
|
||||
# - assert M/N structure of output to match any inputs we have signed in PSBT!
|
||||
# - assert all provided pubkeys are in redeem script, not just ours
|
||||
# - we get all of that by re-constructing the script from our wallet details
|
||||
if not redeem_script and not witness_script:
|
||||
# Perhaps an omission, so let's not call fraud on it
|
||||
# But definately required, else we don't know what script we're sending to.
|
||||
raise FatalPSBTIssue(
|
||||
"Missing redeem/witness script for multisig output #%d" % out_idx
|
||||
)
|
||||
if active_miniscript:
|
||||
# TODO
|
||||
# this should be also acceptable for any other script type, we do not need
|
||||
# redeem/witness script
|
||||
# scriptPubkey can be compared against script that we build - if exact match change
|
||||
# if not not change - definitely not FatalPSBTIssue
|
||||
#
|
||||
# without this I cannot sign with liana as they do not provide witness/redeem
|
||||
try:
|
||||
active_miniscript.validate_script_pubkey(txo.scriptPubKey,
|
||||
list(self.subpaths.values()))
|
||||
self.is_change = True
|
||||
return
|
||||
except Exception as e:
|
||||
raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e)
|
||||
else:
|
||||
# Perhaps an omission, so let's not call fraud on it
|
||||
# But definately required, else we don't know what script we're sending to.
|
||||
raise FatalPSBTIssue("Missing redeem/witness script for output #%d" % out_idx)
|
||||
|
||||
# it cannot be change if it doesn't precisely match our multisig setup
|
||||
if not active_multisig:
|
||||
if not active_multisig and not active_miniscript:
|
||||
# - might be a p2sh output for another wallet that isn't us
|
||||
# - not fraud, just an output with more details than we need.
|
||||
self.is_change = False
|
||||
return
|
||||
|
||||
if MultisigWallet.disable_checks:
|
||||
# Without validation, we have to assume all outputs
|
||||
# will be taken from us, and are not really change.
|
||||
self.is_change = False
|
||||
return
|
||||
|
||||
# redeem script must be exactly what we expect
|
||||
# - pubkeys will be reconstructed from derived paths here
|
||||
# - BIP-45, BIP-67 rules applied (BIP-67 optional from now - depending on imported descriptor)
|
||||
# - p2sh-p2wsh needs witness script here, not redeem script value
|
||||
# - if details provided in output section, must our match multisig wallet
|
||||
try:
|
||||
active_multisig.validate_script(witness_script or redeem_script,
|
||||
subpaths=self.subpaths)
|
||||
except BaseException as exc:
|
||||
raise FraudulentChangeOutput(out_idx,
|
||||
"P2WSH or P2SH change output script: %s" % exc)
|
||||
if active_multisig:
|
||||
# Multisig change output, for wallet we're supposed to be a part of.
|
||||
# - our key must be part of it
|
||||
# - must look like input side redeem script (same fingerprints)
|
||||
# - assert M/N structure of output to match any inputs we have signed in PSBT!
|
||||
# - assert all provided pubkeys are in redeem script, not just ours
|
||||
# - we get all of that by re-constructing the script from our wallet details
|
||||
if MultisigWallet.disable_checks:
|
||||
# Without validation, we have to assume all outputs
|
||||
# will be taken from us, and are not really change.
|
||||
self.is_change = False
|
||||
return
|
||||
# redeem script must be exactly what we expect
|
||||
# - pubkeys will be reconstructed from derived paths here
|
||||
# - BIP-45, BIP-67 rules applied (BIP-67 optional from now - depending on imported descriptor)
|
||||
# - p2sh-p2wsh needs witness script here, not redeem script value
|
||||
# - if details provided in output section, must our match multisig wallet
|
||||
try:
|
||||
active_multisig.validate_script(witness_script or redeem_script,
|
||||
subpaths=self.subpaths)
|
||||
except BaseException as exc:
|
||||
raise FraudulentChangeOutput(out_idx,
|
||||
"P2WSH or P2SH change output script: %s" % exc)
|
||||
else:
|
||||
# active miniscript
|
||||
try:
|
||||
active_miniscript.validate_script(witness_script or redeem_script,
|
||||
list(self.subpaths.values()),
|
||||
script_pubkey=txo.scriptPubKey)
|
||||
except BaseException as exc:
|
||||
raise FraudulentChangeOutput(out_idx,
|
||||
"P2WSH or P2SH change output script: %s" % exc)
|
||||
|
||||
if is_segwit:
|
||||
# p2wsh case
|
||||
@ -622,6 +646,16 @@ class psbtOutputProxy(psbtProxy):
|
||||
expect_pkh = hash160(expect_pubkey)
|
||||
elif addr_type == "p2tr":
|
||||
if expect_pubkey is None and len(self.taproot_subpaths) > 1:
|
||||
if active_miniscript:
|
||||
try:
|
||||
active_miniscript.validate_script_pubkey(
|
||||
b"\x51\x20" + pkh,
|
||||
[v[1:] for v in self.taproot_subpaths.values() if len(v[1:]) > 1]
|
||||
)
|
||||
self.is_change = True
|
||||
return
|
||||
except Exception as e:
|
||||
raise FraudulentChangeOutput(out_idx, "Change output scriptPubkey: %s" % e)
|
||||
expect_pkh = None
|
||||
else:
|
||||
expect_pkh = taptweak(expect_pubkey)
|
||||
@ -873,6 +907,7 @@ class psbtInputProxy(psbtProxy):
|
||||
# - which pubkey needed
|
||||
# - scriptSig value
|
||||
# - also validates redeem_script when present
|
||||
merkle_root = None
|
||||
self.amount = utxo.nValue
|
||||
|
||||
if (not self.subpaths and not self.taproot_subpaths) or self.fully_signed:
|
||||
@ -883,6 +918,7 @@ class psbtInputProxy(psbtProxy):
|
||||
return
|
||||
|
||||
self.is_multisig = False
|
||||
self.is_miniscript = False
|
||||
self.is_p2sh = False
|
||||
which_key = None
|
||||
|
||||
@ -931,9 +967,13 @@ class psbtInputProxy(psbtProxy):
|
||||
self.is_segwit = True
|
||||
else:
|
||||
# multiple keys involved, we probably can't do the finalize step
|
||||
self.is_multisig = True
|
||||
M, N = disassemble_multisig_mn(redeem_script)
|
||||
if M is None and N is None:
|
||||
self.is_miniscript = True
|
||||
else:
|
||||
self.is_multisig = True
|
||||
|
||||
if self.witness_script and not self.is_segwit and self.is_multisig:
|
||||
if self.witness_script and not self.is_segwit and (self.is_miniscript or self.is_multisig):
|
||||
# bugfix
|
||||
addr_type = 'p2sh-p2wsh'
|
||||
self.is_segwit = True
|
||||
@ -965,7 +1005,28 @@ class psbtInputProxy(psbtProxy):
|
||||
if output_key == pubkey:
|
||||
which_key = xonly_pubkey
|
||||
else:
|
||||
which_key = None
|
||||
# tapscript (is always miniscript wallet)
|
||||
self.is_miniscript = True
|
||||
for xonly_pubkey, lhs_path in self.taproot_subpaths.items():
|
||||
lhs, path = lhs_path[0], lhs_path[1:] # meh - should be a tuple
|
||||
# ignore keys that does not have correct xfp specified in PSBT
|
||||
if path[0] == my_xfp:
|
||||
assert merkle_root is not None, "Merkle root not defined"
|
||||
if not lhs:
|
||||
output_key = taptweak(xonly_pubkey, merkle_root)
|
||||
if output_key == pubkey:
|
||||
which_key = xonly_pubkey
|
||||
# if we find a possibiity to spend keypath (internal_key) - we do keypath
|
||||
# even though script path is available
|
||||
self.use_keypath = True
|
||||
break
|
||||
else:
|
||||
internal_key = self.get(self.taproot_internal_key)
|
||||
output_pubkey = taptweak(internal_key, merkle_root)
|
||||
if not which_key:
|
||||
which_key = set()
|
||||
if pubkey == output_pubkey:
|
||||
which_key.add(xonly_pubkey)
|
||||
|
||||
elif addr_type == 'p2pk':
|
||||
# input is single public key (less common)
|
||||
@ -988,7 +1049,6 @@ class psbtInputProxy(psbtProxy):
|
||||
# - check it's the right M/N to match redeem script
|
||||
|
||||
#print("redeem: %s" % b2a_hex(redeem_script))
|
||||
M, N = disassemble_multisig_mn(redeem_script)
|
||||
xfp_paths = list(self.subpaths.values())
|
||||
xfp_paths.sort()
|
||||
|
||||
@ -1010,6 +1070,27 @@ class psbtInputProxy(psbtProxy):
|
||||
sys.print_exception(exc)
|
||||
raise FatalPSBTIssue('Input #%d: %s' % (my_idx, exc))
|
||||
|
||||
if self.is_miniscript and which_key:
|
||||
try:
|
||||
xfp_paths = [item[1:] for item in self.taproot_subpaths.values() if len(item[1:]) > 1]
|
||||
except AttributeError:
|
||||
xfp_paths = list(self.subpaths.values())
|
||||
|
||||
xfp_paths.sort()
|
||||
if not psbt.active_miniscript:
|
||||
wal = MiniScriptWallet.find_match(xfp_paths)
|
||||
if not wal:
|
||||
raise FatalPSBTIssue('Unknown miniscript wallet')
|
||||
psbt.active_miniscript = wal
|
||||
|
||||
assert psbt.active_miniscript
|
||||
try:
|
||||
# contains PSBT merkle root verification
|
||||
psbt.active_miniscript.validate_script_pubkey(utxo.scriptPubKey,
|
||||
xfp_paths, merkle_root)
|
||||
except BaseException as e:
|
||||
raise FatalPSBTIssue('Input #%d: %s\n\n' % (my_idx, e) + problem_file_line(e))
|
||||
|
||||
if not which_key and DEBUG:
|
||||
print("no key: input #%d: type=%s segwit=%d a_or_pk=%s scriptPubKey=%s" % (
|
||||
my_idx, addr_type, self.is_segwit or 0,
|
||||
@ -1215,6 +1296,7 @@ class psbtObject(psbtProxy):
|
||||
# this points to a MS wallet, during operation
|
||||
# - we are only supporting a single multisig wallet during signing
|
||||
self.active_multisig = None
|
||||
self.active_miniscript = None
|
||||
|
||||
self.warnings = []
|
||||
# not a warning just more info about tx
|
||||
@ -1689,7 +1771,7 @@ class psbtObject(psbtProxy):
|
||||
for idx, txo in self.output_iter():
|
||||
output = self.outputs[idx]
|
||||
# perform output validation
|
||||
output.validate(idx, txo, self.my_xfp, self.active_multisig, self)
|
||||
output.validate(idx, txo, self.my_xfp, self.active_multisig, self.active_miniscript, self)
|
||||
total_out += txo.nValue
|
||||
if output.is_change:
|
||||
self.num_change_outputs += 1
|
||||
@ -1824,8 +1906,8 @@ class psbtObject(psbtProxy):
|
||||
iss = "has different hardening pattern"
|
||||
elif path[0:len(path_prefix)] != path_prefix:
|
||||
iss = "goes to diff path prefix"
|
||||
elif (path[-2] & 0x7fffffff) not in {0, 1}:
|
||||
iss = "2nd last component not 0 or 1"
|
||||
# elif (path[-2] & 0x7fffffff) not in {0, 1}:
|
||||
# iss = "2nd last component not 0 or 1"
|
||||
elif (path[-1] & 0x7fffffff) > idx_max:
|
||||
iss = "last component beyond reasonable gap"
|
||||
else:
|
||||
@ -2332,7 +2414,7 @@ class psbtObject(psbtProxy):
|
||||
r = result[1:33]
|
||||
s = result[33:65]
|
||||
der_sig = ser_sig_der(r, s, inp.sighash)
|
||||
inp.part_sig[pk] = sig
|
||||
inp.part_sig[pk] = der_sig
|
||||
# memory cleanup
|
||||
del result, r, s
|
||||
|
||||
@ -2638,8 +2720,8 @@ class psbtObject(psbtProxy):
|
||||
|
||||
# plus we added some signatures
|
||||
for inp in self.inputs:
|
||||
if inp.is_multisig:
|
||||
# but we can't combine/finalize multisig stuff, so will never't be 'final'
|
||||
if inp.is_multisig or (inp.is_miniscript and not inp.use_keypath):
|
||||
# but we can't combine/finalize multisig/miniscript stuff, so will never't be 'final'
|
||||
return False
|
||||
if inp.part_sig and len(inp.part_sig) == len(inp.subpaths):
|
||||
signed += 1
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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
|
||||
|
||||
129
shared/wallet.py
129
shared/wallet.py
@ -3,12 +3,17 @@
|
||||
# wallet.py - A place you find UTXO, addresses and descriptors.
|
||||
#
|
||||
import chains
|
||||
from descriptor import Descriptor
|
||||
from glob import settings
|
||||
from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR
|
||||
from stash import SensitiveValues
|
||||
|
||||
|
||||
MAX_BIP32_IDX = (2 ** 31) - 1
|
||||
|
||||
class WalletOutOfSpace(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WalletABC:
|
||||
# How to make this ABC useful without consuming memory/code space??
|
||||
# - be more of an "interface" than a base class
|
||||
@ -126,10 +131,128 @@ class MasterSingleSigWallet(WalletABC):
|
||||
|
||||
def to_descriptor(self):
|
||||
from glob import settings
|
||||
from descriptor import Descriptor, Key
|
||||
xfp = settings.get('xfp')
|
||||
xpub = settings.get('xpub')
|
||||
keys = (xfp, self._path, xpub)
|
||||
return Descriptor([keys], self.addr_fmt)
|
||||
d = Descriptor(key=Key.from_cc_data(xfp, self._path, xpub))
|
||||
d.set_from_addr_fmt(self.addr_fmt)
|
||||
return d
|
||||
|
||||
|
||||
class BaseStorageWallet(WalletABC):
|
||||
key_name = None
|
||||
|
||||
def __init__(self, chain_type=None):
|
||||
self.storage_idx = -1
|
||||
self.chain_type = chain_type or 'BTC'
|
||||
|
||||
@property
|
||||
def chain(self):
|
||||
return chains.get_chain(self.chain_type)
|
||||
|
||||
@classmethod
|
||||
def none_setup_yet(cls, other_chain=False):
|
||||
return '(none setup yet)' + ("*" if other_chain else "")
|
||||
|
||||
@classmethod
|
||||
def is_correct_chain(cls, o, curr_chain):
|
||||
if o[1] is None:
|
||||
# mainnet
|
||||
ch = "BTC"
|
||||
else:
|
||||
ch = o[1]
|
||||
|
||||
if ch == curr_chain.ctype:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def exists(cls):
|
||||
# are there any wallets defined?
|
||||
exists = False
|
||||
exists_other_chain = False
|
||||
c = chains.current_key_chain()
|
||||
for o in settings.get(cls.key_name, []):
|
||||
if cls.is_correct_chain(o, c):
|
||||
exists = True
|
||||
else:
|
||||
exists_other_chain = True
|
||||
|
||||
return exists, exists_other_chain
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
# return them all, as a generator
|
||||
return cls.iter_wallets()
|
||||
|
||||
@classmethod
|
||||
def iter_wallets(cls):
|
||||
# - this is only place we should be searching this list, please!!
|
||||
lst = settings.get(cls.key_name, [])
|
||||
c = chains.current_key_chain()
|
||||
|
||||
for idx, rec in enumerate(lst):
|
||||
if cls.is_correct_chain(rec, c):
|
||||
yield cls.deserialize(rec, idx)
|
||||
|
||||
def serialize(self):
|
||||
raise NotImplemented
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, c, idx=-1):
|
||||
raise NotImplemented
|
||||
|
||||
@classmethod
|
||||
def get_by_idx(cls, nth):
|
||||
# instance from index number (used in menu)
|
||||
lst = settings.get(cls.key_name, [])
|
||||
try:
|
||||
obj = lst[nth]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
return cls.deserialize(obj, nth)
|
||||
|
||||
def commit(self):
|
||||
# data to save
|
||||
# - important that this fails immediately when nvram overflows
|
||||
obj = self.serialize()
|
||||
|
||||
v = settings.get(self.key_name, [])
|
||||
orig = v.copy()
|
||||
if not v or self.storage_idx == -1:
|
||||
# create
|
||||
self.storage_idx = len(v)
|
||||
v.append(obj)
|
||||
else:
|
||||
# update in place
|
||||
v[self.storage_idx] = obj
|
||||
|
||||
settings.set(self.key_name, v)
|
||||
|
||||
# save now, rather than in background, so we can recover
|
||||
# from out-of-space situation
|
||||
try:
|
||||
settings.save()
|
||||
except:
|
||||
# back out change; no longer sure of NVRAM state
|
||||
try:
|
||||
settings.set(self.key_name, orig)
|
||||
settings.save()
|
||||
except: pass # give up on recovery
|
||||
|
||||
raise WalletOutOfSpace
|
||||
|
||||
def delete(self):
|
||||
# remove saved entry
|
||||
# - important: not expecting more than one instance of this class in memory
|
||||
assert self.storage_idx >= 0
|
||||
lst = settings.get(self.key_name, [])
|
||||
try:
|
||||
del lst[self.storage_idx]
|
||||
settings.set(self.key_name, lst)
|
||||
settings.save()
|
||||
except IndexError: pass
|
||||
self.storage_idx = -1
|
||||
|
||||
# EOF
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
468
testing/descriptor.py
Normal 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
|
||||
@ -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
|
||||
|
||||
13
testing/devtest/wipe_miniscript.py
Normal file
13
testing/devtest/wipe_miniscript.py
Normal 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()
|
||||
@ -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
1654
testing/test_bsms.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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),
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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
2327
testing/test_miniscript.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user