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