diff --git a/.gitignore b/.gitignore index 73f66bbfb..80fa93a57 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ cypress/videos cypress/screenshots node_modules btcd-conn.json -/tests/bitcoin +tests/bitcoin + diff --git a/src/cryptoadvance/specter/key.py b/src/cryptoadvance/specter/key.py index b6ff54510..39f8a9f17 100644 --- a/src/cryptoadvance/specter/key.py +++ b/src/cryptoadvance/specter/key.py @@ -1,6 +1,6 @@ from collections import OrderedDict from binascii import hexlify -from .util.base58 import decode_base58, encode_base58_checksum +from embit import base58 from .util.xpub import get_xpub_fingerprint @@ -101,7 +101,7 @@ class Key: derivation = "" # checking xpub prefix and defining key type - xpub_bytes = decode_base58(xpub, num_bytes=82) + xpub_bytes = base58.decode_check(xpub) prefix = xpub_bytes[:4] is_valid = False key_type = "" @@ -115,7 +115,7 @@ class Key: raise Exception("Invalid xpub prefix: %s", prefix.hex()) xpub_bytes = prefix + xpub_bytes[4:] - xpub = encode_base58_checksum(xpub_bytes) + xpub = base58.encode_check(xpub_bytes) # defining key type from derivation if derivation != "" and key_type == "": @@ -133,7 +133,7 @@ class Key: key_type = "wsh" # infer fingerprint and derivation if depth == 0 or depth == 1 - xpub_bytes = decode_base58(xpub) + xpub_bytes = base58.decode_check(xpub) depth = xpub_bytes[4] if depth == 0: fingerprint = hexlify(get_xpub_fingerprint(xpub)).decode() diff --git a/src/cryptoadvance/specter/util/base58.py b/src/cryptoadvance/specter/util/base58.py index 7f02df6e3..e7dc25d6b 100644 --- a/src/cryptoadvance/specter/util/base58.py +++ b/src/cryptoadvance/specter/util/base58.py @@ -27,10 +27,15 @@ def encode_base58(s): def encode_base58_checksum(s): + """ Adds the checksum and then encodes to base58 """ return encode_base58(s + double_sha256(s)[:4]).decode("ascii") def decode_base58(s, num_bytes=82, strip_leading_zeros=False): + """Decodes a base58 encoded string with a checksum at the end, does not support legacy + addresses due to their prefixes (0 bytes), returns WITHOUT + checksum, strip_leading_zeros has to be set to True to avoid + raising a ValueError""" num = 0 for c in s.encode("ascii"): num *= 58 diff --git a/src/cryptoadvance/specter/util/descriptor.py b/src/cryptoadvance/specter/util/descriptor.py index 41251d688..382156d1f 100644 --- a/src/cryptoadvance/specter/util/descriptor.py +++ b/src/cryptoadvance/specter/util/descriptor.py @@ -1,7 +1,9 @@ import re from embit import bip32, ec, networks, script +from cryptoadvance.specter.specter_error import SpecterError -# From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp +# Based on hwilib by achow101: https://github.com/bitcoin-core/HWI/blob/1.2.1/hwilib/descriptor.py which is from +# https://github.com/bitcoin/bitcoin/blob/0.21/src/script/descriptor.cpp def PolyMod(c, val): @@ -54,6 +56,7 @@ def AddChecksum(desc): return desc + "#" + DescriptorChecksum(desc) +# Not in hwilib def derive_pubkey(key, path_suffix=None, idx=None): # if SEC pubkey - just return it if key[:2] in ["02", "03", "04"]: @@ -133,16 +136,29 @@ class Descriptor: # Check the checksum check_split = desc.split("#") + # Multiple # in desc if len(check_split) > 2: - return None + raise SpecterError( + f"Too many separators in the descriptor. Check if there are multiple # in {desc}." + ) if len(check_split) == 2: - if len(check_split[1]) != 8: - return None + # Empty checkusm + if len(check_split[1]) == 0: + raise SpecterError("Checksum is empty.") + # Incorrect length + elif len(check_split[1]) != 8: + raise SpecterError( + f"Checksum {check_split[1]} doesn't have the correct length. Should be 8 characters not {len(check_split[1])}." + ) checksum = DescriptorChecksum(check_split[0]) - if not checksum.strip(): - return None + # Check of checksum calc + if checksum.strip() == "": + raise SpecterError(f"Checksum calculation went wrong.") + # Wrong checksum if checksum != check_split[1]: - return None + raise SpecterError( + f"{check_split[1]} is the wrong checkum should be {checksum}." + ) desc = check_split[0] if desc.startswith("sh(wpkh("): @@ -171,11 +187,19 @@ class Descriptor: 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) + if int(multisig_M) > multisig_N: + raise SpecterError( + f"Multisig threshold cannot be larger than the number of keys. Threshold is {int(multisig_M)} but only {multisig_N} keys specified." + ) else: keys = [desc.split("(")[-1].split(")", 1)[0]] descriptors = [] for key in keys: + origin_fingerprint = None + origin_path = None + base_key = None + path_suffix = None origin_match = re.search(r"\[(.*)\]", key) if origin_match: origin = origin_match.group(1) @@ -217,6 +241,7 @@ class Descriptor: sort_keys, ) ) + if len(descriptors) == 1: return descriptors[0] else: @@ -244,7 +269,7 @@ class Descriptor: def derive(self, idx, keep_xpubs=False): """ Derives a descriptor with index idx up to the pubkeys. - If keep_xpubs is False all xpubs will be replaces by pubkeys + If keep_xpubs is False all xpubs will be replaced by pubkeys so [fgp/path]xpub/suffix changes to [fgp/path/suffix]pubkey Otherwise xpubs will be sorted according to pubkeys but remain in the descriptor diff --git a/src/cryptoadvance/specter/util/xpub.py b/src/cryptoadvance/specter/util/xpub.py index 10e12b317..512710386 100644 --- a/src/cryptoadvance/specter/util/xpub.py +++ b/src/cryptoadvance/specter/util/xpub.py @@ -1,15 +1,16 @@ import hashlib -from .base58 import decode_base58, encode_base58_checksum +from embit import base58 def hash160(d): + # ripemd160(sha256(d)) return hashlib.new("ripemd160", hashlib.sha256(d).digest()).digest() def convert_xpub_prefix(xpub, prefix_bytes): # Update xpub to specified prefix and re-encode - b = decode_base58(xpub) - return encode_base58_checksum(prefix_bytes + b[4:]) + b = base58.decode_check(xpub) + return base58.encode_check(prefix_bytes + b[4:]) def get_xpub_fingerprint(xpub): @@ -17,5 +18,5 @@ def get_xpub_fingerprint(xpub): Retuns fingerprint of the XPUB itself. IMPORTANT! NOT parent fingerprint, but hash160(pubkey) itself! """ - b = decode_base58(xpub) + b = base58.decode_check(xpub) return hash160(b[-33:])[:4] diff --git a/src/cryptoadvance/specter/wallet.py b/src/cryptoadvance/specter/wallet.py index cafc7f2a6..6023aaad0 100644 --- a/src/cryptoadvance/specter/wallet.py +++ b/src/cryptoadvance/specter/wallet.py @@ -4,7 +4,7 @@ from .device import Device from .key import Key from .util.merkleblock import is_valid_merkle_proof from .helpers import der_to_bytes -from .util.base58 import decode_base58 +from embit import base58 from .util.descriptor import Descriptor, sort_descriptor, AddChecksum from .util.xpub import get_xpub_fingerprint from .util.tx import decoderawtransaction @@ -1262,7 +1262,7 @@ class Wallet: # for multisig add xpub fields if len(self.keys) > 1: for k in self.keys: - key = b"\x01" + decode_base58(k.xpub) + key = b"\x01" + base58.decode_check(k.xpub) if k.fingerprint != "": fingerprint = bytes.fromhex(k.fingerprint) else: diff --git a/tests/conftest.py b/tests/conftest.py index 17dd49aff..ba02be227 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,8 @@ from cryptoadvance.specter.device_manager import DeviceManager from cryptoadvance.specter.specter import Specter from cryptoadvance.specter.server import create_app, init_app +pytest_plugins = ["ghost_machine"] + def pytest_addoption(parser): """Internally called to add options to pytest diff --git a/tests/ghost_machine.py b/tests/ghost_machine.py new file mode 100644 index 000000000..b1d3b142d --- /dev/null +++ b/tests/ghost_machine.py @@ -0,0 +1,67 @@ +import pytest + +# Using https://iancoleman.io/bip39/ and https://jlopp.github.io/xpub-converter/ +# mnemonic = "ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost machine" + + +# m/44'/0'/0' + + +@pytest.fixture +def ghost_machine_xpub_44(): + xpub = "xpub6CGap5qbgNCEsvXg2gAjEho17zECMA9PbZa7QkrEWTPnPRaubE6qKots5pNwhyFtuYSPa9gQu4jTTZi8WPaXJhtCHrvHQaFRqayN1saQoWv" + return xpub + + +# m/49'/0'/0' + + +@pytest.fixture +def ghost_machine_xpub_49(): + xpub = "xpub6BtcNhqbaFaoC3oEfKky3Sm22pF48U2jmAf78cB3wdAkkGyAgmsVrgyt1ooSt3bHWgzsdUQh2pTJ867yTeUAMmFDKNSBp8J7WPmp7Df7zjv" + return xpub + + +@pytest.fixture +def ghost_machine_ypub(): + ypub = "ypub6WisgNWWiw8H3LzMVgYbFXrXCnPW562EgHBKv14wKdYdoNnPwS34Uke231m2sxFCvL7gNx1FVUor1NjYBLtB9zvpBi8cQ37bn7qTVqo3fjR" + return ypub + + +@pytest.fixture +def ghost_machine_tpub_49(): + tpub = "tpubDC5CZBbVc15fpTeqkyUBKgHqYCqkeaUtPjvGz7RJEttndfcN29psPcxTSj5RNJaWYaRQq8kqovLBrZA2tju3ThSAP9fY1eiSvorchnseFZu" + return tpub + + +@pytest.fixture +def ghost_machine_upub(): + upub = "upub5DCn7wm4SgVmzmtdoi8DVVfxhBJkqL1L6mmKHNgVky1Fj5VyBxV6NzKD957sr5fWXkY5y8THtqSVWWpjLnomBYw4iXpxaPbkXg5Gn6s5tQf" + return upub + + +# m/84'/0'/0' + + +@pytest.fixture +def ghost_machine_xpub_84(): + xpub = "xpub6CjsHfiuBnHMPBkxThQ4DDjTw2Qq3VMEVcPBoMBGejZGkj3WQR15LeJLmymPpSzYHX21C8SdFWHgMw2RUBdAQ2Aj4MMS93a68mxPQeS8oHr" + return xpub + + +@pytest.fixture +def ghost_machine_zpub(): + zpub = "zpub6rQPu14jV9NK5n9C8QyJdPvUGxhivjLEKqRdN8y3QkK2rvfxujLCamccpPgZpGJP6oFch5dkApzn8WFYuaTBzVXvo2kHJsD4gE5gBnCBYj1" + return zpub + + +@pytest.fixture +def ghost_machine_tpub_84(): + tpub = "tpubDC4DsqH5rqHqipMNqUbDFtQT3AkKkUrvLsN6miySvortU3s1LGaNVAb7wX2No2VsuxQV82T8s3HJLv3kdx1CPjsJ3onC1Zo5mWCQzRVaWVX" + return tpub + + +@pytest.fixture +def ghost_machine_vpub(): + vpub = "vpub5Y24kG7ZrCFRkRnHia2sdnt5N7MmsrNry1jMrP8XptMEcZZqkjQA6bc1f52RGiEoJmdy1Vk9Qck9tAL1ohKvuq3oFXe3ADVse6UiTHzuyKx" + return vpub diff --git a/tests/test_descriptor.py b/tests/test_descriptor.py deleted file mode 100644 index 991fb5480..000000000 --- a/tests/test_descriptor.py +++ /dev/null @@ -1,33 +0,0 @@ -from cryptoadvance.specter.util.descriptor import * - - -def test_parse(): - descs = [ - "wpkh([5d5c5649/84h/1h/0h]tpubDCB5nE2GEEuX9xyFigt33xT1RidfkSsH2VSqDx93D1TrvghcZgoDBTjWnWwKTtA6DfvW7fKDAzPJoSduEbt1QkUW2YGaC2CgYxvmF9RyRZS/0/*)#ypamvruf", - "sh(wpkh([5d5c5649/84h/1h/0h]tpubDCB5nE2GEEuX9xyFigt33xT1RidfkSsH2VSqDx93D1TrvghcZgoDBTjWnWwKTtA6DfvW7fKDAzPJoSduEbt1QkUW2YGaC2CgYxvmF9RyRZS/0/*))", - "wsh(sortedmulti(2,[5d5c5649/48h/1h/0h/2h]tpubDEizCJr6sdiKWC6Be8b5EB7akzS7omSX8CHfAYNYewweRDzjmX2kgDAnig9RcVxqtcxdKuYQSKhkjHecYjyej22b7WThS8r1RBmY3Rfczb9/0/*,[0b9fb36b/48h/1h/0h/2h]tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/0/*))#2mdfgjf6", - "sh(wsh(sortedmulti(2,[5d5c5649/48h/1h/0h/2h]tpubDEizCJr6sdiKWC6Be8b5EB7akzS7omSX8CHfAYNYewweRDzjmX2kgDAnig9RcVxqtcxdKuYQSKhkjHecYjyej22b7WThS8r1RBmY3Rfczb9/0/*,[0b9fb36b/48h/1h/0h/2h]tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/0/*)))", - "sh(wsh(sortedmulti(2,tpubDEizCJr6sdiKWC6Be8b5EB7akzS7omSX8CHfAYNYewweRDzjmX2kgDAnig9RcVxqtcxdKuYQSKhkjHecYjyej22b7WThS8r1RBmY3Rfczb9/0/*,[0b9fb36b/48h/1h/0h/2h]tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/0/*,tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV,tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/10,03d568305d7ce6185f2512472bcad032a672626cf15dc2c6b5f68fdd2f3e5898ef)))", - ] - - for desc in descs: - d = Descriptor.parse(desc, True) - - -def test_derive(): - desc = "sh(wsh(sortedmulti(2,tpubDEizCJr6sdiKWC6Be8b5EB7akzS7omSX8CHfAYNYewweRDzjmX2kgDAnig9RcVxqtcxdKuYQSKhkjHecYjyej22b7WThS8r1RBmY3Rfczb9/0/*,[0b9fb36b/48h/1h/0h/2h]tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/0/*,tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV,tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/10,03d568305d7ce6185f2512472bcad032a672626cf15dc2c6b5f68fdd2f3e5898ef)))" - d = Descriptor.parse(desc, True) - assert ( - d.derive(1).serialize() - == "sh(wsh(sortedmulti(2,[0b9fb36b/48'/1'/0'/2'/10]0249f0282636a8f3fac54a37387686705ddf717ab255cc18d4cc60fff284b8585c,[0b9fb36b/48'/1'/0'/2']02c0ca2aa23a2c83039437973d7eb44d15978900733569583103d03c705aa8383a,[0b9fb36b/48'/1'/0'/2'/0/1]036e5e49573aa861e10c3a01342bc7badcaf8acb88a02aaf4bdae46187260ca262,[0b9fb36b/48'/1'/0'/2']03d568305d7ce6185f2512472bcad032a672626cf15dc2c6b5f68fdd2f3e5898ef,03d7b53b60cbf4c0a9075d65911ce37f759d513f9ac3eca45752256f8aa2d82a9e)))#6n9weurx" - ) - assert ( - d.derive(1, keep_xpubs=True).serialize() - == "sh(wsh(sortedmulti(2,[0b9fb36b/48'/1'/0'/2']tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/10,[0b9fb36b/48'/1'/0'/2']tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV,[0b9fb36b/48'/1'/0'/2']tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/0/1,[0b9fb36b/48'/1'/0'/2']03d568305d7ce6185f2512472bcad032a672626cf15dc2c6b5f68fdd2f3e5898ef,tpubDEizCJr6sdiKWC6Be8b5EB7akzS7omSX8CHfAYNYewweRDzjmX2kgDAnig9RcVxqtcxdKuYQSKhkjHecYjyej22b7WThS8r1RBmY3Rfczb9/0/1)))#7r3s692f" - ) - assert ( - sort_descriptor(desc, 11) - == "sh(wsh(multi(2,tpubDEizCJr6sdiKWC6Be8b5EB7akzS7omSX8CHfAYNYewweRDzjmX2kgDAnig9RcVxqtcxdKuYQSKhkjHecYjyej22b7WThS8r1RBmY3Rfczb9/0/11,[0b9fb36b/48'/1'/0'/2']tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/10,[0b9fb36b/48'/1'/0'/2']tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV,[0b9fb36b/48'/1'/0'/2']tpubDEhAkijE6ovaiWkJpnGnLhW3VJSSbbQeAtRWQro8EaWgNarWFv2TumZ1sj4iBPReCufziRnnb9QSYSEE8tgZQbbaXJTdLtGtQgQTGXEJdfV/0/11,[0b9fb36b/48'/1'/0'/2']03d568305d7ce6185f2512472bcad032a672626cf15dc2c6b5f68fdd2f3e5898ef)))#sys0qqe8" - ) - assert d.address(10) == "2N1TgzrzxjdgkWSuJLoNUtoLhZBJQSakRRk" - assert d.address(10, "main") == "39uUw84w8BBQJfGkffkcGrMSLq6Ee4A2f7" diff --git a/tests/test_key.py b/tests/test_key.py new file mode 100644 index 000000000..2d34a0eb4 --- /dev/null +++ b/tests/test_key.py @@ -0,0 +1,30 @@ +from cryptoadvance.specter.key import Key + + +### Testing for the major attributes of the Key class using its classmethod +### parse_xpub which in turn uses base58 de- and encoding functions from embit + + +def test_fingerprint(ghost_machine_xpub_44): + key = Key.parse_xpub(ghost_machine_xpub_44) + assert key.fingerprint == "81f802e3" + + +def test_key_type(ghost_machine_ypub): + key = Key.parse_xpub(ghost_machine_ypub) + assert key.key_type == "sh-wpkh" + + +def test_purpose(ghost_machine_ypub): + key = Key.parse_xpub(ghost_machine_ypub) + assert key.purpose == "Single (Nested)" + + +def test_xpub(ghost_machine_ypub, ghost_machine_xpub_49): + key = Key.parse_xpub(ghost_machine_ypub) + assert key.xpub == ghost_machine_xpub_49 + + +def test_derivation(ghost_machine_zpub): + key = Key.parse_xpub(f"[81f802e3/84'/0'/3]{ghost_machine_zpub}") + assert key.derivation == "m/84h/0h/3" diff --git a/tests/test_util_descriptor.py b/tests/test_util_descriptor.py new file mode 100644 index 000000000..1c6caf945 --- /dev/null +++ b/tests/test_util_descriptor.py @@ -0,0 +1,310 @@ +from cryptoadvance.specter.util.descriptor import * +from embit import bip32, ec, networks, script +from cryptoadvance.specter.util.xpub import hash160 +from cryptoadvance.specter.util.base58 import * +import pytest + + +### Tests based on https://github.com/bitcoin-core/HWI/blob/1b1596ac6f4fb1ce47a0d1ca7feb1fc553d08e09/test/test_descriptor.py + + +def test_parse_descriptor_with_origin(): + desc = Descriptor.parse( + "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", + True, + ) + assert desc is not None + assert desc.wpkh == True + assert desc.sh_wpkh == None + assert desc.origin_fingerprint == "00000001" + assert desc.origin_path == "/84'/1'/0'" + assert ( + desc.base_key + == "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + ) + assert desc.path_suffix == "/0/0" + assert desc.testnet == True + assert desc.m_path_base == "m/84'/1'/0'" + assert desc.m_path == "m/84'/1'/0'/0/0" + + +def test_parse_multisig_descriptor_with_origin(): + # achow101 uses 48'/0'/0'/2' which isn't testnet, though + desc = Descriptor.parse( + "wsh(multi(2,[00000001/48'/1'/0'/2']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48'/1'/0'/2']tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))", + True, + ) + assert desc is not None + assert desc.wsh == True + assert desc.origin_fingerprint == ["00000001", "00000002"] + assert desc.origin_path == ["/48'/1'/0'/2'", "/48'/1'/0'/2'"] + assert desc.base_key == [ + "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B", + "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty", + ] + assert desc.path_suffix == ["/0/0", "/0/0"] + assert desc.testnet == True + assert desc.m_path_base == ["m/48'/1'/0'/2'", "m/48'/1'/0'/2'"] + assert desc.m_path == ["m/48'/1'/0'/2'/0/0", "m/48'/1'/0'/2'/0/0"] + + +def test_parse_multisig_descriptor_with_origin_one_lacking(): + desc = Descriptor.parse( + "wsh(multi(2,[00000001/48'/1'/0'/2']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))", + True, + ) + assert desc is not None + assert desc.wsh == True + assert desc.origin_fingerprint == ["00000001", None] + assert desc.origin_path == ["/48'/1'/0'/2'", None] + assert desc.base_key == [ + "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B", + "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty", + ] + assert desc.path_suffix == ["/0/0", "/0/0"] + assert desc.testnet == True + assert desc.m_path_base == ["m/48'/1'/0'/2'", None] + assert desc.m_path == ["m/48'/1'/0'/2'/0/0", None] + + +def test_parse_multisig_descriptor_with_origin_nested(): + desc = Descriptor.parse( + "sh(wsh(multi(2,[00000001/48'/1'/0'/1']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48'/1'/0'/1']tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0)))", + True, + ) + assert desc is not None + assert desc.wsh == None + assert desc.sh_wsh == True + assert desc.origin_fingerprint == ["00000001", "00000002"] + assert desc.origin_path == ["/48'/1'/0'/1'", "/48'/1'/0'/1'"] + assert desc.base_key == [ + "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B", + "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty", + ] + assert desc.path_suffix == ["/0/0", "/0/0"] + assert desc.testnet == True + assert desc.m_path_base == ["m/48'/1'/0'/1'", "m/48'/1'/0'/1'"] + assert desc.m_path == ["m/48'/1'/0'/1'/0/0", "m/48'/1'/0'/1'/0/0"] + + +def test_parse_descriptor_without_origin(): + desc = Descriptor.parse( + "wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", + True, + ) + assert desc is not None + assert desc.wpkh == True + assert desc.sh_wpkh == None + assert desc.origin_fingerprint == None + assert desc.origin_path == None + assert ( + desc.base_key + == "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + ) + assert desc.path_suffix == "/0/0" + assert desc.testnet == True + assert desc.m_path == None + + +def test_parse_descriptor_with_origin_fingerprint_only(): + desc = Descriptor.parse( + "wpkh([00000001]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", + True, + ) + assert desc is not None + assert desc.wpkh == True + assert desc.sh_wpkh == None + assert desc.origin_fingerprint == "00000001" + assert desc.origin_path == "" + assert ( + desc.base_key + == "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B" + ) + assert desc.path_suffix == "/0/0" + assert desc.testnet == True + assert desc.m_path == None + + +def test_parse_descriptor_with_key_at_end_with_origin(): + desc = Descriptor.parse( + "wpkh([00000001/84'/1'/0'/0/0]0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)", + True, + ) + assert desc is not None + assert desc.wpkh == True + assert desc.sh_wpkh == None + assert desc.origin_fingerprint == "00000001" + assert desc.origin_path == "/84'/1'/0'/0/0" + assert ( + desc.base_key + == "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7" + ) + assert desc.path_suffix == None + assert desc.testnet == True + assert desc.m_path == "m/84'/1'/0'/0/0" + + +def test_parse_descriptor_with_key_at_end_without_origin(): + desc = Descriptor.parse( + "wpkh(0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)", True + ) + assert desc is not None + assert desc.wpkh == True + assert desc.sh_wpkh == None + assert desc.origin_fingerprint == None + assert desc.origin_path == None + assert ( + desc.base_key + == "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7" + ) + assert desc.path_suffix == None + assert desc.testnet == True + assert desc.m_path == None + + +def test_parse_empty_descriptor(): + desc = Descriptor.parse("", True) + assert desc is None + + +def test_parse_descriptor_replace_h(): + desc = Descriptor.parse( + "wpkh([00000001/84h/1h/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", + True, + ) + assert desc is not None + assert desc.origin_path == "/84'/1'/0'" + + +def test_serialize_descriptor_with_origin(): + descriptor = "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)#mz20k55p" + desc = Descriptor.parse(descriptor, True) + assert desc.serialize() == descriptor + + +def test_serialize_descriptor_without_origin(): + descriptor = "wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)#ac0p4yhq" + desc = Descriptor.parse(descriptor, True) + assert desc.serialize() == descriptor + + +def test_serialize_descriptor_with_key_at_end_with_origin(): + descriptor = "wpkh([00000001/84'/1'/0'/0/0]0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)#rh7p6vk2" + desc = Descriptor.parse(descriptor, True) + assert desc.serialize() == descriptor + + +def test_parse_descriptor_multi_error(): + with pytest.raises(SpecterError) as excinfo: + Descriptor.parse( + "wsh(multi(3,[00000001/48'/1'/0'/2']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/*,[00000002/48'/1'/0'/2']tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))" + ) + assert ( + str(excinfo.value) + == "Multisig threshold cannot be larger than the number of keys. Threshold is 3 but only 2 keys specified." + ) + + +def test_checksums(): + # Correct checksum + descriptor_ex_checksum = "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))" + assert DescriptorChecksum(descriptor_ex_checksum) == "tjg09x5t" + + # Empty checksum + with pytest.raises(SpecterError) as excinfo: + Descriptor.parse( + "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#" + ) + assert str(excinfo.value) == "Checksum is empty." + + # Checksum too long + with pytest.raises(SpecterError) as excinfo: + Descriptor.parse( + "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5tq" + ) + assert ( + str(excinfo.value) + == "Checksum tjg09x5tq doesn't have the correct length. Should be 8 characters not 9." + ) + + # Checksum too short + with pytest.raises(SpecterError) as excinfo: + Descriptor.parse( + "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5" + ) + assert ( + str(excinfo.value) + == "Checksum tjg09x5 doesn't have the correct length. Should be 8 characters not 7." + ) + + # Error in checksum + with pytest.raises(SpecterError) as excinfo: + Descriptor.parse( + "sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjq09x4t" + ) + assert str(excinfo.value) == "tjq09x4t is the wrong checkum should be tjg09x5t." + + +### Tests of additional descriptor functionality in Specter + + +def test_derive_regtest(): + # Using ghost x 11 + machine (1) + # abandon x 11 + about (2) + # and zoo x 11 + wrong (3) + # /48'/1'/0'/1' + # Using iancoleman to get tpubs + + desc = Descriptor.parse( + "sh(wsh(sortedmulti(2,tpubDDzWqfZ5TH4819JtJT1MaJGh2FYnbn2KGoqkznXRFdNZAuKLD2CsYtQiV5rEVCUezzz9GaRkeHct5NSxVEG9KWUaRoeEtcafVHr2SVE5DRN/*,tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/*,tpubDFPtPArj4GzBBcuqDySkeQbKx4r6HwRgcPbbAjbjB5cxYRzJT6iFtiqzce4qQ9XFWZ83DZJ43WCJJsotdG75p7pw4SgUHZ2nkG4YxLQ414i/2)))", + True, + ) + + assert ( + desc.derive(1).serialize() + == "sh(wsh(sortedmulti(2,03407a711574ae73aa5824f5a66bf4f9a9f49dd274407eb4c27d996019cf4a6552,0376166abb71efb6c9a497a64c1b24c484c29b8a4219a526737e1f370768b1bbe8,03a1427c178f0b1cd679464c4c90444bcfd775d5edb89cf33828170e8be008d921)))#j54e69dq" + ) + # pubkeys cross-checked with iancoleman + + assert desc.address(5) == "2NGamwat67EUkABbkY1HLYjQW3oumUcThnV" + # Verified with bitcoin-cli deriveaddresses + + assert ( + desc.derive(1, keep_xpubs=True).serialize() + == "sh(wsh(sortedmulti(2,tpubDFPtPArj4GzBBcuqDySkeQbKx4r6HwRgcPbbAjbjB5cxYRzJT6iFtiqzce4qQ9XFWZ83DZJ43WCJJsotdG75p7pw4SgUHZ2nkG4YxLQ414i/2,tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/1,tpubDDzWqfZ5TH4819JtJT1MaJGh2FYnbn2KGoqkznXRFdNZAuKLD2CsYtQiV5rEVCUezzz9GaRkeHct5NSxVEG9KWUaRoeEtcafVHr2SVE5DRN/1)))#3mfhjamz" + ) + + +def test_derive_main(): + # Using ghost x 11 + machine (1) + # abandon x 11 + about (2) + # and zoo x 11 + wrong (3) + # /48'/0'/0'/1' + # Using iancoleman to get xpubs + + desc = Descriptor.parse( + "sh(wsh(sortedmulti(2,xpub6DiXipxEgSYqTw3xX2apub7vzsC5gBzmikxriTRnfKRKQjUSpiGQ9XzyFktkVLTGVGF5emH8up1qtsyw726rvnmzHRU8cHH8gDxeLMXSkYE/*,xpub6DkFAXWQ2dHxnMKoSBogHrw1rgNJKR4umdbnNVNTYeCGcduxWnNUHgGptqEQWPKRmeW4Zn4FHSbLMBKEWYaMDYu47Ytg6DdFnPNt8hwn5mE/*,xpub6FHZCoNb3tg3mxjcXsQx1xLpNmod6woECf2fB4nQbe9NXbvha2ucpDpnGbTFF68KUMUr1hNQ9E5jVEvpT2kUkVmFVDrJawcbgXzDpJc2hkF/2)))", + True, + ) + + assert ( + desc.derive(1).serialize() + == "sh(wsh(sortedmulti(2,029dfee2aaa23e2220476c34eda9a76591c1257f8dfce54e42ff014f922ede0838,03151d5b21c6491915e7a103bff913b4d85246c8209a342bb7104850e4cb394686,03646d8e624fedb63739e7963d0c7ad368a7f7935557b2b28c4c954882b19fe6e1)))#rzmdthwy" + ) + # pubkeys cross-checked with iancoleman + + assert desc.address(5, "main") == "388fc825v9R6Ev8BKodXQMFumQRe7C8SZ5" + # Verified with bitcoin-cli deriveaddresses + + assert ( + desc.derive(1, keep_xpubs=True).serialize() + == "sh(wsh(sortedmulti(2,xpub6DkFAXWQ2dHxnMKoSBogHrw1rgNJKR4umdbnNVNTYeCGcduxWnNUHgGptqEQWPKRmeW4Zn4FHSbLMBKEWYaMDYu47Ytg6DdFnPNt8hwn5mE/1,xpub6DiXipxEgSYqTw3xX2apub7vzsC5gBzmikxriTRnfKRKQjUSpiGQ9XzyFktkVLTGVGF5emH8up1qtsyw726rvnmzHRU8cHH8gDxeLMXSkYE/1,xpub6FHZCoNb3tg3mxjcXsQx1xLpNmod6woECf2fB4nQbe9NXbvha2ucpDpnGbTFF68KUMUr1hNQ9E5jVEvpT2kUkVmFVDrJawcbgXzDpJc2hkF/2)))#mgjhd0rk" + ) + + +def test_sort(): + descriptor = "sh(wsh(multi(2,tpubDDzWqfZ5TH4819JtJT1MaJGh2FYnbn2KGoqkznXRFdNZAuKLD2CsYtQiV5rEVCUezzz9GaRkeHct5NSxVEG9KWUaRoeEtcafVHr2SVE5DRN/*,tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/*,tpubDFPtPArj4GzBBcuqDySkeQbKx4r6HwRgcPbbAjbjB5cxYRzJT6iFtiqzce4qQ9XFWZ83DZJ43WCJJsotdG75p7pw4SgUHZ2nkG4YxLQ414i/2)))" + assert ( + sort_descriptor(descriptor, 1) + == "sh(wsh(multi(2,tpubDFPtPArj4GzBBcuqDySkeQbKx4r6HwRgcPbbAjbjB5cxYRzJT6iFtiqzce4qQ9XFWZ83DZJ43WCJJsotdG75p7pw4SgUHZ2nkG4YxLQ414i/2,tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/1,tpubDDzWqfZ5TH4819JtJT1MaJGh2FYnbn2KGoqkznXRFdNZAuKLD2CsYtQiV5rEVCUezzz9GaRkeHct5NSxVEG9KWUaRoeEtcafVHr2SVE5DRN/1)))#w5qd99tr" + ) diff --git a/tests/test_util_xpub.py b/tests/test_util_xpub.py new file mode 100644 index 000000000..ec3f2240c --- /dev/null +++ b/tests/test_util_xpub.py @@ -0,0 +1,43 @@ +import pytest + +from cryptoadvance.specter.util.xpub import ( + convert_xpub_prefix, + get_xpub_fingerprint, +) + +### Tests for xpub + + +def test_convert_to_ypub(ghost_machine_xpub_49, ghost_machine_ypub): + new_prefix = b"\x04\x9d\x7c\xb2" + assert convert_xpub_prefix(ghost_machine_xpub_49, new_prefix) == ghost_machine_ypub + + +def test_convert_to_zpub(ghost_machine_xpub_84, ghost_machine_zpub): + new_prefix = b"\x04\xb2\x47\x46" + assert convert_xpub_prefix(ghost_machine_xpub_84, new_prefix) == ghost_machine_zpub + + +def test_convert_ypub_back(ghost_machine_ypub, ghost_machine_xpub_49): + new_prefix = b"\x04\x88\xb2\x1e" + assert convert_xpub_prefix(ghost_machine_ypub, new_prefix) == ghost_machine_xpub_49 + + +def test_convert_zpub_back(ghost_machine_zpub, ghost_machine_xpub_84): + new_prefix = b"\x04\x88\xb2\x1e" + assert convert_xpub_prefix(ghost_machine_zpub, new_prefix) == ghost_machine_xpub_84 + + +def test_convert_to_upub(ghost_machine_tpub_49, ghost_machine_upub): + new_prefix = b"\x04\x4a\x52\x62" + assert convert_xpub_prefix(ghost_machine_tpub_49, new_prefix) == ghost_machine_upub + + +def test_convert_to_vpub(ghost_machine_tpub_84, ghost_machine_vpub): + new_prefix = b"\x04\x5f\x1c\xf6" + assert convert_xpub_prefix(ghost_machine_tpub_84, new_prefix) == ghost_machine_vpub + + +def test_get_xpub_fingerprint(ghost_machine_xpub_44): + # fingerprint from https://jlopp.github.io/xpub-converter/ + assert get_xpub_fingerprint(ghost_machine_xpub_44).hex() == "81f802e3"