diff --git a/hwilib/commands.py b/hwilib/commands.py index 7371a5356..727c4cdb9 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -42,6 +42,7 @@ RegisteredDescriptor, parse_descriptor, MultisigDescriptor, + MusigPubkeyProvider, TRDescriptor, PKHDescriptor, PubkeyProvider, @@ -511,7 +512,11 @@ def displayaddress( return {"address": client.display_multisig_address(addr_type, descriptor)} is_wpkh = isinstance(descriptor, WPKHDescriptor) if isinstance(descriptor, PKHDescriptor) or is_wpkh or isinstance(descriptor, TRDescriptor): + if descriptor.subdescriptors: + raise BadArgumentError("tr() descriptors with a script tree require a registered BIP 388 policy; use --registration") pubkey = descriptor.pubkeys[0] + if isinstance(pubkey, MusigPubkeyProvider): + raise BadArgumentError("musig() addresses require a registered BIP 388 policy; use --registration") if pubkey.origin is None: raise BadArgumentError(f"Descriptor missing origin info: {desc}") if pubkey.origin.fingerprint != client.get_master_fingerprint(): diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index 1373ad2d2..de13d108d 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -13,6 +13,7 @@ from .key import ( ExtendedKey, KeyOriginInfo, + is_hardened, parse_multipath, multipath_to_string, path_to_string, @@ -32,15 +33,26 @@ from enum import Enum from io import BufferedReader, BytesIO from typing import ( + Callable, + Dict, List, Optional, Tuple, + Union, ) MAX_TAPROOT_NODES = 128 +_MINISCRIPT_WRAPPERS = set("acdjlnstuv") +_MINISCRIPT_KEY_FRAGMENTS = {"pk", "pk_k", "pk_h", "pkh"} +_MINISCRIPT_TAPSCRIPT_MULTI_FRAGMENTS = {"multi_a", "sortedmulti_a"} +_MINISCRIPT_TIMELOCK_FRAGMENTS = {"older", "after"} +_MINISCRIPT_HASH_FRAGMENTS = {"sha256": 64, "hash256": 64, "ripemd160": 40, "hash160": 40} +_MINISCRIPT_BINARY_FRAGMENTS = {"and_v", "and_b", "and_n", "or_b", "or_c", "or_d", "or_i"} + + def PolyMod(c: int, val: int) -> int: """ :meta private: @@ -106,6 +118,28 @@ def AddChecksum(desc: str) -> str: return desc + "#" + DescriptorChecksum(desc) +def _parse_ranged_deriv_path(path_str: str) -> Tuple[Optional[List[List[int]]], bool]: + """ + :meta private: + + Parse a derivation path suffix that may end with a ``/*`` range marker. + + :param path_str: The derivation path, without the leading ``/`` that separates it from the key + :return: The multipath derivation path, or ``None`` if there is none, and whether the path is ranged + :raises: ValueError: if the derivation path is malformed + """ + ranged = path_str.endswith("*") + if ranged: + if path_str == "*": + path_str = "" + elif path_str.endswith("/*"): + path_str = path_str[:-2] + else: + raise ValueError(f"Invalid ranged derivation path: /{path_str}") + deriv_path = parse_multipath(path_str) if path_str else None + return deriv_path, ranged + + class PubkeyProvider(object): """ A public key expression in a descriptor. @@ -124,7 +158,8 @@ def __init__( :param origin: The key origin if one is available :param pubkey: The public key. Either a hex string or a serialized extended pubkey :param deriv_path: Additional derivation path if the pubkey is an extended pubkey - :param expr_index: The position of this key within the descriptor + :param expr_index: The index of this key in the BIP 388 Key information vector. + A key that appears multiple times in a descriptor uses the same index everywhere. """ self.origin = origin self.pubkey = pubkey @@ -155,6 +190,8 @@ def parse(cls, s: str, key_expr_index: int) -> 'PubkeyProvider': deriv_path = None ranged = False + if not s: + raise ValueError("Empty key expression") if s[0] == "[": end = s.index("]") origin = KeyOriginInfo.from_string(s[1:end]) @@ -164,12 +201,7 @@ def parse(cls, s: str, key_expr_index: int) -> 'PubkeyProvider': slash_idx = s.find("/") if slash_idx != -1: pubkey = s[:slash_idx] - path_str = s[slash_idx + 1:] - ranged = path_str.endswith("*") - if ranged: - path_str = path_str[:-2] - if len(path_str) > 0: - deriv_path = parse_multipath(path_str) + deriv_path, ranged = _parse_ranged_deriv_path(s[slash_idx + 1:]) return cls(origin, pubkey, deriv_path, key_expr_index, ranged) @@ -243,16 +275,34 @@ def get_bip388_placeholder(self) -> str: - All multipath specifiers must be exactly 2 items. :return: The key placeholder expression + :raises InvalidPolicyError: If the pubkey does not meet the requirements for a wallet policy as specified in BIP 388 + """ + self._check_bip388_deriv_path() + return f"@{self.expr_index}{self._get_bip388_deriv_suffix()}" + + def _check_bip388_deriv_path(self) -> None: + """ + :meta private: + + Check the BIP 388 requirements on this pubkey's derivation path. + :raises InvalidPolicyError: If the pubkey does not meet the requirements for a wallet policy as specified in BIP 388 """ if not self.ranged: raise InvalidPolicyError("BIP 388 requires all pubkeys to be ranged") if self.multipath_len > 2: raise InvalidPolicyError("BIP 388 requires all multipath specifiers to be exactly 2 elements") + + def _get_bip388_deriv_suffix(self) -> str: + """ + :meta private: + + Get the derivation path suffix for this pubkey's BIP 388 key placeholder expression. + + :return: The derivation path suffix, including the ``/*`` range marker + """ deriv_path = multipath_to_string(self.deriv_path, hardened_char="'") if self.deriv_path else "" - if self.ranged: - deriv_path += "/*" - return f"@{self.expr_index}{deriv_path}" + return deriv_path + "/*" def get_bip388_key_info(self) -> str: """ @@ -272,6 +322,100 @@ def __lt__(self, other: 'PubkeyProvider') -> bool: return self.pubkey < other.pubkey +class MusigPubkeyProvider(PubkeyProvider): + """ + A ``musig()`` aggregate key expression with a shared derivation path, as specified in BIP 390. + """ + + def __init__( + self, + participants: List['PubkeyProvider'], + deriv_path: Optional[List[List[int]]], + ranged: bool, + ) -> None: + r""" + :param participants: The :class:`PubkeyProvider`\ s aggregated by this ``musig()`` expression + :param deriv_path: Derivation path for the aggregate key + :param ranged: Whether the aggregate key is ranged + """ + super().__init__(None, "", deriv_path, participants[0].expr_index, ranged) + self.participants = participants + + @classmethod + def parse_musig(cls, s: str, key_expr_index: int) -> Tuple['MusigPubkeyProvider', int]: + """ + Deserialize a ``musig()`` key expression from the string into a ``MusigPubkeyProvider``. + + :param s: String containing the ``musig()`` key expression + :param key_expr_index: The position of the first participant key within the descriptor + :return: A new ``MusigPubkeyProvider`` and the position of the next key expression + :raises: ValueError: if the ``musig()`` key expression is malformed + """ + func, expr = _get_func_expr(s) + if func != "musig": + raise ValueError(f"Expected musig() key expression, got {func}()") + + suffix = s[s.rindex(")") + 1:] + deriv_path = None + ranged = False + if suffix: + if not suffix.startswith("/"): + raise ValueError("MuSig derivation path must begin with '/'") + deriv_path, ranged = _parse_ranged_deriv_path(suffix[1:]) + + for path in deriv_path or []: + for step in path: + if is_hardened(step): + raise ValueError("musig() cannot have hardened derivation steps") + + participants = [] + while expr: + if expr.startswith("musig("): + raise ValueError("musig() key expressions cannot be nested") + participant, expr, key_expr_index = parse_pubkey(expr, key_expr_index) + participants.append(participant) + if len(participants) < 2: + raise ValueError("musig() requires at least two participants") + if deriv_path is not None or ranged: + for participant in participants: + if participant.extkey is None: + raise ValueError("musig() derivation requires extended public key participants") + if participant.ranged or participant.multipath_len > 1: + raise ValueError("musig() participants cannot be ranged or multipath when musig() itself has a derivation path") + return cls(participants, deriv_path, ranged), key_expr_index + + def to_string(self, hardened_char: str = "h") -> str: + """ + Serialize the ``musig()`` expression to a string to be used in a descriptor + + :return: The ``musig()`` expression as a string + """ + participants = ",".join(p.to_string(hardened_char) for p in self.participants) + result = f"musig({participants})" + if self.deriv_path: + result += multipath_to_string(self.deriv_path, hardened_char) + if self.ranged: + result += "/*" + return result + + def get_bip388_placeholder(self) -> str: + """ + Get the key placeholder expression for this ``musig()`` expression to be used in BIP 388 Wallet Policies. + + :return: The key placeholder expression + :raises InvalidPolicyError: If the aggregate key does not meet the requirements for a wallet policy as specified in BIP 388 + """ + self._check_bip388_deriv_path() + for participant in self.participants: + if participant.deriv_path is not None or participant.ranged: + raise InvalidPolicyError("BIP 388 requires all derivation to follow musig() aggregation") + participants = ",".join(f"@{p.expr_index}" for p in self.participants) + return f"musig({participants}){self._get_bip388_deriv_suffix()}" + + def get_pubkey_bytes(self, pos: int, multipath_pos: int = 0) -> bytes: + raise NotImplementedError("HWI cannot expand musig() aggregate keys") + + class Descriptor(object): r""" An abstract class for Descriptors themselves. @@ -338,23 +482,51 @@ def get_bip388_template(self) -> str: ) def get_pubkey_providers(self) -> list['PubkeyProvider']: - """ - Get the strings of all pubkey expressions contained in this descriptor, - in the same order that they appear in the descriptor string. These can be used with + r""" + Get the individual pubkey expressions contained in this descriptor, in the order in + which they first appear in the descriptor string. A ``musig()`` aggregate key is + replaced by its participant keys, and a key that appears more than once is returned + only once, matching the BIP 388 Key information vector, so these can be used with :func:`get_bip388_template` to get a full BIP 388 Wallet Policy for this descriptor. - :return: List of pubkey expression strings + :return: List of :class:`PubkeyProvider`\ s """ - out = [p for p in self.pubkeys] - for s in self.subdescriptors: - out.extend(s.get_pubkey_providers()) + out: Dict[str, 'PubkeyProvider'] = {} + for pubkey in self.get_derivation_providers(): + participants = pubkey.participants if isinstance(pubkey, MusigPubkeyProvider) else [pubkey] + for participant in participants: + out.setdefault(participant.get_bip388_key_info(), participant) + return list(out.values()) + + def get_derivation_providers(self) -> list['PubkeyProvider']: + r""" + Get the key expressions contained in this descriptor whose derivation path suffixes + belong to the descriptor, in the same order that they appear in the descriptor + string, including keys that appear more than once. Unlike + :func:`get_pubkey_providers`, a ``musig()`` aggregate key with its own derivation + path suffix is returned as a single :class:`MusigPubkeyProvider`, since the suffix + applies to the aggregate key. Participant keys are returned for a ``musig()`` + without a derivation path suffix, where any derivation happens on the participant + keys before aggregation. + + :return: List of :class:`PubkeyProvider`\ s + """ + out: list['PubkeyProvider'] = [] + for pubkey in self.pubkeys: + if isinstance(pubkey, MusigPubkeyProvider) and pubkey.deriv_path is None and not pubkey.ranged: + # Without an aggregate derivation path, derivation happens on the participant keys + out.extend(pubkey.participants) + else: + out.append(pubkey) + for subdescriptor in self.subdescriptors: + out.extend(subdescriptor.get_derivation_providers()) return out def derive(self, pos: int, multipath_index: int = 0) -> 'Descriptor': """Select a multipath entry and address index from a ranged descriptor.""" descriptor = deepcopy(self) - for pubkey in descriptor.get_pubkey_providers(): + for pubkey in descriptor.get_derivation_providers(): path = pubkey.get_deriv_path(pos, multipath_index) pubkey.deriv_path = [[step] for step in path] or None pubkey.ranged = False @@ -532,6 +704,54 @@ def get_bip388_template(self) -> str: return r +class MiniscriptDescriptor(Descriptor): + """ + A Miniscript expression contained in a descriptor + """ + + def __init__( + self, + wrappers: str, + name: str, + args: List[Union[str, 'PubkeyProvider', 'MiniscriptDescriptor']] + ) -> None: + """ + :param wrappers: The Miniscript wrappers applied to this fragment, without the ``:`` separator + :param name: The name of the Miniscript fragment + :param args: The fragment arguments: key expressions, nested Miniscript expressions, + and verbatim strings for numbers and hashes + """ + pubkeys = [arg for arg in args if isinstance(arg, PubkeyProvider)] + subdescriptors: List[Descriptor] = [arg for arg in args if isinstance(arg, MiniscriptDescriptor)] + super().__init__(pubkeys, subdescriptors, name) + self.wrappers = wrappers + self.args = args + + def _serialize(self, serialize_arg: Callable[[Union[str, 'PubkeyProvider', 'MiniscriptDescriptor']], str]) -> str: + prefix = f"{self.wrappers}:" if self.wrappers else "" + if not self.args: + return prefix + self.name + return "{}{}({})".format(prefix, self.name, ",".join(serialize_arg(arg) for arg in self.args)) + + def to_string_no_checksum(self, hardened_char: str = "h") -> str: + def serialize_arg(arg: Union[str, 'PubkeyProvider', 'MiniscriptDescriptor']) -> str: + if isinstance(arg, MiniscriptDescriptor): + return arg.to_string_no_checksum(hardened_char) + if isinstance(arg, PubkeyProvider): + return arg.to_string(hardened_char) + return arg + return self._serialize(serialize_arg) + + def get_bip388_template(self) -> str: + def serialize_arg(arg: Union[str, 'PubkeyProvider', 'MiniscriptDescriptor']) -> str: + if isinstance(arg, MiniscriptDescriptor): + return arg.get_bip388_template() + if isinstance(arg, PubkeyProvider): + return arg.get_bip388_placeholder() + return arg + return self._serialize(serialize_arg) + + def _get_func_expr(s: str) -> Tuple[str, str]: """ Get the function name and then the expression inside @@ -558,6 +778,8 @@ def _get_const(s: str, const: str) -> str: :return: The remainder of the string without the constant character :raises: ValueError: if the first character is not the constant character """ + if not s: + raise ValueError(f"Expected '{const}' but reached the end") if s[0] != const: raise ValueError(f"Expected '{const}' but got '{s[0]}'") return s[1:] @@ -599,6 +821,8 @@ def parse_pubkey(expr: str, key_expr_index: int) -> Tuple['PubkeyProvider', str, if comma_idx != -1: end = comma_idx next_expr = expr[end + 1:] + if not next_expr: + raise ValueError("Trailing comma after key expression") return PubkeyProvider.parse(expr[:end], key_expr_index), next_expr, (key_expr_index + 1) @@ -619,8 +843,166 @@ class _ParseDescriptorContext(Enum): P2WSH = 3 """Within a ``wsh()`` descriptor""" - P2TR = 4 - """Within a ``tr()`` descriptor""" +class _MiniscriptContext(Enum): + """ + :meta private: + + Enum representing the script version used to interpret a Miniscript expression. + """ + + SEGWIT_V0 = 1 + """A Segwit v0 witness script""" + + TAPSCRIPT = 2 + """A Taproot leaf script""" + + +def _parse_miniscript_num(name: str, arg: str) -> int: + if not arg.isdigit(): + raise ValueError(f"{name}() argument must be a number, got {arg}") + return int(arg) + + +def _parse_miniscript( + expr: str, + key_expr_index: int, + ctx: '_MiniscriptContext', +) -> Tuple['MiniscriptDescriptor', int]: + """ + :meta private: + + Parse a Miniscript expression. Only the structure of the expression is + validated; Miniscript type checking is left to the device. + + :param expr: The Miniscript expression to parse + :param key_expr_index: The position of the next key expression within the descriptor + :param ctx: The script version used to interpret the Miniscript expression + :return: The parsed :class:`MiniscriptDescriptor` and the position of the next key expression + :raises: ValueError: if the Miniscript expression is malformed + """ + wrappers = "" + paren_idx = expr.find("(") + colon_idx = expr.find(":") + if colon_idx != -1 and (paren_idx == -1 or colon_idx < paren_idx): + wrappers = expr[:colon_idx] + expr = expr[colon_idx + 1:] + if not wrappers: + raise ValueError("Missing Miniscript wrapper before ':'") + for wrapper in wrappers: + if wrapper not in _MINISCRIPT_WRAPPERS: + raise ValueError(f"Unknown Miniscript wrapper: {wrapper}") + paren_idx = expr.find("(") + + if expr in ("0", "1"): + return MiniscriptDescriptor(wrappers, expr, []), key_expr_index + + if paren_idx == -1 or not expr.endswith(")"): + raise ValueError(f"Invalid Miniscript expression: {expr}") + name = expr[:paren_idx] + + arg_strs = [] + rest = expr[paren_idx + 1:-1] + while rest: + arg, rest = _get_expr(rest) + if not arg: + raise ValueError(f"Empty argument in {name}()") + arg_strs.append(arg) + if rest: + rest = _get_const(rest, ",") + if not rest: + raise ValueError(f"Trailing comma in {name}()") + + args: List[Union[str, 'PubkeyProvider', 'MiniscriptDescriptor']] = [] + if name in _MINISCRIPT_KEY_FRAGMENTS: + if len(arg_strs) != 1: + raise ValueError(f"{name}() takes exactly one key expression") + if ctx != _MiniscriptContext.TAPSCRIPT and arg_strs[0].startswith("musig("): + raise ValueError("musig() is only allowed in tapscript Miniscript") + key, key_expr_index = _parse_key_expr(arg_strs[0], key_expr_index) + args.append(key) + elif name == "multi": + if ctx != _MiniscriptContext.SEGWIT_V0: + raise ValueError("multi() is only allowed in Segwit v0 Miniscript") + if len(arg_strs) < 2: + raise ValueError("multi() takes a threshold and at least one key expression") + if len(arg_strs) - 1 > 20: + raise ValueError("multi() supports at most 20 keys") + thresh = _parse_miniscript_num(name, arg_strs[0]) + if not 1 <= thresh <= len(arg_strs) - 1: + raise ValueError("multi() threshold must be between 1 and the number of keys") + args.append(arg_strs[0]) + for arg_str in arg_strs[1:]: + args.append(PubkeyProvider.parse(arg_str, key_expr_index)) + key_expr_index += 1 + elif name in _MINISCRIPT_TAPSCRIPT_MULTI_FRAGMENTS: + if ctx != _MiniscriptContext.TAPSCRIPT: + raise ValueError(f"{name}() is only allowed in tapscript Miniscript") + if len(arg_strs) < 2: + raise ValueError(f"{name}() takes a threshold and at least one key expression") + if len(arg_strs) - 1 > 999: + raise ValueError(f"{name}() supports at most 999 keys") + thresh = _parse_miniscript_num(name, arg_strs[0]) + if not 1 <= thresh <= len(arg_strs) - 1: + raise ValueError(f"{name}() threshold must be between 1 and the number of keys") + args.append(arg_strs[0]) + for arg_str in arg_strs[1:]: + key, key_expr_index = _parse_key_expr(arg_str, key_expr_index) + args.append(key) + elif name in _MINISCRIPT_TIMELOCK_FRAGMENTS: + if len(arg_strs) != 1: + raise ValueError(f"{name}() takes exactly one number") + locktime = _parse_miniscript_num(name, arg_strs[0]) + if not 1 <= locktime < 2**31: + raise ValueError(f"{name}() locktime must be between 1 and 2**31 - 1") + args.append(arg_strs[0]) + elif name in _MINISCRIPT_HASH_FRAGMENTS: + if len(arg_strs) != 1: + raise ValueError(f"{name}() takes exactly one hash") + hash_len = _MINISCRIPT_HASH_FRAGMENTS[name] + try: + hash_bytes = unhexlify(arg_strs[0]) + except Exception: + raise ValueError(f"{name}() takes a {hash_len} character hex string") + if len(hash_bytes) * 2 != hash_len: + raise ValueError(f"{name}() takes a {hash_len} character hex string") + args.append(arg_strs[0]) + elif name == "andor" or name in _MINISCRIPT_BINARY_FRAGMENTS: + num_args = 3 if name == "andor" else 2 + if len(arg_strs) != num_args: + raise ValueError(f"{name}() takes exactly {num_args} Miniscript expressions") + for arg_str in arg_strs: + sub, key_expr_index = _parse_miniscript(arg_str, key_expr_index, ctx) + args.append(sub) + elif name == "thresh": + if len(arg_strs) < 2: + raise ValueError("thresh() takes a threshold and at least one Miniscript expression") + thresh = _parse_miniscript_num(name, arg_strs[0]) + if not 1 <= thresh <= len(arg_strs) - 1: + raise ValueError("thresh() threshold must be between 1 and the number of subexpressions") + args.append(arg_strs[0]) + for arg_str in arg_strs[1:]: + sub, key_expr_index = _parse_miniscript(arg_str, key_expr_index, ctx) + args.append(sub) + else: + raise ValueError(f"Unknown Miniscript fragment: {name}") + + return MiniscriptDescriptor(wrappers, name, args), key_expr_index + + +def _parse_key_expr(expr: str, key_expr_index: int) -> Tuple['PubkeyProvider', int]: + """ + :meta private: + + Parse a single key expression, which may be a ``musig()`` aggregate key. + + :param expr: The key expression to parse + :param key_expr_index: The position of the key within the descriptor + :return: The parsed :class:`PubkeyProvider` and the position of the next key expression + :raises: ValueError: if the key expression is malformed + """ + if expr.startswith("musig("): + return MusigPubkeyProvider.parse_musig(expr, key_expr_index) + return PubkeyProvider.parse(expr, key_expr_index), key_expr_index + 1 def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: int) -> Tuple['Descriptor', int]: @@ -636,8 +1018,15 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: :return: The parsed descriptor as the first item, and the index of the next key expression as the second. :raises: ValueError: if the descriptor is malformed """ - func, expr = _get_func_expr(desc) + try: + func, expr = _get_func_expr(desc) + except ValueError: + if ctx == _ParseDescriptorContext.P2WSH: + return _parse_miniscript(desc, key_expr_index, _MiniscriptContext.SEGWIT_V0) + raise if func == "pk": + if expr.startswith("musig("): + raise ValueError("musig() is only allowed in tr() descriptors") pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("more than one pubkey in pk descriptor") @@ -645,6 +1034,8 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: if func == "pkh": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): raise ValueError("Can only have pkh at top level, in sh(), or in wsh()") + if expr.startswith("musig("): + raise ValueError("musig() is only allowed in tr() descriptors") pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("More than one pubkey in pkh descriptor") @@ -678,6 +1069,8 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: if func == "wpkh": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): raise ValueError("Can only have wpkh() at top level or inside sh()") + if expr.startswith("musig("): + raise ValueError("musig() is only allowed in tr() descriptors") pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("More than one pubkey in pkh descriptor") @@ -696,12 +1089,14 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: if ctx != _ParseDescriptorContext.TOP: raise ValueError("Can only have tr at top level") multipath_len = None - internal_key, expr, key_expr_index = parse_pubkey(expr, key_expr_index) + internal_expr, expr = _get_expr(expr) + internal_key, key_expr_index = _parse_key_expr(internal_expr, key_expr_index) if internal_key.multipath_len > 1: multipath_len = internal_key.multipath_len - subscripts = [] + subscripts: List[Descriptor] = [] depths = [] if expr: + expr = _get_const(expr, ",") # Path from top of the tree to what we're currently processing. # branches[i] == False: left branch in the i'th step from the top # branches[i] == true: right branch @@ -718,8 +1113,12 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: raise ValueError("tr() supports at most {MAX_TAPROOT_NODES} nesting levels") # Process script expression sarg, expr = _get_expr(expr) - subdesc, key_expr_index = _parse_descriptor(sarg, _ParseDescriptorContext.P2TR, key_expr_index) - for pub in subdesc.pubkeys: + subdesc, key_expr_index = _parse_miniscript( + sarg, + key_expr_index, + _MiniscriptContext.TAPSCRIPT, + ) + for pub in subdesc.get_derivation_providers(): if pub.multipath_len > 1: if multipath_len is None: multipath_len = pub.multipath_len @@ -742,7 +1141,7 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: if ctx == _ParseDescriptorContext.P2SH: raise ValueError("A function is needed within P2SH") elif ctx == _ParseDescriptorContext.P2WSH: - raise ValueError("A function is needed within P2WSH") + return _parse_miniscript(desc, key_expr_index, _MiniscriptContext.SEGWIT_V0) raise ValueError("{} is not a valid descriptor function".format(func)) @@ -762,7 +1161,16 @@ def parse_descriptor(desc: str) -> 'Descriptor': computed = DescriptorChecksum(desc) if computed != checksum: raise ValueError("The checksum does not match; Got {}, expected {}".format(checksum, computed)) - return _parse_descriptor(desc, _ParseDescriptorContext.TOP, 0)[0] + descriptor = _parse_descriptor(desc, _ParseDescriptorContext.TOP, 0)[0] + + # A key that appears more than once must use a single index in the + # BIP 388 Key information vector. + indexes: Dict[str, int] = {} + for pubkey in descriptor.get_derivation_providers(): + participants = pubkey.participants if isinstance(pubkey, MusigPubkeyProvider) else [pubkey] + for participant in participants: + participant.expr_index = indexes.setdefault(participant.get_bip388_key_info(), len(indexes)) + return descriptor class RegisteredDescriptor: """ diff --git a/test/data/speculos-automation.json b/test/data/speculos-automation.json index c228efa54..1705427aa 100644 --- a/test/data/speculos-automation.json +++ b/test/data/speculos-automation.json @@ -33,7 +33,7 @@ ] }, { - "regexp": "^(Address|Review|Account name|Amount|External amounts|You spend|You receive|Confirm|The derivation|Derivation path|Reject if|The change path|Change path|Register wallet|Policy map|Key|Path|Public key|Our key|Their key|Unspendable key|Spend from|Spending policy|Primary spending path|Spending path|Transaction output|From account|Wallet name|Wallet policy|Descriptor template|Verify [Bb]itcoin|Output|Warning).*", + "regexp": "^(Address|Review|Account name|Amount|External amounts|You spend|You receive|Confirm|The derivation|Derivation path|Reject if|The change path|Change path|Register wallet|Policy map|Key|Path|Public key|Our key|Their key|Unspendable key|Spend from|Spending policy|Primary.*ing path|Spending path|Transaction output|From account|Wallet name|Wallet policy|Descriptor template|Verify [Bb]itcoin|Output|Warning).*", "actions": [ [ "button", 2, true ], [ "button", 2, false ] diff --git a/test/test_bitbox02.py b/test/test_bitbox02.py index 6dae9a907..30addc3c1 100644 --- a/test/test_bitbox02.py +++ b/test/test_bitbox02.py @@ -19,6 +19,8 @@ TestGetKeypool, TestGetDescriptors, TestRegisterDescriptor, + TestSegwitMiniscriptDisplay, + TestTaprootMiniscriptDisplay, TestSignTx, ) @@ -40,6 +42,9 @@ def __init__(self, simulator): self.supports_xpub_ms_display = False self.supports_unsorted_ms = False self.supports_taproot = False + self.supports_segwit_miniscript = True + self.supports_taproot_miniscript = True + self.supports_musig2 = False self.strict_bip48 = False self.include_xpubs = True self.supports_device_multiple_multisig = True @@ -138,6 +143,8 @@ def bitbox02_test_suite(simulator, bitcoind, interface): sorted=False, supports_multiple_policies=False, )) + suite.addTest(DeviceTestCase.parameterize(TestSegwitMiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestTaprootMiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_coldcard.py b/test/test_coldcard.py index db978e41e..b298834bb 100755 --- a/test/test_coldcard.py +++ b/test/test_coldcard.py @@ -21,7 +21,11 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestMuSig2Display, + TestMuSig2MiniscriptDisplay, TestRegisterDescriptor, + TestSegwitMiniscriptDisplay, + TestTaprootMiniscriptDisplay, TestSignMessage, TestSignTx, ) @@ -63,6 +67,9 @@ def __init__(self, simulator, is_edge=False): self.supports_xpub_ms_display = False self.supports_unsorted_ms = False self.supports_taproot = is_edge + self.supports_segwit_miniscript = is_edge + self.supports_taproot_miniscript = is_edge + self.supports_musig2 = is_edge self.strict_bip48 = False self.include_xpubs = False self.supports_device_multiple_multisig = True @@ -208,8 +215,12 @@ def coldcard_test_suite(simulator, bitcoind, interface, is_edge=False): returns_registration=False, supports_multiple_policies=is_edge, )) + suite.addTest(DeviceTestCase.parameterize(TestSegwitMiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) if is_edge: suite.addTest(DeviceTestCase.parameterize(TestColdcardEdgeDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestTaprootMiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestMuSig2Display, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestMuSig2MiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_descriptor.py b/test/test_descriptor.py index dca4fbe80..187297f98 100755 --- a/test/test_descriptor.py +++ b/test/test_descriptor.py @@ -1,6 +1,8 @@ #! /usr/bin/env python3 +from hwilib.commands import displayaddress from hwilib.descriptor import ( + MiniscriptDescriptor, parse_descriptor, MultisigDescriptor, SHDescriptor, @@ -10,11 +12,184 @@ WSHDescriptor, ) from hwilib.common import AddressType -from hwilib.errors import InvalidPolicyError +from hwilib.errors import BadArgumentError, InvalidPolicyError +import re import unittest class TestDescriptor(unittest.TestCase): + def test_segwit_miniscript_policy(self): + key_0 = "[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw" + key_1 = "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7" + multipath = "<0;1>/*" + descriptor = ( + f"wsh(and_v(v:pk({key_0}/{multipath})," + f"or_d(pk({key_1}/{multipath}),older(12960))))" + ) + parsed = parse_descriptor(descriptor) + self.assertIsInstance(parsed, WSHDescriptor) + self.assertIsInstance(parsed.subdescriptors[0], MiniscriptDescriptor) + self.assertEqual(parsed.to_string_no_checksum(hardened_char="'"), descriptor) + self.assertEqual( + parsed.get_bip388_template(), + "wsh(and_v(v:pk(@0/<0;1>/*),or_d(pk(@1/<0;1>/*),older(12960))))", + ) + self.assertEqual( + [provider.get_bip388_key_info() for provider in parsed.get_pubkey_providers()], + [key_0, key_1], + ) + + def test_invalid_segwit_miniscript(self): + key = "[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw" + multipath = "<0;1>/*" + with self.assertRaisesRegex(ValueError, "Unknown Miniscript fragment: unknown"): + parse_descriptor(f"wsh(unknown({key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "Unknown Miniscript fragment: Pk"): + parse_descriptor(f"wsh(Pk({key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "Unknown Miniscript wrapper: x"): + parse_descriptor(f"wsh(x:pk({key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "Invalid Miniscript expression"): + parse_descriptor(f"wsh(and_v(v:pk({key}/{multipath}),older(1)") + with self.assertRaisesRegex(ValueError, "takes exactly one number"): + parse_descriptor("wsh(older(1,2))") + with self.assertRaisesRegex(ValueError, "argument must be a number"): + parse_descriptor("wsh(older(-1))") + with self.assertRaisesRegex(ValueError, "character hex string"): + parse_descriptor("wsh(sha256(abcd))") + with self.assertRaisesRegex(ValueError, "threshold must be between"): + parse_descriptor(f"wsh(and_v(v:pk({key}/{multipath}),multi(2,{key}/{multipath})))") + with self.assertRaisesRegex(ValueError, "only allowed in tapscript"): + parse_descriptor(f"wsh(and_v(v:pk({key}/{multipath}),multi_a(1,{key}/{multipath})))") + with self.assertRaisesRegex(ValueError, "Empty argument"): + parse_descriptor("wsh(and_v(,older(1)))") + + # A non-ranged key parses, but is not a valid BIP 388 policy. + non_ranged = parse_descriptor(f"wsh(and_v(v:pk({key}),older(1)))") + with self.assertRaisesRegex(InvalidPolicyError, "ranged"): + non_ranged.get_bip388_template() + + # Segwit v0 limits multi() to 20 keys. + keys = ",".join([f"{key}/{multipath}"] * 21) + with self.assertRaisesRegex(ValueError, "at most 20 keys"): + parse_descriptor(f"wsh(and_v(v:pk({key}/{multipath}),multi(1,{keys})))") + + def test_tapscript_miniscript_policy(self): + key_0 = "[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw" + key_1 = "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7" + recovery_key = "[6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL" + multipath = "<0;1>/*" + descriptor = ( + f"tr({recovery_key}/{multipath},{{" + f"multi_a(2,{key_0}/{multipath},{key_1}/{multipath})," + f"{{andor(pk({key_0}/{multipath}),older(1000),1)," + f"thresh(2,pk({key_1}/{multipath}),s:pk({recovery_key}/{multipath})," + f"snl:sha256(6c60f404f8167a38fc70eaf8aa17ac351023bef86bcb9d1086a19afe95bd5333))}}}})" + ) + parsed = parse_descriptor(descriptor) + self.assertIsInstance(parsed, TRDescriptor) + for subdescriptor in parsed.subdescriptors: + self.assertIsInstance(subdescriptor, MiniscriptDescriptor) + self.assertEqual(parsed.to_string_no_checksum(hardened_char="'"), descriptor) + self.assertEqual( + parsed.get_bip388_template(), + "tr(@0/<0;1>/*,{multi_a(2,@1/<0;1>/*,@2/<0;1>/*)," + "{andor(pk(@1/<0;1>/*),older(1000),1)," + "thresh(2,pk(@2/<0;1>/*),s:pk(@0/<0;1>/*)," + "snl:sha256(6c60f404f8167a38fc70eaf8aa17ac351023bef86bcb9d1086a19afe95bd5333))}})", + ) + self.assertEqual( + [provider.get_bip388_key_info() for provider in parsed.get_pubkey_providers()], + [recovery_key, key_0, key_1], + ) + + def test_invalid_tapscript_miniscript(self): + key = "[6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL" + multipath = "<0;1>/*" + with self.assertRaisesRegex(ValueError, "Unknown Miniscript fragment: unknown"): + parse_descriptor(f"tr({key}/{multipath},unknown({key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "Unknown Miniscript fragment: Pk"): + parse_descriptor(f"tr({key}/{multipath},Pk({key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "Invalid Miniscript expression"): + parse_descriptor(f"tr({key}/{multipath},12345)") + with self.assertRaisesRegex(ValueError, "Unknown Miniscript wrapper: x"): + parse_descriptor(f"tr({key}/{multipath},x:pk({key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "only allowed in Segwit v0"): + parse_descriptor(f"tr({key}/{multipath},multi(1,{key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "threshold must be between"): + parse_descriptor(f"tr({key}/{multipath},multi_a(3,{key}/{multipath},{key}/{multipath}))") + with self.assertRaisesRegex(ValueError, "Mismatched multipath"): + parse_descriptor(f"tr({key}/{multipath},pk({key}/<0;1;2>/*))") + with self.assertRaisesRegex(ValueError, "Invalid Miniscript expression"): + parse_descriptor(f"tr({key}/{multipath},)") + + non_ranged = parse_descriptor(f"tr({key}/{multipath},and_v(v:pk({key}),older(1)))") + with self.assertRaisesRegex(InvalidPolicyError, "ranged"): + non_ranged.get_bip388_template() + + xonly = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + keys = ",".join([xonly] * 1000) + with self.assertRaisesRegex(ValueError, "at most 999 keys"): + parse_descriptor(f"tr({key}/{multipath},multi_a(1,{keys}))") + + def test_bip379_tapscript_corpus(self): + expressions = [ + "andor(and_b(multi_a(2,A,B,C),aj:multi_a(2,D,E,F)),multi_a(2,G,I,J),multi_a(2,K,L,M))", + "thresh(1,or_d(multi_a(2,A,B,C),pk(D)),s:pk(E),s:pk(F))", + "and_v(and_v(or_c(multi_a(2,A,B,C),v:multi_a(2,D,E,F)),v:after(1)),after(500000001))", + "andor(pk(A),older(4194305),pk(B))", + "and_n(pk(A),pk(B))", + "and_b(after(1),a:or_d(or_i(c:pk_h(A),0),multi_a(2,B,C,D)))", + "and_b(after(1),a:and_b(after(1),ac:pk_k(A)))", + "and_b(after(1),a:and_b(c:pk_h(A),an:after(500000001)))", + "and_v(or_c(sha256(926a54995ca48600920a19bf7bc502ca5f2f7d07e6f804c4f00ebf0325084dbc),v:after(1)),1)", + "u:pk(A)", + "l:pk(A)", + "pkh(A)", + "hash160(4355a46b19d348dc2f57c046f8ef63d4538ebb93)", + "ripemd160(4355a46b19d348dc2f57c046f8ef63d4538ebb93)", + "hash256(926a54995ca48600920a19bf7bc502ca5f2f7d07e6f804c4f00ebf0325084dbc)", + ] + internal_key = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + for expression in expressions: + substituted = re.sub( + r"(?/*,{{sortedmulti_a(1,{key_0}/<2;3>/*,{key_1}/<0;1>/*)," + f"or_b(pk({key_2}/<0;1>/*),s:pk({key_3}/<0;1>/*))}})" + ) + self.assertEqual( + descriptor.get_bip388_template(), + "tr(@0/<0;1>/*,{sortedmulti_a(1,@0/<2;3>/*,@1/<0;1>/*)," + "or_b(pk(@2/<0;1>/*),s:pk(@3/<0;1>/*))})", + ) + self.assertEqual( + [provider.get_bip388_key_info() for provider in descriptor.get_pubkey_providers()], + [key_0, key_1, key_2, key_3], + ) + + def test_taproot_script_tree_requires_registration(self): + key = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + descriptor = f"tr({key},pk({key}))" + with self.assertRaisesRegex(BadArgumentError, "registered BIP 388 policy"): + displayaddress(object(), desc=descriptor) + + def test_musig_requires_registration(self): + key_0 = "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + key_1 = "03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659" + with self.assertRaisesRegex(BadArgumentError, "registered BIP 388 policy"): + displayaddress(object(), desc=f"tr(musig({key_0},{key_1}))") + def test_derive(self): xpub = "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" descriptor_str = "wsh(multi(1,{0}/<0;1;2>/*,{0}/<10;11;12>/*))".format(xpub) @@ -165,6 +340,130 @@ def test_parse_descriptor_with_key_at_end_without_origin(self): def test_parse_empty_descriptor(self): self.assertRaises(ValueError, parse_descriptor, "") + def test_parse_invalid_key_expressions(self): + xpub = "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + with self.assertRaisesRegex(ValueError, "Invalid ranged derivation path"): + parse_descriptor(f"wpkh({xpub}/0*)") + with self.assertRaisesRegex(ValueError, "Empty key expression"): + parse_descriptor(f"wsh(multi(1,{xpub}/0/*,,{xpub}/1/*))") + with self.assertRaisesRegex(ValueError, "Trailing comma"): + parse_descriptor(f"wsh(multi(1,{xpub}/0/*,))") + + def test_bip388_key_deduplication(self): + key = "[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw" + other = "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7" + descriptor = parse_descriptor(f"wsh(multi(2,{key}/<0;1>/*,{other}/<0;1>/*,{key}/<2;3>/*))") + self.assertEqual( + descriptor.get_bip388_template(), + "wsh(multi(2,@0/<0;1>/*,@1/<0;1>/*,@0/<2;3>/*))", + ) + self.assertEqual( + [provider.get_bip388_key_info() for provider in descriptor.get_pubkey_providers()], + [key, other], + ) + + def test_parse_invalid_musig(self): + key_0 = "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + key_1 = "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty" + with self.assertRaisesRegex(ValueError, "at least two participants"): + parse_descriptor(f"tr(musig({key_0}))") + with self.assertRaisesRegex(ValueError, "Empty key expression"): + parse_descriptor(f"tr(musig({key_0},,{key_1}))") + with self.assertRaisesRegex(ValueError, "Trailing comma"): + parse_descriptor(f"tr(musig({key_0},{key_1},))") + with self.assertRaisesRegex(ValueError, "cannot be nested"): + parse_descriptor(f"tr(musig(musig({key_0},{key_1}),{key_1}))") + with self.assertRaisesRegex(ValueError, "Invalid ranged derivation path"): + parse_descriptor(f"tr(musig({key_0},{key_1})/0*)") + + def test_parse_musig_bip390(self): + # Test vectors from BIP 390 + hex_1 = "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + hex_2 = "03dff1d77f2a671c5f36183726db2341be58feae1da2deced843240f7b502ba659" + hex_3 = "023590a94e768f8e1815c2f24b4d80a8e3149316c3518ce7b7ad338368d038ca66" + xpub_a = "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL" + xpub_b = "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y" + valid = [ + f"tr(musig({hex_1},{hex_2},{hex_3}))", + f"tr(musig({xpub_a}/1,{xpub_a}/1)/2)", + # Participants may be ranged when the aggregate key is not + f"tr(musig({xpub_a}/*,{xpub_b}/*))", + ] + for descriptor in valid: + self.assertEqual(parse_descriptor(descriptor).to_string_no_checksum(), descriptor) + + # Derivation happens on the participant keys when the aggregate key has no path + self.assertEqual( + parse_descriptor(f"tr(musig({xpub_a}/*,{xpub_b}/*))").derive(3).to_string_no_checksum(), + f"tr(musig({xpub_a}/3,{xpub_b}/3))", + ) + + with self.assertRaisesRegex(ValueError, "only allowed in tr"): + parse_descriptor(f"pk(musig({hex_1},{hex_2},{hex_3}))") + with self.assertRaisesRegex(ValueError, "only allowed in tr"): + parse_descriptor(f"pkh(musig({hex_1},{hex_2},{hex_3}))") + with self.assertRaisesRegex(ValueError, "only allowed in tr"): + parse_descriptor(f"wpkh(musig({hex_1},{hex_2},{hex_3}))") + with self.assertRaisesRegex(ValueError, "only allowed in tr"): + parse_descriptor(f"wsh(pk(musig({hex_1},{hex_2},{hex_3})))") + with self.assertRaisesRegex(ValueError, "Unknown Miniscript fragment: musig"): + parse_descriptor(f"wsh(musig({hex_1},{hex_2},{hex_3}))") + with self.assertRaisesRegex(ValueError, "extended public key participants"): + parse_descriptor(f"tr(musig({hex_1},{hex_2},{hex_3})/0/0)") + with self.assertRaisesRegex(ValueError, "cannot be ranged or multipath"): + parse_descriptor(f"tr(musig({xpub_a}/*,{xpub_b})/0/*)") + with self.assertRaisesRegex(ValueError, "cannot be ranged or multipath"): + parse_descriptor(f"tr(musig({xpub_a}/<0;1>,{xpub_b})/<2;3>)") + with self.assertRaisesRegex(ValueError, "hardened derivation steps"): + parse_descriptor(f"tr(musig({xpub_a},{xpub_b})/0h/*)") + with self.assertRaises(ValueError): + parse_descriptor(f"tr(musig({xpub_a},{xpub_b})/0/*h)") + + # BIP 388 does not allow derivation before aggregation + with self.assertRaisesRegex(InvalidPolicyError, "follow musig"): + parse_descriptor(f"tr(musig({xpub_a}/1,{xpub_b}/1)/<0;1>/*)").get_bip388_template() + + def test_musig_tapscript_key(self): + key_0 = "[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw" + key_1 = "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7" + internal_key = "[6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL" + descriptor = ( + f"tr({internal_key}/<0;1>/*," + f"and_v(v:pk(musig({key_0},{key_1})/<0;1>/*),older(12960)))" + ) + parsed = parse_descriptor(descriptor) + self.assertEqual(parsed.to_string_no_checksum(hardened_char="'"), descriptor) + self.assertEqual( + parsed.get_bip388_template(), + "tr(@0/<0;1>/*,and_v(v:pk(musig(@1,@2)/<0;1>/*),older(12960)))", + ) + self.assertEqual( + [provider.get_bip388_key_info() for provider in parsed.get_pubkey_providers()], + [internal_key, key_0, key_1], + ) + + def test_bip388_musig_tapscript_vector(self): + key_0 = "[6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa" + key_1 = "[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js" + key_2 = "[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2" + descriptor = parse_descriptor( + f"tr(musig({key_0},{key_1},{key_2})/<0;1>/*," + f"{{and_v(v:pk(musig({key_0},{key_1})/<0;1>/*),older(12960))," + f"{{and_v(v:pk(musig({key_0},{key_2})/<0;1>/*),older(12960))," + f"and_v(v:pk(musig({key_1},{key_2})/<0;1>/*),older(12960))}}}})" + ) + self.assertEqual( + descriptor.get_bip388_template(), + "tr(musig(@0,@1,@2)/<0;1>/*," + "{and_v(v:pk(musig(@0,@1)/<0;1>/*),older(12960))," + "{and_v(v:pk(musig(@0,@2)/<0;1>/*),older(12960))," + "and_v(v:pk(musig(@1,@2)/<0;1>/*),older(12960))}})", + ) + self.assertEqual( + [provider.get_bip388_key_info() for provider in descriptor.get_pubkey_providers()], + [key_0, key_1, key_2], + ) + def test_parse_descriptor_replace_h(self): d = "wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" desc = parse_descriptor(d) @@ -215,10 +514,11 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.pubkeys[0].expr_index, 0) + # The same key is used in all four leaves, so it shares one BIP 388 key index self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 1) - self.assertEqual(desc.subdescriptors[1].pubkeys[0].expr_index, 2) - self.assertEqual(desc.subdescriptors[2].pubkeys[0].expr_index, 3) - self.assertEqual(desc.subdescriptors[3].pubkeys[0].expr_index, 4) + self.assertEqual(desc.subdescriptors[1].pubkeys[0].expr_index, 1) + self.assertEqual(desc.subdescriptors[2].pubkeys[0].expr_index, 1) + self.assertEqual(desc.subdescriptors[3].pubkeys[0].expr_index, 1) self.assertEqual(desc.depths, [1, 3, 3, 2]) self.assertEqual(desc.to_string_no_checksum(), d) @@ -320,6 +620,16 @@ def check(descriptor, keys, template): ["[6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL"], "tr(@0/<0;1>/*)" ) + musig_descriptor = "tr(musig([6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw,[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7)/<0;1>/*)" + check( + musig_descriptor, + ["[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw", "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7"], + "tr(musig(@0,@1)/<0;1>/*)" + ) + self.assertEqual( + parse_descriptor(musig_descriptor).derive(7, multipath_index=1).to_string_no_checksum(hardened_char="'"), + "tr(musig([6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw,[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7)/1/7)", + ) check( "wsh(sortedmulti(2,[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw/<0;1>/*,[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7/<0;1>/*))", ["[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw", "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7"], diff --git a/test/test_device.py b/test/test_device.py index 4c09c548a..81f926fb7 100644 --- a/test/test_device.py +++ b/test/test_device.py @@ -33,6 +33,9 @@ def __init__(self): self.supports_xpub_ms_display = None self.supports_unsorted_ms = None self.supports_taproot = None + self.supports_segwit_miniscript = None + self.supports_taproot_miniscript = None + self.supports_musig2 = None self.strict_bip48 = None self.include_xpubs = None self.supports_device_multiple_multisig = None @@ -49,6 +52,9 @@ def start(self): assert self.supports_ms_display is not None assert self.supports_xpub_ms_display is not None assert self.supports_unsorted_ms is not None + assert self.supports_segwit_miniscript is not None + assert self.supports_taproot_miniscript is not None + assert self.supports_musig2 is not None assert self.strict_bip48 is not None assert self.include_xpubs is not None assert self.supports_device_multiple_multisig is not None @@ -964,3 +970,118 @@ def test_sign_multiple_registered_descriptors(self): "--registration", registrations[1], ) + + +class PolicyDisplayTestCase(DeviceTestCase): + EXTERNAL_KEY = "[1a0f5425/48h/1h/0h/2h]tpubDF23ETNjCC283QmYZtJp26GqHkSa6Yw6vPqp3UkMsPCvBzRC4dMQzE1U3WwKsFsx3apUkQA4JHQDSmcC3N1yhE2gF1aKJA1CiVtNyA9Rv4H" + + def _get_account_key(self, account_path): + xpub = self.do_command( + self.dev_args + ["getxpub", account_path] + )["xpub"] + return f"[{self.emulator.fingerprint}{account_path[1:]}]{xpub}" + + def _test_display_address(self, name, descriptor, expected_template): + address_index = 7 + multipath_index = 1 + expected_address = self.rpc.deriveaddresses( + AddChecksum(descriptor), [address_index, address_index] + )[multipath_index][0] + + registration = self.do_command(self.dev_args + [ + "registerdescriptor", + name, + descriptor, + ]) + self.assertNotIn("error", registration) + registered = RegisteredDescriptor.deserialize(registration["registration"]) + self.assertEqual( + registered.descriptor.get_bip388_template(), + expected_template, + ) + + result = self.do_command(self.dev_args + [ + "displayaddress", + "--index", str(address_index), + "--multipath-index", str(multipath_index), + "--registration", registration["registration"], + ]) + self.assertNotIn("error", result) + self.assertEqual( + bech32.decode("bcrt", expected_address), + bech32.decode("tb", result["address"]), + ) + self.assertEqual(result["index"], address_index) + self.assertEqual(result["multipath_index"], multipath_index) + +class TestSegwitMiniscriptDisplay(PolicyDisplayTestCase): + def setUp(self): + if not self.emulator.supports_segwit_miniscript: + self.skipTest("device does not support Segwit Miniscript policies") + super().setUp() + + def test_segwit_miniscript(self): + device_key = self._get_account_key("m/48h/1h/0h/2h") + descriptor = ( + f"wsh(and_v(v:pk({device_key}/<0;1>/*),older(12960)))" + ) + self._test_display_address( + f"Mini{self.emulator.fingerprint}", + descriptor, + "wsh(and_v(v:pk(@0/<0;1>/*),older(12960)))", + ) + +class TestTaprootMiniscriptDisplay(PolicyDisplayTestCase): + def setUp(self): + if not self.emulator.supports_taproot_miniscript: + self.skipTest("device does not support tapscript Miniscript policies") + super().setUp() + + def test_taproot_miniscript(self): + device_key = self._get_account_key("m/86h/1h/0h") + descriptor = ( + f"tr({device_key}/<0;1>/*," + f"and_v(v:pk({self.EXTERNAL_KEY}/<0;1>/*),older(12960)))" + ) + self._test_display_address( + f"TapMini{self.emulator.fingerprint}", + descriptor, + "tr(@0/<0;1>/*,and_v(v:pk(@1/<0;1>/*),older(12960)))", + ) + +class TestMuSig2Display(PolicyDisplayTestCase): + def setUp(self): + if not self.emulator.supports_musig2: + self.skipTest("device does not support MuSig2 policies") + super().setUp() + + def test_musig2(self): + device_key = self._get_account_key("m/87h/1h/0h") + descriptor = f"tr(musig({device_key},{self.EXTERNAL_KEY})/<0;1>/*)" + self._test_display_address( + f"MuSigDisplay{self.emulator.fingerprint}", + descriptor, + "tr(musig(@0,@1)/<0;1>/*)", + ) + +class TestMuSig2MiniscriptDisplay(PolicyDisplayTestCase): + def setUp(self): + if not self.emulator.supports_musig2: + self.skipTest("device does not support MuSig2 policies") + if not self.emulator.supports_taproot_miniscript: + self.skipTest("device does not support tapscript Miniscript policies") + super().setUp() + + def test_musig2_miniscript(self): + device_key = self._get_account_key("m/87h/1h/0h") + recovery_key = self._get_account_key("m/86h/1h/1h") + descriptor = ( + f"tr(musig({device_key},{self.EXTERNAL_KEY})/<0;1>/*," + f"and_v(v:pk({recovery_key}/<0;1>/*),older(12960)))" + ) + self._test_display_address( + f"MuSigMini{self.emulator.fingerprint}", + descriptor, + "tr(musig(@0,@1)/<0;1>/*," + "and_v(v:pk(@2/<0;1>/*),older(12960)))", + ) diff --git a/test/test_digitalbitbox.py b/test/test_digitalbitbox.py index 9fc41ed3f..7ed09e5f6 100755 --- a/test/test_digitalbitbox.py +++ b/test/test_digitalbitbox.py @@ -39,6 +39,9 @@ def __init__(self, simulator): self.supports_xpub_ms_display = False self.supports_unsorted_ms = False self.supports_taproot = False + self.supports_segwit_miniscript = False + self.supports_taproot_miniscript = False + self.supports_musig2 = False self.strict_bip48 = False self.include_xpubs = False self.supports_device_multiple_multisig = True diff --git a/test/test_jade.py b/test/test_jade.py index 741892e80..78f14155d 100755 --- a/test/test_jade.py +++ b/test/test_jade.py @@ -19,6 +19,7 @@ TestGetKeypool, TestGetDescriptors, TestRegisterDescriptor, + TestSegwitMiniscriptDisplay, TestSignMessage, TestSignTx, ) @@ -50,6 +51,9 @@ def __init__(self, jade_qemu_emulator_path): self.supports_xpub_ms_display = False self.supports_unsorted_ms = False self.supports_taproot = False + self.supports_segwit_miniscript = True + self.supports_taproot_miniscript = False + self.supports_musig2 = False self.strict_bip48 = False self.include_xpubs = False self.supports_device_multiple_multisig = True @@ -251,6 +255,7 @@ def jade_test_suite(emulator, bitcoind, interface): suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestJadeSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=False)) + suite.addTest(DeviceTestCase.parameterize(TestSegwitMiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_keepkey.py b/test/test_keepkey.py index ff9cf84c0..e2ed576bc 100755 --- a/test/test_keepkey.py +++ b/test/test_keepkey.py @@ -61,6 +61,9 @@ def __init__(self, path): self.supports_xpub_ms_display = False self.supports_unsorted_ms = False self.supports_taproot = False + self.supports_segwit_miniscript = False + self.supports_taproot_miniscript = False + self.supports_musig2 = False self.strict_bip48 = False self.include_xpubs = False self.supports_device_multiple_multisig = True diff --git a/test/test_ledger.py b/test/test_ledger.py index 0d783cb44..055948f52 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -17,7 +17,11 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestMuSig2Display, + TestMuSig2MiniscriptDisplay, TestRegisterDescriptor, + TestSegwitMiniscriptDisplay, + TestTaprootMiniscriptDisplay, TestSignMessage, TestSignTx, ) @@ -44,6 +48,9 @@ def __init__(self, path, legacy=False): self.supports_xpub_ms_display = False # Legacy does not multisig address display; tests not updated for new app self.supports_unsorted_ms = False # Legacy does not support unsorted multisig; tests not updated for new app self.supports_taproot = not legacy # Legacy does not support Taproot + self.supports_segwit_miniscript = not legacy + self.supports_taproot_miniscript = not legacy + self.supports_musig2 = not legacy self.strict_bip48 = True self.include_xpubs = True self.supports_device_multiple_multisig = True @@ -193,6 +200,10 @@ def ledger_test_suite(emulator, bitcoind, interface, legacy=False): suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) if not legacy: suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=True)) + suite.addTest(DeviceTestCase.parameterize(TestSegwitMiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestTaprootMiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestMuSig2Display, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestMuSig2MiniscriptDisplay, bitcoind, emulator=dev_emulator, interface=interface)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_trezor.py b/test/test_trezor.py index b61c1f00a..af66120d6 100755 --- a/test/test_trezor.py +++ b/test/test_trezor.py @@ -60,6 +60,9 @@ def __init__(self, path, model): self.supports_xpub_ms_display = True self.supports_unsorted_ms = True self.supports_taproot = True + self.supports_segwit_miniscript = False + self.supports_taproot_miniscript = False + self.supports_musig2 = False self.strict_bip48 = True self.include_xpubs = False self.supports_device_multiple_multisig = True