diff --git a/shared/auth.py b/shared/auth.py index b47d374f..47288b59 100644 --- a/shared/auth.py +++ b/shared/auth.py @@ -1451,7 +1451,8 @@ class NewMiniscriptEnrollRequest(UserAuthorizedAction): self.pop_menu() -def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None, miniscript=False): +def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_index=None, + miniscript=False): # Offer to import (enroll) a new multisig/miniscript wallet. Allow reject by user. from glob import dis from multisig import MultisigWallet @@ -1461,6 +1462,7 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_ dis.fullscreen('Wait...') dis.busy_bar(True) + bip388 = False try: if sf_len: with SFFile(TXN_INPUT_OFFSET, length=sf_len) as fd: @@ -1468,9 +1470,14 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_ try: j_conf = ujson.loads(config) - assert "desc" in j_conf, "'desc' key required" - config = j_conf["desc"] - assert config, "'desc' empty" + if "desc_template" in j_conf and "keys_info" in j_conf: + assert "name" in j_conf + config = j_conf + bip388 = miniscript = True + else: + assert "desc" in j_conf, "'desc' key required" + config = j_conf["desc"] + assert config, "'desc' empty" if "name" in j_conf: # name from json has preference over filenames and desc checksum @@ -1488,7 +1495,7 @@ def maybe_enroll_xpub(sf_len=None, config=None, name=None, ux_reset=False, bsms_ msc = MiniScriptWallet.from_file(config, name=name) elif miniscript: - msc = MiniScriptWallet.from_file(config, name=name) + msc = MiniScriptWallet.from_file(config, name=name, bip388=bip388) else: msc = MultisigWallet.from_file(config, name=name) diff --git a/shared/bsms.py b/shared/bsms.py index 4e88b254..f9a14f3a 100644 --- a/shared/bsms.py +++ b/shared/bsms.py @@ -1047,7 +1047,7 @@ async def bsms_signer_round2(menu, label, item): nodes = [] progress_counter = 0.2 # last displayed progress # (desired value after loop - last displayed progress) / N - progress_chunk = (0.5 - progress_counter) / len(desc_obj.miniscript.keys) + progress_chunk = (0.5 - progress_counter) / len(desc_obj.keys) for key in desc_obj.keys: if key.origin.cc_fp == my_xfp: my_keys.append(key) diff --git a/shared/desc_utils.py b/shared/desc_utils.py index c8146464..d2ad5839 100644 --- a/shared/desc_utils.py +++ b/shared/desc_utils.py @@ -169,6 +169,9 @@ class KeyDerivationInfo: def not_hardened(x): assert (b"'" not in x) and (b"h" not in x), "Cannot use hardened sub derivation path" + def get_ext_int(self): + return self.indexes[self.multi_path_index] + @classmethod def parse(cls, s): err = "Malformed key derivation" @@ -183,6 +186,7 @@ class KeyDerivationInfo: cls.not_hardened(ext_num) int_num, char = read_until(s, b">") assert char, err + assert b";" not in int_num, "Solved cardinality > 2" cls.not_hardened(int_num) assert int_num != ext_num # cannot be the same @@ -241,12 +245,11 @@ class Key: self.chain_type = chain_type def __eq__(self, other): - return self.origin == other.origin \ - and self.derivation.indexes == other.derivation.indexes + return hash(self) == hash(other) def __hash__(self): # return hash(self.to_string()) - return hash(self.origin) + hash(self.derivation) + return hash(self.node.pubkey()) + hash(self.derivation) def __len__(self): return 34 - int(self.taproot) # <33:sec> or <32:xonly> @@ -422,4 +425,29 @@ def bip388_wallet_policy_to_descriptor(desc_tmplt, keys_info): k_str = keys_info[i] ph = "@%d" % i desc_tmplt = desc_tmplt.replace(ph, k_str) - return desc_tmplt + return desc_tmplt.replace("/**", "/<0;1>/*") + + +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"@") + if not char: + # no more - done + break + + # key derivation info required for policy + got, char = read_until(s, b"/") + assert char, "key derivation missing" + num = int(got.decode()) + if num not in r: + r.append(num) + + assert s.read(1) in b"<*", "need multipath" + + + assert len(r) == len(keys_info), "Invalid policy" + assert r == list(range(len(r))), "Out of order" diff --git a/shared/descriptor.py b/shared/descriptor.py index 193fd64f..36a22c36 100644 --- a/shared/descriptor.py +++ b/shared/descriptor.py @@ -144,21 +144,29 @@ class Descriptor: assert len(self.miniscript.keys) == len(set(self.miniscript.keys)), "Insane" my_xfp = settings.get('xfp') + ext_nums = set() + int_nums = set() for k in self.keys: has_mine += k.validate(my_xfp) + ext, int = k.derivation.get_ext_int() + ext_nums.add(ext) + int_nums.add(int) c += 1 + assert ext_nums.isdisjoint(int_nums), "Non-disjoint multipath" assert c <= max_signers, "max signers" assert has_mine > 0, 'My key %s missing in descriptor.' % xfp2str(my_xfp).upper() def bip388_wallet_policy(self): + # only same origin keys keys_info = OrderedDict() for k in self.keys: - if k.origin not in keys_info: - keys_info[k.origin] = k.to_string(subderiv=False) + pk = k.node.pubkey() + if pk not in keys_info: + keys_info[pk] = k.to_string(subderiv=False) - desc_tmplt = self.to_string(checksum=False) + desc_tmplt = self.to_string(checksum=False).replace("/<0;1>/*", "/**") keys_info = list(keys_info.values()) for i, k_str in enumerate(keys_info): @@ -218,13 +226,16 @@ class Descriptor: if self.tapscript: # internal is always first - # otherwise order of keys is not preserved (after set ops) - keys = set() + # use ordered dict as order preserving set + keys = OrderedDict() + # add internal key + keys[self.key] = None + # taptree keys for lv in self.tapscript.iter_leaves(): for k in lv.keys: - keys.add(k) + keys[k] = None - self._keys = [self.key] + list(keys) + self._keys = list(keys) elif self.miniscript: self._keys = self.miniscript.keys diff --git a/shared/miniscript.py b/shared/miniscript.py index 283efbd8..55d31028 100644 --- a/shared/miniscript.py +++ b/shared/miniscript.py @@ -6,7 +6,7 @@ import ngu, ujson, uio, chains, ure, version, stash from binascii import unhexlify as a2b_hex from binascii import hexlify as b2a_hex from serializations import ser_compact_size, ser_string -from desc_utils import Key, read_until, bip388_wallet_policy_to_descriptor, append_checksum +from desc_utils import Key, read_until, bip388_wallet_policy_to_descriptor, append_checksum, bip388_validate_policy from public_constants import MAX_TR_SIGNERS, AF_P2TR from wallet import BaseStorageWallet, MAX_BIP32_IDX from menu import MenuSystem, MenuItem @@ -23,7 +23,7 @@ class MiniScriptWallet(BaseStorageWallet): key_name = "miniscript" def __init__(self, name, desc_tmplt=None, keys_info=None, desc=None, - af=None, ik_u=None, chain=None): + af=None, ik_u=None): assert (desc_tmplt and keys_info) or desc @@ -51,7 +51,7 @@ class MiniScriptWallet(BaseStorageWallet): rv.storage_idx = idx return rv - def to_descriptor(self): + def to_descriptor(self, validate=False): if self.desc is None: # actual descriptor is not loaded, but was asked for # fill policy - aka storage format - to actual descriptor @@ -66,7 +66,7 @@ class MiniScriptWallet(BaseStorageWallet): desc_str = bip388_wallet_policy_to_descriptor(self.desc_tmplt, self.keys_info) print("loading... filled policy:\n", desc_str) # no need to validate already saved descriptor - was validated upon enroll - self.desc = Descriptor.from_string(desc_str, validate=False) + self.desc = Descriptor.from_string(desc_str, validate=validate) # cache len always 1 glob.DESC_CACHE = {} glob.DESC_CACHE[self.name] = self.desc @@ -191,23 +191,37 @@ class MiniScriptWallet(BaseStorageWallet): await ux_show_story(msg) @classmethod - def from_file(cls, config, name=None): + def from_bip388_wallet_policy(cls, name, desc_template, keys_info): + bip388_validate_policy(desc_template, keys_info) + msc = cls(name, desc_template, keys_info) + msc.to_descriptor(validate=True) + return msc + + @classmethod + def from_file(cls, config, name=None, bip388=False): from descriptor import Descriptor - if name is None: - desc_obj, cs = Descriptor.from_string(config.strip(), checksum=True) - name = cs + if bip388: + # config is JSON wallet policy + wal = cls.from_bip388_wallet_policy(config["name"], config["desc_template"], + config["keys_info"]) else: - name = to_ascii_printable(name) - desc_obj = Descriptor.from_string(config.strip()) + if name is None: + desc_obj, cs = Descriptor.from_string(config.strip(), checksum=True) + name = cs + else: + name = to_ascii_printable(name) + desc_obj = Descriptor.from_string(config.strip()) - wal = cls(name, desc=desc_obj) + wal = cls(name, desc=desc_obj) - # BIP388 wasn't generated yet - generating from descriptor upon import/enroll - wal.desc_tmplt, wal.keys_info = desc_obj.bip388_wallet_policy() + # BIP388 wasn't generated yet - generating from descriptor upon import/enroll + wal.desc_tmplt, wal.keys_info = desc_obj.bip388_wallet_policy() - wal.ik_u = desc_obj.key and desc_obj.key.is_provably_unspendable - wal.addr_fmt = desc_obj.addr_fmt + bip388_validate_policy(wal.desc_tmplt, wal.keys_info) + + wal.ik_u = wal.desc.key and wal.desc.key.is_provably_unspendable + wal.addr_fmt = wal.desc.addr_fmt return wal def find_duplicates(self): @@ -337,7 +351,7 @@ class MiniScriptWallet(BaseStorageWallet): name = "BIP-388 Wallet Policy" fname_pattern = 'b388-%s.json' % self.name res = ujson.dumps({"name": self.name, - "desc_tmplt": self.desc_tmplt.replace("/<0;1>/*", "/**"), + "desc_template": self.desc_tmplt.replace("/<0;1>/*", "/**"), "keys_info": self.keys_info}) else: name = "Miniscript" diff --git a/testing/devtest/unit_bip388.py b/testing/devtest/unit_bip388.py new file mode 100644 index 00000000..b338eac2 --- /dev/null +++ b/testing/devtest/unit_bip388.py @@ -0,0 +1,145 @@ +# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# BIP-0388 vectors https://github.com/bitcoin/bips/blob/master/bip-0388.mediawiki + +valid = [ + ( + "pkh(@0/**)", + ["[6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb"], + "pkh([6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb/<0;1>/*)", + ), + ( + "sh(wpkh(@0/**))", + ["[6738736c/49'/0'/1']xpub6Bex1CHWGXNNwGVKHLqNC7kcV348FxkCxpZXyCWp1k27kin8sRPayjZUKDjyQeZzGUdyeAj2emoW5zStFFUAHRgd5w8iVVbLgZ7PmjAKAm9"], + "sh(wpkh([6738736c/49'/0'/1']xpub6Bex1CHWGXNNwGVKHLqNC7kcV348FxkCxpZXyCWp1k27kin8sRPayjZUKDjyQeZzGUdyeAj2emoW5zStFFUAHRgd5w8iVVbLgZ7PmjAKAm9/<0;1>/*))", + ), + ( + "wpkh(@0/**)", + ["[6738736c/84'/0'/2']xpub6CRQzb8u9dmMcq5XAwwRn9gcoYCjndJkhKgD11WKzbVGd932UmrExWFxCAvRnDN3ez6ZujLmMvmLBaSWdfWVn75L83Qxu1qSX4fJNrJg2Gt"], + "wpkh([6738736c/84'/0'/2']xpub6CRQzb8u9dmMcq5XAwwRn9gcoYCjndJkhKgD11WKzbVGd932UmrExWFxCAvRnDN3ez6ZujLmMvmLBaSWdfWVn75L83Qxu1qSX4fJNrJg2Gt/<0;1>/*)", + ), + ( + "tr(@0/**)", + ["[6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL"], + "tr([6738736c/86'/0'/0']xpub6CryUDWPS28eR2cDyojB8G354izmx294BdjeSvH469Ty3o2E6Tq5VjBJCn8rWBgesvTJnyXNAJ3QpLFGuNwqFXNt3gn612raffLWfdHNkYL/<0;1>/*)", + ), + ( + "wsh(sortedmulti(2,@0/**,@1/**))", + ["[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw", + "[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7"], + "wsh(sortedmulti(2,[6738736c/48'/0'/0'/2']xpub6FC1fXFP1GXLX5TKtcjHGT4q89SDRehkQLtbKJ2PzWcvbBHtyDsJPLtpLtkGqYNYZdVVAjRQ5kug9CsapegmmeRutpP7PW4u4wVF9JfkDhw/<0;1>/*,[b2b1f0cf/48'/0'/0'/2']xpub6EWhjpPa6FqrcaPBuGBZRJVjzGJ1ZsMygRF26RwN932Vfkn1gyCiTbECVitBjRCkexEvetLdiqzTcYimmzYxyR1BZ79KNevgt61PDcukmC7/<0;1>/*))", + ), + ( + "wsh(thresh(3,pk(@0/**),s:pk(@1/**),s:pk(@2/**),sln:older(12960)))", + ["[6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa", + "[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js", + "[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2"], + "wsh(thresh(3,pk([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa/<0;1>/*),s:pk([b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js/<0;1>/*),s:pk([a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2/<0;1>/*),sln:older(12960)))", + ), + ( + "wsh(or_d(pk(@0/**),and_v(v:multi(2,@1/**,@2/**,@3/**),older(65535))))", + ["[6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa", + "[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js", + "[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2", + "[bb641298/44'/0'/0'/100']xpub6Dz8PHFmXkYkykQ83ySkruky567XtJb9N69uXScJZqweYiQn6FyieajdiyjCvWzRZ2GoLHMRE1cwDfuJZ6461YvNRGVBJNnLA35cZrQKSRJ"], + "wsh(or_d(pk([6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa/<0;1>/*),and_v(v:multi(2,[b2b1f0cf/44'/0'/0'/100']xpub6EYajCJHe2CK53RLVXrN14uWoEttZgrRSaRztujsXg7yRhGtHmLBt9ot9Pd5ugfwWEu6eWyJYKSshyvZFKDXiNbBcoK42KRZbxwjRQpm5Js/<0;1>/*,[a666a867/44'/0'/0'/100']xpub6Dgsze3ujLi1EiHoCtHFMS9VLS1UheVqxrHGfP7sBJ2DBfChEUHV4MDwmxAXR2ayeytpwm3zJEU3H3pjCR6q6U5sP2p2qzAD71x9z5QShK2/<0;1>/*,[bb641298/44'/0'/0'/100']xpub6Dz8PHFmXkYkykQ83ySkruky567XtJb9N69uXScJZqweYiQn6FyieajdiyjCvWzRZ2GoLHMRE1cwDfuJZ6461YvNRGVBJNnLA35cZrQKSRJ/<0;1>/*),older(65535))))", + ), + ( + "tr(@0/**,{sortedmulti_a(1,@0/<2;3>/*,@1/**),or_b(pk(@2/**),s:pk(@3/**))})", + ["[6738736c/48'/0'/0'/100']xpub6FC1fXFP1GXQpyRFfSE1vzzySqs3Vg63bzimYLeqtNUYbzA87kMNTcuy9ubr7MmavGRjW2FRYHP4WGKjwutbf1ghgkUW9H7e3ceaPLRcVwa", + "xpub6Fc2TRaCWNgfT49nRGG2G78d1dPnjhW66gEXi7oYZML7qEFN8e21b2DLDipTZZnfV6V7ivrMkvh4VbnHY2ChHTS9qM3XVLJiAgcfagYQk6K", + "xpub6GxHB9kRdFfTqYka8tgtX9Gh3Td3A9XS8uakUGVcJ9NGZ1uLrGZrRVr67DjpMNCHprZmVmceFTY4X4wWfksy8nVwPiNvzJ5pjLxzPtpnfEM", + "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))}})", + # ), +] + +invalid = [ + ( + # Key placeholder with no path following it + "key derivation missing", + "pkh(@0)", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg"], + ), + ( + # Key placeholder with an explicit path present + "need multipath", + "pkh(@0/0/**)", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg"], + ), + ( + # Key placeholders out of order + "Out of order", + "sh(multi(1,@1/**,@0/**))", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg", + "[6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb"], + ), + ( + # Skipped key placeholder @1 + "Out of order", + "sh(multi(1,@0/**,@2/**))", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg", + "[6738736c/44'/0'/0']xpub6Br37sWxruYfT8ASpCjVHKGwgdnYFEn98DwiN76i2oyY6fgH1LAPmmDcF46xjxJr22gw4jmVjTE2E3URMnRPEPYyo1zoPSUba563ESMXCeb"], + ), + ( + # Repeated keys with the same path expression + "Insane", + "sh(multi(1,@0/**,@0/**))", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg"], + ), + ( + # Non-disjoint multipath expressions (@0/1/* appears twice) + "Non-disjoint multipath", + "sh(multi(1,@0/<0;1>/*,@0/<1;2>/*))", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg"], + ), + ( + # Taproot non-disjoint multipath expressions (@0/1/* appears twice in tapscript) + "Non-disjoint multipath", + "tr(@0/<5;6>/*,multi_a(1,@0/<0;1>/*,@0/<1;2>/*))", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg"], + ), + ( + # Taproot non-disjoint multipath expressions (@0/1/* appears twice as internal key and tapscript key) + "Non-disjoint multipath", + "tr(@0/<0;1>/*,multi_a(1,@0/<5;6>/*,@0/<1;2>/*))", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg"], + ), + ( + # solved cardinality > 2 + "Solved cardinality > 2", + "pkh(@0/<0;1;2>/*)", + ["[0f056943/99h/0h/0h]xpub6DMjVrmtVxXyn5hBuLScBtHeQ9X3ws6uasj7mWRu7ay7mQrX5suQKwYgNZBJYnWKugRk1KrgmTHtgwGvB7QcgELYCuacE3oA25SGMQZTiRg"], + ) +] + +import glob +from glob import settings +from descriptor import Descriptor +from miniscript import MiniScriptWallet + +settings.set('chain', "BTC") + +# valid vectors +for policy, keys_info, desc in valid: + d = Descriptor.from_string(desc, validate=False) + pol, ki = d.bip388_wallet_policy() + assert pol == policy, "\n" + pol + "\n" + policy + assert keys_info == keys_info + +# invalid vectors +for err, policy, keys_info in invalid: + glob.DESC_CACHE = {} + try: + msc = MiniScriptWallet.from_bip388_wallet_policy("random_name", policy, keys_info) + assert False, "succeeded, but must have failed!" + except BaseException as e: + if err not in str(e): + raise diff --git a/testing/test_unit.py b/testing/test_unit.py index 4e23556b..25f4e7fe 100644 --- a/testing/test_unit.py +++ b/testing/test_unit.py @@ -394,4 +394,8 @@ def test_script(sim_execfile): res = sim_execfile('devtest/unit_script.py') assert res == "" +def test_bip388(sim_execfile): + res = sim_execfile('devtest/unit_bip388.py') + assert res == "" + # EOF