Repository: bitcoin-core/HWI
Pull request #412 (2020-12-10 18:47:37)

Reimplement descriptors in a way similar to Bitcoin Core
Reimplements our descriptor objects and handling in a way that is similar to what Bitcoin Core does. This should make it easier to expand to future descriptor types and also remove some weird and bad hacks that we had for multisig descriptors.

Based on #376


Patch:
diff --git a/.travis.yml b/.travis.yml
index 432b749..3a63c9e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -70,7 +70,7 @@ jobs:
                 - pip install mypy
                 - pip install poetry
                 - poetry install
-            script: mypy --implicit-reexport --strict hwilib/base58.py hwilib/errors.py hwilib/serializations.py hwilib/hwwclient.py hwilib/devices/bitbox02.py hwilib/key.py
+            script: mypy --implicit-reexport --strict hwilib/base58.py hwilib/errors.py hwilib/serializations.py hwilib/hwwclient.py hwilib/devices/bitbox02.py hwilib/key.py hwilib/descriptor.py
         -   name: Run non-device tests only
             stage: test
             install:
diff --git a/hwilib/commands.py b/hwilib/commands.py
index 8ffcd30..54b4115 100644
--- a/hwilib/commands.py
+++ b/hwilib/commands.py
@@ -2,20 +2,41 @@
 
 # Hardware wallet interaction script
 
+import binascii
 import importlib
 import platform
 
 from .serializations import PSBT
 from .base58 import xpub_to_pub_hex
+from .key import (
+    H_,
+    HARDENED_FLAG,
+    is_hardened,
+    KeyOriginInfo,
+    parse_path,
+)
 from .errors import (
     UnknownDeviceError,
     UnavailableActionError,
     BAD_ARGUMENT,
     NOT_IMPLEMENTED,
 )
-from .descriptor import Descriptor
+from .descriptor import (
+    Descriptor,
+    parse_descriptor,
+    MultisigDescriptor,
+    PKHDescriptor,
+    PubkeyProvider,
+    SHDescriptor,
+    WPKHDescriptor,
+    WSHDescriptor,
+)
 from .devices import __all__ as all_devs
+
 from enum import Enum
+from itertools import count
+
+py_enumerate = enumerate
 
 class AddressType(Enum):
     PKH = 1
@@ -106,7 +127,7 @@ def getkeypool_inner(client, path, start, end, internal=False, keypool=True, acc
 
     this_import = {}
 
-    this_import['desc'] = desc.serialize()
+    this_import['desc'] = desc.to_string()
     this_import['range'] = [start, end]
     this_import['timestamp'] = 'now'
     this_import['internal'] = internal
@@ -121,53 +142,65 @@ def getdescriptor(client, master_fpr, testnet=False, path=None, internal=False,
     is_wpkh = addr_type is AddressType.WPKH
     is_sh_wpkh = addr_type is AddressType.SH_WPKH
 
+    parsed_path = []
     if not path:
-        # Master key:
-        path = "m/"
-
         # Purpose
         if is_wpkh:
-            path += "84'/"
+            parsed_path.append(H_(84))
         elif is_sh_wpkh:
-            path += "49'/"
+            parsed_path.append(H_(49))
         else:
             assert addr_type == AddressType.PKH
-            path += "44'/"
+            parsed_path.append(H_(44))
 
         # Coin type
         if testnet:
-            path += "1'/"
+            parsed_path.append(H_(1))
         else:
-            path += "0'/"
+            parsed_path.append(H_(0))
 
         # Account
-        path += str(account) + '\'/'
+        parsed_path.append(H_(account))
 
         # Receive or change
         if internal:
-            path += "1/*"
+            parsed_path.append(1)
         else:
-            path += "0/*"
+            parsed_path.append(0)
     else:
         if path[0] != "m":
             return {'error': 'Path must start with m/', 'code': BAD_ARGUMENT}
         if path[-1] != "*":
             return {'error': 'Path must end with /*', 'code': BAD_ARGUMENT}
+        parsed_path = parse_path(path[:-2])
 
     # Find the last hardened derivation:
-    path = path.replace('\'', 'h')
-    path_suffix = ''
-    for component in path.split("/")[::-1]:
-        if component[-1] == 'h' or component[-1] == 'm':
+    for i, p in zip(count(len(parsed_path) - 1, -1), reversed(parsed_path)):
+        if is_hardened(p):
             break
-        path_suffix = '/' + component + path_suffix
-    path_base = path.rsplit(path_suffix)[0]
+    i += 1
+
+    origin = KeyOriginInfo(binascii.unhexlify(master_fpr), parsed_path[:i])
+    path_base = origin.get_derivation_path()
+
+    path_suffix = ""
+    for p in parsed_path[i:]:
+        hardened = is_hardened(p)
+        p &= ~HARDENED_FLAG
+        path_suffix += "/{}{}".format(p, "h" if hardened else "")
+    path_suffix += "/*"
 
     # Get the key at the base
     if client.xpub_cache.get(path_base) is None:
         client.xpub_cache[path_base] = client.get_pubkey_at_path(path_base)['xpub']
 
-    return Descriptor(master_fpr, path_base.replace('m', ''), client.xpub_cache.get(path_base), path_suffix, client.is_testnet, is_sh_wpkh, is_wpkh)
+    pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base), path_suffix)
+    if is_wpkh:
+        return WPKHDescriptor(pubkey)
+    elif is_sh_wpkh:
+        return SHDescriptor(WPKHDescriptor(pubkey))
+    else:
+        return PKHDescriptor(pubkey)
 
 def getkeypool(client, path, start, end, internal=False, keypool=True, account=0, sh_wpkh=False, wpkh=True, addr_all=False):
 
