From 63f8ef8d57d33040d855cbb00adc152a472a8eee Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 30 Jul 2025 18:29:38 +0200 Subject: [PATCH 1/7] Introduce BIP388Policy dataclass --- hwilib/common.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/hwilib/common.py b/hwilib/common.py index 0c5c00606..862b155a6 100644 --- a/hwilib/common.py +++ b/hwilib/common.py @@ -3,12 +3,17 @@ **************************** """ +from dataclasses import dataclass + import hashlib from enum import Enum -from typing import Union - +from typing import ( + List, + Optional, + Union, +) class Chain(Enum): """ @@ -56,6 +61,15 @@ def argparse(s: str) -> Union['AddressType', str]: except KeyError: return s +@dataclass +class BIP388Policy: + """ + Serialization agnostic BIP388 policy. + """ + name: str + descriptor_template: str + keys_info: List[str] + hmac: Optional[str] = None def sha256(s: bytes) -> bytes: """ From fdeeef881ee6049ef427cc4a2662a161d365b7ef Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 30 Jul 2025 21:03:17 +0200 Subject: [PATCH 2/7] Add register command for BIP388 policies --- hwilib/_cli.py | 12 ++++++++++++ hwilib/commands.py | 18 ++++++++++++++++++ hwilib/devices/ledger.py | 19 +++++++++++++++++++ hwilib/hwwclient.py | 17 ++++++++++++++++- 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/hwilib/_cli.py b/hwilib/_cli.py index e0afa7dd2..b7b439f17 100644 --- a/hwilib/_cli.py +++ b/hwilib/_cli.py @@ -12,6 +12,7 @@ getdescriptors, prompt_pin, toggle_passphrase, + register, restore_device, send_pin, setup_device, @@ -22,6 +23,7 @@ ) from .common import ( AddressType, + BIP388Policy, Chain, ) from .errors import ( @@ -59,6 +61,10 @@ def backup_device_handler(args: argparse.Namespace, client: HardwareWalletClient def displayaddress_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: return displayaddress(client, desc=args.desc, path=args.path, addr_type=args.addr_type) +def register_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: + policy = BIP388Policy(name=args.name, descriptor_template=args.desc, keys_info=args.key) + return register(client, bip388_policy=policy) + def enumerate_handler(args: argparse.Namespace) -> List[Dict[str, Any]]: return enumerate(password=args.password, expert=args.expert, chain=args.chain, allow_emulators=args.allow_emulators) @@ -197,6 +203,12 @@ def get_parser() -> HWIArgumentParser: displayaddr_parser.add_argument("--addr-type", help="The address type to display", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore displayaddr_parser.set_defaults(func=displayaddress_handler) + register_parser = subparsers.add_parser('register', help='Register a BIP388 wallet policy') + register_parser.add_argument('--name', help='Name for the policy') + register_parser.add_argument('--desc', help='Descriptor template, e.g. tr(musig(@0,@1)') + register_parser.add_argument('--key', help='Key information, e.g. [00000000/84h/0h/0h]xpub...', action='append') + register_parser.set_defaults(func=register_handler) + setupdev_parser = subparsers.add_parser('setup', help='Setup a device. Passphrase protection uses the password given by -p. Requires interactive mode') setupdev_parser.add_argument('--label', '-l', help='The name to give to the device', default='') setupdev_parser.add_argument('--backup_passphrase', '-b', help='The passphrase to use for the backup, if applicable', default='') diff --git a/hwilib/commands.py b/hwilib/commands.py index 6d192aa5f..5416f72f0 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -52,6 +52,7 @@ from .devices import __all__ as all_devs from .common import ( AddressType, + BIP388Policy, Chain, ) from .hwwclient import HardwareWalletClient @@ -494,6 +495,23 @@ def displayaddress( return {"address": client.display_singlesig_address(pubkey.get_full_derivation_path(0), addr_type)} raise BadArgumentError("Missing both path and descriptor") +def register( + client: HardwareWalletClient, + bip388_policy: BIP388Policy, +) -> Dict[str, str]: + """ + Register a BIP388 policy on the device for client. + + :param name: Name for the policy + :param desc: Descriptor template + :return: A dictionary containing policy HMAC. + Returned as ``{"hmac": }``. + :raises: BadArgumentError: if an argument is malformed, missing, or conflicts. + """ + assert bip388_policy.hmac is None + + return {"hmac": client.register_bip388_policy(bip388_policy)} + def setup_device(client: HardwareWalletClient, label: str = "", backup_passphrase: str = "") -> Dict[str, bool]: """ Setup a device that has not yet been initialized. diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index d3cf46325..fd5b8d640 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -31,6 +31,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, ) from .ledger_bitcoin.client import ( @@ -479,6 +480,24 @@ def format_key_info(pubkey: PubkeyProvider) -> str: return self.client.get_wallet_address(multisig_wallet, registered_hmac, change, address_index, True) + @ledger_exception + def register_bip388_policy( + self, + bip388_policy: BIP388Policy, + ) -> str: + if isinstance(self.client, LegacyClient): + raise BadArgumentError("Registering a BIP388 policy not supported by this version of the Bitcoin App") + + wallet_policy = WalletPolicy( + name=bip388_policy.name, + descriptor_template=bip388_policy.descriptor_template, + keys_info=bip388_policy.keys_info + ) + + _, registered_hmac = self.client.register_wallet(wallet_policy) + + return registered_hmac.hex() + def setup_device(self, label: str = "", passphrase: str = "") -> bool: """ Ledgers do not support setup via software. diff --git a/hwilib/hwwclient.py b/hwilib/hwwclient.py index 565afcf44..72b4e75ca 100644 --- a/hwilib/hwwclient.py +++ b/hwilib/hwwclient.py @@ -17,7 +17,11 @@ get_bip44_chain, ) from .psbt import PSBT -from .common import AddressType, Chain +from .common import ( + AddressType, + BIP388Policy, + Chain, +) class HardwareWalletClient(object): @@ -135,6 +139,17 @@ def display_multisig_address( raise NotImplementedError("The HardwareWalletClient base class " "does not implement this method") + def register_bip388_policy( + self, + bip388_policy: BIP388Policy, + ) -> str: + """ + Register a BIP388 policy. + + :return: The policy HMAC + """ + raise NotImplementedError("This device does not support BIP388 policies or it's not yet implemented") + def wipe_device(self) -> bool: """ Wipe the device. From c6c7bfbee5b2b1dde6fa46755f1532732f8721c7 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 30 Jul 2025 18:35:57 +0200 Subject: [PATCH 3/7] Consistently name sign_tx psbt argument --- hwilib/devices/coldcard.py | 14 +++++++------- hwilib/devices/digitalbitbox.py | 12 ++++++------ hwilib/devices/jade.py | 6 +++--- hwilib/devices/ledger.py | 16 ++++++++-------- hwilib/devices/trezor.py | 22 +++++++++++----------- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/hwilib/devices/coldcard.py b/hwilib/devices/coldcard.py index e64f3619c..19afc1ef6 100644 --- a/hwilib/devices/coldcard.py +++ b/hwilib/devices/coldcard.py @@ -116,7 +116,7 @@ def get_master_fingerprint(self) -> bytes: return struct.pack(' PSBT: + def sign_tx(self, psbt: PSBT) -> PSBT: """ Sign a transaction with the Coldcard. @@ -132,7 +132,7 @@ def sign_tx(self, tx: PSBT) -> PSBT: # For multisigs, we may need to do multiple passes if we appear in an input multiple times passes = 1 - for psbt_in in tx.inputs: + for psbt_in in psbt.inputs: our_keys = 0 for key in psbt_in.hd_keypaths.keys(): keypath = psbt_in.hd_keypaths[key] @@ -143,8 +143,8 @@ def sign_tx(self, tx: PSBT) -> PSBT: for _ in range(passes): # Get psbt in hex and then make binary - tx.convert_to_v0() - fd = io.BytesIO(base64.b64decode(tx.serialize())) + psbt.convert_to_v0() + fd = io.BytesIO(base64.b64decode(psbt.serialize())) # learn size (portable way) sz = fd.seek(0, 2) @@ -190,10 +190,10 @@ def sign_tx(self, tx: PSBT) -> PSBT: result = self.device.download_file(result_len, result_sha, file_number=1) - tx = PSBT() - tx.deserialize(base64.b64encode(result).decode()) + psbt = PSBT() + psbt.deserialize(base64.b64encode(result).decode()) - return tx + return psbt @coldcard_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: diff --git a/hwilib/devices/digitalbitbox.py b/hwilib/devices/digitalbitbox.py index 2733d0a2e..426fd2364 100644 --- a/hwilib/devices/digitalbitbox.py +++ b/hwilib/devices/digitalbitbox.py @@ -387,17 +387,17 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return xpub @digitalbitbox_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx(self, psbt: PSBT) -> PSBT: # Create a transaction with all scriptsigs blanked out - blank_tx = tx.get_unsigned_tx() + blank_tx = psbt.get_unsigned_tx() # Get the master key fingerprint master_fp = self.get_master_fingerprint() # create sighashes sighash_tuples = [] - for txin, psbt_in, i_num in zip(blank_tx.vin, tx.inputs, range(len(blank_tx.vin))): + for txin, psbt_in, i_num in zip(blank_tx.vin, psbt.inputs, range(len(blank_tx.vin))): sighash = b"" utxo = None if psbt_in.witness_utxo: @@ -493,7 +493,7 @@ def sign_tx(self, tx: PSBT) -> PSBT: # Return early if nothing to do if len(sighash_tuples) == 0: - return tx + return psbt for i in range(0, len(sighash_tuples), 15): tups = sighash_tuples[i:i + 15] @@ -533,9 +533,9 @@ def sign_tx(self, tx: PSBT) -> PSBT: # add sigs to tx for tup, sig in zip(tups, der_sigs): - tx.inputs[tup[2]].partial_sigs[tup[3]] = sig + psbt.inputs[tup[2]].partial_sigs[tup[3]] = sig - return tx + return psbt @digitalbitbox_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index 2530ebb34..f9652b958 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -370,16 +370,16 @@ def _split_at_last_hardened_element(path: Sequence[int]) -> Tuple[Sequence[int], # Sign tx PSBT - newer Jade firmware supports native PSBT signing, but old firmwares require # mapping to the legacy 'sign_tx' structures. @jade_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx(self, psbt: PSBT) -> PSBT: """ Sign a transaction with the Blockstream Jade. """ # Old firmware does not have native PSBT handling - use legacy method if self.PSBT_SUPPORTED_FW_VERSION > self.fw_version.finalize_version(): - return self.legacy_sign_tx(tx) + return self.legacy_sign_tx(psbt) # Firmware 0.1.47 (March 2023) and later support native PSBT signing - psbt_b64 = tx.serialize() + psbt_b64 = psbt.serialize() psbt_bytes = base64.b64decode(psbt_b64.strip()) # NOTE: sign_psbt() does not use AE signatures, so sticks with default (rfc6979) diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index fd5b8d640..3c439e1d8 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -186,7 +186,7 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return ExtendedKey.deserialize(xpub_str) @ledger_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx(self, psbt: PSBT) -> PSBT: """ Sign a transaction with a Ledger device. Not all transactions can be signed by a Ledger. @@ -207,20 +207,20 @@ def legacy_sign_tx() -> PSBT: if not isinstance(client, LegacyClient): client = LegacyClient(self.transport_client, self.chain) wallet = WalletPolicy("", "wpkh(@0/**)", [""]) - legacy_input_sigs = client.sign_psbt(tx, wallet, None) + legacy_input_sigs = client.sign_psbt(psbt, wallet, None) for idx, pubkey, sig in legacy_input_sigs: - psbt_in = tx.inputs[idx] + psbt_in = psbt.inputs[idx] psbt_in.partial_sigs[pubkey] = sig - return tx + return psbt if isinstance(self.client, LegacyClient): return legacy_sign_tx() # Make a deepcopy of this psbt. We will need to modify it to get signing to work, # which will affect the caller's detection for whether signing occured. - psbt2 = copy.deepcopy(tx) - if tx.version != 2: + psbt2 = copy.deepcopy(psbt) + if psbt.version != 2: psbt2.convert_to_v2() # Figure out which wallets are signing @@ -370,13 +370,13 @@ def process_origin(origin: KeyOriginInfo) -> None: psbt_in.partial_sigs[pubkey] = sig # Extract the sigs from psbt2 and put them into tx - for sig_in, psbt_in in zip(psbt2.inputs, tx.inputs): + for sig_in, psbt_in in zip(psbt2.inputs, psbt.inputs): psbt_in.partial_sigs.update(sig_in.partial_sigs) psbt_in.tap_script_sigs.update(sig_in.tap_script_sigs) if len(sig_in.tap_key_sig) != 0 and len(psbt_in.tap_key_sig) == 0: psbt_in.tap_key_sig = sig_in.tap_key_sig - return tx + return psbt @ledger_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index bd5a79733..9143e88fa 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -355,7 +355,7 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return xpub @trezor_exception - def sign_tx(self, tx: PSBT) -> PSBT: + def sign_tx(self, psbt: PSBT) -> PSBT: """ Sign a transaction with the Trezor. There are some limitations to what transactions can be signed. @@ -378,7 +378,7 @@ def sign_tx(self, tx: PSBT) -> PSBT: # Prepare inputs inputs = [] to_ignore = [] # Note down which inputs whose signatures we're going to ignore - for input_num, psbt_in in builtins.enumerate(tx.inputs): + for input_num, psbt_in in builtins.enumerate(psbt.inputs): assert psbt_in.prev_txid is not None assert psbt_in.prev_out is not None assert psbt_in.sequence is not None @@ -443,7 +443,7 @@ def ignore_input() -> None: to_ignore.append(input_num) # Check for multisig - is_ms, multisig = parse_multisig(scriptcode, tx.xpub, psbt_in) + is_ms, multisig = parse_multisig(scriptcode, psbt.xpub, psbt_in) if is_ms: # Add to txinputtype txinputtype.multisig = multisig @@ -529,7 +529,7 @@ def ignore_input() -> None: # prepare outputs outputs = [] - for psbt_out in tx.outputs: + for psbt_out in psbt.outputs: out = psbt_out.get_txout() txoutput = messages.TxOutputType(amount=out.nValue) txoutput.script_type = messages.OutputScriptType.PAYTOADDRESS @@ -578,7 +578,7 @@ def ignore_input() -> None: if psbt_out.witness_script or psbt_out.redeem_script: is_ms, multisig = parse_multisig( psbt_out.witness_script or psbt_out.redeem_script, - tx.xpub, psbt_out) + psbt.xpub, psbt_out) if is_ms: txoutput.multisig = multisig if not wit: @@ -589,7 +589,7 @@ def ignore_input() -> None: # Prepare prev txs prevtxs = {} - for psbt_in in tx.inputs: + for psbt_in in psbt.inputs: if psbt_in.non_witness_utxo: prev = psbt_in.non_witness_utxo @@ -618,20 +618,20 @@ def ignore_input() -> None: prevtxs[ser_uint256(psbt_in.non_witness_utxo.sha256)[::-1]] = t # Sign the transaction - assert tx.tx_version is not None + assert psbt.tx_version is not None signed_tx = btc.sign_tx( client=self.client, coin_name=self.coin_name, inputs=inputs, outputs=outputs, prev_txes=prevtxs, - version=tx.tx_version, - lock_time=tx.compute_lock_time(), + version=psbt.tx_version, + lock_time=psbt.compute_lock_time(), serialize=False, ) # Each input has one signature - for input_num, (psbt_in, sig) in py_enumerate(list(zip(tx.inputs, signed_tx[0]))): + for input_num, (psbt_in, sig) in py_enumerate(list(zip(psbt.inputs, signed_tx[0]))): if input_num in to_ignore: continue for pubkey in psbt_in.hd_keypaths.keys(): @@ -646,7 +646,7 @@ def ignore_input() -> None: p += 1 - return tx + return psbt @trezor_exception def sign_message(self, message: Union[str, bytes], keypath: str) -> str: From 830f8b55ab356b2d09b9ef9c26d186dfc6f253f5 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 30 Jul 2025 21:01:51 +0200 Subject: [PATCH 4/7] Optionally pass BIP388 policy to signtx --- hwilib/_cli.py | 13 ++++++++++++- hwilib/commands.py | 8 ++++++-- hwilib/devices/bitbox02.py | 7 ++++++- hwilib/devices/coldcard.py | 7 ++++++- hwilib/devices/digitalbitbox.py | 7 ++++++- hwilib/devices/jade.py | 8 +++++++- hwilib/devices/ledger.py | 27 ++++++++++++++++++++++++++- hwilib/devices/trezor.py | 7 ++++++- hwilib/hwwclient.py | 6 +++++- 9 files changed, 80 insertions(+), 10 deletions(-) diff --git a/hwilib/_cli.py b/hwilib/_cli.py index b7b439f17..915e6e739 100644 --- a/hwilib/_cli.py +++ b/hwilib/_cli.py @@ -94,7 +94,13 @@ def signmessage_handler(args: argparse.Namespace, client: HardwareWalletClient) return signmessage(client, message=args.message, path=args.path) def signtx_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, Union[bool, str]]: - return signtx(client, psbt=args.psbt) + policy = BIP388Policy( + name=args.policy_name, + descriptor_template=args.policy_desc, + keys_info=args.key, + hmac=args.hmac + ) + return signtx(client, psbt=args.psbt, bip388_policy=policy) def wipe_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: return wipe_device(client) @@ -167,6 +173,11 @@ def get_parser() -> HWIArgumentParser: signtx_parser = subparsers.add_parser('signtx', help='Sign a PSBT') signtx_parser.add_argument('psbt', help='The Partially Signed Bitcoin Transaction to sign') + signtx_policy_group = signtx_parser.add_argument_group("BIP388 policy") + signtx_policy_group.add_argument('--policy-name', help='Registered policy name') + signtx_policy_group.add_argument('--policy-desc', help='Registered policy descriptor template') + signtx_policy_group.add_argument('--key', help='Registered policy key information', action='append') + signtx_policy_group.add_argument('--hmac', help='Registered policy hmac, obtained via register command') signtx_parser.set_defaults(func=signtx_handler) getxpub_parser = subparsers.add_parser('getxpub', help='Get an extended public key') diff --git a/hwilib/commands.py b/hwilib/commands.py index 5416f72f0..7c566b5b4 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -184,7 +184,11 @@ def getmasterxpub(client: HardwareWalletClient, addrtype: AddressType = AddressT """ return {"xpub": client.get_master_xpub(addrtype, account).to_string()} -def signtx(client: HardwareWalletClient, psbt: str) -> Dict[str, Union[bool, str]]: +def signtx( + client: HardwareWalletClient, + psbt: str, + bip388_policy: Optional[BIP388Policy] +) -> Dict[str, Union[bool, str]]: """ Sign a Partially Signed Bitcoin Transaction (PSBT) with the client. @@ -196,7 +200,7 @@ def signtx(client: HardwareWalletClient, psbt: str) -> Dict[str, Union[bool, str # Deserialize the transaction tx = PSBT() tx.deserialize(psbt) - result = client.sign_tx(tx).serialize() + result = client.sign_tx(tx, bip388_policy).serialize() return {"psbt": result, "signed": result != psbt} def getxpub(client: HardwareWalletClient, path: str, expert: bool = False) -> Dict[str, Any]: diff --git a/hwilib/devices/bitbox02.py b/hwilib/devices/bitbox02.py index cc6b783b3..c908e5eb1 100644 --- a/hwilib/devices/bitbox02.py +++ b/hwilib/devices/bitbox02.py @@ -57,6 +57,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, ) @@ -563,7 +564,11 @@ def display_multisig_address( return address @bitbox02_exception - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the BitBox02. diff --git a/hwilib/devices/coldcard.py b/hwilib/devices/coldcard.py index 19afc1ef6..4db1950d9 100644 --- a/hwilib/devices/coldcard.py +++ b/hwilib/devices/coldcard.py @@ -51,6 +51,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, ) from functools import wraps @@ -116,7 +117,11 @@ def get_master_fingerprint(self) -> bytes: return struct.pack(' PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the Coldcard. diff --git a/hwilib/devices/digitalbitbox.py b/hwilib/devices/digitalbitbox.py index 426fd2364..582f0b875 100644 --- a/hwilib/devices/digitalbitbox.py +++ b/hwilib/devices/digitalbitbox.py @@ -29,6 +29,7 @@ from ..common import ( AddressType, + BIP388Policy, Chain, hash256, ) @@ -387,7 +388,11 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return xpub @digitalbitbox_exception - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: # Create a transaction with all scriptsigs blanked out blank_tx = psbt.get_unsigned_tx() diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index f9652b958..3cf7b5dcc 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -34,6 +34,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, sha256 ) @@ -370,10 +371,15 @@ def _split_at_last_hardened_element(path: Sequence[int]) -> Tuple[Sequence[int], # Sign tx PSBT - newer Jade firmware supports native PSBT signing, but old firmwares require # mapping to the legacy 'sign_tx' structures. @jade_exception - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the Blockstream Jade. """ + # Old firmware does not have native PSBT handling - use legacy method if self.PSBT_SUPPORTED_FW_VERSION > self.fw_version.finalize_version(): return self.legacy_sign_tx(psbt) diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index 3c439e1d8..fdcfe379f 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -186,7 +186,11 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return ExtendedKey.deserialize(xpub_str) @ledger_exception - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + bip388_policy: Optional[BIP388Policy] + ) -> PSBT: """ Sign a transaction with a Ledger device. Not all transactions can be signed by a Ledger. @@ -199,6 +203,8 @@ def sign_tx(self, psbt: PSBT) -> PSBT: For application versions 2.1.x and above: - Only keys derived with standard BIP 44, 49, 84, and 86 derivation paths are supported for single signature addresses. + + BIP388: for basic descriptors this is optional, but if provided name must be empty """ master_fp = self.get_master_fingerprint() @@ -265,6 +271,25 @@ def legacy_sign_tx() -> PSBT: else: continue + if bip388_policy is not None: + policy = WalletPolicy( + name=bip388_policy.name, + descriptor_template=bip388_policy.descriptor_template, + keys_info=bip388_policy.keys_info + ) + if policy.id not in wallets: + if bip388_policy.hmac is None: + raise BadArgumentError("Missing --hmac") + wallets[policy.id] = ( + signing_priority[script_addrtype], + script_addrtype, + policy, + bytes.fromhex(bip388_policy.hmac), + ) + continue + + # No BIP388 policy provided, construct on the fly + # Check if P2WSH if is_p2wsh(scriptcode): if len(psbt_in.witness_script) == 0: diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index 9143e88fa..b7510474b 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -77,6 +77,7 @@ ) from ..common import ( AddressType, + BIP388Policy, Chain, hash256, ) @@ -355,7 +356,11 @@ def get_pubkey_at_path(self, path: str) -> ExtendedKey: return xpub @trezor_exception - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + __: Optional[BIP388Policy], + ) -> PSBT: """ Sign a transaction with the Trezor. There are some limitations to what transactions can be signed. diff --git a/hwilib/hwwclient.py b/hwilib/hwwclient.py index 72b4e75ca..85abbb716 100644 --- a/hwilib/hwwclient.py +++ b/hwilib/hwwclient.py @@ -82,7 +82,11 @@ def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: raise NotImplementedError("The HardwareWalletClient base class " "does not implement this method") - def sign_tx(self, psbt: PSBT) -> PSBT: + def sign_tx( + self, + psbt: PSBT, + bip388_policy: Optional[BIP388Policy] + ) -> PSBT: """ Sign a partially signed bitcoin transaction (PSBT). From 69571d5f8819a1f94a458063419945ff8408a68d Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 30 Jul 2025 14:04:00 +0200 Subject: [PATCH 5/7] psbt: add MuSig2 fields Co-Authored-By: Salvatore Ingala <6681844+bigspider@users.noreply.github.com> --- hwilib/psbt.py | 92 ++++++++++++++++++++++++++++++++++++++++ test/data/test_psbt.json | 31 ++++++++++++-- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/hwilib/psbt.py b/hwilib/psbt.py index f5ab31b3c..a8c1ebd59 100644 --- a/hwilib/psbt.py +++ b/hwilib/psbt.py @@ -103,6 +103,9 @@ class PartiallySignedInput: PSBT_IN_TAP_BIP32_DERIVATION = 0x16 PSBT_IN_TAP_INTERNAL_KEY = 0x17 PSBT_IN_TAP_MERKLE_ROOT = 0x18 + PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS = 0x1a + PSBT_IN_MUSIG2_PUB_NONCE = 0x1b + PSBT_IN_MUSIG2_PARTIAL_SIG = 0x1c def __init__(self, version: int) -> None: self.non_witness_utxo: Optional[CTransaction] = None @@ -125,6 +128,9 @@ def __init__(self, version: int) -> None: self.tap_bip32_paths: Dict[bytes, Tuple[Set[bytes], KeyOriginInfo]] = {} self.tap_internal_key = b"" self.tap_merkle_root = b"" + self.musig2_participant_pubkeys: Dict[bytes, List[bytes]] = {} + self.musig2_pub_nonces: Dict[Tuple[bytes, bytes, Optional[bytes]], bytes] = {} + self.musig2_partial_sigs: Dict[Tuple[bytes, bytes, Optional[bytes]], bytes] = {} self.unknown: Dict[bytes, bytes] = {} self.version: int = version @@ -153,6 +159,9 @@ def set_null(self) -> None: self.sequence = None self.time_locktime = None self.height_locktime = None + self.musig2_participant_pubkeys.clear() + self.musig2_pub_nonces.clear() + self.musig2_partial_sigs.clear() self.unknown.clear() def deserialize(self, f: Readable) -> None: @@ -351,6 +360,51 @@ def deserialize(self, f: Readable) -> None: self.tap_merkle_root = deser_string(f) if len(self.tap_merkle_root) != 32: raise PSBTSerializationError("Input Taproot merkle root is not 32 bytes") + elif key_type == PartiallySignedInput.PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Musig2 participant pubkeys already provided") + elif len(key) != 1 + 33: + raise PSBTSerializationError("Input Musig2 aggregate compressed pubkey is not 33 bytes") + + pubkeys_cat = deser_string(f) + if len(pubkeys_cat) == 0: + raise PSBTSerializationError("The list of compressed pubkeys for Musig2 cannot be empty") + if (len(pubkeys_cat) % 33) != 0: + raise PSBTSerializationError("The compressed pubkeys for Musig2 must be exactly 33 bytes long") + pubkeys = [] + for i in range(0, len(pubkeys_cat), 33): + pubkeys.append(pubkeys_cat[i: i + 33]) + + self.musig2_participant_pubkeys[key[1:]] = pubkeys + elif key_type == PartiallySignedInput.PSBT_IN_MUSIG2_PUB_NONCE: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, Musig2 public nonce already provided") + elif len(key) not in [1 + 33 + 33, 1 + 33 + 33 + 32]: + raise PSBTSerializationError("Invalid key length for Musig2 public nonce") + + providing_pubkey = key[1:1 + 33] + aggregate_pubkey = key[1 + 33:1 + 33 + 33] + tapleaf_hash = None if len(key) == 1 + 33 + 33 else key[1 + 33 + 33:] + + public_nonces = deser_string(f) + if len(public_nonces) != 66: + raise PSBTSerializationError("The length of the public nonces in Musig2 must be exactly 66 bytes") + + self.musig2_pub_nonces[(providing_pubkey, aggregate_pubkey, tapleaf_hash)] = public_nonces + elif key_type == PartiallySignedInput.PSBT_IN_MUSIG2_PARTIAL_SIG: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, Musig2 partial signature already provided") + elif len(key) not in [1 + 33 + 33, 1 + 33 + 33 + 32]: + raise PSBTSerializationError("Invalid key length for Musig2 partial signature") + + providing_pubkey = key[1:1 + 33] + aggregate_pubkey = key[1 + 33:1 + 33 + 33] + tapleaf_hash = None if len(key) == 1 + 33 + 33 else key[1 + 33 + 33:] + + partial_sig = deser_string(f) + if len(partial_sig) != 32: + raise PSBTSerializationError("The length of the partial signature in Musig2 must be exactly 32 bytes") + self.musig2_partial_sigs[(providing_pubkey, aggregate_pubkey, tapleaf_hash)] = partial_sig else: if key in self.unknown: raise PSBTSerializationError("Duplicate key, key for unknown value already provided") @@ -441,6 +495,20 @@ def serialize(self) -> bytes: witstack = self.final_script_witness.serialize() r += ser_string(witstack) + for pk, pubkeys in self.musig2_participant_pubkeys.items(): + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS) + pk) + r += ser_string(b''.join(pubkeys)) + + for (pk, aggpk, hash), pubnonce in self.musig2_pub_nonces.items(): + key_value = pk + aggpk + (hash or b'') + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_MUSIG2_PUB_NONCE) + key_value) + r += ser_string(pubnonce) + + for (pk, aggpk, hash), partial_sig in self.musig2_partial_sigs.items(): + key_value = pk + aggpk + (hash or b'') + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_MUSIG2_PARTIAL_SIG) + key_value) + r += ser_string(partial_sig) + if self.version >= 2: if len(self.prev_txid) != 0: r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_PREVIOUS_TXID)) @@ -483,6 +551,7 @@ class PartiallySignedOutput: PSBT_OUT_TAP_INTERNAL_KEY = 0x05 PSBT_OUT_TAP_TREE = 0x06 PSBT_OUT_TAP_BIP32_DERIVATION = 0x07 + PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS = 0x08 def __init__(self, version: int) -> None: self.redeem_script = b"" @@ -493,6 +562,7 @@ def __init__(self, version: int) -> None: self.tap_internal_key = b"" self.tap_tree = b"" self.tap_bip32_paths: Dict[bytes, Tuple[Set[bytes], KeyOriginInfo]] = {} + self.musig2_participant_pubkeys: Dict[bytes, List[bytes]] = {} self.unknown: Dict[bytes, bytes] = {} self.version: int = version @@ -509,6 +579,7 @@ def set_null(self) -> None: self.tap_bip32_paths.clear() self.amount = None self.script = b"" + self.musig2_participant_pubkeys = {} self.unknown.clear() def deserialize(self, f: Readable) -> None: @@ -589,6 +660,22 @@ def deserialize(self, f: Readable) -> None: for i in range(0, num_hashes): leaf_hashes.add(vs.read(32)) self.tap_bip32_paths[xonly] = (leaf_hashes, KeyOriginInfo.deserialize(vs.read())) + elif key_type == PartiallySignedOutput.PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output Musig2 participant pubkeys already provided") + elif len(key) != 1 + 33: + raise PSBTSerializationError("Output Musig2 aggregate compressed pubkey is not 33 bytes") + + pubkeys_cat = deser_string(f) + if len(pubkeys_cat) == 0: + raise PSBTSerializationError("The list of compressed pubkeys for Musig2 cannot be empty") + if (len(pubkeys_cat) % 33) != 0: + raise PSBTSerializationError("The compressed pubkeys for Musig2 must be exactly 33 bytes long") + pubkeys = [] + for i in range(0, len(pubkeys_cat), 33): + pubkeys.append(pubkeys_cat[i: i + 33]) + + self.musig2_participant_pubkeys[key[1:]] = pubkeys else: if key in self.unknown: raise PSBTSerializationError("Duplicate key, key for unknown value already provided") @@ -646,6 +733,11 @@ def serialize(self) -> bytes: value += origin.serialize() r += ser_string(value) + for pk, pubkeys in self.musig2_participant_pubkeys.items(): + r += ser_string(ser_compact_size( + PartiallySignedOutput.PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS) + pk) + r += ser_string(b''.join(pubkeys)) + for key, value in sorted(self.unknown.items()): r += ser_string(key) r += ser_string(value) diff --git a/test/data/test_psbt.json b/test/data/test_psbt.json index 3651f1984..1a1886233 100644 --- a/test/data/test_psbt.json +++ b/test/data/test_psbt.json @@ -29,8 +29,19 @@ "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwlCiXVqo3OczGiewPzzo2C+MswLWbFuk6Hou0YFcmssp6P/cGxBdmSWMrLMaOH5ErileONxnOdxCIXHqWb0m81DywEBAAA=", "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwk5iXVqo3OczGiewPzzo2C+MswLWbFuk6Hou0YFcmssp6P/cGxBdmSWMrLMaOH5ErileONxnOdxCIXHqWb0m81DywAA", "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJjFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgAIyAssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20qzAAAA=", - "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJhFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4SMgLLE6xoJI3oBqpqNlnPPAPraCHQnIEUpOho/r3oZbttKswAAA" - ], + "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" + ] + , "valid" : [ "cHNidP8BAHUCAAAAASaBcTce3/KF6Tet7qSze3gADAVmy7OtZGQXE8pCFxv2AAAAAAD+////AtPf9QUAAAAAGXapFNDFmQPFusKGh2DpD9UhpGZap2UgiKwA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHh7MuEwAAAQD9pQEBAAAAAAECiaPHHqtNIOA3G7ukzGmPopXJRjr6Ljl/hTPMti+VZ+UBAAAAFxYAFL4Y0VKpsBIDna89p95PUzSe7LmF/////4b4qkOnHf8USIk6UwpyN+9rRgi7st0tAXHmOuxqSJC0AQAAABcWABT+Pp7xp0XpdNkCxDVZQ6vLNL1TU/////8CAMLrCwAAAAAZdqkUhc/xCX/Z4Ai7NK9wnGIZeziXikiIrHL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHAkcwRAIgJxK+IuAnDzlPVoMR3HyppolwuAJf3TskAinwf4pfOiQCIAGLONfc0xTnNMkna9b7QPZzMlvEuqFEyADS8vAtsnZcASED0uFWdJQbrUqZY3LLh+GFbTZSYG2YVi/jnF6efkE/IQUCSDBFAiEA0SuFLYXc2WHS9fSrZgZU327tzHlMDDPOXMMJ/7X85Y0CIGczio4OFyXBl/saiK9Z9R5E5CVbIBZ8hoQDHAXR8lkqASECI7cr7vCWXRC+B3jv7NYfysb3mk6haTkzgHNEZPhPKrMAAAAAAAAA", "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEHakcwRAIgR1lmF5fAGwNrJZKJSGhiGDR9iYZLcZ4ff89X0eURZYcCIFMJ6r9Wqk2Ikf/REf3xM286KdqGbX+EhtdVRs7tr5MZASEDXNxh/HupccC1AaZGoqg7ECy0OIEhfKaC3Ibi1z+ogpIAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIAAAA", @@ -45,7 +56,21 @@ "cHNidP8BAF4CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgABBSARJNp67JLM0GyVRWJkf0N7E4uVchqEvivyJ2u92rPmcSEHESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEZAHcrLadWAACAAQAAgAAAAIAAAAAABQAAAAA=", "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgjICyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSrMBCFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJfG5v6l/3FP9XJEmZkIEOQG6YqhD1v35fZ4S8HQqabOIyBDILC/FvARtT6nvmFZJKp/J+XSmtIOoRVdhIZ2w7rRsqzAYhXBUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsDNlw4V9T/AyC+VD9Vg/6kZt2FyvgFzaKiZE68HT0ALCRFfLkkK98xFxPeFEfNgV85cWlxWMlop+0TfwgPzVuH4IyD6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqazAIRYssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20jkBzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwl3Ky2nVgAAgAEAAIACAACAAAAAAAAAAAAhFkMgsL8W8BG1Pqe+YVkkqn8n5dKa0g6hFV2EhnbDutGyOQERXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+HcrLadWAACAAQAAgAEAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvoPejzvOx0MCmzn0m4XraCy5cktGe+tSLQYWcuKRRypOQFvfWIFnpSXoaSiZ1admHbaYBAa/zjjUpubk5zn+RrpcHcrLadWAACAAQAAgAMAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARgg8DYuL3Wm9CClvePrIh2WrmcgzyX4GJDJWx13WstRXmUAAQUgESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEhBxEk2nrskszQbJVFYmR/Q3sTi5VyGoS+K/Ina73as+ZxGQB3Ky2nVgAAgAEAAIAAAACAAAAAAAUAAAAA", "cHNidP8BAF4CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAIlEgCoy9yG3hzhwPnK6yLW33ztNoP+Qj4F0eQCqHk0HW9vUAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgABBSBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAEGbwLAIiBzblcpAP4SUliaIUPI88efcaBBLSNTr3VelwHHgmlKAqwCwCIgYxxfO1gyuPvev7GXBM7rMjwh9A96JPQ9aO8MwmsSWWmsAcAiIET6pJoDON5IjI3//s37bzKfOAvVZu8gyN9tgT6rHEJzrCEHRPqkmgM43kiMjf/+zftvMp84C9Vm7yDI322BPqscQnM5AfBreYuSoQ7ZqdC7/Trxc6U7FhfaOkFZygCCFs2Fay4Odystp1YAAIABAACAAQAAgAAAAAADAAAAIQdQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAUAfEYeXSEHYxxfO1gyuPvev7GXBM7rMjwh9A96JPQ9aO8MwmsSWWk5ARis5AmIl4Xg6nDO67jhyokqenjq7eDy4pbPQ1lhqPTKdystp1YAAIABAACAAgAAgAAAAAADAAAAIQdzblcpAP4SUliaIUPI88efcaBBLSNTr3VelwHHgmlKAjkBKaW0kVCQFi11mv0/4Pk/ozJgVtC0CIy5M8rngmy42Cx3Ky2nVgAAgAEAAIADAACAAAAAAAMAAAAA", - "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwlAv4GNl1fW/+tTi6BX+0wfxOD17xhudlvrVkeR4Cr1/T1eJVHU404z2G8na4LJnHmu0/A5Wgge/NLMLGXdfmk9eUEUQyCwvxbwEbU+p75hWSSqfyfl0prSDqEVXYSGdsO60bIRXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+EDh8atvq/omsjbyGDNxncHUKKt2jYD5H5mI2KvvR7+4Y7sfKlKfdowV8AzjTsKDzcB+iPhCi+KPbvZAQ8MpEYEaQRT6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqW99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwQOwfA3kgZGHIM0IoVCMyZwirAx8NpKJT7kWq+luMkgNNi2BUkPjNE+APmJmJuX4hX6o28S3uNpPS2szzeBwXV/ZiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgjICyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSrMBCFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJfG5v6l/3FP9XJEmZkIEOQG6YqhD1v35fZ4S8HQqabOIyBDILC/FvARtT6nvmFZJKp/J+XSmtIOoRVdhIZ2w7rRsqzAYhXBUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsDNlw4V9T/AyC+VD9Vg/6kZt2FyvgFzaKiZE68HT0ALCRFfLkkK98xFxPeFEfNgV85cWlxWMlop+0TfwgPzVuH4IyD6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqazAIRYssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20jkBzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwl3Ky2nVgAAgAEAAIACAACAAAAAAAAAAAAhFkMgsL8W8BG1Pqe+YVkkqn8n5dKa0g6hFV2EhnbDutGyOQERXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+HcrLadWAACAAQAAgAEAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvoPejzvOx0MCmzn0m4XraCy5cktGe+tSLQYWcuKRRypOQFvfWIFnpSXoaSiZ1admHbaYBAa/zjjUpubk5zn+RrpcHcrLadWAACAAQAAgAMAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARgg8DYuL3Wm9CClvePrIh2WrmcgzyX4GJDJWx13WstRXmUAAQUgESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEhBxEk2nrskszQbJVFYmR/Q3sTi5VyGoS+K/Ina73as+ZxGQB3Ky2nVgAAgAEAAIAAAACAAAAAAAUAAAAA" + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwlAv4GNl1fW/+tTi6BX+0wfxOD17xhudlvrVkeR4Cr1/T1eJVHU404z2G8na4LJnHmu0/A5Wgge/NLMLGXdfmk9eUEUQyCwvxbwEbU+p75hWSSqfyfl0prSDqEVXYSGdsO60bIRXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+EDh8atvq/omsjbyGDNxncHUKKt2jYD5H5mI2KvvR7+4Y7sfKlKfdowV8AzjTsKDzcB+iPhCi+KPbvZAQ8MpEYEaQRT6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqW99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwQOwfA3kgZGHIM0IoVCMyZwirAx8NpKJT7kWq+luMkgNNi2BUkPjNE+APmJmJuX4hX6o28S3uNpPS2szzeBwXV/ZiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgjICyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSrMBCFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJfG5v6l/3FP9XJEmZkIEOQG6YqhD1v35fZ4S8HQqabOIyBDILC/FvARtT6nvmFZJKp/J+XSmtIOoRVdhIZ2w7rRsqzAYhXBUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsDNlw4V9T/AyC+VD9Vg/6kZt2FyvgFzaKiZE68HT0ALCRFfLkkK98xFxPeFEfNgV85cWlxWMlop+0TfwgPzVuH4IyD6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqazAIRYssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20jkBzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwl3Ky2nVgAAgAEAAIACAACAAAAAAAAAAAAhFkMgsL8W8BG1Pqe+YVkkqn8n5dKa0g6hFV2EhnbDutGyOQERXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+HcrLadWAACAAQAAgAEAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvoPejzvOx0MCmzn0m4XraCy5cktGe+tSLQYWcuKRRypOQFvfWIFnpSXoaSiZ1admHbaYBAa/zjjUpubk5zn+RrpcHcrLadWAACAAQAAgAMAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARgg8DYuL3Wm9CClvePrIh2WrmcgzyX4GJDJWx13WstRXmUAAQUgESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEhBxEk2nrskszQbJVFYmR/Q3sTi5VyGoS+K/Ina73as+ZxGQB3Ky2nVgAAgAEAAIAAAACAAAAAAAUAAAAA", + "cHNidP8BAFICAAAAAVaG3/QAFl9OBApYVfZYCTRyybz4EIsnKl0x8YH3tP+xAQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QhFgtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUBQAmgN1uIRY0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAUAWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwFAMMkmoIhFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5BQB91lWSIhoDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RjAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AAA=", + "cHNidP8BAFICAAAAAVaG3/QAFl9OBApYVfZYCTRyybz4EIsnKl0x8YH3tP+xAQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QhFgtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUBQAmgN1uIRY0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAUAWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwFAMMkmoIhFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5BQB91lWSIhoDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RjAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5QxsCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQADC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RCAlKbGdeHnMwEyRVIf18TQbxoWLC8dOUDbGQ9N7U6Q3G1A7f4r+MmP8s+8kVP4W86Z1nFYAx45je436DYPoVSiCBWQxsCT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RCA6gdlzSZ50+C0rWrE/Hmnp4Rpf20D1Rm72cubkzroL55A0mVk3ZxUDlO7OOE0FHEW2FcUtvhyOs2ENezRLQ7fa1tQxsC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RCAzlJ2/8xudUCUdiCHmbMXJe346/X2Vv9x3UA/aiUfzrgAj8g5PC/C3bw1AzTq/5843PWaFu9d7X6KOf0n7Qp0YmwAAA=", + "cHNidP8BAFICAAAAAVaG3/QAFl9OBApYVfZYCTRyybz4EIsnKl0x8YH3tP+xAQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QhFgtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUBQAmgN1uIRY0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAUAWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwFAMMkmoIhFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5BQB91lWSIhoDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RjAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5QxsCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQADC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RCAlKbGdeHnMwEyRVIf18TQbxoWLC8dOUDbGQ9N7U6Q3G1A7f4r+MmP8s+8kVP4W86Z1nFYAx45je436DYPoVSiCBWQxsCT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RCA6gdlzSZ50+C0rWrE/Hmnp4Rpf20D1Rm72cubkzroL55A0mVk3ZxUDlO7OOE0FHEW2FcUtvhyOs2ENezRLQ7fa1tQxsC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RCAzlJ2/8xudUCUdiCHmbMXJe346/X2Vv9x3UA/aiUfzrgAj8g5PC/C3bw1AzTq/5843PWaFu9d7X6KOf0n7Qp0YmwQxwCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQADC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QgDlfKTKDeGjEW0/1rrxnThXLkfo/wJOfvw5USdR5U7TFDHAJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1CByVAecqxZrDVC1QoP8y0rqFfd2dHpdKlPX2gYjk0DbzEMcAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUIAEkXohh5irFv8AAhBj9BXzmsDwXsda1xpgEE8XE41iXAAA=", + "cHNidP8BAFICAAAAAVgYqc1kSzacMGx/sZHsAU/2JeY8KD8A+dF6lZ/vo+j2AAAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUIhFgtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUBQAmgN1uIRY0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAUAWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwFAMMkmoIhFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5BQB91lWSARcgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QiGgMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1GMCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQACT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkAAA==", + "cHNidP8BAFICAAAAAVgYqc1kSzacMGx/sZHsAU/2JeY8KD8A+dF6lZ/vo+j2AAAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUIhFgtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUBQAmgN1uIRY0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAUAWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwFAMMkmoIhFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5BQB91lWSARcgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QiGgMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1GMCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQACT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvlDGwI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAMpZ9LQIKl5XacrUb5PP8oluw5X6RxbPnqBq/pyMqNJQkIDzBdIXKAcLrsOuhyAs+ra9e6cwUYp+k8QcdGYIOLQf8wCrfytRaaM+2d1nJhJzdV5Y55NGGcnK5kaA2PRynYoPvxDGwJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAMpZ9LQIKl5XacrUb5PP8oluw5X6RxbPnqBq/pyMqNJQkICNDu4NZxDT19qnWtG91cyWPv7tWG7UhLL9YUKgqmgL34CRugyIWZEJ6ym0TwQgJostLVNrpp7L1q3ZCE91HmmqUJDGwL5MIoBkljDEEk0T4X4nVIptTHIRYNvmbCGAfETvOA2+QMpZ9LQIKl5XacrUb5PP8oluw5X6RxbPnqBq/pyMqNJQkIDne5CWLjf40RgCG/xcDIJ5HhDfGqw9Zii5ugJ/TCp/zoD4GtOBOxN5PdXyE1RrNr2yx70vMv9gQNwO8AahF3PM2UAAA==", + "cHNidP8BAFICAAAAAVgYqc1kSzacMGx/sZHsAU/2JeY8KD8A+dF6lZ/vo+j2AAAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUIBE0Auiae9+QhcZDjRXd8ahncqZSIiRCdukwL/3Z+pOxwgrlimsRpr6YsVHYWC2qhMEAF8mU2SNbE+xRipR4LGfEDiIRYLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1AUAJoDdbiEWNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQAFAFgLCIchFk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsBQDDJJqCIRb5MIoBkljDEEk0T4X4nVIptTHIRYNvmbCGAfETvOA2+QUAfdZVkgEXIAtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUIhoDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RjAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5QxsCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQADKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUJCA8wXSFygHC67DrocgLPq2vXunMFGKfpPEHHRmCDi0H/MAq38rUWmjPtndZyYSc3VeWOeTRhnJyuZGgNj0cp2KD78QxsCT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwDKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUJCAjQ7uDWcQ09fap1rRvdXMlj7+7Vhu1ISy/WFCoKpoC9+AkboMiFmRCesptE8EICaLLS1Ta6aey9at2QhPdR5pqlCQxsC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkDKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUJCA53uQli43+NEYAhv8XAyCeR4Q3xqsPWYouboCf0wqf86A+BrTgTsTeT3V8hNUaza9sse9LzL/YEDcDvAGoRdzzNlQxwCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQADKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUIgEJrTIVVyIBTdyiGuO408OpOm0KtcfhnQxpPVZcR8FiZDHAJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAMpZ9LQIKl5XacrUb5PP8oluw5X6RxbPnqBq/pyMqNJQiA11e7MQE+ipjZE8wz4r0P9vYKeXNnHRwfKmzOpE0x1bkMcAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5Ayln0tAgqXldpytRvk8/yiW7DlfpHFs+eoGr+nIyo0lCILnIVd79RGdv8/xTQq28kMLewVYk6rVbH/KXZTFWA9tuAAA=", + "cHNidP8BAFICAAAAAZqLSlB5a5YAmQ9/4R36ALxw79KWBIr8hnGa8PsfqRk3AQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgVr20gbTWcQP21d6oqar9NoSmp5tKLiR3mduNSxqG4fgiFcBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wCMgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9SswCEWC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QlAbEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosJoDdbiEWNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQAlAbEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwlAbEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPoswySagiEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5JQGxH+2qY6CVZQGnMIyTtWNzcedhPZuK3heD1J4mwGz6LH3WVZIBFyBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAEYILEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosIhoDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RjAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AAA=", + "cHNidP8BAFICAAAAAZqLSlB5a5YAmQ9/4R36ALxw79KWBIr8hnGa8PsfqRk3AQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgVr20gbTWcQP21d6oqar9NoSmp5tKLiR3mduNSxqG4fgiFcBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wCMgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9SswCEWC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QlAbEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosJoDdbiEWNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQAlAbEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwlAbEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPoswySagiEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5JQGxH+2qY6CVZQGnMIyTtWNzcedhPZuK3heD1J4mwGz6LH3WVZIBFyBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAEYILEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosIhoDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RjAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5YxsCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQADC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9SxH+2qY6CVZQGnMIyTtWNzcedhPZuK3heD1J4mwGz6LEIC2Z58hxmzrQhWawy5x9XtoxJ8noEZGFt9WE2Tmxc5FfUCQN8iqreDMs8PJTKdED3A0gYKA3QulEgCbnNrzz25jzxjGwJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1LEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosQgLnAqcHUiag2TE6d+bbELjnQtTP/clIoO3JuFbBO0EuVAM4agKY8wj7MJkVV3LkWyqo53D0Nb7f4gQdHMTT03U4xmMbAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+ixCArHZEtRddTzu0JVEF7qYJlbSrsU/hji9byl9rjt0PXGwAy9CRTfVmdKPFNWf4KEbgv6iqiJqKYD/2srV/asg+AaDAAA=", + "cHNidP8BAFICAAAAAZqLSlB5a5YAmQ9/4R36ALxw79KWBIr8hnGa8PsfqRk3AQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEgVr20gbTWcQP21d6oqar9NoSmp5tKLiR3mduNSxqG4fhBFAtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+ixAJmfVL2zAf+BtsxsaX37+gZA/nL7vQPpk2vygHSyx1WQDvHUEiY5VhyVX0W0sp5vFX+8QlzhBoz7AMtiEdYyf5iIVwFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAIyALWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1KzAIRYLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1CUBsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+iwmgN1uIRY0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNACUBsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+ixYCwiHIRZPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLCUBsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+izDJJqCIRZQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAUAfEYeXSEW+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvklAbEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosfdZVkgEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARggsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+iwiGgMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1GMCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQACT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvljGwI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1LEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosQgLZnnyHGbOtCFZrDLnH1e2jEnyegRkYW31YTZObFzkV9QJA3yKqt4Myzw8lMp0QPcDSBgoDdC6USAJuc2vPPbmPPGMbAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+ixCAucCpwdSJqDZMTp35tsQuOdC1M/9yUig7cm4VsE7QS5UAzhqApjzCPswmRVXcuRbKqjncPQ1vt/iBB0cxNPTdTjGYxsC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9SxH+2qY6CVZQGnMIyTtWNzcedhPZuK3heD1J4mwGz6LEICsdkS1F11PO7QlUQXupgmVtKuxT+GOL1vKX2uO3Q9cbADL0JFN9WZ0o8U1Z/goRuC/qKqImopgP/aytX9qyD4BoNjHAI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1LEf7apjoJVlAacwjJO1Y3Nx52E9m4reF4PUnibAbPosIK2ni3CvjP+kxQhjrvUVrFijJ81YxVutKaFi5n2cQTMiYxwCT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9SxH+2qY6CVZQGnMIyTtWNzcedhPZuK3heD1J4mwGz6LCAfqLfmdBwh642bD2UmnfnkIzafw5CtLpGEIMV/6yobYWMcAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUsR/tqmOglWUBpzCMk7Vjc3HnYT2bit4Xg9SeJsBs+iwgNmwxrWXlM/bStF5ZEPP1h1Q/gGGRgWe/WoGLE75XkqQAAA==", + "cHNidP8BAFICAAAAASWJ53Z5WLoVT5AYzM8N7ephR7tgzRoZS241kKmWVpDWAQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEg0LImxlmfJzh034/mhKtsMCgIG+6KLL7TGhNvWGX2z6QhFjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0ABQBYCwiHIRZPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAUAwySagiEWjdlquFiyWcUYIYwBSkbrTmrImeUcZ173dPu2ioeZzi8NACaA3W4BAAAAAgAAACEW+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkFAH3WVZIBFyCN2Wq4WLJZxRghjAFKRutOasiZ5RxnXvd0+7aKh5nOLyIaAwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUYwI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAL5MIoBkljDEEk0T4X4nVIptTHIRYNvmbCGAfETvOA2+QAA", + "cHNidP8BAFICAAAAASWJ53Z5WLoVT5AYzM8N7ephR7tgzRoZS241kKmWVpDWAQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEg0LImxlmfJzh034/mhKtsMCgIG+6KLL7TGhNvWGX2z6QhFjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0ABQBYCwiHIRZPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAUAwySagiEWjdlquFiyWcUYIYwBSkbrTmrImeUcZ173dPu2ioeZzi8NACaA3W4BAAAAAgAAACEW+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkFAH3WVZIBFyCN2Wq4WLJZxRghjAFKRutOasiZ5RxnXvd0+7aKh5nOLyIaAwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUYwI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAL5MIoBkljDEEk0T4X4nVIptTHIRYNvmbCGAfETvOA2+UMbAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAtCyJsZZnyc4dN+P5oSrbDAoCBvuiiy+0xoTb1hl9s+kQgJO78n90SvnR0ZIXGeLgmiUnMkjbp/OgiQTlVI68EJivwOMJ26DKq1L+56QSFFi9XTIsmGd9b0Z24/6LrAFlJO/G0MbAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAtCyJsZZnyc4dN+P5oSrbDAoCBvuiiy+0xoTb1hl9s+kQgOjJKP0Ihv6srb6B4anBI8zRc40TxRY4VG6GHtZqrSYywKjY4JZukzMRv552NeaTZ5wTsD3cBteZk1NhzODivSRlkMbAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AtCyJsZZnyc4dN+P5oSrbDAoCBvuiiy+0xoTb1hl9s+kQgLBLrTvh2AyHAcqUdj7ZcNO6LRSoUgYVX9h3wYShaWAkQOgjkGyYpQsblky/R+12ZI2iXmJ9ukS3CFHbSdxh0EUkwAA", + "cHNidP8BAFICAAAAASWJ53Z5WLoVT5AYzM8N7ephR7tgzRoZS241kKmWVpDWAQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAAEBKwDh9QUAAAAAIlEg0LImxlmfJzh034/mhKtsMCgIG+6KLL7TGhNvWGX2z6QBE0CeOYl6wv/idSXcRg+FhP3dEf6al84uUMFIm4waTpL8wH5I22OhpMy50pdTfQwDiDg3i78njeeqGhKJldFiXMXNIRY0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAUAWAsIhyEWT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwFAMMkmoIhFo3ZarhYslnFGCGMAUpG605qyJnlHGde93T7toqHmc4vDQAmgN1uAQAAAAIAAAAhFvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5BQB91lWSARcgjdlquFiyWcUYIYwBSkbrTmrImeUcZ173dPu2ioeZzi8iGgMLWOM3qk04UqjCk4fEJAjYz746YTpeOX4KnwGl+3EH1GMCNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQACT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvlDGwI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAALQsibGWZ8nOHTfj+aEq2wwKAgb7oosvtMaE29YZfbPpEICTu/J/dEr50dGSFxni4JolJzJI26fzoIkE5VSOvBCYr8DjCdugyqtS/uekEhRYvV0yLJhnfW9GduP+i6wBZSTvxtDGwJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLALQsibGWZ8nOHTfj+aEq2wwKAgb7oosvtMaE29YZfbPpEIDoySj9CIb+rK2+geGpwSPM0XONE8UWOFRuhh7Waq0mMsCo2OCWbpMzEb+edjXmk2ecE7A93AbXmZNTYczg4r0kZZDGwL5MIoBkljDEEk0T4X4nVIptTHIRYNvmbCGAfETvOA2+QLQsibGWZ8nOHTfj+aEq2wwKAgb7oosvtMaE29YZfbPpEICwS6074dgMhwHKlHY+2XDTui0UqFIGFV/Yd8GEoWlgJEDoI5BsmKULG5ZMv0ftdmSNol5ifbpEtwhR20ncYdBFJNDHAI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAALQsibGWZ8nOHTfj+aEq2wwKAgb7oosvtMaE29YZfbPpCBlfbKGvhToDs4N2EtNF8TcQUw+VryM7wgnsGG03SyPQ0McAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAtCyJsZZnyc4dN+P5oSrbDAoCBvuiiy+0xoTb1hl9s+kIOeFUvtM6bLQDh7Q4suY4IcZkSdHfo8aHGgaALnEznCYQxwC+TCKAZJYwxBJNE+F+J1SKbUxyEWDb5mwhgHxE7zgNvkC0LImxlmfJzh034/mhKtsMCgIG+6KLL7TGhNvWGX2z6Qgy8yVeGocZ01I2KxS4yLdfG6rrDv8S+ebdQ3ijfguL3MAAA==", + "cHNidP8BAH0CAAAAASWJ53Z5WLoVT5AYzM8N7ephR7tgzRoZS241kKmWVpDWAAAAAAD9////AoCWmAAAAAAAIlEgKWfS0CCpeV2nK1G+Tz/KJbsOV+kcWz56gav6cjKjSUIPwJJ8AAAAABYAFDScXTMCeMMAKmT1l9KwGqPcG9kDAAAAAAABAH0CAAAAAZqLSlB5a5YAmQ9/4R36ALxw79KWBIr8hnGa8PsfqRk3AAAAAAD9////AolcK30AAAAAFgAUz9mLoQJ+pO1L0q4bNIthVqAVA3UA4fUFAAAAACJRINCyJsZZnyc4dN+P5oSrbDAoCBvuiiy+0xoTb1hl9s+k4QAAAAEBH4lcK30AAAAAFgAUz9mLoQJ+pO1L0q4bNIthVqAVA3UiBgKmZlDwi/+k8InrIu3NvnYWZF/2zRgKNkhNS8gQVFlbexi//0SjVAAAgAEAAIAAAACAAQAAAIoCAAAAAQUgC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9QhBwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUBQAmgN1uIQc0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAUAWAsIhyEHT6/WX4FpGG/Cv9siM8d+Yw0QvigKJMcWXAmidhF3XCwFAMMkmoIhB/kwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5BQB91lWSIggDC1jjN6pNOFKowpOHxCQI2M++OmE6Xjl+Cp8BpftxB9RjAjRrmVkzVxB8nTRZ6d66jT6vROZjbIXH+FPrkLpS6M0AAk+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5ACICA75K5T03zAdfQLMNbaRynUCt7rmDUzrxYWGSvHbVsmEqGL//RKNUAACAAQAAgAAAAIABAAAAjQIAAAA=", + "cHNidP8BAH0CAAAAAfg1peyOQAj5bxdAfhPww0kSw50nYggA+uArr4a4p452AAAAAAD9////AoCWmAAAAAAAIlEg0LImxlmfJzh034/mhKtsMCgIG+6KLL7TGhNvWGX2z6SeQF0FAAAAABYAFJ+UrC20ZCC5XcDbHMj0vsC7kjTYAAAAAAABAFICAAAAAVaG3/QAFl9OBApYVfZYCTRyybz4EIsnKl0x8YH3tP+xAQAAAAD9////ARjd9QUAAAAAFgAUyRI+BujX8JZsXRzQ+TMALU63V80AAAAAAQEfGN31BQAAAAAWABTJEj4G6NfwlmxdHND5MwAtTrdXzSIGAyk8jY3Ee3EtfBOl0FNrfy4xkyZ+YPfmblY5OdvlB0ecGL//RKNUAACAAQAAgAAAAIAAAAAAlwEAAAABBSCN2Wq4WLJZxRghjAFKRutOasiZ5RxnXvd0+7aKh5nOLyEHNGuZWTNXEHydNFnp3rqNPq9E5mNshcf4U+uQulLozQAFAFgLCIchB0+v1l+BaRhvwr/bIjPHfmMNEL4oCiTHFlwJonYRd1wsBQDDJJqCIQeN2Wq4WLJZxRghjAFKRutOasiZ5RxnXvd0+7aKh5nOLw0AJoDdbgEAAAACAAAAIQf5MIoBkljDEEk0T4X4nVIptTHIRYNvmbCGAfETvOA2+QUAfdZVkiIIAwtY4zeqTThSqMKTh8QkCNjPvjphOl45fgqfAaX7cQfUYwI0a5lZM1cQfJ00Weneuo0+r0TmY2yFx/hT65C6UujNAAJPr9ZfgWkYb8K/2yIzx35jDRC+KAokxxZcCaJ2EXdcLAL5MIoBkljDEEk0T4X4nVIptTHIRYNvmbCGAfETvOA2+QAiAgLOad6WdiKxyC6woPgM6wVzoPdosC/bBHsTwXHnkXT8zhi//0SjVAAAgAEAAIAAAACAAQAAAI4CAAAA" ], "creator" : [ { From 5d52156588d612cbe7fecff9f9070004f22125f0 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Wed, 30 Jul 2025 20:59:59 +0200 Subject: [PATCH 6/7] ledger: have sign_psbt return SignPsbtYieldedObject Taken from LedgerHQ/app-bitcoin-new at 2.4.1 Conflicts: hwilib/devices/ledger.py Rename tx to psbt. --- hwilib/devices/ledger.py | 10 ++-- hwilib/devices/ledger_bitcoin/client.py | 49 ++++++++++++++----- hwilib/devices/ledger_bitcoin/client_base.py | 31 ++++++++++-- .../devices/ledger_bitcoin/client_legacy.py | 10 ++-- 4 files changed, 73 insertions(+), 27 deletions(-) diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index fdcfe379f..7e51ac98e 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -215,9 +215,9 @@ def legacy_sign_tx() -> PSBT: wallet = WalletPolicy("", "wpkh(@0/**)", [""]) legacy_input_sigs = client.sign_psbt(psbt, wallet, None) - for idx, pubkey, sig in legacy_input_sigs: + for idx, partial_sig in legacy_input_sigs: psbt_in = psbt.inputs[idx] - psbt_in.partial_sigs[pubkey] = sig + psbt_in.partial_sigs[partial_sig.pubkey] = partial_sig.signature return psbt if isinstance(self.client, LegacyClient): @@ -374,7 +374,7 @@ def process_origin(origin: KeyOriginInfo) -> None: input_sigs = self.client.sign_psbt(psbt2, wallet, wallet_hmac) - for idx, pubkey, sig in input_sigs: + for idx, yielded in input_sigs: psbt_in = psbt2.inputs[idx] utxo = None @@ -390,9 +390,9 @@ def process_origin(origin: KeyOriginInfo) -> None: if is_wit and wit_ver >= 1: # TODO: Deal with script path signatures # For now, assume key path signature - psbt_in.tap_key_sig = sig + psbt_in.tap_key_sig = yielded.signature else: - psbt_in.partial_sigs[pubkey] = sig + psbt_in.partial_sigs[yielded.pubkey] = yielded.signature # Extract the sigs from psbt2 and put them into tx for sig_in, psbt_in in zip(psbt2.inputs, psbt.inputs): diff --git a/hwilib/devices/ledger_bitcoin/client.py b/hwilib/devices/ledger_bitcoin/client.py index 64bbe3adc..406760b95 100644 --- a/hwilib/devices/ledger_bitcoin/client.py +++ b/hwilib/devices/ledger_bitcoin/client.py @@ -5,8 +5,9 @@ from .command_builder import BitcoinCommandBuilder, BitcoinInsType from ...common import Chain from .client_command import ClientCommandInterpreter -from .client_base import Client, TransportClient +from .client_base import Client, PartialSignature, SignPsbtYieldedObject, TransportClient from .client_legacy import LegacyClient +from .errors import UnknownDeviceError from .exception import DeviceException, NotSupportedError from .merkle import get_merkleized_map_commitment from .wallet import WalletPolicy, WalletType @@ -31,6 +32,37 @@ def parse_stream_to_map(f: BufferedReader) -> Mapping[bytes, bytes]: result[key] = value return result +def _make_partial_signature(pubkey_augm: bytes, signature: bytes) -> PartialSignature: + if len(pubkey_augm) == 64: + # tapscript spend: pubkey_augm is the concatenation of: + # - a 32-byte x-only pubkey + # - the 32-byte tapleaf_hash + return PartialSignature(signature=signature, pubkey=pubkey_augm[0:32], tapleaf_hash=pubkey_augm[32:]) + + else: + # either legacy, segwit or taproot keypath spend + # pubkey must be 32 (taproot x-only pubkey) or 33 bytes (compressed pubkey) + + if len(pubkey_augm) not in [32, 33]: + raise UnknownDeviceError(f"Invalid pubkey length returned: {len(pubkey_augm)}") + + return PartialSignature(signature=signature, pubkey=pubkey_augm) + +def _decode_signpsbt_yielded_value(res: bytes) -> Tuple[int, SignPsbtYieldedObject]: + res_buffer = BytesIO(res) + input_index_or_tag = read_varint(res_buffer) + + # values follow an encoding without an explicit tag, where the + # first element is the input index. All the signature types are implemented + # by the PartialSignature type (not to be confused with the musig Partial Signature). + input_index = input_index_or_tag + + pubkey_augm_len = read_uint(res_buffer, 8) + pubkey_augm = res_buffer.read(pubkey_augm_len) + + signature = res_buffer.read() + + return((input_index, _make_partial_signature(pubkey_augm, signature))) def read_uint(buf: BytesIO, bit_len: int, @@ -156,7 +188,7 @@ def get_wallet_address( return response.decode() - def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, bytes, bytes]]: + def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, SignPsbtYieldedObject]]: """Signs a PSBT using a registered wallet (or a standard wallet that does not need registration). Signature requires explicit approval from the user. @@ -240,17 +272,10 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte if any(len(x) <= 1 for x in results): raise RuntimeError("Invalid response") - results_list: List[Tuple[int, bytes, bytes]] = [] + results_list: List[Tuple[int, SignPsbtYieldedObject]] = [] for res in results: - res_buffer = BytesIO(res) - input_index = read_varint(res_buffer) - - pubkey_len = read_uint(res_buffer, 8) - pubkey = res_buffer.read(pubkey_len) - - signature = res_buffer.read() - - results_list.append((input_index, pubkey, signature)) + input_index, obj = _decode_signpsbt_yielded_value(res) + results_list.append((input_index, obj)) return results_list diff --git a/hwilib/devices/ledger_bitcoin/client_base.py b/hwilib/devices/ledger_bitcoin/client_base.py index 5b846963f..a0a57af62 100644 --- a/hwilib/devices/ledger_bitcoin/client_base.py +++ b/hwilib/devices/ledger_bitcoin/client_base.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from typing import Tuple, Optional, Union, List from io import BytesIO @@ -45,6 +47,25 @@ def apdu_exchange_nowait( def stop(self) -> None: self.transport.close() +@dataclass(frozen=True) +class PartialSignature: + """Represents a partial signature returned by sign_psbt. Such objects can be added to the PSBT. + + It always contains a pubkey and a signature. + The pubkey is a compressed 33-byte for legacy and segwit Scripts, or 32-byte x-only key for taproot. + The signature is in the format it would be pushed on the scriptSig or the witness stack, therefore of + variable length, and possibly concatenated with the SIGHASH flag byte if appropriate. + + The tapleaf_hash is also filled if signing for a tapscript. + + Note: not to be confused with 'partial signature' of protocols like MuSig2; + """ + pubkey: bytes + signature: bytes + tapleaf_hash: Optional[bytes] = None + + +SignPsbtYieldedObject = Union[PartialSignature] class Client: def __init__(self, transport_client: TransportClient, chain: Chain = Chain.MAIN) -> None: @@ -183,18 +204,19 @@ def get_wallet_address( raise NotImplementedError - def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, bytes, bytes]]: + def sign_psbt(self, psbt: Union[PSBT, bytes, str], wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, SignPsbtYieldedObject]]: """Signs a PSBT using a registered wallet (or a standard wallet that does not need registration). Signature requires explicit approval from the user. Parameters ---------- - psbt : PSBT + psbt : PSBT | bytes | str A PSBT of version 0 or 2, with all the necessary information to sign the inputs already filled in; what the required fields changes depending on the type of input. The non-witness UTXO must be present for both legacy and SegWit inputs, or the hardware wallet will reject signing (this will change for Taproot inputs). + The argument can be either a `PSBT` object, or `bytes`, or a base64-encoded `str`. wallet : WalletPolicy The registered wallet policy, or a standard wallet policy. @@ -204,11 +226,10 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte Returns ------- - List[Tuple[int, bytes, bytes]] + List[Tuple[int, PartialSignature]] A list of tuples returned by the hardware wallets, where each element is a tuple of: - an integer, the index of the input being signed; - - a `bytes` array of length 33 (compressed ecdsa pubkey) or 32 (x-only BIP-0340 pubkey), the corresponding pubkey for this signature; - - a `bytes` array with the signature. + - an instance of `PartialSignature`. """ raise NotImplementedError diff --git a/hwilib/devices/ledger_bitcoin/client_legacy.py b/hwilib/devices/ledger_bitcoin/client_legacy.py index b545fe593..8089ed5c0 100644 --- a/hwilib/devices/ledger_bitcoin/client_legacy.py +++ b/hwilib/devices/ledger_bitcoin/client_legacy.py @@ -10,7 +10,7 @@ import re import base64 -from .client import Client, TransportClient +from .client import Client, PartialSignature, SignPsbtYieldedObject, TransportClient from typing import List, Tuple, Optional, Union @@ -137,7 +137,7 @@ def get_wallet_address( return output['address'][12:-2] # HACK: A bug in getWalletPublicKey results in the address being returned as the string "bytearray(b'
')". This extracts the actual address to work around this. # NOTE: This is different from the new API, but we need it for multisig support. - def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, bytes, bytes]]: + def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, SignPsbtYieldedObject]]: if wallet_hmac is not None or wallet.n_keys != 1: raise NotImplementedError("Policy wallets are only supported from version 2.0.0. Please update your Ledger hardware wallet") @@ -259,7 +259,7 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte all_signature_attempts[i_num] = signature_attempts - result: List[int, bytes, bytes] = [] + result: List[int, SignPsbtYieldedObject] = [] # Sign any segwit inputs if has_segwit: @@ -276,7 +276,7 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte for signature_attempt in all_signature_attempts[i]: self.app.startUntrustedTransaction(False, 0, [segwit_inputs[i]], script_codes[i], c_tx.nVersion) - result.append((i, signature_attempt[1], self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01))) + result.append((i, PartialSignature(pubkey=signature_attempt[1], signature=self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01)))) elif has_legacy: first_input = True @@ -287,7 +287,7 @@ def sign_psbt(self, psbt: PSBT, wallet: WalletPolicy, wallet_hmac: Optional[byte self.app.startUntrustedTransaction(first_input, i, legacy_inputs, script_codes[i], c_tx.nVersion) self.app.finalizeInput(b"DUMMY", -1, -1, change_path, tx_bytes) - result.append((i, signature_attempt[1], self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01))) + result.append((i, PartialSignature(pubkey=signature_attempt[1], signature=self.app.untrustedHashSign(signature_attempt[0], "", c_tx.nLockTime, 0x01)))) first_input = False From ec2a844005eaa970cf61bf2395ff7428315e0fd1 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 5 Sep 2025 15:37:36 +0200 Subject: [PATCH 7/7] ledger: sign MuSig2 This adds support handling public nonces and partial signatures. --- hwilib/devices/ledger.py | 55 ++++++++++++------ hwilib/devices/ledger_bitcoin/client.py | 57 +++++++++++++++---- hwilib/devices/ledger_bitcoin/client_base.py | 37 +++++++++++- .../devices/ledger_bitcoin/client_command.py | 4 +- 4 files changed, 125 insertions(+), 28 deletions(-) diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index 7e51ac98e..dbae0d5fe 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -40,7 +40,7 @@ LegacyClient, TransportClient, ) -from .ledger_bitcoin.client_base import ApduException +from .ledger_bitcoin.client_base import ApduException, MusigPubNonce, MusigPartialSignature from .ledger_bitcoin.exception import NotSupportedError from .ledger_bitcoin.wallet import ( MultisigWallet, @@ -372,31 +372,54 @@ def process_origin(origin: KeyOriginInfo) -> None: if not is_wit: psbt_in.witness_utxo = None - input_sigs = self.client.sign_psbt(psbt2, wallet, wallet_hmac) + res = self.client.sign_psbt(psbt2, wallet, wallet_hmac) - for idx, yielded in input_sigs: + for idx, yielded in res: psbt_in = psbt2.inputs[idx] - utxo = None - if psbt_in.witness_utxo: - utxo = psbt_in.witness_utxo - if psbt_in.non_witness_utxo: - assert psbt_in.prev_out is not None - utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] - assert utxo is not None + if isinstance(yielded, MusigPubNonce): + psbt_key = ( + yielded.participant_pubkey, + yielded.aggregate_pubkey, + yielded.tapleaf_hash + ) + + assert len(yielded.aggregate_pubkey) == 33 - is_wit, wit_ver, _ = utxo.is_witness() + psbt_in.musig2_pub_nonces[psbt_key] = yielded.pubnonce + elif isinstance(yielded, MusigPartialSignature): + psbt_key = ( + yielded.participant_pubkey, + yielded.aggregate_pubkey, + yielded.tapleaf_hash + ) - if is_wit and wit_ver >= 1: - # TODO: Deal with script path signatures - # For now, assume key path signature - psbt_in.tap_key_sig = yielded.signature + psbt_in.musig2_partial_sigs[psbt_key] = yielded.partial_signature else: - psbt_in.partial_sigs[yielded.pubkey] = yielded.signature + utxo = None + if psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo + if psbt_in.non_witness_utxo: + assert psbt_in.prev_out is not None + utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] + assert utxo is not None + + is_wit, wit_ver, _ = utxo.is_witness() + + if is_wit and wit_ver >= 1: + if yielded.tapleaf_hash is None: + psbt_in.tap_key_sig = yielded.signature + else: + psbt_in.tap_script_sigs[(yielded.pubkey, yielded.tapleaf_hash)] = yielded.signature + + else: + psbt_in.partial_sigs[yielded.pubkey] = yielded.signature # Extract the sigs from psbt2 and put them into tx for sig_in, psbt_in in zip(psbt2.inputs, psbt.inputs): psbt_in.partial_sigs.update(sig_in.partial_sigs) + psbt_in.musig2_pub_nonces.update(sig_in.musig2_pub_nonces) + psbt_in.musig2_partial_sigs.update(sig_in.musig2_partial_sigs) psbt_in.tap_script_sigs.update(sig_in.tap_script_sigs) if len(sig_in.tap_key_sig) != 0 and len(psbt_in.tap_key_sig) == 0: psbt_in.tap_key_sig = sig_in.tap_key_sig diff --git a/hwilib/devices/ledger_bitcoin/client.py b/hwilib/devices/ledger_bitcoin/client.py index 406760b95..219b78f76 100644 --- a/hwilib/devices/ledger_bitcoin/client.py +++ b/hwilib/devices/ledger_bitcoin/client.py @@ -4,8 +4,8 @@ from .command_builder import BitcoinCommandBuilder, BitcoinInsType from ...common import Chain -from .client_command import ClientCommandInterpreter -from .client_base import Client, PartialSignature, SignPsbtYieldedObject, TransportClient +from .client_command import ClientCommandInterpreter, CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG, CCMD_YIELD_MUSIG_PUBNONCE_TAG +from .client_base import Client, MusigPartialSignature, MusigPubNonce, PartialSignature, SignPsbtYieldedObject, TransportClient from .client_legacy import LegacyClient from .errors import UnknownDeviceError from .exception import DeviceException, NotSupportedError @@ -51,18 +51,55 @@ def _make_partial_signature(pubkey_augm: bytes, signature: bytes) -> PartialSign def _decode_signpsbt_yielded_value(res: bytes) -> Tuple[int, SignPsbtYieldedObject]: res_buffer = BytesIO(res) input_index_or_tag = read_varint(res_buffer) + if input_index_or_tag == CCMD_YIELD_MUSIG_PUBNONCE_TAG: + input_index = read_varint(res_buffer) + pubnonce = res_buffer.read(66) + participant_pk = res_buffer.read(33) + aggregate_pubkey = res_buffer.read(33) + tapleaf_hash = res_buffer.read() + if len(tapleaf_hash) == 0: + tapleaf_hash = None + + return ( + input_index, + MusigPubNonce( + participant_pubkey=participant_pk, + aggregate_pubkey=aggregate_pubkey, + tapleaf_hash=tapleaf_hash, + pubnonce=pubnonce + ) + ) + elif input_index_or_tag == CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG: + input_index = read_varint(res_buffer) + partial_signature = res_buffer.read(32) + participant_pk = res_buffer.read(33) + aggregate_pubkey = res_buffer.read(33) + tapleaf_hash = res_buffer.read() + if len(tapleaf_hash) == 0: + tapleaf_hash = None + + return ( + input_index, + MusigPartialSignature( + participant_pubkey=participant_pk, + aggregate_pubkey=aggregate_pubkey, + tapleaf_hash=tapleaf_hash, + partial_signature=partial_signature + ) + ) + else: + # other values follow an encoding without an explicit tag, where the + # first element is the input index. All the signature types are implemented + # by the PartialSignature type (not to be confused with the musig Partial Signature). + input_index = input_index_or_tag - # values follow an encoding without an explicit tag, where the - # first element is the input index. All the signature types are implemented - # by the PartialSignature type (not to be confused with the musig Partial Signature). - input_index = input_index_or_tag + pubkey_augm_len = read_uint(res_buffer, 8) + pubkey_augm = res_buffer.read(pubkey_augm_len) - pubkey_augm_len = read_uint(res_buffer, 8) - pubkey_augm = res_buffer.read(pubkey_augm_len) + signature = res_buffer.read() - signature = res_buffer.read() + return((input_index, _make_partial_signature(pubkey_augm, signature))) - return((input_index, _make_partial_signature(pubkey_augm, signature))) def read_uint(buf: BytesIO, bit_len: int, diff --git a/hwilib/devices/ledger_bitcoin/client_base.py b/hwilib/devices/ledger_bitcoin/client_base.py index a0a57af62..6020043eb 100644 --- a/hwilib/devices/ledger_bitcoin/client_base.py +++ b/hwilib/devices/ledger_bitcoin/client_base.py @@ -65,7 +65,42 @@ class PartialSignature: tapleaf_hash: Optional[bytes] = None -SignPsbtYieldedObject = Union[PartialSignature] +@dataclass(frozen=True) +class MusigPubNonce: + """Represents a pubnonce returned by sign_psbt during the first round of a Musig2 signing session. + + It always contains + - the participant_pubkey, a 33-byte compressed pubkey; + - aggregate_pubkey, the 33-byte compressed pubkey key that is the aggregate of all the participant + pubkeys, with the necessary tweaks; its x-only version is the key present in the Script; + - the 66-byte pubnonce. + + The tapleaf_hash is also filled if signing for a tapscript; `None` otherwise. + """ + participant_pubkey: bytes + aggregate_pubkey: bytes + tapleaf_hash: Optional[bytes] + pubnonce: bytes + + +@dataclass(frozen=True) +class MusigPartialSignature: + """Represents a partial signature returned by sign_psbt during the second round of a Musig2 signing session. + + It always contains + - the participant_pubkey, a 33-byte compressed pubkey; + - aggregate_pubkey, the 33-byte compressed pubkey key that is the aggregate of all the participant + pubkeys, with the necessary tweaks; its x-only version is the key present in the Script; + - the partial_signature, the 32-byte partial signature for this participant. + + The tapleaf_hash is also filled if signing for a tapscript; `None` otherwise + """ + participant_pubkey: bytes + aggregate_pubkey: bytes + tapleaf_hash: Optional[bytes] + partial_signature: bytes + +SignPsbtYieldedObject = Union[PartialSignature, MusigPubNonce, MusigPartialSignature] class Client: def __init__(self, transport_client: TransportClient, chain: Chain = Chain.MAIN) -> None: diff --git a/hwilib/devices/ledger_bitcoin/client_command.py b/hwilib/devices/ledger_bitcoin/client_command.py index 9fd57c464..7a01167e4 100644 --- a/hwilib/devices/ledger_bitcoin/client_command.py +++ b/hwilib/devices/ledger_bitcoin/client_command.py @@ -46,6 +46,8 @@ class ClientCommandCode(IntEnum): GET_MERKLE_LEAF_INDEX = 0x42 GET_MORE_ELEMENTS = 0xA0 +CCMD_YIELD_MUSIG_PUBNONCE_TAG = 0xFFFFFFFF +CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG = 0xFFFFFFFE class ClientCommand: def execute(self, request: bytes) -> bytes: @@ -350,7 +352,7 @@ def add_known_mapping(self, mapping: Mapping[bytes, bytes]) -> None: of a mapping of bytes to bytes. Adds the Merkle tree of the list of keys, and the Merkle tree of the list of corresponding - values, with the same semantics as the `add_known_list` applied separately to the two lists. + values, with the same semantics as the `add_known_list` applied separately to the two lists. Parameters ----------