This commit is contained in:
scgbckbone 2025-06-25 13:54:30 +02:00
parent 0ac89511f1
commit 68ffcecc30
7 changed files with 96 additions and 148 deletions

View File

@ -22,6 +22,7 @@ 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`
* both keys with key origin info `[xfp/p/a/t/h]xpub/<0;1>/*` & blinded keys `xpub/<2;3>/*` allowed
* use of blinded keys for co-signers requires PSBT provider to supply path from current key fingerprint
* maximum number of keys allowed in segwit v0 miniscript is 20
* check MiniTapscript limitations in `docs/taproot.md`

View File

@ -31,7 +31,8 @@ There are 2 methods to provide provably unspendable internal key, if users wish
`tr(xpub/<0:1>/*, sortedmulti_a(2,@0,@1))` which is the same thing as `tr(xpub, sortedmulti_a(2,@0,@1))` because `/<0;1>/*` is implied if not derivation path not provided.
2. **(recommended)** Use `unspend(` [notation](https://gist.github.com/sipa/06c5c844df155d4e5044c2c8cac9c05e#unspendable-keys). Has to be ranged.
### Below option was deprecated in version 6.3.5X & 6.3.5QX
2. Use `unspend(` [notation](https://gist.github.com/sipa/06c5c844df155d4e5044c2c8cac9c05e#unspendable-keys). Has to be ranged.
`tr(unspend(77ec0c0fdb9733e6a3c753b1374c4a465cba80dff52fc196972640a26dd08b76)/<0:1>/*, sortedmulti_a(2,@0,@1))`
@ -58,7 +59,7 @@ Options 4. and 5. are problematic to some extent as internal key is static. Use
In current version only `TREE` of max depth 4 is allowed (max 8 leaf script allowed).
Taproot single leaf multisig has artificial limit of max 32 signers (M=N=32).
Number of keys in taptree is limited to 32.
Number of keys in whole taptree is limited to 32.
If Coldcard can sign by both key path and script path - key path has precedence.
@ -68,9 +69,9 @@ PSBT provider MUST provide following Taproot specific input fields in PSBT:
1. `PSBT_IN_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes.
2. `PSBT_IN_TAP_INTERNAL_KEY` MUST match internal key provided in `PSBT_IN_TAP_BIP32_DERIVATION`
3. `PSBT_IN_TAP_MERKLE_ROOT` MUST be empty if there is no script path. Otherwise it MUST match what Coldcard can calculate from registered descriptor.
4. `PSBT_IN_TAP_LEAF_SCRIPT` MUST be specified if there is a script path. Currently MUST be of length 1 (only one script allowed)
4. `PSBT_IN_TAP_LEAF_SCRIPT` MUST be specified if there is a script path.
PSBT provider MUST provide following Taproot specific output fields in PSBT:
1. `PSBT_OUT_TAP_BIP32_DERIVATION` with all the necessary keys with their leaf hashes and derivation (including XFP). Internal key has to be specified here with empty leaf hashes.
2. `PSBT_OUT_TAP_INTERNAL_KEY` must match internal key provided in `PSBT_OUT_TAP_BIP32_DERIVATION`
3. `PSBT_OUT_TAP_TREE` with depth, leaf version and script defined. Currently only one script is allowed.
3. `PSBT_OUT_TAP_TREE` with depth, leaf version and script defined.

View File

@ -98,12 +98,7 @@ def multisig_descriptor_template(xpub, path, xfp, addr_fmt):
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:
@ -111,14 +106,13 @@ def read_until(s, chars=b",)(#"):
if chunk in chars:
return res, chunk
res += chunk
return res, None
class KeyOriginInfo:
def __init__(self, fingerprint: bytes, derivation: list):
def __init__(self, fingerprint: bytes, derivation: list, cc_fp=None):
self.fingerprint = fingerprint
self.derivation = derivation
self.cc_fp = swab32(int(b2a_hex(self.fingerprint).decode(), 16))
self._cc_fp = cc_fp
def __eq__(self, other):
return self.psbt_derivation() == other.psbt_derivation()
@ -126,6 +120,12 @@ class KeyOriginInfo:
def __hash__(self):
return hash(tuple(self.psbt_derivation()))
@property
def cc_fp(self):
if self._cc_fp is None:
self._cc_fp = ustruct.unpack('<I', self.fingerprint)[0]
return self._cc_fp
def str_derivation(self):
return keypath_to_str(self.derivation, prefix='m/', skip=0)
@ -157,32 +157,13 @@ class KeyDerivationInfo:
def __init__(self, indexes=None):
self.indexes = indexes
if self.indexes is None:
self.indexes = [[0, 1], WILDCARD]
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]]
def __hash__(self):
return hash(self.indexes)
@classmethod
def parse(cls, s):
@ -200,7 +181,7 @@ class KeyDerivationInfo:
assert char, err
multi_i = len(idxs)
idxs.append([int(ext_num.decode()), int(int_num.decode())])
idxs.append((int(ext_num.decode()), int(int_num.decode())))
elif got == b"*":
@ -212,15 +193,16 @@ class KeyDerivationInfo:
assert (b"'" not in got) and (b"h" not in got), "Cannot use hardened sub derivation path"
idxs.append(int(got.decode()))
assert idxs[-1] == WILDCARD, "All keys must be ranged"
if idxs == [0, WILDCARD]:
# normalize and instead save as <0;1> as change derivation was not provided
obj = cls()
else:
assert idxs[-1] == WILDCARD, "All keys must be ranged"
if multi_i is not None:
assert len(idxs[multi_i]) == 2, "wrong multipath"
obj = cls(idxs)
obj = cls(tuple(idxs))
obj.multi_path_index = multi_i
return obj
@ -228,7 +210,7 @@ class KeyDerivationInfo:
def to_string(self, external=True, internal=True):
res = []
for i in self.indexes:
if isinstance(i, list):
if isinstance(i, tuple):
if internal is True and external is False:
i = str(i[1])
elif internal is False and external is True:
@ -240,16 +222,12 @@ class KeyDerivationInfo:
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.derivation = derivation or KeyDerivationInfo()
self.taproot = taproot
self.chain_type = chain_type
@ -258,7 +236,8 @@ class Key:
and self.derivation.indexes == other.derivation.indexes
def __hash__(self):
return hash(self.to_string())
# return hash(self.to_string())
return hash(self.origin) + hash(self.derivation)
def __len__(self):
return 34 - int(self.taproot) # <33:sec> or <32:xonly>
@ -286,17 +265,20 @@ class Key:
origin = KeyOriginInfo.from_string(prefix.decode())
else:
s.seek(-1, 1)
k, char = read_until(s, b",)/")
der = b""
der = None
if char == b"/":
der = KeyDerivationInfo.parse(s)
if char is not None:
s.seek(-1, 1)
# parse key
node, chain_type = cls.parse_key(k)
if origin is None:
origin = KeyOriginInfo(ustruct.pack('<I', swab32(node.my_fp())), [])
return cls(node, origin, der or KeyDerivationInfo(), chain_type=chain_type)
cc_fp = swab32(node.my_fp())
origin = KeyOriginInfo(ustruct.pack('<I', cc_fp), [], cc_fp)
return cls(node, origin, der, chain_type=chain_type)
@classmethod
def parse_key(cls, key_str):
@ -312,7 +294,9 @@ class Key:
node = ngu.hdnode.HDNode()
node.deserialize(key_str)
assert node.privkey() is None
try:
assert node.privkey() is None
except: pass
return node, chain_type
@ -361,13 +345,13 @@ class Key:
new_node = self.node.copy()
new_node.derive(idx, False)
if self.origin:
origin = KeyOriginInfo(self.origin.fingerprint, self.origin.derivation + [idx])
origin = KeyOriginInfo(self.origin.fingerprint, self.origin.derivation + [idx],
self.origin.cc_fp)
else:
fp = ustruct.pack('<I', swab32(self.node.my_fp()))
origin = KeyOriginInfo(fp, [idx])
origin = KeyOriginInfo(self.origin.fingerprint, [idx], self.origin.cc_fp)
derivation = KeyDerivationInfo(self.derivation.indexes[1:])
return type(self)(new_node, origin, derivation, taproot=self.taproot)
return type(self)(new_node, origin, KeyDerivationInfo(self.derivation.indexes[1:]),
taproot=self.taproot)
@classmethod
def read_from(cls, s, taproot=False):
@ -431,34 +415,3 @@ def bip388_wallet_policy_to_descriptor(desc_tmplt, keys_info):
ph = "@%d" % i
desc_tmplt = desc_tmplt.replace(ph, k_str)
return desc_tmplt
# ph_len = len(ph)
# while True:
# ix = policy.find(ph)
# if ix == -1:
# break
#
# assert policy[ix+ph_len] == "/"
# # subderivation is part of the policy
# x = ix + ph_len
# substr = policy[x:x+26] # 26 is the longest possible subderivation allowed "/<2147483647;2147483646>/*"
# mp_start = substr.find("<")
# assert mp_start != -1
# mp_end = substr.find(">")
# 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:]
#
# x = policy[ix:ix + ph_len]
# assert x == ph
# policy = policy[:ix] + k + policy[ix + ph_len:]
#
# return policy

View File

@ -6,7 +6,7 @@ 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, swab32
from utils import 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
@ -14,14 +14,6 @@ from miniscript import Miniscript
from precomp_tag_hash import TAP_BRANCH_H
class DescriptorException(ValueError):
pass
class WrongCheckSumError(Exception):
pass
class Tapscript:
def __init__(self, tree):
self.tree = tree # miniscript or (tapscript, tapscript)
@ -112,8 +104,6 @@ class Tapscript:
s.seek(-1, 1)
ms = Miniscript.read_from(s, taproot=True)
ms.is_sane(taproot=True)
ms.verify()
return cls(ms)
def script_tree(self):
@ -148,32 +138,39 @@ class Descriptor:
self.addr_fmt = addr_fmt
def validate(self):
# should only be run once while importing wallet
from glob import settings
if self.miniscript:
if self.is_basic_multisig:
assert len(self.keys) <= MAX_SIGNERS
else:
assert len(self.keys) <= 20
self.miniscript.verify()
if self.miniscript.type != "B":
raise DescriptorException("Top level miniscript should be 'B'")
has_mine = 0
my_xfp = settings.get('xfp')
c = 0
has_mine = 0
err_top_B = "Top level miniscript should be 'B'"
max_signers = 20
if self.tapscript:
assert self.key # internal key (would fail during parse)
max_signers = MAX_TR_SIGNERS
for l in self.tapscript.iter_leaves():
assert l.type == "B", err_top_B
l.verify()
l.is_sane(taproot=True)
# cannot have same keys in single miniscript
# provably unspendable taproot internal key is not covered here
assert len(l.keys) == len(set(l.keys)), "Insane"
elif self.miniscript:
assert self.key is None
assert self.miniscript.type == "B", err_top_B
self.miniscript.verify()
self.miniscript.is_sane(taproot=False)
# cannot have same keys in single miniscript
assert len(self.miniscript.keys) == len(set(self.miniscript.keys)), "Insane"
my_xfp = settings.get('xfp')
for k in self.keys:
has_mine += k.validate(my_xfp)
c += 1
if self.tapscript:
if self.key.is_provably_unspendable:
c -= 1
assert c <= MAX_TR_SIGNERS
assert self.key # internal key (would fail during parse)
else:
assert self.key is None and self.miniscript, "not miniscript"
assert c <= max_signers, "max signers"
assert has_mine != 0, 'My key %s missing in descriptor.' % xfp2str(my_xfp).upper()
@ -207,11 +204,9 @@ class Descriptor:
for k in self.keys:
if self.is_taproot and k.is_provably_unspendable and skip_unspend_ik:
continue
elif k.origin:
res.append(k.origin.psbt_derivation())
else:
# origin less - TODO should not be here, origin should already be created
res.append([swab32(self.key.node.my_fp())])
res.append(k.origin.psbt_derivation())
return res
@property
@ -301,7 +296,7 @@ class Descriptor:
@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:
@ -328,15 +323,15 @@ class Descriptor:
return desc_w_checksum, None
calc_checksum = descriptor_checksum(desc)
if calc_checksum != checksum:
raise WrongCheckSumError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
raise ValueError("Wrong checksum %s, expected %s" % (checksum, calc_checksum))
return desc, checksum
@classmethod
def from_string(cls, desc, checksum=False):
def from_string(cls, desc, checksum=False, validate=True):
desc = parse_desc_str(desc)
desc, cs = cls.checksum_check(desc)
s = BytesIO(desc.encode())
res = cls.read_from(s)
res = cls.read_from(s, validate)
left = s.read()
if len(left) > 0:
raise ValueError("Unexpected characters after descriptor: %r" % left)
@ -347,7 +342,7 @@ class Descriptor:
return res
@classmethod
def read_from(cls, s):
def read_from(cls, s, validate=True):
start = s.read(8)
af = AF_CLASSIC
internal_key = None
@ -389,7 +384,6 @@ class Descriptor:
nbrackets = 1
elif af in [AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH]:
miniscript = Miniscript.read_from(s)
miniscript.is_sane(taproot=False)
key = internal_key
nbrackets = 1 + int(af == AF_P2WSH_P2SH)
else:
@ -400,9 +394,10 @@ class Descriptor:
if end != b")" * nbrackets:
raise ValueError("Invalid descriptor")
o = cls(key, miniscript, tapscript, af)
o.validate()
return o
desc = cls(key, miniscript, tapscript, af)
if validate:
desc.validate()
return desc
def to_string(self, external=True, internal=True, checksum=True, unspent_compat=False):
if self.is_taproot:

View File

@ -8,7 +8,7 @@ from binascii import hexlify as b2a_hex
from serializations import ser_compact_size, ser_string
from desc_utils import Key, read_until, bip388_wallet_policy_to_descriptor
from public_constants import MAX_TR_SIGNERS, AF_P2TR
from wallet import BaseStorageWallet
from wallet import BaseStorageWallet, MAX_BIP32_IDX
from menu import MenuSystem, MenuItem
from ux import ux_show_story, ux_confirm, ux_dramatic_pause
from files import CardSlot, CardMissingError, needs_microsd
@ -37,7 +37,6 @@ class MiniScriptWallet(BaseStorageWallet):
self.desc = desc
self.addr_fmt = af
self.ik_u = ik_u
# self.chain =
@property
def chain(self):
@ -64,7 +63,8 @@ class MiniScriptWallet(BaseStorageWallet):
else:
desc_str = bip388_wallet_policy_to_descriptor(self.desc_tmplt, self.keys_info)
print("loading... filled policy:\n", desc_str)
self.desc = Descriptor.from_string(desc_str)
# no need to validate already saved descriptor - was validated upon enroll
self.desc = Descriptor.from_string(desc_str, validate=False)
# cache len always 1
glob.DESC_CACHE = {}
glob.DESC_CACHE[self.name] = self.desc
@ -90,12 +90,14 @@ class MiniScriptWallet(BaseStorageWallet):
if addr_fmt is not None:
if rv.addr_fmt != addr_fmt:
continue
if rv.matching_subpaths(xfp_paths):
return rv
return None
def matching_subpaths(self, xfp_paths):
my_xfp_paths = self.to_descriptor().xfp_paths()
if len(xfp_paths) != len(my_xfp_paths):
return False
@ -262,6 +264,8 @@ class MiniScriptWallet(BaseStorageWallet):
dd = self.to_descriptor().derive(None, change=change)
idx = start_idx
while count:
if idx > MAX_BIP32_IDX:
break
# make the redeem script, convert into address
d = dd.derive(idx)
scr = d.miniscript.compile() if d.miniscript else None
@ -712,11 +716,6 @@ class Miniscript:
def is_sane(self, taproot=False):
err = "multi mixin"
keys = self.keys
# cannot have same keys in single miniscript
# provably unspendable taproot internal key is not covered here
# all other keys (miniscript,tapscript) require key origin info
assert len(keys) == len(set(keys)), "Insane"
forbiden = (Sortedmulti, Multi) if taproot else (Sortedmulti_a, Multi_a)
assert type(self) not in forbiden, err
@ -736,11 +735,10 @@ class Miniscript:
def derive(self, idx, key_map=None, change=False):
args = []
for arg in self.args:
if hasattr(arg, "derive"):
if isinstance(arg, Key): # KeyHash is subclass of Key
arg = self.key_derive(arg, idx, key_map, change=change)
else:
arg = arg.derive(idx, key_map, change)
if isinstance(arg, Key): # KeyHash is subclass of Key
arg = self.key_derive(arg, idx, key_map, change=change)
elif hasattr(arg, "derive"):
arg = arg.derive(idx, key_map, change)
args.append(arg)
return type(self)(*args)

View File

@ -13,7 +13,7 @@ from descriptor import Descriptor
from miniscript import Key, Sortedmulti, Number, Multi
from desc_utils import multisig_descriptor_template
from public_constants import AF_P2SH, AF_P2WSH_P2SH, AF_P2WSH, AFC_SCRIPT, MAX_SIGNERS, AF_P2TR, AF_CLASSIC
from menu import MenuSystem, MenuItem, NonDefaultMenuItem, start_chooser
from menu import MenuSystem, MenuItem, start_chooser
from opcodes import OP_CHECKMULTISIG
from exceptions import FatalPSBTIssue
from glob import settings

View File

@ -280,7 +280,7 @@ def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu,
wal_name = m[-1]
pick_menu_item(wal_name)
time.sleep(5)
time.sleep(1)
if way == "qr":
need_keypress(KEY_QR)
cc_addrs = []
@ -296,13 +296,13 @@ def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu,
press_select()
time.sleep(2)
time.sleep(1)
title, story = cap_story()
assert "change addresses." in story and "(0)" in story
need_keypress("0")
time.sleep(2)
time.sleep(1)
title, story = cap_story()
assert "(0)" not in story
@ -347,7 +347,7 @@ def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu,
else:
external_desc = desc["desc"]
time.sleep(5)
time.sleep(1)
if export_check:
desc_export = miniscript_descriptors(cc_minsc_name)