@@ -216,7 +249,7 @@ def getdescriptors(client, account=0):
                 continue
             if not isinstance(desc, Descriptor):
                 return desc
-            descriptors.append(desc.serialize())
+            descriptors.append(desc.to_string())
         if internal:
             result["internal"] = descriptors
         else:
@@ -234,32 +267,41 @@ def displayaddress(client, path=None, desc=None, sh_wpkh=False, wpkh=False, rede
             return {'error': ' `--wpkh` and `--sh_wpkh` can not be combined with --desc', 'code': BAD_ARGUMENT}
         if redeem_script:
             return {'error': ' `--redeem_script` can not be combined with --desc', 'code': BAD_ARGUMENT}
-        descriptor = Descriptor.parse(desc, client.is_testnet)
-        if descriptor is None:
-            return {'error': 'Unable to parse descriptor: ' + desc, 'code': BAD_ARGUMENT}
-        if descriptor.sh or descriptor.sh_wsh or descriptor.wsh:
-            path = ''
-            redeem_script = format(80 + int(descriptor.multisig_M), 'x')
-            xpubs_descriptor = False
-            for i in range(0, descriptor.multisig_N):
-                path += descriptor.origin_fingerprint[i] + descriptor.origin_path[i]
-                if not descriptor.path_suffix[i]:
-                    redeem_script += '21' + descriptor.base_key[i]
-                else:
-                    path += descriptor.path_suffix[i]
-                    xpubs_descriptor = True
-                path += ','
-            path = path[0:-1]
-            redeem_script += format(80 + descriptor.multisig_N, 'x') + 'ae'
-            return client.display_address(path, descriptor.sh_wpkh or descriptor.sh_wsh, descriptor.wpkh or descriptor.wsh, redeem_script, descriptor=descriptor if xpubs_descriptor else None)
-        if descriptor.m_path is None:
-            return {'error': 'Descriptor missing origin info: ' + desc, 'code': BAD_ARGUMENT}
-        if descriptor.origin_fingerprint != client.get_master_fingerprint_hex():
-            return {'error': 'Descriptor fingerprint does not match device: ' + desc, 'code': BAD_ARGUMENT}
-        xpub = client.get_pubkey_at_path(descriptor.m_path_base)['xpub']
-        if descriptor.base_key != xpub and descriptor.base_key != xpub_to_pub_hex(xpub):
-            return {'error': 'Key in descriptor does not match device: ' + desc, 'code': BAD_ARGUMENT}
-        return client.display_address(descriptor.m_path, descriptor.sh_wpkh, descriptor.wpkh)
+        descriptor = parse_descriptor(desc)
+        is_sh = isinstance(descriptor, SHDescriptor)
+        is_wsh = isinstance(descriptor, WSHDescriptor)
+        if is_sh or is_wsh:
+            descriptor = descriptor.subdescriptor
+            if isinstance(descriptor, WSHDescriptor):
+                is_wsh = True
+                descriptor = descriptor.subdescriptor
+            if isinstance(descriptor, MultisigDescriptor):
+                path = ''
+                redeem_script = format(80 + int(descriptor.thresh), 'x')
+                xpubs_descriptor = False
+                for p in descriptor.pubkeys:
+                    path += p.origin.to_string()
+                    if not p.deriv_path:
+                        redeem_script += format(len(p.pubkey) // 2, 'x')
+                        redeem_script += p.pubkey
+                    else:
+                        path += p.deriv_path
+                        xpubs_descriptor = True
+                    path += ','
+                path = path[0:-1]
+                redeem_script += format(80 + len(descriptor.pubkeys), 'x') + 'ae'
+                return client.display_address(path, is_sh and is_wsh, not is_sh and is_wsh, redeem_script, descriptor=descriptor if xpubs_descriptor else None)
+        is_wpkh = isinstance(descriptor, WPKHDescriptor)
+        if isinstance(descriptor, PKHDescriptor) or is_wpkh:
+            pubkey = descriptor.pubkeys[0]
+            if pubkey.origin is None:
+                return {'error': 'Descriptor missing origin info: ' + desc, 'code': BAD_ARGUMENT}
+            if pubkey.origin.get_fingerprint_hex() != client.get_master_fingerprint_hex():
+                return {'error': 'Descriptor fingerprint does not match device: ' + desc, 'code': BAD_ARGUMENT}
+            xpub = client.get_pubkey_at_path(pubkey.origin.get_derivation_path())['xpub']
+            if pubkey.pubkey != xpub and pubkey.pubkey != xpub_to_pub_hex(xpub):
+                return {'error': 'Key in descriptor does not match device: ' + desc, 'code': BAD_ARGUMENT}
+            return client.display_address(pubkey.origin.get_derivation_path(), is_sh and is_wpkh, not is_sh and is_wpkh)
 
 def setup_device(client, label='', backup_passphrase=''):
     return client.setup_device(label, backup_passphrase)
diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py
index d5c3cf1..5d56bd8 100644
--- a/hwilib/descriptor.py
+++ b/hwilib/descriptor.py
@@ -1,9 +1,15 @@
-# mypy: ignore-errors
-import re
+from .key import KeyOriginInfo
+
+from enum import Enum
+from typing import (
+    List,
+    Optional,
+    Tuple,
+)
 
 # From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp
 
-def PolyMod(c, val):
+def PolyMod(c: int, val: int) -> int:
     c0 = c >> 35
     c = ((c & 0x7ffffffff) << 5) ^ val
     if (c0 & 1):
@@ -18,7 +24,7 @@ def PolyMod(c, val):
         c ^= 0x644d626ffd
     return c
 
-def DescriptorChecksum(desc):
+def DescriptorChecksum(desc: str) -> str:
     INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
     CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
 
@@ -42,174 +48,207 @@ def DescriptorChecksum(desc):
         c = PolyMod(c, 0)
     c ^= 1
 
-    ret = [None] * 8
+    ret = [''] * 8
     for j in range(0, 8):
         ret[j] = CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
     return ''.join(ret)
 
-def AddChecksum(desc):
+def AddChecksum(desc: str) -> str:
     return desc + "#" + DescriptorChecksum(desc)
 
-class Descriptor:
+
+class PubkeyProvider(object):
     def __init__(
         self,
-        origin_fingerprint,
-        origin_path,
-        base_key,
-        path_suffix,
-        testnet,
-        sh_wpkh=None,
-        wpkh=None,
-        sh=None,
-        sh_wsh=None,
-        wsh=None,
-        multisig_M=None,
-        multisig_N=None
-    ):
-        self.origin_fingerprint = origin_fingerprint
-        self.origin_path = origin_path
-        self.path_suffix = path_suffix
-        self.base_key = base_key
-        self.testnet = testnet
-        self.sh_wpkh = sh_wpkh
-        self.wpkh = wpkh
-        self.sh = sh
-        self.sh_wsh = sh_wsh
-        self.wsh = wsh
-        self.multisig_M = multisig_M
-        self.multisig_N = multisig_N
-        self.m_path = None
-
-        if origin_path and not isinstance(origin_path, list):
-            self.m_path_base = "m" + origin_path
-            self.m_path = "m" + origin_path + (path_suffix or "")
-        elif isinstance(origin_path, list):
-            self.m_path_base = []
-            self.m_path = []
-            for i in range(0, len(origin_path)):
-                self.m_path_base.append("m" + origin_path[i])
-                self.m_path.append("m" + origin_path[i] + (path_suffix[i] or ""))
+        origin: Optional['KeyOriginInfo'],
+        pubkey: str,
+        deriv_path: Optional[str]
+    ) -> None:
+        self.origin = origin
+        self.pubkey = pubkey
+        self.deriv_path = deriv_path
 
     @classmethod
-    def parse(cls, desc, testnet=False):
-        sh_wpkh = None
-        wpkh = None
-        sh = None
-        sh_wsh = None
-        wsh = None
-        origin_fingerprint = None
-        origin_path = None
-        base_key_and_path_match = None
-        base_key = None
-        path_suffix = None
-        multisig_M = None
-        multisig_N = None
-
-        # Check the checksum
-        check_split = desc.split('#')
-        if len(check_split) > 2:
-            return None
-        if len(check_split) == 2:
-            if len(check_split[1]) != 8:
-                return None
-            checksum = DescriptorChecksum(check_split[0])
-            if not checksum.strip():
-                return None
-            if checksum != check_split[1]:
-                return None
-        desc = check_split[0]
-
-        if desc.startswith("sh(wpkh("):
-            sh_wpkh = True
-        elif desc.startswith("wpkh("):
-            wpkh = True
-        elif desc.startswith("sh(wsh("):
-            sh_wsh = True
-        elif desc.startswith("wsh("):
-            wsh = True
-        elif desc.startswith("sh("):
-            sh = True
-
-        if sh or sh_wsh or wsh:
-            if 'multi(' not in desc:
-                # only multisig scripts are supported
-                return None
-            # get the list of keys only
-            keys = desc.split(',', 1)[1].split(')', 1)[0].split(',')
-            if 'sortedmulti' in desc:
-                keys.sort(key=lambda x: x if ']' not in x else x.split(']')[1])
-            multisig_M = desc.split(',')[0].split('(')[-1]
-            multisig_N = len(keys)
-        else:
-            keys = [desc.split('(')[-1].split(')', 1)[0]]
-
-        descriptors = []
-        for key in keys:
-            origin_match = re.search(r"\[(.*)\]", key)
-            if origin_match:
-                origin = origin_match.group(1)
-                match = re.search(r"^([0-9a-fA-F]{8})(\/.*)", origin)
-                if match:
-                    origin_fingerprint = match.group(1)
-                    origin_path = match.group(2)
-                    # Replace h with '
-                    origin_path = origin_path.replace('h', '\'')
-                else:
-                    origin_fingerprint = origin
-                    origin_path = ''
-
-                base_key_and_path_match = re.search(r"\[.*\](\w+)([\d'\/\*]*)", key)
-            else:
-                base_key_and_path_match = re.search(r"(\w+)([\d'\/\*]*)", key)
-
-            if base_key_and_path_match:
-                base_key = base_key_and_path_match.group(1)
-                path_suffix = base_key_and_path_match.group(2)
-                if path_suffix == '':
-                    path_suffix = None
-            else:
-                if origin_match is None:
-                    return None
-
-            descriptors.append(cls(origin_fingerprint, origin_path, base_key, path_suffix, testnet, sh_wpkh, wpkh, sh, sh_wsh, wsh))
-        if len(descriptors) == 1:
-            return descriptors[0]
-        else:
-            # for multisig scripts save as lists all keypaths fields
-            return cls(
-                [descriptor.origin_fingerprint for descriptor in descriptors],
-                [descriptor.origin_path for descriptor in descriptors],
-                [descriptor.base_key for descriptor in descriptors],
-                [descriptor.path_suffix for descriptor in descriptors],
-                testnet,
-                sh_wpkh,
-                wpkh,
-                sh,
-                sh_wsh,
-                wsh,
-                multisig_M,
-                multisig_N
-            )
-
-    def serialize(self):
-        descriptor_open = 'pkh('
-        descriptor_close = ')'
-        origin = ''
-        path_suffix = ''
-
-        if self.wpkh:
-            descriptor_open = 'wpkh('
-        elif self.sh_wpkh:
-            descriptor_open = 'sh(wpkh('
-            descriptor_close = '))'
-        elif self.sh or self.sh_wsh or self.wsh:
-            # serialize multisig descriptor is not supported yet.
-            return None
-
-        if self.origin_fingerprint and self.origin_path:
-            origin = '[' + self.origin_fingerprint + self.origin_path + ']'
-
-        if self.path_suffix:
-            path_suffix = self.path_suffix
-
-        return AddChecksum(descriptor_open + origin + self.base_key + path_suffix + descriptor_close)
+    def parse(cls, s: str) -> 'PubkeyProvider':
+        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:
+        s = ""
+        if self.origin:
+            s += "[{}]".format(self.origin.to_string())
+        s += self.pubkey
+        if self.deriv_path:
+            s += self.deriv_path
+        return s
+
+    def __lt__(self, other: 'PubkeyProvider') -> bool:
+        return self.pubkey < other.pubkey
+
+
+class Descriptor(object):
+    def __init__(
+        self,
+        pubkeys: List['PubkeyProvider'],
+        subdescriptor: Optional['Descriptor'],
+        name: str
+    ) -> None:
+        self.pubkeys = pubkeys
+        self.subdescriptor = subdescriptor
+        self.name = name
+
+    def to_string_no_checksum(self) -> str:
+        return "{}({}{})".format(
+            self.name,
+            ",".join([p.to_string() for p in self.pubkeys]),
+            self.subdescriptor.to_string_no_checksum() if self.subdescriptor else ""
+        )
+
+    def to_string(self) -> str:
+        return AddChecksum(self.to_string_no_checksum())
+
+
+class PKHDescriptor(Descriptor):
+    def __init__(
+        self,
+        pubkey: 'PubkeyProvider'
+    ) -> None:
+        super().__init__([pubkey], None, "pkh")
+
+
+class WPKHDescriptor(Descriptor):
+    def __init__(
+        self,
+        pubkey: 'PubkeyProvider'
+    ) -> None:
+        super().__init__([pubkey], None, "wpkh")
+
+
+class MultisigDescriptor(Descriptor):
+    def __init__(
+        self,
+        pubkeys: List['PubkeyProvider'],
+        thresh: int,
+        is_sorted: bool
+    ) -> None:
+        super().__init__(pubkeys, None, "sortedmulti" if is_sorted else "multi")
+        self.thresh = thresh
+        if is_sorted:
+            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]))
+
+
+class SHDescriptor(Descriptor):
+    def __init__(
+        self,
+        subdescriptor: Optional['Descriptor']
+    ) -> None:
+        super().__init__([], subdescriptor, "sh")
+
+
+class WSHDescriptor(Descriptor):
+    def __init__(
+        self,
+        subdescriptor: Optional['Descriptor']
+    ) -> None:
+        super().__init__([], subdescriptor, "wsh")
+
+
+def _get_func_expr(s: str) -> Tuple[str, str]:
+    """
+    Get the function name and then the expression inside
+    """
+    start = s.index("(")
+    end = s.rindex(")")
+    return s[0:start], s[start + 1:end]
+
+
+def parse_pubkey(expr: str) -> Tuple['PubkeyProvider', str]:
+    end = len(expr)
+    comma_idx = expr.find(",")
+    next_expr = ""
+    if comma_idx != -1:
+        end = comma_idx
+        next_expr = expr[end + 1:]
+    return PubkeyProvider.parse(expr[:end]), next_expr
+
+
+class _ParseDescriptorContext(Enum):
+    TOP = 1
+    P2SH = 2
+    P2WSH = 3
+
+
+def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor':
+    func, expr = _get_func_expr(desc)
+    if func == "pkh":
+        pubkey, expr = parse_pubkey(expr)
+        if expr:
+            raise ValueError("More than one pubkey in pkh descriptor")
+        return PKHDescriptor(pubkey)
+    if func == "sortedmulti" or func == "multi":
+        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)
+            pubkeys.append(pubkey)
+        if len(pubkeys) == 0 or len(pubkeys) > 16:
+            raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 16 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 ctx != _ParseDescriptorContext.P2WSH and func == "wpkh":
+        pubkey, expr = parse_pubkey(expr)
+        if expr:
+            raise ValueError("More than one pubkey in pkh descriptor")
+        return WPKHDescriptor(pubkey)
+    elif ctx == _ParseDescriptorContext.P2WSH and func == "wpkh":
+        raise ValueError("Cannot have wpkh within wsh")
+    if ctx == _ParseDescriptorContext.TOP and func == "sh":
+        subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2SH)
+        return SHDescriptor(subdesc)
+    elif ctx != _ParseDescriptorContext.TOP and func == "sh":
+        raise ValueError("Cannot have sh in non-top level")
+    if ctx != _ParseDescriptorContext.P2WSH and func == "wsh":
+        subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2WSH)
+        return WSHDescriptor(subdesc)
+    elif ctx == _ParseDescriptorContext.P2WSH and func == "wsh":
+        raise ValueError("Cannot have wsh within wsh")
+    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':
+    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, _ParseDescriptorContext.TOP)
diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py
index 1b6a147..6c73aab 100644
--- a/hwilib/devices/trezor.py
+++ b/hwilib/devices/trezor.py
@@ -418,11 +418,11 @@ class TrezorClient(HardwareWalletClient):
         # descriptor means multisig with xpubs
         if descriptor:
             pubkeys = []
