Add support for generating PSBTs for MuSig2 in txmaker
What changed, and why it matters
This commit adds test-only helper code for creating Bitcoin transaction fixtures that support newer wallet types (MuSig2 multi-signature and several legacy/wrapped SegWit descriptor forms). It is not a change to the Ledger app firmware itself, but to Python utilities used in automated tests. There is no obvious security bug introduced, and the commit does not claim to fix a vulnerability.
No security action required. Treat as normal feature/test-infrastructure commit. If reviewing for release, verify that the new MuSig2 PSBT generation aligns with BIP-373 and that the synthetic xpub chaincode matches the production client implementation.
Security signals we found
No security-relevant signal: change is in test utilities only
Adds cryptographic helper for MuSig2 key aggregation in test fixtures
Adds P2SH/P2SH-P2WPKH/P2SH-P2WSH script generation in test fixtures
No input validation, memory safety, or privilege changes in firmware
Evidence from the diff
The diff extends test_utils/txmaker.py and test_utils/wallet_policy.py to: (1) parse and generate PSBT fields for BIP-327/BIP-328/BIP-373 MuSig2 aggregated key placeholders, (2) add descriptor template classes for sh(wpkh(…)), sh(wsh(…)), and sh(…), and (3) fill corresponding redeem/witness scripts in PSBT inputs/outputs. It also resolves old TODOs about legacy/segwit descriptor classification. The code is confined to test infrastructure and uses pinned BIP-328 chaincode and BIP-327 KeyAgg matching the reference implementation in the Ledger client library.
Changed components
test_utils/txmaker.pytest_utils/wallet_policy.pyInspect captured patch +211 / −11
diff --git a/test_utils/txmaker.py b/test_utils/txmaker.py
index b5d30f8..46bfc89 100644
--- a/test_utils/txmaker.py
+++ b/test_utils/txmaker.py
@@ -31,7 +31,35 @@ from embit.script import Script
from bitcoin_client.ledger_bitcoin.embit.descriptor.miniscript import Miniscript
from test_utils import bip0340, sha256, hash160
-from test_utils.wallet_policy import DescriptorTemplate, KeyPlaceholder, PlainKeyPlaceholder, TrDescriptorTemplate, WshDescriptorTemplate, WpkhDescriptorTemplate, PkhDescriptorTemplate, derive_plain_descriptor, tapleaf_hash
+from test_utils.bip0327 import cbytes, key_agg
+from test_utils.wallet_policy import DescriptorTemplate, KeyPlaceholder, MuSig2KeyPlaceholder, PlainKeyPlaceholder, ShDescriptorTemplate, ShWpkhDescriptorTemplate, ShWshDescriptorTemplate, TrDescriptorTemplate, WshDescriptorTemplate, WpkhDescriptorTemplate, PkhDescriptorTemplate, derive_plain_descriptor, tapleaf_hash
+
+
+# BIP-328 chaincode used by BIP-388 to derive a synthetic xpub from an
+# aggregated musig2 public key. Pinned constant; see the reference
+# implementation in bitcoin_client.ledger_bitcoin.client.aggr_xpub.
+_BIP328_CHAINCODE = bytes.fromhex(
+ "868087ca02a6f974c4598924c36b57762d32cb45717167e300622c7167e38965"
+)
+
+
+def _musig_root_aggregate(placeholder: MuSig2KeyPlaceholder, keys_info: List[str]) -> Tuple[bytes, List[bytes]]:
+ """Returns `(aggregate_pubkey, sorted_root_participants)` for a musig
+ placeholder. The aggregate is the 33-byte compressed result of BIP-327
+ KeyAgg over the sorted participant root pubkeys — i.e. the input to the
+ BIP-328 synthetic-xpub derivation, before any BIP-32 or taproot tweaks.
+ This is the form expected by BIP-373's MUSIG2_PARTICIPANT_PUBKEYS field.
+ """
+ participant_pubkeys: List[bytes] = []
+ for idx in placeholder.key_indexes:
+ key_info = keys_info[idx]
+ origin_end = key_info.find("]")
+ xpub_str = key_info if origin_end == -1 else key_info[origin_end + 1:]
+ participant_pubkeys.append(ExtendedKey.deserialize(xpub_str).pubkey)
+
+ sorted_participants = sorted(participant_pubkeys)
+ aggregate = cbytes(key_agg(sorted_participants).Q)
+ return aggregate, sorted_participants
SPECULOS_SEED = "glory promote mansion idle axis finger extra february uncover one trip resource lawn turtle enact monster seven myth punch hobby comfort wild raise skin"
@@ -132,6 +160,13 @@ def getScriptPubkeyFromWallet(wallet: WalletPolicy, change: bool, address_index:
pubkey = _derive_key(desc_tmpl.key)
return Script(b'\x00\x14' + hash160(pubkey))
+ elif isinstance(desc_tmpl, ShWpkhDescriptorTemplate):
+ # BIP-49 wrapped segwit: scriptPubKey = OP_HASH160 <hash160(redeemScript)> OP_EQUAL
+ # where redeemScript = OP_0 <hash160(pubkey)>
+ pubkey = _derive_key(desc_tmpl.key)
+ redeem_script = b'\x00\x14' + hash160(pubkey)
+ return Script(b'\xa9\x14' + hash160(redeem_script) + b'\x87')
+
elif isinstance(desc_tmpl, PkhDescriptorTemplate):
pubkey = _derive_key(desc_tmpl.key)
return Script(b'\x76\xa9\x14' + hash160(pubkey) + b'\x88\xac')
@@ -207,10 +242,30 @@ def get_placeholder_root_key(placeholder: KeyPlaceholder, keys_info: List[str])
root_key_origin = KeyOriginInfo.from_string(
key_info[1:key_origin_end_pos])
root_pubkey = ExtendedKey.deserialize(xpub)
+ return root_pubkey, root_key_origin
+ elif isinstance(placeholder, MuSig2KeyPlaceholder):
+ aggregated_pubkey, _ = _musig_root_aggregate(placeholder, keys_info)
+
+ # get the version from the first participant's key info
+ first_key_info = keys_info[placeholder.key_indexes[0]]
+ origin_end = first_key_info.find("]")
+ first_xpub_str = first_key_info if origin_end == -1 else first_key_info[origin_end + 1:]
+ version = ExtendedKey.deserialize(first_xpub_str).version
+
+ synthetic = ExtendedKey(
+ version,
+ 0,
+ b"\x00\x00\x00\x00",
+ 0,
+ _BIP328_CHAINCODE,
+ None,
+ aggregated_pubkey,
+ )
+ # Aggregated keys have no single origin; return None and let
+ # callers compute a fingerprint from the aggregate pubkey if needed.
+ return synthetic, None
else:
- raise ValueError("Unsupported placeholder type")
-
- return root_pubkey, root_key_origin
+ raise ValueError(f"Unsupported placeholder type: {type(placeholder).__name__}")
def fill_inout(wallet_policy: WalletPolicy, inout: Union[PartiallySignedInput, PartiallySignedOutput], is_change: bool, address_index: int):
@@ -234,6 +289,13 @@ def fill_inout(wallet_policy: WalletPolicy, inout: Union[PartiallySignedInput, P
wallet_policy.keys_info, is_change, address_index)
for placeholder, tapleaf_desc in desc_tmpl.placeholders():
+ if isinstance(placeholder, MuSig2KeyPlaceholder):
+ # BIP-373: emit PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS mapping the
+ # aggregate key to the sorted root participants.
+ aggregate, sorted_participants = _musig_root_aggregate(
+ placeholder, wallet_policy.keys_info)
+ inout.musig2_participant_pubkeys[aggregate] = sorted_participants
+
root_pubkey, root_pubkey_origin = get_placeholder_root_key(
placeholder, wallet_policy.keys_info)
@@ -284,6 +346,34 @@ def fill_inout(wallet_policy: WalletPolicy, inout: Union[PartiallySignedInput, P
s = BytesIO(desc_str.encode())
desc: Descriptor = Descriptor.read_from(s)
inout.witness_script = desc.witness_script().data
+ elif isinstance(desc_tmpl, ShWpkhDescriptorTemplate):
+ # BIP-49 wrapped segwit: redeemScript = OP_0 <hash160(pubkey)>
+ root_pubkey, _ = get_placeholder_root_key(
+ desc_tmpl.key, wallet_policy.keys_info)
+ der_subpath = [
+ desc_tmpl.key.num1 if not is_change else desc_tmpl.key.num2,
+ address_index,
+ ]
+ derived_pubkey = root_pubkey.derive_pub_path(der_subpath).pubkey
+ inout.redeem_script = b"\x00\x14" + hash160(derived_pubkey)
+ elif isinstance(desc_tmpl, ShWshDescriptorTemplate):
+ # sh(wsh(SCRIPT)): redeemScript = OP_0 <sha256(witnessScript)>, witnessScript = compiled inner
+ inner_desc_str = derive_plain_descriptor(
+ desc_tmpl.inner_script, wallet_policy.keys_info, is_change, address_index)
+ # technically incorrect to use miniscript here, as this might both work for non-standard or unsafe scripts,
+ # and not work for some descriptors that are not miniscript, like things with sortedmulti.
+ # Good enough for the purposes of this tool.
+ witness_script = Miniscript.read_from(
+ BytesIO(inner_desc_str.encode()), taproot=False).compile()
+ inout.witness_script = witness_script
+ inout.redeem_script = b'\x00\x20' + sha256(witness_script)
+ elif isinstance(desc_tmpl, ShDescriptorTemplate):
+ # sh(SCRIPT): redeemScript = compiled inner script
+ inner_desc_str = derive_plain_descriptor(
+ desc_tmpl.inner_script, wallet_policy.keys_info, is_change, address_index)
+ # As above, technically incorrect to use miniscript here.
+ inout.redeem_script = Miniscript.read_from(
+ BytesIO(inner_desc_str.encode()), taproot=False).compile()
for placeholder, _ in desc_tmpl.placeholders():
root_pubkey, root_pubkey_origin = get_placeholder_root_key(
diff --git a/test_utils/wallet_policy.py b/test_utils/wallet_policy.py
index 3830f99..8285414 100644
--- a/test_utils/wallet_policy.py
+++ b/test_utils/wallet_policy.py
@@ -36,13 +36,31 @@ class PlainKeyPlaceholder:
num2: int
-# future extensions will have multiple subtypes (e.g.: MuSig2KeyPlaceholder)
-KeyPlaceholder = PlainKeyPlaceholder
+@dataclass
+class MuSig2KeyPlaceholder:
+ """A `musig(@i,@j,...)/<num1;num2>/*` placeholder. The key indexes refer
+ to entries in the wallet's keys_info; the participant pubkeys are
+ aggregated via BIP-327 to form a single synthetic key, after which the
+ `/<num1;num2>/*` derivation is applied."""
+ key_indexes: List[int]
+ num1: int
+ num2: int
+
+
+KeyPlaceholder = Union[PlainKeyPlaceholder, MuSig2KeyPlaceholder]
def parse_placeholder(placeholder_str: str) -> KeyPlaceholder:
"""Parses a placeholder string to create a KeyPlaceholder object."""
- if placeholder_str.startswith('@'):
+ if placeholder_str.startswith('musig('):
+ close = placeholder_str.index(')')
+ keys_part = placeholder_str[len('musig('):close]
+ key_indexes = [int(k.strip().lstrip('@')) for k in keys_part.split(',')]
+ m = re.match(r'/<(\d+);(\d+)>/\*', placeholder_str[close + 1:])
+ if m is None:
+ raise ValueError(f"Invalid musig placeholder string: {placeholder_str!r}")
+ return MuSig2KeyPlaceholder(key_indexes, int(m.group(1)), int(m.group(2)))
+ elif placeholder_str.startswith('@'):
parts = placeholder_str.split('/')
key_index = int(parts[0].strip('@'))
@@ -187,6 +205,15 @@ class GenericParser(ABC):
num2 = self.parse_num()
self.consume('>/*')
return PlainKeyPlaceholder(key_index, num1, num2)
+ elif self.input.startswith('musig(', self.index):
+ self.consume('musig(')
+ key_indexes = self.parse_key_indexes()
+ self.consume(')/<')
+ num1 = self.parse_num()
+ self.consume(';')
+ num2 = self.parse_num()
+ self.consume('>/*')
+ return MuSig2KeyPlaceholder(key_indexes, num1, num2)
else:
raise Exception("Syntax error in key placeholder")
@@ -266,6 +293,12 @@ class DescriptorTemplate(ABC):
return WshDescriptorTemplate
elif input_string.startswith('wpkh('):
return WpkhDescriptorTemplate
+ elif input_string.startswith('sh(wpkh('):
+ return ShWpkhDescriptorTemplate
+ elif input_string.startswith('sh(wsh('):
+ return ShWshDescriptorTemplate
+ elif input_string.startswith('sh('):
+ return ShDescriptorTemplate
elif input_string.startswith('pkh('):
return PkhDescriptorTemplate
else:
@@ -277,12 +310,12 @@ class DescriptorTemplate(ABC):
return descriptor_type.from_string(input_string)
def is_legacy(self) -> bool:
- # TODO: incomplete, missing legacy sh(...) descriptors
- return isinstance(self, PkhDescriptorTemplate)
+ return isinstance(self, (PkhDescriptorTemplate, ShDescriptorTemplate))
def is_segwit(self) -> bool:
- # TODO: incomplete, missing sh(wsh(...)) and sh(wpkh(...)) descriptors
- return isinstance(self, (WshDescriptorTemplate, WpkhDescriptorTemplate, TrDescriptorTemplate))
+ return isinstance(self, (WshDescriptorTemplate, WpkhDescriptorTemplate,
+ ShWpkhDescriptorTemplate, ShWshDescriptorTemplate,
+ TrDescriptorTemplate))
def is_taproot(self) -> bool:
return isinstance(self, TrDescriptorTemplate)
@@ -417,3 +450,80 @@ class PkhDescriptorTemplate(DescriptorTemplate):
def placeholders(self) -> Iterator[Tuple[KeyPlaceholder, Optional[str]]]:
yield (self.key, None)
+
+
+class ShWpkhDescriptorTemplate(DescriptorTemplate):
+ """
+ Represents a sh(wpkh(KEY)) descriptor template — BIP-49 wrapped segwit.
+ """
+
+ def __init__(self, key: KeyPlaceholder):
+ self.key = key
+
+ @classmethod
+ def from_string(cls, input_string):
+ parser = cls.Parser(input_string.replace("/**", "/<0;1>/*"))
+ return parser.parse()
+
+ class Parser(GenericParser):
+ def parse(self) -> 'ShWpkhDescriptorTemplate':
+ self.consume('sh(wpkh(')
+ key = self.parse_keyplaceholder()
+ self.consume('))')
+ return ShWpkhDescriptorTemplate(key)
+
+ def placeholders(self) -> Iterator[Tuple[KeyPlaceholder, Optional[str]]]:
+ yield (self.key, None)
+
+
+class ShWshDescriptorTemplate(DescriptorTemplate):
+ """
+ Represents a sh(wsh(SCRIPT)) descriptor template.
+ """
+
+ def __init__(self, inner_script: str):
+ self.inner_script = inner_script
+
+ @classmethod
+ def from_string(cls, input_string):
+ parser = cls.Parser(input_string.replace("/**", "/<0;1>/*"))
+ return parser.parse()
+
+ class Parser(GenericParser):
+ def parse(self) -> 'ShWshDescriptorTemplate':
+ self.consume('sh(wsh(')
+ inner_script = self.parse_script()
+ self.consume('))')
+ return ShWshDescriptorTemplate(inner_script)
+
+ def placeholders(self) -> Iterator[Tuple[KeyPlaceholder, Optional[str]]]:
+ for placeholder in extract_placeholders(self.inner_script):
+ yield (placeholder, None)
+
+
+class ShDescriptorTemplate(DescriptorTemplate):
+ """
+ Represents a legacy sh(SCRIPT) descriptor template — bare P2SH, where
+ SCRIPT is neither wpkh(...) nor wsh(...) (those have their own
+ classes). Used for `sh(multi(...))`, `sh(sortedmulti(...))`,
+ `sh(pkh(...))`, etc.
+ """
+
+ def __init__(self, inner_script: str):
+ self.inner_script = inner_script
+
+ @classmethod
+ def from_string(cls, input_string):
+ parser = cls.Parser(input_string.replace("/**", "/<0;1>/*"))
+ return parser.parse()
+
+ class Parser(GenericParser):
+ def parse(self) -> 'ShDescriptorTemplate':
+ self.consume('sh(')
+ inner_script = self.parse_script()
+ self.consume(')')
+ return ShDescriptorTemplate(inner_script)
+
+ def placeholders(self) -> Iterator[Tuple[KeyPlaceholder, Optional[str]]]:
+ for placeholder in extract_placeholders(self.inner_script):
+ yield (placeholder, None)
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.