From 5277da33dcbd828acacf0db829f0111b12e555d6 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sun, 23 Oct 2022 16:57:05 +0200 Subject: [PATCH 1/4] bip380: multipath descriptors (BIP389) support --- bip380/descriptors/__init__.py | 53 ++++++++++- bip380/key.py | 161 +++++++++++++++++++++++++-------- tests/test_descriptors.py | 33 +++++-- 3 files changed, 197 insertions(+), 50 deletions(-) diff --git a/bip380/descriptors/__init__.py b/bip380/descriptors/__init__.py index b5070a5..9e3caa4 100644 --- a/bip380/descriptors/__init__.py +++ b/bip380/descriptors/__init__.py @@ -10,6 +10,7 @@ ) from .checksum import descsum_create +from .errors import DescriptorParsingError from .parsing import descriptor_from_str @@ -21,7 +22,22 @@ def from_str(desc_str, strict=False): :param strict: whether to require the presence of a checksum. """ - return descriptor_from_str(desc_str, strict) + desc = descriptor_from_str(desc_str, strict) + + # BIP389 prescribes that no two multipath key expressions in a single descriptor + # have different length. + multipath_len = None + for key in desc.keys: + if key.is_multipath(): + m_len = len(key.path.paths) + if multipath_len is None: + multipath_len = m_len + elif multipath_len != m_len: + raise DescriptorParsingError( + f"Descriptor contains multipath key expressions with varying length: '{desc_str}'." + ) + + return desc @property def script_pubkey(self): @@ -60,6 +76,41 @@ def satisfy(self, *args, **kwargs): # To be implemented by derived classes raise NotImplementedError + def copy(self): + """Get a copy of this descriptor.""" + # FIXME: do something nicer than roundtripping through string ser + return Descriptor.from_str(str(self)) + + def is_multipath(self): + """Whether this descriptor contains multipath key expression(s).""" + return any(k.is_multipath() for k in self.keys) + + def singlepath_descriptors(self): + """Get a list of descriptors that only contain keys that don't have multiple + derivation paths. + """ + singlepath_descs = [self.copy()] + + # First figure out the number of descriptors there will be + for key in self.keys: + if key.is_multipath(): + singlepath_descs += [self.copy() for _ in range(len(key.path.paths) - 1)] + break + + # Return early if there was no multipath key expression + if len(singlepath_descs) == 1: + return singlepath_descs + + # Then use one path for each + for i, desc in enumerate(singlepath_descs): + for key in desc.keys: + if key.is_multipath(): + assert len(key.path.paths) == len(singlepath_descs) + key.path.paths = key.path.paths[i : i + 1] + + assert all(not d.is_multipath() for d in singlepath_descs) + return singlepath_descs + # TODO: add methods to give access to all the Miniscript analysis class WshDescriptor(Descriptor): diff --git a/bip380/key.py b/bip380/key.py index ae50eda..51c6c36 100644 --- a/bip380/key.py +++ b/bip380/key.py @@ -1,4 +1,6 @@ -from bip32 import BIP32 +import copy + +from bip32 import BIP32, HARDENED_INDEX from bip32.utils import coincurve, _deriv_path_str_to_list from bip380.utils.hashes import hash160 from enum import Enum, auto @@ -58,48 +60,83 @@ def is_wildcard(self): return self in [KeyPathKind.WILDCARD_HARDENED, KeyPathKind.WILDCARD_UNHARDENED] +def parse_index(index_str): + """Parse a derivation index, as contained in a derivation path.""" + assert isinstance(index_str, str) + + try: + # if HARDENED + if index_str[-1:] in ["'", "h", "H"]: + return int(index_str[:-1]) + HARDENED_INDEX + else: + return int(index_str) + except ValueError as e: + raise DescriptorKeyError(f"Invalid derivation index {index_str}: '{e}'") + + class DescriptorKeyPath: """The derivation path of a key in a descriptor. - See https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions. + See https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#key-expressions + as well as BIP389 for multipath expressions. """ - def __init__(self, path, kind): - assert isinstance(path, list) and isinstance(kind, KeyPathKind) + def __init__(self, paths, kind): + assert ( + isinstance(paths, list) + and isinstance(kind, KeyPathKind) + and len(paths) > 0 + and all(isinstance(p, list) for p in paths) + ) - self.path = path + self.paths = paths self.kind = kind + def is_multipath(self): + """Whether this derivation path actually contains multiple of them.""" + return len(self.paths) > 1 + def from_str(path_str): - if len(path_str) < 1: + if len(path_str) < 2: raise DescriptorKeyError(f"Insane key path: '{path_str}'") - if path_str[0] == "/": + if path_str[0] != "/": raise DescriptorKeyError(f"Insane key path: '{path_str}'") # Determine whether this key may be derived. kind = KeyPathKind.FINAL - if path_str[-2:] in ["*'", "*h", "*H"]: + if len(path_str) > 2 and path_str[-3:] in ["/*'", "/*h", "/*H"]: kind = KeyPathKind.WILDCARD_HARDENED - path_str = path_str[:-2] - elif path_str[-1] == "*": + path_str = path_str[:-3] + elif len(path_str) > 1 and path_str[-2:] == "/*": kind = KeyPathKind.WILDCARD_UNHARDENED - path_str = path_str[:-1] - - # We use an internal helper from python-bip32 to parse the path. - # The helper operates on "m/10h/11/12'/13", so give it a "m/". - if len(path_str) > 1: - dummy = "m/" - # If we just trimmed the wildcard part, time the trailing '/' too. - if kind.is_wildcard(): - path_str = path_str[:-1] - try: - path = _deriv_path_str_to_list(dummy + path_str) - except ValueError: - raise DescriptorKeyError(f"Insane path in key path: '{path_str}'") - else: - path = [] + path_str = path_str[:-2] - return DescriptorKeyPath(path, kind) + paths = [[]] + if len(path_str) == 0: + return DescriptorKeyPath(paths, kind) + + for index in path_str[1:].split("/"): + # If this is a multipath expression, of the form '' + if ( + index.startswith("<") + and index.endswith(">") + and ";" in index + and len(index) >= 5 + ): + # Can't have more than one multipath expression + if len(paths) > 1: + raise DescriptorKeyError( + f"May only have a single multipath step in derivation path: '{path_str}'" + ) + indexes = index[1:-1].split(";") + paths = [copy.copy(paths[0]) for _ in indexes] + for i, der_index in enumerate(indexes): + paths[i].append(parse_index(der_index)) + else: + # This is a "single index" expression. + for path in paths: + path.append(parse_index(index)) + return DescriptorKeyPath(paths, kind) class DescriptorKey: @@ -144,7 +181,7 @@ def __init__(self, key): splitted_key = key.split("/", maxsplit=1) if len(splitted_key) == 2: key, path = splitted_key - self.path = DescriptorKeyPath.from_str(path) + self.path = DescriptorKeyPath.from_str("/" + path) try: self.key = BIP32.from_xpub(key) @@ -159,17 +196,33 @@ def __init__(self, key): def __repr__(self): key = "" - def ser_path(key, path): - for i in path: - if i < 2**31: - key += f"/{i}" + def ser_index(key, der_index): + # If this a hardened step, deduce the threshold and mark it. + if der_index < HARDENED_INDEX: + return str(der_index) + else: + return f"{der_index - 2**31}'" + + def ser_paths(key, paths): + assert len(paths) > 0 + + for i, der_index in enumerate(paths[0]): + # If this is a multipath expression, write the multi-index step accordingly + if len(paths) > 1 and paths[1][i] != der_index: + key += "/<" + for j, path in enumerate(paths): + key += ser_index(key, path[i]) + if j < len(paths) - 1: + key += ";" + key += ">" else: - key += f"/{i - 2**31}'" + key += "/" + ser_index(key, der_index) + return key if self.origin is not None: key += f"[{self.origin.fingerprint.hex()}" - key = ser_path(key, self.origin.path) + key = ser_paths(key, [self.origin.path]) key += "]" if isinstance(self.key, BIP32): @@ -179,30 +232,57 @@ def ser_path(key, path): key += self.key.format().hex() if self.path is not None: - key = ser_path(key, self.path.path) + key = ser_paths(key, self.path.paths) if self.path.kind.is_wildcard(): key += "/*" return key + def is_multipath(self): + """Whether this key contains more than one derivation path.""" + return self.path is not None and self.path.is_multipath() + + def derivation_path(self): + """Get the single derivation path for this key. + + Will raise if it has multiple, and return None if it doesn't have any. + """ + if self.path is None: + return None + if self.path.is_multipath(): + raise DescriptorKeyError( + f"Key has multiple derivation paths: {self.path.paths}" + ) + return self.path.paths[0] + def bytes(self): + """Get this key as raw bytes. + + Will raise if this key contains multiple derivation paths. + """ if isinstance(self.key, coincurve.PublicKey): return self.key.format() else: assert isinstance(self.key, BIP32) - if self.path is None or self.path.path == []: + path = self.derivation_path() + if path is None: return self.key.pubkey assert not self.path.kind.is_wildcard() # TODO: real errors - return self.key.get_pubkey_from_path(self.path.path) + return self.key.get_pubkey_from_path(path) def derive(self, index): """Derive the key at the given index. + Will raise if this key contains multiple derivation paths. A no-op if the key isn't a wildcard. Will start from 2**31 if the key is a "hardened wildcard". """ assert isinstance(index, int) - if self.path is None or self.path.kind == KeyPathKind.FINAL: + if ( + self.path is None + or self.path.is_multipath() + or self.path.kind == KeyPathKind.FINAL + ): return assert isinstance(self.key, BIP32) @@ -215,8 +295,9 @@ def derive(self, index): self.origin = DescriporKeyOrigin(fingerprint, [index]) else: self.origin.path.append(index) + + # This can't fail now. + path = self.derivation_path() # TODO(bip32): have a way to derive without roundtripping through string ser. - self.key = BIP32.from_xpub( - self.key.get_xpub_from_path(self.path.path + [index]) - ) + self.key = BIP32.from_xpub(self.key.get_xpub_from_path(path + [index])) self.path = None diff --git a/tests/test_descriptors.py b/tests/test_descriptors.py index 68427ee..9dd8b3e 100644 --- a/tests/test_descriptors.py +++ b/tests/test_descriptors.py @@ -187,12 +187,12 @@ def test_descriptor_parsing(): desc = Descriptor.from_str( "wpkh([00aabbcc/1]xpub6BsJ4SAX3CYhcZVV9bFVvmGJ7cyboy4LJqbRJJEziPvm9Pq7v7cWkBAa1LixG9vJybxHDuWcHTtq3K4tsaKG1jMJcpZmkiacFuc7LkzUCWu/*)" ) - assert desc.keys[0].path.path == [] + assert desc.keys[0].path.paths == [[]] assert desc.keys[0].path.kind == KeyPathKind.WILDCARD_UNHARDENED desc = Descriptor.from_str( "wpkh([00aabbcc/1]xpub6BsJ4SAX3CYhcZVV9bFVvmGJ7cyboy4LJqbRJJEziPvm9Pq7v7cWkBAa1LixG9vJybxHDuWcHTtq3K4tsaKG1jMJcpZmkiacFuc7LkzUCWu/0/2/3242/5H/2'/*h)" ) - assert desc.keys[0].path.path == [0, 2, 3242, 5 + 2 ** 31, 2 + 2 ** 31] + assert desc.keys[0].path.paths == [[0, 2, 3242, 5 + 2 ** 31, 2 + 2 ** 31]] assert desc.keys[0].path.kind == KeyPathKind.WILDCARD_HARDENED # Multiple origins @@ -266,24 +266,39 @@ def test_descriptor_parsing(): assert str(desc2) == str(desc) # Test against a Revault deposit descriptor using rust-miniscript - xpub = BIP32.from_xpub("tpubD6NzVbkrYhZ4YgUwLbJjHAo4khrBPHJfZ1nzeeWxaTpYHzvM7SaEFLnuWjcRt8aM3LicBzeqVcN4fKsbTzHSkUJn388HSc5Xxpd1tPSmDYQ") - assert xpub.get_pubkey_from_path([0]).hex() == "02cc24adfed5a481b000192042b2399087437d8eb16095c3dda1d45a4fbf868017" + xpub = BIP32.from_xpub( + "tpubD6NzVbkrYhZ4YgUwLbJjHAo4khrBPHJfZ1nzeeWxaTpYHzvM7SaEFLnuWjcRt8aM3LicBzeqVcN4fKsbTzHSkUJn388HSc5Xxpd1tPSmDYQ" + ) + assert ( + xpub.get_pubkey_from_path([0]).hex() + == "02cc24adfed5a481b000192042b2399087437d8eb16095c3dda1d45a4fbf868017" + ) desc_str = "wsh(multi(5,tpubD6NzVbkrYhZ4YgUwLbJjHAo4khrBPHJfZ1nzeeWxaTpYHzvM7SaEFLnuWjcRt8aM3LicBzeqVcN4fKsbTzHSkUJn388HSc5Xxpd1tPSmDYQ/*,tpubD6NzVbkrYhZ4X9w1pgeFqiDm7o4dkvEku1ibW6frK5n3vWsSGjxoo3DESgwwZW5N8eN72vCywJzmbezhQQHMbpUytZcxYYTAEaQzUntBEtP/*,tpubD6NzVbkrYhZ4X2M619JZbwnPoQ65e5qzosWPtXYMnMtevcQTwVHq6HFbu5whCAp4PpynzrE65MXk2kgqUb22aE2V5NPZJautw8vXDmVMGuz/*,tpubD6NzVbkrYhZ4YNHo23GAaYnfs8xzyhxpaWZsHJ72a9RPwiQd36BtyHnpRSQFYAJMLK2tWb6i7QJcjNuko4b4V3kGyhe6Z4TxZGXJfEvTU12/*,tpubD6NzVbkrYhZ4YdMUbJuBi6mhtYAC53MevdrFtpQicPavbnYDni6YsAD62NUhQxHYYJpAniVk4Ba9Q2GiptSZPz8ugbo3zgecm2aXQRFny4a/*))#339j7vh3" rust_bitcoin_desc_str = "wsh(multi(5,[7fba6fe6/0]02cc24adfed5a481b000192042b2399087437d8eb16095c3dda1d45a4fbf868017,[d7724f76/0]039b2b68caf451ba88afe617cb57f2e9840511bedb0ac8ffa2dc2b25d4ea84adf1,[0c39ed43/0]03f7c1d37ff5dfd5a8b5326533810cef71f7f724fd53d2a88f49e3c63edc5f9688,[e69af179/0]0296209843f0f4dd7b1f3a072e72e7b4edd2e3ff416afc862a7a7aa0b9d40d2de6,[e42852b6/0]03427930b60ba45aeb5c7e03fc3b6b7b22637bec5d355c55204678d7dd8a029981))#vatx0fxr" desc = Descriptor.from_str(desc_str) + assert str(desc) == desc_str desc.derive(0) # In the string representation they would use raw keys. Do the same for asserting. for key in desc.keys: key.key = coincurve.PublicKey(key.key.pubkey) + print(desc) assert str(desc) == str(rust_bitcoin_desc_str) # Same, but to check that the Script is actually being derived too... desc_str = "wsh(multi(5,tpubD6NzVbkrYhZ4Yb5yyh2qqUnfGzyakvyzYei3qf2roEMuP7DFB47CDhcUW93YjFGGpwXgUbjeFfoapYyXyyUD2cT1tTzdBCMAhsNTmEJxLM2/*,tpubD6NzVbkrYhZ4Wn1byYeaSwqq6aHni5hQmzHmha8WUgQFH7H5mQ4NZXM8dTs52kqsaxFuau7edrm27ZXNbyp6V5vRJxLZ9oxB92F1dVVAnTn/*,tpubD6NzVbkrYhZ4XLQ56KtSZs1ezkUfD2f1QsUPRvVRqmoo1xsJ9DM6Yao4XKqkEDxGHenroWaooEbpjDTzr7W2LB5CYVPn83eacD1swW38W5G/*,tpubD6NzVbkrYhZ4Ys7ii3MvAhZVowvQRPHwT9uctEnxEmnXR7KtBqyEofT6LmvXov5tpMLDcMhNCC3pi4NrLq1vG51rPcsFGtP5MDHq2F9Bj5Z/*,tpubD6NzVbkrYhZ4WmzxsFZByU1tKop9SWd5YHH81b2gbT5ycGAkZfthcwNAcQZmxswzTvpjBaswKgbcEKksbkGW65wbQsA4DEaCq9c7SqUZ9oi/*))#p26mhq70, deriv index 0, desc wsh(multi(5,[838f8104/0]02de76d54f7e28d731f403f5d1fad4da1df208e1d6e00dbe6dfbadd804461c2743,[24e59fd4/0]02f65c2812d2a8d1da479d0cf32d4ca717263bcdadd4b3f11a014b8cc27f73ec44,[cf0b5330/0]024bf97e1bfc4b5c1de90172d50d92fe072da40a8ccd0f89cd5e858b9dc1226623,[cecf756b/0]023bdc599713ea7b982dc3f439aad24f6c6c8b1a4617f339ba976a48d9067a7d67,[04458729/0]0245cca25b3ecea1a82157bc98b9c35caa53d0f65b9ecb5bfdbb80749d22357c45))#h88gukn3" - desc = Descriptor.from_str(desc_str) - desc.derive(0) - assert desc.script_pubkey != Descriptor.from_str(desc_str).script_pubkey - assert desc.script_pubkey.hex() == "002076fb586cb821ac94fbe094e012b93d82cc42925bcf543415416f42aa3ba1822c" - assert desc.witness_script.script.hex() == "552102de76d54f7e28d731f403f5d1fad4da1df208e1d6e00dbe6dfbadd804461c27432102f65c2812d2a8d1da479d0cf32d4ca717263bcdadd4b3f11a014b8cc27f73ec4421024bf97e1bfc4b5c1de90172d50d92fe072da40a8ccd0f89cd5e858b9dc122662321023bdc599713ea7b982dc3f439aad24f6c6c8b1a4617f339ba976a48d9067a7d67210245cca25b3ecea1a82157bc98b9c35caa53d0f65b9ecb5bfdbb80749d22357c4555ae" + desc_a = Descriptor.from_str(desc_str) + desc_a.derive(0) + desc_b = Descriptor.from_str(desc_str) + desc_b.derive(1) + assert desc_a.script_pubkey != desc_b.script_pubkey + assert ( + desc_a.script_pubkey.hex() + == "002076fb586cb821ac94fbe094e012b93d82cc42925bcf543415416f42aa3ba1822c" + ) + assert ( + desc_a.witness_script.script.hex() + == "552102de76d54f7e28d731f403f5d1fad4da1df208e1d6e00dbe6dfbadd804461c27432102f65c2812d2a8d1da479d0cf32d4ca717263bcdadd4b3f11a014b8cc27f73ec4421024bf97e1bfc4b5c1de90172d50d92fe072da40a8ccd0f89cd5e858b9dc122662321023bdc599713ea7b982dc3f439aad24f6c6c8b1a4617f339ba976a48d9067a7d67210245cca25b3ecea1a82157bc98b9c35caa53d0f65b9ecb5bfdbb80749d22357c4555ae" + ) # An Unvault descriptor from Revault desc_str = "wsh(andor(thresh(1,pk(tpubD6NzVbkrYhZ4Wu1wWF6gEL8tAZvATeGodn1ymPeC3eo9XGdj6fats9QdMG88KZ23FjV4SyTn5LAHLLwRmor4n6yWBH5ccJLnj7LWcuyPuDQ/*)),and_v(v:multi(2,0227cb9432f93edc3ba82ca70c75bda335553a999e6ab885bc337fcb837aa18f4a,02ed00f0a17f220c7b2179ab9610ea2cccaf290c0f726ce472ab959b2528d2b9de),older(9990)),thresh(2,pkh(tpubD6NzVbkrYhZ4Y1KSo5w1yFPreF7THiygs775SRLyMKJZ8ACgtkLJPNb9UiDk4L4MJuYPsdViWfY65tteiub51YZtqjjv6kLKdKH5WSdH7Br/*),a:pkh(tpubD6NzVbkrYhZ4Xhspiqm3eot2TddA2XmcPmqHyRftxFaKkZWuePH4RXw3Af6CpPfnhRBKPjz7TveUGi91EXTph5V7qHYJ4ijG3NtCjrCKPRH/*))))#se46h9uw" From 7b0cd1dfb2478bdc65c690bfe38a57144877a35f Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sun, 23 Oct 2022 17:21:50 +0200 Subject: [PATCH 2/4] bip380: fix serialization of hardened wildcards --- bip380/key.py | 4 +++- tests/test_descriptors.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bip380/key.py b/bip380/key.py index 51c6c36..cb4e71b 100644 --- a/bip380/key.py +++ b/bip380/key.py @@ -233,8 +233,10 @@ def ser_paths(key, paths): if self.path is not None: key = ser_paths(key, self.path.paths) - if self.path.kind.is_wildcard(): + if self.path.kind == KeyPathKind.WILDCARD_UNHARDENED: key += "/*" + elif self.path.kind == KeyPathKind.WILDCARD_HARDENED: + key += "/*'" return key diff --git a/tests/test_descriptors.py b/tests/test_descriptors.py index 9dd8b3e..dce665c 100644 --- a/tests/test_descriptors.py +++ b/tests/test_descriptors.py @@ -108,6 +108,7 @@ def test_xpub_parsing(): "xpub661MyMwAqRbcGC7awXn2f36qPMLE2x42cQM5qHrSRg3Q8X7qbDEG1aKS4XAA1PcWTZn7c4Y2WJKCvcivjpZBXTo8fpCRrxtmNKW4H1rpACa/1'/2", "xpub661MyMwAqRbcGC7awXn2f36qPMLE2x42cQM5qHrSRg3Q8X7qbDEG1aKS4XAA1PcWTZn7c4Y2WJKCvcivjpZBXTo8fpCRrxtmNKW4H1rpACa/145/*", "[aabbccdd/0/1'/2]xpub661MyMwAqRbcGC7awXn2f36qPMLE2x42cQM5qHrSRg3Q8X7qbDEG1aKS4XAA1PcWTZn7c4Y2WJKCvcivjpZBXTo8fpCRrxtmNKW4H1rpACa/1'/2/*", + "xpub661MyMwAqRbcGC7awXn2f36qPMLE2x42cQM5qHrSRg3Q8X7qbDEG1aKS4XAA1PcWTZn7c4Y2WJKCvcivjpZBXTo8fpCRrxtmNKW4H1rpACa/*'", ] for xpub in xpubs: assert str(DescriptorKey(xpub)) == xpub From f45a946b6a9128457eb1303c894e507a35ab4238 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sun, 23 Oct 2022 17:46:17 +0200 Subject: [PATCH 3/4] qa: test multipath descriptors support --- tests/test_descriptors.py | 118 +++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/tests/test_descriptors.py b/tests/test_descriptors.py index dce665c..7ac0789 100644 --- a/tests/test_descriptors.py +++ b/tests/test_descriptors.py @@ -2,7 +2,7 @@ import os import pytest -from bip32 import BIP32 +from bip32 import BIP32, HARDENED_INDEX from bitcointx.core import ( CMutableTxIn, CMutableTxOut, @@ -20,7 +20,7 @@ SIGVERSION_WITNESS_V0, ) from bip380.descriptors import Descriptor -from bip380.key import DescriptorKey, KeyPathKind +from bip380.key import DescriptorKey, KeyPathKind, DescriptorKeyError from bip380.miniscript import SatisfactionMaterial from bip380.descriptors.errors import DescriptorParsingError from bip380.utils.hashes import sha256 @@ -109,10 +109,59 @@ def test_xpub_parsing(): "xpub661MyMwAqRbcGC7awXn2f36qPMLE2x42cQM5qHrSRg3Q8X7qbDEG1aKS4XAA1PcWTZn7c4Y2WJKCvcivjpZBXTo8fpCRrxtmNKW4H1rpACa/145/*", "[aabbccdd/0/1'/2]xpub661MyMwAqRbcGC7awXn2f36qPMLE2x42cQM5qHrSRg3Q8X7qbDEG1aKS4XAA1PcWTZn7c4Y2WJKCvcivjpZBXTo8fpCRrxtmNKW4H1rpACa/1'/2/*", "xpub661MyMwAqRbcGC7awXn2f36qPMLE2x42cQM5qHrSRg3Q8X7qbDEG1aKS4XAA1PcWTZn7c4Y2WJKCvcivjpZBXTo8fpCRrxtmNKW4H1rpACa/*'", + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/<0;1;42;9854>", + "[aabbccdd/0/1'/2]tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/<0;1;42;9854>", + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/<0;1;9854>/0/5/10", + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/<0;1;9854>/3456/9876/*", + "[abcdef00/0'/1']tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/<0;1>/*", + "[abcdef00/0'/1']tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/9478'/<0';1'>/8'/*'", ] for xpub in xpubs: assert str(DescriptorKey(xpub)) == xpub + tpub = DescriptorKey( + "[abcdef00/0'/1]tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/9478'/<0';1';420>/8'/*'" + ) + assert tpub.path.paths == [ + [9478 + HARDENED_INDEX, 0 + HARDENED_INDEX, 8 + HARDENED_INDEX], + [9478 + HARDENED_INDEX, 1 + HARDENED_INDEX, 8 + HARDENED_INDEX], + [9478 + HARDENED_INDEX, 420, 8 + HARDENED_INDEX], + ] + assert tpub.path.kind == KeyPathKind.WILDCARD_HARDENED + assert tpub.origin.fingerprint == bytes.fromhex("abcdef00") + assert tpub.origin.path == [0 + HARDENED_INDEX, 1] + + with pytest.raises(DescriptorKeyError, match="Invalid derivation index"): + DescriptorKey( + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/<0;1;42;9854" + ) + with pytest.raises(DescriptorKeyError, match="Invalid derivation index"): + DescriptorKey( + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/0;1;42;9854>" + ) + with pytest.raises( + DescriptorKeyError, match="May only have a single multipath step" + ): + DescriptorKey( + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/4/<0;1>/96/<0;1>" + ) + with pytest.raises(DescriptorKeyError, match="Invalid derivation index"): + DescriptorKey( + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/4/<0>" + ) + with pytest.raises(DescriptorKeyError, match="Invalid derivation index"): + DescriptorKey( + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/4/<0;>" + ) + with pytest.raises(DescriptorKeyError, match="Invalid derivation index"): + DescriptorKey( + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/4/<;1>" + ) + with pytest.raises(DescriptorKeyError, match="Invalid derivation index"): + DescriptorKey( + "tpubDBrgjcxBxnXyL575sHdkpKohWu5qHKoQ7TJXKNrYznh5fVEGBv89hA8ENW7A8MFVpFUSvgLqc4Nj1WZcpePX6rrxviVtPowvMuGF5rdT2Vi/2/4/<0;1;>" + ) + def test_descriptor_parsing(): """Misc descriptor parsing checks.""" @@ -304,3 +353,68 @@ def test_descriptor_parsing(): # An Unvault descriptor from Revault desc_str = "wsh(andor(thresh(1,pk(tpubD6NzVbkrYhZ4Wu1wWF6gEL8tAZvATeGodn1ymPeC3eo9XGdj6fats9QdMG88KZ23FjV4SyTn5LAHLLwRmor4n6yWBH5ccJLnj7LWcuyPuDQ/*)),and_v(v:multi(2,0227cb9432f93edc3ba82ca70c75bda335553a999e6ab885bc337fcb837aa18f4a,02ed00f0a17f220c7b2179ab9610ea2cccaf290c0f726ce472ab959b2528d2b9de),older(9990)),thresh(2,pkh(tpubD6NzVbkrYhZ4Y1KSo5w1yFPreF7THiygs775SRLyMKJZ8ACgtkLJPNb9UiDk4L4MJuYPsdViWfY65tteiub51YZtqjjv6kLKdKH5WSdH7Br/*),a:pkh(tpubD6NzVbkrYhZ4Xhspiqm3eot2TddA2XmcPmqHyRftxFaKkZWuePH4RXw3Af6CpPfnhRBKPjz7TveUGi91EXTph5V7qHYJ4ijG3NtCjrCKPRH/*))))#se46h9uw" Descriptor.from_str(desc_str) + + # We can parse a multipath descriptors, and make it into separate single-path descriptors. + multipath_desc = Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/<7';8h;20>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/<0;1;987>/*)))" + ) + assert multipath_desc.is_multipath() + single_path_descs = multipath_desc.singlepath_descriptors() + assert [str(d) for d in single_path_descs] == [ + str( + Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/7'/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/0/*)))" + ) + ), + str( + Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/8h/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/1/*)))" + ) + ), + str( + Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/20/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/987/*)))" + ) + ), + ] + + # Even if only one of the keys is multipath + multipath_desc = Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/*)))" + ) + assert multipath_desc.is_multipath() + single_path_descs = multipath_desc.singlepath_descriptors() + assert [str(d) for d in single_path_descs] == [ + str( + Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/0/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/*)))" + ) + ), + str( + Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/1/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/*)))" + ) + ), + ] + + # We can detect regular singlepath descs + desc = Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/*)))" + ) + assert not desc.is_multipath() + + # We refuse to parse descriptor with multipath key expressions of varying length + with pytest.raises( + DescriptorParsingError, + match="Descriptor contains multipath key expressions with varying length", + ): + Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/<0;1;2;3;4>/*)))" + ) + with pytest.raises( + DescriptorParsingError, + match="Descriptor contains multipath key expressions with varying length", + ): + Descriptor.from_str( + "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/<0;1;2;3>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/<0;1;2>/*)))" + ) From 6c3dc58d15c9151fffcacbb897011d50b4017764 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sun, 23 Oct 2022 18:12:44 +0200 Subject: [PATCH 4/4] miniscript: make exec_info a method for combiners --- bip380/miniscript/fragments.py | 124 +++++++++++++++++++++++---------- tests/test_descriptors.py | 3 + 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/bip380/miniscript/fragments.py b/bip380/miniscript/fragments.py index 8500505..5bed864 100644 --- a/bip380/miniscript/fragments.py +++ b/bip380/miniscript/fragments.py @@ -495,13 +495,19 @@ def __init__(self, sub_x, sub_y): or self.rel_heightlocks and self.rel_timelocks ) - self.exec_info = ExecutionInfo.from_concat(sub_x.exec_info, sub_y.exec_info) - self.exec_info.set_undissatisfiable() # it's V. @property def _script(self): return sum((sub._script for sub in self.subs), start=[]) + @property + def exec_info(self): + exec_info = ExecutionInfo.from_concat( + self.subs[0].exec_info, self.subs[1].exec_info + ) + exec_info.set_undissatisfiable() # it's V. + return exec_info + def satisfaction(self, sat_material): return Satisfaction.from_concat(sat_material, *self.subs) @@ -544,14 +550,17 @@ def __init__(self, sub_x, sub_y): or self.rel_heightlocks and self.rel_timelocks ) - self.exec_info = ExecutionInfo.from_concat( - sub_x.exec_info, sub_y.exec_info, ops_count=1 - ) @property def _script(self): return sum((sub._script for sub in self.subs), start=[]) + [OP_BOOLAND] + @property + def exec_info(self): + return ExecutionInfo.from_concat( + self.subs[0].exec_info, self.subs[1].exec_info, ops_count=1 + ) + def satisfaction(self, sat_material): return Satisfaction.from_concat(sat_material, self.subs[0], self.subs[1]) @@ -585,14 +594,20 @@ def __init__(self, sub_x, sub_z): self.abs_timelocks = any(sub.abs_timelocks for sub in self.subs) self.rel_timelocks = any(sub.rel_timelocks for sub in self.subs) self.no_timelock_mix = all(sub.no_timelock_mix for sub in self.subs) - self.exec_info = ExecutionInfo.from_concat( - sub_x.exec_info, sub_z.exec_info, ops_count=1, disjunction=True - ) @property def _script(self): return sum((sub._script for sub in self.subs), start=[]) + [OP_BOOLOR] + @property + def exec_info(self): + return ExecutionInfo.from_concat( + self.subs[0].exec_info, + self.subs[1].exec_info, + ops_count=1, + disjunction=True, + ) + def satisfaction(self, sat_material): return Satisfaction.from_concat( sat_material, self.subs[0], self.subs[1], disjunction=True @@ -629,15 +644,18 @@ def __init__(self, sub_x, sub_z): self.abs_timelocks = any(sub.abs_timelocks for sub in self.subs) self.rel_timelocks = any(sub.rel_timelocks for sub in self.subs) self.no_timelock_mix = all(sub.no_timelock_mix for sub in self.subs) - self.exec_info = ExecutionInfo.from_or_uneven( - sub_x.exec_info, sub_z.exec_info, ops_count=2 - ) - self.exec_info.set_undissatisfiable() # it's V. @property def _script(self): return self.subs[0]._script + [OP_NOTIF] + self.subs[1]._script + [OP_ENDIF] + @property + def exec_info(self): + exec_info = ExecutionInfo.from_or_uneven( + self.subs[0].exec_info, self.subs[1].exec_info, ops_count=2 + ) + return exec_info.set_undissatisfiable() # it's V. + def satisfaction(self, sat_material): return Satisfaction.from_or_uneven(sat_material, self.subs[0], self.subs[1]) @@ -675,9 +693,6 @@ def __init__(self, sub_x, sub_z): self.abs_timelocks = any(sub.abs_timelocks for sub in self.subs) self.rel_timelocks = any(sub.rel_timelocks for sub in self.subs) self.no_timelock_mix = all(sub.no_timelock_mix for sub in self.subs) - self.exec_info = ExecutionInfo.from_or_uneven( - sub_x.exec_info, sub_z.exec_info, ops_count=3 - ) @property def _script(self): @@ -688,6 +703,12 @@ def _script(self): + [OP_ENDIF] ) + @property + def exec_info(self): + return ExecutionInfo.from_or_uneven( + self.subs[0].exec_info, self.subs[1].exec_info, ops_count=3 + ) + def satisfaction(self, sat_material): return Satisfaction.from_or_uneven(sat_material, self.subs[0], self.subs[1]) @@ -726,9 +747,6 @@ def __init__(self, sub_x, sub_z): self.abs_timelocks = any(sub.abs_timelocks for sub in self.subs) self.rel_timelocks = any(sub.rel_timelocks for sub in self.subs) self.no_timelock_mix = all(sub.no_timelock_mix for sub in self.subs) - self.exec_info = ExecutionInfo.from_or_even( - sub_x.exec_info, sub_z.exec_info, ops_count=3 - ) @property def _script(self): @@ -740,6 +758,12 @@ def _script(self): + [OP_ENDIF] ) + @property + def exec_info(self): + return ExecutionInfo.from_or_even( + self.subs[0].exec_info, self.subs[1].exec_info, ops_count=3 + ) + def satisfaction(self, sat_material): return (self.subs[0].satisfaction(sat_material) + Satisfaction([b"\x01"])) | ( self.subs[1].satisfaction(sat_material) + Satisfaction([b""]) @@ -801,9 +825,6 @@ def __init__(self, sub_x, sub_y, sub_z): or any(sub.abs_timelocks for sub in [sub_x, sub_y]) and any(sub.abs_heightlocks for sub in [sub_x, sub_y]) ) - self.exec_info = ExecutionInfo.from_andor_uneven( - sub_x.exec_info, sub_y.exec_info, sub_z.exec_info, ops_count=3 - ) @property def _script(self): @@ -816,6 +837,15 @@ def _script(self): + [OP_ENDIF] ) + @property + def exec_info(self): + return ExecutionInfo.from_andor_uneven( + self.subs[0].exec_info, + self.subs[1].exec_info, + self.subs[2].exec_info, + ops_count=3, + ) + def satisfaction(self, sat_material): # (A and B) or (!A and C) return ( @@ -898,7 +928,6 @@ def __init__(self, k, subs): or self.rel_heightlocks and self.rel_timelocks ) - self.exec_info = ExecutionInfo.from_thresh(k, [sub.exec_info for sub in subs]) @property def _script(self): @@ -908,6 +937,10 @@ def _script(self): + [self.k, OP_EQUAL] ) + @property + def exec_info(self): + return ExecutionInfo.from_thresh(self.k, [sub.exec_info for sub in self.subs]) + def satisfaction(self, sat_material): return Satisfaction.from_thresh(sat_material, self.k, self.subs) @@ -974,12 +1007,15 @@ def __init__(self, sub): WrapperNode.__init__(self, sub) self.p = Property("W" + "".join(c for c in "ud" if getattr(sub.p, c))) - self.exec_info = ExecutionInfo.from_wrap(sub.exec_info, ops_count=2) @property def _script(self): return [OP_TOALTSTACK] + self.sub._script + [OP_FROMALTSTACK] + @property + def exec_info(self): + return ExecutionInfo.from_wrap(self.sub.exec_info, ops_count=2) + def __repr__(self): # Don't duplicate colons if self.skip_colon(): @@ -993,12 +1029,15 @@ def __init__(self, sub): WrapperNode.__init__(self, sub) self.p = Property("W" + "".join(c for c in "ud" if getattr(sub.p, c))) - self.exec_info = ExecutionInfo.from_wrap(sub.exec_info, ops_count=1) @property def _script(self): return [OP_SWAP] + self.sub._script + @property + def exec_info(self): + return ExecutionInfo.from_wrap(self.sub.exec_info, ops_count=1) + def __repr__(self): # Avoid duplicating colons if self.skip_colon(): @@ -1013,15 +1052,16 @@ def __init__(self, sub): # FIXME: shouldn't n and d be default props on the website? self.p = Property("Bu" + "".join(c for c in "dno" if getattr(sub.p, c))) - # FIXME: should need_sig be set to True here instead of in keys? - self.exec_info = ExecutionInfo.from_wrap( - sub.exec_info, ops_count=1, sat=1, dissat=1 - ) @property def _script(self): return self.sub._script + [OP_CHECKSIG] + @property + def exec_info(self): + # FIXME: should need_sig be set to True here instead of in keys? + return ExecutionInfo.from_wrap(self.sub.exec_info, ops_count=1, sat=1, dissat=1) + def __repr__(self): # Special case of aliases if isinstance(self.subs[0], Pk): @@ -1056,14 +1096,17 @@ def __init__(self, sub): self.p = Property("Bond") self.is_forced = True # sub is V self.is_expressive = True # sub is V, and we add a single dissat - self.exec_info = ExecutionInfo.from_wrap_dissat( - sub.exec_info, ops_count=3, sat=1, dissat=1 - ) @property def _script(self): return [OP_DUP, OP_IF] + self.sub._script + [OP_ENDIF] + @property + def exec_info(self): + return ExecutionInfo.from_wrap_dissat( + self.sub.exec_info, ops_count=3, sat=1, dissat=1 + ) + def satisfaction(self, sat_material): return Satisfaction(witness=[b"\x01"]) + self.subs[0].satisfaction(sat_material) @@ -1085,8 +1128,6 @@ def __init__(self, sub): self.p = Property("V" + "".join(c for c in "zon" if getattr(sub.p, c))) self.is_forced = True # V self.is_expressive = False # V - verify_cost = int(self._script[-1] == OP_VERIFY) - self.exec_info = ExecutionInfo.from_wrap(sub.exec_info, ops_count=verify_cost) @property def _script(self): @@ -1098,6 +1139,11 @@ def _script(self): return self.sub._script[:-1] + [OP_EQUALVERIFY] return self.sub._script + [OP_VERIFY] + @property + def exec_info(self): + verify_cost = int(self._script[-1] == OP_VERIFY) + return ExecutionInfo.from_wrap(self.sub.exec_info, ops_count=verify_cost) + def dissatisfaction(self): return Satisfaction.unavailable() # It's V. @@ -1116,14 +1162,15 @@ def __init__(self, sub): self.p = Property("Bnd" + "".join(c for c in "ou" if getattr(sub.p, c))) self.is_forced = False # d self.is_expressive = sub.is_forced - self.exec_info = ExecutionInfo.from_wrap_dissat( - sub.exec_info, ops_count=4, dissat=1 - ) @property def _script(self): return [OP_SIZE, OP_0NOTEQUAL, OP_IF, *self.sub._script, OP_ENDIF] + @property + def exec_info(self): + return ExecutionInfo.from_wrap_dissat(self.sub.exec_info, ops_count=4, dissat=1) + def dissatisfaction(self): return Satisfaction(witness=[b""]) @@ -1140,12 +1187,15 @@ def __init__(self, sub): WrapperNode.__init__(self, sub) self.p = Property("Bu" + "".join(c for c in "zond" if getattr(sub.p, c))) - self.exec_info = ExecutionInfo.from_wrap(sub.exec_info, ops_count=1) @property def _script(self): return [*self.sub._script, OP_0NOTEQUAL] + @property + def exec_info(self): + return ExecutionInfo.from_wrap(self.sub.exec_info, ops_count=1) + def __repr__(self): # Avoid duplicating colons if self.skip_colon(): diff --git a/tests/test_descriptors.py b/tests/test_descriptors.py index 7ac0789..e301f9c 100644 --- a/tests/test_descriptors.py +++ b/tests/test_descriptors.py @@ -378,6 +378,9 @@ def test_descriptor_parsing(): ), ] + # Minisafe descriptor + Descriptor.from_str("wsh(or_d(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/<0;1>/*),older(65000))))") + # Even if only one of the keys is multipath multipath_desc = Descriptor.from_str( "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/0'/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/8/4567/*)))"