-            for i in range(0, descriptor.multisig_N):
-                xpub = ExtendedKey.deserialize(descriptor.base_key[i])
+            for p in descriptor.pubkeys:
+                xpub = ExtendedKey.deserialize(p.pubkey)
                 hd_node = proto.HDNodeType(depth=xpub.depth, fingerprint=int.from_bytes(xpub.parent_fingerprint, 'big'), child_num=xpub.child_num, chain_code=xpub.chaincode, public_key=xpub.pubkey)
-                pubkeys.append(proto.HDNodePathType(node=hd_node, address_n=parse_path('m' + descriptor.path_suffix[i])))
-            multisig = proto.MultisigRedeemScriptType(m=int(descriptor.multisig_M), signatures=[b''] * int(descriptor.multisig_N), pubkeys=pubkeys)
+                pubkeys.append(proto.HDNodePathType(node=hd_node, address_n=parse_path("m" + p.deriv_path)))
+            multisig = proto.MultisigRedeemScriptType(m=descriptor.thresh, signatures=[b''] * len(descriptor.pubkeys), pubkeys=pubkeys)
         # redeem_script means p2sh/multisig
         elif redeem_script:
             # Get multisig object required by Trezor's get_address
diff --git a/hwilib/key.py b/hwilib/key.py
index 409eb85..62be8cb 100644
--- a/hwilib/key.py
+++ b/hwilib/key.py
@@ -223,12 +223,33 @@ class KeyOriginInfo(object):
         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 = parse_path(s[9:])
