From 1219089dbf0f80bd396d0408f718d7e67a806550 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 18:26:57 -0700 Subject: [PATCH 01/16] descriptor: Don't sort PubkeyProviders in sortedmulti The sort in sortedmulti occurs only during script expansion. Keep the pubkeys in the order that they were provided to the parser. --- hwilib/descriptor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index 4e743fda7..5be55d592 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -324,8 +324,6 @@ def __init__( super().__init__(pubkeys, [], "sortedmulti" if is_sorted else "multi") self.thresh = thresh self.is_sorted = is_sorted - if self.is_sorted: - self.pubkeys.sort() def to_string_no_checksum(self, hardened_char: str = "h") -> str: return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string(hardened_char) for p in self.pubkeys])) From 7a89c984d932a0773fb0abc784a345d9362051c1 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 13:13:04 -0700 Subject: [PATCH 02/16] descriptor: Track key expression index For BIP388 policies, we will need to know wht index of each key expression, so keep track of them explicitly with a key expression index stored in each PubkeyProvider. This also mirrors what Bitcoin Core does for descriptors. --- hwilib/commands.py | 2 +- hwilib/descriptor.py | 55 ++++++++++++++++++++++------------------ hwilib/devices/ledger.py | 4 +-- test/test_descriptor.py | 19 ++++++++++++++ 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/hwilib/commands.py b/hwilib/commands.py index 6d192aa5f..059fa88ed 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -339,7 +339,7 @@ def getdescriptor( if client.xpub_cache.get(path_base) is None: client.xpub_cache[path_base] = client.get_pubkey_at_path(path_base).to_string() - pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix) + pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix, 0) if addr_type is AddressType.LEGACY: return PKHDescriptor(pubkey) elif addr_type is AddressType.SH_WIT: diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index 5be55d592..a43e7983f 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -103,16 +103,19 @@ def __init__( self, origin: Optional['KeyOriginInfo'], pubkey: str, - deriv_path: Optional[str] + deriv_path: Optional[str], + expr_index: int ) -> None: """ :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 """ self.origin = origin self.pubkey = pubkey self.deriv_path = deriv_path + self.expr_index = expr_index # Make ExtendedKey from pubkey if it isn't hex self.extkey = None @@ -124,11 +127,12 @@ def __init__( self.extkey = ExtendedKey.deserialize(self.pubkey) @classmethod - def parse(cls, s: str) -> 'PubkeyProvider': + def parse(cls, s: str, key_expr_index: int) -> 'PubkeyProvider': """ Deserialize a key expression from the string into a ``PubkeyProvider``. :param s: String containing the key expression + :param key_expr_index: The position of this key within the descriptor :return: A new ``PubkeyProvider`` containing the details given by ``s`` """ origin = None @@ -145,7 +149,7 @@ def parse(cls, s: str) -> 'PubkeyProvider': pubkey = s[:slash_idx] deriv_path = s[slash_idx:] - return cls(origin, pubkey, deriv_path) + return cls(origin, pubkey, deriv_path, key_expr_index) def to_string(self, hardened_char: str = "h") -> str: """ @@ -475,12 +479,13 @@ def _get_expr(s: str) -> Tuple[str, str]: return s, "" return s[0:i], s[i:] -def parse_pubkey(expr: str) -> Tuple['PubkeyProvider', str]: +def parse_pubkey(expr: str, key_expr_index: int) -> Tuple['PubkeyProvider', str, int]: """ Parses an individual pubkey expression from a string that may contain more than one pubkey expression. :param expr: The expression to parse a pubkey expression from - :return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, and the remainder of the expression as the second item. + :param key_expr_index: The position of the next key to be parsed + :return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, the remainder of the expression as the second item, and the index of the next key expression as the third. """ end = len(expr) comma_idx = expr.find(",") @@ -488,7 +493,7 @@ def parse_pubkey(expr: str) -> Tuple['PubkeyProvider', str]: if comma_idx != -1: end = comma_idx next_expr = expr[end + 1:] - return PubkeyProvider.parse(expr[:end]), next_expr + return PubkeyProvider.parse(expr[:end], key_expr_index), next_expr, (key_expr_index + 1) class _ParseDescriptorContext(Enum): @@ -512,7 +517,7 @@ class _ParseDescriptorContext(Enum): """Within a ``tr()`` descriptor""" -def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor': +def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: int) -> Tuple['Descriptor', int]: """ :meta private: @@ -521,22 +526,23 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' :param desc: The descriptor string to parse :param ctx: The :class:`_ParseDescriptorContext` indicating the level we are in - :return: The parsed descriptor + :param key_expr_index: The position of the next key to be parsed within the descriptor + :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) if func == "pk": - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("more than one pubkey in pk descriptor") - return PKDescriptor(pubkey) + return PKDescriptor(pubkey), 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()") - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("More than one pubkey in pkh descriptor") - return PKHDescriptor(pubkey) + return PKHDescriptor(pubkey), key_expr_index if func == "sortedmulti" or func == "multi": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): raise ValueError("Can only have multi/sortedmulti at top level, in sh(), or in wsh()") @@ -546,7 +552,7 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' expr = expr[comma_idx + 1:] pubkeys = [] while expr: - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) pubkeys.append(pubkey) if len(pubkeys) == 0 or len(pubkeys) > 16: raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 16 keys, inclusive".format(len(pubkeys))) @@ -556,28 +562,28 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' raise ValueError("Multisig threshold cannot be larger than the number of keys; threshold is {} but only {} keys specified".format(thresh, len(pubkeys))) if ctx == _ParseDescriptorContext.TOP and len(pubkeys) > 3: raise ValueError("Cannot have {} pubkeys in bare multisig: only at most 3 pubkeys") - return MultisigDescriptor(pubkeys, thresh, is_sorted) + return MultisigDescriptor(pubkeys, thresh, is_sorted), 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()") - pubkey, expr = parse_pubkey(expr) + pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) if expr: raise ValueError("More than one pubkey in pkh descriptor") - return WPKHDescriptor(pubkey) + return WPKHDescriptor(pubkey), key_expr_index if func == "sh": if ctx != _ParseDescriptorContext.TOP: raise ValueError("Can only have sh() at top level") - subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2SH) - return SHDescriptor(subdesc) + subdesc, key_expr_index = _parse_descriptor(expr, _ParseDescriptorContext.P2SH, key_expr_index) + return SHDescriptor(subdesc), key_expr_index if func == "wsh": if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): raise ValueError("Can only have wsh() at top level or inside sh()") - subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2WSH) - return WSHDescriptor(subdesc) + subdesc, key_expr_index = _parse_descriptor(expr, _ParseDescriptorContext.P2WSH, key_expr_index) + return WSHDescriptor(subdesc), key_expr_index if func == "tr": if ctx != _ParseDescriptorContext.TOP: raise ValueError("Can only have tr at top level") - internal_key, expr = parse_pubkey(expr) + internal_key, expr, key_expr_index = parse_pubkey(expr, key_expr_index) subscripts = [] depths = [] if expr: @@ -597,7 +603,8 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' raise ValueError("tr() supports at most {MAX_TAPROOT_NODES} nesting levels") # Process script expression sarg, expr = _get_expr(expr) - subscripts.append(_parse_descriptor(sarg, _ParseDescriptorContext.P2TR)) + subdesc, key_expr_index = _parse_descriptor(sarg, _ParseDescriptorContext.P2TR, key_expr_index) + subscripts.append(subdesc) depths.append(len(branches)) # Process closing braces while len(branches) > 0 and branches[-1]: @@ -610,7 +617,7 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor' if len(branches) == 0: break - return TRDescriptor(internal_key, subscripts, depths) + return TRDescriptor(internal_key, subscripts, depths), key_expr_index if ctx == _ParseDescriptorContext.P2SH: raise ValueError("A function is needed within P2SH") elif ctx == _ParseDescriptorContext.P2WSH: @@ -634,4 +641,4 @@ 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) + return _parse_descriptor(desc, _ParseDescriptorContext.TOP, 0)[0] diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index fdb6b6d82..f3426b567 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -288,7 +288,7 @@ def legacy_sign_tx() -> PSBT: for xpub_bytes, xpub_origin in psbt2.xpub.items(): xpub = ExtendedKey.from_bytes(xpub_bytes) if (xpub_origin.fingerprint == pk_origin.fingerprint) and (xpub_origin.path == pk_origin.path[:len(xpub_origin.path)]): - key_exprs.append(PubkeyProvider(xpub_origin, xpub.to_string(), None).to_string(hardened_char="'")) + key_exprs.append(PubkeyProvider(xpub_origin, xpub.to_string(), None, 0).to_string(hardened_char="'")) break else: # No xpub, Ledger will not accept this multisig @@ -406,7 +406,7 @@ def _get_singlesig_default_wallet_policy(self, addr_type: AddressType, account: # Build a PubkeyProvider for the key we're going to use origin = KeyOriginInfo(self.get_master_fingerprint(), path) - pk_prov = PubkeyProvider(origin, self.get_pubkey_at_path(f"m{origin._path_string()}").to_string(), None) + pk_prov = PubkeyProvider(origin, self.get_pubkey_at_path(f"m{origin._path_string()}").to_string(), None, 0) key_str = pk_prov.to_string(hardened_char="'") # Make the Wallet object diff --git a/test/test_descriptor.py b/test/test_descriptor.py index 9e281a479..10f3a9c20 100755 --- a/test/test_descriptor.py +++ b/test/test_descriptor.py @@ -23,6 +23,7 @@ def test_parse_descriptor_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -38,11 +39,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("002084b64b2b8651df8fd3e9735f6269edbf9e03abf619ae0788be9f17bf18e83d59")) @@ -57,11 +60,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("a91495ee6326805b1586bb821fc3c0eeab2c68441b4187")) @@ -77,11 +82,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("a914779ae0f6958e98b997cc177f9b554289905fbb5587")) @@ -109,6 +116,7 @@ def test_parse_descriptor_with_origin_fingerprint_only(self): self.assertEqual(len(desc.pubkeys[0].origin.path), 0) self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -123,6 +131,7 @@ def test_parse_descriptor_with_key_at_end_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -136,6 +145,7 @@ def test_parse_descriptor_with_key_at_end_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("76a914d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa88ac")) @@ -149,6 +159,7 @@ def test_parse_descriptor_with_key_at_end_without_origin(self): self.assertEqual(desc.pubkeys[0].origin, None) self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) def test_parse_empty_descriptor(self): @@ -192,6 +203,7 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) d = "tr([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),{{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)},pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)}})" @@ -202,6 +214,11 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].expr_index, 0) + 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.depths, [1, 3, 3, 2]) self.assertEqual(desc.to_string_no_checksum(), d) @@ -213,6 +230,8 @@ def test_tr_descriptor(self): self.assertEqual(desc.depths, [0]) self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "669b8afcec803a0d323e9a17f3ea8e68e8abe5a278020a929adbec52421adbd0") self.assertEqual(desc.to_string_no_checksum(), d) + self.assertEqual(desc.pubkeys[0].expr_index, 0) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 1) if __name__ == "__main__": From d185e56eed7d81763d1e9656a6c2352fa48694d4 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Tue, 4 Aug 2026 13:32:01 -0700 Subject: [PATCH 03/16] descriptors: Actually parse the derivation path The derivation path should be parsed and validated for correctness, not just copied directly into a pubkey provider. --- hwilib/commands.py | 10 ++----- hwilib/descriptor.py | 58 ++++++++++++++++++++++------------------ hwilib/devices/jade.py | 3 +-- hwilib/devices/ledger.py | 17 ++++++------ hwilib/devices/trezor.py | 5 ++-- hwilib/key.py | 27 +++++++++++++------ test/test_descriptor.py | 22 +++++++-------- 7 files changed, 75 insertions(+), 67 deletions(-) diff --git a/hwilib/commands.py b/hwilib/commands.py index 059fa88ed..c2689d075 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -27,7 +27,6 @@ get_bip44_purpose, get_bip44_chain, H_, - HARDENED_FLAG, is_hardened, KeyOriginInfo, parse_path, @@ -328,18 +327,13 @@ def getdescriptor( origin = KeyOriginInfo(master_fpr, parsed_path[:i]) path_base = origin.get_derivation_path() - path_suffix = "" - for p in parsed_path[i:]: - hardened = is_hardened(p) - p &= ~HARDENED_FLAG - path_suffix += "/{}{}".format(p, "h" if hardened else "") - path_suffix += "/*" + path_suffix = parsed_path[i:] # Get the key at the base if client.xpub_cache.get(path_base) is None: client.xpub_cache[path_base] = client.get_pubkey_at_path(path_base).to_string() - pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix, 0) + pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix, 0, True) if addr_type is AddressType.LEGACY: return PKHDescriptor(pubkey) elif addr_type is AddressType.SH_WIT: diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index a43e7983f..5e2894450 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -10,11 +10,17 @@ """ -from .key import ExtendedKey, KeyOriginInfo, parse_path +from .key import ( + ExtendedKey, + KeyOriginInfo, + parse_path, + path_to_string, +) from .common import hash160, sha256 from binascii import unhexlify from collections import namedtuple +from copy import deepcopy from enum import Enum from typing import ( List, @@ -103,8 +109,9 @@ def __init__( self, origin: Optional['KeyOriginInfo'], pubkey: str, - deriv_path: Optional[str], - expr_index: int + deriv_path: Optional[List[int]], + expr_index: int, + ranged: bool ) -> None: """ :param origin: The key origin if one is available @@ -116,6 +123,7 @@ def __init__( self.pubkey = pubkey self.deriv_path = deriv_path self.expr_index = expr_index + self.ranged = ranged # Make ExtendedKey from pubkey if it isn't hex self.extkey = None @@ -137,6 +145,7 @@ def parse(cls, s: str, key_expr_index: int) -> 'PubkeyProvider': """ origin = None deriv_path = None + ranged = False if s[0] == "[": end = s.index("]") @@ -147,9 +156,13 @@ def parse(cls, s: str, key_expr_index: int) -> 'PubkeyProvider': slash_idx = s.find("/") if slash_idx != -1: pubkey = s[:slash_idx] - deriv_path = s[slash_idx:] + path_str = s[slash_idx + 1:] + ranged = path_str.endswith("*") + if ranged: + path_str = path_str[:-2] + deriv_path = parse_path(path_str) - return cls(origin, pubkey, deriv_path, key_expr_index) + return cls(origin, pubkey, deriv_path, key_expr_index, ranged) def to_string(self, hardened_char: str = "h") -> str: """ @@ -162,16 +175,17 @@ def to_string(self, hardened_char: str = "h") -> str: s += "[{}]".format(self.origin.to_string(hardened_char)) s += self.pubkey if self.deriv_path: - s += self.deriv_path + s += path_to_string(self.deriv_path, hardened_char) + if self.ranged: + s += "/*" return s def get_pubkey_bytes(self, pos: int) -> bytes: if self.extkey is not None: if self.deriv_path is not None: - path_str = self.deriv_path[1:] - if path_str[-1] == "*": - path_str = path_str[:-1] + str(pos) - path = parse_path(path_str) + path = deepcopy(self.deriv_path) + if self.ranged: + path.append(pos) child_key = self.extkey.derive_pub_path(path) return child_key.pubkey else: @@ -183,9 +197,10 @@ def get_full_derivation_path(self, pos: int) -> str: Returns the full derivation path at the given position, including the origin """ path = self.origin.get_derivation_path() if self.origin is not None else "m/" - path += self.deriv_path if self.deriv_path is not None else "" - if path[-1] == "*": - path = path[:-1] + str(pos) + if self.deriv_path: + path += path_to_string(self.deriv_path) + if self.ranged: + path += str(pos) return path def get_full_derivation_int_list(self, pos: int) -> List[int]: @@ -194,19 +209,10 @@ def get_full_derivation_int_list(self, pos: int) -> List[int]: Includes the origin and master key fingerprint as an int """ path: List[int] = self.origin.get_full_int_list() if self.origin is not None else [] - if self.deriv_path is not None: - der_split = self.deriv_path.split("/") - for p in der_split: - if not p: - continue - if p == "*": - i = pos - elif p[-1] in "'phHP": - assert len(p) >= 2 - i = int(p[:-1]) | 0x80000000 - else: - i = int(p) - path.append(i) + if self.deriv_path: + path.extend(self.deriv_path) + if self.ranged: + path.append(pos) return path def __lt__(self, other: 'PubkeyProvider') -> bool: diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index 2530ebb34..dc3ebefa6 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -437,8 +437,7 @@ def display_multisig_address(self, addr_type: AddressType, multisig: MultisigDes 'path': []}) # Instead hold it as the address path - path = pubkey.deriv_path[1:] if pubkey.deriv_path[0] == '/' else pubkey.deriv_path - paths.append(parse_path(path)) + paths.append(pubkey.deriv_path) if multisig.is_sorted and paths[:-1] != paths[1:]: logging.warning('Sorted multisig with different derivations per signer') diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index f3426b567..31fdc4454 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -288,7 +288,7 @@ def legacy_sign_tx() -> PSBT: for xpub_bytes, xpub_origin in psbt2.xpub.items(): xpub = ExtendedKey.from_bytes(xpub_bytes) if (xpub_origin.fingerprint == pk_origin.fingerprint) and (xpub_origin.path == pk_origin.path[:len(xpub_origin.path)]): - key_exprs.append(PubkeyProvider(xpub_origin, xpub.to_string(), None, 0).to_string(hardened_char="'")) + key_exprs.append(PubkeyProvider(xpub_origin, xpub.to_string(), None, 0, False).to_string(hardened_char="'")) break else: # No xpub, Ledger will not accept this multisig @@ -406,7 +406,7 @@ def _get_singlesig_default_wallet_policy(self, addr_type: AddressType, account: # Build a PubkeyProvider for the key we're going to use origin = KeyOriginInfo(self.get_master_fingerprint(), path) - pk_prov = PubkeyProvider(origin, self.get_pubkey_at_path(f"m{origin._path_string()}").to_string(), None, 0) + pk_prov = PubkeyProvider(origin, self.get_pubkey_at_path(f"m{origin._path_string()}").to_string(), None, 0, False) key_str = pk_prov.to_string(hardened_char="'") # Make the Wallet object @@ -451,13 +451,12 @@ def display_multisig_address( if isinstance(self.client, LegacyClient): raise BadArgumentError("Displaying multisignature addresses is not supported by this version of the Bitcoin App") - def is_valid_der_path(path: Optional[str]) -> bool: - if path is None: + def is_valid_der_path(pk: PubkeyProvider) -> bool: + if pk.deriv_path is None: return False - path_parts = path.split("/") - return len(path_parts) == 3 and path_parts[1] in ["0", "1"] and path_parts[2].isdigit() and 0 <= int(path_parts[2]) <= 0x7fffffff + return len(pk.deriv_path) == 3 and pk.deriv_path[1] in [0, 1] and 0 <= pk.deriv_path[2] <= 0x7fffffff and pk.ranged - if any(not is_valid_der_path(pk.deriv_path) for pk in multisig.pubkeys): + if any(not is_valid_der_path(pk) for pk in multisig.pubkeys): raise BadArgumentError("Ledger Bitcoin app requires derivation paths ending with /0/* or /1/* for multisig") if not (all(pk.deriv_path == multisig.pubkeys[0].deriv_path for pk in multisig.pubkeys)): @@ -477,8 +476,8 @@ def format_key_info(pubkey: PubkeyProvider) -> str: _, registered_hmac = self.client.register_wallet(multisig_wallet) assert multisig.pubkeys[0].deriv_path is not None # already checked above with is_valid_der_path - change = 0 if multisig.pubkeys[0].deriv_path[:3] == "/0/" else 1 - address_index = int(multisig.pubkeys[0].deriv_path.split("/")[2]) + change = 0 if multisig.pubkeys[0].deriv_path[3] == 0 else 1 + address_index = int(multisig.pubkeys[0].deriv_path[2]) return self.client.get_wallet_address(multisig_wallet, registered_hmac, change, address_index, True) diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index bd5a79733..523541880 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -712,7 +712,7 @@ def display_multisig_address( if p.extkey is not None: xpub = p.extkey hd_node = messages.HDNodeType(depth=xpub.depth, fingerprint=int.from_bytes(xpub.parent_fingerprint, 'big'), child_num=xpub.child_num, chain_code=xpub.chaincode, public_key=xpub.pubkey) - pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=parse_path("m" + p.deriv_path if p.deriv_path is not None else ""))) + pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=p.deriv_path)) else: hd_node = messages.HDNodeType(depth=0, fingerprint=0, child_num=0, chain_code=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', public_key=pk) pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=[])) @@ -730,8 +730,7 @@ def display_multisig_address( raise BadArgumentError("Unknown address type") for p in multisig.pubkeys: - keypath = p.origin.get_derivation_path() if p.origin is not None else "m/" - keypath += p.deriv_path if p.deriv_path is not None else "" + keypath = p.get_full_derivation_path(0) path = parse_path(keypath) try: address = btc.get_address( diff --git a/hwilib/key.py b/hwilib/key.py index cb89e3926..fdb985930 100644 --- a/hwilib/key.py +++ b/hwilib/key.py @@ -281,14 +281,7 @@ def serialize(self) -> bytes: return r def _path_string(self, hardened_char: str = "h") -> str: - s = "" - for i in self.path: - hardened = is_hardened(i) - i &= ~HARDENED_FLAG - s += "/" + str(i) - if hardened: - s += hardened_char - return s + return path_to_string(self.path, hardened_char) def to_string(self, hardened_char: str = "h") -> str: """ @@ -339,6 +332,7 @@ def parse_path(nstr: str) -> List[int]: :param nstr: path string :return: list of integers + :raises ValueError: If the path contains any invalid characters """ if not nstr: return [] @@ -363,6 +357,23 @@ def str_to_harden(x: str) -> int: raise ValueError("Invalid BIP32 path", nstr) +def path_to_string(path: Sequence[int], hardened_char: str = "h") -> str: + """ + Convert a list of ints specifying a BIP32 derivation path to a string + + :param path: Path as a list of ints + :return: String representing the path + """ + s = "" + for i in path: + hardened = is_hardened(i) + i &= ~HARDENED_FLAG + s += "/" + str(i) + if hardened: + s += hardened_char + return s + + def get_bip44_purpose(addrtype: AddressType) -> int: """ Determine the BIP 44 purpose based on the given :class:`~hwilib.common.AddressType`. diff --git a/test/test_descriptor.py b/test/test_descriptor.py index 10f3a9c20..6d1955bd5 100755 --- a/test/test_descriptor.py +++ b/test/test_descriptor.py @@ -22,7 +22,7 @@ def test_parse_descriptor_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -38,13 +38,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [0, 0]) self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -59,13 +59,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [0, 0]) self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -81,13 +81,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, [0, 0]) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -101,7 +101,7 @@ def test_parse_descriptor_without_origin(self): self.assertTrue(isinstance(desc, WPKHDescriptor)) self.assertEqual(desc.pubkeys[0].origin, None) self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -115,7 +115,7 @@ def test_parse_descriptor_with_origin_fingerprint_only(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(len(desc.pubkeys[0].origin.path), 0) self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -202,7 +202,7 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) @@ -213,7 +213,7 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 1) self.assertEqual(desc.subdescriptors[1].pubkeys[0].expr_index, 2) From b1bce522bcbcf66f62f661d7c6c09d46f587d491 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Tue, 4 Aug 2026 13:55:21 -0700 Subject: [PATCH 04/16] descriptor: Parse multipath path specifiers --- hwilib/commands.py | 2 +- hwilib/descriptor.py | 53 +++++++++++++++----- hwilib/devices/bitbox02.py | 2 +- hwilib/devices/jade.py | 2 +- hwilib/devices/ledger.py | 6 +-- hwilib/devices/trezor.py | 2 +- hwilib/key.py | 100 +++++++++++++++++++++++++++++-------- test/test_descriptor.py | 83 ++++++++++++++++++++++++++---- 8 files changed, 198 insertions(+), 52 deletions(-) diff --git a/hwilib/commands.py b/hwilib/commands.py index c2689d075..d385b6b0e 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -327,7 +327,7 @@ def getdescriptor( origin = KeyOriginInfo(master_fpr, parsed_path[:i]) path_base = origin.get_derivation_path() - path_suffix = parsed_path[i:] + path_suffix = [[p] for p in parsed_path[i:]] # Get the key at the base if client.xpub_cache.get(path_base) is None: diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index 5e2894450..085774994 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -13,14 +13,14 @@ from .key import ( ExtendedKey, KeyOriginInfo, - parse_path, + parse_multipath, + multipath_to_string, path_to_string, ) from .common import hash160, sha256 from binascii import unhexlify from collections import namedtuple -from copy import deepcopy from enum import Enum from typing import ( List, @@ -109,7 +109,7 @@ def __init__( self, origin: Optional['KeyOriginInfo'], pubkey: str, - deriv_path: Optional[List[int]], + deriv_path: Optional[List[List[int]]], expr_index: int, ranged: bool ) -> None: @@ -124,6 +124,7 @@ def __init__( self.deriv_path = deriv_path self.expr_index = expr_index self.ranged = ranged + self.multipath_len = max([len(p) for p in self.deriv_path]) if self.deriv_path is not None and len(self.deriv_path) > 0 else 1 # Make ExtendedKey from pubkey if it isn't hex self.extkey = None @@ -160,7 +161,8 @@ def parse(cls, s: str, key_expr_index: int) -> 'PubkeyProvider': ranged = path_str.endswith("*") if ranged: path_str = path_str[:-2] - deriv_path = parse_path(path_str) + if len(path_str) > 0: + deriv_path = parse_multipath(path_str) return cls(origin, pubkey, deriv_path, key_expr_index, ranged) @@ -175,42 +177,52 @@ def to_string(self, hardened_char: str = "h") -> str: s += "[{}]".format(self.origin.to_string(hardened_char)) s += self.pubkey if self.deriv_path: - s += path_to_string(self.deriv_path, hardened_char) + s += multipath_to_string(self.deriv_path, hardened_char) if self.ranged: s += "/*" return s - def get_pubkey_bytes(self, pos: int) -> bytes: + def get_deriv_path(self, pos: int, multipath_pos: int) -> List[int]: + path = [] + if self.deriv_path: + for p in self.deriv_path: + if len(p) == 1: + path.append(p[0]) + else: + path.append(p[multipath_pos]) + if self.ranged: + path.append(pos) + return path + + def get_pubkey_bytes(self, pos: int, multipath_pos: int = 0) -> bytes: if self.extkey is not None: if self.deriv_path is not None: - path = deepcopy(self.deriv_path) - if self.ranged: - path.append(pos) + path = self.get_deriv_path(pos, multipath_pos) child_key = self.extkey.derive_pub_path(path) return child_key.pubkey else: return self.extkey.pubkey return unhexlify(self.pubkey) - def get_full_derivation_path(self, pos: int) -> str: + def get_full_derivation_path(self, pos: int, multipath_pos: int = 0) -> str: """ Returns the full derivation path at the given position, including the origin """ path = self.origin.get_derivation_path() if self.origin is not None else "m/" if self.deriv_path: - path += path_to_string(self.deriv_path) + path += path_to_string(self.get_deriv_path(pos, multipath_pos)) if self.ranged: path += str(pos) return path - def get_full_derivation_int_list(self, pos: int) -> List[int]: + def get_full_derivation_int_list(self, pos: int, multipath_pos: int = 0) -> List[int]: """ Returns the full derivation path as an integer list at the given position. Includes the origin and master key fingerprint as an int """ path: List[int] = self.origin.get_full_int_list() if self.origin is not None else [] if self.deriv_path: - path.extend(self.deriv_path) + path.extend(self.get_deriv_path(pos, multipath_pos)) if self.ranged: path.append(pos) return path @@ -557,8 +569,14 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: thresh = int(expr[:comma_idx]) expr = expr[comma_idx + 1:] pubkeys = [] + multipath_len = None while expr: pubkey, expr, key_expr_index = parse_pubkey(expr, key_expr_index) + if pubkey.multipath_len > 1: + if multipath_len is None: + multipath_len = pubkey.multipath_len + elif multipath_len != pubkey.multipath_len: + raise ValueError("Mismatched multipath paths") pubkeys.append(pubkey) if len(pubkeys) == 0 or len(pubkeys) > 16: raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 16 keys, inclusive".format(len(pubkeys))) @@ -589,7 +607,10 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: if func == "tr": 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) + if internal_key.multipath_len > 1: + multipath_len = internal_key.multipath_len subscripts = [] depths = [] if expr: @@ -610,6 +631,12 @@ def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext', key_expr_index: # 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: + if pub.multipath_len > 1: + if multipath_len is None: + multipath_len = pub.multipath_len + elif multipath_len != pub.multipath_len: + raise ValueError("Mismatched multipath paths") subscripts.append(subdesc) depths.append(len(branches)) # Process closing braces diff --git a/hwilib/devices/bitbox02.py b/hwilib/devices/bitbox02.py index cc6b783b3..70d2fc817 100644 --- a/hwilib/devices/bitbox02.py +++ b/hwilib/devices/bitbox02.py @@ -526,7 +526,7 @@ def display_multisig_address( if not multisig.is_sorted: raise BadArgumentError("BitBox02 only supports sortedmulti descriptors") - path_suffixes = set(p.deriv_path for p in multisig.pubkeys) + path_suffixes = set(p.get_deriv_path(0, 0) for p in multisig.pubkeys) if len(path_suffixes) != 1: # Path suffix refers to the path after the account-level xpub, usually //
. # The BitBox02 currently enforces that all of them are the same. diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index dc3ebefa6..2bccee59d 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -437,7 +437,7 @@ def display_multisig_address(self, addr_type: AddressType, multisig: MultisigDes 'path': []}) # Instead hold it as the address path - paths.append(pubkey.deriv_path) + paths.append(pubkey.get_deriv_path(0, 0)) if multisig.is_sorted and paths[:-1] != paths[1:]: logging.warning('Sorted multisig with different derivations per signer') diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index 31fdc4454..f28ac226e 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -454,7 +454,7 @@ def display_multisig_address( def is_valid_der_path(pk: PubkeyProvider) -> bool: if pk.deriv_path is None: return False - return len(pk.deriv_path) == 3 and pk.deriv_path[1] in [0, 1] and 0 <= pk.deriv_path[2] <= 0x7fffffff and pk.ranged + return len(pk.deriv_path) == 3 and pk.deriv_path[1] in [[0], [1]] and 0 <= pk.deriv_path[2][0] <= 0x7fffffff and pk.ranged if any(not is_valid_der_path(pk) for pk in multisig.pubkeys): raise BadArgumentError("Ledger Bitcoin app requires derivation paths ending with /0/* or /1/* for multisig") @@ -476,8 +476,8 @@ def format_key_info(pubkey: PubkeyProvider) -> str: _, registered_hmac = self.client.register_wallet(multisig_wallet) assert multisig.pubkeys[0].deriv_path is not None # already checked above with is_valid_der_path - change = 0 if multisig.pubkeys[0].deriv_path[3] == 0 else 1 - address_index = int(multisig.pubkeys[0].deriv_path[2]) + change = 0 if multisig.pubkeys[0].deriv_path[3] == [0] else 1 + address_index = int(multisig.pubkeys[0].deriv_path[2][0]) return self.client.get_wallet_address(multisig_wallet, registered_hmac, change, address_index, True) diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index 523541880..9723766c6 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -712,7 +712,7 @@ def display_multisig_address( if p.extkey is not None: xpub = p.extkey hd_node = messages.HDNodeType(depth=xpub.depth, fingerprint=int.from_bytes(xpub.parent_fingerprint, 'big'), child_num=xpub.child_num, chain_code=xpub.chaincode, public_key=xpub.pubkey) - pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=p.deriv_path)) + pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=p.get_deriv_path(0, 0))) else: hd_node = messages.HDNodeType(depth=0, fingerprint=0, child_num=0, chain_code=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', public_key=pk) pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=[])) diff --git a/hwilib/key.py b/hwilib/key.py index fdb985930..506fb1aad 100644 --- a/hwilib/key.py +++ b/hwilib/key.py @@ -323,17 +323,7 @@ def get_full_int_list(self) -> List[int]: return xfp -def parse_path(nstr: str) -> List[int]: - """ - Convert BIP32 path string to list of uint32 integers with hardened flags. - Several conventions are supported to set the hardened flag: -1, 1', 1h - - e.g.: "0/1h/1" -> [0, 0x80000001, 1] - - :param nstr: path string - :return: list of integers - :raises ValueError: If the path contains any invalid characters - """ +def _parse_path(nstr: str, allow_multipath: bool) -> List[List[int]]: if not nstr: return [] @@ -351,29 +341,97 @@ def str_to_harden(x: str) -> int: else: return int(x) + def parse_index(x: str, seen_multipath: bool) -> Tuple[List[int], bool]: + if x.startswith("<"): + if seen_multipath: + raise ValueError("Cannot have multiple multipath specifiers") + if not allow_multipath: + raise ValueError(f"Multipath specifier not allowed: {x}") + if not x.endswith(">"): + raise ValueError(f"Invalid multipath specification, missing trailing '>': {x}") + mp = x[1:-1].split(";") + if len(mp) < 2: + raise ValueError(f"Invalid multipath specification, less than 2 indexes specified: {x}") + seen_multipath = True + return [str_to_harden(p) for p in mp], seen_multipath + return [str_to_harden(x)], seen_multipath + try: - return [str_to_harden(x) for x in n] + path = [] + seen_multipath = False + for x in n: + idx, seen_multipath = parse_index(x, seen_multipath) + path.append(idx) + return path + except ValueError as e: + raise e except Exception: - raise ValueError("Invalid BIP32 path", nstr) + raise ValueError(f"Invalid BIP32 path: {nstr}") -def path_to_string(path: Sequence[int], hardened_char: str = "h") -> str: +def parse_path(nstr: str) -> List[int]: """ - Convert a list of ints specifying a BIP32 derivation path to a string + Convert BIP32 path string to list of uint32 integers with hardened flags. + Several conventions are supported to set the hardened flag: -1, 1', 1h - :param path: Path as a list of ints - :return: String representing the path + e.g.: "0/1h/1" -> [0, 0x80000001, 1] + + :param nstr: path string + :return: list of integers + :raises ValueError: If the path contains any invalid characters """ - s = "" - for i in path: + return [i[0] for i in _parse_path(nstr, False)] + + +def parse_multipath(s: str) -> List[List[int]]: + """ + Convert multipath BIP32 path strings as specified in BIP389 to a list of lists ints. + + :param s: path string + :return: list of lists of integers + :raises ValueError: If the path contains any invalid specifiers + """ + return _parse_path(s, True) + + +def _path_to_string(path: Sequence[Sequence[int]], hardened_char: str = "h") -> str: + def index_to_str(i: int) -> str: hardened = is_hardened(i) i &= ~HARDENED_FLAG - s += "/" + str(i) + out = str(i) if hardened: - s += hardened_char + out += hardened_char + return out + + s = "" + for mp in path: + if len(mp) == 1: + s += f"/{index_to_str(mp[0])}" + else: + s += f"/<{';'.join([index_to_str(p) for p in mp])}>" return s +def path_to_string(path: Sequence[int], hardened_char: str = "h") -> str: + """ + Convert a list of ints specifying a BIP32 derivation path to a string + + :param path: Path as a list of ints + :return: String representing the path + """ + return _path_to_string([[i] for i in path], hardened_char) + + +def multipath_to_string(path: Sequence[Sequence[int]], hardened_char: str = "h") -> str: + """ + Convert a list of list of ints specifying a multipath BIP32 path to a string as specified by BIP389 + + :param path: The path as a list of list of ints + :return: String representing the path + """ + return _path_to_string(path, hardened_char) + + def get_bip44_purpose(addrtype: AddressType) -> int: """ Determine the BIP 44 purpose based on the given :class:`~hwilib.common.AddressType`. diff --git a/test/test_descriptor.py b/test/test_descriptor.py index 6d1955bd5..c6f0e177a 100755 --- a/test/test_descriptor.py +++ b/test/test_descriptor.py @@ -22,7 +22,7 @@ def test_parse_descriptor_with_origin(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -38,13 +38,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [0, 0]) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[0], [0]]) self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -59,13 +59,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [0, 0]) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[0], [0]]) self.assertEqual(desc.subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -81,13 +81,13 @@ def test_parse_multisig_descriptor_with_origin(self): self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") - self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, [0, 0]) + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, [[0], [0]]) self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].expr_index, 1) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -101,7 +101,7 @@ def test_parse_descriptor_without_origin(self): self.assertTrue(isinstance(desc, WPKHDescriptor)) self.assertEqual(desc.pubkeys[0].origin, None) self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) @@ -115,7 +115,7 @@ def test_parse_descriptor_with_origin_fingerprint_only(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(len(desc.pubkeys[0].origin.path), 0) self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) e = desc.expand(0) @@ -202,7 +202,7 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.to_string_no_checksum(), d) @@ -213,7 +213,7 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.pubkeys[0].deriv_path, [0, 0]) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0], [0]]) self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 1) self.assertEqual(desc.subdescriptors[1].pubkeys[0].expr_index, 2) @@ -233,6 +233,67 @@ def test_tr_descriptor(self): self.assertEqual(desc.pubkeys[0].expr_index, 0) self.assertEqual(desc.subdescriptors[0].pubkeys[0].expr_index, 1) + def test_multipath_descriptors(self): + d = "pk(xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/<0;1>)" + desc = parse_descriptor(d) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0, 1]]) + self.assertFalse(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "pkh(xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U/<2147483647h;0>/0)" + desc = parse_descriptor(d) + self.assertEqual(desc.pubkeys[0].deriv_path, [[0xffffffff, 0], [0]]) + self.assertFalse(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "wpkh([ffffffff/13h]xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/<1;3>/2/*)" + desc = parse_descriptor(d) + self.assertEqual(desc.pubkeys[0].deriv_path, [[1, 3], [2]]) + self.assertTrue(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "sh(multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/<1;2>/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/<3;4>/0/*))" + desc = parse_descriptor(d) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[1, 2]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[0].ranged) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[3, 4], [0]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[1].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "pkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<0;1;2>)" + desc = parse_descriptor(d) + self.assertTrue(desc.pubkeys[0].deriv_path, [[0, 1, 2]]) + self.assertFalse(desc.pubkeys[0].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + d = "sh(multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/<1;2;3>/0/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/*,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/<3;4;5>/*))" + desc = parse_descriptor(d) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, [[1, 2, 3], [0]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[0].ranged) + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, [[0]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[1].ranged) + self.assertEqual(desc.subdescriptors[0].pubkeys[2].deriv_path, [[0], [0], [3, 4, 5]]) + self.assertTrue(desc.subdescriptors[0].pubkeys[2].ranged) + self.assertEqual(d, desc.to_string_no_checksum()) + + def test_invalid_multipath_descriptors(self): + with self.assertRaisesRegex(ValueError, "Cannot have multiple multipath specifiers"): + parse_descriptor("pkh(xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U/<0;1>/<2;3>)") + with self.assertRaisesRegex(ValueError, "Multipath specifier not allowed"): + parse_descriptor("pkh([deadbeef/<0;1>]xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/0)") + with self.assertRaisesRegex(ValueError, "Mismatched multipath paths"): + parse_descriptor("tr(xpub661MyMwAqRbcF3yVrV2KyYetLMYA5mCbv4BhrKwUrFE9LZM6JRR1AEt8Jq4V4C8LwtTke6YEEdCZqgXp85YRk2j74EfJKhe3QybQ9kcUjs4/<6;7;8;9>/*,{pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/<1;2;3>/0/*),pk(xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/<3;4;5>/*)})") + with self.assertRaisesRegex(ValueError, "Mismatched multipath paths"): + parse_descriptor("sh(multi(2,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/<1;2;3>/0/*,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0/*,xprv9s21ZrQH143K3jUwNHoqQNrtzJnJmx4Yup8NkNLdVQCymYbPbJXnPhwkfTfxZfptcs3rLAPUXS39oDLgrNKQGwbGsEmJJ8BU3RzQuvShEG4/0/0/<3;4>/*))") + with self.assertRaisesRegex(ValueError, "Invalid multipath specification, less than 2 indexes specified: <>"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<>/*)") + with self.assertRaisesRegex(ValueError, "invalid literal for int()"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/0>/*)") + with self.assertRaisesRegex(ValueError, "Invalid multipath specification, missing trailing '>'"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<0/*)") + with self.assertRaisesRegex(ValueError, "invalid literal for int()"): + parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<0;>/*)") + if __name__ == "__main__": unittest.main() From 6b39816756a544af3294ba7f7efb2b9ad5d74dda Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 13:34:25 -0700 Subject: [PATCH 05/16] descriptor: Add functions for making BIP 388 wallet policies get_bip388_template returns the descriptor as a BIP 388 Wallet Descriptor Template string. get_pubkey_providers returns all of the pubkey providers from the descriptor, in the same order as the placeholders in the bip388 template. Callers can get the strings for the key information vector by calling PubkeyProvider.to_string_no_deriv() --- hwilib/descriptor.py | 92 +++++++++++++++++++++++++++++++++++++++++ hwilib/errors.py | 11 +++++ test/test_descriptor.py | 57 +++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index 085774994..f7f4be88e 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -18,6 +18,7 @@ path_to_string, ) from .common import hash160, sha256 +from .errors import InvalidPolicyError from binascii import unhexlify from collections import namedtuple @@ -227,6 +228,40 @@ def get_full_derivation_int_list(self, pos: int, multipath_pos: int = 0) -> List path.append(pos) return path + def get_bip388_placeholder(self) -> str: + """ + Get the key placeholder expression for this pubkey to be used in BIP 388 Wallet Policies. + The descriptor will be first checked for whether it likely confirms to BIP 388. Specifically: + + - All pubkeys must be ranged + - 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 + """ + 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") + 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}" + + def get_bip388_key_info(self) -> str: + """ + Serialize the pubkey expression to a string without the trailing derivation path. + Used in the Key information vector of BIP 388 Wallet Policies. + + :return: The pubkey expression without trailing derivaiton path as a string + :raises InvalidPolicyError: If the pubkey does not meet the requirements for a wallet policy as specified in BIP 388 + """ + s = "" + if self.origin: + s += "[{}]".format(self.origin.to_string("'")) + s += self.pubkey + return s + def __lt__(self, other: 'PubkeyProvider') -> bool: return self.pubkey < other.pubkey @@ -277,6 +312,40 @@ def expand(self, pos: int) -> "ExpandedScripts": """ raise NotImplementedError("The Descriptor base class does not implement this method") + def get_bip388_template(self) -> str: + """ + Get the BIP 388 Wallet Descriptor Template string for this descriptor. + + Some BIP 388 specified checks are performed to determine. See ``get_bip388_placeholder()`` for the + pubkey specific checks that are performed. + + Note that not all BIP 388 specified checks are performed, specifically the following checks are not performed: + + - Duplicate keys check + - Disjoint multipath check + + :return: The template string + :raises InvalidPolicyError: If the pubkey does not meet the requirements for a wallet policy as specified in BIP 388 + """ + return "{}({}{})".format( + self.name, + ",".join([p.get_bip388_placeholder() for p in self.pubkeys]), + self.subdescriptors[0].get_bip388_template() if len(self.subdescriptors) > 0 else "" + ) + + 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 + :func:`get_bip388_template` to get a full BIP 388 Wallet Policy for this descriptor. + + :return: List of pubkey expression strings + """ + out = [p for p in self.pubkeys] + for s in self.subdescriptors: + out.extend(s.get_pubkey_providers()) + return out + class PKDescriptor(Descriptor): """ @@ -366,6 +435,9 @@ def expand(self, pos: int) -> "ExpandedScripts": return ExpandedScripts(script, None, None) + def get_bip388_template(self) -> str: + return "{}({},{})".format(self.name, self.thresh, ",".join([p.get_bip388_placeholder() for p in self.pubkeys])) + class SHDescriptor(Descriptor): """ @@ -444,6 +516,26 @@ def to_string_no_checksum(self, hardened_char: str = "h") -> str: r += ")" return r + def get_bip388_template(self) -> str: + r = f"{self.name}({self.pubkeys[0].get_bip388_placeholder()}" + path: List[bool] = [] # Track left or right for each depth + for p, depth in enumerate(self.depths): + r += "," + while len(path) <= depth: + if len(path) > 0: + r += "{" + path.append(False) + r += self.subdescriptors[p].get_bip388_template() + while len(path) > 0 and path[-1]: + if len(path) > 0: + r += "}" + path.pop() + if len(path) > 0: + path[-1] = True + r += ")" + return r + + def _get_func_expr(s: str) -> Tuple[str, str]: """ Get the function name and then the expression inside diff --git a/hwilib/errors.py b/hwilib/errors.py index 1f8c9195d..2c5cbde33 100644 --- a/hwilib/errors.py +++ b/hwilib/errors.py @@ -31,6 +31,7 @@ NEED_TO_BE_ROOT = -16 #: User needs to be root to perform action HELP_TEXT = -17 #: Help text was requested by the user DEVICE_NOT_INITIALIZED = -18 #: Device is not initialized +INVALID_POLICY = -19 #: Supplied descriptor cannot become a valid BIP 388 wallet policy # Exceptions class HWWError(Exception): @@ -204,6 +205,16 @@ class NeedsRootError(HWWError): def __init__(self, msg: str): HWWError.__init__(self, msg, NEED_TO_BE_ROOT) +class InvalidPolicyError(HWWError): + """ + :class:`HWWError` for :data:`INVALID_POLICY` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, INVALID_POLICY) + @contextmanager def handle_errors( msg: Optional[str] = None, diff --git a/test/test_descriptor.py b/test/test_descriptor.py index c6f0e177a..828e24616 100755 --- a/test/test_descriptor.py +++ b/test/test_descriptor.py @@ -9,6 +9,7 @@ WPKHDescriptor, WSHDescriptor, ) +from hwilib.errors import InvalidPolicyError from binascii import unhexlify @@ -294,6 +295,62 @@ def test_invalid_multipath_descriptors(self): with self.assertRaisesRegex(ValueError, "invalid literal for int()"): parse_descriptor("wpkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/<0;>/*)") + def test_valid_bip388_conversion(self): + def check(descriptor, keys, template): + d = parse_descriptor(descriptor) + self.assertEqual(d.get_bip388_template(), template) + self.assertEqual([p.get_bip388_key_info() for p in d.get_pubkey_providers()], keys) + + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0;1>/*)", + ["[6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb"], + "pkh(@0/<0;1>/*)" + ) + check( + "sh(wpkh([6738736c/49'/0'/1']xpub6Bex1CHWGXNNwGVKHLqNC7kcV348FxkCxpZXyCWp1k27kin8sRPayjZUKDjyQeZzGUdyeAj2emoW5zStFFUAHRgd5w8iVVbLgZ7PmjAKAm9/<0;1>/*))", + ["[6738736c/49'/0'/1']xpub6Bex1CHWGXNNwGVKHLqNC7kcV348FxkCxpZXyCWp1k27kin8sRPayjZUKDjyQeZzGUdyeAj2emoW5zStFFUAHRgd5w8iVVbLgZ7PmjAKAm9"], + "sh(wpkh(@0/<0;1>/*))" + ) + check( + "wpkh([6738736c/84'/0'/2']xpub6CRQzb8u9dmMcq5XAwwRn9gcoYCjndJkhKgD11WKzbVGd932UmrExWFxCAvRnDN3ez6ZujLmMvmLBaSWdfWVn75L83Qxu1qSX4fJNrJg2Gt/<0;1>/*)", + ["[6738736c/84'/0'/2']xpub6CRQzb8u9dmMcq5XAwwRn9gcoYCjndJkhKgD11WKzbVGd932UmrExWFxCAvRnDN3ez6ZujLmMvmLBaSWdfWVn75L83Qxu1qSX4fJNrJg2Gt"], + "wpkh(@0/<0;1>/*)" + ) + check( + "tr([6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL/<0;1>/*)", + ["[6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL"], + "tr(@0/<0;1>/*)" + ) + 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"], + "wsh(sortedmulti(2,@0/<0;1>/*,@1/<0;1>/*))" + ) + check( + "pkh([6738736c/44h/0h/0h]xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0h;1>/*)", + ["[6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb"], + "pkh(@0/<0';1>/*)" + ) + + def test_invalid_bip388_converstion(self): + def check(descriptor, error): + with self.assertRaisesRegex(InvalidPolicyError, error): + d = parse_descriptor(descriptor) + d.get_bip388_template() + + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0;1>)", + "BIP 388 requires all pubkeys to be ranged" + ) + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/0/1)", + "BIP 388 requires all pubkeys to be ranged" + ) + check( + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0;1;2>/*)", + "BIP 388 requires all multipath specifiers to be exactly 2 elements" + ) + if __name__ == "__main__": unittest.main() From 0626f37885e5d23684c11c9d665cfbea6750b6a7 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Wed, 5 Aug 2026 14:20:05 -0700 Subject: [PATCH 06/16] descriptor: Add RegisteredDescriptor for holding registration data After a descriptor is registered, we want to return to the caller some information about the registration. RegisteredDescriptor is a class that contains the name, the descriptor, the device type, and any data that the device returned in response to the registration. This class can be de/serialized from/to a string. --- hwilib/descriptor.py | 60 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index f7f4be88e..0e8531179 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -18,11 +18,19 @@ path_to_string, ) from .common import hash160, sha256 -from .errors import InvalidPolicyError +from .errors import BadArgumentError, InvalidPolicyError +from ._serialize import ( + deser_compact_size, + deser_string, + ser_compact_size, + ser_string, +) +from base64 import b64decode, b64encode from binascii import unhexlify from collections import namedtuple from enum import Enum +from io import BufferedReader, BytesIO from typing import ( List, Optional, @@ -767,3 +775,53 @@ def parse_descriptor(desc: str) -> 'Descriptor': if computed != checksum: raise ValueError("The checksum does not match; Got {}, expected {}".format(checksum, computed)) return _parse_descriptor(desc, _ParseDescriptorContext.TOP, 0)[0] + +class RegisteredDescriptor: + """ + An object containing a policy that was registered with a device + """ + + REG_VERSION = 0x00 + REG_NAME = 0x01 + REG_DESCRIPTOR = 0x02 + REG_DEVICE_TYPE = 0x03 + REG_REGISTRATION = 0x04 + + def __init__(self, name: str, descriptor: Descriptor, device_type: str, registration: bytes) -> None: + self.version = 1 + self.name = name + self.descriptor = descriptor + self.device_type = device_type + self.registration = registration + + def serialize(self) -> str: + r = b"rdesc" + + r += ser_compact_size(self.version) + r += ser_string(self.name.encode()) + r += ser_string(self.descriptor.to_string().encode()) + r += ser_string(self.device_type.encode()) + r += ser_string(self.registration) + + return b64encode(r).decode() + + @classmethod + def deserialize(cls, policy: str) -> 'RegisteredDescriptor': + policy_bytes = b64decode(policy.strip()) + s = BufferedReader(BytesIO(policy_bytes)) # type: ignore + + magic = s.read(5) + if magic != b"rdesc": + raise BadArgumentError("Policy has invalid magic bytes") + + version = deser_compact_size(s) + + if version != 1: + raise BadArgumentError("Serialized policy registration has unknown version number") + + name = deser_string(s).decode() + descriptor = parse_descriptor(deser_string(s).decode()) + device_type = deser_string(s).decode() + registration = deser_string(s) + + return cls(name, descriptor, device_type, registration) From 9f69a3d3d0b94569f195d5965789741346683fda Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 13:57:33 -0700 Subject: [PATCH 07/16] CLI command and boilerplate for registerdescriptor Adds registerdescriptor CLI command and its handlers. Adds register_descriptor to HardwareWalletClient with boilerplate implementation. --- hwilib/_cli.py | 9 +++++++++ hwilib/commands.py | 13 +++++++++++++ hwilib/hwwclient.py | 16 +++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/hwilib/_cli.py b/hwilib/_cli.py index e0afa7dd2..3d14600d9 100644 --- a/hwilib/_cli.py +++ b/hwilib/_cli.py @@ -12,6 +12,7 @@ getdescriptors, prompt_pin, toggle_passphrase, + register_descriptor, restore_device, send_pin, setup_device, @@ -105,6 +106,9 @@ def send_pin_handler(args: argparse.Namespace, client: HardwareWalletClient) -> def install_udev_rules_handler(args: argparse.Namespace) -> Dict[str, bool]: return install_udev_rules('udev', args.location) +def register_descriptor_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: + return register_descriptor(client, args.name, args.descriptor) + class HWIHelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): pass @@ -225,6 +229,11 @@ def get_parser() -> HWIArgumentParser: sendpin_parser.add_argument('pin', help='The numeric positions of the PIN') sendpin_parser.set_defaults(func=send_pin_handler) + register_descriptor_parser = subparsers.add_parser("registerdescriptor", help="Register a descriptor with the device") + register_descriptor_parser.add_argument("name", help="A human readable name for this descriptor. May be used by the device to identify the descriptor being used.") + register_descriptor_parser.add_argument("descriptor", help="The descriptor to register.") + register_descriptor_parser.set_defaults(func=register_descriptor_handler) + if sys.platform.startswith("linux"): udevrules_parser = subparsers.add_parser('installudevrules', help='Install and load the udev rule files for the hardware wallet devices') udevrules_parser.add_argument('--location', help='The path where the udev rules files will be copied', default='/etc/udev/rules.d/') diff --git a/hwilib/commands.py b/hwilib/commands.py index d385b6b0e..1730f9ffc 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -584,3 +584,16 @@ def install_udev_rules(source: str, location: str) -> Dict[str, bool]: from .udevinstaller import UDevInstaller return {"success": UDevInstaller.install(source, location)} raise NotImplementedError("udev rules are not needed on your platform") + + +def register_descriptor(client: HardwareWalletClient, name: str, descriptor: str) -> Dict[str, str]: + """ + Register a descriptor with the device. + + Returns any information that will be needed in the future to inform the + device about the descriptor to sign with. Some devices do not require any + information and will return nothing. + + :return: A dictionary with the ``registration`` key containing a string + """ + return {"registration": client.register_descriptor(name, parse_descriptor(descriptor)).serialize()} diff --git a/hwilib/hwwclient.py b/hwilib/hwwclient.py index 565afcf44..394bd837f 100644 --- a/hwilib/hwwclient.py +++ b/hwilib/hwwclient.py @@ -10,7 +10,11 @@ Optional, Union, ) -from .descriptor import MultisigDescriptor +from .descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from .key import ( ExtendedKey, get_bip44_purpose, @@ -237,3 +241,13 @@ def can_sign_taproot(self) -> bool: """ raise NotImplementedError("The HardwareWalletClient base class " "does not implement this method") + + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + Register a descriptor with the device + + :return: The registration result string + :raises UnavilableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") From 304d587ce43a181fd7ba556759104ced42cd8a42 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 16:17:12 -0700 Subject: [PATCH 08/16] Implement register_descriptors for devices that don't support it BitBox01, Trezor, and Keepkey do not support registering descriptors. Implement the function as a throw. --- hwilib/devices/digitalbitbox.py | 14 +++++++++++++- hwilib/devices/keepkey.py | 13 +++++++++++++ hwilib/devices/trezor.py | 14 +++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/hwilib/devices/digitalbitbox.py b/hwilib/devices/digitalbitbox.py index 2733d0a2e..efab96e83 100644 --- a/hwilib/devices/digitalbitbox.py +++ b/hwilib/devices/digitalbitbox.py @@ -32,7 +32,11 @@ Chain, hash256, ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -678,6 +682,14 @@ def can_sign_taproot(self) -> bool: """ return False + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + The BitBox02 does not support registering descriptors + + "raises UnavilableActionError: Always, this function is unavailable + """ + raise UnavailableActionError("The Digital Bitbox does not support registering descriptors") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/devices/keepkey.py b/hwilib/devices/keepkey.py index 21845e955..aefab5cc2 100644 --- a/hwilib/devices/keepkey.py +++ b/hwilib/devices/keepkey.py @@ -4,11 +4,16 @@ """ from ..common import Chain +from ..descriptor import ( + Descriptor, + RegisteredDescriptor, +) from ..errors import ( DEVICE_NOT_INITIALIZED, DeviceNotReadyError, common_err_msgs, handle_errors, + UnavailableActionError, ) from .trezorlib import protobuf from .trezorlib.transport import ( @@ -171,6 +176,14 @@ def can_sign_taproot(self) -> bool: """ return False + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + The KeepKey does not support registering descriptors + + "raises UnavilableActionError: Always, this function is unavailable + """ + raise UnavailableActionError("The KeepKey does not support registering descriptors") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index 9723766c6..89e1b58ce 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -16,7 +16,11 @@ Tuple, Union, ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -849,6 +853,14 @@ def can_sign_taproot(self) -> bool: return bool(self.client.version >= (1, 10, 4)) return True + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + """ + The Trezor does not support registering descriptors + + "raises UnavilableActionError: Always, this function is unavailable + """ + raise UnavailableActionError("The Trezor does not support registering descriptors") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] From 520bec09483cfadb742fa6a491e57561c7d14cb2 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 14:07:58 -0700 Subject: [PATCH 09/16] ledger: Implement register_descriptor --- hwilib/devices/ledger.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index f28ac226e..b4df7140b 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -15,8 +15,10 @@ ) from ..descriptor import ( + Descriptor, MultisigDescriptor, PubkeyProvider, + RegisteredDescriptor, ) from ..hwwclient import HardwareWalletClient from ..errors import ( @@ -549,6 +551,17 @@ def can_sign_taproot(self) -> bool: """ return isinstance(self.client, NewClient) + @ledger_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + if isinstance(self.client, LegacyClient): + raise UnavailableActionError("Legacy Ledger app does not support descriptor registration") + + template = descriptor.get_bip388_template() + keys = [p.get_bip388_key_info() for p in descriptor.get_pubkey_providers()] + policy = WalletPolicy(name, template, keys) + _, registered_hmac = self.client.register_wallet(policy) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="ledger", registration=registered_hmac) + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] From fa345896190aae9e1ca0d677798882f435d24fd4 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 14:19:47 -0700 Subject: [PATCH 10/16] bitbox02: Implement register_descriptor --- hwilib/devices/bitbox02.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/hwilib/devices/bitbox02.py b/hwilib/devices/bitbox02.py index 70d2fc817..67f7209b8 100644 --- a/hwilib/devices/bitbox02.py +++ b/hwilib/devices/bitbox02.py @@ -23,7 +23,11 @@ from functools import wraps from .._base58 import decode_check, encode_check -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..key import ExtendedKey from .._script import ( @@ -423,7 +427,7 @@ def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: return xpub def _maybe_register_script_config( - self, script_config: bitbox02.btc.BTCScriptConfig, keypath: Sequence[int] + self, script_config: bitbox02.btc.BTCScriptConfig, keypath: Sequence[int], name: str = "" ) -> None: bb02 = self.init() is_registered = bb02.btc_is_script_config_registered( @@ -434,7 +438,7 @@ def _maybe_register_script_config( coin=self._get_coin(), script_config=script_config, keypath=keypath, - name="", # enter name on the device + name=name, # Default empty string means enter name on the device xpub_type=bitbox02.btc.BTCRegisterScriptConfigRequest.AUTO_XPUB_TPUB, ) @@ -962,3 +966,23 @@ def can_sign_taproot(self) -> bool: :returns: False, always """ return False + + def _bip388_script_config(self, descriptor: Descriptor) -> bitbox02.btc.BTCScriptConfig: + desc_keys = [] + for pk in descriptor.get_pubkey_providers(): + desc_keys.append(bitbox02.common.KeyOriginInfo( + root_fingerprint=pk.origin.fingerprint if pk.origin else b"", + keypath=pk.origin.path if pk.origin else None, + xpub=util.parse_xpub(pk.pubkey) + )) + policy = bitbox02.btc.BTCScriptConfig.Policy( + policy=descriptor.get_bip388_template(), + keys=desc_keys, + ) + return bitbox02.btc.BTCScriptConfig(policy=policy) + + @bitbox02_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + script_config = self._bip388_script_config(descriptor) + self._maybe_register_script_config(script_config, [], name) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="bitbox02", registration=b"") From 16844ad3fe19308444f4485fb248821c766f9999 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 17:31:08 -0700 Subject: [PATCH 11/16] coldcard: Implement register_descriptor --- hwilib/devices/coldcard.py | 43 +++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/hwilib/devices/coldcard.py b/hwilib/devices/coldcard.py index 1efe889b9..4f5e5586d 100644 --- a/hwilib/devices/coldcard.py +++ b/hwilib/devices/coldcard.py @@ -9,7 +9,11 @@ Union, ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -59,6 +63,7 @@ import base64 import hid import io +import json import sys import time import struct @@ -415,6 +420,42 @@ def can_sign_taproot(self) -> bool: """ return self.is_edge + @coldcard_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + conf = { + "desc": descriptor.to_string(), + "name": name + } + conf_bytes = json.dumps(conf).encode() + + stream = io.BytesIO(conf_bytes) + size = len(conf_bytes) + + # Send the descriptor bytes + left = size + check = sha256() + for pos in range(0, size, MAX_BLK_LEN): + here = stream.read(min(MAX_BLK_LEN, left)) + if not here: + break + left -= len(here) + result = self.device.send_recv(CCProtocolPacker.upload(pos, size, here)) + assert result == pos + check.update(here) + + # verify the send + expect = check.digest() + result = self.device.send_recv(CCProtocolPacker.sha256()) + assert len(result) == 32 + if result != expect: + raise DeviceFailureError(f"Wrong checksum, expected {expect.hex()}, got {result.hex()}") + + # Register the descriptor + self.device.send_recv(CCProtocolPacker.multisig_enroll(size, expect), timeout=None) + if self.device.is_simulator: + self.device.send_recv(CCProtocolPacker.sim_keypress(b'y')) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="coldcard", registration=b"") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = True) -> List[Dict[str, Any]]: results = [] From 64209b40facc0a7d22fe382bf5a60db9491add2f Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 17:47:16 -0700 Subject: [PATCH 12/16] jade: Implement register_descriptor --- hwilib/devices/jade.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index 2bccee59d..91848bb36 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -20,7 +20,11 @@ Tuple, Union ) -from ..descriptor import MultisigDescriptor +from ..descriptor import ( + Descriptor, + MultisigDescriptor, + RegisteredDescriptor, +) from ..hwwclient import HardwareWalletClient from ..errors import ( ActionCanceledError, @@ -530,6 +534,13 @@ def can_sign_taproot(self) -> bool: """ return False + @jade_exception + def register_descriptor(self, name: str, descriptor: 'Descriptor') -> RegisteredDescriptor: + template = descriptor.get_bip388_template() + datavalues = {f"@{p.expr_index}": p.get_bip388_key_info() for p in descriptor.get_pubkey_providers()} + self.jade.register_descriptor(self._network(), name, template, datavalues) + return RegisteredDescriptor(name=name, descriptor=descriptor, device_type="jade", registration=b"") + def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]: results = [] From 616b9dad42a58aeedbc46f17d987358f2477f3fd Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 3 Aug 2026 17:58:44 -0700 Subject: [PATCH 13/16] test: Add basic test for register_descriptor --- test/data/speculos-automation.json | 9 +-------- test/test_bitbox02.py | 2 ++ test/test_coldcard.py | 2 ++ test/test_device.py | 28 +++++++++++++++++++++++++++- test/test_jade.py | 2 ++ test/test_ledger.py | 3 +++ 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/test/data/speculos-automation.json b/test/data/speculos-automation.json index 41cc06129..5618fefb7 100644 --- a/test/data/speculos-automation.json +++ b/test/data/speculos-automation.json @@ -39,14 +39,7 @@ ] }, { - "regexp": "^. of . Multisig$", - "actions": [ - [ "button", 2, true ], - [ "button", 2, false ] - ] - }, - { - "regexp": "^(Address|Review|Amount|External amounts|You spend|You receive|Fee|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|Wallet name|Wallet policy|Descriptor template|Verify [Bb]itcoin|Output|Warning).*", + "regexp": "^(Address|Review|Account name|Amount|External amounts|You spend|You receive|Fee|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).*", "actions": [ [ "button", 2, true ], [ "button", 2, false ] diff --git a/test/test_bitbox02.py b/test/test_bitbox02.py index 400da4afa..5085c34f4 100644 --- a/test/test_bitbox02.py +++ b/test/test_bitbox02.py @@ -18,6 +18,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignTx, ) @@ -128,6 +129,7 @@ def bitbox02_test_suite(simulator, bitcoind, interface): # TestSignMessage is removed, since its only testcase is for legacy p2pkh, which is not supported by BitBox02 # suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestBitbox02GetXpub, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=False, sorted=False)) 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 4259d1efd..ca64aa523 100755 --- a/test/test_coldcard.py +++ b/test/test_coldcard.py @@ -19,6 +19,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignMessage, TestSignTx, ) @@ -162,6 +163,7 @@ def coldcard_test_suite(simulator, bitcoind, interface): suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + suite.addTest(DeviceTestCase.parameterize(TestRegisterDescriptor, bitcoind, emulator=dev_emulator, interface=interface, returns_registration=False)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_device.py b/test/test_device.py index af26c5c78..0b0fe45ba 100644 --- a/test/test_device.py +++ b/test/test_device.py @@ -16,7 +16,7 @@ from authproxy import AuthServiceProxy, JSONRPCException from hwilib._base58 import xpub_to_pub_hex, to_address, decode from hwilib._cli import process_commands -from hwilib.descriptor import AddChecksum, parse_descriptor, PubkeyProvider +from hwilib.descriptor import AddChecksum, parse_descriptor, PubkeyProvider, RegisteredDescriptor from hwilib.key import ExtendedKey, KeyOriginInfo from hwilib.psbt import PSBT @@ -784,3 +784,29 @@ def test_sign_msg(self): def test_bad_path(self): result = self.do_command(self.dev_args + ['signmessage', "Message signing test", 'f']) self.assertEqual(result['code'], -7) + +class TestRegisterDescriptor(DeviceTestCase): + def __init__(self, *args, returns_registration, sorted=True, **kwargs): + super().__init__(*args, **kwargs) + self.returns_registration = returns_registration + self.sorted = sorted + + def test_register_descriptor(self): + account_path = "m/48h/1h/0h/2h" + account_xpub = self.do_command(self.dev_args + ["getxpub", account_path])["xpub"] + multi = "sortedmulti" if self.sorted else "multi" + descriptor = f"wsh({multi}(1,[{self.emulator.fingerprint}{account_path[1:]}]{account_xpub}/<0;1>/*,[1a0f5425{account_path[1:]}]tpubDF23ETNjCC283QmYZtJp26GqHkSa6Yw6vPqp3UkMsPCvBzRC4dMQzE1U3WwKsFsx3apUkQA4JHQDSmcC3N1yhE2gF1aKJA1CiVtNyA9Rv4H/<0;1>/*))" # noqa: E702 + desc_name = "HWI_testing" + result = self.do_command(self.dev_args + ["registerdescriptor", desc_name, descriptor]) + + self.assertNotIn("error", result) + self.assertIn("registration", result) + reg_str = result["registration"] + reg = RegisteredDescriptor.deserialize(reg_str) + self.assertEqual(reg.name, desc_name) + self.assertEqual(reg.descriptor.to_string_no_checksum(), descriptor) + self.assertEqual(reg.device_type, self.emulator.type) + if self.returns_registration: + self.assertGreater(len(reg.registration), 0) + else: + self.assertEqual(len(reg.registration), 0) diff --git a/test/test_jade.py b/test/test_jade.py index f5fef76dd..741892e80 100755 --- a/test/test_jade.py +++ b/test/test_jade.py @@ -18,6 +18,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignMessage, TestSignTx, ) @@ -249,6 +250,7 @@ def jade_test_suite(emulator, bitcoind, interface): suite.addTest(DeviceTestCase.parameterize(TestJadeGetMultisigAddresses, bitcoind, emulator=dev_emulator, interface=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)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() diff --git a/test/test_ledger.py b/test/test_ledger.py index 00a0ceeb2..0d783cb44 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -17,6 +17,7 @@ TestDisplayAddress, TestGetKeypool, TestGetDescriptors, + TestRegisterDescriptor, TestSignMessage, TestSignTx, ) @@ -190,6 +191,8 @@ def ledger_test_suite(emulator, bitcoind, interface, legacy=False): suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) 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)) result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) return result.wasSuccessful() From b9c94b6c7fd860659b175ad303b3d6909a13da4d Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Wed, 12 Aug 2026 10:32:07 -0700 Subject: [PATCH 14/16] test: Drop multiple multisigs test It's not a realistic test case to have the same device participate in a multisig multiple times. This case originally existed as a convenience, not a requirement. The paths for testing a multisig where the device is a single participant were already present in the tests. --- test/test_bitbox02.py | 1 - test/test_coldcard.py | 1 - test/test_device.py | 82 ++++++++++++++++++-------------------- test/test_digitalbitbox.py | 1 - test/test_jade.py | 1 - test/test_keepkey.py | 1 - test/test_ledger.py | 1 - test/test_trezor.py | 1 - 8 files changed, 39 insertions(+), 50 deletions(-) diff --git a/test/test_bitbox02.py b/test/test_bitbox02.py index 5085c34f4..329483690 100644 --- a/test/test_bitbox02.py +++ b/test/test_bitbox02.py @@ -42,7 +42,6 @@ def __init__(self, simulator): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = True - self.supports_device_multiple_multisig = True self.supports_legacy = False self.supports_arbitrary_keypool_paths = True diff --git a/test/test_coldcard.py b/test/test_coldcard.py index ca64aa523..d282c3c51 100755 --- a/test/test_coldcard.py +++ b/test/test_coldcard.py @@ -44,7 +44,6 @@ def __init__(self, simulator): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True diff --git a/test/test_device.py b/test/test_device.py index 0b0fe45ba..0db6b4f5a 100644 --- a/test/test_device.py +++ b/test/test_device.py @@ -34,7 +34,6 @@ def __init__(self): self.supports_taproot = None self.strict_bip48 = None self.include_xpubs = None - self.supports_device_multiple_multisig = None self.supports_legacy = None self.supports_arbitrary_keypool_paths = True @@ -50,7 +49,6 @@ def start(self): assert self.supports_unsorted_ms 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 assert self.supports_legacy is not None def stop(self): @@ -364,10 +362,9 @@ def setUp(self): self.setup_wallets() def _generate_and_finalize(self, unknown_inputs, psbt): - if not self.emulator.supports_device_multiple_multisig: - # We will need Core to sign so that the multisig is complete - core_sign_res = self.wpk_rpc.walletprocesspsbt(psbt=psbt, finalize=False) - psbt = core_sign_res["psbt"] + # We will need Core to sign so that the multisig is complete + core_sign_res = self.wpk_rpc.walletprocesspsbt(psbt=psbt, finalize=False) + psbt = core_sign_res["psbt"] if not unknown_inputs: # Just do the normal signing process to test "all inputs" case @@ -446,43 +443,42 @@ def _make_multisig(self, addrtype): desc_pubkeys = [] xpubs: Dict[bytes, KeyOriginInfo] = {} - for account in range(0, 3 if self.emulator.supports_device_multiple_multisig else 1): - path = f"/48h/1h/{account}h/{coin_type}h" - origin = '{}{}'.format(self.emulator.fingerprint, path) - xpub = self.do_command(self.dev_args + ["getxpub", "m{}".format(path)]) - desc_pubkeys.append("[{}]{}/0/0".format(origin, xpub["xpub"])) - if self.emulator.include_xpubs: - extkey = ExtendedKey.deserialize(xpub["xpub"]) - xpubs[extkey.serialize()] = KeyOriginInfo.from_string(origin) - - if not self.emulator.supports_device_multiple_multisig: - # If the device does not support itself in the multisig more than once, - # we need to fetch a key from Core, and use another key that will not be signed with - counter_descs = self.wpk_rpc.listdescriptors()["descriptors"] - desc = parse_descriptor(counter_descs[0]["desc"]) - pubkey_prov = None - while pubkey_prov is None: - if len(desc.pubkeys) > 0: - pubkey_prov = desc.pubkeys[0] - else: - desc = desc.subdescriptors[0] - assert pubkey_prov.extkey is not None - assert pubkey_prov.origin is not None - pubkey_prov.deriv_path = "/0/0" - desc_pubkeys.append(pubkey_prov.to_string()) - if self.emulator.include_xpubs: - xpubs[pubkey_prov.extkey.serialize()] = pubkey_prov.origin - - # A fixed key - fixed_extkey = ExtendedKey.deserialize("tpubDCBWBScQPGv4Xk3JSbhw6wYYpayMjb2eAYyArpbSqQTbLDpphHGAetB6VQgVeftLML8vDSUEWcC2xDi3qJJ3YCDChJDvqVzpgoYSuT52MhJ") - fixed_origin = KeyOriginInfo(b"\xde\xad\xbe\xef", [0x80000000]) - desc_pubkeys.append(PubkeyProvider(fixed_origin, fixed_extkey.to_string(), "/0/0").to_string()) - if self.emulator.include_xpubs: - xpubs[fixed_extkey.serialize()] = fixed_origin + path = f"/48h/1h/0h/{coin_type}h" + origin = '{}{}'.format(self.emulator.fingerprint, path) + xpub = self.do_command(self.dev_args + ["getxpub", "m{}".format(path)]) + desc_pubkeys.append("[{}]{}/0/*".format(origin, xpub["xpub"])) + if self.emulator.include_xpubs: + extkey = ExtendedKey.deserialize(xpub["xpub"]) + xpubs[extkey.serialize()] = KeyOriginInfo.from_string(origin) + + # fetch a key from Core, and use another key that will not be signed with + counter_descs = self.wpk_rpc.listdescriptors()["descriptors"] + desc = parse_descriptor(counter_descs[0]["desc"]) + pubkey_prov = None + while pubkey_prov is None: + if len(desc.pubkeys) > 0: + pubkey_prov = desc.pubkeys[0] + else: + desc = desc.subdescriptors[0] + assert pubkey_prov.extkey is not None + assert pubkey_prov.origin is not None + pubkey_prov.deriv_path = [[0]] + pubkey_prov.ranged = True + pubkey_prov.expr_index = 1 + desc_pubkeys.append(pubkey_prov.to_string()) + if self.emulator.include_xpubs: + xpubs[pubkey_prov.extkey.serialize()] = pubkey_prov.origin + + # A fixed key + fixed_extkey = ExtendedKey.deserialize("tpubDCBWBScQPGv4Xk3JSbhw6wYYpayMjb2eAYyArpbSqQTbLDpphHGAetB6VQgVeftLML8vDSUEWcC2xDi3qJJ3YCDChJDvqVzpgoYSuT52MhJ") + fixed_origin = KeyOriginInfo(b"\xde\xad\xbe\xef", [0x80000000]) + desc_pubkeys.append(PubkeyProvider(fixed_origin, fixed_extkey.to_string(), [[0]], 2, True).to_string()) + if self.emulator.include_xpubs: + xpubs[fixed_extkey.serialize()] = fixed_origin desc = AddChecksum(f"{desc_prefix}sortedmulti(2,{desc_pubkeys[0]},{desc_pubkeys[1]},{desc_pubkeys[2]}){desc_suffix}") - return desc, self.rpc.deriveaddresses(desc)[0], xpubs + return desc, self.rpc.deriveaddresses(desc, [0, 1])[0], xpubs def _test_signtx(self, input_types, multisig_types, external, op_return: bool): # Import some keys to the watch only wallet and send coins to them @@ -520,7 +516,7 @@ def _test_signtx(self, input_types, multisig_types, external, op_return: bool): xpubs.update(sh_multi_xpubs) - sh_multi_import = {'desc': sh_multi_desc, "timestamp": "now", "label": "shmulti"} + sh_multi_import = {'desc': sh_multi_desc, "timestamp": "now", "range": [0, 10]} multi_result = self.wrpc.importdescriptors([sh_multi_import]) self.assertTrue(multi_result[0]['success']) @@ -533,8 +529,8 @@ def _test_signtx(self, input_types, multisig_types, external, op_return: bool): xpubs.update(sh_wsh_xpubs) xpubs.update(wsh_xpubs) - sh_wsh_multi_import = {'desc': sh_wsh_multi_desc, "timestamp": "now", "label": "shwshmulti"} - wsh_multi_import = {'desc': wsh_multi_desc, "timestamp": "now", "label": "wshmulti"} + sh_wsh_multi_import = {'desc': sh_wsh_multi_desc, "timestamp": "now", "range": [0, 10]} + wsh_multi_import = {'desc': wsh_multi_desc, "timestamp": "now", "range": [0, 10]} multi_result = self.wrpc.importdescriptors([sh_wsh_multi_import, wsh_multi_import]) self.assertTrue(multi_result[0]['success']) diff --git a/test/test_digitalbitbox.py b/test/test_digitalbitbox.py index 9fc41ed3f..48f53890f 100755 --- a/test/test_digitalbitbox.py +++ b/test/test_digitalbitbox.py @@ -41,7 +41,6 @@ def __init__(self, simulator): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True diff --git a/test/test_jade.py b/test/test_jade.py index 741892e80..078bc1238 100755 --- a/test/test_jade.py +++ b/test/test_jade.py @@ -52,7 +52,6 @@ def __init__(self, jade_qemu_emulator_path): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True diff --git a/test/test_keepkey.py b/test/test_keepkey.py index ff9cf84c0..60ab59061 100755 --- a/test/test_keepkey.py +++ b/test/test_keepkey.py @@ -63,7 +63,6 @@ def __init__(self, path): self.supports_taproot = False self.strict_bip48 = False self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True diff --git a/test/test_ledger.py b/test/test_ledger.py index 0d783cb44..a170a4a86 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -46,7 +46,6 @@ def __init__(self, path, legacy=False): self.supports_taproot = not legacy # Legacy does not support Taproot self.strict_bip48 = True self.include_xpubs = True - self.supports_device_multiple_multisig = True self.supports_legacy = True # Bitcoin app 2.5.0 enforces standard Ledger paths for xpub derivation. self.supports_arbitrary_keypool_paths = legacy diff --git a/test/test_trezor.py b/test/test_trezor.py index b61c1f00a..4deb56881 100755 --- a/test/test_trezor.py +++ b/test/test_trezor.py @@ -62,7 +62,6 @@ def __init__(self, path, model): self.supports_taproot = True self.strict_bip48 = True self.include_xpubs = False - self.supports_device_multiple_multisig = True self.supports_legacy = True self.supports_arbitrary_keypool_paths = True From 5f634fe1d4bceae59136e0fbeb14eebe667199df Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Wed, 12 Aug 2026 10:36:41 -0700 Subject: [PATCH 15/16] test: Drop coldcard multisig patches --- test/data/coldcard-multisig.patch | 114 ------------------------------ test/data/coldcard_xpubs.json | 47 ------------ test/setup_environment.sh | 2 - 3 files changed, 163 deletions(-) delete mode 100644 test/data/coldcard-multisig.patch delete mode 100644 test/data/coldcard_xpubs.json diff --git a/test/data/coldcard-multisig.patch b/test/data/coldcard-multisig.patch deleted file mode 100644 index 05216356f..000000000 --- a/test/data/coldcard-multisig.patch +++ /dev/null @@ -1,114 +0,0 @@ -From 038e4b4f5e8128f3910745324332770b0973408c Mon Sep 17 00:00:00 2001 -From: Ava Chow -Date: Wed, 1 Oct 2025 13:30:01 -0700 -Subject: [PATCH 1/2] Change default simulator multisig - ---- - unix/variant/sim_settings.py | 6 +++++- - 1 file changed, 5 insertions(+), 1 deletion(-) - -diff --git a/unix/variant/sim_settings.py b/unix/variant/sim_settings.py -index 01392d8f..527d0d9b 100644 ---- a/unix/variant/sim_settings.py -+++ b/unix/variant/sim_settings.py -@@ -71,7 +71,11 @@ if '--ms' in sys.argv: - sim_defaults['multisig'] = [["CC-2-of-4", [2, 4], [[1130956047, "tpubDF2rnouQaaYrUEy2JM1YD3RFzew4onawGM4X2Re67gguTf5CbHonBRiFGe3Xjz7DK88dxBFGf2i7K1hef3PM4cFKyUjcbJXddaY9F5tJBoP"], [3503269483, "tpubDFcrvj5n7gyatVbr8dHCUfHT4CGvL8hREBjtxc4ge7HZgqNuPhFimPRtVg6fRRwfXiQthV9EBjNbwbpgV2VoQeL1ZNXoAWXxP2L9vMtRjax"], [2389277556, "tpubDExj5FnaUnPAjjgzELoSiNRkuXJG8Cm1pbdiA4Hc5vkAZHphibeVcUp6mqH5LuNVKbtLVZxVSzyja5X26Cfmx6pzRH6gXBUJAH7MiqwNyuM"], [3190206587, "tpubDFiuHYSJhNbHaGtB5skiuDLg12tRboh2uVZ6KGXxr8WVr28pLcS7F3gv8SsHFa2tm1jtx3VAuw56YfgRkdo6DXyfp51oygTKY3nJFT5jBMt"]], {"pp": "48'/1'/0'/1'", "ch": "XTN", "ft": 26}]] - else: - # P2SH: 2of4 using BIP39 passwords: "Me", "Myself", "and I", and (empty string) on simulator -- sim_defaults['multisig'] = [['MeMyself', [2, 4], [[3503269483, 'tpubD9429UXFGCTKJ9NdiNK4rC5ygqSUkginycYHccqSg5gkmyQ7PZRHNjk99M6a6Y3NY8ctEUUJvCu6iCCui8Ju3xrHRu3Ez1CKB4ZFoRZDdP9'], [2389277556, 'tpubD97nVL37v5tWyMf9ofh5rznwhh1593WMRg6FT4o6MRJkKWANtwAMHYLrcJFsFmPfYbY1TE1LLQ4KBb84LBPt1ubvFwoosvMkcWJtMwvXgSc'], [3190206587, 'tpubD9ArfXowvGHnuECKdGXVKDMfZVGdephVWg8fWGWStH3VKHzT4ph3A4ZcgXWqFu1F5xGTfxncmrnf3sLC86dup2a8Kx7z3xQ3AgeNTQeFxPa'], [1130956047, 'tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n']], {'ch': 'XTN', 'pp': "45'"}]] -+ sim_defaults['multisig'] = [ -+ ['mstest', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrR9x68P5Jm8WjhCE4atyGiPviFA9ve5iMnYbkTjof2HjzejcQcD7getPusDLPsWJLN2UttzK3pyVgBkRs52MiRZM7ZJ8TrEq'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 8, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], -+ ['mstest1', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrR9x68P5Jm8WjhCE4atyGiPviFA9ve5iMnYbkTjof2HjzejcQcD7getPusDLPsWJLN2UttzK3pyVgBkRs52MiRZM7ZJ8TrEq'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 14, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], -+ ['mstest2', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrR9x68P5Jm8WjhCE4atyGiPviFA9ve5iMnYbkTjof2HjzejcQcD7getPusDLPsWJLN2UttzK3pyVgBkRs52MiRZM7ZJ8TrEq'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 26, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], -+ ] - sim_defaults['fee_limit'] = -1 - - if '--xfp' in sys.argv: --- -2.51.0 - - -From 3f7b6dfcf72788a4a0784eb871462c289e8a747b Mon Sep 17 00:00:00 2001 -From: Sjors Provoost -Date: Wed, 23 Jul 2025 10:16:22 +0200 -Subject: [PATCH 2/2] Allow multisigs to share master fingerprint - -Co-Authored-By: Ava Chow ---- - shared/multisig.py | 39 ++++++++++++++++++++++++--------------- - 1 file changed, 24 insertions(+), 15 deletions(-) - -diff --git a/shared/multisig.py b/shared/multisig.py -index 1e692d41..6294e9a3 100644 ---- a/shared/multisig.py -+++ b/shared/multisig.py -@@ -144,9 +144,9 @@ class MultisigWallet(WalletABC): - # calc useful cache value: numeric xfp+subpath, with lookup - self.xfp_paths = {} - for xfp, deriv, xpub in self.xpubs: -- self.xfp_paths[xfp] = str_to_keypath(xfp, deriv) -+ self.xfp_paths.setdefault(xfp, list()).append(str_to_keypath(xfp, deriv)) - -- assert len(self.xfp_paths) == self.N, 'dup XFP' # not supported -+ assert len(self.xpubs) == self.N, 'Number of pubkeys does not match N' - - @classmethod - def render_addr_fmt(cls, addr_fmt): -@@ -275,7 +275,11 @@ class MultisigWallet(WalletABC): - - def get_xfp_paths(self): - # return list of lists [xfp, *deriv] -- return list(self.xfp_paths.values()) -+ ret = [] -+ for paths_list in self.xfp_paths: -+ for xfp_path in paths_list: -+ ret.append(xfp_path) -+ return ret - - @classmethod - def find_match(cls, M, N, xfp_paths, addr_fmts=None): -@@ -305,24 +309,29 @@ class MultisigWallet(WalletABC): - # the same prefix path per-each xfp, as indicated - # xfp_paths (unordered)? - # - could also check non-prefix part is all non-hardened -- if len(xfp_paths) != len(self.xfp_paths): -+ if len(xfp_paths) != sum([len(x) for _, x in self.xfp_paths.items()]): - # cannot be the same if len(w0.N) != len(w1.N) - # maybe check duplicates first? - return False - for x in xfp_paths: - if x[0] not in self.xfp_paths: - return False -- prefix = self.xfp_paths[x[0]] -- -- if len(x) < len(prefix): -- # PSBT specs a path shorter than wallet's xpub -- #print('path len: %d vs %d' % (len(prefix), len(x))) -- return False -- -- comm = len(prefix) -- if tuple(prefix[:comm]) != tuple(x[:comm]): -- # xfp => maps to wrong path -- #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm)) -+ for prefix in self.xfp_paths[x[0]]: -+ if len(x) < len(prefix): -+ # PSBT specs a path shorter than wallet's xpub -+ #print('path len: %d vs %d' % (len(prefix), len(x))) -+ return False -+ -+ comm = len(prefix) -+ if tuple(prefix[:comm]) != tuple(x[:comm]): -+ # xfp => maps to wrong path -+ #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm)) -+ continue -+ else: -+ # Found a match, cleanly exit -+ break -+ else: -+ # No match was found - return False - - return True --- -2.51.0 - diff --git a/test/data/coldcard_xpubs.json b/test/data/coldcard_xpubs.json deleted file mode 100644 index 164807d16..000000000 --- a/test/data/coldcard_xpubs.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "master_xpub": "tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd", - "vectors" : [ - { - "path" : "m/0h", - "xpub" : "tpubD8NXmKsmWp3Y4WD8VqhKZ61UrvLfghEJjjHsJT8NYBPaHMVDVmCWGmttsn4HE6mEMSdCwcaGtB2iftGFZRcfeF2NxbpzSt2jpkYjdRQuX4W" - }, - { - "path" : "m/0h/1", - "xpub" : "tpubDBKU51v7tZU5KvJ34JHeQ5Rr2uRg8mJLr3JSRR68HPZXEtwubGyqXBDDUqJmUAHzb9h23zyAJVSbzrV6P5qCKfqu8gQfX1EpH16SBeshMXp" - }, - { - "path" : "m/0h/1/2h", - "xpub" : "tpubDD4Y2tN94L2KZiQ1tdpC5oUUATuWAwmRTuPchMY3HCkz2fexHBZDriebcpBYBcicEy1WTYAiALgemNXKd6jcuj9Vj4kUxhG3Uw9m8YgbcTn" - }, - { - "path" : "m/0h/1/2h/2", - "xpub" : "tpubDEt7ap9HgGHPeP5DJqTvuptUw6RoJXfX5bstN8CdSvFgwFa69Qh2mB3hwJ6PAGfvpckRwrazGCJbmeUYtfr5ysREdfqyQSU4UFqhktMCmDr" - }, - { - "path" : "m/0h/1/2h/2/1000000000", - "xpub" : "tpubDGV2stSVNpSZ7BPHmYYTqt4DXPaqKdqGMTX8ecS5Ae5tiVXd2NrqArPV8EBXzZ55y6u86SQF1i4eQ3JVYJbLdGYz3yTN8UrkJQrbgYjEeHH" - }, - { - "path" : "m/0", - "xpub" : "tpubD8NXmKsdB9WZs2HhkE7jEZLymyb5dyvjJg2d7gGJCadyaoogRhtcpTUWdxmvECgVUVpGrxxHJzYdKw6q79NtLL4mzNeqbk4h2B2sE8aisFE" - }, - { - "path" : "m/0/2147483647h", - "xpub" : "tpubDAogKPq6VDemCnuzg2ChNjDFtfiw2bG4Ja17GTxyAmohbNmz29Z7xRitwVdTrUPBdVF1EEuQBrAvUJ6m6kNFKSB723ws3RBxL8a67BnYNd3" - }, - { - "path" : "m/0/2147483647h/1", - "xpub" : "tpubDDAsHiKquEdhD3dpctEhMgrF3QVFKz2NeCS6kkdTcN6AszAyCppLbQDzTqdqzZV4N1guZyWmxpT59dUPWKuKtijGvETuHSbNQubNvZck3L7" - }, - { - "path" : "m/0/2147483647h/1/2147483646h", - "xpub" : "tpubDFNKqVRhj269YBcR4QHUeYHruqHQYCJkQX4K2oJG2S9EEEkvzP2fsEL5RB7zXBspZJNuxvv1qFUDafQQaN7j7Tv2g5U5gzb2YNshNZDR1Fx" - }, - { - "path" : "m/0/2147483647h/1/2147483646h/2", - "xpub" : "tpubDGRkLZjvcoBbAnQjmCcx5Crc6hcU2BBJbXFyX516GSEw68mj6NTB7W7sXnMNA3iVoxDPYTt2QbR3oP8cjxxgkjaN9mqrAP43VqASDa3W4Qz" - } - ] - } -] \ No newline at end of file diff --git a/test/setup_environment.sh b/test/setup_environment.sh index ac76242d9..1ccb8cd7e 100755 --- a/test/setup_environment.sh +++ b/test/setup_environment.sh @@ -163,8 +163,6 @@ if [[ -n ${build_coldcard} ]]; then coldcard_setup_needed=true fi fi - # Apply patch to make simulator work in linux environments - git am ../../data/coldcard-multisig.patch # Build the simulator. This is cached, but it is also fast poetry run pip install -r requirements.txt From 1af00789624bf21acd892fad22a8af27d1cd0e0d Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Thu, 13 Aug 2026 12:32:50 -0700 Subject: [PATCH 16/16] test: Some devices need multisigs to be registered to display them --- test/test_bitbox02.py | 1 + test/test_coldcard.py | 1 + test/test_device.py | 8 ++++++++ test/test_digitalbitbox.py | 1 + test/test_jade.py | 1 + test/test_keepkey.py | 1 + test/test_ledger.py | 1 + test/test_trezor.py | 1 + 8 files changed, 15 insertions(+) diff --git a/test/test_bitbox02.py b/test/test_bitbox02.py index 329483690..b8bde6f63 100644 --- a/test/test_bitbox02.py +++ b/test/test_bitbox02.py @@ -44,6 +44,7 @@ def __init__(self, simulator): self.include_xpubs = True self.supports_legacy = False self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() diff --git a/test/test_coldcard.py b/test/test_coldcard.py index d282c3c51..eb27984ed 100755 --- a/test/test_coldcard.py +++ b/test/test_coldcard.py @@ -46,6 +46,7 @@ def __init__(self, simulator): self.include_xpubs = False self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = True def start(self): super().start() diff --git a/test/test_device.py b/test/test_device.py index 0db6b4f5a..bd63f8465 100644 --- a/test/test_device.py +++ b/test/test_device.py @@ -36,6 +36,7 @@ def __init__(self): self.include_xpubs = None self.supports_legacy = None self.supports_arbitrary_keypool_paths = True + self.register_multisig = None def start(self): assert self.type is not None @@ -50,6 +51,7 @@ def start(self): assert self.strict_bip48 is not None assert self.include_xpubs is not None assert self.supports_legacy is not None + assert self.register_multisig is not None def stop(self): pass @@ -728,6 +730,12 @@ def _make_single_multisig(self, addrtype, sort, use_xpub): else: self.fail("Oops the test is broken") + if self.emulator.register_multisig: + desc_name = f"{addrtype}_{'sorted' if sort else 'unsorted'}_{'xpub' if use_xpub else 'single'}" + result = self.do_command(self.dev_args + ["registerdescriptor", desc_name, desc]) + self.assertNotIn("error", result) + self.assertIn("registration", result) + return addr, desc def test_display_address_multisig(self): diff --git a/test/test_digitalbitbox.py b/test/test_digitalbitbox.py index 48f53890f..aaecf3b43 100755 --- a/test/test_digitalbitbox.py +++ b/test/test_digitalbitbox.py @@ -43,6 +43,7 @@ def __init__(self, simulator): self.include_xpubs = False self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() diff --git a/test/test_jade.py b/test/test_jade.py index 078bc1238..5617a4e18 100755 --- a/test/test_jade.py +++ b/test/test_jade.py @@ -54,6 +54,7 @@ def __init__(self, jade_qemu_emulator_path): self.include_xpubs = False self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() diff --git a/test/test_keepkey.py b/test/test_keepkey.py index 60ab59061..f2cf0296b 100755 --- a/test/test_keepkey.py +++ b/test/test_keepkey.py @@ -65,6 +65,7 @@ def __init__(self, path): self.include_xpubs = False self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start() diff --git a/test/test_ledger.py b/test/test_ledger.py index a170a4a86..4887617bc 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -49,6 +49,7 @@ def __init__(self, path, legacy=False): self.supports_legacy = True # Bitcoin app 2.5.0 enforces standard Ledger paths for xpub derivation. self.supports_arbitrary_keypool_paths = legacy + self.register_multisig = not legacy def start(self): super().start() diff --git a/test/test_trezor.py b/test/test_trezor.py index 4deb56881..cae937af1 100755 --- a/test/test_trezor.py +++ b/test/test_trezor.py @@ -64,6 +64,7 @@ def __init__(self, path, model): self.include_xpubs = False self.supports_legacy = True self.supports_arbitrary_keypool_paths = True + self.register_multisig = False def start(self): super().start()