diff --git a/electrum/bip32.py b/electrum/bip32.py index 796777081..225427f78 100644 --- a/electrum/bip32.py +++ b/electrum/bip32.py @@ -2,7 +2,9 @@ # Distributed under the MIT software license, see the accompanying # file LICENCE or http://www.opensource.org/licenses/mit-license.php +import binascii import hashlib +import struct from typing import List, Tuple, NamedTuple, Union, Iterable, Sequence, Optional from .util import bfh, BitcoinException @@ -122,7 +124,13 @@ class BIP32Node(NamedTuple): child_number: bytes = b'\x00'*4 @classmethod - def from_xkey(cls, xkey: str, *, net=None) -> 'BIP32Node': + def from_xkey( + cls, + xkey: str, + *, + net=None, + allow_custom_headers: bool = True, # to also accept ypub/zpub + ) -> 'BIP32Node': if net is None: net = constants.net xkey = DecodeBase58Check(xkey) @@ -143,6 +151,8 @@ class BIP32Node(NamedTuple): else: raise InvalidMasterKeyVersionBytes(f'Invalid extended key format: {hex(header)}') xtype = headers_inv[header] + if not allow_custom_headers and xtype != "standard": + raise ValueError(f"only standard xpub/xprv allowed. found custom xtype={xtype}") if is_private: eckey = ecc.ECPrivkey(xkey[13 + 33:]) else: @@ -324,14 +334,18 @@ def convert_bip32_path_to_list_of_uint32(n: str) -> List[int]: # makes concatenating paths easier continue prime = 0 - if x.endswith("'") or x.endswith("h"): + if x.endswith("'") or x.endswith("h"): # note: some implementations also accept "H", "p", "P" x = x[:-1] prime = BIP32_PRIME if x.startswith('-'): if prime: raise ValueError(f"bip32 path child index is signalling hardened level in multiple ways") prime = BIP32_PRIME - child_index = abs(int(x)) | prime + try: + x_int = int(x) + except ValueError as e: + raise ValueError(f"failed to parse bip32 path: {(str(e))}") from None + child_index = abs(x_int) | prime if child_index > UINT32_MAX: raise ValueError(f"bip32 path child index too large: {child_index} > {UINT32_MAX}") path.append(child_index) @@ -426,3 +440,84 @@ def is_xkey_consistent_with_key_origin_info(xkey: str, *, if bfh(root_fingerprint) != bip32node.fingerprint: return False return True + + +class KeyOriginInfo: + """ + Object representing the origin of a key. + + from https://github.com/bitcoin-core/HWI/blob/5f300d3dee7b317a6194680ad293eaa0962a3cc7/hwilib/key.py + # Copyright (c) 2020 The HWI developers + # Distributed under the MIT software license. + """ + def __init__(self, fingerprint: bytes, path: Sequence[int]) -> None: + """ + :param fingerprint: The 4 byte BIP 32 fingerprint of a parent key from which this key is derived from + :param path: The derivation path to reach this key from the key at ``fingerprint`` + """ + self.fingerprint: bytes = fingerprint + self.path: Sequence[int] = path + + @classmethod + def deserialize(cls, s: bytes) -> 'KeyOriginInfo': + """ + Deserialize a serialized KeyOriginInfo. + They will be serialized in the same way that PSBTs serialize derivation paths + """ + fingerprint = s[0:4] + s = s[4:] + path = list(struct.unpack("<" + "I" * (len(s) // 4), s)) + return cls(fingerprint, path) + + def serialize(self) -> bytes: + """ + Serializes the KeyOriginInfo in the same way that derivation paths are stored in PSBTs + """ + r = self.fingerprint + r += struct.pack("<" + "I" * len(self.path), *self.path) + return r + + def _path_string(self) -> str: + strpath = self.get_derivation_path() + if len(strpath) >= 2: + assert strpath.startswith("m/") + return strpath[1:] # cut leading "m" + + def to_string(self) -> str: + """ + Return the KeyOriginInfo as a string in the form ///... + This is the same way that KeyOriginInfo is shown in descriptors + """ + s = binascii.hexlify(self.fingerprint).decode() + s += self._path_string() + return s + + @classmethod + def from_string(cls, s: str) -> 'KeyOriginInfo': + """ + Create a KeyOriginInfo from the string + :param s: The string to parse + """ + s = s.lower() + entries = s.split("/") + fingerprint = binascii.unhexlify(s[0:8]) + path: Sequence[int] = [] + if len(entries) > 1: + path = convert_bip32_path_to_list_of_uint32(s[9:]) + return cls(fingerprint, path) + + def get_derivation_path(self) -> str: + """ + Return the string for just the path + """ + return convert_bip32_intpath_to_strpath(self.path) + + def get_full_int_list(self) -> List[int]: + """ + Return a list of ints representing this KeyOriginInfo. + The first int is the fingerprint, followed by the path + """ + xfp = [struct.unpack(" str: return construct_script([0, wsh]) def pubkey_to_address(txin_type: str, pubkey: str, *, net=None) -> str: - if txin_type == 'p2pkh': - return public_key_to_p2pkh(bfh(pubkey), net=net) - elif txin_type == 'p2wpkh': - return public_key_to_p2wpkh(bfh(pubkey), net=net) - elif txin_type == 'p2wpkh-p2sh': - scriptSig = p2wpkh_nested_script(pubkey) - return hash160_to_p2sh(hash_160(bfh(scriptSig)), net=net) - else: - raise NotImplementedError(txin_type) + from . import descriptor + desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=txin_type) + return desc.expand().address(net=net) # TODO this method is confusingly named @@ -448,7 +442,7 @@ def redeem_script_to_address(txin_type: str, scriptcode: str, *, net=None) -> st raise NotImplementedError(txin_type) -def script_to_address(script: str, *, net=None) -> str: +def script_to_address(script: str, *, net=None) -> Optional[str]: from .transaction import get_address_from_output_script return get_address_from_output_script(bfh(script), net=net) diff --git a/electrum/commands.py b/electrum/commands.py index 960bae252..33073bb39 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -66,6 +66,7 @@ from . import submarine_swaps from . import GuiImportError from . import crypto from . import constants +from . import descriptor if TYPE_CHECKING: from .network import Network @@ -394,9 +395,8 @@ class Commands: txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec) pubkey = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed) keypairs[pubkey] = privkey, compressed - txin.script_type = txin_type - txin.pubkeys = [bfh(pubkey)] - txin.num_sig = 1 + desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=txin_type) + txin.script_descriptor = desc inputs.append(txin) outputs = [PartialTxOutput.from_address_and_value(txout['address'], int(txout.get('value', txout['value_sats']))) @@ -420,11 +420,11 @@ class Commands: for priv in privkey: txin_type, priv2, compressed = bitcoin.deserialize_privkey(priv) pubkey = ecc.ECPrivkey(priv2).get_public_key_bytes(compressed=compressed) - address = bitcoin.pubkey_to_address(txin_type, pubkey.hex()) + desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey.hex(), script_type=txin_type) + address = desc.expand().address() if address in txins_dict.keys(): for txin in txins_dict[address]: - txin.pubkeys = [pubkey] - txin.script_type = txin_type + txin.script_descriptor = desc tx.sign({pubkey.hex(): (priv2, compressed)}) return tx.serialize() diff --git a/electrum/descriptor.py b/electrum/descriptor.py new file mode 100644 index 000000000..4ad9fd71a --- /dev/null +++ b/electrum/descriptor.py @@ -0,0 +1,1047 @@ +# Copyright (c) 2017 Andrew Chow +# Copyright (c) 2023 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php +# +# forked from https://github.com/bitcoin-core/HWI/blob/5f300d3dee7b317a6194680ad293eaa0962a3cc7/hwilib/descriptor.py +# +# Output Script Descriptors +# See https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md +# +# TODO allow xprv +# TODO hardened derivation +# TODO allow WIF privkeys +# TODO impl ADDR descriptors +# TODO impl RAW descriptors + +import enum + +from .bip32 import convert_bip32_path_to_list_of_uint32, BIP32Node, KeyOriginInfo, BIP32_PRIME +from . import bitcoin +from .bitcoin import construct_script, opcodes, construct_witness +from . import constants +from .crypto import hash_160, sha256 +from . import ecc +from . import segwit_addr +from .util import bfh + +from binascii import unhexlify +from enum import Enum +from typing import ( + List, + NamedTuple, + Optional, + Tuple, + Sequence, + Mapping, + Set, +) + + +MAX_TAPROOT_NODES = 128 + +# we guess that signatures will be 72 bytes long +# note: DER-encoded ECDSA signatures are 71 or 72 bytes in practice +# See https://bitcoin.stackexchange.com/questions/77191/what-is-the-maximum-size-of-a-der-encoded-ecdsa-signature +# We assume low S (as that is a bitcoin standardness rule). +# We do not assume low R (even though the sigs we create conform), as external sigs, +# e.g. from a hw signer cannot be expected to have a low R. +DUMMY_DER_SIG = 72 * b"\x00" + + +class ExpandedScripts: + + def __init__( + self, + *, + output_script: bytes, # "scriptPubKey" + redeem_script: Optional[bytes] = None, + witness_script: Optional[bytes] = None, + scriptcode_for_sighash: Optional[bytes] = None + ): + self.output_script = output_script + self.redeem_script = redeem_script + self.witness_script = witness_script + self.scriptcode_for_sighash = scriptcode_for_sighash + + @property + def scriptcode_for_sighash(self) -> Optional[bytes]: + if self._scriptcode_for_sighash: + return self._scriptcode_for_sighash + return self.witness_script or self.redeem_script or self.output_script + + @scriptcode_for_sighash.setter + def scriptcode_for_sighash(self, value: Optional[bytes]): + self._scriptcode_for_sighash = value + + def address(self, *, net=None) -> Optional[str]: + return bitcoin.script_to_address(self.output_script.hex(), net=net) + + +class ScriptSolutionInner(NamedTuple): + witness_items: Optional[Sequence] = None + + +class ScriptSolutionTop(NamedTuple): + witness: Optional[bytes] = None + script_sig: Optional[bytes] = None + + +class MissingSolutionPiece(Exception): pass + + +def PolyMod(c: int, val: int) -> int: + """ + :meta private: + Function to compute modulo over the polynomial used for descriptor checksums + From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp + """ + c0 = c >> 35 + c = ((c & 0x7ffffffff) << 5) ^ val + if (c0 & 1): + c ^= 0xf5dee51989 + if (c0 & 2): + c ^= 0xa9fdca3312 + if (c0 & 4): + c ^= 0x1bab10e32d + if (c0 & 8): + c ^= 0x3706b1677a + if (c0 & 16): + c ^= 0x644d626ffd + return c + + +_INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +_INPUT_CHARSET_INV = {c: i for (i, c) in enumerate(_INPUT_CHARSET)} +_CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + +def DescriptorChecksum(desc: str) -> str: + """ + Compute the checksum for a descriptor + + :param desc: The descriptor string to compute a checksum for + :return: A checksum + """ + c = 1 + cls = 0 + clscount = 0 + for ch in desc: + try: + pos = _INPUT_CHARSET_INV[ch] + except KeyError: + return "" + c = PolyMod(c, pos & 31) + cls = cls * 3 + (pos >> 5) + clscount += 1 + if clscount == 3: + c = PolyMod(c, cls) + cls = 0 + clscount = 0 + if clscount > 0: + c = PolyMod(c, cls) + for j in range(0, 8): + c = PolyMod(c, 0) + c ^= 1 + + ret = [''] * 8 + for j in range(0, 8): + ret[j] = _CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] + return ''.join(ret) + +def AddChecksum(desc: str) -> str: + """ + Compute and attach the checksum for a descriptor + + :param desc: The descriptor string to add a checksum to + :return: Descriptor with checksum + """ + return desc + "#" + DescriptorChecksum(desc) + + +class PubkeyProvider(object): + """ + A public key expression in a descriptor. + Can contain the key origin info, the pubkey itself, and subsequent derivation paths for derivation from the pubkey + The pubkey can be a typical pubkey or an extended pubkey. + """ + def __init__( + self, + origin: Optional['KeyOriginInfo'], + pubkey: str, + deriv_path: Optional[str] + ) -> None: + """ + :param origin: The key origin if one is available + :param pubkey: The public key. Either a hex string or a serialized extended pubkey + :param deriv_path: Additional derivation path (suffix) if the pubkey is an extended pubkey + """ + self.origin = origin + self.pubkey = pubkey + self.deriv_path = deriv_path + if deriv_path: + wildcard_count = deriv_path.count("*") + if wildcard_count > 1: + raise ValueError("only one wildcard(*) is allowed in a descriptor") + if wildcard_count == 1: + if deriv_path[-1] != "*": + raise ValueError("wildcard in descriptor only allowed in last position") + if deriv_path[0] != "/": + raise ValueError(f"deriv_path suffix must start with a '/'. got {deriv_path!r}") + # Make ExtendedKey from pubkey if it isn't hex + self.extkey = None + try: + unhexlify(self.pubkey) + # Is hex, normal pubkey + except Exception: + # Not hex, maybe xpub (but don't allow ypub/zpub) + self.extkey = BIP32Node.from_xkey(pubkey, allow_custom_headers=False) + if deriv_path and self.extkey is None: + raise ValueError("deriv_path suffix present for simple pubkey") + + @classmethod + def parse(cls, s: str) -> 'PubkeyProvider': + """ + Deserialize a key expression from the string into a ``PubkeyProvider``. + + :param s: String containing the key expression + :return: A new ``PubkeyProvider`` containing the details given by ``s`` + """ + origin = None + deriv_path = None + + if s[0] == "[": + end = s.index("]") + origin = KeyOriginInfo.from_string(s[1:end]) + s = s[end + 1:] + + pubkey = s + slash_idx = s.find("/") + if slash_idx != -1: + pubkey = s[:slash_idx] + deriv_path = s[slash_idx:] + + return cls(origin, pubkey, deriv_path) + + def to_string(self) -> str: + """ + Serialize the pubkey expression to a string to be used in a descriptor + + :return: The pubkey expression as a string + """ + s = "" + if self.origin: + s += "[{}]".format(self.origin.to_string()) + s += self.pubkey + if self.deriv_path: + s += self.deriv_path + return s + + def get_pubkey_bytes(self, *, pos: Optional[int] = None) -> bytes: + if self.is_range() and pos is None: + raise ValueError("pos must be set for ranged descriptor") + # note: if not ranged, we ignore pos. + if self.extkey is not None: + compressed = True # bip32 implies compressed pubkeys + if self.deriv_path is None: + assert not self.is_range() + return self.extkey.eckey.get_public_key_bytes(compressed=compressed) + else: + path_str = self.deriv_path[1:] + if self.is_range(): + assert path_str[-1] == "*" + path_str = path_str[:-1] + str(pos) + path = convert_bip32_path_to_list_of_uint32(path_str) + child_key = self.extkey.subkey_at_public_derivation(path) + return child_key.eckey.get_public_key_bytes(compressed=compressed) + else: + assert not self.is_range() + return unhexlify(self.pubkey) + + def get_full_derivation_path(self, *, pos: Optional[int] = None) -> str: + """ + Returns the full derivation path at the given position, including the origin + """ + if self.is_range() and pos is None: + raise ValueError("pos must be set for ranged descriptor") + path = self.origin.get_derivation_path() if self.origin is not None else "m" + path += self.deriv_path if self.deriv_path is not None else "" + if path[-1] == "*": + path = path[:-1] + str(pos) + return path + + def get_full_derivation_int_list(self, *, pos: Optional[int] = None) -> List[int]: + """ + Returns the full derivation path as an integer list at the given position. + Includes the origin and master key fingerprint as an int + """ + if self.is_range() and pos is None: + raise ValueError("pos must be set for ranged descriptor") + path: List[int] = self.origin.get_full_int_list() if self.origin is not None else [] + path.extend(self.get_der_suffix_int_list(pos=pos)) + return path + + def get_der_suffix_int_list(self, *, pos: Optional[int] = None) -> List[int]: + if not self.deriv_path: + return [] + der_suffix = self.deriv_path + assert (wc_count := der_suffix.count("*")) <= 1, wc_count + der_suffix = der_suffix.replace("*", str(pos)) + return convert_bip32_path_to_list_of_uint32(der_suffix) + + def __lt__(self, other: 'PubkeyProvider') -> bool: + return self.pubkey < other.pubkey + + def is_range(self) -> bool: + if not self.deriv_path: + return False + if self.deriv_path[-1] == "*": # TODO hardened + return True + return False + + def has_uncompressed_pubkey(self) -> bool: + if self.is_range(): # bip32 implies compressed + return False + return b"\x04" == self.get_pubkey_bytes()[:1] + + +class Descriptor(object): + r""" + An abstract class for Descriptors themselves. + Descriptors can contain multiple :class:`PubkeyProvider`\ s and multiple ``Descriptor`` as subdescriptors. + + Note: a significant portion of input validation logic is in parse_descriptor(), + maybe these checks should be moved to (or also done in) this class? + For example, sh() must be top-level, or segwit mandates compressed pubkeys, + or bare-multisig cannot have >3 pubkeys. + """ + def __init__( + self, + pubkeys: List['PubkeyProvider'], + subdescriptors: List['Descriptor'], + name: str + ) -> None: + r""" + :param pubkeys: The :class:`PubkeyProvider`\ s that are part of this descriptor + :param subdescriptor: The ``Descriptor``\ s that are part of this descriptor + :param name: The name of the function for this descriptor + """ + self.pubkeys = pubkeys + self.subdescriptors = subdescriptors + self.name = name + + def to_string_no_checksum(self) -> str: + """ + Serializes the descriptor as a string without the descriptor checksum + + :return: The descriptor string + """ + return "{}({}{})".format( + self.name, + ",".join([p.to_string() for p in self.pubkeys]), + self.subdescriptors[0].to_string_no_checksum() if len(self.subdescriptors) > 0 else "" + ) + + def to_string(self) -> str: + """ + Serializes the descriptor as a string with the checksum + + :return: The descriptor with a checksum + """ + return AddChecksum(self.to_string_no_checksum()) + + def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": + """ + Returns the scripts for a descriptor at the given `pos` for ranged descriptors. + """ + raise NotImplementedError("The Descriptor base class does not implement this method") + + def _satisfy_inner( + self, + *, + sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig + allow_dummy: bool = False, + ) -> ScriptSolutionInner: + raise NotImplementedError("The Descriptor base class does not implement this method") + + def satisfy( + self, + *, + sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig + allow_dummy: bool = False, + ) -> ScriptSolutionTop: + """Construct a witness and/or scriptSig to be used in a txin, to satisfy the bitcoin SCRIPT. + + Raises MissingSolutionPiece if satisfaction is not yet possible due to e.g. missing a signature, + unless `allow_dummy` is set to True, in which case dummy data is used where needed (e.g. for size estimation). + """ + assert not self.is_range() + sol = self._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy) + witness = None + script_sig = None + if self.is_segwit(): + witness = bfh(construct_witness(sol.witness_items)) + else: + script_sig = bfh(construct_script(sol.witness_items)) + return ScriptSolutionTop( + witness=witness, + script_sig=script_sig, + ) + + def get_satisfaction_progress( + self, + *, + sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig + ) -> Tuple[int, int]: + """Returns (num_sigs_we_have, num_sigs_required) towards satisfying this script. + Besides signatures, later this can also consider hash-preimages. + """ + assert not self.is_range() + nhave, nreq = 0, 0 + for desc in self.subdescriptors: + a, b = desc.get_satisfaction_progress(sigdata=sigdata) + nhave += a + nreq += b + return nhave, nreq + + def is_range(self) -> bool: + for pubkey in self.pubkeys: + if pubkey.is_range(): + return True + for desc in self.subdescriptors: + if desc.is_range(): + return True + return False + + def is_segwit(self) -> bool: + return any([desc.is_segwit() for desc in self.subdescriptors]) + + def get_all_pubkeys(self) -> Set[bytes]: + """Returns set of pubkeys that appear at any level in this descriptor.""" + assert not self.is_range() + all_pubkeys = set([p.get_pubkey_bytes() for p in self.pubkeys]) + for desc in self.subdescriptors: + all_pubkeys |= desc.get_all_pubkeys() + return all_pubkeys + + def get_simple_singlesig(self) -> Optional['Descriptor']: + """Returns innermost pk/pkh/wpkh descriptor, or None if we are not a simple singlesig. + + note: besides pk,pkh,sh(wpkh),wpkh, overly complicated stuff such as sh(pk),wsh(sh(pkh),etc is also accepted + """ + if len(self.subdescriptors) == 1: + return self.subdescriptors[0].get_simple_singlesig() + return None + + def get_simple_multisig(self) -> Optional['MultisigDescriptor']: + """Returns innermost multi descriptor, or None if we are not a simple multisig.""" + if len(self.subdescriptors) == 1: + return self.subdescriptors[0].get_simple_multisig() + return None + + def to_legacy_electrum_script_type(self) -> str: + if isinstance(self, PKDescriptor): + return "p2pk" + elif isinstance(self, PKHDescriptor): + return "p2pkh" + elif isinstance(self, WPKHDescriptor): + return "p2wpkh" + elif isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], WPKHDescriptor): + return "p2wpkh-p2sh" + elif isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], MultisigDescriptor): + return "p2sh" + elif isinstance(self, WSHDescriptor) and isinstance(self.subdescriptors[0], MultisigDescriptor): + return "p2wsh" + elif (isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], WSHDescriptor) + and isinstance(self.subdescriptors[0].subdescriptors[0], MultisigDescriptor)): + return "p2wsh-p2sh" + return "unknown" + + +class PKDescriptor(Descriptor): + """ + A descriptor for ``pk()`` descriptors + """ + def __init__( + self, + pubkey: 'PubkeyProvider' + ) -> None: + """ + :param pubkey: The :class:`PubkeyProvider` for this descriptor + """ + super().__init__([pubkey], [], "pk") + + def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": + pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos) + script = construct_script([pubkey, opcodes.OP_CHECKSIG]) + return ExpandedScripts(output_script=bytes.fromhex(script)) + + def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner: + if sigdata is None: sigdata = {} + assert not self.is_range() + assert not self.subdescriptors + pubkey = self.pubkeys[0].get_pubkey_bytes() + sig = sigdata.get(pubkey) + if sig is None and allow_dummy: + sig = DUMMY_DER_SIG + if sig is None: + raise MissingSolutionPiece(f"no sig for {pubkey.hex()}") + return ScriptSolutionInner( + witness_items=(sig,), + ) + + def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]: + if sigdata is None: sigdata = {} + signatures = list(sigdata.values()) + return len(signatures), 1 + + def get_simple_singlesig(self) -> Optional['Descriptor']: + return self + + +class PKHDescriptor(Descriptor): + """ + A descriptor for ``pkh()`` descriptors + """ + def __init__( + self, + pubkey: 'PubkeyProvider' + ) -> None: + """ + :param pubkey: The :class:`PubkeyProvider` for this descriptor + """ + super().__init__([pubkey], [], "pkh") + + def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": + pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos) + pkh = hash_160(pubkey).hex() + script = bitcoin.pubkeyhash_to_p2pkh_script(pkh) + return ExpandedScripts(output_script=bytes.fromhex(script)) + + def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner: + if sigdata is None: sigdata = {} + assert not self.is_range() + assert not self.subdescriptors + pubkey = self.pubkeys[0].get_pubkey_bytes() + sig = sigdata.get(pubkey) + if sig is None and allow_dummy: + sig = DUMMY_DER_SIG + if sig is None: + raise MissingSolutionPiece(f"no sig for {pubkey.hex()}") + return ScriptSolutionInner( + witness_items=(sig, pubkey), + ) + + def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]: + if sigdata is None: sigdata = {} + signatures = list(sigdata.values()) + return len(signatures), 1 + + def get_simple_singlesig(self) -> Optional['Descriptor']: + return self + + +class WPKHDescriptor(Descriptor): + """ + A descriptor for ``wpkh()`` descriptors + """ + def __init__( + self, + pubkey: 'PubkeyProvider' + ) -> None: + """ + :param pubkey: The :class:`PubkeyProvider` for this descriptor + """ + super().__init__([pubkey], [], "wpkh") + + def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": + pkh = hash_160(self.pubkeys[0].get_pubkey_bytes(pos=pos)) + output_script = construct_script([0, pkh]) + scriptcode = bitcoin.pubkeyhash_to_p2pkh_script(pkh.hex()) + return ExpandedScripts( + output_script=bytes.fromhex(output_script), + scriptcode_for_sighash=bytes.fromhex(scriptcode), + ) + + def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner: + if sigdata is None: sigdata = {} + assert not self.is_range() + assert not self.subdescriptors + pubkey = self.pubkeys[0].get_pubkey_bytes() + sig = sigdata.get(pubkey) + if sig is None and allow_dummy: + sig = DUMMY_DER_SIG + if sig is None: + raise MissingSolutionPiece(f"no sig for {pubkey.hex()}") + return ScriptSolutionInner( + witness_items=(sig, pubkey), + ) + + def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]: + if sigdata is None: sigdata = {} + signatures = list(sigdata.values()) + return len(signatures), 1 + + def is_segwit(self) -> bool: + return True + + def get_simple_singlesig(self) -> Optional['Descriptor']: + return self + + +class MultisigDescriptor(Descriptor): + """ + A descriptor for ``multi()`` and ``sortedmulti()`` descriptors + """ + def __init__( + self, + pubkeys: List['PubkeyProvider'], + thresh: int, + is_sorted: bool + ) -> None: + r""" + :param pubkeys: The :class:`PubkeyProvider`\ s for this descriptor + :param thresh: The number of keys required to sign this multisig + :param is_sorted: Whether this is a ``sortedmulti()`` descriptor + """ + super().__init__(pubkeys, [], "sortedmulti" if is_sorted else "multi") + if not (1 <= thresh <= len(pubkeys) <= 15): + raise ValueError(f'{thresh=}, {len(pubkeys)=}') + self.thresh = thresh + self.is_sorted = is_sorted + if self.is_sorted: + if not self.is_range(): + # sort xpubs using the order of pubkeys + der_pks = [p.get_pubkey_bytes() for p in self.pubkeys] + self.pubkeys = [x[1] for x in sorted(zip(der_pks, self.pubkeys))] + else: + # not possible to sort according to final order in expanded scripts, + # but for easier visual comparison, we do a lexicographical sort + self.pubkeys.sort() + + def to_string_no_checksum(self) -> str: + return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string() for p in self.pubkeys])) + + def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": + der_pks = [p.get_pubkey_bytes(pos=pos) for p in self.pubkeys] + if self.is_sorted: + der_pks.sort() + script = bfh(construct_script([self.thresh, *der_pks, len(der_pks), opcodes.OP_CHECKMULTISIG])) + return ExpandedScripts(output_script=script) + + def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner: + if sigdata is None: sigdata = {} + assert not self.is_range() + assert not self.subdescriptors + der_pks = [p.get_pubkey_bytes() for p in self.pubkeys] + if self.is_sorted: + der_pks.sort() + signatures = [] + for pubkey in der_pks: + if sig := sigdata.get(pubkey): + signatures.append(sig) + if len(signatures) >= self.thresh: + break + if allow_dummy: + dummy_sig = DUMMY_DER_SIG + signatures += (self.thresh - len(signatures)) * [dummy_sig] + if len(signatures) < self.thresh: + raise MissingSolutionPiece(f"not enough sigs") + assert len(signatures) == self.thresh, f"thresh={self.thresh}, but got {len(signatures)} sigs" + return ScriptSolutionInner( + witness_items=(0, *signatures), + ) + + def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]: + if sigdata is None: sigdata = {} + signatures = list(sigdata.values()) + return len(signatures), self.thresh + + def get_simple_multisig(self) -> Optional['MultisigDescriptor']: + return self + + +class SHDescriptor(Descriptor): + """ + A descriptor for ``sh()`` descriptors + """ + def __init__( + self, + subdescriptor: 'Descriptor' + ) -> None: + """ + :param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor + """ + super().__init__([], [subdescriptor], "sh") + + def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": + assert len(self.subdescriptors) == 1 + sub_scripts = self.subdescriptors[0].expand(pos=pos) + redeem_script = sub_scripts.output_script + witness_script = sub_scripts.witness_script + script = bfh(construct_script([opcodes.OP_HASH160, hash_160(redeem_script), opcodes.OP_EQUAL])) + return ExpandedScripts( + output_script=script, + redeem_script=redeem_script, + witness_script=witness_script, + scriptcode_for_sighash=sub_scripts.scriptcode_for_sighash, + ) + + def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner: + raise Exception("does not make sense for sh()") + + def satisfy(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionTop: + assert not self.is_range() + assert len(self.subdescriptors) == 1 + subdesc = self.subdescriptors[0] + redeem_script = self.expand().redeem_script + witness = None + if isinstance(subdesc, (WSHDescriptor, WPKHDescriptor)): # witness_v0 nested in p2sh + witness = subdesc.satisfy(sigdata=sigdata, allow_dummy=allow_dummy).witness + script_sig = bfh(construct_script([redeem_script])) + else: # legacy p2sh + subsol = subdesc._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy) + script_sig = bfh(construct_script([*subsol.witness_items, redeem_script])) + return ScriptSolutionTop( + witness=witness, + script_sig=script_sig, + ) + + +class WSHDescriptor(Descriptor): + """ + A descriptor for ``wsh()`` descriptors + """ + def __init__( + self, + subdescriptor: 'Descriptor' + ) -> None: + """ + :param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor + """ + super().__init__([], [subdescriptor], "wsh") + + def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": + assert len(self.subdescriptors) == 1 + sub_scripts = self.subdescriptors[0].expand(pos=pos) + witness_script = sub_scripts.output_script + output_script = bfh(construct_script([0, sha256(witness_script)])) + return ExpandedScripts( + output_script=output_script, + witness_script=witness_script, + ) + + def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner: + raise Exception("does not make sense for wsh()") + + def satisfy(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionTop: + assert not self.is_range() + assert len(self.subdescriptors) == 1 + subsol = self.subdescriptors[0]._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy) + witness_script = self.expand().witness_script + witness = construct_witness([*subsol.witness_items, witness_script]) + return ScriptSolutionTop( + witness=bytes.fromhex(witness), + ) + + def is_segwit(self) -> bool: + return True + + +class TRDescriptor(Descriptor): + """ + A descriptor for ``tr()`` descriptors + """ + def __init__( + self, + internal_key: 'PubkeyProvider', + subdescriptors: List['Descriptor'] = None, + depths: List[int] = None, + ) -> None: + r""" + :param internal_key: The :class:`PubkeyProvider` that is the internal key for this descriptor + :param subdescriptors: The :class:`Descriptor`\ s that are the leaf scripts for this descriptor + :param depths: The depths of the leaf scripts in the same order as `subdescriptors` + """ + if subdescriptors is None: + subdescriptors = [] + if depths is None: + depths = [] + super().__init__([internal_key], subdescriptors, "tr") + self.depths = depths + + def to_string_no_checksum(self) -> str: + r = f"{self.name}({self.pubkeys[0].to_string()}" + path: List[bool] = [] # Track left or right for each depth + for p, depth in enumerate(self.depths): + r += "," + while len(path) <= depth: + if len(path) > 0: + r += "{" + path.append(False) + r += self.subdescriptors[p].to_string_no_checksum() + while len(path) > 0 and path[-1]: + if len(path) > 0: + r += "}" + path.pop() + if len(path) > 0: + path[-1] = True + r += ")" + return r + + def is_segwit(self) -> bool: + return True + + +def _get_func_expr(s: str) -> Tuple[str, str]: + """ + Get the function name and then the expression inside + + :param s: The string that begins with a function name + :return: The function name as the first element of the tuple, and the expression contained within the function as the second element + :raises: ValueError: if a matching pair of parentheses cannot be found + """ + start = s.index("(") + end = s.rindex(")") + return s[0:start], s[start + 1:end] + + +def _get_const(s: str, const: str) -> str: + """ + Get the first character of the string, make sure it is the expected character, + and return the rest of the string + + :param s: The string that begins with a constant character + :param const: The constant character + :return: The remainder of the string without the constant character + :raises: ValueError: if the first character is not the constant character + """ + if s[0] != const: + raise ValueError(f"Expected '{const}' but got '{s[0]}'") + return s[1:] + + +def _get_expr(s: str) -> Tuple[str, str]: + """ + Extract the expression that ``s`` begins with. + + This will return the initial part of ``s``, up to the first comma or closing brace, + skipping ones that are surrounded by braces. + + :param s: The string to extract the expression from + :return: A pair with the first item being the extracted expression and the second the rest of the string + """ + level: int = 0 + for i, c in enumerate(s): + if c in ["(", "{"]: + level += 1 + elif level > 0 and c in [")", "}"]: + level -= 1 + elif level == 0 and c in [")", "}", ","]: + break + return s[0:i], s[i:] + +def parse_pubkey(expr: str, *, ctx: '_ParseDescriptorContext') -> Tuple['PubkeyProvider', str]: + """ + Parses an individual pubkey expression from a string that may contain more than one pubkey expression. + + :param expr: The expression to parse a pubkey expression from + :return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, and the remainder of the expression as the second item. + """ + end = len(expr) + comma_idx = expr.find(",") + next_expr = "" + if comma_idx != -1: + end = comma_idx + next_expr = expr[end + 1:] + pubkey_provider = PubkeyProvider.parse(expr[:end]) + permit_uncompressed = ctx in (_ParseDescriptorContext.TOP, _ParseDescriptorContext.P2SH) + if not permit_uncompressed and pubkey_provider.has_uncompressed_pubkey(): + raise ValueError("uncompressed pubkeys are not allowed") + return pubkey_provider, next_expr + + +class _ParseDescriptorContext(Enum): + """ + :meta private: + + Enum representing the level that we are in when parsing a descriptor. + Some expressions aren't allowed at certain levels, this helps us track those. + """ + + TOP = enum.auto() # The top level, not within any descriptor + P2SH = enum.auto() # Within an sh() descriptor + P2WPKH = enum.auto() # Within wpkh() descriptor + P2WSH = enum.auto() # Within a wsh() descriptor + P2TR = enum.auto() # Within a tr() descriptor + + +def _parse_descriptor(desc: str, *, ctx: '_ParseDescriptorContext') -> 'Descriptor': + """ + :meta private: + + Parse a descriptor given the context level we are in. + Used recursively to parse subdescriptors + + :param desc: The descriptor string to parse + :param ctx: The :class:`_ParseDescriptorContext` indicating the level we are in + :return: The parsed descriptor + :raises: ValueError: if the descriptor is malformed + """ + func, expr = _get_func_expr(desc) + if func == "pk": + pubkey, expr = parse_pubkey(expr, ctx=ctx) + if expr: + raise ValueError("more than one pubkey in pk descriptor") + return PKDescriptor(pubkey) + if func == "pkh": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): + raise ValueError("Can only have pkh at top level, in sh(), or in wsh()") + pubkey, expr = parse_pubkey(expr, ctx=ctx) + if expr: + raise ValueError("More than one pubkey in pkh descriptor") + return PKHDescriptor(pubkey) + if func == "sortedmulti" or func == "multi": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): + raise ValueError("Can only have multi/sortedmulti at top level, in sh(), or in wsh()") + is_sorted = func == "sortedmulti" + comma_idx = expr.index(",") + thresh = int(expr[:comma_idx]) + expr = expr[comma_idx + 1:] + pubkeys = [] + while expr: + pubkey, expr = parse_pubkey(expr, ctx=ctx) + pubkeys.append(pubkey) + if len(pubkeys) == 0 or len(pubkeys) > 15: + raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 15 keys, inclusive".format(len(pubkeys))) + elif thresh < 1: + raise ValueError("Multisig threshold cannot be {}, must be at least 1".format(thresh)) + elif thresh > len(pubkeys): + raise ValueError("Multisig threshold cannot be larger than the number of keys; threshold is {} but only {} keys specified".format(thresh, len(pubkeys))) + if ctx == _ParseDescriptorContext.TOP and len(pubkeys) > 3: + raise ValueError("Cannot have {} pubkeys in bare multisig: only at most 3 pubkeys") + return MultisigDescriptor(pubkeys, thresh, is_sorted) + if func == "wpkh": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): + raise ValueError("Can only have wpkh() at top level or inside sh()") + pubkey, expr = parse_pubkey(expr, ctx=_ParseDescriptorContext.P2WPKH) + if expr: + raise ValueError("More than one pubkey in pkh descriptor") + return WPKHDescriptor(pubkey) + if func == "sh": + if ctx != _ParseDescriptorContext.TOP: + raise ValueError("Can only have sh() at top level") + subdesc = _parse_descriptor(expr, ctx=_ParseDescriptorContext.P2SH) + return SHDescriptor(subdesc) + if func == "wsh": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): + raise ValueError("Can only have wsh() at top level or inside sh()") + subdesc = _parse_descriptor(expr, ctx=_ParseDescriptorContext.P2WSH) + return WSHDescriptor(subdesc) + if func == "tr": + if ctx != _ParseDescriptorContext.TOP: + raise ValueError("Can only have tr at top level") + internal_key, expr = parse_pubkey(expr, ctx=ctx) + subscripts = [] + depths = [] + if expr: + # Path from top of the tree to what we're currently processing. + # branches[i] == False: left branch in the i'th step from the top + # branches[i] == true: right branch + branches = [] + while True: + # Process open braces + while True: + try: + expr = _get_const(expr, "{") + branches.append(False) + except ValueError: + break + if len(branches) > MAX_TAPROOT_NODES: + raise ValueError(f"tr() supports at most {MAX_TAPROOT_NODES} nesting levels") # TODO xxxx fixed upstream bug here + # Process script expression + sarg, expr = _get_expr(expr) + subscripts.append(_parse_descriptor(sarg, ctx=_ParseDescriptorContext.P2TR)) + depths.append(len(branches)) + # Process closing braces + while len(branches) > 0 and branches[-1]: + expr = _get_const(expr, "}") + branches.pop() + # If we're at the end of a left branch, expect a comma + if len(branches) > 0 and not branches[-1]: + expr = _get_const(expr, ",") + branches[-1] = True + + if len(branches) == 0: + break + return TRDescriptor(internal_key, subscripts, depths) + if ctx == _ParseDescriptorContext.P2SH: + raise ValueError("A function is needed within P2SH") + elif ctx == _ParseDescriptorContext.P2WSH: + raise ValueError("A function is needed within P2WSH") + raise ValueError("{} is not a valid descriptor function".format(func)) + + +def parse_descriptor(desc: str) -> 'Descriptor': + """ + Parse a descriptor string into a :class:`Descriptor`. + Validates the checksum if one is provided in the string + + :param desc: The descriptor string + :return: The parsed :class:`Descriptor` + :raises: ValueError: if the descriptor string is malformed + """ + i = desc.find("#") + if i != -1: + checksum = desc[i + 1:] + desc = desc[:i] + computed = DescriptorChecksum(desc) + if computed != checksum: + raise ValueError("The checksum does not match; Got {}, expected {}".format(checksum, computed)) + return _parse_descriptor(desc, ctx=_ParseDescriptorContext.TOP) + + +##### + + +def get_singlesig_descriptor_from_legacy_leaf(*, pubkey: str, script_type: str) -> Optional[Descriptor]: + pubkey = PubkeyProvider.parse(pubkey) + if script_type == 'p2pk': + return PKDescriptor(pubkey=pubkey) + elif script_type == 'p2pkh': + return PKHDescriptor(pubkey=pubkey) + elif script_type == 'p2wpkh': + return WPKHDescriptor(pubkey=pubkey) + elif script_type == 'p2wpkh-p2sh': + wpkh = WPKHDescriptor(pubkey=pubkey) + return SHDescriptor(subdescriptor=wpkh) + else: + raise NotImplementedError(f"unexpected {script_type=}") + + +def create_dummy_descriptor_from_address(addr: Optional[str]) -> 'Descriptor': + # It's not possible to tell the script type in general just from an address. + # - "1" addresses are of course p2pkh + # - "3" addresses are p2sh but we don't know the redeem script... + # - "bc1" addresses (if they are 42-long) are p2wpkh + # - "bc1" addresses that are 62-long are p2wsh but we don't know the script... + # If we don't know the script, we _guess_ it is pubkeyhash. + # As this method is used e.g. for tx size estimation, + # the estimation will not be precise. + def guess_script_type(addr: Optional[str]) -> str: + if addr is None: + return 'p2wpkh' # the default guess + witver, witprog = segwit_addr.decode_segwit_address(constants.net.SEGWIT_HRP, addr) + if witprog is not None: + return 'p2wpkh' + addrtype, hash_160_ = bitcoin.b58_address_to_hash160(addr) + if addrtype == constants.net.ADDRTYPE_P2PKH: + return 'p2pkh' + elif addrtype == constants.net.ADDRTYPE_P2SH: + return 'p2wpkh-p2sh' + raise Exception(f'unrecognized address: {repr(addr)}') + + script_type = guess_script_type(addr) + # guess pubkey-len to be 33-bytes: + pubkey = ecc.GENERATOR.get_public_key_bytes(compressed=True).hex() + desc = get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=script_type) + return desc diff --git a/electrum/keystore.py b/electrum/keystore.py index e0419fb4f..9ccf2ab30 100644 --- a/electrum/keystore.py +++ b/electrum/keystore.py @@ -36,7 +36,9 @@ from .bitcoin import deserialize_privkey, serialize_privkey, BaseDecodeError from .transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, TxInput from .bip32 import (convert_bip32_path_to_list_of_uint32, BIP32_PRIME, is_xpub, is_xprv, BIP32Node, normalize_bip32_derivation, - convert_bip32_intpath_to_strpath, is_xkey_consistent_with_key_origin_info) + convert_bip32_intpath_to_strpath, is_xkey_consistent_with_key_origin_info, + KeyOriginInfo) +from .descriptor import PubkeyProvider from .ecc import string_to_number from .crypto import (pw_decode, pw_encode, sha256, sha256d, PW_HASH_VERSION_LATEST, SUPPORTED_PW_HASH_VERSIONS, UnsupportedPasswordHashVersion, hash_160, @@ -179,6 +181,10 @@ class KeyStore(Logger, ABC): """ pass + @abstractmethod + def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]: + pass + def find_my_pubkey_in_txinout( self, txinout: Union['PartialTxInput', 'PartialTxOutput'], *, only_der_suffix: bool = False @@ -302,6 +308,15 @@ class Imported_KeyStore(Software_KeyStore): return pubkey.hex() return None + def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]: + if sequence in self.keypairs: + return PubkeyProvider( + origin=None, + pubkey=sequence, + deriv_path=None, + ) + return None + def update_password(self, old_password, new_password): self.check_password(old_password) if new_password == '': @@ -403,6 +418,9 @@ class MasterPublicKeyMixin(ABC): """ pass + def get_key_origin_info(self) -> Optional[KeyOriginInfo]: + return None + @abstractmethod def derive_pubkey(self, for_change: int, n: int) -> bytes: """Returns pubkey at given path. @@ -532,6 +550,22 @@ class Xpub(MasterPublicKeyMixin): ) return bip32node.to_xpub() + def get_key_origin_info(self) -> Optional[KeyOriginInfo]: + fp_bytes, der_full = self.get_fp_and_derivation_to_be_used_in_partial_tx( + der_suffix=[], only_der_suffix=False) + origin = KeyOriginInfo(fingerprint=fp_bytes, path=der_full) + return origin + + def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]: + strpath = convert_bip32_intpath_to_strpath(sequence) + strpath = strpath[1:] # cut leading "m" + bip32node = self.get_bip32_node_for_xpub() + return PubkeyProvider( + origin=self.get_key_origin_info(), + pubkey=bip32node._replace(xtype="standard").to_xkey(), + deriv_path=strpath, + ) + def add_key_origin_from_root_node(self, *, derivation_prefix: str, root_node: BIP32Node): assert self.xpub # try to derive ourselves from what we were given @@ -802,6 +836,13 @@ class Old_KeyStore(MasterPublicKeyMixin, Deterministic_KeyStore): der_full = der_prefix_ints + list(der_suffix) return fingerprint_bytes, der_full + def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]: + return PubkeyProvider( + origin=None, + pubkey=self.derive_pubkey(*sequence).hex(), + deriv_path=None, + ) + def update_password(self, old_password, new_password): self.check_password(old_password) if new_password == '': diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py index 3190ea9df..10151c33c 100644 --- a/electrum/lnsweep.py +++ b/electrum/lnsweep.py @@ -8,6 +8,7 @@ from enum import Enum, auto from .util import bfh from .bitcoin import redeem_script_to_address, dust_threshold, construct_witness from .invoices import PR_PAID +from . import descriptor from . import ecc from .lnutil import (make_commitment_output_to_remote_address, make_commitment_output_to_local_witness_script, derive_privkey, derive_pubkey, derive_blinded_pubkey, derive_blinded_privkey, @@ -513,9 +514,8 @@ def create_sweeptx_their_ctx_to_remote( prevout = TxOutpoint(txid=bfh(ctx.txid()), out_idx=output_idx) txin = PartialTxInput(prevout=prevout) txin._trusted_value_sats = val - txin.script_type = 'p2wpkh' - txin.pubkeys = [bfh(our_payment_pubkey)] - txin.num_sig = 1 + desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=our_payment_pubkey, script_type='p2wpkh') + txin.script_descriptor = desc sweep_inputs = [txin] tx_size_bytes = 110 # approx size of p2wpkh->p2wpkh fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 025f772c7..b325fb7ab 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -21,6 +21,7 @@ from .transaction import (Transaction, PartialTransaction, PartialTxInput, TxOut PartialTxOutput, opcodes, TxOutput) from .ecc import CURVE_ORDER, sig_string_from_der_sig, ECPubkey, string_to_number from . import ecc, bitcoin, crypto, transaction +from . import descriptor from .bitcoin import (push_script, redeem_script_to_address, address_to_script, construct_witness, construct_script) from . import segwit_addr @@ -818,9 +819,10 @@ def make_funding_input(local_funding_pubkey: bytes, remote_funding_pubkey: bytes # commitment tx input prevout = TxOutpoint(txid=bfh(funding_txid), out_idx=funding_pos) c_input = PartialTxInput(prevout=prevout) - c_input.script_type = 'p2wsh' - c_input.pubkeys = [bfh(pk) for pk in pubkeys] - c_input.num_sig = 2 + + ppubkeys = [descriptor.PubkeyProvider.parse(pk) for pk in pubkeys] + multi = descriptor.MultisigDescriptor(pubkeys=ppubkeys, thresh=2, is_sorted=True) + c_input.script_descriptor = descriptor.WSHDescriptor(subdescriptor=multi) c_input._trusted_value_sats = funding_sat return c_input diff --git a/electrum/plugins/bitbox02/bitbox02.py b/electrum/plugins/bitbox02/bitbox02.py index cb7e614a1..4b328dc6b 100644 --- a/electrum/plugins/bitbox02/bitbox02.py +++ b/electrum/plugins/bitbox02/bitbox02.py @@ -444,9 +444,10 @@ class BitBox02Client(HardwareClientBase): } ) + assert (desc := txin.script_descriptor) if tx_script_type is None: - tx_script_type = txin.script_type - elif tx_script_type != txin.script_type: + tx_script_type = desc.to_legacy_electrum_script_type() + elif tx_script_type != desc.to_legacy_electrum_script_type(): raise Exception("Cannot mix different input script types") if tx_script_type == "p2wpkh": diff --git a/electrum/plugins/digitalbitbox/digitalbitbox.py b/electrum/plugins/digitalbitbox/digitalbitbox.py index 2d64b2f66..1f774c62d 100644 --- a/electrum/plugins/digitalbitbox/digitalbitbox.py +++ b/electrum/plugins/digitalbitbox/digitalbitbox.py @@ -19,6 +19,7 @@ import copy from electrum.crypto import sha256d, EncodeAES_bytes, DecodeAES_bytes, hmac_oneshot from electrum.bitcoin import public_key_to_p2pkh from electrum.bip32 import BIP32Node, convert_bip32_intpath_to_strpath, is_all_public_derivation +from electrum import descriptor from electrum import ecc from electrum.ecc import msg_magic from electrum.wallet import Standard_Wallet @@ -527,7 +528,8 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): if txin.is_coinbase_input(): self.give_error("Coinbase not supported") # should never happen - if txin.script_type != 'p2pkh': + assert (desc := txin.script_descriptor) + if desc.to_legacy_electrum_script_type() != 'p2pkh': p2pkhTransaction = False my_pubkey, inputPath = self.find_my_pubkey_in_txinout(txin) @@ -557,9 +559,10 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): tx_copy = copy.deepcopy(tx) # monkey-patch method of tx_copy instance to change serialization def input_script(self, txin: PartialTxInput, *, estimate_size=False): - if txin.script_type == 'p2pkh': + desc = txin.script_descriptor + if isinstance(desc, descriptor.PKHDescriptor): return Transaction.get_preimage_script(txin) - raise Exception("unsupported type %s" % txin.script_type) + raise Exception(f"unsupported txin type. only p2pkh is supported. got: {desc.to_string()[:10]}") tx_copy.input_script = input_script.__get__(tx_copy, PartialTransaction) tx_dbb_serialized = tx_copy.serialize_to_network() else: diff --git a/electrum/plugins/hw_wallet/plugin.py b/electrum/plugins/hw_wallet/plugin.py index 5886ca57c..4c347da80 100644 --- a/electrum/plugins/hw_wallet/plugin.py +++ b/electrum/plugins/hw_wallet/plugin.py @@ -354,25 +354,6 @@ def validate_op_return_output(output: TxOutput, *, max_size: int = None) -> None raise UserFacingException(_("Amount for OP_RETURN output must be zero.")) -def get_xpubs_and_der_suffixes_from_txinout(tx: PartialTransaction, - txinout: Union[PartialTxInput, PartialTxOutput]) \ - -> List[Tuple[str, List[int]]]: - xfp_to_xpub_map = {xfp: bip32node for bip32node, (xfp, path) - in tx.xpubs.items()} # type: Dict[bytes, BIP32Node] - xfps = [txinout.bip32_paths[pubkey][0] for pubkey in txinout.pubkeys] - try: - xpubs = [xfp_to_xpub_map[xfp] for xfp in xfps] - except KeyError as e: - raise Exception(f"Partial transaction is missing global xpub for " - f"fingerprint ({str(e)}) in input/output") from e - xpubs_and_deriv_suffixes = [] - for bip32node, pubkey in zip(xpubs, txinout.pubkeys): - xfp, path = txinout.bip32_paths[pubkey] - der_suffix = list(path)[bip32node.depth:] - xpubs_and_deriv_suffixes.append((bip32node.to_xpub(), der_suffix)) - return xpubs_and_deriv_suffixes - - def only_hook_if_libraries_available(func): # note: this decorator must wrap @hook, not the other way around, # as 'hook' uses the name of the function it wraps diff --git a/electrum/plugins/jade/jade.py b/electrum/plugins/jade/jade.py index 0869cee41..135713d6d 100644 --- a/electrum/plugins/jade/jade.py +++ b/electrum/plugins/jade/jade.py @@ -264,7 +264,7 @@ class Jade_KeyStore(Hardware_KeyStore): jade_inputs = [] for txin in tx.inputs(): pubkey, path = self.find_my_pubkey_in_txinout(txin) - witness_input = txin.script_type in ['p2wpkh-p2sh', 'p2wsh-p2sh', 'p2wpkh', 'p2wsh'] + witness_input = txin.is_segwit() redeem_script = Transaction.get_preimage_script(txin) redeem_script = bytes.fromhex(redeem_script) if redeem_script is not None else None input_tx = txin.utxo @@ -280,6 +280,7 @@ class Jade_KeyStore(Hardware_KeyStore): change = [None] * len(tx.outputs()) for index, txout in enumerate(tx.outputs()): if txout.is_mine and txout.is_change: + assert (desc := txout.script_descriptor) if is_multisig: # Multisig - wallet details must be registered on Jade hw multisig_name = _register_multisig_wallet(wallet, self, txout.address) @@ -294,7 +295,7 @@ class Jade_KeyStore(Hardware_KeyStore): else: # Pass entire path pubkey, path = self.find_my_pubkey_in_txinout(txout) - change[index] = {'path':path, 'variant': txout.script_type} + change[index] = {'path':path, 'variant': desc.to_legacy_electrum_script_type()} # The txn itself txn_bytes = bytes.fromhex(tx.serialize_to_network()) diff --git a/electrum/plugins/keepkey/keepkey.py b/electrum/plugins/keepkey/keepkey.py index 7bbb0d7ae..573819ec6 100644 --- a/electrum/plugins/keepkey/keepkey.py +++ b/electrum/plugins/keepkey/keepkey.py @@ -1,10 +1,11 @@ from binascii import hexlify, unhexlify import traceback import sys -from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING +from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING, Sequence from electrum.util import bfh, UserCancelled, UserFacingException from electrum.bip32 import BIP32Node +from electrum import descriptor from electrum import constants from electrum.i18n import _ from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, Sighash @@ -13,8 +14,7 @@ from electrum.plugin import Device, runs_in_hwd_thread from electrum.base_wizard import ScriptTypeNotSupported from ..hw_wallet import HW_PluginBase -from ..hw_wallet.plugin import (is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data, - get_xpubs_and_der_suffixes_from_txinout) +from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data if TYPE_CHECKING: import usb1 @@ -271,7 +271,7 @@ class KeepKeyPlugin(HW_PluginBase): client.load_device_by_xprv(item, pin, passphrase_protection, label, language) - def _make_node_path(self, xpub, address_n): + def _make_node_path(self, xpub: str, address_n: Sequence[int]): bip32node = BIP32Node.from_xkey(xpub) node = self.types.HDNodeType( depth=bip32node.depth, @@ -351,14 +351,9 @@ class KeepKeyPlugin(HW_PluginBase): script_type = self.get_keepkey_input_script_type(wallet.txin_type) # prepare multisig, if available: - xpubs = wallet.get_master_public_keys() - if len(xpubs) > 1: - pubkeys = wallet.get_public_keys(address) - # sort xpubs using the order of pubkeys - sorted_pairs = sorted(zip(pubkeys, xpubs)) - multisig = self._make_multisig( - wallet.m, - [(xpub, deriv_suffix) for pubkey, xpub in sorted_pairs]) + desc = wallet.get_script_descriptor_for_address(address) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None @@ -376,12 +371,12 @@ class KeepKeyPlugin(HW_PluginBase): assert isinstance(tx, PartialTransaction) assert isinstance(txin, PartialTxInput) assert keystore - if len(txin.pubkeys) > 1: - xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txin) - multisig = self._make_multisig(txin.num_sig, xpubs_and_deriv_suffixes) + assert (desc := txin.script_descriptor) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None - script_type = self.get_keepkey_input_script_type(txin.script_type) + script_type = self.get_keepkey_input_script_type(desc.to_legacy_electrum_script_type()) txinputtype = self.types.TxInputType( script_type=script_type, multisig=multisig) @@ -406,22 +401,26 @@ class KeepKeyPlugin(HW_PluginBase): return inputs - def _make_multisig(self, m, xpubs): - if len(xpubs) == 1: - return None - pubkeys = [self._make_node_path(xpub, deriv) for xpub, deriv in xpubs] + def _make_multisig(self, desc: descriptor.MultisigDescriptor): + pubkeys = [] + for pubkey_provider in desc.pubkeys: + assert not pubkey_provider.is_range() + assert pubkey_provider.extkey is not None + xpub = pubkey_provider.pubkey + der_suffix = pubkey_provider.get_der_suffix_int_list() + pubkeys.append(self._make_node_path(xpub, der_suffix)) return self.types.MultisigRedeemScriptType( pubkeys=pubkeys, signatures=[b''] * len(pubkeys), - m=m) + m=desc.thresh) def tx_outputs(self, tx: PartialTransaction, *, keystore: 'KeepKey_KeyStore'): def create_output_by_derivation(): - script_type = self.get_keepkey_output_script_type(txout.script_type) - if len(txout.pubkeys) > 1: - xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txout) - multisig = self._make_multisig(txout.num_sig, xpubs_and_deriv_suffixes) + assert (desc := txout.script_descriptor) + script_type = self.get_keepkey_output_script_type(desc.to_legacy_electrum_script_type()) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txout) diff --git a/electrum/plugins/ledger/ledger.py b/electrum/plugins/ledger/ledger.py index be1ca437f..5b1d213db 100644 --- a/electrum/plugins/ledger/ledger.py +++ b/electrum/plugins/ledger/ledger.py @@ -8,6 +8,7 @@ from typing import Dict, List, Optional, Sequence, Tuple from electrum import bip32, constants, ecc +from electrum import descriptor from electrum.base_wizard import ScriptTypeNotSupported from electrum.bip32 import BIP32Node, convert_bip32_intpath_to_strpath from electrum.bitcoin import EncodeBase58Check, int_to_hex, is_b58_address, is_segwit_script_type, var_int @@ -16,7 +17,7 @@ from electrum.i18n import _ from electrum.keystore import Hardware_KeyStore from electrum.logging import get_logger from electrum.plugin import Device, runs_in_hwd_thread -from electrum.transaction import PartialTransaction, Transaction +from electrum.transaction import PartialTransaction, Transaction, PartialTxInput from electrum.util import bfh, UserFacingException, versiontuple from electrum.wallet import Standard_Wallet @@ -544,20 +545,25 @@ class Ledger_Client_Legacy(Ledger_Client): pin = "" # prompt for the PIN before displaying the dialog if necessary + def is_txin_legacy_multisig(txin: PartialTxInput) -> bool: + desc = txin.script_descriptor + return (isinstance(desc, descriptor.SHDescriptor) + and isinstance(desc.subdescriptors[0], descriptor.MultisigDescriptor)) + # Fetch inputs of the transaction to sign for txin in tx.inputs(): if txin.is_coinbase_input(): self.give_error("Coinbase not supported") # should never happen - if txin.script_type in ['p2sh']: + if is_txin_legacy_multisig(txin): p2shTransaction = True - if txin.script_type in ['p2wpkh-p2sh', 'p2wsh-p2sh']: + if txin.is_p2sh_segwit(): if not self.supports_segwit(): self.give_error(MSG_NEEDS_FW_UPDATE_SEGWIT) segwitTransaction = True - if txin.script_type in ['p2wpkh', 'p2wsh']: + if txin.is_native_segwit(): if not self.supports_native_segwit(): self.give_error(MSG_NEEDS_FW_UPDATE_SEGWIT) segwitTransaction = True @@ -584,7 +590,7 @@ class Ledger_Client_Legacy(Ledger_Client): # Sanity check if p2shTransaction: for txin in tx.inputs(): - if txin.script_type != 'p2sh': + if not is_txin_legacy_multisig(txin): self.give_error("P2SH / regular input mixed in same transaction not supported") # should never happen txOutput = var_int(len(tx.outputs())) @@ -1083,7 +1089,6 @@ class Ledger_Client_New(Ledger_Client): raise UserFacingException("Coinbase not supported") # should never happen utxo = None - scriptcode = b"" if psbt_in.witness_utxo: utxo = psbt_in.witness_utxo if psbt_in.non_witness_utxo: @@ -1094,19 +1099,9 @@ class Ledger_Client_New(Ledger_Client): if utxo is None: continue - scriptcode = utxo.scriptPubKey - if electrum_txin.script_type in ['p2sh', 'p2wpkh-p2sh']: - if len(psbt_in.redeem_script) == 0: - continue - scriptcode = psbt_in.redeem_script - elif electrum_txin.script_type in ['p2wsh', 'p2wsh-p2sh']: - if len(psbt_in.witness_script) == 0: - continue - scriptcode = psbt_in.witness_script - - p2sh = False - if electrum_txin.script_type in ['p2sh', 'p2wpkh-p2sh', 'p2wsh-p2sh']: - p2sh = True + if (desc := electrum_txin.script_descriptor) is None: + raise Exception("script_descriptor missing for txin ") + scriptcode = desc.expand().scriptcode_for_sighash is_wit, wit_ver, __ = is_witness(psbt_in.redeem_script or utxo.scriptPubKey) @@ -1115,7 +1110,7 @@ class Ledger_Client_New(Ledger_Client): # if it's a segwit spend (any version), make sure the witness_utxo is also present psbt_in.witness_utxo = utxo - if p2sh: + if electrum_txin.is_p2sh_segwit(): if wit_ver == 0: script_addrtype = AddressType.SH_WIT else: diff --git a/electrum/plugins/safe_t/safe_t.py b/electrum/plugins/safe_t/safe_t.py index 376bbfa60..750171fc0 100644 --- a/electrum/plugins/safe_t/safe_t.py +++ b/electrum/plugins/safe_t/safe_t.py @@ -1,10 +1,11 @@ from binascii import hexlify, unhexlify import traceback import sys -from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING +from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING, Sequence from electrum.util import bfh, versiontuple, UserCancelled, UserFacingException from electrum.bip32 import BIP32Node +from electrum import descriptor from electrum import constants from electrum.i18n import _ from electrum.plugin import Device, runs_in_hwd_thread @@ -13,8 +14,7 @@ from electrum.keystore import Hardware_KeyStore from electrum.base_wizard import ScriptTypeNotSupported from ..hw_wallet import HW_PluginBase -from ..hw_wallet.plugin import (is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data, - get_xpubs_and_der_suffixes_from_txinout) +from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data if TYPE_CHECKING: from .client import SafeTClient @@ -241,7 +241,7 @@ class SafeTPlugin(HW_PluginBase): client.load_device_by_xprv(item, pin, passphrase_protection, label, language) - def _make_node_path(self, xpub, address_n): + def _make_node_path(self, xpub: str, address_n: Sequence[int]): bip32node = BIP32Node.from_xkey(xpub) node = self.types.HDNodeType( depth=bip32node.depth, @@ -321,14 +321,9 @@ class SafeTPlugin(HW_PluginBase): script_type = self.get_safet_input_script_type(wallet.txin_type) # prepare multisig, if available: - xpubs = wallet.get_master_public_keys() - if len(xpubs) > 1: - pubkeys = wallet.get_public_keys(address) - # sort xpubs using the order of pubkeys - sorted_pairs = sorted(zip(pubkeys, xpubs)) - multisig = self._make_multisig( - wallet.m, - [(xpub, deriv_suffix) for pubkey, xpub in sorted_pairs]) + desc = wallet.get_script_descriptor_for_address(address) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None @@ -346,12 +341,12 @@ class SafeTPlugin(HW_PluginBase): assert isinstance(tx, PartialTransaction) assert isinstance(txin, PartialTxInput) assert keystore - if len(txin.pubkeys) > 1: - xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txin) - multisig = self._make_multisig(txin.num_sig, xpubs_and_deriv_suffixes) + assert (desc := txin.script_descriptor) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None - script_type = self.get_safet_input_script_type(txin.script_type) + script_type = self.get_safet_input_script_type(desc.to_legacy_electrum_script_type()) txinputtype = self.types.TxInputType( script_type=script_type, multisig=multisig) @@ -376,22 +371,26 @@ class SafeTPlugin(HW_PluginBase): return inputs - def _make_multisig(self, m, xpubs): - if len(xpubs) == 1: - return None - pubkeys = [self._make_node_path(xpub, deriv) for xpub, deriv in xpubs] + def _make_multisig(self, desc: descriptor.MultisigDescriptor): + pubkeys = [] + for pubkey_provider in desc.pubkeys: + assert not pubkey_provider.is_range() + assert pubkey_provider.extkey is not None + xpub = pubkey_provider.pubkey + der_suffix = pubkey_provider.get_der_suffix_int_list() + pubkeys.append(self._make_node_path(xpub, der_suffix)) return self.types.MultisigRedeemScriptType( pubkeys=pubkeys, signatures=[b''] * len(pubkeys), - m=m) + m=desc.thresh) def tx_outputs(self, tx: PartialTransaction, *, keystore: 'SafeTKeyStore'): def create_output_by_derivation(): - script_type = self.get_safet_output_script_type(txout.script_type) - if len(txout.pubkeys) > 1: - xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txout) - multisig = self._make_multisig(txout.num_sig, xpubs_and_deriv_suffixes) + assert (desc := txout.script_descriptor) + script_type = self.get_safet_output_script_type(desc.to_legacy_electrum_script_type()) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txout) diff --git a/electrum/plugins/trezor/trezor.py b/electrum/plugins/trezor/trezor.py index db52bd53f..390a812fa 100644 --- a/electrum/plugins/trezor/trezor.py +++ b/electrum/plugins/trezor/trezor.py @@ -1,9 +1,10 @@ import traceback import sys -from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING +from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING, Sequence from electrum.util import bfh, versiontuple, UserCancelled, UserFacingException from electrum.bip32 import BIP32Node, convert_bip32_path_to_list_of_uint32 as parse_path +from electrum import descriptor from electrum import constants from electrum.i18n import _ from electrum.plugin import Device, runs_in_hwd_thread @@ -14,8 +15,7 @@ from electrum.logging import get_logger from ..hw_wallet import HW_PluginBase from ..hw_wallet.plugin import (is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data, - LibraryFoundButUnusable, OutdatedHwFirmwareException, - get_xpubs_and_der_suffixes_from_txinout) + LibraryFoundButUnusable, OutdatedHwFirmwareException) _logger = get_logger(__name__) @@ -284,7 +284,7 @@ class TrezorPlugin(HW_PluginBase): else: raise RuntimeError("Unsupported recovery method") - def _make_node_path(self, xpub, address_n): + def _make_node_path(self, xpub: str, address_n: Sequence[int]): bip32node = BIP32Node.from_xkey(xpub) node = HDNodeType( depth=bip32node.depth, @@ -386,14 +386,9 @@ class TrezorPlugin(HW_PluginBase): script_type = self.get_trezor_input_script_type(wallet.txin_type) # prepare multisig, if available: - xpubs = wallet.get_master_public_keys() - if len(xpubs) > 1: - pubkeys = wallet.get_public_keys(address) - # sort xpubs using the order of pubkeys - sorted_pairs = sorted(zip(pubkeys, xpubs)) - multisig = self._make_multisig( - wallet.m, - [(xpub, deriv_suffix) for pubkey, xpub in sorted_pairs]) + desc = wallet.get_script_descriptor_for_address(address) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None @@ -417,10 +412,10 @@ class TrezorPlugin(HW_PluginBase): assert isinstance(tx, PartialTransaction) assert isinstance(txin, PartialTxInput) assert keystore - if len(txin.pubkeys) > 1: - xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txin) - txinputtype.multisig = self._make_multisig(txin.num_sig, xpubs_and_deriv_suffixes) - txinputtype.script_type = self.get_trezor_input_script_type(txin.script_type) + assert (desc := txin.script_descriptor) + if multi := desc.get_simple_multisig(): + txinputtype.multisig = self._make_multisig(multi) + txinputtype.script_type = self.get_trezor_input_script_type(desc.to_legacy_electrum_script_type()) my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txin) if full_path: txinputtype.address_n = full_path @@ -433,22 +428,26 @@ class TrezorPlugin(HW_PluginBase): return inputs - def _make_multisig(self, m, xpubs): - if len(xpubs) == 1: - return None - pubkeys = [self._make_node_path(xpub, deriv) for xpub, deriv in xpubs] + def _make_multisig(self, desc: descriptor.MultisigDescriptor): + pubkeys = [] + for pubkey_provider in desc.pubkeys: + assert not pubkey_provider.is_range() + assert pubkey_provider.extkey is not None + xpub = pubkey_provider.pubkey + der_suffix = pubkey_provider.get_der_suffix_int_list() + pubkeys.append(self._make_node_path(xpub, der_suffix)) return MultisigRedeemScriptType( pubkeys=pubkeys, signatures=[b''] * len(pubkeys), - m=m) + m=desc.thresh) def tx_outputs(self, tx: PartialTransaction, *, keystore: 'TrezorKeyStore'): def create_output_by_derivation(): - script_type = self.get_trezor_output_script_type(txout.script_type) - if len(txout.pubkeys) > 1: - xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txout) - multisig = self._make_multisig(txout.num_sig, xpubs_and_deriv_suffixes) + assert (desc := txout.script_descriptor) + script_type = self.get_trezor_output_script_type(desc.to_legacy_electrum_script_type()) + if multi := desc.get_simple_multisig(): + multisig = self._make_multisig(multi) else: multisig = None my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txout) diff --git a/electrum/segwit_addr.py b/electrum/segwit_addr.py index b13a9175e..9d121c3b9 100644 --- a/electrum/segwit_addr.py +++ b/electrum/segwit_addr.py @@ -25,7 +25,7 @@ from enum import Enum from typing import Tuple, Optional, Sequence, NamedTuple, List CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" -_CHARSET_INVERSE = {x: CHARSET.find(x) for x in CHARSET} +_CHARSET_INVERSE = {c: i for (i, c) in enumerate(CHARSET)} BECH32_CONST = 1 BECH32M_CONST = 0x2bc830a3 diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 9532bc8a6..7b82b739a 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -124,7 +124,6 @@ def create_claim_tx( """Create tx to either claim successful reverse-swap, or to get refunded for timed-out forward-swap. """ - txin.script_type = 'p2wsh' txin.script_sig = b'' txin.witness_script = witness_script txout = PartialTxOutput.from_address_and_value(address, amount_sat) @@ -622,8 +621,6 @@ class SwapManager(Logger): return preimage = swap.preimage if swap.is_reverse else 0 witness_script = swap.redeem_script - txin.script_type = 'p2wsh' - txin.num_sig = 1 # hack so that txin not considered "is_complete" txin.script_sig = b'' txin.witness_script = witness_script sig_dummy = b'\x00' * 71 # DER-encoded ECDSA sig, with low S and low R @@ -637,7 +634,6 @@ class SwapManager(Logger): txin = tx.inputs()[0] assert len(tx.inputs()) == 1, f"expected 1 input for swap claim tx. found {len(tx.inputs())}" assert txin.prevout.txid.hex() == swap.funding_txid - txin.script_type = 'p2wsh' txin.script_sig = b'' txin.witness_script = witness_script sig = bytes.fromhex(tx.sign_txin(0, swap.privkey)) diff --git a/electrum/tests/test_bitcoin.py b/electrum/tests/test_bitcoin.py index 08e881ecd..c3026a0d2 100644 --- a/electrum/tests/test_bitcoin.py +++ b/electrum/tests/test_bitcoin.py @@ -815,6 +815,28 @@ class Test_xprv_xpub(ElectrumTestCase): self.assertFalse(is_xprv('xprv1nval1d')) self.assertFalse(is_xprv('xprv661MyMwAqRbcFWohJWt7PHsFEJfZAvw9ZxwQoDa4SoMgsDDM1T7WK3u9E4edkC4ugRnZ8E4xDZRpk8Rnts3Nbt97dPwT52WRONGBADWRONG')) + def test_bip32_from_xkey(self): + bip32node1 = BIP32Node.from_xkey("xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy") + self.assertEqual( + BIP32Node( + xtype='standard', + eckey=ecc.ECPubkey(bytes.fromhex("022a471424da5e657499d1ff51cb43c47481a03b1e77f951fe64cec9f5a48f7011")), + chaincode=bytes.fromhex("c783e67b921d2beb8f6b389cc646d7263b4145701dadd2161548a8b078e65e9e"), + depth=5, + fingerprint=bytes.fromhex("d880d7d8"), + child_number=bytes.fromhex("3b9aca00"), + ), + bip32node1) + with self.assertRaises(ValueError): + BIP32Node.from_xkey( + "zpub6jftahH18ngZyLeqfLBFAm7YaWFVttE9pku5pNMX2qPzTjoq1FVgZMmhjecyB2nqFb31gHE9vNvbaggU6vvWpNZbXEWLLUjYjFqG95LNyT8", + allow_custom_headers=False) + bip32node2 = BIP32Node.from_xkey( + "zpub6jftahH18ngZyLeqfLBFAm7YaWFVttE9pku5pNMX2qPzTjoq1FVgZMmhjecyB2nqFb31gHE9vNvbaggU6vvWpNZbXEWLLUjYjFqG95LNyT8", + allow_custom_headers=True) + self.assertEqual(bytes.fromhex("03f18e53f3386a5f9a9d2c369ad3b84b429eb397b4bc69ce600f2d833b54ba32f4"), + bip32node2.eckey.get_public_key_bytes(compressed=True)) + def test_is_bip32_derivation(self): self.assertTrue(is_bip32_derivation("m/0'/1")) self.assertTrue(is_bip32_derivation("m/0'/0'")) diff --git a/electrum/tests/test_descriptor.py b/electrum/tests/test_descriptor.py new file mode 100644 index 000000000..936d36683 --- /dev/null +++ b/electrum/tests/test_descriptor.py @@ -0,0 +1,390 @@ +# Copyright (c) 2018-2023 The HWI developers +# Copyright (c) 2023 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +# +# originally from https://github.com/bitcoin-core/HWI/blob/f5a9b29c00e483cc99a1b8f4f5ef75413a092869/test/test_descriptor.py + +from binascii import unhexlify +import unittest + +from electrum.descriptor import ( + parse_descriptor, + MultisigDescriptor, + SHDescriptor, + TRDescriptor, + PKHDescriptor, + WPKHDescriptor, + WSHDescriptor, + PubkeyProvider, +) +from electrum import ecc +from electrum.util import bfh + +from . import ElectrumTestCase, as_testnet + + +class TestDescriptor(ElectrumTestCase): + + @as_testnet + def test_parse_descriptor_with_origin(self): + d = "wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].get_full_derivation_path(), "m/84h/1h/0h/0/0") + self.assertEqual(desc.pubkeys[0].get_full_derivation_int_list(), [16777216, 2147483732, 2147483649, 2147483648, 0, 0]) + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + self.assertEqual(e.address(), "tb1qm90ugl4d48jv8n6e5t9ln6t9zlpm5th690vysp") + + @as_testnet + def test_parse_multisig_descriptor_with_origin(self): + d = "wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WSHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0], MultisigDescriptor)) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].get_full_derivation_path(), "m/48h/0h/0h/2h/0/0") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].get_full_derivation_int_list(), [16777216, 2147483696, 2147483648, 2147483648, 2147483650, 0, 0]) + + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("002084b64b2b8651df8fd3e9735f6269edbf9e03abf619ae0788be9f17bf18e83d59")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, unhexlify("522102c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c721033a4f18d2b498273ed7439c59f6d8a673d5b9c67a03163d530e12c941ca22be3352ae")) + + d = "sh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, SHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0], MultisigDescriptor)) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("a91495ee6326805b1586bb821fc3c0eeab2c68441b4187")) + self.assertEqual(e.redeem_script, unhexlify("522102c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c721033a4f18d2b498273ed7439c59f6d8a673d5b9c67a03163d530e12c941ca22be3352ae")) + self.assertEqual(e.witness_script, None) + + d = "sh(wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0)))" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, SHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0], WSHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0].subdescriptors[0], MultisigDescriptor)) + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("a914779ae0f6958e98b997cc177f9b554289905fbb5587")) + self.assertEqual(e.redeem_script, unhexlify("002084b64b2b8651df8fd3e9735f6269edbf9e03abf619ae0788be9f17bf18e83d59")) + self.assertEqual(e.witness_script, unhexlify("522102c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c721033a4f18d2b498273ed7439c59f6d8a673d5b9c67a03163d530e12c941ca22be3352ae")) + + @as_testnet + def test_parse_descriptor_without_origin(self): + d = "wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin, None) + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.pubkeys[0].get_full_derivation_path(), "m/0/0") + self.assertEqual(desc.pubkeys[0].get_full_derivation_int_list(), [0, 0]) + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + @as_testnet + def test_parse_descriptor_with_origin_fingerprint_only(self): + d = "wpkh([00000001]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(len(desc.pubkeys[0].origin.path), 0) + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + def test_parse_descriptor_with_key_at_end_with_origin(self): + d = "wpkh([00000001/84h/1h/0h/0/0]02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") + self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") + self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].get_full_derivation_path(), "m/84h/1h/0h/0/0") + self.assertEqual(desc.pubkeys[0].get_full_derivation_int_list(), [16777216, 2147483732, 2147483649, 2147483648, 0, 0]) + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + d = "pkh([00000001/84h/1h/0h/0/0]02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, PKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") + self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") + self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand() + self.assertEqual(e.output_script, unhexlify("76a914d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa88ac")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + def test_parse_descriptor_with_key_at_end_without_origin(self): + d = "wpkh(02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin, None) + self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") + self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.pubkeys[0].get_full_derivation_path(), "m") + self.assertEqual(desc.pubkeys[0].get_full_derivation_int_list(), []) + self.assertEqual(desc.to_string_no_checksum(), d) + + def test_parse_empty_descriptor(self): + self.assertRaises(ValueError, parse_descriptor, "") + + @as_testnet + def test_parse_descriptor_replace_h(self): + d = "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertIsNotNone(desc) + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + + @as_testnet + def test_parse_descriptor_unknown_notation_for_hardened_derivation(self): + with self.assertRaises(ValueError): + desc = parse_descriptor("wpkh([00000001/84x/1x/0x]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)") + with self.assertRaises(ValueError): + desc = parse_descriptor("wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0x)") + + def test_checksums(self): + with self.subTest(msg="Valid checksum"): + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kwj")) + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#hgmsckna")) + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))")) + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))")) + with self.subTest(msg="Empty Checksum"): + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#") + with self.subTest(msg="Too long Checksum"): + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kwjq") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#hgmscknaq") + with self.subTest(msg="Too Short Checksum"): + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kw") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#hgmsckn") + with self.subTest(msg="Error in Payload"): + self.assertRaises(ValueError, parse_descriptor, "sh(multi(3,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxf") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(3,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5") + with self.subTest(msg="Error in Checksum"): + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kej") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09y5") + + @as_testnet + def test_tr_descriptor(self): + d = "tr([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, TRDescriptor)) + self.assertEqual(len(desc.pubkeys), 1) + self.assertEqual(len(desc.subdescriptors), 0) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + + d = "tr([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),{{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)},pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)}})" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, TRDescriptor)) + self.assertEqual(len(desc.subdescriptors), 4) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.depths, [1, 3, 3, 2]) + self.assertEqual(desc.to_string_no_checksum(), d) + + @as_testnet + def test_parse_descriptor_with_range(self): + d = "wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/*") + self.assertEqual(desc.to_string_no_checksum(), d) + with self.assertRaises(ValueError): # "pos" arg needed due to "*" + e = desc.expand() + e = desc.expand(pos=7) + self.assertEqual(e.output_script, unhexlify("0014c5f80de08f6ae8dd720bf4e4948ba498c96256a1")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + with self.assertRaises(ValueError): # wildcard only allowed in last position + parse_descriptor("wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/*/0)") + with self.assertRaises(ValueError): # only one wildcard(*) is allowed + parse_descriptor("wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/*/*)") + + @as_testnet + def test_parse_multisig_descriptor_with_range(self): + d = "wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/*))" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WSHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0], MultisigDescriptor)) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/*") + + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/*") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(pos=7) + self.assertEqual(e.output_script, unhexlify("0020453cdf90aef0997947bc0605481f81dd2978ecd2d04ac36fb57397a82341682d")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, unhexlify("5221034e703dfcd64f23ad5d6156ee3b9dd7566137626c663bb521bf710957599723342102c35627535d26de98ae749b7a7849df99cbe53af795005437ca647c8af9a006af52ae")) + + @as_testnet + def test_multisig_descriptor_with_mixed_range(self): + d = "sh(wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0)))" + desc = parse_descriptor(d) + e = desc.expand(pos=7) + self.assertEqual(e.output_script, bfh("a914644ece12bab2f84ad6de96ec18de51e6168c028987")) + self.assertEqual(e.redeem_script, bfh("0020824ce4ffab74a8d09c2f77ed447fb040ea5dfbed06f8e3b3327127a18634f6a7")) + self.assertEqual(e.witness_script, bfh("5221034e703dfcd64f23ad5d6156ee3b9dd7566137626c663bb521bf7109575997233421033a4f18d2b498273ed7439c59f6d8a673d5b9c67a03163d530e12c941ca22be3352ae")) + self.assertEqual(e.address(), "2N2Pbxw3HNJ9jrUw8LCSfXyDWx9TKGRT2an") + + @as_testnet + def test_uncompressed_pubkey_in_segwit(self): + pubkey = ecc.ECPubkey(bfh("02a0507c8bb3d96dfd7731bafb0ae30e6ed10bbadd6a9f9f88eaf0602b9cc99adc")) + pubkey_comp_hex = pubkey.get_public_key_hex(compressed=True) + pubkey_uncomp_hex = pubkey.get_public_key_hex(compressed=False) + self.assertEqual(pubkey_comp_hex, "02a0507c8bb3d96dfd7731bafb0ae30e6ed10bbadd6a9f9f88eaf0602b9cc99adc") + self.assertEqual(pubkey_uncomp_hex, "04a0507c8bb3d96dfd7731bafb0ae30e6ed10bbadd6a9f9f88eaf0602b9cc99adc3ccfc29410b8f23c15d88413a6b88c8cd44b016a7f1dd91a8d64c3107c6bce1a") + # pkh + desc = parse_descriptor(f"pkh({pubkey_comp_hex})") + self.assertEqual(desc.expand().output_script, bfh("76a9140297bde2689a3c79ffe050583b62f86f2d9dae5488ac")) + desc = parse_descriptor(f"pkh({pubkey_uncomp_hex})") + self.assertEqual(desc.expand().output_script, bfh("76a914e1f4a76b122f0288b013404cd52a9d1de0ced3c488ac")) + # wpkh + desc = parse_descriptor(f"wpkh({pubkey_comp_hex})") + self.assertEqual(desc.expand().output_script, bfh("00140297bde2689a3c79ffe050583b62f86f2d9dae54")) + with self.assertRaises(ValueError): # only compressed public keys can be used in segwit scripts + desc = parse_descriptor(f"wpkh({pubkey_uncomp_hex})") + # sh(wsh(multi())) + desc = parse_descriptor(f"sh(wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*,{pubkey_comp_hex})))") + self.assertEqual(desc.expand(pos=2).output_script, bfh("a9148f162cce29ad81e63ed45cd09aff83418316eab687")) + with self.assertRaises(ValueError): # only compressed public keys can be used in segwit scripts + desc = parse_descriptor(f"sh(wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*,{pubkey_uncomp_hex})))") + + @as_testnet + def test_parse_descriptor_context(self): + desc = parse_descriptor("sh(wsh(sortedmulti(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0)))") + self.assertTrue(isinstance(desc, SHDescriptor)) + with self.assertRaises(ValueError): # Can only have sh() at top level + desc = parse_descriptor("wsh(sh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0)))") + with self.assertRaises(ValueError): # Can only have wsh() at top level or inside sh() + desc = parse_descriptor("wsh(wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0)))") + + desc = parse_descriptor("wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)") + self.assertTrue(isinstance(desc, WPKHDescriptor)) + with self.assertRaises(ValueError): # Can only have wpkh() at top level or inside sh() + desc = parse_descriptor("wsh(wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0))") + + def test_parse_descriptor_ypub_zpub_forbidden(self): + desc = parse_descriptor("wpkh([535e473f/0h]xpub68W3CJPrQzHhTQcHM6tbCvNVB9ih4tbzsFBLwe7zZUj5uHuhxBUhvnXe1RQhbKCTiTj3D7kXni6yAD88i2xnjKHaJ5NqTtHawKnPFCDnmo4/0/*)") + with self.assertRaises(ValueError): # only standard xpub/xprv allowed + desc = parse_descriptor("wpkh([535e473f/0h]ypub6TLJVy4mZfqBJhoQBTgDR1TzM7s91WbVnMhZj31swV6xxPiwCqeGYrBn2dNHbDrP86qqxbM6FNTX3VjhRjNoXYyBAR5G3o75D3r2djmhZwM/0/*)") + with self.assertRaises(ValueError): # only standard xpub/xprv allowed + desc = parse_descriptor("wpkh([535e473f/0h]zpub6nAZodjgiMNf9zzX1pTqd6ZVX61ax8azhUDnWRumKVUr1VYATVoqAuqv3qKsb8WJXjxei4wei2p4vnMG9RnpKnen2kmgdhvZUmug2NnHNsr/0/*)") + + @as_testnet + def test_sortedmulti_ranged_pubkey_order(self): + xpub1 = "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + xpub2 = "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty" + # if ranged, we sort lexicographically + desc = parse_descriptor(f"sh(wsh(sortedmulti(2,[00000001/48h/0h/0h/2h]{xpub1}/0/*,[00000002/48h/0h/0h/2h]{xpub2}/0/*)))") + self.assertEqual([xpub1, xpub2], [pk.pubkey for pk in desc.subdescriptors[0].subdescriptors[0].pubkeys]) + desc = parse_descriptor(f"sh(wsh(sortedmulti(2,[00000002/48h/0h/0h/2h]{xpub2}/0/*,[00000001/48h/0h/0h/2h]{xpub1}/0/*)))") + self.assertEqual([xpub1, xpub2], [pk.pubkey for pk in desc.subdescriptors[0].subdescriptors[0].pubkeys]) + # if unsorted "multi", don't touch order + desc = parse_descriptor(f"sh(wsh(multi(2,[00000002/48h/0h/0h/2h]{xpub2}/0/*,[00000001/48h/0h/0h/2h]{xpub1}/0/*)))") + self.assertEqual([xpub2, xpub1], [pk.pubkey for pk in desc.subdescriptors[0].subdescriptors[0].pubkeys]) + + @as_testnet + def test_sortedmulti_unranged_pubkey_order(self): + xpub1 = "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + xpub2 = "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty" + # if not ranged, we sort according to final derived pubkey order + desc = parse_descriptor(f"sh(wsh(sortedmulti(2,[00000001/48h/0h/0h/2h]{xpub1}/0/0,[00000002/48h/0h/0h/2h]{xpub2}/0/0)))") + self.assertEqual([xpub1, xpub2], [pk.pubkey for pk in desc.subdescriptors[0].subdescriptors[0].pubkeys]) + desc = parse_descriptor(f"sh(wsh(sortedmulti(2,[00000001/48h/0h/0h/2h]{xpub1}/0/1,[00000002/48h/0h/0h/2h]{xpub2}/0/1)))") + self.assertEqual([xpub2, xpub1], [pk.pubkey for pk in desc.subdescriptors[0].subdescriptors[0].pubkeys]) + desc = parse_descriptor(f"sh(wsh(sortedmulti(2,[00000001/48h/0h/0h/2h]{xpub1}/0/4,[00000002/48h/0h/0h/2h]{xpub2}/0/4)))") + self.assertEqual([xpub1, xpub2], [pk.pubkey for pk in desc.subdescriptors[0].subdescriptors[0].pubkeys]) + # if unsorted "multi", don't touch order + desc = parse_descriptor(f"sh(wsh(multi(2,[00000001/48h/0h/0h/2h]{xpub1}/0/1,[00000002/48h/0h/0h/2h]{xpub2}/0/1)))") + self.assertEqual([xpub1, xpub2], [pk.pubkey for pk in desc.subdescriptors[0].subdescriptors[0].pubkeys]) + + def test_pubkey_provider_deriv_path(self): + xpub = "xpub68W3CJPrQzHhTQcHM6tbCvNVB9ih4tbzsFBLwe7zZUj5uHuhxBUhvnXe1RQhbKCTiTj3D7kXni6yAD88i2xnjKHaJ5NqTtHawKnPFCDnmo4" + # valid: + pp = PubkeyProvider(origin=None, pubkey=xpub, deriv_path="/1/7") + pp = PubkeyProvider(origin=None, pubkey=xpub, deriv_path="/1/*") + # invalid: + with self.assertRaises(ValueError): + pp = PubkeyProvider(origin=None, pubkey=xpub, deriv_path="1") + with self.assertRaises(ValueError): + pp = PubkeyProvider(origin=None, pubkey=xpub, deriv_path="1/7") + with self.assertRaises(ValueError): + pp = PubkeyProvider(origin=None, pubkey=xpub, deriv_path="m/1/7") + with self.assertRaises(ValueError): + pp = PubkeyProvider(origin=None, pubkey=xpub, deriv_path="*/7") + with self.assertRaises(ValueError): + pp = PubkeyProvider(origin=None, pubkey=xpub, deriv_path="*/*") + + pubkey_hex = "02a0507c8bb3d96dfd7731bafb0ae30e6ed10bbadd6a9f9f88eaf0602b9cc99adc" + # valid: + pp = PubkeyProvider(origin=None, pubkey=pubkey_hex, deriv_path=None) + # invalid: + with self.assertRaises(ValueError): + pp = PubkeyProvider(origin=None, pubkey=pubkey_hex, deriv_path="/1/7") diff --git a/electrum/tests/test_transaction.py b/electrum/tests/test_transaction.py index c1bb1bb6f..50fbafeda 100644 --- a/electrum/tests/test_transaction.py +++ b/electrum/tests/test_transaction.py @@ -9,8 +9,9 @@ from electrum.util import bfh from electrum.bitcoin import (deserialize_privkey, opcodes, construct_script, construct_witness) from electrum.ecc import ECPrivkey -from .test_bitcoin import disable_ecdsa_r_value_grinding +from electrum import descriptor +from .test_bitcoin import disable_ecdsa_r_value_grinding from . import ElectrumTestCase signed_blob = '01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65bf38633b424eb4031000000006c493046022100a82bbc57a0136751e5433f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7de89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d985012102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae35cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a42146f11ef8414ae929feaafc388ac00000000' @@ -89,9 +90,10 @@ class TestTransaction(ElectrumTestCase): def test_tx_update_signatures(self): tx = tx_from_any("cHNidP8BAFUBAAAAASpcmpT83pj1WBzQAWLGChOTbOt1OJ6mW/OGM7Qk60AxAAAAAAD/////AUBCDwAAAAAAGXapFCMKw3g0BzpCFG8R74QUrpKf6q/DiKwAAAAAAAAA") - tx.inputs()[0].script_type = 'p2pkh' - tx.inputs()[0].pubkeys = [bfh('02e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae35cdb84d2f6')] - tx.inputs()[0].num_sig = 1 + pubkey = bfh('02e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae35cdb84d2f6') + script_type = 'p2pkh' + desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey.hex(), script_type=script_type) + tx.inputs()[0].script_descriptor = desc tx.update_signatures(signed_blob_signatures) self.assertEqual(tx.serialize(), signed_blob) @@ -873,7 +875,6 @@ class TestTransactionTestnet(ElectrumTestCase): prevout = TxOutpoint(txid=bfh('6d500966f9e494b38a04545f0cea35fc7b3944e341a64b804fed71cdee11d434'), out_idx=1) txin = PartialTxInput(prevout=prevout) txin.nsequence = 2 ** 32 - 3 - txin.script_type = 'p2sh' redeem_script = bfh(construct_script([ locktime, opcodes.OP_CHECKLOCKTIMEVERIFY, opcodes.OP_DROP, pubkey, opcodes.OP_CHECKSIG, ])) @@ -935,7 +936,6 @@ class TestSighashTypes(ElectrumTestCase): prevout = TxOutpoint(txid=bfh('6eb98797a21c6c10aa74edf29d618be109f48a8e94c694f3701e08ca69186436'), out_idx=1) txin = PartialTxInput(prevout=prevout) txin.nsequence=0xffffffff - txin.script_type='p2sh-p2wsh' txin.witness_script = bfh('56210307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba32103b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b21034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a21033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f42103a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac162102d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b56ae') txin.redeem_script = bfh('0020a16b5755f7f6f96dbd65f5f0d6ab9418b89af4b1f14a1bb8a09062c35f0dcb54') txin._trusted_value_sats = 987654321 @@ -945,7 +945,6 @@ class TestSighashTypes(ElectrumTestCase): def test_check_sighash_types_sighash_all(self): self.txin.sighash=Sighash.ALL - self.txin.pubkeys = [bfh('0307b8ae49ac90a048e9b53357a2354b3334e9c8bee813ecb98e99a7e07e8c3ba3')] privkey = bfh('730fff80e1413068a05b57d6a58261f07551163369787f349438ea38ca80fac6') tx = PartialTransaction.from_io(inputs=[self.txin], outputs=[self.txout1,self.txout2], locktime=self.locktime, version=1, BIP69_sort=False) sig = tx.sign_txin(0,privkey) @@ -954,7 +953,6 @@ class TestSighashTypes(ElectrumTestCase): def test_check_sighash_types_sighash_none(self): self.txin.sighash=Sighash.NONE - self.txin.pubkeys = [bfh('03b28f0c28bfab54554ae8c658ac5c3e0ce6e79ad336331f78c428dd43eea8449b')] privkey = bfh('11fa3d25a17cbc22b29c44a484ba552b5a53149d106d3d853e22fdd05a2d8bb3') tx = PartialTransaction.from_io(inputs=[self.txin], outputs=[self.txout1,self.txout2], locktime=self.locktime, version=1, BIP69_sort=False) sig = tx.sign_txin(0,privkey) @@ -963,7 +961,6 @@ class TestSighashTypes(ElectrumTestCase): def test_check_sighash_types_sighash_single(self): self.txin.sighash=Sighash.SINGLE - self.txin.pubkeys = [bfh('034b8113d703413d57761b8b9781957b8c0ac1dfe69f492580ca4195f50376ba4a')] privkey = bfh('77bf4141a87d55bdd7f3cd0bdccf6e9e642935fec45f2f30047be7b799120661') tx = PartialTransaction.from_io(inputs=[self.txin], outputs=[self.txout1,self.txout2], locktime=self.locktime, version=1, BIP69_sort=False) sig = tx.sign_txin(0,privkey) @@ -973,7 +970,6 @@ class TestSighashTypes(ElectrumTestCase): @disable_ecdsa_r_value_grinding def test_check_sighash_types_sighash_all_anyonecanpay(self): self.txin.sighash=Sighash.ALL|Sighash.ANYONECANPAY - self.txin.pubkeys = [bfh('033400f6afecb833092a9a21cfdf1ed1376e58c5d1f47de74683123987e967a8f4')] privkey = bfh('14af36970f5025ea3e8b5542c0f8ebe7763e674838d08808896b63c3351ffe49') tx = PartialTransaction.from_io(inputs=[self.txin], outputs=[self.txout1,self.txout2], locktime=self.locktime, version=1, BIP69_sort=False) sig = tx.sign_txin(0,privkey) @@ -983,7 +979,6 @@ class TestSighashTypes(ElectrumTestCase): @disable_ecdsa_r_value_grinding def test_check_sighash_types_sighash_none_anyonecanpay(self): self.txin.sighash=Sighash.NONE|Sighash.ANYONECANPAY - self.txin.pubkeys = [bfh('03a6d48b1131e94ba04d9737d61acdaa1322008af9602b3b14862c07a1789aac16')] privkey = bfh('fe9a95c19eef81dde2b95c1284ef39be497d128e2aa46916fb02d552485e0323') tx = PartialTransaction.from_io(inputs=[self.txin], outputs=[self.txout1,self.txout2], locktime=self.locktime, version=1, BIP69_sort=False) sig = tx.sign_txin(0,privkey) @@ -992,7 +987,6 @@ class TestSighashTypes(ElectrumTestCase): def test_check_sighash_types_sighash_single_anyonecanpay(self): self.txin.sighash=Sighash.SINGLE|Sighash.ANYONECANPAY - self.txin.pubkeys = [bfh('02d8b661b0b3302ee2f162b09e07a55ad5dfbe673a9f01d9f0c19617681024306b')] privkey = bfh('428a7aee9f0c2af0cd19af3cf1c78149951ea528726989b2e83e4778d2c3f890') tx = PartialTransaction.from_io(inputs=[self.txin], outputs=[self.txout1,self.txout2], locktime=self.locktime, version=1, BIP69_sort=False) sig = tx.sign_txin(0,privkey) diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py index 83d90d2b3..dac6b64d7 100644 --- a/electrum/tests/test_wallet_vertical.py +++ b/electrum/tests/test_wallet_vertical.py @@ -729,7 +729,6 @@ class TestWalletSending(ElectrumTestCase): self.assertTrue(tx.is_complete()) self.assertTrue(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet1.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet1.is_mine(wallet1.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -749,7 +748,6 @@ class TestWalletSending(ElectrumTestCase): self.assertTrue(tx.is_complete()) self.assertFalse(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet2.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet2.is_mine(wallet2.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -809,7 +807,6 @@ class TestWalletSending(ElectrumTestCase): self.assertTrue(tx.is_complete()) self.assertFalse(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet1a.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet1a.is_mine(wallet1a.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -824,12 +821,15 @@ class TestWalletSending(ElectrumTestCase): # wallet2 -> wallet1 outputs = [PartialTxOutput.from_address_and_value(wallet1a.get_receiving_address(), 100000)] - tx = wallet2.mktx(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet2.mktx(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False, sign=False) + self.assertEqual( + "pkh(045f7ba332df2a7b4f5d13f246e307c9174cfa9b8b05f3b83410a3c23ef8958d610be285963d67c7bc1feb082f168fa9877c25999963ff8b56b242a852b23e25ed)", + tx.inputs()[0].script_descriptor.to_string_no_checksum()) + wallet2.sign_transaction(tx, password=None) self.assertTrue(tx.is_complete()) self.assertFalse(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet2.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet2.is_mine(wallet2.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -895,7 +895,13 @@ class TestWalletSending(ElectrumTestCase): # wallet1 -> wallet2 outputs = [PartialTxOutput.from_address_and_value(wallet2a.get_receiving_address(), 165000)] - tx = wallet1a.mktx(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + tx = wallet1a.mktx(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False, sign=False) + self.assertEqual((0, 2), tx.signature_count()) + self.assertEqual( + "wsh(sortedmulti(2,[b2e35a7d/1h]tpubD9aPYLPPYw8MxU3cD57LwpV5v7GomHxdv62MSbPcRkp47zwXx69ACUFsKrj8xzuzRrij9FWVhfvkvNqtqsr8ZtefkDsGZ9GLuHzoS6bXyk1/0/0,[53b77ddb/1h]tpubD8spLJysN7v7V1KHvkZ7AwjnXShKafopi7Vu3Ahs2S46FxBPTode8DgGxDo55k4pJvETGScZFwnM5f2Y31EUjteJdhxR73sjr9ieydgah2U/0/0,[43067d63/1h]tpubD8khd1g1tzFeKeaU59QV811hyvhwn9KDfy5sqFJ5m2wJLw6rUt4AZviqutRPXTUAK4SpU2we3y2WBP916Ma8Em4qFGcbYkFvXVfpGYV3oZR/0/0))", + tx.inputs()[0].script_descriptor.to_string_no_checksum()) + wallet1a.sign_transaction(tx, password=None) + self.assertEqual((1, 2), tx.signature_count()) txid = tx.txid() partial_tx = tx.serialize_as_bytes().hex() self.assertEqual("70736274ff01007e0100000001213e1012a461e056752fab5a6414a2fb63f950cd21a50ac5e2b82d339d6cbdd20000000000feffffff023075000000000000220020cc5e4cc05a76d0648cd0742768556317e9f8cc729aed077134287909035dba88888402000000000017a914187842cea9c15989a51ce7ca889a08b824bf874387000000000001012b400d0300000000002200203c43ac80d6e3015cf378bf6bac0c22456723d6050bef324ec641e7762440c63c0100eb01000000000101a41aae475d026c9255200082c7fad26dc47771275b0afba238dccda98a597bd20000000000fdffffff02400d0300000000002200203c43ac80d6e3015cf378bf6bac0c22456723d6050bef324ec641e7762440c63c9dcd410000000000160014824626055515f3ed1d2cfc9152d2e70685c71e8f02483045022100b9f39fad57d07ce1e18251424034f21f10f20e59931041b5167ae343ce973cf602200fefb727fa0ffd25b353f1bcdae2395898fe407b692c62f5885afbf52fa06f5701210301a28f68511ace43114b674371257bb599fd2c686c4b19544870b1799c954b40e9c1130022020223f815ab09f6bfc8519165c5232947ae89d9d43d678fb3486f3b28382a2371fa473044022055cb04fa71c4b5955724d7ac5da90436d75212e7847fc121cb588f54bcdffdc4022064eca1ad639b7c748101059dc69f2893abb3b396bcf9c13f670415076f93ddbf0101056952210223f815ab09f6bfc8519165c5232947ae89d9d43d678fb3486f3b28382a2371fa210273c529c2c9a99592f2066cebc2172a48991af2b471cb726b9df78c6497ce984e2102aa8fc578b445a1e4257be6b978fcece92980def98dce0e1eb89e7364635ae94153ae22060223f815ab09f6bfc8519165c5232947ae89d9d43d678fb3486f3b28382a2371fa10b2e35a7d01000080000000000000000022060273c529c2c9a99592f2066cebc2172a48991af2b471cb726b9df78c6497ce984e1053b77ddb010000800000000000000000220602aa8fc578b445a1e4257be6b978fcece92980def98dce0e1eb89e7364635ae9411043067d6301000080000000000000000000010169522102174696a58a8dcd6c6455bd25e0749e9a6fc7d84ee09e192ab37b0d0b18c2de1a2102c807a19ca6783261f8c198ffcc437622e7ecba8d6c5692f3a5e7f1e45af53fd52102eee40c7e24d89639182db32f5e9188613e4bc212da2ee9b4ccc85d9b82e1a98053ae220202174696a58a8dcd6c6455bd25e0749e9a6fc7d84ee09e192ab37b0d0b18c2de1a1053b77ddb010000800100000000000000220202c807a19ca6783261f8c198ffcc437622e7ecba8d6c5692f3a5e7f1e45af53fd51043067d63010000800100000000000000220202eee40c7e24d89639182db32f5e9188613e4bc212da2ee9b4ccc85d9b82e1a98010b2e35a7d0100008001000000000000000000", @@ -906,9 +912,9 @@ class TestWalletSending(ElectrumTestCase): wallet1b.sign_transaction(tx, password=None) self.assertTrue(tx.is_complete()) + self.assertEqual((2, 2), tx.signature_count()) self.assertTrue(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet1a.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet1a.is_mine(wallet1a.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -925,6 +931,10 @@ class TestWalletSending(ElectrumTestCase): # wallet2 -> wallet1 outputs = [PartialTxOutput.from_address_and_value(wallet1a.get_receiving_address(), 100000)] tx = wallet2a.mktx(outputs=outputs, password=None, fee=5000, tx_version=1, rbf=False) + self.assertEqual((1, 2), tx.signature_count()) + self.assertEqual( + "sh(wsh(sortedmulti(2,[d1dbcc21]tpubDDsv4RpsGViZeEVwivuj3aaKhFQSv1kYsz64mwRoHkqBfw8qBSYEmc8TtyVGotJb44V3pviGzefP9m9hidRg9dPPaDWL2yoRpMW3hdje3Rk/0/0,[17cea914]tpubDCZU2kACPGACYDvAXvZUXQ7cE7msFfCtpah5QCuaz8iarKMLTgR4c2u8RGKdFhbb3YJxzmktDd1rCtF58ksyVgFw28pchY55uwkDiXjY9hU/0/0)))", + tx.inputs()[0].script_descriptor.to_string_no_checksum()) txid = tx.txid() partial_tx = tx.serialize_as_bytes().hex() self.assertEqual("70736274ff01007e010000000149d077be0ee9d52776211e9b4fec1cc02bd53661a04e120a97db8b78d83c9c6e0100000000feffffff0260ea00000000000017a9143025051b6b5ccd4baf30dfe2de8aa84f0dd567ed87a086010000000000220020f7b6b30c3073ae2680a7e90c589bbfec5303331be68bbab843eed5d51ba012390000000000010120888402000000000017a914187842cea9c15989a51ce7ca889a08b824bf8743870100fd7c0101000000000101213e1012a461e056752fab5a6414a2fb63f950cd21a50ac5e2b82d339d6cbdd20000000000feffffff023075000000000000220020cc5e4cc05a76d0648cd0742768556317e9f8cc729aed077134287909035dba88888402000000000017a914187842cea9c15989a51ce7ca889a08b824bf8743870400473044022055cb04fa71c4b5955724d7ac5da90436d75212e7847fc121cb588f54bcdffdc4022064eca1ad639b7c748101059dc69f2893abb3b396bcf9c13f670415076f93ddbf01473044022009230e456724f2a4c10d886c836eeec599b21db0bf078aa8fc8c95868b8920ec02200dfda835a66acb5af50f0d95fcc4b76c6e8f4789a7184c182275b087d1efe556016952210223f815ab09f6bfc8519165c5232947ae89d9d43d678fb3486f3b28382a2371fa210273c529c2c9a99592f2066cebc2172a48991af2b471cb726b9df78c6497ce984e2102aa8fc578b445a1e4257be6b978fcece92980def98dce0e1eb89e7364635ae94153ae00000000220202119f899075a131d4d519d4cdcf5de5907dc2df3b93d54b53ded852211d2b6cb14730440220091ea67af7c1131f51f62fe9596dff0a60c8b45bfc5be675389e193912e8a71802201bf813bbf83933a35ecc46e2d5b0442bd8758fa82e0f8ed16392c10d51f7f7660101042200204311edae835c7a5aa712c8ca644180f13a3b2f3b420fa879b181474724d6163c010547522102119f899075a131d4d519d4cdcf5de5907dc2df3b93d54b53ded852211d2b6cb12102fdb0f6775d4b6619257c43343ba5e7807b0164f1eb3f00f2b594ab9e53ab812652ae220602119f899075a131d4d519d4cdcf5de5907dc2df3b93d54b53ded852211d2b6cb10cd1dbcc210000000000000000220602fdb0f6775d4b6619257c43343ba5e7807b0164f1eb3f00f2b594ab9e53ab81260c17cea9140000000000000000000100220020717ab7037b81797cb3e192a8a1b4d88083444bbfcd26934cadf3bcf890f14e05010147522102987c184fcd8ace2e2a314250e04a15a4b8c885fb4eb778ab82c45838bcbcbdde21034084c4a0493c248783e60d8415cd30b3ba2c3b7a79201e38b953adea2bc44f9952ae220202987c184fcd8ace2e2a314250e04a15a4b8c885fb4eb778ab82c45838bcbcbdde0c17cea91401000000000000002202034084c4a0493c248783e60d8415cd30b3ba2c3b7a79201e38b953adea2bc44f990cd1dbcc2101000000000000000000", @@ -935,9 +945,9 @@ class TestWalletSending(ElectrumTestCase): wallet2b.sign_transaction(tx, password=None) self.assertTrue(tx.is_complete()) + self.assertEqual((2, 2), tx.signature_count()) self.assertTrue(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet2a.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet2a.is_mine(wallet2a.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -987,7 +997,6 @@ class TestWalletSending(ElectrumTestCase): self.assertTrue(tx.is_complete()) self.assertFalse(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet1a.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet1a.is_mine(wallet1a.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -1007,7 +1016,6 @@ class TestWalletSending(ElectrumTestCase): self.assertTrue(tx.is_complete()) self.assertTrue(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) - self.assertEqual(wallet2.txin_type, tx.inputs()[0].script_type) tx_copy = tx_from_any(tx.serialize()) self.assertTrue(wallet2.is_mine(wallet2.adb.get_txin_address(tx_copy.inputs()[0]))) @@ -2615,6 +2623,9 @@ class TestWalletSending(ElectrumTestCase): tx.version = 2 tx.locktime = 2378363 self.assertEqual("04cf670cc809560ab6b1a362c119dbd59ea6a7621d00a4a05c0ef1839e65c035", tx.txid()) + self.assertEqual( + "wsh(sortedmulti(2,[9559fbd1/9999h]tpubD9MoDeHnEQnU5EMgt9mc4yKU6SURbfq2ooMToY5GH95B8Li1CEsuo9dBKXM2sdjuDGq4KCXLuigss3y22fZULzVrfVuZDxEN55Sp6CcU9DK/0/0,[015148ee]tpubDFF7YPCSGHZy55HkQj6HJkXCR8DWbKKXpTYBH38fSHf6VuoEzNmZQZdAoKEVy36S8zXkbGeV4XQU6vaRXGsQfgptFYPR4HSpAenqkY7J7Lg/0/0))", + tx.inputs()[0].script_descriptor.to_string_no_checksum()) self.assertEqual({}, tx.to_json()['xpubs']) self.assertEqual( {'022c4338968f87a09b0fefd0aaac36f1b983bab237565d521944c60fdc48275049': ('9559fbd1', "m/9999h/0/0"), @@ -2642,6 +2653,9 @@ class TestWalletSending(ElectrumTestCase): tx.version = 2 tx.locktime = 2378363 self.assertEqual("04cf670cc809560ab6b1a362c119dbd59ea6a7621d00a4a05c0ef1839e65c035", tx.txid()) + self.assertEqual( + "wsh(sortedmulti(2,[9559fbd1/9999h]tpubD9MoDeHnEQnU5EMgt9mc4yKU6SURbfq2ooMToY5GH95B8Li1CEsuo9dBKXM2sdjuDGq4KCXLuigss3y22fZULzVrfVuZDxEN55Sp6CcU9DK/0/0,[30cf1be5/48h/1h/0h/2h]tpubDFF7YPCSGHZy55HkQj6HJkXCR8DWbKKXpTYBH38fSHf6VuoEzNmZQZdAoKEVy36S8zXkbGeV4XQU6vaRXGsQfgptFYPR4HSpAenqkY7J7Lg/0/0))", + tx.inputs()[0].script_descriptor.to_string_no_checksum()) self.assertEqual({}, tx.to_json()['xpubs']) self.assertEqual( {'022c4338968f87a09b0fefd0aaac36f1b983bab237565d521944c60fdc48275049': ('9559fbd1', "m/9999h/0/0"), @@ -2726,6 +2740,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): tx.version = 1 self.assertFalse(tx.is_complete()) + self.assertEqual((0, 1), tx.signature_count()) self.assertFalse(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) partial_tx = tx.serialize_as_bytes().hex() @@ -2739,6 +2754,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # sign tx tx = wallet_offline.sign_transaction(tx_copy, password=None) self.assertTrue(tx.is_complete()) + self.assertEqual((1, 1), tx.signature_count()) self.assertFalse(tx.is_segwit()) self.assertEqual('01000000015608436ec7dc01c95ca1ca91519f2dc62b6613ac3d6304cb56462f6081059e3b020000008a47304402206bed3e02af8a38f6ba2fa3bf5908cb8c643aa62e78e8de6d9af2e19dec55fafc0220039cc1d81d4e5e0292bbc54ea92b8ec4ec016d4828eedc8975a66952cedf13a1014104e79eb77f2f3f989f5e9d090bc0af50afeb0d5bd6ec916f2022c5629ed022e84a87584ef647d69f073ea314a0f0c110ebe24ad64bc1922a10819ea264fc3f35f5fdffffff02a02526000000000016001423a3878d93d5acac68e7245a4433169d3d455087585d7200000000001976a914b6a6bbbc4cf9da58786a8acc58291e218d52130688acff121600', str(tx)) @@ -2871,6 +2887,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): tx.version = 1 self.assertFalse(tx.is_complete()) + self.assertEqual((0, 1), tx.signature_count()) self.assertTrue(tx.is_segwit()) self.assertEqual(1, len(tx.inputs())) @@ -2895,6 +2912,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # sign tx tx = wallet_offline.sign_transaction(tx_copy, password=None) self.assertTrue(tx.is_complete()) + self.assertEqual((1, 1), tx.signature_count()) self.assertTrue(tx.is_segwit()) self.assertEqual('ee76c0c6da87f0eb5ab4d1ae05d3942512dcd3c4c42518f9d3619e74400cfc1f', tx.txid()) self.assertEqual('484e350beaa722a744bb3e2aa38de005baa8526d86536d6143e5814355acf775', tx.wtxid()) @@ -3034,6 +3052,9 @@ class TestWalletOfflineSigning(ElectrumTestCase): # sign tx tx = wallet_offline.sign_transaction(tx_copy, password=None) + self.assertEqual( + "sh(wpkh(03845818239fe468a9e7c7ae1a3d3653a8333f89ff316a771a3acf6854b4d8c6db))", + tx.inputs()[0].script_descriptor.to_string_no_checksum()) self.assertTrue(tx.is_complete()) self.assertTrue(tx.is_segwit()) self.assertEqual('7642816d051aa3b333b6564bb6e44fe3a5885bfe7db9860dfbc9973a5c9a6562', tx.txid()) @@ -3112,6 +3133,9 @@ class TestWalletOfflineSigning(ElectrumTestCase): # sign tx tx = wallet_offline.sign_transaction(tx_copy, password=None) + self.assertEqual( + "pkh([233d2ae4]tpubDDMN69wQjDZxaJz9afZQGa48hZS7X5oSegF2hg67yddNvqfpuTN9DqvDEp7YyVf7AzXnqBqHdLhzTAStHvsoMDDb8WoJQzNrcHgDJHVYgQF/0/1)", + tx.inputs()[0].script_descriptor.to_string_no_checksum()) self.assertTrue(tx.is_complete()) self.assertFalse(tx.is_segwit()) self.assertEqual('e56da664631b8c666c6df38ec80c954c4ac3c4f56f040faf0070e4681e937fc4', tx.txid()) @@ -3249,6 +3273,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # sign tx - first tx = wallet_offline1.sign_transaction(tx_copy, password=None) self.assertFalse(tx.is_complete()) + self.assertEqual((1, 2), tx.signature_count()) partial_tx = tx.serialize_as_bytes().hex() self.assertEqual("70736274ff010073010000000132352f6459e847e65e56aa05cbd7b9ee67be90b40d8f92f6f11e9bfaa11399c50000000000fdffffff02a02526000000000017a9141567b2578f300fa618ef0033611fd67087aff6d187585d72000000000017a91480c2353f6a7bc3c71e99e062655b19adb3dd2e4887bf391400000100f7010000000001016207d958dc46508d706e4cd7d3bc46c5c2b02160e2578e5fad2efafc3927050301000000171600147a4fc8cdc1c2cf7abbcd88ef6d880e59269797acfdffffff02809698000000000017a91480c2353f6a7bc3c71e99e062655b19adb3dd2e48870d0916020000000017a914703f83ef20f3a52d908475dcad00c5144164d5a2870247304402203b1a5cb48cadeee14fa6c7bbf2bc581ca63104762ec5c37c703df778884cc5b702203233fa53a2a0bfbd85617c636e415da72214e359282cce409019319d031766c50121021112c01a48cc7ea13cba70493c6bffebb3e805df10ff4611d2bf559d26e25c04bf391400220202afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f284730440220451f77cb18224adcb4981492d9be2c3fa7537f94f4b29eb405992dbdd5df04aa022071e6759d40dde810caa01ca7f16bad3cb742d64428c419c8fb4bad6f1c3f718101010469522102afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f2821030b482838721a38d94847699fed8818b5c5f56500ef72f13489e365b65e5749cf2103e5db7969ae2f2576e6a061bf3bb2db16571e77ffb41e0b27170734359235cbce53ae220602afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f280c0036e9ac00000000000000002206030b482838721a38d94847699fed8818b5c5f56500ef72f13489e365b65e5749cf0c48adc7a00000000000000000220603e5db7969ae2f2576e6a061bf3bb2db16571e77ffb41e0b27170734359235cbce0cdb69242700000000000000000000010069522102afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f2821030b482838721a38d94847699fed8818b5c5f56500ef72f13489e365b65e5749cf2103e5db7969ae2f2576e6a061bf3bb2db16571e77ffb41e0b27170734359235cbce53ae220202afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f280c0036e9ac00000000000000002202030b482838721a38d94847699fed8818b5c5f56500ef72f13489e365b65e5749cf0c48adc7a00000000000000000220203e5db7969ae2f2576e6a061bf3bb2db16571e77ffb41e0b27170734359235cbce0cdb692427000000000000000000", partial_tx) @@ -3257,6 +3282,7 @@ class TestWalletOfflineSigning(ElectrumTestCase): # sign tx - second tx = wallet_offline2.sign_transaction(tx, password=None) self.assertTrue(tx.is_complete()) + self.assertEqual((2, 2), tx.signature_count()) tx = tx_from_any(tx.serialize()) self.assertEqual('010000000132352f6459e847e65e56aa05cbd7b9ee67be90b40d8f92f6f11e9bfaa11399c500000000fc004730440220451f77cb18224adcb4981492d9be2c3fa7537f94f4b29eb405992dbdd5df04aa022071e6759d40dde810caa01ca7f16bad3cb742d64428c419c8fb4bad6f1c3f718101473044022052980154bdf2e43d6bd8775316cc220ef5ae13b4b9574a7a904a691ee3c5efd3022069b3eddf904cc645bd8fc8b2aaa7aaf7eb5bbfb7bbbd3b6e6cd89b37dfb2856c014c69522102afb4af9a91264e1c6dce3ebe5312801723270ac0ba8134b7b49129328fcb0f2821030b482838721a38d94847699fed8818b5c5f56500ef72f13489e365b65e5749cf2103e5db7969ae2f2576e6a061bf3bb2db16571e77ffb41e0b27170734359235cbce53aefdffffff02a02526000000000017a9141567b2578f300fa618ef0033611fd67087aff6d187585d72000000000017a91480c2353f6a7bc3c71e99e062655b19adb3dd2e4887bf391400', diff --git a/electrum/transaction.py b/electrum/transaction.py index 92c18c321..3e1ff536b 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -47,11 +47,12 @@ from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160, hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr, var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN, int_to_hex, push_script, b58_address_to_hash160, - opcodes, add_number_to_script, base_decode, is_segwit_script_type, + opcodes, add_number_to_script, base_decode, base_encode, construct_witness, construct_script) from .crypto import sha256d from .logging import get_logger from .util import ShortID +from .descriptor import Descriptor, MissingSolutionPiece, create_dummy_descriptor_from_address if TYPE_CHECKING: from .wallet import Abstract_Wallet @@ -732,33 +733,6 @@ class Transaction: if vds.can_read_more(): raise SerializationError('extra junk at the end') - @classmethod - def get_siglist(self, txin: 'PartialTxInput', *, estimate_size=False): - if txin.is_coinbase_input(): - return [], [] - - if estimate_size: - try: - pubkey_size = len(txin.pubkeys[0]) - except IndexError: - pubkey_size = 33 # guess it is compressed - num_pubkeys = max(1, len(txin.pubkeys)) - pk_list = ["00" * pubkey_size] * num_pubkeys - num_sig = max(1, txin.num_sig) - # we guess that signatures will be 72 bytes long - # note: DER-encoded ECDSA signatures are 71 or 72 bytes in practice - # See https://bitcoin.stackexchange.com/questions/77191/what-is-the-maximum-size-of-a-der-encoded-ecdsa-signature - # We assume low S (as that is a bitcoin standardness rule). - # We do not assume low R (even though the sigs we create conform), as external sigs, - # e.g. from a hw signer cannot be expected to have a low R. - sig_list = ["00" * 72] * num_sig - else: - pk_list = [pubkey.hex() for pubkey in txin.pubkeys] - sig_list = [txin.part_sigs.get(pubkey, b'').hex() for pubkey in txin.pubkeys] - if txin.is_complete(): - sig_list = [sig for sig in sig_list if sig] - return pk_list, sig_list - @classmethod def serialize_witness(cls, txin: TxInput, *, estimate_size=False) -> str: if txin.witness is not None: @@ -767,46 +741,21 @@ class Transaction: return '' assert isinstance(txin, PartialTxInput) - _type = txin.script_type if not txin.is_segwit(): return construct_witness([]) if estimate_size and txin.witness_sizehint is not None: return '00' * txin.witness_sizehint - if _type in ('address', 'unknown') and estimate_size: - _type = cls.guess_txintype_from_address(txin.address) - pubkeys, sig_list = cls.get_siglist(txin, estimate_size=estimate_size) - if _type in ['p2wpkh', 'p2wpkh-p2sh']: - return construct_witness([sig_list[0], pubkeys[0]]) - elif _type in ['p2wsh', 'p2wsh-p2sh']: - witness_script = multisig_script(pubkeys, txin.num_sig) - return construct_witness([0, *sig_list, witness_script]) - elif _type in ['p2pk', 'p2pkh', 'p2sh']: - return construct_witness([]) - raise UnknownTxinType(f'cannot construct witness for txin_type: {_type}') - @classmethod - def guess_txintype_from_address(cls, addr: Optional[str]) -> str: - # It's not possible to tell the script type in general - # just from an address. - # - "1" addresses are of course p2pkh - # - "3" addresses are p2sh but we don't know the redeem script.. - # - "bc1" addresses (if they are 42-long) are p2wpkh - # - "bc1" addresses that are 62-long are p2wsh but we don't know the script.. - # If we don't know the script, we _guess_ it is pubkeyhash. - # As this method is used e.g. for tx size estimation, - # the estimation will not be precise. - if addr is None: - return 'p2wpkh' - witver, witprog = segwit_addr.decode_segwit_address(constants.net.SEGWIT_HRP, addr) - if witprog is not None: - return 'p2wpkh' - addrtype, hash_160_ = b58_address_to_hash160(addr) - if addrtype == constants.net.ADDRTYPE_P2PKH: - return 'p2pkh' - elif addrtype == constants.net.ADDRTYPE_P2SH: - return 'p2wpkh-p2sh' - raise Exception(f'unrecognized address: {repr(addr)}') + dummy_desc = None + if estimate_size: + dummy_desc = create_dummy_descriptor_from_address(txin.address) + if desc := (txin.script_descriptor or dummy_desc): + sol = desc.satisfy(allow_dummy=estimate_size, sigdata=txin.part_sigs) + if sol.witness is not None: + return sol.witness.hex() + return construct_witness([]) + raise UnknownTxinType("cannot construct witness") @classmethod def input_script(self, txin: TxInput, *, estimate_size=False) -> str: @@ -821,31 +770,19 @@ class Transaction: if txin.is_native_segwit(): return '' - _type = txin.script_type - pubkeys, sig_list = self.get_siglist(txin, estimate_size=estimate_size) - if _type in ('address', 'unknown') and estimate_size: - _type = self.guess_txintype_from_address(txin.address) - if _type == 'p2pk': - return construct_script([sig_list[0]]) - elif _type == 'p2sh': - # put op_0 before script - redeem_script = multisig_script(pubkeys, txin.num_sig) - return construct_script([0, *sig_list, redeem_script]) - elif _type == 'p2pkh': - return construct_script([sig_list[0], pubkeys[0]]) - elif _type in ['p2wpkh', 'p2wsh']: - return '' - elif _type == 'p2wpkh-p2sh': - redeem_script = bitcoin.p2wpkh_nested_script(pubkeys[0]) - return construct_script([redeem_script]) - elif _type == 'p2wsh-p2sh': - if estimate_size: - witness_script = '' - else: - witness_script = self.get_preimage_script(txin) - redeem_script = bitcoin.p2wsh_nested_script(witness_script) - return construct_script([redeem_script]) - raise UnknownTxinType(f'cannot construct scriptSig for txin_type: {_type}') + dummy_desc = None + if estimate_size: + dummy_desc = create_dummy_descriptor_from_address(txin.address) + if desc := (txin.script_descriptor or dummy_desc): + if desc.is_segwit(): + if redeem_script := desc.expand().redeem_script: + return construct_script([redeem_script]) + return "" + sol = desc.satisfy(allow_dummy=estimate_size, sigdata=txin.part_sigs) + if sol.script_sig is not None: + return sol.script_sig.hex() + return "" + raise UnknownTxinType("cannot construct scriptSig") @classmethod def get_preimage_script(cls, txin: 'PartialTxInput') -> str: @@ -858,18 +795,12 @@ class Transaction: raise Exception('OP_CODESEPARATOR black magic is not supported') return txin.redeem_script.hex() - pubkeys = [pk.hex() for pk in txin.pubkeys] - if txin.script_type in ['p2sh', 'p2wsh', 'p2wsh-p2sh']: - return multisig_script(pubkeys, txin.num_sig) - elif txin.script_type in ['p2pkh', 'p2wpkh', 'p2wpkh-p2sh']: - pubkey = pubkeys[0] - pkh = hash_160(bfh(pubkey)).hex() - return bitcoin.pubkeyhash_to_p2pkh_script(pkh) - elif txin.script_type == 'p2pk': - pubkey = pubkeys[0] - return bitcoin.public_key_to_p2pk_script(pubkey) - else: - raise UnknownTxinType(f'cannot construct preimage_script for txin_type: {txin.script_type}') + if desc := txin.script_descriptor: + sc = desc.expand() + if script := sc.scriptcode_for_sighash: + return script.hex() + raise Exception(f"don't know scriptcode for descriptor: {desc.to_string()}") + raise UnknownTxinType(f'cannot construct preimage_script') def _calc_bip143_shared_txdigest_fields(self) -> BIP143SharedTxDigestFields: inputs = self.inputs() @@ -1255,9 +1186,7 @@ class PartialTxInput(TxInput, PSBTSection): self.witness_script = None # type: Optional[bytes] self._unknown = {} # type: Dict[bytes, bytes] - self.script_type = 'unknown' - self.num_sig = 0 # type: int # num req sigs for multisig - self.pubkeys = [] # type: List[bytes] # note: order matters + self._script_descriptor = None # type: Optional[Descriptor] self._trusted_value_sats = None # type: Optional[int] self._trusted_address = None # type: Optional[str] self._is_p2sh_segwit = None # type: Optional[bool] # None means unknown @@ -1290,12 +1219,32 @@ class PartialTxInput(TxInput, PSBTSection): self._witness_utxo = value self.validate_data() + @property + def pubkeys(self) -> Set[bytes]: + if desc := self.script_descriptor: + return desc.get_all_pubkeys() + return set() + + @property + def script_descriptor(self): + return self._script_descriptor + + @script_descriptor.setter + def script_descriptor(self, desc: Optional[Descriptor]): + self._script_descriptor = desc + if desc: + if self.redeem_script is None: + self.redeem_script = desc.expand().redeem_script + if self.witness_script is None: + self.witness_script = desc.expand().witness_script + def to_json(self): d = super().to_json() d.update({ 'height': self.block_height, 'value_sats': self.value_sats(), 'address': self.address, + 'desc': self.script_descriptor.to_string() if self.script_descriptor else None, 'utxo': str(self.utxo) if self.utxo else None, 'witness_utxo': self.witness_utxo.serialize_to_network().hex() if self.witness_utxo else None, 'sighash': self.sighash, @@ -1468,22 +1417,6 @@ class PartialTxInput(TxInput, PSBTSection): return self.witness_utxo.scriptpubkey return None - def set_script_type(self) -> None: - if self.scriptpubkey is None: - return - type = get_script_type_from_output_script(self.scriptpubkey) - inner_type = None - if type is not None: - if type == 'p2sh': - inner_type = get_script_type_from_output_script(self.redeem_script) - elif type == 'p2wsh': - inner_type = get_script_type_from_output_script(self.witness_script) - if inner_type is not None: - type = inner_type + '-' + type - if type in ('p2pkh', 'p2wpkh-p2sh', 'p2wpkh'): - self.script_type = type - return - def is_complete(self) -> bool: if self.script_sig is not None and self.witness is not None: return True @@ -1491,19 +1424,20 @@ class PartialTxInput(TxInput, PSBTSection): return True if self.script_sig is not None and not self.is_segwit(): return True - signatures = list(self.part_sigs.values()) - s = len(signatures) - # note: The 'script_type' field is currently only set by the wallet, - # for its own addresses. This means we can only finalize inputs - # that are related to the wallet. - # The 'fix' would be adding extra logic that matches on templates, - # and figures out the script_type from available fields. - if self.script_type in ('p2pk', 'p2pkh', 'p2wpkh', 'p2wpkh-p2sh'): - return s >= 1 - if self.script_type in ('p2sh', 'p2wsh', 'p2wsh-p2sh'): - return s >= self.num_sig + if desc := self.script_descriptor: + try: + desc.satisfy(allow_dummy=False, sigdata=self.part_sigs) + except MissingSolutionPiece: + pass + else: + return True return False + def get_satisfaction_progress(self) -> Tuple[int, int]: + if desc := self.script_descriptor: + return desc.get_satisfaction_progress(sigdata=self.part_sigs) + return 0, 0 + def finalize(self) -> None: def clear_fields_when_finalized(): # BIP-174: "All other data except the UTXO and unknown fields in the @@ -1594,10 +1528,12 @@ class PartialTxInput(TxInput, PSBTSection): return False if self.witness_script: return True - _type = self.script_type - if _type == 'address' and guess_for_address: - _type = Transaction.guess_txintype_from_address(self.address) - return is_segwit_script_type(_type) + if desc := self.script_descriptor: + return desc.is_segwit() + if guess_for_address: + dummy_desc = create_dummy_descriptor_from_address(self.address) + return dummy_desc.is_segwit() + return False # can be false-negative def already_has_some_signatures(self) -> bool: """Returns whether progress has been made towards completing this input.""" @@ -1614,15 +1550,33 @@ class PartialTxOutput(TxOutput, PSBTSection): self.bip32_paths = {} # type: Dict[bytes, Tuple[bytes, Sequence[int]]] # pubkey -> (xpub_fingerprint, path) self._unknown = {} # type: Dict[bytes, bytes] - self.script_type = 'unknown' - self.num_sig = 0 # num req sigs for multisig - self.pubkeys = [] # type: List[bytes] # note: order matters + self._script_descriptor = None # type: Optional[Descriptor] self.is_mine = False # type: bool # whether the wallet considers the output to be ismine self.is_change = False # type: bool # whether the wallet considers the output to be change + @property + def pubkeys(self) -> Set[bytes]: + if desc := self.script_descriptor: + return desc.get_all_pubkeys() + return set() + + @property + def script_descriptor(self): + return self._script_descriptor + + @script_descriptor.setter + def script_descriptor(self, desc: Optional[Descriptor]): + self._script_descriptor = desc + if desc: + if self.redeem_script is None: + self.redeem_script = desc.expand().redeem_script + if self.witness_script is None: + self.witness_script = desc.expand().witness_script + def to_json(self): d = super().to_json() d.update({ + 'desc': self.script_descriptor.to_string() if self.script_descriptor else None, 'redeem_script': self.redeem_script.hex() if self.redeem_script else None, 'witness_script': self.witness_script.hex() if self.witness_script else None, 'bip32_paths': {pubkey.hex(): (xfp.hex(), bip32.convert_bip32_intpath_to_strpath(path)) @@ -2013,15 +1967,12 @@ class PartialTransaction(Transaction): return all([txin.is_complete() for txin in self.inputs()]) def signature_count(self) -> Tuple[int, int]: - s = 0 # "num Sigs we have" - r = 0 # "Required" + nhave, nreq = 0, 0 for txin in self.inputs(): - if txin.is_coinbase_input(): - continue - signatures = list(txin.part_sigs.values()) - s += len(signatures) - r += txin.num_sig - return s, r + a, b = txin.get_satisfaction_progress() + nhave += a + nreq += b + return nhave, nreq def serialize(self) -> str: """Returns PSBT as base64 text, or raw hex of network tx (if complete).""" @@ -2170,14 +2121,6 @@ class PartialTransaction(Transaction): assert not self.is_complete() self.invalidate_ser_cache() - def update_txin_script_type(self): - """Determine the script_type of each input by analyzing the scripts. - It updates all tx-Inputs, NOT only the wallet owned, if the - scriptpubkey is present. - """ - for txin in self.inputs(): - if txin.script_type in ('unknown', 'address'): - txin.set_script_type() def pack_bip32_root_fingerprint_and_int_path(xfp: bytes, path: Sequence[int]) -> bytes: if len(xfp) != 4: diff --git a/electrum/wallet.py b/electrum/wallet.py index cd88432e5..0985fa94a 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -84,6 +84,8 @@ from .lnworker import LNWallet from .paymentrequest import PaymentRequest from .util import read_json_file, write_json_file, UserFacingException, FileImportFailed from .util import EventListener, event_listener +from . import descriptor +from .descriptor import Descriptor if TYPE_CHECKING: from .network import Network @@ -100,16 +102,15 @@ TX_STATUS = [ ] -async def _append_utxos_to_inputs(*, inputs: List[PartialTxInput], network: 'Network', - pubkey: str, txin_type: str, imax: int) -> None: - if txin_type in ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh'): - address = bitcoin.pubkey_to_address(txin_type, pubkey) - scripthash = bitcoin.address_to_scripthash(address) - elif txin_type == 'p2pk': - script = bitcoin.public_key_to_p2pk_script(pubkey) - scripthash = bitcoin.script_to_scripthash(script) - else: - raise Exception(f'unexpected txin_type to sweep: {txin_type}') +async def _append_utxos_to_inputs( + *, + inputs: List[PartialTxInput], + network: 'Network', + script_descriptor: 'descriptor.Descriptor', + imax: int, +) -> None: + script = script_descriptor.expand().output_script.hex() + scripthash = bitcoin.script_to_scripthash(script) async def append_single_utxo(item): prev_tx_raw = await network.get_transaction(item['tx_hash']) @@ -122,11 +123,7 @@ async def _append_utxos_to_inputs(*, inputs: List[PartialTxInput], network: 'Net txin = PartialTxInput(prevout=prevout) txin.utxo = prev_tx txin.block_height = int(item['height']) - txin.script_type = txin_type - txin.pubkeys = [bfh(pubkey)] - txin.num_sig = 1 - if txin_type == 'p2wpkh-p2sh': - txin.redeem_script = bfh(bitcoin.p2wpkh_nested_script(pubkey)) + txin.script_descriptor = script_descriptor inputs.append(txin) u = await network.listunspent_for_scripthash(scripthash) @@ -141,11 +138,11 @@ async def sweep_preparations(privkeys, network: 'Network', imax=100): async def find_utxos_for_privkey(txin_type, privkey, compressed): pubkey = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed) + desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=txin_type) await _append_utxos_to_inputs( inputs=inputs, network=network, - pubkey=pubkey, - txin_type=txin_type, + script_descriptor=desc, imax=imax) keypairs[pubkey] = privkey, compressed @@ -684,13 +681,19 @@ class Abstract_Wallet(ABC, Logger, EventListener): """ pass - @abstractmethod def get_redeem_script(self, address: str) -> Optional[str]: - pass + desc = self.get_script_descriptor_for_address(address) + if desc is None: return None + redeem_script = desc.expand().redeem_script + if redeem_script: + return redeem_script.hex() - @abstractmethod def get_witness_script(self, address: str) -> Optional[str]: - pass + desc = self.get_script_descriptor_for_address(address) + if desc is None: return None + witness_script = desc.expand().witness_script + if witness_script: + return witness_script.hex() @abstractmethod def get_txin_type(self, address: str) -> str: @@ -2138,10 +2141,6 @@ class Abstract_Wallet(ABC, Logger, EventListener): tx_new.add_info_from_wallet(self) return tx_new - @abstractmethod - def _add_input_sig_info(self, txin: PartialTxInput, address: str, *, only_der_suffix: bool) -> None: - pass - def _add_txinout_derivation_info(self, txinout: Union[PartialTxInput, PartialTxOutput], address: str, *, only_der_suffix: bool) -> None: pass # implemented by subclasses @@ -2194,24 +2193,44 @@ class Abstract_Wallet(ABC, Logger, EventListener): if self.lnworker: self.lnworker.swap_manager.add_txin_info(txin) return - # set script_type first, as later checks might rely on it: - txin.script_type = self.get_txin_type(address) - txin.num_sig = self.m if isinstance(self, Multisig_Wallet) else 1 - if txin.redeem_script is None: - try: - redeem_script_hex = self.get_redeem_script(address) - txin.redeem_script = bfh(redeem_script_hex) if redeem_script_hex else None - except UnknownTxinType: - pass - if txin.witness_script is None: - try: - witness_script_hex = self.get_witness_script(address) - txin.witness_script = bfh(witness_script_hex) if witness_script_hex else None - except UnknownTxinType: - pass - self._add_input_sig_info(txin, address, only_der_suffix=only_der_suffix) + txin.script_descriptor = self.get_script_descriptor_for_address(address) + self._add_txinout_derivation_info(txin, address, only_der_suffix=only_der_suffix) txin.block_height = self.adb.get_tx_height(txin.prevout.txid.hex()).height + def get_script_descriptor_for_address(self, address: str) -> Optional[Descriptor]: + if not self.is_mine(address): + return None + script_type = self.get_txin_type(address) + if script_type in ('address', 'unknown'): + return None + addr_index = self.get_address_index(address) + if addr_index is None: + return None + pubkeys = [ks.get_pubkey_provider(addr_index) for ks in self.get_keystores()] + if not pubkeys: + return None + if script_type == 'p2pk': + return descriptor.PKDescriptor(pubkey=pubkeys[0]) + elif script_type == 'p2pkh': + return descriptor.PKHDescriptor(pubkey=pubkeys[0]) + elif script_type == 'p2wpkh': + return descriptor.WPKHDescriptor(pubkey=pubkeys[0]) + elif script_type == 'p2wpkh-p2sh': + wpkh = descriptor.WPKHDescriptor(pubkey=pubkeys[0]) + return descriptor.SHDescriptor(subdescriptor=wpkh) + elif script_type == 'p2sh': + multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True) + return descriptor.SHDescriptor(subdescriptor=multi) + elif script_type == 'p2wsh': + multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True) + return descriptor.WSHDescriptor(subdescriptor=multi) + elif script_type == 'p2wsh-p2sh': + multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True) + wsh = descriptor.WSHDescriptor(subdescriptor=multi) + return descriptor.SHDescriptor(subdescriptor=wsh) + else: + raise NotImplementedError(f"unexpected {script_type=}") + def can_sign(self, tx: Transaction) -> bool: if not isinstance(tx, PartialTransaction): return False @@ -2263,24 +2282,10 @@ class Abstract_Wallet(ABC, Logger, EventListener): is_mine = self._learn_derivation_path_for_address_from_txinout(txout, address) if not is_mine: return - txout.script_type = self.get_txin_type(address) + txout.script_descriptor = self.get_script_descriptor_for_address(address) txout.is_mine = True txout.is_change = self.is_change(address) - if isinstance(self, Multisig_Wallet): - txout.num_sig = self.m self._add_txinout_derivation_info(txout, address, only_der_suffix=only_der_suffix) - if txout.redeem_script is None: - try: - redeem_script_hex = self.get_redeem_script(address) - txout.redeem_script = bfh(redeem_script_hex) if redeem_script_hex else None - except UnknownTxinType: - pass - if txout.witness_script is None: - try: - witness_script_hex = self.get_witness_script(address) - txout.witness_script = bfh(witness_script_hex) if witness_script_hex else None - except UnknownTxinType: - pass def sign_transaction(self, tx: Transaction, password) -> Optional[PartialTransaction]: if self.is_watching_only(): @@ -2942,20 +2947,6 @@ class Simple_Wallet(Abstract_Wallet): def get_public_keys(self, address: str) -> Sequence[str]: return [self.get_public_key(address)] - def get_redeem_script(self, address: str) -> Optional[str]: - txin_type = self.get_txin_type(address) - if txin_type in ('p2pkh', 'p2wpkh', 'p2pk'): - return None - if txin_type == 'p2wpkh-p2sh': - pubkey = self.get_public_key(address) - return bitcoin.p2wpkh_nested_script(pubkey) - if txin_type == 'address': - return None - raise UnknownTxinType(f'unexpected txin_type {txin_type}') - - def get_witness_script(self, address: str) -> Optional[str]: - return None - class Imported_Wallet(Simple_Wallet): # wallet made of imported addresses @@ -3163,20 +3154,6 @@ class Imported_Wallet(Simple_Wallet): if addr != bitcoin.pubkey_to_address(txin_type, pubkey): raise InternalAddressCorruption() - def _add_input_sig_info(self, txin, address, *, only_der_suffix): - if not self.is_mine(address): - return - if txin.script_type in ('unknown', 'address'): - return - elif txin.script_type in ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh'): - pubkey = self.get_public_key(address) - if not pubkey: - return - txin.pubkeys = [bfh(pubkey)] - else: - raise Exception(f'Unexpected script type: {txin.script_type}. ' - f'Imported wallets are not implemented to handle this.') - def pubkeys_to_address(self, pubkeys): pubkey = pubkeys[0] # FIXME This is slow. @@ -3304,14 +3281,10 @@ class Deterministic_Wallet(Abstract_Wallet): return {k.derive_pubkey(*der_suffix): (k, der_suffix) for k in self.get_keystores()} - def _add_input_sig_info(self, txin, address, *, only_der_suffix): - self._add_txinout_derivation_info(txin, address, only_der_suffix=only_der_suffix) - def _add_txinout_derivation_info(self, txinout, address, *, only_der_suffix): if not self.is_mine(address): return pubkey_deriv_info = self.get_public_keys_with_deriv_info(address) - txinout.pubkeys = sorted([pk for pk in list(pubkey_deriv_info)]) for pubkey in pubkey_deriv_info: ks, der_suffix = pubkey_deriv_info[pubkey] fp_bytes, der_full = ks.get_fp_and_derivation_to_be_used_in_partial_tx(der_suffix, @@ -3423,7 +3396,7 @@ class Simple_Deterministic_Wallet(Simple_Wallet, Deterministic_Wallet): return pubkeys[0] def load_keystore(self): - self.keystore = load_keystore(self.db, 'keystore') + self.keystore = load_keystore(self.db, 'keystore') # type: KeyStoreWithMPK try: xtype = bip32.xpub_type(self.keystore.xpub) except: @@ -3467,28 +3440,6 @@ class Multisig_Wallet(Deterministic_Wallet): def pubkeys_to_scriptcode(self, pubkeys: Sequence[str]) -> str: return transaction.multisig_script(sorted(pubkeys), self.m) - def get_redeem_script(self, address): - txin_type = self.get_txin_type(address) - pubkeys = self.get_public_keys(address) - scriptcode = self.pubkeys_to_scriptcode(pubkeys) - if txin_type == 'p2sh': - return scriptcode - elif txin_type == 'p2wsh-p2sh': - return bitcoin.p2wsh_nested_script(scriptcode) - elif txin_type == 'p2wsh': - return None - raise UnknownTxinType(f'unexpected txin_type {txin_type}') - - def get_witness_script(self, address): - txin_type = self.get_txin_type(address) - pubkeys = self.get_public_keys(address) - scriptcode = self.pubkeys_to_scriptcode(pubkeys) - if txin_type == 'p2sh': - return None - elif txin_type in ('p2wsh-p2sh', 'p2wsh'): - return scriptcode - raise UnknownTxinType(f'unexpected txin_type {txin_type}') - def derive_pubkeys(self, c, i): return [k.derive_pubkey(c, i).hex() for k in self.get_keystores()]