+        return cls(fingerprint, path)
+
     def get_derivation_path(self) -> str:
         """
         Return the string for just the path
         """
         return "m" + self._path_string()
 
+    def get_fingerprint_hex(self) -> str:
+        """
+        Return the hex for just the fingerprint
+        """
+        return binascii.hexlify(self.fingerprint).decode()
+
 
 def parse_path(nstr: str) -> Sequence[int]:
     """


Test patch:
diff --git a/test/test_descriptor.py b/test/test_descriptor.py
index 667e814..0760c9c 100755
--- a/test/test_descriptor.py
+++ b/test/test_descriptor.py
@@ -1,126 +1,109 @@
 #! /usr/bin/env python3
 
-from hwilib.descriptor import Descriptor
+from hwilib.descriptor import (
+    parse_descriptor,
+    MultisigDescriptor,
+    WPKHDescriptor,
+    WSHDescriptor,
+)
 import unittest
 
 class TestDescriptor(unittest.TestCase):
     def test_parse_descriptor_with_origin(self):
-        desc = Descriptor.parse("wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True)
-        self.assertIsNotNone(desc)
-        self.assertEqual(desc.wpkh, True)
-        self.assertEqual(desc.sh_wpkh, None)
-        self.assertEqual(desc.origin_fingerprint, "00000001")
-        self.assertEqual(desc.origin_path, "/84'/1'/0'")
-        self.assertEqual(desc.base_key, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B")
-        self.assertEqual(desc.path_suffix, "/0/0")
-        self.assertEqual(desc.testnet, True)
-        self.assertEqual(desc.m_path, "m/84'/1'/0'/0/0")
+        d = "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)"
+        desc = parse_descriptor(d)
+        self.assertTrue(isinstance(desc, WPKHDescriptor))
+        self.assertEqual(desc.pubkeys[0].origin.get_fingerprint_hex(), "00000001")
+        self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84'/1'/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)
 
     def test_parse_multisig_descriptor_with_origin(self):
-        desc = Descriptor.parse("wsh(multi(2,[00000001/48'/0'/0'/2']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48'/0'/0'/2']tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))", True)
-        self.assertIsNotNone(desc)
-        self.assertEqual(desc.wsh, True)
-        self.assertEqual(desc.origin_fingerprint, ["00000001", "00000002"])
-        self.assertEqual(desc.origin_path, ["/48'/0'/0'/2'", "/48'/0'/0'/2'"])
-        self.assertEqual(desc.base_key, ["tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B", "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty"])
-        self.assertEqual(desc.path_suffix, ["/0/0", "/0/0"])
-        self.assertEqual(desc.testnet, True)
-        self.assertEqual(desc.m_path_base, ["m/48'/0'/0'/2'", "m/48'/0'/0'/2'"])
-        self.assertEqual(desc.m_path, ["m/48'/0'/0'/2'/0/0", "m/48'/0'/0'/2'/0/0"])
+        d = "wsh(multi(2,[00000001/48'/0'/0'/2']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48'/0'/0'/2']tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))"
+        desc = parse_descriptor(d)
+        self.assertTrue(isinstance(desc, WSHDescriptor))
+        self.assertTrue(isinstance(desc.subdescriptor, MultisigDescriptor))
+        self.assertEqual(desc.subdescriptor.pubkeys[0].origin.get_fingerprint_hex(), "00000001")
+        self.assertEqual(desc.subdescriptor.pubkeys[0].origin.get_derivation_path(), "m/48'/0'/0'/2'")
+        self.assertEqual(desc.subdescriptor.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B")
+        self.assertEqual(desc.subdescriptor.pubkeys[0].deriv_path, "/0/0")
+
+        self.assertEqual(desc.subdescriptor.pubkeys[1].origin.get_fingerprint_hex(), "00000002")
+        self.assertEqual(desc.subdescriptor.pubkeys[1].origin.get_derivation_path(), "m/48'/0'/0'/2'")
+        self.assertEqual(desc.subdescriptor.pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty")
+        self.assertEqual(desc.subdescriptor.pubkeys[1].deriv_path, "/0/0")
+
+        self.assertEqual(desc.to_string_no_checksum(), d)
 
     def test_parse_descriptor_without_origin(self):
-        desc = Descriptor.parse("wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True)
-        self.assertIsNotNone(desc)
-        self.assertEqual(desc.wpkh, True)
-        self.assertEqual(desc.sh_wpkh, None)
-        self.assertEqual(desc.origin_fingerprint, None)
-        self.assertEqual(desc.origin_path, None)
-        self.assertEqual(desc.base_key, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B")
-        self.assertEqual(desc.path_suffix, "/0/0")
-        self.assertEqual(desc.testnet, True)
-        self.assertEqual(desc.m_path, None)
+        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.to_string_no_checksum(), d)
 
     def test_parse_descriptor_with_origin_fingerprint_only(self):
-        desc = Descriptor.parse("wpkh([00000001]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True)
-        self.assertIsNotNone(desc)
-        self.assertEqual(desc.wpkh, True)
-        self.assertEqual(desc.sh_wpkh, None)
-        self.assertEqual(desc.origin_fingerprint, "00000001")
-        self.assertEqual(desc.origin_path, "")
-        self.assertEqual(desc.base_key, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B")
-        self.assertEqual(desc.path_suffix, "/0/0")
-        self.assertEqual(desc.testnet, True)
-        self.assertEqual(desc.m_path, None)
+        d = "wpkh([00000001]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)"
+        desc = parse_descriptor(d)
+        self.assertTrue(isinstance(desc, WPKHDescriptor))
+        self.assertEqual(desc.pubkeys[0].origin.get_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)
 
     def test_parse_descriptor_with_key_at_end_with_origin(self):
-        desc = Descriptor.parse("wpkh([00000001/84'/1'/0'/0/0]0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)", True)
-        self.assertIsNotNone(desc)
-        self.assertEqual(desc.wpkh, True)
-        self.assertEqual(desc.sh_wpkh, None)
-        self.assertEqual(desc.origin_fingerprint, "00000001")
-        self.assertEqual(desc.origin_path, "/84'/1'/0'/0/0")
-        self.assertEqual(desc.base_key, "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7")
-        self.assertEqual(desc.path_suffix, None)
-        self.assertEqual(desc.testnet, True)
-        self.assertEqual(desc.m_path, "m/84'/1'/0'/0/0")
+        d = "wpkh([00000001/84'/1'/0'/0/0]0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)"
+        desc = parse_descriptor(d)
+        self.assertTrue(isinstance(desc, WPKHDescriptor))
+        self.assertEqual(desc.pubkeys[0].origin.get_fingerprint_hex(), "00000001")
+        self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84'/1'/0'/0/0")
+        self.assertEqual(desc.pubkeys[0].pubkey, "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7")
+        self.assertEqual(desc.pubkeys[0].deriv_path, None)
+        self.assertEqual(desc.to_string_no_checksum(), d)
 
     def test_parse_descriptor_with_key_at_end_without_origin(self):
-        desc = Descriptor.parse("wpkh(0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)", True)
-        self.assertIsNotNone(desc)
-        self.assertEqual(desc.wpkh, True)
-        self.assertEqual(desc.sh_wpkh, None)
-        self.assertEqual(desc.origin_fingerprint, None)
-        self.assertEqual(desc.origin_path, None)
-        self.assertEqual(desc.base_key, "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7")
-        self.assertEqual(desc.path_suffix, None)
-        self.assertEqual(desc.testnet, True)
-        self.assertEqual(desc.m_path, None)
+        d = "wpkh(0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)"
+        desc = parse_descriptor(d)
+        self.assertTrue(isinstance(desc, WPKHDescriptor))
+        self.assertEqual(desc.pubkeys[0].origin, None)
+        self.assertEqual(desc.pubkeys[0].pubkey, "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7")
+        self.assertEqual(desc.pubkeys[0].deriv_path, None)
+        self.assertEqual(desc.to_string_no_checksum(), d)
 
     def test_parse_empty_descriptor(self):
-        desc = Descriptor.parse("", True)
-        self.assertIsNone(desc)
+        self.assertRaises(ValueError, parse_descriptor, "")
 
     def test_parse_descriptor_replace_h(self):
-        desc = Descriptor.parse("wpkh([00000001/84h/1h/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True)
+        d = "wpkh([00000001/84h/1h/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)"
+        desc = parse_descriptor(d)
         self.assertIsNotNone(desc)
-        self.assertEqual(desc.origin_path, "/84'/1'/0'")
-
-    def test_serialize_descriptor_with_origin(self):
-        descriptor = "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)#mz20k55p"
-        desc = Descriptor.parse(descriptor, True)
-        self.assertEqual(desc.serialize(), descriptor)
-
-    def test_serialize_descriptor_without_origin(self):
-        descriptor = "wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)#ac0p4yhq"
-        desc = Descriptor.parse(descriptor, True)
-        self.assertEqual(desc.serialize(), descriptor)
-
-    def test_serialize_descriptor_with_key_at_end_with_origin(self):
-        descriptor = "wpkh([00000001/84'/1'/0'/0/0]0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)#rh7p6vk2"
-        desc = Descriptor.parse(descriptor, True)
-        self.assertEqual(desc.serialize(), descriptor)
+        self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84'/1'/0'")
 
     def test_checksums(self):
         with self.subTest(msg='Valid checksum'):
-            self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfy"))
-            self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5t"))
-            self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))"))
-            self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))"))
+            self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfy"))
+            self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5t"))
+            self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))"))
+            self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))"))
         with self.subTest(msg="Empty Checksum"):
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#"))
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#"))
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#")
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#")
         with self.subTest(msg="Too long Checksum"):
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfyq"))
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5tq"))
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfyq")
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5tq")
         with self.subTest(msg="Too Short Checksum"):
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxf"))
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5"))
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxf")
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5")
         with self.subTest(msg="Error in Payload"):
-            self.assertIsNone(Descriptor.parse("sh(multi(3,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfy"))
-            self.assertIsNone(Descriptor.parse("sh(multi(3,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5t"))
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(3,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfy")
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(3,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5t")
         with self.subTest(msg="Error in Checksum"):
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggssrxfy"))
-            self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjq09x4t"))
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggssrxfy")
+            self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjq09x4t")
 
 if __name__ == "__main__":
     unittest.main()