diff --git a/docs/limitations.md b/docs/limitations.md index 7a5bb54a..345aac0a 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -77,6 +77,7 @@ - derivation path for each cosigner must be known and consistent with PSBT - XFP values (fingerprints) MUST be unique for each of the co-signers - multisig wallet `name` can only contain printable ASCII characters `range(32, 127)` +- for taproot multisig (musig) limitations check musig.md ### BIP-67 diff --git a/docs/miniscript.md b/docs/miniscript.md index 93c8a316..eb4decae 100644 --- a/docs/miniscript.md +++ b/docs/miniscript.md @@ -1,7 +1,6 @@ # Miniscript -**COLDCARD®** Mk4 experimental `EDGE` versions -support Miniscript and MiniTapscript. +**COLDCARD®** `EDGE` versions support Miniscript and MiniTapscript. ## Import/Export diff --git a/docs/musig.md b/docs/musig.md new file mode 100644 index 00000000..422817ff --- /dev/null +++ b/docs/musig.md @@ -0,0 +1,25 @@ +# MuSig2 + +**COLDCARD®** `EDGE` versions support [MuSig2](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki) from version `6.4.2X` & `6.4.2QX`. + +COLDCARD implements all following BIPs, further restricting their scope (read more in Limitations section): +* PSBT fields [BIP-373](https://github.com/bitcoin/bips/blob/master/bip-0373.mediawiki) +* `musig()` descriptor key expression [BIP-390](https://github.com/bitcoin/bips/blob/master/bip-0390.mediawiki) +* Derivation Scheme for MuSig2 Aggregate Keys [BIP-328](https://github.com/bitcoin/bips/blob/master/bip-0328.mediawiki) + +### Limitations: +* COLDCARD must stay powered up between 1st and 2nd round as necessary musig session data are stored in volatile memory only +* `musig()` can only be used inside `tr()` expression as key expression +* cannot be nested within another `musig()` expression +* only one own key in `musig()` expression +* `musig(KEY, KEY, ..., KEY)//*` + * all `KEY`s MUST be unique - no repeated keys + * `KEY` expression MUST be extended key (not plain pubkey) + * `KEY` expression cannot contain child derivation, only `musig()` expression can contain derivation steps + * `KEY`s are sorted prior to aggregation + * hardened derivation not allowed for `musig()` expression + * derivation must end with `*` - only ranged `musig()` expression allowed, if `musig()` derivation is omitted, `/<0;1>/*` is implied +* PSBT must contain all the data required by BIP-373 +* COLDCARD strictly differentiate between 1st & 2nd MuSig2 round. If COLDCARD provides nonce, it will not attempt to sign even if it could (a.k.a enough nonces from cosigners are available). + To provide both nonce(s) & signature(s) signing needs to be preformed twice. +* keys from WIF Store cannot be used for MuSig2 signing \ No newline at end of file diff --git a/external/ckcc-protocol b/external/ckcc-protocol index 2afc7d34..6d9f7193 160000 --- a/external/ckcc-protocol +++ b/external/ckcc-protocol @@ -1 +1 @@ -Subproject commit 2afc7d34d27568f984022c6e006408bf6b50e369 +Subproject commit 6d9f7193b336ab1097c7f941ce8c7e2ae80bfe29 diff --git a/external/libngu b/external/libngu index 537519a8..b8e0c70f 160000 --- a/external/libngu +++ b/external/libngu @@ -1 +1 @@ -Subproject commit 537519a829259622ea6b0334fbafd6cae852852f +Subproject commit b8e0c70f6b29d1b9393c89bdbea3f750f2ba3e94 diff --git a/releases/EdgeChangeLog.md b/releases/EdgeChangeLog.md index fed6abe9..ff3d1969 100644 --- a/releases/EdgeChangeLog.md +++ b/releases/EdgeChangeLog.md @@ -13,24 +13,22 @@ This lists the changes in the most recent EDGE firmware, for each hardware platf # Shared Improvements - Both Mk4 and Q -- New Feature: Support for v3 transactions -- New Feature: Send keystrokes with all derived BIP-85 secrets -- Enhancement: CCC allow to reset block height +- New Feature: Ability to sign MuSig2 UTXOs. Read more [here](docs/musig.md) TODO proper link - Bugfix: PSBT global XPUBs validation when signing with specific wallet - Bugfix: Do not allow sighash DEFAULT outside taproot context # Mk4 Specific Changes -## 6.4.1X - 2026-xx-xx +## 6.5.0X - 2026-03-24 -- todo +- synced with master up to `5.5.0` # Q Specific Changes -## 6.4.0QX - 2026-xx-xx +## 6.5.0QX - 2026-03-24 -- todo +- synced with master up to `1.4.0Q` # Release History diff --git a/shared/auth.py b/shared/auth.py index 99d4b9dc..f28259bd 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -8,6 +8,7 @@ from ubinascii import hexlify as b2a_hex from ubinascii import unhexlify as a2b_hex from uhashlib import sha256 +from ustruct import pack from public_constants import AFC_SCRIPT, AF_CLASSIC, AFC_BECH32, SUPPORTED_ADDR_FORMATS, AF_P2TR from public_constants import STXN_FINALIZE, STXN_VISUALIZE, STXN_SIGNED, AF_P2SH, AF_P2WPKH_P2SH from sffile import SFFile @@ -275,8 +276,10 @@ async def try_push_tx(data, txid, txn_sha=None): class ApproveTransaction(UserAuthorizedAction): def __init__(self, psbt_len, flags=None, psbt_sha=None, input_method=None, - output_encoder=None, filename=None, miniscript_wallet=None): + output_encoder=None, filename=None, miniscript_wallet=None, + offset=TXN_INPUT_OFFSET): super().__init__() + self.offset = offset self.psbt_len = psbt_len # do finalize is None if not USB, None = decide based on is_complete @@ -405,7 +408,7 @@ async def interact(self): # step 1: parse PSBT from PSRAM into in-memory objects. try: - with SFFile(TXN_INPUT_OFFSET, length=self.psbt_len, message='Reading...') as fd: + with SFFile(self.offset, length=self.psbt_len, message='Reading...') as fd: # NOTE: psbtObject captures the file descriptor and uses it later self.psbt = psbtObject.read_psbt(fd) except BaseException as exc: @@ -433,6 +436,9 @@ async def interact(self): # which outputs are change self.psbt.consider_dangerous_sighash() + if self.psbt.session: + self.psbt.session.update(pack(' + + def __eq__(self, other): + return hash(self) == hash(other) + + def __hash__(self): + return hash(self.node.pubkey()) + hash(self.derivation) + + def serialize(self): + return self.key_bytes() + + def compile(self): + d = self.serialize() + return ser_compact_size(len(d)) + d + + @property + def node(self): + if self._node is None: + self._node = musig_synthetic_node(self.aggregate_pubkey().to_bytes()) + return self._node + + def validate(self, my_xfp, disable_checks=False): + has_mine = 0 + for k in self.keys: + assert not k.is_provably_unspendable, "unspendable key inside musig" + if k.validate(my_xfp, disable_checks): + has_mine += 1 + + assert len(self.keys) == len(set(self.keys)), "musig keys not unique" + assert has_mine <= 1, "multiple own keys in musig" + return has_mine + + def key_bytes(self): + return ngu.secp256k1.pubkey(self.node.pubkey()).to_xonly().to_bytes() + + def aggregate_pubkey(self): + keyagg_cache = ngu.secp256k1.MusigKeyAggCache() + secp_pubkeys = [ngu.secp256k1.pubkey(k.node.pubkey()) for k in self.keys] + ngu.secp256k1.musig_pubkey_agg(secp_pubkeys, keyagg_cache) + return keyagg_cache.agg_pubkey() + + def to_string(self, external=True, internal=True): + base = "musig(%s)" % (",".join([k.to_string(False, False) for k in self.keys])) + base += "/" + self.derivation.to_string(external, internal) + return base + + def derive(self, idx=None, change=False): + idx = self.derivation.der_index(idx, change) + new_node = self.node.copy() + new_node.derive(idx, False) + + return type(self)(self.keys, KeyDerivationInfo(self.derivation.indexes[1:]), + node=new_node) + + @property + def is_provably_unspendable(self): + return False + + @classmethod + def read_from(cls, s, taproot=True): + assert taproot, "musig in non-taproot context" + assert s.read(6) == b"musig(", "not musig()" + + der = None + keys = [] + while True: + k = ExtendedKey.read_from(s, taproot=taproot, musig=True) + k.der = None + k.taproot = taproot + # already verified that no der present in keys + k.derivation = None + keys.append(k) + c = s.read(1) + if c == b")": + sep = s.read(1) + if sep == b"/": + der = KeyDerivationInfo.parse(s) + + s.seek(-1, 1) + break + + assert c == b"," + + return cls(keys, der) + + @classmethod + def from_string(cls, s): + s = BytesIO(s.encode()) + return cls.read_from(s) + + +class KeyExpression: + @classmethod + def read_from(cls, s, taproot=False): + is_musig = (s.read(6) == b"musig(") + s.seek(-6, 1) + if is_musig: + return MusigKey.read_from(s, taproot=taproot) + else: + return ExtendedKey.read_from(s, taproot=taproot) def bip388_wallet_policy_to_descriptor(desc_tmplt, keys_info): @@ -453,22 +582,36 @@ def bip388_wallet_policy_to_descriptor(desc_tmplt, keys_info): def bip388_validate_policy(desc_tmplt, keys_info): - from uio import BytesIO - s = BytesIO(desc_tmplt) r = [] while True: - got, char = read_until(s, b"@") + g1, char = read_until(s, b"@") if not char: # no more - done break # key derivation info required for policy - got, char = read_until(s, b"/") + g2, char = read_until(s, b"/") assert char, "key derivation missing" - num = int(got.decode()) - if num not in r: - r.append(num) + if g1.endswith(b"musig("): + # key derivations not allowed inside musig + assert b"/" not in g2 + assert g2[-1:] == b")" + + for i, num in enumerate(g2[:-1].split(b",")): + if i: + # 0th element has @ already removed + assert num[0:1] == b"@" + num = num[1:] + + num = int(num.decode()) + if num not in r: + r.append(num) + + else: + num = int(g2.decode()) + if num not in r: + r.append(num) assert s.read(1) in b"<*", "need multipath" diff --git a/shared/descriptor.py b/shared/descriptor.py index 9ab0c1a1..98a80622 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -5,11 +5,11 @@ import ngu, chains from io import BytesIO from collections import OrderedDict -from binascii import hexlify as b2a_hex -from utils import xfp2str +from utils import xfp2str, swab32 from public_constants import AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH, AF_P2TR from public_constants import AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, MAX_TR_SIGNERS -from desc_utils import parse_desc_str, append_checksum, descriptor_checksum, Key +from desc_utils import (parse_desc_str, append_checksum, descriptor_checksum, + KeyExpression, ExtendedKey, MusigKey) from miniscript import Miniscript from precomp_tag_hash import TAP_BRANCH_H @@ -170,9 +170,12 @@ def bip388_wallet_policy(self): keys_info = OrderedDict() for k in self.keys: - pk = k.node.pubkey() - if pk not in keys_info: - keys_info[pk] = k.to_string(external=False, internal=False) + ks = k.keys if isinstance(k, MusigKey) else [k] + + for kk in ks: + pk = kk.node.pubkey() + if pk not in keys_info: + keys_info[pk] = kk.to_string(external=False, internal=False) desc_tmplt = self.to_string(checksum=False).replace("/<0;1>/*", "/**") @@ -198,7 +201,17 @@ def xfp_paths(self, skip_unspend_ik=False): if self.is_taproot and k.is_provably_unspendable and skip_unspend_ik: continue - res.append(k.origin.psbt_derivation()) + if isinstance(k, MusigKey): + agg_k = [swab32(k.node.my_fp())] + # even if dupes - add + res.append(agg_k) + + for kk in k.keys: + psbt_der = kk.origin.psbt_derivation() + if psbt_der not in res: + res.append(psbt_der) + else: + res.append(k.origin.psbt_derivation()) return res @@ -231,16 +244,18 @@ def keys(self): if self._keys: return self._keys - if self.tapscript: + if self.is_taproot: # internal is always first # use ordered dict as order preserving set keys = OrderedDict() - # add internal key + # add internal key (whether musig or not) keys[self.key] = None - # taptree keys - for lv in self.tapscript.iter_leaves(): - for k in lv.keys: - keys[k] = None + + if self.tapscript: + # taptree keys + for lv in self.tapscript.iter_leaves(): + for k in lv.keys: + keys[k] = None self._keys = list(keys) @@ -259,21 +274,20 @@ def derive(self, idx=None, change=False): # duplicate keys can be may be found in different leaves # use map to derive each key just once derived_keys = OrderedDict() - ikd = None for i, k in enumerate(self.keys): - dk = k.derive(idx, change=change) - dk.taproot = self.is_taproot - derived_keys[k] = dk - if not i: - # internal key is always at index 0 in self.keys - ikd = dk + if not isinstance(k, MusigKey): + dk = k.derive(idx, change=change) + dk.taproot = self.is_taproot + derived_keys[k] = dk + + derived_tapsript = None + if self.tapscript: + derived_tapsript = self.tapscript.derive(idx, derived_keys, change=change) + + return type(self)(self.key.derive(idx, change=change), + tapscript=derived_tapsript, addr_fmt=self.addr_fmt, + keys=list(derived_keys.values())) - return type(self)( - ikd, - tapscript=self.tapscript.derive(idx, derived_keys, change=change), - addr_fmt=self.addr_fmt, - keys=list(derived_keys.values()), - ) if self.miniscript: return type(self)( None, @@ -371,8 +385,7 @@ def read_from(cls, s): if start.startswith(b"tr("): af = AF_P2TR s.seek(-5, 1) - internal_key = Key.parse(s) - internal_key.taproot = True + internal_key = KeyExpression.read_from(s, taproot=True) sep = s.read(1) if sep == b")": s.seek(-1, 1) @@ -408,7 +421,7 @@ def read_from(cls, s): key = internal_key nbrackets = 1 + int(af == AF_P2WSH_P2SH) else: - key = Key.parse(s) + key = ExtendedKey.read_from(s, taproot=False) nbrackets = 1 + int(af == AF_P2WPKH_P2SH) end = s.read(nbrackets) diff --git a/shared/export.py b/shared/export.py index e3b46ba5..59c417db 100644 --- a/shared/export.py +++ b/shared/export.py @@ -275,7 +275,7 @@ async def make_bitcoin_core_wallet(account_num=0, fname_pattern='bitcoin-core.tx def generate_bitcoin_core_wallet(account_num, example_addrs): # Generate the data for an RPC command to import keys into Bitcoin Core # - yields dicts for json purposes - from descriptor import Descriptor, Key + from descriptor import Descriptor, ExtendedKey chain = chains.current_chain() @@ -306,10 +306,10 @@ def generate_bitcoin_core_wallet(account_num, example_addrs): example_addrs.append(('m/%s/%s' % (derive_v1, sp), a)) xfp = settings.get('xfp') - key0 = Key.from_cc_data(xfp, derive_v0, xpub_v0) + key0 = ExtendedKey.from_cc_data(xfp, derive_v0, xpub_v0) desc_v0 = Descriptor(key=key0, addr_fmt=AF_P2WPKH) - key1 = Key.from_cc_data(xfp, derive_v1, xpub_v1) + key1 = ExtendedKey.from_cc_data(xfp, derive_v1, xpub_v1) desc_v1 = Descriptor(key=key1, addr_fmt=AF_P2TR) OWNERSHIP.note_wallet_used(AF_P2WPKH, account_num) @@ -391,7 +391,7 @@ def generate_unchained_export(account_num=0): def generate_generic_export(account_num=0): # Generate data that other programers will use to import Coldcard (single-signer) - from descriptor import Descriptor, Key + from descriptor import Descriptor, ExtendedKey chain = chains.current_chain() master_xfp = settings.get("xfp") @@ -422,7 +422,7 @@ def generate_generic_export(account_num=0): xfp = xfp2str(swab32(node.my_fp())) xp = chain.serialize_public(node, AF_CLASSIC) zp = chain.serialize_public(node, fmt) if fmt not in (AF_CLASSIC, AF_P2TR) else None - key = Key.from_cc_data(master_xfp, dd, xp) + key = ExtendedKey.from_cc_data(master_xfp, dd, xp) key_exp = key.to_string(external=False, internal=False) rv[name] = OrderedDict(name=atype, @@ -491,7 +491,7 @@ def generate_electrum_wallet(addr_type, account_num): async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int_ext=True, fname_pattern="descriptor.txt", direct_way=None): - from descriptor import Descriptor, Key + from descriptor import Descriptor, ExtendedKey from glob import dis dis.fullscreen('Generating...') @@ -514,7 +514,7 @@ async def make_descriptor_wallet_export(addr_type, account_num=0, mode=None, int dis.progress_bar_show(0.7) - key = Key.from_cc_data(xfp, derive, xpub) + key = ExtendedKey.from_cc_data(xfp, derive, xpub) desc = Descriptor(key=key, addr_fmt=addr_type) dis.progress_bar_show(0.8) if int_ext: diff --git a/shared/miniscript.py b/shared/miniscript.py index 4bbdbb4c..a588c4c7 100644 --- a/shared/miniscript.py +++ b/shared/miniscript.py @@ -6,7 +6,7 @@ from binascii import unhexlify as a2b_hex from binascii import hexlify as b2a_hex from serializations import ser_compact_size -from desc_utils import Key, read_until +from desc_utils import ExtendedKey, MusigKey, KeyExpression, read_until from public_constants import MAX_TR_SIGNERS @@ -41,15 +41,7 @@ def to_string(self, *args, **kwargs): return "%d" % self.num -class KeyHash(Key): - @classmethod - def parse_key(cls, k: bytes, *args, **kwargs): - # convert to string - kd = k.decode() - # raw 20-byte hash - if len(kd) == 40: - return kd, None - return super().parse_key(k, *args, **kwargs) +class KeyHash(ExtendedKey): def serialize(self, *args, **kwargs): start = 1 if self.taproot else 0 @@ -63,6 +55,27 @@ def compile(self): return ser_compact_size(len(d)) + d +class KeyHashMusig(MusigKey): + def serialize(self, *args, **kwargs): + return ngu.hash.hash160(self.key_bytes()) + + def __len__(self): + return 21 # <20:pkh> + + def compile(self): + d = self.serialize() + return ser_compact_size(len(d)) + d + +class KeyHashExpression: + @classmethod + def read_from(cls, s, taproot=False): + is_musig = (s.read(6) == b"musig(") + s.seek(-6, 1) + if is_musig: + return KeyHashMusig.read_from(s, taproot=taproot) + else: + return KeyHash.read_from(s, taproot=taproot) + class Raw: def __init__(self, raw): if len(raw) != self.LEN * 2: @@ -114,7 +127,7 @@ def keys(self): for arg in self.args: if isinstance(arg, Miniscript): res += arg.keys - elif isinstance(arg, Key): # KeyHash is subclass of Key + elif isinstance(arg, ExtendedKey) or isinstance(arg, MusigKey): # KeyHash is subclass of ExtendedKey res.append(arg) return res @@ -139,7 +152,9 @@ def key_derive(key, idx, key_map=None, change=False): def derive(self, idx, key_map=None, change=False): args = [] for arg in self.args: - if isinstance(arg, Key): # KeyHash is subclass of Key + if isinstance(arg, MusigKey): + arg = arg.derive(idx, change) + elif isinstance(arg, ExtendedKey): # KeyHash is subclass of ExtendedKey arg = self.key_derive(arg, idx, key_map, change=change) elif hasattr(arg, "derive"): arg = arg.derive(idx, key_map, change) @@ -244,7 +259,7 @@ def carg(self): class PkK(OneArg): # NAME = "pk_k" - ARGCLS = Key + ARGCLS = KeyExpression TYPE = "K" PROPS = "ondu" @@ -258,7 +273,7 @@ def __len__(self): class PkH(OneArg): # DUP HASH160 EQUALVERIFY NAME = "pk_h" - ARGCLS = KeyHash + ARGCLS = KeyHashExpression TYPE = "K" PROPS = "ndu" @@ -752,7 +767,7 @@ class Multi(Miniscript): # ... CHECKMULTISIG NAME = "multi" NARGS = None - ARGCLS = (Number, Key) + ARGCLS = (Number, ExtendedKey) TYPE = "B" PROPS = "ndu" N_MAX = 20 @@ -831,7 +846,7 @@ class Sortedmulti_a(Multi_a): class Pk(OneArg): # CHECKSIG NAME = "pk" - ARGCLS = Key + ARGCLS = KeyExpression TYPE = "B" PROPS = "ondu" @@ -845,7 +860,7 @@ def __len__(self): class Pkh(OneArg): # DUP HASH160 EQUALVERIFY CHECKSIG NAME = "pkh" - ARGCLS = KeyHash + ARGCLS = KeyHashExpression TYPE = "B" PROPS = "ndu" diff --git a/shared/multisig.py b/shared/multisig.py index 2aa63f2e..d0b65c5c 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -11,7 +11,7 @@ from public_constants import MAX_SIGNERS from glob import settings from charcodes import KEY_QR -from desc_utils import Key, KeyOriginInfo +from desc_utils import ExtendedKey, KeyOriginInfo async def ms_coordinator_qr(af_str, my_xfp): @@ -50,9 +50,9 @@ def convertor(got): break try: if isinstance(key, dict): - k = Key.from_cc_json(key, af_str) + k = ExtendedKey.from_cc_json(key, af_str) else: - k = Key.from_string(key) + k = ExtendedKey.from_string(key) num_mine += k.validate(my_xfp) keys.append(k) @@ -116,9 +116,9 @@ async def ms_coordinator_file(af_str, my_xfp, slot_b=None): try: if isinstance(vals, dict): - k = Key.from_cc_json(vals, af_str) + k = ExtendedKey.from_cc_json(vals, af_str) else: - k = Key.from_string(vals) + k = ExtendedKey.from_string(vals) except Exception as e: # sys.print_exception(e) raise @@ -152,7 +152,7 @@ def add_own_xpub(chain, acct_num, addr_fmt, secret=None): the_xfp = xfp2str(sv.get_xfp()) koi = KeyOriginInfo.from_string(the_xfp + "/" + deriv) node = sv.derive_path(deriv, register=False) - key = Key(node, koi, chain_type=chain.ctype) + key = ExtendedKey(node, koi, chain_type=chain.ctype) return key diff --git a/shared/psbt.py b/shared/psbt.py index c37489d1..d16f9673 100644 --- a/shared/psbt.py +++ b/shared/psbt.py @@ -7,7 +7,7 @@ from ustruct import unpack_from, unpack, pack from ubinascii import hexlify as b2a_hex from utils import xfp2str, B2A, keypath_to_str, validate_derivation_path_length, problem_file_line, node_from_privkey -from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str +from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, swab32 from uhashlib import sha256 from uio import BytesIO from sffile import SizerFile @@ -22,6 +22,7 @@ from opcodes import OP_CHECKMULTISIG, OP_RETURN from glob import settings from precomp_tag_hash import TAP_TWEAK_H, TAP_SIGHASH_H +from desc_utils import MusigKey, MUSIG_CHAIN_CODE from wif import init_wif_store from public_constants import ( @@ -38,6 +39,8 @@ PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_SIGNERS, + PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS, PSBT_IN_MUSIG2_PUB_NONCE, PSBT_IN_MUSIG2_PARTIAL_SIG, + PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, AF_P2TR, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH, AFC_SEGWIT, AF_BARE_PK ) @@ -63,6 +66,9 @@ # print some things, sometimes DEBUG = ckcc.is_simulator() + +MUSIG_SESSION_CACHE = {} + class HashNDump: def __init__(self, d=None): self.rv = sha256() @@ -210,7 +216,7 @@ class psbtProxy: no_keys = () # these fields will return None but are not stored unless a value is set - blank_flds = ('unknown', ) + blank_flds = ('unknown',) def __init__(self): self.fd = None @@ -316,6 +322,7 @@ def handle_zero_xfp(self, xfp_path, my_xfp, parent=None): def parse_taproot_subpaths(self, my_xfp, parent, cosign_xfp=None): my_sp_idxs = [] + ik_idxs = [] parsed_subpaths = OrderedDict() for i in range(len(self.taproot_subpaths)): key, val = self.taproot_subpaths[i] @@ -328,7 +335,7 @@ def parse_taproot_subpaths(self, my_xfp, parent, cosign_xfp=None): if leaf_hash_len: self.fd.seek(32*leaf_hash_len, 1) else: - self.ik_idx = i + ik_idxs.append(i) curr_pos = self.fd.tell() # this position is where actual xfp+path starts @@ -343,14 +350,15 @@ def parse_taproot_subpaths(self, my_xfp, parent, cosign_xfp=None): here = list(unpack_from('<%dI' % (to_read // 4), v)) here = self.handle_zero_xfp(here, my_xfp, parent) parsed_subpaths[xonly_pk] = [leaf_hash_len] + here - if (here[0] == my_xfp) or (cosign_xfp and (here[0] == cosign_xfp)): - my_sp_idxs.append(i) - elif parent.key_in_wif_store(xonly_pk): + if (here[0] == my_xfp) or (here[0] == cosign_xfp) or parent.key_in_wif_store(xonly_pk): my_sp_idxs.append(i) if my_sp_idxs: self.sp_idxs = my_sp_idxs + if ik_idxs: + self.ik_idx = ik_idxs + return parsed_subpaths def parse_non_taproot_subpaths(self, my_xfp, parent, cosign_xfp=None): @@ -369,7 +377,7 @@ def parse_non_taproot_subpaths(self, my_xfp, parent, cosign_xfp=None): here = self.handle_zero_xfp(here, my_xfp, parent) parsed_subpaths[pk] = here - if (here[0] == my_xfp) or (cosign_xfp and (here[0] == cosign_xfp)) or (pk in parent.wif_store): + if (here[0] == my_xfp) or (here[0] == cosign_xfp) or parent.key_in_wif_store(pk): my_sp_idxs.append(i) # else: @@ -397,7 +405,8 @@ class psbtOutputProxy(psbtProxy): blank_flds = ('unknown', 'subpaths', 'redeem_script', 'witness_script', 'sp_idxs', 'is_change', 'amount', 'script', 'attestation', 'proprietary', - 'taproot_internal_key', 'taproot_subpaths', 'taproot_tree', 'ik_idx') + 'taproot_internal_key', 'taproot_subpaths', 'taproot_tree', 'ik_idx', + 'musig_pubkeys') def __init__(self, fd, idx): super().__init__() @@ -412,6 +421,7 @@ def __init__(self, fd, idx): #self.witness_script = None #self.script = None #self.amount = None + #self.musig_pubkeys = None # this flag is set when we are assuming output will be change (same wallet) #self.is_change = False @@ -459,6 +469,9 @@ def store(self, kt, key, val): self.taproot_subpaths.append((key, val)) elif kt == PSBT_OUT_TAP_TREE: self.taproot_tree = val + elif kt == PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS: + self.musig_pubkeys = self.musig_pubkeys or [] + self.musig_pubkeys.append((key, val)) else: self.unknown = self.unknown or [] pos, length = key @@ -488,6 +501,10 @@ def serialize(self, out_fd, is_v2): if self.taproot_tree: wr(PSBT_OUT_TAP_TREE, self.taproot_tree) + if self.musig_pubkeys: + for k, v in self.musig_pubkeys: + wr(PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS, v, k) + if is_v2: wr(PSBT_OUT_SCRIPT, self.script) wr(PSBT_OUT_AMOUNT, self.amount) @@ -595,7 +612,7 @@ def fraud(idx, af, err=""): elif af == AF_P2TR: if msc: try: - xfp_paths = [v[1:] for v in parsed_subpaths.values() if len(v[1:]) > 1] + xfp_paths = [v[1:] for v in parsed_subpaths.values()] if msc.matching_subpaths(xfp_paths): msc.validate_script_pubkey(txo.scriptPubKey, xfp_paths) self.is_change = True @@ -638,9 +655,10 @@ class psbtInputProxy(psbtProxy): 'fully_signed', 'af', 'is_miniscript', "subpaths", 'utxo', 'utxo_spk', 'amount', 'previous_txid', 'part_sigs', 'added_sigs', 'prevout_idx', 'sequence', 'req_time_locktime', 'req_height_locktime', - 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', 'use_keypath', + 'taproot_merkle_root', 'taproot_script_sigs', 'taproot_scripts', 'taproot_subpaths', 'taproot_internal_key', 'taproot_key_sig', 'tr_added_sigs', - 'ik_idx', + 'ik_idx', 'musig_pubkeys', 'musig_pubnonces', 'musig_part_sigs', 'musig_agg_idx', + 'musig_added_pubnonces', 'musig_added_sigs' ) def __init__(self, fd, idx): @@ -674,7 +692,6 @@ def __init__(self, fd, idx): # self.taproot_merkle_root = None # self.taproot_script_sigs = None # self.taproot_scripts = None - # self.use_keypath = None # signing taproot inputs that have script path with internal key # self.ik_idx = None # index of taproot internal key in taproot_subpaths # === @@ -684,12 +701,21 @@ def __init__(self, fd, idx): #self.req_time_locktime = None #self.req_height_locktime = None + # === musig === + #self.musig_pubkeys = None + #self.musig_pubnonces = None + #self.musig_part_sigs = None + self.parse(fd) @property def is_segwit(self): return self.af & AFC_SEGWIT + @property + def is_musig(self): + return bool(self.musig_pubkeys or self.musig_pubnonces or self.musig_part_sigs) + def get_taproot_script_sigs(self): # returns set of (xonly, script) provided via PSBT_IN_TAP_SCRIPT_SIG # we do not parse control blocks (k) not needed @@ -711,6 +737,65 @@ def get_taproot_scripts(self): return t_scr + def get_musig_pubkeys(self): + parsed_musig_pubkeys = {} + for k, v in self.musig_pubkeys or []: + key = self.get(k) + pubkeys = self.get(v) + pk_list = [] + for i in range(0, len(pubkeys), 33): + pk_list.append(pubkeys[i:i+33]) + + parsed_musig_pubkeys[key] = pk_list + + return parsed_musig_pubkeys + + def parse_musig_composite_key(self, key_coords): + # helper function to parse key from: + # * PSBT_IN_MUSIG2_PUB_NONCE + # * PSBT_IN_MUSIG2_PARTIAL_SIG + key = self.get(key_coords) + return key[:33], key[33:66], key[66:] + + def get_musig_pubnonces(self): + parsed_musig_pubnonces = {} + for k, v in self.musig_pubnonces or []: + # participant pubkey, aggregate key, tapleaf hash + pk, ak, tlh = self.parse_musig_composite_key(k) + pubnonce = self.get(v) + parsed_musig_pubnonces[(pk, ak, tlh)] = pubnonce + + return parsed_musig_pubnonces + + def get_musig_part_sigs(self): + parsed_musig_part_sigs = {} + for k, v in self.musig_part_sigs or []: + # participant pubkey, aggregate key, tapleaf hash + pk, ak, tlh = self.parse_musig_composite_key(k) + sig = self.get(v) + parsed_musig_part_sigs[(pk, ak, tlh)] = sig + + return parsed_musig_part_sigs + + def get_tr_der_coords_by_key(self, target_key): + if not self.taproot_subpaths: + return None + + if len(target_key) == 33: + # taproot subpaths only contain xonly keys + # yet function parameter 'target_key' may be classic compressed pubkey + # get rid of first bytes (containing parity bit) + target_key = target_key[1:] + + sp = None + for k, v in self.taproot_subpaths: + xonly = self.get(k) + if target_key == xonly: + sp = v[2] + break + + return sp + def has_relative_timelock(self, txin): # https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki SEQUENCE_LOCKTIME_DISABLE_FLAG = (1 << 31) @@ -894,7 +979,7 @@ def determine_my_signing_key(self, my_idx, addr_or_pubkey, my_xfp, psbt, parsed_ # remove from sp_idxs so we do not attempt to sign again self.sp_idxs.remove(i) - elif (path[0] == my_xfp) or (pubkey in psbt.wif_store): + elif (path[0] == my_xfp) or psbt.key_in_wif_store(pubkey): # slight chance of dup xfps, so handle assert i in self.sp_idxs @@ -912,14 +997,13 @@ def determine_my_signing_key(self, my_idx, addr_or_pubkey, my_xfp, psbt, parsed_ if len(parsed_subpaths) == 1: # keyspend without a script path assert self.taproot_merkle_root is None, "merkle_root should not be defined for simple keyspend" - assert self.ik_idx is not None + assert self.ik_idx == [0] xonly_pubkey, lhs_path = list(parsed_subpaths.items())[0] lhs, path = lhs_path[0], lhs_path[1:] assert not lhs, "LeafHashes have to be empty for internal key" assert self.sp_idxs[0] == 0 assert taptweak(xonly_pubkey) == addr_or_pubkey else: - # tapscript (is always miniscript wallet) self.is_miniscript = True if self.taproot_merkle_root is not None: @@ -934,21 +1018,19 @@ def determine_my_signing_key(self, my_idx, addr_or_pubkey, my_xfp, psbt, parsed_ assert i in self.sp_idxs lhs, path = lhs_path[0], lhs_path[1:] - assert merkle_root is not None, "Merkle root not defined" - if self.ik_idx == i: + # assert merkle_root is not None, "Merkle root not defined" + if self.ik_idx and len(self.ik_idx) == 1 and self.ik_idx[0] == i: assert not lhs output_key = taptweak(xonly_pubkey, merkle_root) if output_key == addr_or_pubkey: # if we find a possibility to spend keypath (internal_key) - we do keypath # even though script path is available self.sp_idxs = [i] - self.use_keypath = True break # done ignoring all other possibilities else: internal_key = self.get(self.taproot_internal_key) output_pubkey = taptweak(internal_key, merkle_root) - if addr_or_pubkey == output_pubkey: - assert i in self.sp_idxs + assert addr_or_pubkey == output_pubkey if self.is_miniscript: if not self.sp_idxs: return @@ -960,9 +1042,7 @@ def determine_my_signing_key(self, my_idx, addr_or_pubkey, my_xfp, psbt, parsed_ return # required key is None if self.af == AF_P2TR: - xfp_paths = [item[1:] - for item in parsed_subpaths.values() - if len(item[1:]) > 1] + xfp_paths = [item[1:] for item in parsed_subpaths.values()] else: xfp_paths = list(parsed_subpaths.values()) @@ -987,8 +1067,8 @@ def determine_my_signing_key(self, my_idx, addr_or_pubkey, my_xfp, psbt, parsed_ try: # contains PSBT merkle root verification (if taproot) if not MiniScriptWallet.disable_checks: - psbt.active_miniscript.validate_script_pubkey(self.utxo_spk, - xfp_paths, merkle_root) + psbt.active_miniscript.validate_script_pubkey(self.utxo_spk, xfp_paths, + merkle_root) except BaseException as e: # sys.print_exception(e) raise FatalPSBTIssue('Input #%d: %s\n\n' % (my_idx, e) + problem_file_line(e)) @@ -1074,6 +1154,15 @@ def store(self, kt, key, val): self.req_time_locktime = unpack(" public key (xonly or classic compressed) # wif_store -> initialized wif store as in psbt class @@ -1596,6 +1713,15 @@ def validate_unkonwn(self, obj, label): raise FatalPSBTIssue("Duplicate key. Key for unknown value" " already provided in %s." % label) + def validate_musig_pubkeys(self, obj): + # for both input & output objects: + # * PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS + # * PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS + for k, v in obj.musig_pubkeys: + assert k[1] == 33 # compressed pubkey len 33 + assert v[1] # list of pubkeys cannot be empty + assert (v[1] % 33 == 0) # each pubkey len 33 + async def validate(self): # Do a first pass over the txn. Raise assertions, be terse tho because # these messages are rarely seen. These are syntax/fatal errors. @@ -1685,6 +1811,20 @@ async def validate(self): assert (k[1] - 1) % 32 == 0 # "PSBT_IN_TAP_LEAF_SCRIPT control block is not valid" assert v[1] != 0 # "PSBT_IN_TAP_LEAF_SCRIPT cannot be empty" + if i.musig_pubkeys: + self.validate_musig_pubkeys(i) + + if i.musig_pubnonces: + for k, v in i.musig_pubnonces: + assert k[1] in (66, 98) # PSBT_IN_MUSIG2_PUB_NONCE key is participant pubkey (33) + aggregate pubkey (33) + (optional) tapleaf hash (32) + assert v[1] == 66 # PSBT_IN_MUSIG2_PUB_NONCE value is pubnonce + + if i.musig_part_sigs: + for k, v in i.musig_part_sigs: + assert k[1] in (66, 98) # PSBT_IN_MUSIG2_PARTIAL_SIG key is participant pubkey (33) + aggregate pubkey (33) + (optional) tapleaf hash (32) + assert v[1] == 32 # PSBT_IN_MUSIG2_PARTIAL_SIG value is partial signature + + if i.sighash and (i.sighash not in ALL_SIGHASH_FLAGS): raise FatalPSBTIssue("Unsupported sighash flag 0x%x" % i.sighash) @@ -1706,6 +1846,9 @@ async def validate(self): if o.taproot_internal_key: assert o.taproot_internal_key[1] == 32 # "PSBT_OUT_TAP_INTERNAL_KEY length != 32" + if o.musig_pubkeys: + self.validate_musig_pubkeys(o) + self.validate_unkonwn(o, "output") if not self.is_v2 and (self.num_outputs == 1): @@ -1765,6 +1908,12 @@ def consider_outputs(self, len_pths, hard_p, prefix_pths, idx_max, cosign_xfp=No for idx, txo in self.output_iter(): dis.progress_sofar(idx, self.num_outputs) + + if self.session: + if idx == 0: + self.session.update(ser_compact_size(self.num_outputs)) + self.session.update(txo.serialize()) + output = self.outputs[idx] parsed_subpaths = output.parse_subpaths(self.my_xfp, self, cosign_xfp) @@ -1938,6 +2087,11 @@ def consider_inputs(self, cosign_xfp=None): if len(prevouts) < 100: prevouts.add(k) + if self.session: + if i == 0: + self.session.update(ser_compact_size(self.num_inputs)) + self.session.update(txi.serialize()) + inp = self.inputs[i] if inp.part_sigs: @@ -2244,9 +2398,22 @@ def read_psbt(cls, fd): assert rv.num_inputs is not None assert rv.num_outputs is not None - rv.inputs = [psbtInputProxy(fd, idx) for idx in range(rv.num_inputs)] + + has_musig_inputs = False + rv.inputs = [] + for idx in range(rv.num_inputs): + inp = psbtInputProxy(fd, idx) + if inp.is_musig: + has_musig_inputs = True + rv.inputs.append(inp) + rv.outputs = [psbtOutputProxy(fd, idx) for idx in range(rv.num_outputs)] + if has_musig_inputs: + # we need session + rv.session = sha256() + rv.session.update(pack('I", idx)) + IL, ck = I[:32], I[32:] + ngu.secp256k1.musig_pubkey_ec_tweak_add(keyagg_cache, IL) + agg = keyagg_cache.agg_pubkey().to_bytes() + + return agg + + def musig_process_input(self, session, inp_idx, inp, keypair, agg_k, der_agg_k, + digest, leaf_hash=b""): + + assert session # needed + session_digest, session_rand, round1 = session + my_participant_key = keypair.pubkey().to_bytes() + musig_pubkeys = inp.get_musig_pubkeys() + cosigners = musig_pubkeys.get(agg_k, None) + + if (cosigners is None) or (my_participant_key not in cosigners): + return + + musig_partial_sigs = inp.get_musig_part_sigs() + musig_pubnonces = inp.get_musig_pubnonces() + + keyagg_cache = ngu.secp256k1.MusigKeyAggCache() + + # below will sort, but should be already sorted in PSBT + ngu.secp256k1.musig_pubkey_agg( + [ngu.secp256k1.pubkey(pk) for pk in cosigners], + keyagg_cache + ) + # verify aggregate key is correct + assert keyagg_cache.agg_pubkey().to_bytes() == agg_k + + musig_index = None # index of musig expression in key list + for i, k in enumerate(self.active_miniscript.to_descriptor().keys): + if not isinstance(k, MusigKey): + continue + if k.node.pubkey() == agg_k: + musig_index = i + break + + assert musig_index is not None # important, must be there + + # get derivation we need to use for musig + sp = inp.get_tr_der_coords_by_key(der_agg_k) + assert sp + to_derive = self.parse_xfp_path(sp)[1:] + + # is derived aggregate key xonly ? + dak_xo = int(len(der_agg_k) == 32) + # key is derived inside the key_agg cache + assert self.musig_derive_keyagg_cache(to_derive, agg_k, keyagg_cache)[dak_xo:] == der_agg_k + + if not leaf_hash: + # now finally get the output key - only for musig in taproot internal key + tweak_data = der_agg_k + if inp.taproot_merkle_root: + tweak_data += self.get(inp.taproot_merkle_root) + tweak32 = ngu.hash.sha256t(TAP_TWEAK_H, tweak_data, True) + output_key = ngu.secp256k1.musig_pubkey_xonly_tweak_add(keyagg_cache, tweak32) + # tweaked derived aggregate key + der_agg_k = output_key.to_bytes() + + my_musig_pubnonces_key = (my_participant_key, der_agg_k, leaf_hash) + + inp.musig_added_pubnonces = inp.musig_added_pubnonces or {} + + if my_musig_pubnonces_key in musig_partial_sigs: + # we have already signed + self.allow_cache_store = False + return + + if my_musig_pubnonces_key in musig_pubnonces: + # I have already provided pubnonce & now I need to use the same secrand + # so that my pubnonce & secnonce match what I have provided in first round + if round1: + raise FatalPSBTIssue("musig needs restart") + else: + if not round1: + raise FatalPSBTIssue("resign") + + # sec_rand is pseudo random, derived from session true randomness + sec_rand = ngu.hash.sha256s(session_rand + pack(" easy - # separate container for PSBT_IN_TAP_SCRIPT_SIG that we added - inp.tr_added_sigs[_key] = sig - else: # BIP 341 states: "If the spending conditions do not require a script path, # the output key should commit to an unspendable script path instead of having no script path. # This can be achieved by computing the output key point as Q = P + int(hashTapTweak(bytes(P)))G." tweak = xonly_pk - if inp.taproot_merkle_root and inp.use_keypath: + if inp.taproot_merkle_root: # we have a script path but internal key is spendable by us # merkle root needs to be added to tweak with internal key # merkle root was already verified against registered script in determine_my_signing_key @@ -2615,6 +2951,11 @@ def sign_it(self, alternate_secret=None, my_xfp=None): tweak = ngu.hash.sha256t(TAP_TWEAK_H, tweak, True) kpt = kp.xonly_tweak_add(tweak) + + digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash) + if sv.deltamode: + digest = ngu.hash.sha256d(digest) + sig = ngu.secp256k1.sign_schnorr(kpt, digest, ngu.random.bytes(32)) if inp.sighash != SIGHASH_DEFAULT: sig += bytes([inp.sighash]) @@ -2622,14 +2963,91 @@ def sign_it(self, alternate_secret=None, my_xfp=None): # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed inp.taproot_key_sig = sig + self.sig_added = True + # debug + # ngu.secp256k1.verify_schnorr(sig, digest, kpt.xonly_pubkey()) del kpt + elif isinstance(self.active_miniscript.to_descriptor().key, MusigKey): + # internal key is musig + agg_k = self.active_miniscript.to_descriptor().key.node.pubkey() + + digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash) + if sv.deltamode: + digest = ngu.hash.sha256d(digest) + + complete = self.musig_process_input(musig_session, in_idx, inp, kp, + agg_k, internal_key, digest) + if complete: + agg_sig, pubkey = complete + # we can finalize musig in taproot internal key + if inp.sighash != SIGHASH_DEFAULT: + agg_sig += bytes([inp.sighash]) + inp.taproot_key_sig = agg_sig + + # debug + # ngu.secp256k1.verify_schnorr(agg_sig, digest, ngu.secp256k1.xonly_pubkey(pubkey[1:])) + + if tr_sh: + # in tapscript keys are not tweaked, just sign with the key in the script + # signing tapscript even if we already signed internal key + taproot_script_sigs = inp.get_taproot_script_sigs() + inp.tr_added_sigs = inp.tr_added_sigs or {} + + for taproot_script, leaf_ver, is_musig in tr_sh[i]: + tlh = tapleaf_hash(taproot_script, leaf_ver) + digest = self.make_txn_taproot_sighash(in_idx, hash_type=inp.sighash, + scriptpath=True, + script=taproot_script, + leaf_ver=leaf_ver) + if sv.deltamode: + digest = ngu.hash.sha256d(digest) + + if is_musig: + agg_k, der_agg_k = is_musig + assert len(der_agg_k) == 33 + _key = (der_agg_k[1:], tlh) + if _key in taproot_script_sigs: + continue # already done ? + + complete = self.musig_process_input(musig_session, in_idx, inp, kp, + agg_k, der_agg_k, digest, tlh) + if complete: + agg_sig, pubkey = complete + assert der_agg_k == pubkey + if inp.sighash != SIGHASH_DEFAULT: + agg_sig += bytes([inp.sighash]) + + # separate container for PSBT_IN_TAP_SCRIPT_SIG that we added + inp.tr_added_sigs[_key] = agg_sig + # debug + # ngu.secp256k1.verify_schnorr(agg_sig, digest, ngu.secp256k1.xonly_pubkey(pubkey[1:])) + + else: + _key = (xonly_pk, tlh) + if _key in taproot_script_sigs: + continue # already done ? + + sig = ngu.secp256k1.sign_schnorr(sk, digest, ngu.random.bytes(32)) + # in the common case of SIGHASH_DEFAULT, encoded as '0x00', a space optimization MUST be made by + # 'omitting' the sighash byte, resulting in a 64-byte signature with SIGHASH_DEFAULT assumed + if inp.sighash != SIGHASH_DEFAULT: + sig += bytes([inp.sighash]) + + # separate container for PSBT_IN_TAP_SCRIPT_SIG that we added + inp.tr_added_sigs[_key] = sig + self.sig_added = True + # debug + # ngu.secp256k1.verify_schnorr(sig, digest, kp.xonly_pubkey()) + del kp else: + # ECDSA signing der_sig = self.ecdsa_grind_sign(sk, digest, inp.sighash) inp.added_sigs = inp.added_sigs or [] inp.added_sigs.append((pk_coord, der_sig)) + self.sig_added = True # private key no longer required stash.blank_object(sk) @@ -2648,6 +3066,11 @@ def sign_it(self, alternate_secret=None, my_xfp=None): del to_sign gc.collect() + # store musig session - only at the end of this function execution + # if any exceptions were raised - just do not store + if musig_session and musig_round1 and self.allow_cache_store: + MUSIG_SESSION_CACHE[session_digest] = session_rand + # done. dis.progress_bar_show(1) @@ -3017,6 +3440,7 @@ def miniscript_xfps_needed(self): # - used to find which multisig-signer needs to go next rv = set() done_keys = set() + ignore_keys = set() for inp in self.inputs: if inp.fully_signed: @@ -3036,6 +3460,42 @@ def miniscript_xfps_needed(self): for (xo, _) in inp.tr_added_sigs: done_keys.add(xo) + if inp.is_musig: + # in how many musig expressions is this key included + key_musig_num_map = {} + for ak, key_lst in inp.get_musig_pubkeys().items(): + # filter out just musig aggregate keys (they are not co-signers) + ignore_keys.add(unpack(' keys to ignore, currently only musig aggregate keys + need, ignore = psbt.miniscript_xfps_needed() + need = [x for x in need if x in all_xfps] # maybe it's not really a PSBT where we know the other signers? might be # a weird coinjoin we don't fully understand if not need: @@ -679,7 +681,7 @@ async def done_cb(m, idx, item): ci = [] next_signer = None - for idx, x in enumerate(all_xfps): + for idx, x in enumerate(all_xfps - ignore): # set diff txt = '[%s] Co-signer #%d' % (xfp2str(x), idx+1) f = done_cb if x == my_xfp: @@ -687,12 +689,12 @@ async def done_cb(m, idx, item): f = None if x in need: # we haven't signed ourselves yet, so allow that - from auth import sign_transaction, TXN_INPUT_OFFSET + from auth import sign_transaction async def sign_now(*a): # this will reset the UX stack: # flags=None --> whether to finalize is decided based on psbt.is_complete - sign_transaction(psbt_len, flags=None) + sign_transaction(psbt_len, flags=None, offset=psbt_offset) f = sign_now @@ -715,7 +717,7 @@ async def sign_now(*a): m.goto_idx(next_signer) # position cursor on next candidate the_ux.push(m) await m.interact() - + if m.next_xfp: assert m.next_xfp != my_xfp ri, rx_pubkey, kp = ms.kt_make_rxkey(m.next_xfp) @@ -784,6 +786,6 @@ async def kt_send_file_psbt(*a): await ux_show_story("We are not part of this wallet.", "Cannot Teleport PSBT") return - await kt_send_psbt(psbt, psbt_len=psbt_len) + await kt_send_psbt(psbt, psbt_len=psbt_len, psbt_offset=TXN_INPUT_OFFSET) # EOF diff --git a/shared/wallet.py b/shared/wallet.py index 3977261b..9f75e6ef 100644 --- a/shared/wallet.py +++ b/shared/wallet.py @@ -6,7 +6,8 @@ import ngu, ujson, uio, chains, ure, version, stash from binascii import hexlify as b2a_hex from serializations import ser_string -from desc_utils import bip388_wallet_policy_to_descriptor, append_checksum, bip388_validate_policy, Key +from desc_utils import (bip388_wallet_policy_to_descriptor, append_checksum, bip388_validate_policy, + ExtendedKey, MusigKey) from public_constants import AF_P2TR, AF_P2WSH, AF_CLASSIC, AF_P2SH, AF_P2WSH_P2SH from menu import MenuSystem, MenuItem, start_chooser from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_enter_bip32_index @@ -144,10 +145,10 @@ def render_path(self, change_idx, idx): return self._path + '/%d/%d' % (change_idx, idx) def to_descriptor(self): - from descriptor import Descriptor, Key + from descriptor import Descriptor, ExtendedKey xfp = settings.get('xfp') xpub = settings.get('xpub') - d = Descriptor(key=Key.from_cc_data(xfp, self._path, xpub), addr_fmt=self.addr_fmt) + d = Descriptor(key=ExtendedKey.from_cc_data(xfp, self._path, xpub), addr_fmt=self.addr_fmt) return d @@ -329,7 +330,7 @@ def xfp_paths(self, skip_unspend_ik=False): for i, k_str in enumerate(self.keys_info): if not i and self.ik_u and skip_unspend_ik: continue - k = Key.from_string(k_str) + k = ExtendedKey.from_string(k_str) res.append(k.origin.psbt_derivation()) return res @@ -337,7 +338,6 @@ def xfp_paths(self, skip_unspend_ik=False): def matching_subpaths(self, xfp_paths): my_xfp_paths = self.to_descriptor().xfp_paths() - if len(xfp_paths) != len(my_xfp_paths): return False @@ -359,7 +359,8 @@ def subderivation_indexes(self, xfp_paths): for y in xfp_paths: if x == y[:prefix_len]: to_derive = tuple(y[prefix_len:]) - res.add(to_derive) + if to_derive: + res.add(to_derive) err = "derivation indexes" assert res, err @@ -460,11 +461,8 @@ async def show_keys(self): for idx, k_str in enumerate(self.keys_info): if idx: msg += '\n---===---\n\n' - elif self.addr_fmt == AF_P2TR: - # index 0, taproot internal key - msg += "Taproot internal key:\n\n" - if self.ik_u: - msg += "(provably unspendable)\n\n" + elif self.addr_fmt == AF_P2TR and self.ik_u: + msg += "Provably unspendable internal key:\n\n" msg += '@%s:\n %s\n\n' % (idx, k_str) @@ -568,7 +566,7 @@ def import_from_psbt(cls, addr_fmt, M, N, xpubs_list): keys = [] for ek, xfp_pth in xpubs_list: - k = Key.from_psbt_xpub(ek, xfp_pth) + k = ExtendedKey.from_psbt_xpub(ek, xfp_pth) has_mine += k.validate(my_xfp, cls.disable_checks) keys.append(k) @@ -586,7 +584,7 @@ def validate_psbt_xpubs(self, psbt_xpubs): # using __hash__ of the key object ignores origin derivation keys = set() for ek, xfp_pth in psbt_xpubs: - key = Key.from_psbt_xpub(ek, xfp_pth) + key = ExtendedKey.from_psbt_xpub(ek, xfp_pth) key.validate(settings.get('xfp', 0), self.disable_checks) keys.add(key.to_string(external=False, internal=False)) @@ -815,11 +813,13 @@ def xpubs_from_xfp(self, xfp): # return list of XPUB's which match xfp res = [] desc = self.to_descriptor() - for k in desc.keys: - if k.origin and k.origin.cc_fp == xfp: - res.append(k) - elif swab32(k.node.my_fp()) == xfp: - res.append(k) + for key in desc.keys: + ks = key.keys if isinstance(key, MusigKey) else [key] + for k in ks: + if k.origin and k.origin.cc_fp == xfp: + res.append(k) + elif swab32(k.node.my_fp()) == xfp: + res.append(k) assert res, "missing xfp %s" % xfp2str(xfp) # returned is list of keys with corresponding master xfp @@ -870,15 +870,19 @@ def kt_search_rxkey(cls, payload): for msc in cls.iter_wallets(): kp = msc.kt_my_keypair(ri) - for k in msc.to_descriptor().keys: - if k.origin.cc_fp == my_xfp: - continue - kk = k.derive(KT_RXPUBKEY_DERIV).derive(ri) - his_pubkey = kk.node.pubkey() - # if implied session key decodes the checksum, it is right - ses_key, body = decode_step1(kp, his_pubkey, payload[4:]) - if ses_key: - return ses_key, body, kk.origin.cc_fp + + for key in msc.to_descriptor().keys: + ks = key.keys if isinstance(key, MusigKey) else [key] + for k in ks: + if not k.origin: continue + if k.origin.cc_fp == my_xfp: + continue + kk = k.derive(KT_RXPUBKEY_DERIV).derive(ri) + his_pubkey = kk.node.pubkey() + # if implied session key decodes the checksum, it is right + ses_key, body = decode_step1(kp, his_pubkey, payload[4:]) + if ses_key: + return ses_key, body, kk.origin.cc_fp return None, None, None @@ -1007,6 +1011,7 @@ def possible(filename): possible_name = (fn.split('/')[-1].split('.'))[0] if fn else None maybe_enroll_xpub(config=data, name=possible_name) except BaseException as e: + # import sys;sys.print_exception(e) await ux_show_story('Failed to import miniscript.\n\n%s\n%s' % (e, problem_file_line(e))) async def import_miniscript_nfc(*a): @@ -1313,7 +1318,7 @@ def remove_subderivation(str_key): ik_key = chains.current_chain().serialize_public(n) else: ik_key, ik_subder = remove_subderivation(key) - ik_u = Key.from_string(ik_key).is_provably_unspendable + ik_u = ExtendedKey.from_string(ik_key).is_provably_unspendable if ik_subder == "/<0;1>/*": ik_subder = "/**" diff --git a/stm32/MK-Makefile b/stm32/MK-Makefile index 9f33b3a3..a9b06ec6 100644 --- a/stm32/MK-Makefile +++ b/stm32/MK-Makefile @@ -19,7 +19,7 @@ LATEST_RELEASE = $(shell ls -t1 ../releases/*-mk-*.dfu ../releases/*-mk4-*.dfu | # Our version for this release. # - caution, the bootrom will not accept version < 3.0.0 -VERSION_STRING = 6.4.1X +VERSION_STRING = 6.5.0X # keep near top, because defined default target (all) include shared.mk diff --git a/stm32/Q1-Makefile b/stm32/Q1-Makefile index fb25472b..2d2e6092 100644 --- a/stm32/Q1-Makefile +++ b/stm32/Q1-Makefile @@ -16,7 +16,7 @@ BOOTLOADER_DIR = q1-bootloader LATEST_RELEASE = $(shell ls -t1 ../releases/*-q1-*.dfu | head -1) # Our version for this release. -VERSION_STRING = 6.4.1QX +VERSION_STRING = 6.5.0QX # Remove this closer to shipping. #$(warning "Forcing debug build") diff --git a/testing/api.py b/testing/api.py index 58d7db70..4f4ca4d3 100644 --- a/testing/api.py +++ b/testing/api.py @@ -66,7 +66,6 @@ def get_free_port(): "-server=1", "-listen=0", "-keypool=1", - "-listen=0" f"-port={self.p2p_port}", f"-rpcport={self.rpc_port}" ] diff --git a/testing/bip32.py b/testing/bip32.py index 57899a39..fbe7fa13 100644 --- a/testing/bip32.py +++ b/testing/bip32.py @@ -1,6 +1,6 @@ # (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -import hashlib, hmac, bech32 +import hashlib, hmac, bech32, os from typing import Union from io import BytesIO try: @@ -19,6 +19,7 @@ from helpers import hash160, str_to_path from base58 import encode_base58_checksum, decode_base58_checksum +from constants import BIP_341_H HARDENED = 2 ** 31 @@ -787,3 +788,21 @@ def privkey(self): def parent_fingerprint(self): return self.node.parent_fingerprint + + +def ranged_unspendable_internal_key(chain_code=32 * b"\x01", subderiv="/<0;1>/*"): + # provide ranged provably unspendable key in serialized extended key format for core to understand it + # core does NOT understand 'unspend(' + pk = b"\x02" + bytes.fromhex(BIP_341_H) + node = BIP32Node.from_chaincode_pubkey(chain_code, pk) + return node.hwif() + subderiv + + +def random_keys(num_keys, path="86h/1h/0h"): + keys = [] + for _ in range(num_keys): + k = BIP32Node.from_master_secret(os.urandom(32)) + key = f"[{k.fingerprint().hex()}/{path}]{k.subkey_for_path(path).hwif()}" + keys.append(key) + + return keys \ No newline at end of file diff --git a/testing/conftest.py b/testing/conftest.py index 32559ae9..c4190287 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -3081,8 +3081,11 @@ def doit(wif_lst, way="sd", sep="\n", early_exit=False): from test_hobble import set_hobble from test_msg import verify_msg_sign_story, sign_msg_from_text, msg_sign_export, sign_msg_from_address from test_multisig import import_ms_wallet, make_multisig, fake_ms_txn -from test_miniscript import offer_minsc_import, get_cc_key, bitcoin_core_signer, import_miniscript, usb_miniscript_get, usb_miniscript_addr, create_core_wallet +from test_miniscript import (offer_minsc_import, get_cc_key, bitcoin_core_signer, import_miniscript, usb_miniscript_get, + usb_miniscript_addr, create_core_wallet, import_duplicate, address_explorer_check, + miniscript_descriptors) from test_multisig import make_ms_address, make_myself_wallet +from test_musig2 import build_musig_wallet from test_notes import need_some_notes, need_some_passwords, goto_notes from test_nfc import try_sign_nfc, ndef_parse_txn_psbt from test_se2 import goto_trick_menu, clear_all_tricks, new_trick_pin, se2_gate, new_pin_confirmed diff --git a/testing/constants.py b/testing/constants.py index b972cdaf..33bf1694 100644 --- a/testing/constants.py +++ b/testing/constants.py @@ -68,4 +68,6 @@ SIGHASH_MAP_NON_TAPROOT = {k:v for k, v in SIGHASH_MAP.items() if k != "DEFAULT"} # (2**31) - 1 --> max unhardened, but we handle hardened via h elsewhere -MAX_BIP32_IDX = 2147483647 \ No newline at end of file +MAX_BIP32_IDX = 2147483647 + +BIP_341_H = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" \ No newline at end of file diff --git a/testing/devtest/unit_bip32.py b/testing/devtest/unit_bip32.py index 7ab29575..1a04e371 100644 --- a/testing/devtest/unit_bip32.py +++ b/testing/devtest/unit_bip32.py @@ -2,7 +2,7 @@ # # Invalid Extended Keys test # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-5 -from desc_utils import Key +from desc_utils import ExtendedKey from seed import xprv_to_encoded_secret from glob import settings @@ -32,7 +32,7 @@ settings.set('chain', "BTC") for i, ek in enumerate(TO_CHECK_PUB): try: - Key.from_string(ek).validate(None) + ExtendedKey.from_string(ek).validate(None) raise RuntimeError except (AssertionError, ValueError) as e: print("exc", e) diff --git a/testing/devtest/unit_bip388.py b/testing/devtest/unit_bip388.py index 64014632..4b1728b3 100644 --- a/testing/devtest/unit_bip388.py +++ b/testing/devtest/unit_bip388.py @@ -52,13 +52,13 @@ "xpub6GjFUVVYewLj5no5uoNKCWuyWhQ1rKGvV8DgXBG9Uc6DvAKxt2dhrj1EZFrTNB5qxAoBkVW3wF8uCS3q1ri9fueAa6y7heFTcf27Q4gyeh6"], "tr([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa/<0;1>/*,{sortedmulti_a(1,[6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa/<2;3>/*,xpub6Fc2TRaCWNgfT49nRGG2G78d1dPnjhW66gEXi7oYZML7qEFN8e21b2DLDipTZZnfV6V7ivrMkvh4VbnHY2ChHTS9qM3XVLJiAgcfagYQk6K/<0;1>/*),or_b(pk(xpub6GxHB9kRdFfTqYka8tgtX9Gh3Td3A9XS8uakUGVcJ9NGZ1uLrGZrRVr67DjpMNCHprZmVmceFTY4X4wWfksy8nVwPiNvzJ5pjLxzPtpnfEM/<0;1>/*),s:pk(xpub6GjFUVVYewLj5no5uoNKCWuyWhQ1rKGvV8DgXBG9Uc6DvAKxt2dhrj1EZFrTNB5qxAoBkVW3wF8uCS3q1ri9fueAa6y7heFTcf27Q4gyeh6/<0;1>/*))})", ), - # ( - # "tr(musig(@0,@1,@2)/**,{and_v(v:pk(musig(@0,@1)/**),older(12960)),{and_v(v:pk(musig(@0,@2)/**),older(12960)),and_v(v:pk(musig(@1,@2)/**),older(12960))}})", - # ["[6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa", - # "[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js", - # "[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2"], - # "tr(musig([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa,[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js,[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2)/<0;1>/*,{and_v(v:pk(musig([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa,[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js)/<0;1>/*),older(12960)),{and_v(v:pk(musig([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa,[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2)/<0;1>/*),older(12960)),and_v(v:pk(musig([b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js,[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2)/<0;1>/*),older(12960))}})", - # ), + ( + "tr(musig(@0,@1,@2)/**,{and_v(v:pk(musig(@0,@1)/**),older(12960)),{and_v(v:pk(musig(@0,@2)/**),older(12960)),and_v(v:pk(musig(@1,@2)/**),older(12960))}})", + ["[6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa", + "[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js", + "[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2"], + "tr(musig([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa,[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js,[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2)/<0;1>/*,{and_v(v:pk(musig([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa,[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js)/<0;1>/*),older(12960)),{and_v(v:pk(musig([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa,[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2)/<0;1>/*),older(12960)),and_v(v:pk(musig([b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js,[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2)/<0;1>/*),older(12960))}})", + ), ] invalid = [ @@ -134,6 +134,9 @@ assert pol == policy, "\n" + pol + "\n" + policy assert keys_info == keys_info + dd = MiniScriptWallet._from_bip388_wallet_policy(pol, ki, validate=False) + assert dd.to_string() == d.to_string() + # invalid vectors for err, policy, keys_info in invalid: glob.DESC_CACHE = {} diff --git a/testing/devtest/unit_musig.py b/testing/devtest/unit_musig.py new file mode 100644 index 00000000..f4eaa2a1 --- /dev/null +++ b/testing/devtest/unit_musig.py @@ -0,0 +1,57 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +import chains, ngu +from glob import settings +from ubinascii import hexlify as b2a_hex +from ubinascii import unhexlify as a2b_hex +from desc_utils import musig_synthetic_node +from descriptor import Descriptor + +settings.set("chain", "BTC") +chain = chains.get_chain("BTC") + +# BIP-328 test vectors https://github.com/bitcoin/bips/blob/master/bip-0328.mediawiki +bip_328_test_vectors = [ + [ + ["03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9", + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9"], + "0354240c76b8f2999143301a99c7f721ee57eee0bce401df3afeaa9ae218c70f23", + "xpub661MyMwAqRbcFt6tk3uaczE1y6EvM1TqXvawXcYmFEWijEM4PDBnuCXwwXEKGEouzXE6QLLRxjatMcLLzJ5LV5Nib1BN7vJg6yp45yHHRbm", + + ], + [ + ["02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "03DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66"], + "0290539eede565f5d054f32cc0c220126889ed1e5d193baf15aef344fe59d4610c", + "xpub661MyMwAqRbcFt6tk3uaczE1y6EvM1TqXvawXcYmFEWijEM4PDBnuCXwwVk5TFJk8Tw5WAdV3DhrGfbFA216sE9BsQQiSFTdudkETnKdg8k" + ], + [ + ["02DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "023590A94E768F8E1815C2F24B4D80A8E3149316C3518CE7B7AD338368D038CA66", + "02F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "03935F972DA013F80AE011890FA89B67A27B7BE6CCB24D3274D18B2D4067F261A9"], + "022479f134cdb266141dab1a023cbba30a870f8995b95a91fc8464e56a7d41f8ea", + "xpub661MyMwAqRbcFt6tk3uaczE1y6EvM1TqXvawXcYmFEWijEM4PDBnuCXwwUvaZYpysLX4wN59tjwU5pBuDjNrPEJbfxjLwn7ruzbXTcUTHkZ" + ], +] + +for musig_keys, aggregate_key, synthetic_xpub in bip_328_test_vectors: + keyagg_cache = ngu.secp256k1.MusigKeyAggCache() + keys = [] + for k in musig_keys: + keys.append(a2b_hex(k)) + + secp_keys = [] + for k in keys: + secp_keys.append(ngu.secp256k1.pubkey(k)) + + # aggregate without sorting (last arg False) + ngu.secp256k1.musig_pubkey_agg(secp_keys, keyagg_cache, False) + agg_pubkey = keyagg_cache.agg_pubkey().to_bytes() + agg_pubkey_target = a2b_hex(aggregate_key) + assert agg_pubkey == agg_pubkey_target + node = musig_synthetic_node(agg_pubkey) + assert chain.serialize_public(node) == synthetic_xpub + +# EOF \ No newline at end of file diff --git a/testing/helpers.py b/testing/helpers.py index 578fb34b..f91652f3 100644 --- a/testing/helpers.py +++ b/testing/helpers.py @@ -236,4 +236,31 @@ def bitcoind_addr_fmt(script_type): return addr_type + +def generate_binary_tree_template(num_scripts, strategy="balanced"): + assert num_scripts >= 1 + assert strategy in {"balanced", "left_heavy", "right_heavy"} + + def build(n: int) -> str: + if n == 1: + return '%s' + + left_sizes = list(range(1, n)) + if strategy == "left_heavy": + left_sizes.sort(reverse=True) + elif strategy == "right_heavy": + left_sizes.sort() + elif strategy == "balanced": + left_sizes.sort(key=lambda l: (abs(2 * l - n), -l)) + + for left_size in left_sizes: + right_size = n - left_size + if strategy == "balanced" and left_size > right_size: + continue + return f'{{{build(left_size)},{build(right_size)}}}' + + raise RuntimeError("Unreachable") + + return build(num_scripts) + # EOF diff --git a/testing/psbt.py b/testing/psbt.py index d32c961e..b553d15f 100644 --- a/testing/psbt.py +++ b/testing/psbt.py @@ -14,58 +14,63 @@ # BIP-174 aka PSBT defined values # # GLOBAL === -PSBT_GLOBAL_UNSIGNED_TX = 0x00 -PSBT_GLOBAL_XPUB = 0x01 -PSBT_GLOBAL_VERSION = 0xfb -PSBT_GLOBAL_PROPRIETARY = 0xfc +PSBT_GLOBAL_UNSIGNED_TX = 0x00 +PSBT_GLOBAL_XPUB = 0x01 +PSBT_GLOBAL_VERSION = 0xfb +PSBT_GLOBAL_PROPRIETARY = 0xfc # BIP-370 -PSBT_GLOBAL_TX_VERSION = 0x02 -PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03 -PSBT_GLOBAL_INPUT_COUNT = 0x04 -PSBT_GLOBAL_OUTPUT_COUNT = 0x05 -PSBT_GLOBAL_TX_MODIFIABLE = 0x06 +PSBT_GLOBAL_TX_VERSION = 0x02 +PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03 +PSBT_GLOBAL_INPUT_COUNT = 0x04 +PSBT_GLOBAL_OUTPUT_COUNT = 0x05 +PSBT_GLOBAL_TX_MODIFIABLE = 0x06 # INPUTS === -PSBT_IN_NON_WITNESS_UTXO = 0x00 -PSBT_IN_WITNESS_UTXO = 0x01 -PSBT_IN_PARTIAL_SIG = 0x02 -PSBT_IN_SIGHASH_TYPE = 0x03 -PSBT_IN_REDEEM_SCRIPT = 0x04 -PSBT_IN_WITNESS_SCRIPT = 0x05 -PSBT_IN_BIP32_DERIVATION = 0x06 -PSBT_IN_FINAL_SCRIPTSIG = 0x07 -PSBT_IN_FINAL_SCRIPTWITNESS = 0x08 -PSBT_IN_POR_COMMITMENT = 0x09 # Proof of Reserves -PSBT_IN_RIPEMD160 = 0x0a -PSBT_IN_SHA256 = 0x0b -PSBT_IN_HASH160 = 0x0c -PSBT_IN_HASH256 = 0x0d +PSBT_IN_NON_WITNESS_UTXO = 0x00 +PSBT_IN_WITNESS_UTXO = 0x01 +PSBT_IN_PARTIAL_SIG = 0x02 +PSBT_IN_SIGHASH_TYPE = 0x03 +PSBT_IN_REDEEM_SCRIPT = 0x04 +PSBT_IN_WITNESS_SCRIPT = 0x05 +PSBT_IN_BIP32_DERIVATION = 0x06 +PSBT_IN_FINAL_SCRIPTSIG = 0x07 +PSBT_IN_FINAL_SCRIPTWITNESS = 0x08 +PSBT_IN_POR_COMMITMENT = 0x09 # Proof of Reserves +PSBT_IN_RIPEMD160 = 0x0a +PSBT_IN_SHA256 = 0x0b +PSBT_IN_HASH160 = 0x0c +PSBT_IN_HASH256 = 0x0d # BIP-370 -PSBT_IN_PREVIOUS_TXID = 0x0e -PSBT_IN_OUTPUT_INDEX = 0x0f -PSBT_IN_SEQUENCE = 0x10 -PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11 -PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12 +PSBT_IN_PREVIOUS_TXID = 0x0e +PSBT_IN_OUTPUT_INDEX = 0x0f +PSBT_IN_SEQUENCE = 0x10 +PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11 +PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12 # BIP-371 -PSBT_IN_TAP_KEY_SIG = 0x13 -PSBT_IN_TAP_SCRIPT_SIG = 0x14 -PSBT_IN_TAP_LEAF_SCRIPT = 0x15 -PSBT_IN_TAP_BIP32_DERIVATION = 0x16 -PSBT_IN_TAP_INTERNAL_KEY = 0x17 -PSBT_IN_TAP_MERKLE_ROOT = 0x18 +PSBT_IN_TAP_KEY_SIG = 0x13 +PSBT_IN_TAP_SCRIPT_SIG = 0x14 +PSBT_IN_TAP_LEAF_SCRIPT = 0x15 +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 # OUTPUTS === -PSBT_OUT_REDEEM_SCRIPT = 0x00 -PSBT_OUT_WITNESS_SCRIPT = 0x01 -PSBT_OUT_BIP32_DERIVATION = 0x02 +PSBT_OUT_REDEEM_SCRIPT = 0x00 +PSBT_OUT_WITNESS_SCRIPT = 0x01 +PSBT_OUT_BIP32_DERIVATION = 0x02 # BIP-370 -PSBT_OUT_AMOUNT = 0x03 -PSBT_OUT_SCRIPT = 0x04 +PSBT_OUT_AMOUNT = 0x03 +PSBT_OUT_SCRIPT = 0x04 # BIP-371 -PSBT_OUT_TAP_INTERNAL_KEY = 0x05 -PSBT_OUT_TAP_TREE = 0x06 -PSBT_OUT_TAP_BIP32_DERIVATION = 0x07 +PSBT_OUT_TAP_INTERNAL_KEY = 0x05 +PSBT_OUT_TAP_TREE = 0x06 +PSBT_OUT_TAP_BIP32_DERIVATION = 0x07 +PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS = 0x08 PSBT_PROP_CK_ID = b"COINKITE" @@ -133,6 +138,9 @@ def defaults(self): self.sequence = None # v2 self.req_time_locktime = None # v2 self.req_height_locktime = None # v2 + self.musig_pubkeys = {} + self.musig_pubnonces = {} + self.musig_part_sigs = {} self.others = {} self.unknown = {} @@ -160,6 +168,9 @@ def __eq__(a, b): a.sequence == b.sequence and \ a.req_time_locktime == b.req_time_locktime and \ a.req_height_locktime == b.req_height_locktime and \ + a.musig_pubkeys == b.musig_pubkeys and \ + a.musig_pubnonces == b.musig_pubnonces and \ + a.musig_part_sigs == b.musig_part_sigs and \ a.unknown == b.unknown if rv: # NOTE: equality test on signatures requires parsing DER stupidness @@ -225,6 +236,30 @@ def parse_kv(self, kt, key, val): self.taproot_scripts[leaf_script].add(key) elif kt == PSBT_IN_TAP_MERKLE_ROOT: self.taproot_merkle_root = val + elif kt == PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS: + assert len(key) == 33 # compressed aggregate pubkey length + assert len(val) % 33 == 0 # list of compressed participant pubkeys + assert key not in self.musig_pubkeys + pk_list = [] + for i in range(0, len(val), 33): + pk_list.append(val[i:i + 33]) + self.musig_pubkeys[key] = pk_list + elif kt == PSBT_IN_MUSIG2_PUB_NONCE: + assert len(key) in (66, 98) # participant pubkey (33) + aggregate pubkey (33) + (optional) tapleaf hash (32) + assert len(val) == 66 # serialized pubnonce + assert key not in self.musig_pubnonces + participant_key = key[:33] + aggregate_key = key[33:66] + tapleaf_h = key[66:] + self.musig_pubnonces[(participant_key, aggregate_key, tapleaf_h)] = val + elif kt == PSBT_IN_MUSIG2_PARTIAL_SIG: + assert len(key) in (66, 98) # participant pubkey (33) + aggregate pubkey (33) + (optional) tapleaf hash (32) + assert len(val) == 32 # serialized musig partial signature + assert key not in self.musig_part_sigs + participant_key = key[:33] + aggregate_key = key[33:66] + tapleaf_h = key[66:] + self.musig_part_sigs[(participant_key, aggregate_key, tapleaf_h)] = val else: self.unknown[bytes([kt]) + key] = val @@ -280,6 +315,18 @@ def serialize_kvs(self, wr, v2): if self.req_height_locktime is not None: wr(PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, struct.pack(" helper function to replace keys with letters press_select() - wo = create_core_wallet(name, addr_fmt, "sd", True) + wo = create_core_wallet(name, addr_fmt, "sd", num_ins) unspent = wo.listunspent() - assert len(unspent) == 1 + assert len(unspent) == num_ins - if num_ins == 20: - all_of_it = wo.getbalance() - num_outs = 20 - nVal = all_of_it / num_outs - conso_addrs = [{wo.getnewaddress("", addr_fmt): nVal} for _ in range(num_outs)] # self-spend - else: - conso_addrs = [{bitcoind.supply_wallet.getnewaddress(): 2.333}] + all_of_it = wo.getbalance() + to_send = [{bitcoind.supply_wallet.getnewaddress(): all_of_it}] - psbt_resp = wo.walletcreatefundedpsbt([], conso_addrs, 0, {"fee_rate": 2, - "change_type": addr_fmt, - "subtractFeeFromOutputs": [0]}) + psbt_resp = wo.walletcreatefundedpsbt([], to_send, 0, {"fee_rate": 2, + "change_type": addr_fmt, + "subtractFeeFromOutputs": [0]}) psbt = psbt_resp.get("psbt") - if num_ins == 20: - if has_c: - psbt_res = signers[1].walletprocesspsbt(psbt, True, - "DEFAULT" if addr_fmt == "bech32m" else "ALL") - psbt = psbt_res.get("psbt") - - start_sign(base64.b64decode(psbt)) - res = end_sign(accept=True) - - res = wo.finalizepsbt(base64.b64encode(res).decode()) - assert res["complete"] - tx_hex = res["hex"] - res = wo.testmempoolaccept([tx_hex]) - assert res[0]["allowed"] - res = wo.sendrawtransaction(tx_hex) - assert len(res) == 64 # tx id - bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) - unspent = wo.listunspent() - assert len(unspent) == 20 - dest = [{bitcoind.supply_wallet.getnewaddress(): wo.getbalance()}] - psbt_resp = wo.walletcreatefundedpsbt([], dest, 0, {"fee_rate": 2, "change_type": addr_fmt, - "subtractFeeFromOutputs": [0]}) - psbt = psbt_resp.get("psbt") por_psbt, _ = bip322_from_classic_tx(psbt.encode()) # this is miniscript that we cannot finalize @@ -755,4 +727,76 @@ def test_miniscript_bip322_por(minisc, clear_miniscript, cap_story, microsd_path else: assert len(i.part_sigs) == 1 + +@pytest.mark.parametrize("num_ins", [1, 10]) +@pytest.mark.parametrize("tapscript", [True, False]) +def test_musig_bip322_por(num_ins, tapscript, bitcoind, use_regtest, clear_miniscript, cap_story, + build_musig_wallet, bip322_from_classic_tx, start_sign, end_sign, + verify_msg_bip322_por): + use_regtest() + clear_miniscript() + + name = "b322_musig" + wo, signers, desc = build_musig_wallet(name, 3, tapscript=tapscript, num_utxo_available=num_ins, + tapscript_musig_threshold=2) + + unspent = wo.listunspent() + assert len(unspent) == num_ins + + all_of_it = wo.getbalance() + to_send = [{bitcoind.supply_wallet.getnewaddress(): all_of_it}] + + psbt_resp = wo.walletcreatefundedpsbt([], to_send, 0, {"fee_rate": 2, + "change_type": "bech32m", + "subtractFeeFromOutputs": [0]}) + psbt = psbt_resp.get("psbt") + + por_psbt, _ = bip322_from_classic_tx(psbt.encode()) + start_sign(por_psbt) + verify_msg_bip322_por("POR", way="sd") + time.sleep(.1) + title, story = cap_story() + assert "Proof of Reserves" in story + assert "warning" not in story + assert f"{num_ins} input{'s' if num_ins > 1 else ''}" in story + assert "1 output" in story + assert "- OP_RETURN -" in story + assert "null-data" in story + res = end_sign(accept=True) + po = BasicPSBT().parse(res) + for i in po.inputs: + assert len(i.musig_pubnonces) == (3 if tapscript else 1) + + b64_res_psbt = po.as_b64_str() + for s in signers: + psbt_resp = s.walletprocesspsbt(b64_res_psbt, True, "DEFAULT", True, False) + b64_res_psbt = psbt_resp.get("psbt") + + po = BasicPSBT().parse(base64.b64decode(b64_res_psbt)) + for i in po.inputs: + assert len(i.musig_pubnonces) == (9 if tapscript else 3) + + # cosigners adding signatures - seems core is unable to add both nonce and signature in one iteration + for s in signers[int(tapscript):]: + psbt_resp = s.walletprocesspsbt(b64_res_psbt, True, "DEFAULT", True, False) + b64_res_psbt = psbt_resp.get("psbt") + + po = BasicPSBT().parse(base64.b64decode(b64_res_psbt)) + for i in po.inputs: + assert len(i.musig_part_sigs) == (3 if tapscript else 2) + + # core fixed utxo for input0 - rework + por_psbt, _ = bip322_from_classic_tx(po.as_bytes()) + start_sign(por_psbt, finalize=not tapscript) + verify_msg_bip322_por("POR", way="sd") + time.sleep(.1) + title, story = cap_story() + assert "Proof of Reserves" in story + assert "warning" not in story + assert f"{num_ins} input{'s' if num_ins > 1 else ''}" in story + assert "1 output" in story + assert "- OP_RETURN -" in story + assert "null-data" in story + end_sign(accept=True, finalize=not tapscript) + # EOF diff --git a/testing/test_miniscript.py b/testing/test_miniscript.py index f6bcdc41..4b0d8baa 100644 --- a/testing/test_miniscript.py +++ b/testing/test_miniscript.py @@ -2,40 +2,15 @@ # # Miniscript-related tests. # -import pytest, json, time, itertools, struct, random, os, base64 +import pytest, json, time, itertools, struct, random, os, base64, re, copy from ckcc.protocol import CCProtocolPacker from constants import AF_P2TR from psbt import BasicPSBT from charcodes import KEY_QR, KEY_RIGHT, KEY_CANCEL, KEY_DELETE from bbqr import split_qrs -from bip32 import BIP32Node -from helpers import str_to_path - - -H = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" # BIP-0341 -TREE = { - 1: '%s', - 2: '{%s,%s}', - 3: random.choice(['{{%s,%s},%s}','{%s,{%s,%s}}']), - 4: '{{%s,%s},{%s,%s}}', - 5: random.choice(['{{%s,%s},{%s,{%s,%s}}}', '{{{%s,%s},%s},{%s,%s}}']), - 6: '{{%s,{%s,%s}},{{%s,%s},%s}}', - 7: '{{%s,{%s,%s}},{%s,{%s,{%s,%s}}}}', - 8: '{{{%s,%s},{%s,%s}},{{%s,%s},{%s,%s}}}', - # more than MAX (4) for test purposes - 9: '{{{%s,{%s,%s}},{%s,%s}},{{%s,%s},{%s,%s}}}', - 10: '{{{{%s,%s},{%s,%s}},{%s,%s}},{{%s,%s},{%s,%s}}}', - 11: '{{{{%s,%s},{%s,%s}},{%s,%s}},{{%s,%s},{%s,{%s,%s}}}}', - 12: '{{{{%s,%s},{%s,%s}},{%s,%s}},{{%s,%s},{{%s,%s},{%s,%s}}}}', -} - - -def ranged_unspendable_internal_key(chain_code=32 * b"\x01", subderiv="/<0;1>/*"): - # provide ranged provably unspendable key in serialized extended key format for core to understand it - # core does NOT understand 'unspend(' - pk = b"\x02" + bytes.fromhex(H) - node = BIP32Node.from_chaincode_pubkey(chain_code, pk) - return node.hwif() + subderiv +from bip32 import BIP32Node, ranged_unspendable_internal_key +from constants import BIP_341_H +from helpers import generate_binary_tree_template, str_to_path @pytest.fixture @@ -305,28 +280,55 @@ def doit(path, subderiv=None): @pytest.fixture def bitcoin_core_signer(bitcoind): - def doit(name="core_signer"): + def doit(name="core_signer", desc_type="pkh(", privkey=False): # core signer + par_c = desc_type.count("(") signer = bitcoind.create_wallet(wallet_name=name, disable_private_keys=False, blank=False, passphrase=None, avoid_reuse=False, descriptors=True) target_desc = "" bitcoind_descriptors = signer.listdescriptors()["descriptors"] for d in bitcoind_descriptors: - if d["desc"].startswith("pkh(") and d["internal"] is False: + if d["desc"].startswith(desc_type) and d["internal"] is False: target_desc = d["desc"] break core_desc, checksum = target_desc.split("#") - core_key = core_desc[4:-1] - return signer, core_key + core_pubkey = core_desc[len(desc_type):-par_c] + + if privkey: + bitcoind_descriptors = signer.listdescriptors(True)["descriptors"] + for d in bitcoind_descriptors: + if d["desc"].startswith(desc_type) and d["internal"] is False: + target_desc = d["desc"] + break + core_desc, checksum = target_desc.split("#") + core_privkey = core_desc[len(desc_type):-par_c] + + return signer, core_pubkey, core_privkey + + return signer, core_pubkey return doit +def find_multipath_derivations(text): + number_pattern = r'(?:0|[1-9]\d*)' + pattern = rf'/<{number_pattern};{number_pattern}>/\*' + matches = re.findall(pattern, text) + return matches + + +def multipath_to_singlepath(multipath): + s = multipath.split(";") + ext_num = s[0].split("<")[-1] + int_num = s[1].split(">")[0] + return f"/{ext_num}/*", f"/{int_num}/*" + + @pytest.fixture def address_explorer_check(goto_home, pick_menu_item, need_keypress, cap_menu, cap_story, miniscript_descriptors, load_export, usb_miniscript_addr, cap_screen_qr, press_select): - def doit(way, addr_fmt, wallet, cc_minsc_name, export_check=True): + def doit(way, addr_fmt, wallet, cc_minsc_name): goto_home() pick_menu_item("Address Explorer") need_keypress('4') # warning @@ -401,26 +403,38 @@ def doit(way, addr_fmt, wallet, cc_minsc_name, export_check=True): time.sleep(1) - if export_check: - desc_export = miniscript_descriptors(cc_minsc_name) + desc_export = miniscript_descriptors(cc_minsc_name) + + def remove_minisc_syntactic_sugar(descriptor, a, b): + # syntactic sugar https://bitcoin.sipa.be/miniscript/ + target_len = len(a) + idx = 0 + while idx != -1: + idx = descriptor.find(a, idx) + if idx == -1: break + # needs colon more identities than just 'c' + rep = f":{b}" if descriptor[idx-1] in "asctdvjnlu" else f"{b}" + descriptor = descriptor[:idx] + rep + descriptor[idx+target_len:] + + return descriptor - def remove_minisc_syntactic_sugar(descriptor, a, b): - # syntactic sugar https://bitcoin.sipa.be/miniscript/ - target_len = len(a) - idx = 0 - while idx != -1: - idx = descriptor.find(a, idx) - if idx == -1: break - # needs colon more identities than just 'c' - rep = f":{b}" if descriptor[idx-1] in "asctdvjnlu" else f"{b}" - descriptor = descriptor[:idx] + rep + descriptor[idx+target_len:] + desc_export = remove_minisc_syntactic_sugar(desc_export, "c:pk_k(", "pk(") + desc_export = remove_minisc_syntactic_sugar(desc_export, "c:pk_h(", "pkh(") - return descriptor + desc_export = desc_export.split("#")[0] # remove checksum - desc_export = remove_minisc_syntactic_sugar(desc_export, "c:pk_k(", "pk(") - desc_export = remove_minisc_syntactic_sugar(desc_export, "c:pk_h(", "pkh(") - # TODO format with and without multipath expression - # assert desc_export.split("#")[0] == external_desc.split("#")[0].replace("'", "h") + multipaths = find_multipath_derivations(desc_export) + + # fake copy + desc_ext_export = str(desc_export) + desc_int_export = str(desc_export) + for mp in multipaths: + ext_path, int_path = multipath_to_singlepath(mp) + desc_int_export = desc_int_export.replace(mp, int_path) + desc_ext_export = desc_ext_export.replace(mp, ext_path) + + assert desc_ext_export == external_desc.split("#")[0].replace("'", "h") + assert desc_int_export == internal_desc.split("#")[0].replace("'", "h") bitcoind_addrs = wallet.deriveaddresses(external_desc, addr_range) bitcoind_addrs_change = wallet.deriveaddresses(internal_desc, addr_range) @@ -459,7 +473,7 @@ def remove_minisc_syntactic_sugar(descriptor, a, b): @pytest.fixture def create_core_wallet(goto_home, pick_menu_item, load_export, bitcoind): - def doit(name, addr_type, way="sd", funded=True): + def doit(name, addr_type, way="sd", funded=1): try: pick_menu_item(name) # pick imported descriptor multisig wallet except: @@ -491,16 +505,19 @@ def doit(name, addr_type, way="sd", funded=True): assert obj["success"] if funded: - addr = ms.getnewaddress("", addr_type) + addrs = [ms.getnewaddress("", addr_type) for _ in range(funded)] if addr_type == "bech32": sw = "bcrt1q" elif addr_type == "bech32m": sw = "bcrt1p" else: sw = "2" - assert addr.startswith(sw) - # get some coins and fund above multisig address - bitcoind.supply_wallet.sendtoaddress(addr, 49) + + for addr in addrs: + assert addr.startswith(sw) + + nVal = 49 / funded + bitcoind.supply_wallet.sendmany("", {a: nVal for a in addrs}) bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above return ms @@ -558,7 +575,7 @@ def doit(M, N, script_type, internal_key=None, cc_account=0, funded=True, else: pytest.skip("Scripts full") - temp = TREE[len(scripts)] + temp = generate_binary_tree_template(len(scripts)) temp = temp % tuple(scripts) desc = desc % temp @@ -1103,7 +1120,7 @@ def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bi use_regtest() clear_miniscript() microsd_wipe() - tmplt = TREE[num_leafs] + tmplt = generate_binary_tree_template(num_leafs) bitcoind_signers_xpubs = [] bitcoind_signers = [] for i in range(num_leafs): @@ -1172,19 +1189,27 @@ def test_tapscript_pk(num_leafs, use_regtest, clear_miniscript, microsd_wipe, bi assert "PSBT Signed" == title assert "Updated PSBT is:" in story press_select() - fname_psbt = story.split("\n\n")[1] - # fname_txn = story.split("\n\n")[3] + split_story = story.split("\n\n") + fname_psbt = split_story[1] fpath_psbt = microsd_path(fname_psbt) with open(fpath_psbt, "r") as f: final_psbt = f.read().strip() garbage_collector.append(fpath_psbt) - # with open(microsd_path(fname_txn), "r") as f: - # final_txn = f.read().strip() + + if internal_key_spendable: + fname_txn = split_story[3] + fpath_txn = microsd_path(fname_txn) + with open(fpath_txn, "r") as f: + final_txn = f.read().strip() + + garbage_collector.append(fpath_txn) + res = ts.finalizepsbt(final_psbt) assert res["complete"] tx_hex = res["hex"] - # assert tx_hex == final_txn + if internal_key_spendable: + assert tx_hex == final_txn res = ts.testmempoolaccept([tx_hex]) assert res[0]["allowed"] txn_id = bitcoind.supply_wallet.sendrawtransaction(tx_hex) @@ -1235,7 +1260,7 @@ def test_duplicate_tapscript_leaves(use_regtest, clear_miniscript, microsd_wipe, cc_key = get_cc_key("86h/0h/100h") cc_leaf = f"pk({cc_key})" - tmplt = TREE[2] + tmplt = generate_binary_tree_template(2) tmplt = tmplt % (cc_leaf, cc_leaf) desc = f"tr({core_key},{tmplt})" fname = "dup_leafs.txt" @@ -1559,7 +1584,7 @@ def test_tapscript_depth(get_cc_key, pick_menu_item, cap_story, k = get_cc_key(f"84h/0h/{i}h") scripts.append(f"pk({k})") - tree = TREE[leaf_num] % tuple(scripts) + tree = generate_binary_tree_template(leaf_num) % tuple(scripts) desc = f"tr({ranged_unspendable_internal_key()},{tree})" fname = "9leafs.txt" fpath = microsd_path(fname) @@ -2888,7 +2913,7 @@ def test_originless_keys(tmplt, offer_minsc_import, get_cc_key, bitcoin_core_sig @pytest.mark.parametrize("internal_key", [ - H, + BIP_341_H, "r=@", "r=dfed64ff493dca2ab09eadefaa0c88be8404908fa6eff869ff71c0d359d086b9", "f19573a10866ee9881769e24464f9a0e989c2cb8e585db385934130462abed90" diff --git a/testing/test_musig2.py b/testing/test_musig2.py new file mode 100644 index 00000000..699cced9 --- /dev/null +++ b/testing/test_musig2.py @@ -0,0 +1,1523 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# MuSig2 tests. +# +import pytest, base64, itertools, time, json, copy, random, os +from txn import BasicPSBT +from constants import SIGHASH_MAP +from bip32 import random_keys, ranged_unspendable_internal_key, BIP32Node +from helpers import generate_binary_tree_template +from mnemonic import Mnemonic + + +def sighash_check(psbt, sighash): + po = BasicPSBT().parse(psbt) + for inp in po.inputs: + if sighash != "DEFAULT": + assert inp.sighash == SIGHASH_MAP[sighash] + else: + assert inp.sighash is None + + +@pytest.fixture +def build_musig_wallet(bitcoin_core_signer, microsd_path, garbage_collector, press_select, get_cc_key, + import_duplicate, create_core_wallet, import_miniscript, offer_minsc_import): + + def doit(wal_name, num_signers, cc_key_orig_der="86h/1h/0h", import_way="usb", + musig_subder=None, tapscript=False, tapscript_musig_threshold=0, wallet_type=0, + tree_design="balanced", num_utxo_available=1): + + # wallet type 0 -> musig with all participant keys in taproot internal, N-1 signer leaves in tapscript + # wallet type 1 -> N-1 across both internal key and tapscript leaves + one fallback sortedmulti + + # derivation not allowed inside musig + core_pubkeys = [] + core_privkeys = [] + signers = [] + + # first signer is CC + cc_key = get_cc_key(cc_key_orig_der).replace("/<0;1>/*", "") + + for i in range(num_signers-1): + # core signers + signer, core_pk, core_sk = bitcoin_core_signer(f"{wal_name}_cosigner{i}", privkey=True) + signer.keypoolrefill(25) + core_pubkeys.append(core_pk.replace("/0/*", "")) + core_privkeys.append(core_sk.replace("/0/*", "")) + signers.append(signer) + + all_pks = [cc_key] + core_pubkeys + if isinstance(tapscript, str): + # custom descriptor - fill keys + desc = tapscript.replace("$H", ranged_unspendable_internal_key()) + for i in range(len(all_pks) -1, -1, -1): + desc = desc.replace(f"${i}", all_pks[i]) + + else: + random.shuffle(all_pks) + inner = "musig(%s)" % ",".join(all_pks) + if musig_subder: + inner += musig_subder + + if tapscript: + if wallet_type == 0: + scripts = [] + for c in itertools.combinations(all_pks, tapscript_musig_threshold): + msig = f"pk(musig({','.join(c)}){musig_subder or ''})" + scripts.append(msig) + + tmplt = generate_binary_tree_template(len(scripts), strategy=tree_design) + tapscript = tmplt % tuple(scripts) + + inner += "," + inner += tapscript + + elif wallet_type == 1: + scripts = [] + for c in itertools.combinations(all_pks, tapscript_musig_threshold): + msig = f"musig({','.join(c)}){musig_subder or ''}" + scripts.append(msig) + + # internal key is just one of the musigs with N-1 keys + inner = scripts.pop(0) + scripts = [f"pk({sc})" for sc in scripts] + # add fallback sortedmulti classic multisig + scripts.append(f"sortedmulti_a({tapscript_musig_threshold},{','.join(all_pks)})") + # as one musig was removed from scripts & one fallback added, len is correct + tmplt = generate_binary_tree_template(len(scripts), strategy=tree_design) + tapscript = tmplt % tuple(scripts) + + inner += "," + inner += tapscript + + desc = f"tr({inner})" + + if import_way == "usb": + _, story = offer_minsc_import(json.dumps(dict(name=wal_name, desc=desc))) + elif import_way == "sd": + fname = f"{wal_name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + garbage_collector.append(fpath) + _, story = import_miniscript(fname) + else: + raise ValueError # not implemented (yet) + + assert "Create new miniscript wallet?" in story + assert wal_name in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + + wo = create_core_wallet(wal_name, "bech32m", "sd", num_utxo_available) + + desc_lst = [] + for obj in wo.listdescriptors()["descriptors"]: + del obj["next"] + del obj["next_index"] + desc_lst.append(obj) + + # import musig descriptor to signers + # each signer has it's own privkey loaded + for s, spk, ssk in zip(signers, core_pubkeys, core_privkeys): + to_import = copy.deepcopy(desc_lst) + for dobj in to_import: + dobj["desc"] = dobj["desc"].split("#")[0].replace(spk, ssk) + csum = wo.getdescriptorinfo(dobj["desc"])["checksum"] + dobj["desc"] = dobj["desc"] + "#" + csum + + res = s.importdescriptors(to_import) + for o in res: + assert o["success"] + + # return watch only wallet with musig imported + # & core signers with musig wallet imported + # & descriptor that was imported into CC and watch onyl wallet + return wo, signers, desc + + return doit + + +@pytest.fixture +def musig_signing(start_sign, end_sign, microsd_path, garbage_collector, cap_story, goto_home, + pick_menu_item, press_select, bitcoind, need_keypress, press_cancel): + + def doit(wallet_name, watch_only, core_signers, coldcard_first, signers_start, signers_end, + finalized, split_to=10, sequence=None, locktime=0, cc_first_no_sigs_added=True): + + all_of_it = watch_only.getbalance() + unspent = watch_only.listunspent() + assert len(unspent) == 1 + + if sequence: + inp = [{"txid": unspent[0]["txid"], "vout": unspent[0]["vout"], "sequence": sequence}] + else: + inp = [] # auto-selection + + # split to + nVal = all_of_it / split_to + conso_addrs = [{watch_only.getnewaddress("", "bech32m"): nVal} for _ in range(split_to)] # self-spend + psbt_resp = watch_only.walletcreatefundedpsbt( + inp, + conso_addrs, + locktime, + {"fee_rate": 2, "change_type": "bech32m", "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + if not coldcard_first: + # cosigners adding nonces + for s in core_signers: + psbt_resp = s.walletprocesspsbt(psbt, True, "DEFAULT", True, False) + psbt = psbt_resp.get("psbt") + + # CC add nonce + # even if all nonces from co-signers are already present we do not add signatures + # 1st & 2nd round are strictly separated + # if CC adds nonce, no signatures are added and vice-versa + start_sign(base64.b64decode(psbt)) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {wallet_name}" in story + need_keypress("2") + pick_menu_item("Inputs") + title, story = cap_story() + assert "MuSig2" in story + press_cancel() + press_cancel() + res_psbt = end_sign(exit_export_loop=False) + time.sleep(.1) + title, story = cap_story() + if not cc_first_no_sigs_added: + assert "PSBT Signed" == title + else: + assert "PSBT Updated" == title + + press_cancel() # exit export loop + + b64_res_psbt = base64.b64encode(res_psbt).decode() + + if coldcard_first: + # if cc was first to add pubnonce - now core cosigners will add + for s in core_signers: + psbt_resp = s.walletprocesspsbt(b64_res_psbt, True, "DEFAULT", True, False) + b64_res_psbt = psbt_resp.get("psbt") + + # cosigners adding signatures - core also strictly separates 1st & 2nd round (cannot add both nonce and sigs in one sitting) + for s in core_signers[signers_start: signers_end]: + psbt_resp = s.walletprocesspsbt(b64_res_psbt, True, "DEFAULT", True, False) + b64_res_psbt = psbt_resp.get("psbt") + + final_txn = None + cc_txid = None + # now CC adds signatures + # go via SD, as we want to see both PSBT and finalized tx + fname = f"{wallet_name}.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(b64_res_psbt) + + garbage_collector.append(fpath) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + split_story = story.split("\n\n") + fname_psbt = split_story[1] + + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + b64_res_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + + if finalized: + fname_txn = split_story[3] + cc_txid = split_story[4].split("\n")[-1] + fpath_txn = microsd_path(fname_txn) + with open(fpath_txn, "r") as f: + final_txn = f.read().strip() + garbage_collector.append(fpath_txn) + + res = watch_only.finalizepsbt(b64_res_psbt) + assert res["complete"] + tx_hex = res["hex"] + if finalized: + assert tx_hex == final_txn + + if (sequence or locktime) and not finalized: + # we are signing for timelocked tapscript + res = watch_only.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] is False + assert res[0]['reject-reason'] == 'non-BIP68-final' if sequence else "non-final" + if sequence: + bitcoind.supply_wallet.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) + else: + block_h = watch_only.getblockchaininfo()["blocks"] + bitcoind.supply_wallet.generatetoaddress(locktime - block_h, bitcoind.supply_wallet.getnewaddress()) + + + res = watch_only.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = watch_only.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + if finalized: + assert res == cc_txid + + # + # now consolidate multiple inputs to send out + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + all_of_it = watch_only.getbalance() + unspent = watch_only.listunspent() + assert len(unspent) == split_to + + ins = [{"txid": u["txid"], "vout": u["vout"]} for u in unspent] + if sequence: + for i in ins: + i["sequence"] = sequence + + psbt_resp = watch_only.walletcreatefundedpsbt( + ins, + [{bitcoind.supply_wallet.getnewaddress(): all_of_it - 2}], # slightly less so we still have some change + locktime, + {"fee_rate": 2, "change_type": "bech32m"}, + ) + psbt1 = psbt_resp.get("psbt") + + if not coldcard_first: + # cosigners adding nonces + for s in core_signers: + psbt_resp = s.walletprocesspsbt(psbt1, True, "DEFAULT", True, False) + psbt1 = psbt_resp.get("psbt") + + # CC adds nonces + start_sign(base64.b64decode(psbt1)) + title, story = cap_story() + assert "Consolidating" not in story + assert "Change back:" in story # has one change address + assert f"Wallet: {wallet_name}" in story + res_psbt1 = end_sign(exit_export_loop=False) + time.sleep(.1) + title, story = cap_story() + if not cc_first_no_sigs_added: + assert "PSBT Signed" == title + else: + assert "PSBT Updated" == title + + press_cancel() # exit export loop + + b64_res_psbt1 = base64.b64encode(res_psbt1).decode() + + if coldcard_first: + # if cc was first to add pubnonce - now core cosigners will add nonces + for s in core_signers: + psbt_resp = s.walletprocesspsbt(b64_res_psbt1, True, "DEFAULT", True, False) + b64_res_psbt1 = psbt_resp.get("psbt") + + # cosigners adding signatures + for s in core_signers[signers_start: signers_end]: + psbt_resp = s.walletprocesspsbt(b64_res_psbt1, True, "DEFAULT", True, False) + b64_res_psbt1 = psbt_resp.get("psbt") + + final_txn1 = None + cc_txid1 = None + # CC adds signatures + # go via SD, as we want to see both PSBT and finalized tx + fname = f"{wallet_name}.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(b64_res_psbt1) + + garbage_collector.append(fpath) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" not in story + assert "Change back:" in story # has one change address + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + split_story = story.split("\n\n") + fname_psbt = split_story[1] + + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + b64_res_psbt1 = f.read().strip() + garbage_collector.append(fpath_psbt) + + if finalized: + fname_txn = split_story[3] + cc_txid1 = split_story[4].split("\n")[-1] + fpath_txn = microsd_path(fname_txn) + with open(fpath_txn, "r") as f: + final_txn1 = f.read().strip() + garbage_collector.append(fpath_txn) + + res1 = watch_only.finalizepsbt(b64_res_psbt1) + assert res1["complete"] + tx_hex1 = res1["hex"] + if coldcard_first and finalized: + assert tx_hex1 == final_txn1 + + if sequence and not finalized: + # we are signing for timelocked tapscript + res = watch_only.testmempoolaccept([tx_hex1]) + assert res[0]["allowed"] is False + assert res[0]['reject-reason'] == 'non-BIP68-final' + bitcoind.supply_wallet.generatetoaddress(sequence, bitcoind.supply_wallet.getnewaddress()) + + res = watch_only.testmempoolaccept([tx_hex1]) + assert res[0]["allowed"] + res = watch_only.sendrawtransaction(tx_hex1) + assert len(res) == 64 # tx id + if coldcard_first and finalized: + assert res == cc_txid1 + + return doit + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("tapscript", [1, False, 2, 3]) +@pytest.mark.parametrize("ts_level", [0, 1]) +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("originless", [True, False]) # co-signer keys do not include origin derivation +def test_musig(tapscript, ts_level, cc_first, clear_miniscript, microsd_path, use_regtest, + address_explorer_check, get_cc_key, import_miniscript, bitcoin_core_signer, + import_duplicate, press_select, create_core_wallet, garbage_collector, + musig_signing, originless): + + use_regtest() + + # derivation not allowed inside musig + core_pubkeys = [] + core_privkeys = [] + signers = [] + for i in range(2): + # core signers + signer, core_pk, core_sk = bitcoin_core_signer(f"musig-co-signer{i}", privkey=True) + signer.keypoolrefill(25) + core_pk = core_pk.replace("/0/*", "") + if originless: + core_pk = core_pk.split("]")[-1] + core_sk = core_sk.replace("/0/*", "") + core_pubkeys.append(core_pk) + core_privkeys.append(core_sk) + signers.append(signer) + + cc_key = get_cc_key("86h/1h/0h").replace("/<0;1>/*", "") + + inner = "musig(%s)/<2;3>/*" % ",".join([cc_key] + core_pubkeys) + + sequence = None + cc_first_no_sigs_added = True + if tapscript: + if tapscript == 1: + # musig in tapscript + s0 = f"pk(musig({cc_key},{core_pubkeys[1]})/<2;3>/*)" + s1 = f"pk(musig({cc_key},{core_pubkeys[0]})/<2;3>/*)" + s2 = f"pk(musig({core_pubkeys[0]},{core_pubkeys[1]})/<2;3>/*)" + elif tapscript == 2: + # classic multisig in tapscript + s0 = f"sortedmulti_a(2,{cc_key}/<2;3>/*,{core_pubkeys[1]}/<2;3>/*)" + s1 = f"sortedmulti_a(2,{cc_key}/<2;3>/*,{core_pubkeys[0]}/<2;3>/*)" + s2 = f"sortedmulti_a(2,{core_pubkeys[0]}/<2;3>/*,{core_pubkeys[1]}/<2;3>/*)" + # we will add signatures for classic multisig leafs, so title will be PSBT Signed (not PSBT Updated) + cc_first_no_sigs_added = False + elif tapscript == 3: + # time-locked musig in tapscript + sequence = 10 + s0 = f"and_v(v:pk(musig({cc_key},{core_pubkeys[1]})/<2;3>/*),older(10))" + s1 = f"and_v(v:pk(musig({cc_key},{core_pubkeys[0]})/<2;3>/*),older(10))" + s2 = f"and_v(v:pk(musig({core_pubkeys[0]},{core_pubkeys[1]})/<2;3>/*),older(10))" + else: + raise NotImplementedError + + inner += "," + + # in tapscript we're always signing only with signer[1] + # only one core signer to not satisfy musig in internal key & actually test tapscript + # ts_level decides whether "signable leaf" is at depth 0 or 1 + if ts_level: + # signable tapscript leaf (s0) at level 1 + inner += "{%s,{%s,%s}}" % (s2,s1,s0) + else: + # signable tapscript leaf (s0) at level 0 + inner += "{%s,{%s,%s}}" % (s0,s1,s2) + + desc = f"tr({inner})" + + clear_miniscript() + name = "musig" + fname = f"{name}.txt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(desc) + + garbage_collector.append(fpath) + _, story = import_miniscript(fname) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + import_duplicate(fname) + + wo = create_core_wallet(name, "bech32m", "sd", True) + + desc_lst = [] + for obj in wo.listdescriptors()["descriptors"]: + del obj["next"] + del obj["next_index"] + desc_lst.append(obj) + + # import musig descriptor to signers + # each signer has it's own privkey loaded + for s, spk, ssk in zip(signers, core_pubkeys, core_privkeys): + to_import = copy.deepcopy(desc_lst) + for dobj in to_import: + dobj["desc"] = dobj["desc"].split("#")[0].replace(spk, ssk) + csum = wo.getdescriptorinfo(dobj["desc"])["checksum"] + dobj["desc"] = dobj["desc"] + "#" + csum + + res = s.importdescriptors(to_import) + for o in res: + assert o["success"] + + if tapscript: + # sign with just one core signer + CC + # all the leafs have 2of2, only internal key has 3of3, so enough to produce finalizable tx + _from, _to = 1, 2 + else: + # signing with internal key - needs all signatures + _from, _to = 0, 2 + + musig_signing(name, wo, signers, cc_first, _from, _to, finalized=not tapscript, split_to=10, + sequence=sequence, cc_first_no_sigs_added=cc_first_no_sigs_added) + + # check addresses are correct + address_explorer_check("sd", "bech32m", wo, name) + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("N_K", [(5,3),(6,4), (10,9)]) +@pytest.mark.parametrize("tapscript", [True, False]) +@pytest.mark.parametrize("cc_first", [True, False]) +def test_musig_big(N_K, cc_first, tapscript, clear_miniscript, use_regtest, address_explorer_check, + build_musig_wallet, musig_signing): + + num_signers, threshold = N_K + use_regtest() + clear_miniscript() + + # how many signers need to sing in different situations + # if only internal key musig, all must sign so from will be 0 and to len(signers) + if tapscript: + _from, _to = 1, threshold + else: + _from, _to = 0, num_signers + + name = "big_musig" + wo, signers, desc = build_musig_wallet(name, num_signers, tapscript=tapscript, + tree_design=random.choice(["left_heavy", "right_heavy"]), # not balanced tree + tapscript_musig_threshold=threshold) + + musig_signing(name, wo, signers, cc_first, _from, _to, finalized=not tapscript, split_to=20) + + # check addresses are correct + address_explorer_check("sd", "bech32m", wo, name) + + +@pytest.mark.bitcoind +@pytest.mark.parametrize("N_K", [(3,2),(4,3)]) +@pytest.mark.parametrize("cc_first", [True, False]) +def test_musig_alt(N_K, cc_first, clear_miniscript, use_regtest, address_explorer_check, + build_musig_wallet, musig_signing): + + num_signers, threshold = N_K + use_regtest() + clear_miniscript() + + name = "alt_musig" + wo, signers, desc = build_musig_wallet(name, num_signers, tapscript=True, wallet_type=1, + tapscript_musig_threshold=threshold) + + # we may finalize, but only randomly as we have no idea whether our key will be in the internal key + # key order is randomized in build musig wallet + musig_signing(name, wo, signers, cc_first, 1, threshold, finalized=False, split_to=5, + cc_first_no_sigs_added=False) + + # check addresses are correct + address_explorer_check("sd", "bech32m", wo, name) + + +@pytest.mark.parametrize("tapscript", [ + "tr($H,and_v(v:pk(musig($0,$1,$2)/<0;1>/*),after(120)))", + "tr($H,and_v(vc:pk_k(musig($0,$1,$2)/<0;1>/*),after(120)))", + "tr($H,and_v(v:pkh(musig($0,$1,$2)/<0;1>/*),after(120)))", + "tr($H,and_v(vc:pk_h(musig($0,$1,$2)/<0;1>/*),after(120)))", + "tr($H,{and_v(v:pk(musig($0,$2)/0/*),after(120)),and_v(v:pk(musig($1,$2)/0/*),after(120))})", +]) +def test_miniscript_musig_variations(tapscript, clear_miniscript, use_regtest, address_explorer_check, + build_musig_wallet, musig_signing): + + num_signers = 3 + use_regtest() + clear_miniscript() + + name = "mini_tap" + wo, signers, desc = build_musig_wallet(name, num_signers, tapscript=tapscript) + + musig_signing(name, wo, signers, False, 0, num_signers, finalized=False, + split_to=4, locktime=120) + + # check addresses are correct + address_explorer_check("sd", "bech32m", wo, name) + + +def test_resign_musig_psbt_nonce(use_regtest, clear_miniscript, build_musig_wallet, start_sign, + cap_story, end_sign, press_cancel): + use_regtest() + clear_miniscript() + name = "musig_resign_nonce" + wo, signers, desc = build_musig_wallet(name, 3, tapscript=True, + tapscript_musig_threshold=2) + + psbt_resp = wo.walletcreatefundedpsbt([], [{wo.getnewaddress("", "bech32m"): 5}], 0, + {"fee_rate": 2, "change_type": "bech32m"}) + # nothing added yet + empty_psbt = psbt_resp.get("psbt") + + start_sign(base64.b64decode(empty_psbt)) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + + # resign empty PSBT - even thou CC already has session rand stored for this TX + # FAIL + start_sign(base64.b64decode(empty_psbt)) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + with pytest.raises(Exception) as err: + end_sign() + assert err.value.args[0] == "Coldcard Error: Signing failed late" + time.sleep(.1) + title, story = cap_story() + assert "resign" in story + + po = BasicPSBT().parse(res_psbt) + # we added nonce for all the leafs we're part of + assert len(po.inputs[0].musig_pubnonces) == 3 + # no signature was added + assert len(po.inputs[0].musig_part_sigs) == 0 + my_nonce_psbt = po.as_b64_str() + + # provide same PSBT to coldcard - one where it already provided pubnonces + # this causes - session rand to be dropped from cache, so even if this works + # subsequent signature providing will fail + start_sign(base64.b64decode(my_nonce_psbt)) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + with pytest.raises(Exception) as err: + end_sign() + assert err.value.args[0] == "Coldcard Error: Signing failed late" + time.sleep(.1) + title, story = cap_story() + assert "musig needs restart" in story + press_cancel() + +def test_resign_musig_psbt_sig(use_regtest, clear_miniscript, build_musig_wallet, start_sign, + cap_story, end_sign, press_cancel): + + use_regtest() + clear_miniscript() + name = "musig_resign_sig" + wo, signers, desc = build_musig_wallet(name, 3, tapscript=True, + tapscript_musig_threshold=2) + + psbt_resp = wo.walletcreatefundedpsbt([], [{wo.getnewaddress("", "bech32m"): 3}], 0, + {"fee_rate": 3, "change_type": "bech32m"}) + # nothing added yet + empty_psbt = psbt_resp.get("psbt") + + # add our nonces + start_sign(base64.b64decode(empty_psbt)) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + + po = BasicPSBT().parse(res_psbt) + # nothing was added - still just 3 nonce from first run + assert len(po.inputs[0].musig_pubnonces) == 3 + # still no signature was added + assert len(po.inputs[0].musig_part_sigs) == 0 + + # cosigners adding nonces + full_nonce_psbt = po.as_b64_str() + for s in signers: + psbt_resp = s.walletprocesspsbt(full_nonce_psbt, True, "DEFAULT", True, False) + full_nonce_psbt = psbt_resp.get("psbt") + + start_sign(base64.b64decode(full_nonce_psbt)) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + po = BasicPSBT().parse(res_psbt) + # all nonces added at this point + assert len(po.inputs[0].musig_pubnonces) == 9 + # coldcard also added partial signatures - as all pubnonces were already available + assert len(po.inputs[0].musig_part_sigs) == 3 + + # resign PSBT that we have already signed + start_sign(base64.b64decode(po.as_b64_str())) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + + # nothing changed + po = BasicPSBT().parse(res_psbt) + assert len(po.inputs[0].musig_pubnonces) == 9 + assert len(po.inputs[0].musig_part_sigs) == 3 + + final_psbt = po.as_b64_str() + for s in signers: + psbt_resp = s.walletprocesspsbt(final_psbt, True, "DEFAULT", True, False) # do not finalize + final_psbt = psbt_resp.get("psbt") + + res = wo.finalizepsbt(final_psbt) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + +def test_identical_musig_fragments(use_regtest, bitcoin_core_signer, get_cc_key, clear_miniscript, + offer_minsc_import, press_select, create_core_wallet, start_sign, + end_sign, cap_story, bitcoind): + # three identical musig in descriptor - one in internal key, other two in tapscript leaves + # CC provides signature for internal key & one ONLY one tapleaf as tapleafs are completely same (even sighash is the same) + use_regtest() + + core_pubkeys = [] + core_privkeys = [] + signers = [] + for i in range(2): + # core signers + signer, core_pk, core_sk = bitcoin_core_signer(f"musig-co-signer{i}", privkey=True) + signer.keypoolrefill(25) + core_pubkeys.append(core_pk.replace("/0/*", "")) + core_privkeys.append(core_sk.replace("/0/*", "")) + signers.append(signer) + + cc_key = get_cc_key("86h/1h/0h").replace("/<0;1>/*", "") + msig = "musig(%s)" % ",".join([cc_key] + core_pubkeys) + desc = f"tr({msig}/<0;1>/*,{{pk({msig}/<0;1>/*),pk({msig}/<0;1>/*)}})" + + clear_miniscript() + name = "ident_musig" + _, story = offer_minsc_import(json.dumps(dict(name=name, desc=desc))) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + + wo = create_core_wallet(name, "bech32m", funded=True) + + desc_lst = [] + for obj in wo.listdescriptors()["descriptors"]: + del obj["next"] + del obj["next_index"] + desc_lst.append(obj) + + # import musig descriptor to signers + # each signer has it's own privkey loaded + for s, spk, ssk in zip(signers, core_pubkeys, core_privkeys): + to_import = copy.deepcopy(desc_lst) + for dobj in to_import: + dobj["desc"] = dobj["desc"].split("#")[0].replace(spk, ssk) + csum = wo.getdescriptorinfo(dobj["desc"])["checksum"] + dobj["desc"] = dobj["desc"] + "#" + csum + + res = s.importdescriptors(to_import) + for o in res: + assert o["success"] + + psbt_resp = wo.walletcreatefundedpsbt([], [{wo.getnewaddress("", "bech32m"): 5}], 0, + {"fee_rate": 2, "change_type": "bech32m"}) + # nothing added yet + psbt = psbt_resp.get("psbt") + + start_sign(base64.b64decode(psbt)) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + + po = BasicPSBT().parse(res_psbt) + assert len(po.inputs[0].musig_pubnonces) == 2 + assert len(po.inputs[0].musig_part_sigs) == 0 + + full_nonce_psbt = po.as_b64_str() + for s in signers: + psbt_resp = s.walletprocesspsbt(full_nonce_psbt, True, "DEFAULT", True, False) + full_nonce_psbt = psbt_resp.get("psbt") + + po = BasicPSBT().parse(base64.b64decode(full_nonce_psbt)) + assert len(po.inputs[0].musig_pubnonces) == 6 + assert len(po.inputs[0].musig_part_sigs) == 0 + + start_sign(po.as_bytes()) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + + po = BasicPSBT().parse(res_psbt) + assert len(po.inputs[0].musig_pubnonces) == 6 + assert len(po.inputs[0].musig_part_sigs) == 2 + + final_psbt = po.as_b64_str() + for s in signers: + psbt_resp = s.walletprocesspsbt(final_psbt, True, "DEFAULT", True, False) + final_psbt = psbt_resp.get("psbt") + + po = BasicPSBT().parse(base64.b64decode(final_psbt)) + assert len(po.inputs[0].musig_pubnonces) == 6 + assert len(po.inputs[0].musig_part_sigs) == 6 + + res = wo.finalizepsbt(po.as_b64_str()) + assert res["complete"] + tx_hex = res["hex"] + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + +def test_identical_musig_subder(use_regtest, bitcoin_core_signer, get_cc_key, clear_miniscript, + offer_minsc_import, press_select, create_core_wallet, + musig_signing, address_explorer_check): + # TODO bitcoin-core bitching that this descriptor is not sane because it contains duplicate public keys + # needs https://github.com/bitcoin/bitcoin/pull/34697 (or something less buggy) + # identical musig in one tapleaf, but musig subderivation differs, i.e. different key + raise pytest.skip("needs updated bitcoind") + use_regtest() + + core_pubkeys = [] + core_privkeys = [] + signers = [] + for i in range(2): + # core signers + signer, core_pk, core_sk = bitcoin_core_signer(f"musig-co-signer{i}", privkey=True) + signer.keypoolrefill(25) + core_pubkeys.append(core_pk.replace("/0/*", "")) + core_privkeys.append(core_sk.replace("/0/*", "")) + signers.append(signer) + + cc_key = get_cc_key("86h/1h/0h").replace("/<0;1>/*", "") + msig = "musig(%s)" % ",".join([cc_key] + core_pubkeys) + desc = f"tr({ranged_unspendable_internal_key()},and_v(v:pk({msig}/<0;1>/*),pk({msig}/<2;3>/*)))" + + clear_miniscript() + name = "ident_musig_subder" + _, story = offer_minsc_import(json.dumps(dict(name=name, desc=desc))) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + + wo = create_core_wallet(name, "bech32m", funded=True) + + desc_lst = [] + for obj in wo.listdescriptors()["descriptors"]: + del obj["next"] + del obj["next_index"] + desc_lst.append(obj) + + # import musig descriptor to signers + # each signer has it's own privkey loaded + for s, spk, ssk in zip(signers, core_pubkeys, core_privkeys): + to_import = copy.deepcopy(desc_lst) + for dobj in to_import: + dobj["desc"] = dobj["desc"].split("#")[0].replace(spk, ssk) + csum = wo.getdescriptorinfo(dobj["desc"])["checksum"] + dobj["desc"] = dobj["desc"] + "#" + csum + + res = s.importdescriptors(to_import) + for o in res: + assert o["success"] + + + musig_signing(name, wo, signers, True, 0, 3, finalized=False, + split_to=2) + + # check addresses are correct + address_explorer_check("sd", "bech32m", wo, name) + + +def test_multiple_musig_sessions_simple(use_regtest, clear_miniscript, build_musig_wallet, + start_sign, end_sign, cap_story, garbage_collector, + goto_home, microsd_path, pick_menu_item, press_select): + use_regtest() + clear_miniscript() + # below wallets have identical structure, but the keys differ + # testing our session cache logic + wo0, signers0, desc0 = build_musig_wallet("wal0", 4, tapscript=True, + tapscript_musig_threshold=3) + + wo1, signers1, desc1 = build_musig_wallet("wal1", 4, tapscript=True, + tapscript_musig_threshold=3) + + wo2, signers2, desc2 = build_musig_wallet("wal2", 4, tapscript=True, + tapscript_musig_threshold=3) + + psbts = [] # ordered 0,1,2 + for wal in [wo0, wo1, wo2]: + psbt_resp = wal.walletcreatefundedpsbt([], [{wal.getnewaddress("", "bech32m"): 5}], 0, + {"fee_rate": 2, "change_type": "bech32m"}) + psbts.append(psbt_resp.get("psbt")) + + # initialize musig sessions for all three PSBTs + for i in range(3): + start_sign(base64.b64decode(psbts[i])) + title, story = cap_story() + assert "Consolidating" in story + assert f"Wallet: wal{i}" in story + res_psbt = end_sign() + + po = BasicPSBT().parse(res_psbt) + assert len(po.inputs[0].musig_pubnonces) == 4 # internal key + 3 leafs out of 4 + assert len(po.inputs[0].musig_part_sigs) == 0 + + psbts[i] = po.as_b64_str() # replace with updated PSBT + + + # add pubnonce from co-signers + for i, signers in enumerate([signers0, signers1, signers2]): + the_psbt = psbts[i] + for s in signers: + psbt_resp = s.walletprocesspsbt(the_psbt, True, "DEFAULT", True, False) # do not finalize + the_psbt = psbt_resp.get("psbt") + + psbts[i] = the_psbt # update + + for psbt in psbts: + po = BasicPSBT().parse(base64.b64decode(psbt)) + assert len(po.inputs[0].musig_pubnonces) == (4*4) # each cosigner added 4 nonces (internal key + 3 leafs) + assert len(po.inputs[0].musig_part_sigs) == 0 + + # add signatures from co-signers + for i, signers in enumerate([signers0, signers1, signers2]): + the_psbt = psbts[i] + for s in signers: + psbt_resp = s.walletprocesspsbt(the_psbt, True, "DEFAULT", True, False) # do not finalize + the_psbt = psbt_resp.get("psbt") + + psbts[i] = the_psbt # update + + for i in range(2,-1,-1): # reverse order + fname = f"{i}.psbt" + fpath = microsd_path(fname) + with open(fpath, "w") as f: + f.write(psbts[i]) + + garbage_collector.append(fpath) + + goto_home() + pick_menu_item("Ready To Sign") + time.sleep(.1) + title, story = cap_story() + if "OK TO SEND?" not in title: + time.sleep(0.1) + pick_menu_item(fname) + time.sleep(0.1) + title, story = cap_story() + assert title == "OK TO SEND?" + assert "Consolidating" in story + assert "Change back:" in story + press_select() # confirm signing + time.sleep(0.5) + title, story = cap_story() + assert "PSBT Signed" == title + assert "Updated PSBT is:" in story + press_select() + split_story = story.split("\n\n") + fname_psbt = split_story[1] + + fpath_psbt = microsd_path(fname_psbt) + with open(fpath_psbt, "r") as f: + res_psbt = f.read().strip() + garbage_collector.append(fpath_psbt) + + # finalize txn will be provided as internal key signing is done + fname_txn = split_story[3] + cc_txid = split_story[4].split("\n")[-1] + fpath_txn = microsd_path(fname_txn) + with open(fpath_txn, "r") as f: + final_txn = f.read().strip() + garbage_collector.append(fpath_txn) + + # does not matter which wallet is used for finalization + res = wo0.finalizepsbt(res_psbt) + assert res["complete"] + tx_hex = res["hex"] + assert tx_hex == final_txn + res = wo0.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo0.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + assert res == cc_txid + + +def test_multiple_musig_sessions_identical_leave(use_regtest, clear_miniscript, build_musig_wallet, + start_sign, end_sign, cap_story, garbage_collector, + create_core_wallet, press_select, offer_minsc_import): + use_regtest() + clear_miniscript() + # below wallets have identical structure, but the keys differ + wo, signers, desc = build_musig_wallet("ww", 3, tapscript=True, + tapscript_musig_threshold=2, musig_subder="/<0;1>/*") + + # replace musig internal key with unspendable + ik = ranged_unspendable_internal_key() + new_start = f"tr({ik}" + end_idx = desc.find("*") + desc = new_start + desc[end_idx+1:] + + wal_name = "qqq" + _, story = offer_minsc_import(json.dumps(dict(name=wal_name, desc=desc))) + assert "Create new miniscript wallet?" in story + assert wal_name in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + + wo0 = create_core_wallet(wal_name, "bech32m", "sd", True) + + psbts = [] + for wal in [wo, wo0]: + psbt_resp = wal.walletcreatefundedpsbt([], [{wal.getnewaddress("", "bech32m"): 1.256}], 0, + {"fee_rate": 2, "change_type": "bech32m"}) + psbts.append(psbt_resp.get("psbt")) + + for i in range(2): + start_sign(base64.b64decode(psbts[i])) + title, story = cap_story() + assert "Consolidating" in story + res_psbt = end_sign() + + po = BasicPSBT().parse(res_psbt) + psbts[i] = po.as_b64_str() # replace with updated PSBT + + # add pubnonces from co-signers + for i in range(2): + the_psbt = psbts[i] + for s in signers: + psbt_resp = s.walletprocesspsbt(the_psbt, True, "DEFAULT", True, False) # do not finalize + the_psbt = psbt_resp.get("psbt") + + psbts[i] = the_psbt # update + + # add signatures from co-signers + for i in range(2): + the_psbt = psbts[i] + for s in signers: + psbt_resp = s.walletprocesspsbt(the_psbt, True, "DEFAULT", True, False) # do not finalize + the_psbt = psbt_resp.get("psbt") + + psbts[i] = the_psbt # update + + # finalize on CC + for i in range(2): + start_sign(base64.b64decode(psbts[i])) + title, story = cap_story() + assert "Consolidating" in story + res_psbt = end_sign() + + res = wo0.finalizepsbt(base64.b64encode(res_psbt).decode()) + assert res["complete"] + tx_hex = res["hex"] + res = wo0.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo0.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + +@pytest.mark.parametrize("tapscript", [ + False, + "tr($H,{and_v(v:pk(musig($0,$2)/0/*),after(120)),and_v(v:pk(musig($1,$2)/0/*),after(120))})", +]) +@pytest.mark.parametrize("cc_first", [True, False]) +@pytest.mark.parametrize("sighash", ["NONE", "SINGLE", "ALL|ANYONECANPAY", "NONE|ANYONECANPAY", "SINGLE|ANYONECANPAY"]) +def test_exotic_sighash_musig(tapscript, clear_miniscript, use_regtest, address_explorer_check, + build_musig_wallet, start_sign, end_sign, cc_first, sighash, + cap_story, bitcoind, settings_set): + + num_signers = 3 + locktime = 120 + use_regtest() + clear_miniscript() + settings_set("sighshchk", 1) # disable checks + + name = "sighash_musig" + wo, signers, desc = build_musig_wallet(name, num_signers, tapscript=tapscript) + + if tapscript: + _from, _to = 1, 2 + else: + _from, _to = 0, num_signers + + all_of_it = wo.getbalance() + unspent = wo.listunspent() + assert len(unspent) == 1 + + # split to + # use sighash ALL for consolidation + nVal = all_of_it / 4 + conso_addrs = [{wo.getnewaddress("", "bech32m"): nVal} for _ in range(4)] # self-spend + psbt_resp = wo.walletcreatefundedpsbt( + [], + conso_addrs, + 120 if tapscript else 0, + {"fee_rate": 2, "change_type": "bech32m", "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + if not cc_first: + # cosigners adding nonces + for s in signers: + psbt_resp = s.walletprocesspsbt(psbt, True, "ALL", True, False) + psbt = psbt_resp.get("psbt") + + else: + # CC is going first, tweak sighash to ALL (only one working besides DEFAULT for conso tx) + po = BasicPSBT().parse(base64.b64decode(psbt)) + for inp in po.inputs: + inp.sighash = SIGHASH_MAP["ALL"] + + psbt = po.as_b64_str() + + # CC add nonce + start_sign(base64.b64decode(psbt)) + title, story = cap_story() + assert "warning" not in story + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + + sighash_check(res_psbt, "ALL") + + b64_res_psbt = base64.b64encode(res_psbt).decode() + + if cc_first: + # if cc was first to add pubnonce - now core cosigners will add + for s in signers: + psbt_resp = s.walletprocesspsbt(b64_res_psbt, True, "ALL", True, False) + b64_res_psbt = psbt_resp.get("psbt") + + # cosigners adding signatures - seems core is unable to add both nonce and signature in one iteration + for s in signers[_from: _to]: + psbt_resp = s.walletprocesspsbt(b64_res_psbt, True, "ALL", True, False) + b64_res_psbt = psbt_resp.get("psbt") + + # CC add sig + start_sign(base64.b64decode(b64_res_psbt)) + title, story = cap_story() + assert "warning" not in story + assert "Consolidating" in story + assert f"Wallet: {name}" in story + res_psbt = end_sign() + sighash_check(res_psbt, "ALL") + b64_res_psbt = base64.b64encode(res_psbt).decode() + + res = wo.finalizepsbt(b64_res_psbt) + assert res["complete"] + tx_hex = res["hex"] + + if tapscript: + # we are signing for timelocked tapscript + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] is False + assert res[0]['reject-reason'] == "non-final" + block_h = wo.getblockchaininfo()["blocks"] + bitcoind.supply_wallet.generatetoaddress(locktime - block_h, bitcoind.supply_wallet.getnewaddress()) + + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + + # + # now consolidate multiple inputs to send out + # we can check all sighash flags here as this is not pure consolidation + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + unspent = wo.listunspent() + assert len(unspent) == 4 + + psbt_resp = wo.walletcreatefundedpsbt( + [], + [ + {bitcoind.supply_wallet.getnewaddress(): 2}, + {bitcoind.supply_wallet.getnewaddress(): 2}, + {bitcoind.supply_wallet.getnewaddress(): 2}, + {bitcoind.supply_wallet.getnewaddress(): 2}, + ], + locktime if tapscript else 0, + {"fee_rate": 2, "change_type": "bech32m"}, + ) + psbt1 = psbt_resp.get("psbt") + + if not cc_first: + # cosigners adding nonces + for s in signers: + psbt_resp = s.walletprocesspsbt(psbt1, True, sighash, True, False) + psbt1 = psbt_resp.get("psbt") + + else: + # CC is going first, tweak sighash + po = BasicPSBT().parse(base64.b64decode(psbt1)) + for inp in po.inputs: + inp.sighash = SIGHASH_MAP[sighash] + + psbt1 = po.as_b64_str() + + # CC adds nonce only + start_sign(base64.b64decode(psbt1)) + title, story = cap_story() + assert "Consolidating" not in story + assert "Change back:" in story # has one change address + assert "warning" in story + assert "sighash" in story + if sighash == "NONE": + assert sighash in story + else: + assert "Some inputs have unusual SIGHASH values" + assert f"Wallet: {name}" in story + res_psbt1 = end_sign() + sighash_check(res_psbt1, sighash) + b64_res_psbt1 = base64.b64encode(res_psbt1).decode() + + if cc_first: + # if cc was first to add pubnonce - now core cosigners will add + for s in signers: + psbt_resp = s.walletprocesspsbt(b64_res_psbt1, True, sighash, True, False) + b64_res_psbt1 = psbt_resp.get("psbt") + + # cosigners adding signatures + for s in signers[_from: _to]: + psbt_resp = s.walletprocesspsbt(b64_res_psbt1, True, sighash, True, False) + b64_res_psbt1 = psbt_resp.get("psbt") + + # CC adds sig + start_sign(base64.b64decode(b64_res_psbt1)) + title, story = cap_story() + assert "warning" in story + assert "sighash" in story + if sighash == "NONE": + assert sighash in story + else: + assert "Some inputs have unusual SIGHASH values" + assert f"Wallet: {name}" in story + res_psbt = end_sign() + sighash_check(res_psbt, sighash) + b64_res_psbt1 = base64.b64encode(res_psbt).decode() + + res1 = wo.finalizepsbt(b64_res_psbt1) + assert res1["complete"] + tx_hex1 = res1["hex"] + res = wo.testmempoolaccept([tx_hex1]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex1) + assert len(res) == 64 # tx id + + # now try forbidden consolidation tx with exotic sighash - must fail + settings_set("sighshchk", 0) # enable checks + bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress()) # mine above + al_of_it = wo.getbalance() + psbt_resp = wo.walletcreatefundedpsbt( + [], + [{wo.getnewaddress("", "bech32m"): al_of_it}], + 120 if tapscript else 0, + {"fee_rate": 2, "change_type": "bech32m", "subtractFeeFromOutputs": [0]}, + ) + psbt = psbt_resp.get("psbt") + + po = BasicPSBT().parse(base64.b64decode(psbt)) + for inp in po.inputs: + inp.sighash = SIGHASH_MAP[sighash] + + psbt = po.as_bytes() + start_sign(psbt) + title, story = cap_story() + assert "Failure" == title + assert "Only sighash ALL/DEFAULT is allowed for pure consolidation transactions." in story + + +def test_duplicate_musig_in_tapleaf(get_cc_key, offer_minsc_import): + path = "99h/1h/0h" + cc_key = get_cc_key(path).replace("/<0;1>/*", "") + keys = random_keys(2, path=path) + + # duplicate musig in tapscript leaf + musig = f"musig({cc_key},{keys[0]},{keys[1]})" + desc = f"tr({ranged_unspendable_internal_key()},and_v(v:pk({musig}),pk({musig})))" + with pytest.raises(Exception) as e: + offer_minsc_import(desc) + assert e.value.args[0] == "Coldcard Error: Insane" + + +def test_unspendable_key_in_musig(get_cc_key, offer_minsc_import): + path = "199h/1h/3h" + cc_key = get_cc_key(path).replace("/<0;1>/*", "") + keys = random_keys(2, path=path) + unspend = ranged_unspendable_internal_key(subderiv="") + + # duplicate musig in tapscript leaf + musig = f"musig({cc_key},{keys[0]},{unspend})" + data = [ + f"tr({musig})", + f"tr({keys[1]},pk({musig}))", + ] + for desc in data: + with pytest.raises(Exception) as e: + offer_minsc_import(desc) + assert e.value.args[0] == "Coldcard Error: unspendable key inside musig" + + +def test_musig_outside_taproot_context(get_cc_key, offer_minsc_import): + # musig only allowed in taproot - whether internal key or tapscript + path = "1001h/1h/99h" + cc_key = get_cc_key(path).replace("/<0;1>/*", "") + keys = random_keys(2, path=path) + + inner = f"musig({cc_key},{keys[0]},{keys[1]}" + data = [ + f"wsh(pk({inner}))", + f"sh(wsh(pk({inner})))", + f"sh(pk({inner}))", + ] + + for desc in data: + with pytest.raises(Exception) as e: + offer_minsc_import(desc) + assert e.value.args[0] == "Coldcard Error: musig in non-taproot context" + + +def test_nested_musig(get_cc_key, offer_minsc_import): + # musig key expression nested in another key expression is not allowed + path = "99h/1h/0h" + cc_key = get_cc_key(path).replace("/<0;1>/*", "") + keys = random_keys(2, path=path) + + data = [ + f"tr(musig({cc_key},{keys[0]},musig({cc_key},{keys[0]},{keys[1]})))", + f"tr({cc_key},pk(musig({cc_key},{keys[0]},musig({cc_key},{keys[0]},{keys[1]}))))", + ] + + for desc in data: + with pytest.raises(Exception) as e: + offer_minsc_import(desc) + assert e.value.args[0] == "Coldcard Error: nested musig not allowed" + + +def test_key_derivation_not_allowed_inside_musig(get_cc_key, offer_minsc_import): + # only whole musig key expression can have key derivation, not single keys + path = "86h/1h/3h" + cc_key = get_cc_key(path).replace("/<0;1>/*", "") + keys = random_keys(2, path=path) + + data = [ + f"tr(musig({cc_key}/<0;1>/*,{keys[0]},{keys[1]}))", # internal key + f"tr({cc_key},pk(musig({cc_key}/<0;1>/*,{keys[0]}/<0;1>/*,{keys[1]}/<0;1>/*)))", # nested musig in tapscript + ] + + for desc in data: + with pytest.raises(Exception) as e: + offer_minsc_import(desc) + assert e.value.args[0] == "Coldcard Error: key derivation not allowed inside musig" + + +def test_hardened_musig_derivation(get_cc_key, offer_minsc_import): + # only whole musig key expression can have key derivation, not single keys + path = "88h/0h/0h" + cc_key = get_cc_key(path).replace("/<0;1>/*", "") + keys = random_keys(2, path=path) + + data = [ + f"tr(musig({cc_key},{keys[0]},{keys[1]})/1h/*)", + f"tr({cc_key},pk(musig({cc_key},{keys[0]},{keys[0]})/3h/*))", + ] + + for desc in data: + with pytest.raises(Exception) as e: + offer_minsc_import(desc) + assert e.value.args[0] == "Coldcard Error: Cannot use hardened sub derivation path" + + +def test_only_unique_keys_in_musig(get_cc_key, offer_minsc_import): + path = "88h/0h/0h" + cc_key = get_cc_key(path).replace("/<0;1>/*", "") + keys = random_keys(1, path=path) + + data = [ + f"tr(musig({cc_key},{keys[0]},{keys[0]}))", # internal key non-unique foreign keys + f"tr(musig({cc_key},{cc_key},{keys[0]}))", # internal key non-unique own keys + f"tr({cc_key},pk(musig({cc_key},{keys[0]},{keys[0]})))", # tapscript key non-unique foreign keys + f"tr({cc_key},pk(musig({cc_key},{cc_key},{keys[0]})))", # tapscript key non-unique own keys + ] + + for desc in data: + with pytest.raises(Exception) as e: + offer_minsc_import(desc) + assert e.value.args[0] == "Coldcard Error: musig keys not unique" + + +@pytest.mark.bitcoind +def test_tmp_seed_cosign(bitcoind, settings_set, end_sign, start_sign, restore_main_seed, use_regtest, + cap_story, goto_eph_seed_menu, pick_menu_item, word_menu_entry, + confirm_tmp_seed, usb_miniscript_get, offer_minsc_import, press_select, + clear_miniscript, get_cc_key, create_core_wallet): + + # proves that we can hold secnonce for multiple seed types on one device (even for same txn where respective keys are co-signers) + b_words = "sight will strike aspect nerve saddle young special dragon fence chest tattoo" + + use_regtest() + clear_miniscript() + + name = "tmp_musig_cosign" + der_pth = "86h/1h/0h" + + # it is string mnemonic + b39_seed = Mnemonic.to_seed(b_words) + b_node = BIP32Node.from_master_secret(b39_seed) + b_xfp = b_node.fingerprint().hex() + b_key = b_node.subkey_for_path(der_pth).hwif() + b_key_exp = f"[{b_xfp}/{der_pth}]{b_key}" + + # C is just random key we won't use + c_node = BIP32Node.from_master_secret(os.urandom(32)) + c_xfp = c_node.fingerprint().hex() + c_key = c_node.subkey_for_path(der_pth).hwif() + c_key_exp = f"[{c_xfp}/{der_pth}]{c_key}" + + cc_key = get_cc_key("86h/1h/0h").replace("/<0;1>/*", "") + + inner = "musig(%s)" % ",".join([cc_key, b_key_exp, c_key_exp]) + + s0 = f"pk(musig({cc_key},{b_key_exp}))" + s1 = f"pk(musig({cc_key},{c_key_exp}))" + s2 = f"pk(musig({c_key_exp},{b_key_exp}))" + + + inner += "," + inner += "{%s,{%s,%s}}" % (s0,s1,s2) + desc = f"tr({inner})" + + title, story = offer_minsc_import(json.dumps(dict(name=name, desc=desc))) + assert "Create new miniscript wallet?" in story + # do some checks on policy --> helper function to replace keys with letters + press_select() + + bitcoind_wo = create_core_wallet(name, "bech32m", "sd", True) + + psbt = bitcoind_wo.walletcreatefundedpsbt( + [], [{bitcoind.supply_wallet.getnewaddress(): 0.2}, + {bitcoind.supply_wallet.getnewaddress(): 0.255}], + 0, {"fee_rate": 20, "change_type": "bech32m"} + )['psbt'] + + start_sign(base64.b64decode(psbt)) + time.sleep(.1) + title, story = cap_story() + assert 'OK TO SEND?' == title + + signed = end_sign(accept=True) + po = BasicPSBT().parse(signed) + assert not po.inputs[0].musig_part_sigs + assert po.inputs[0].musig_pubnonces + + goto_eph_seed_menu() + pick_menu_item("Import Words") + pick_menu_item("12 Words") + time.sleep(0.1) + word_menu_entry(b_words.split()) + confirm_tmp_seed(seedvault=False) + title, story = offer_minsc_import(desc) + assert "Create new miniscript wallet?" in story + press_select() + + start_sign(signed) + time.sleep(.1) + title, story = cap_story() + assert 'OK TO SEND?' == title + assert "warning" not in story + signed = end_sign(accept=True) + po = BasicPSBT().parse(signed) + # now we should have all pubnonces that we need + assert len(po.inputs[0].musig_part_sigs) == 0 + assert po.inputs[0].musig_pubnonces + + # 2nd round - get signature + start_sign(signed) + time.sleep(.1) + title, story = cap_story() + assert 'OK TO SEND?' == title + assert "warning" not in story + signed = end_sign(accept=True) + po = BasicPSBT().parse(signed) + # now we should have signature one signature + assert len(po.inputs[0].musig_part_sigs) == 1 + + try: + # this is run with --eff so number of settings may be incorrect - no prob + restore_main_seed() + except: pass + + start_sign(signed) + time.sleep(.1) + title, story = cap_story() + assert 'OK TO SEND?' == title + + signed = end_sign(accept=True) + po = BasicPSBT().parse(signed) + assert len(po.inputs[0].musig_part_sigs) == 2 + assert po.inputs[0].musig_pubnonces + # 1 aggregate sig for aggregated musig leaf + assert len(po.inputs[0].taproot_script_sigs) == 1 + + res = bitcoind_wo.finalizepsbt(base64.b64encode(signed).decode()) + assert res["complete"] + tx_hex = res["hex"] + res = bitcoind_wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = bitcoind_wo.sendrawtransaction(tx_hex) + assert len(res) == 64 # tx id + +# EOF \ No newline at end of file diff --git a/testing/test_teleport.py b/testing/test_teleport.py index c87b8b2d..6fe894d4 100644 --- a/testing/test_teleport.py +++ b/testing/test_teleport.py @@ -966,6 +966,216 @@ def test_teleport_miniscript_sign(dev, taproot, policy, get_cc_key, bitcoind, us assert '(T) to use Key Teleport to send PSBT to other co-signers' in body +@pytest.mark.bitcoind +@pytest.mark.parametrize("tapscript,wtype", [(True,0), (False,0), (True,1)]) +def test_teleport_musig_sign(tapscript, wtype, reset_seed_words, use_regtest, clear_miniscript, + build_musig_wallet, bitcoind, try_sign, cap_story, need_keypress, + cap_menu, press_cancel, cap_screen, dev, pick_menu_item, grab_payload, + offer_minsc_import, press_select, rx_complete, goto_eph_seed_menu, + microsd_path, settings_get, goto_home, restore_main_seed): + reset_seed_words() + use_regtest() + clear_miniscript() + name = "tele_musig" + + wo, signers, desc = build_musig_wallet(name, 3, tapscript=tapscript, num_utxo_available=1, + tapscript_musig_threshold=2, wallet_type=wtype) + + private_data = [] + for s in signers: + res = s.listdescriptors(True)["descriptors"] + priv_ek = None + for obj in res: + dd = obj.get("desc", "") + if dd.startswith("pkh("): + priv_ek = dd.replace("pkh(", "").split("/")[0] + break + + assert priv_ek + node = BIP32Node.from_wallet_key(priv_ek) + private_data.append((priv_ek, node.fingerprint().hex().upper(), node)) + + unspent = wo.listunspent() + assert len(unspent) == 1 + + all_of_it = wo.getbalance() + to_send = [{bitcoind.supply_wallet.getnewaddress(): all_of_it}] + + psbt_resp = wo.walletcreatefundedpsbt([], to_send, 0, {"fee_rate": 2, + "change_type": "bech32m", + "subtractFeeFromOutputs": [0]}) + psbt = psbt_resp.get("psbt") + + _, psbt = try_sign(base64.b64decode(psbt), accept=True, exit_export_loop=False) + title, body = cap_story() + if not wtype: + assert title == "PSBT Updated" # added only nonce + else: + # wallet type 1 contains classic sortedmulti_a leaf to which we provide signature in first sitting + assert title == "PSBT Signed" + assert '(T) to use Key Teleport to send PSBT to other co-signers' in body + + my_xfp = xfp2str(simulator_fixed_xfp) + + # nonces first + for i, (ek, fp, node) in enumerate(private_data): + need_keypress('t') + time.sleep(.1) + + m = cap_menu() + assert len(m) == (len(signers) + 1) # signers + me + + assert "\x14\x00" not in cap_screen() # nothing is done + + # pick other xfp to send to + nm, = [mi for mi in m if fp in mi] + pick_menu_item(nm) + + # grab the payload and pw + pw, data, qr_raw = grab_payload('E') + assert len(pw) == 8 + + time.sleep(.1) + title, story = cap_story() + assert title == 'Sent by Teleport' + + fname = "ek_priv.txt" + with open(microsd_path(fname), "w") as f: + f.write(ek) + + # switch personalities, and try to read that QR + goto_eph_seed_menu() + pick_menu_item("Import XPRV") + need_keypress("1") + try: + pick_menu_item(fname) + except: pass + + press_select() + + use_regtest() + clear_miniscript() + dev.start_encryption() + assert xfp2str(settings_get('xfp')) == fp + + # need miniscript wallet + title, story = offer_minsc_import(json.dumps({"name": name, "desc": desc})) + assert "Create new miniscript wallet?" in story + press_select() + time.sleep(.2) + + # import and sign + last_xfp = my_xfp if (i == 0) else private_data[i-1][1] + rx_complete(('E', qr_raw), pw, expect_xfp=str2xfp(last_xfp)) + + title, body = cap_story() + assert title == 'OK TO SEND?' + + press_select() + time.sleep(.25) + + title, body = cap_story() + if not wtype: + assert title == "PSBT Updated" # added only nonce + else: + # wallet type 1 contains classic sortedmulti_a leaf to which we provide signature in first sitting + assert title == "PSBT Signed" + assert '(T) to use Key Teleport to send PSBT to other co-signers' in body + + + # now start adding signatures + for i, (ek, fp, node) in enumerate(reversed(private_data)): + # reversed so that we do not need to change tmp seed for the first signer that was last in nonce adding + need_keypress('t') + + time.sleep(.1) + m = cap_menu() + nm, = [mi for mi in m if fp in mi] + pick_menu_item(nm) + + if i: + # grab the payload and pw + pw, data, qr_raw = grab_payload('E') + assert len(pw) == 8 + + time.sleep(.1) + title, story = cap_story() + assert title == 'Sent by Teleport' + + fname = "ek_priv.txt" + with open(microsd_path(fname), "w") as f: + f.write(ek) + + # switch personalities, and try to read that QR + goto_eph_seed_menu() + pick_menu_item("Import XPRV") + need_keypress("1") + try: + pick_menu_item(fname) + except: + pass + + press_select() + + dev.start_encryption() + assert xfp2str(settings_get('xfp')) == fp + + rx_complete(('E', qr_raw), pw, expect_xfp=str2xfp(private_data[1][1])) + + title, body = cap_story() + assert title == 'OK TO SEND?' + + press_select() + time.sleep(.25) + + title, body = cap_story() + assert title == "PSBT Signed" # added signature(s) + assert '(T) to use Key Teleport to send PSBT to other co-signers' in body + + # now finish with sim CC + need_keypress('t') + # now both cosigners are done + time.sleep(.1) + m = cap_menu() + scr = cap_screen() + assert "YOU \x14\x00" in scr + assert "DONE" in scr + + time.sleep(.1) + nm, = [mi for mi in m if xfp2str(simulator_fixed_xfp) in mi] + pick_menu_item(nm) + pw, data, qr_raw = grab_payload('E') + time.sleep(.1) + title, story = cap_story() + assert title == 'Sent by Teleport' + try: + restore_main_seed() + except: pass + dev.start_encryption() + assert xfp2str(settings_get('xfp')) == xfp2str(simulator_fixed_xfp) + + # import and sign + rx_complete(('E', qr_raw), pw) + title, body = cap_story() + assert title == 'OK TO SEND?' + press_select() + time.sleep(.25) + title, body = cap_story() + assert '(T) to use Key Teleport to send PSBT to other co-signers' not in body # done + assert "Finalized TX ready for broadcast" in body + txid = body.split("\n\n")[1].split()[1] + + done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None) + resp_len, chk = done + tx_out = dev.download_file(resp_len, chk) + tx_hex = tx_out.hex() + res = wo.testmempoolaccept([tx_hex]) + assert res[0]["allowed"] + res = wo.sendrawtransaction(tx_hex) + assert res == txid # tx id + press_cancel() + + def test_hobble_limited(set_hobble, scan_a_qr, cap_menu, cap_screen, pick_menu_item, grab_payload, rx_complete, cap_story, press_cancel, press_select, settings_get, settings_set, restore_backup_unpacked, main_do_over, set_encoded_secret, diff --git a/testing/test_unit.py b/testing/test_unit.py index 44c84da4..3eab386c 100644 --- a/testing/test_unit.py +++ b/testing/test_unit.py @@ -398,4 +398,8 @@ def test_bip32(sim_execfile): res = sim_execfile('devtest/unit_bip32.py') assert res == "" +def test_musig(sim_execfile): + res = sim_execfile('devtest/unit_musig.py') + assert res == "" + # EOF