diff --git a/HISTORY.md b/HISTORY.md index fa02c749..5e6afda3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,7 +9,7 @@ full year, short month, short day (YYYY-M-D) Major changes include: -- TBD +- add descriptor util functions ## v2023.5.30 diff --git a/btclib/descriptors.py b/btclib/descriptors.py new file mode 100644 index 00000000..625c280e --- /dev/null +++ b/btclib/descriptors.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2017-2022 The btclib developers +# +# This file is part of btclib. It is subject to the license terms in the +# LICENSE file found in the top-level directory of this distribution. +# +# No part of btclib including this file, may be copied, modified, propagated, +# or distributed except according to the terms contained in the LICENSE file. +"""Descriptors util functions. + +BIP 380: https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki +""" + +from btclib.exceptions import BTClibValueError + +INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +GENERATOR = [0xF5DEE51989, 0xA9FDCA3312, 0x1BAB10E32D, 0x3706B1677A, 0x644D626FFD] + + +def __descsum_polymod(symbols): + chk = 1 + for value in symbols: + top = chk >> 35 + chk = (chk & 0x7FFFFFFFF) << 5 ^ value + for i in range(5): + chk ^= GENERATOR[i] if ((top >> i) & 1) else 0 + return chk + + +def __descsum_expand(descriptor_string: str): + """Perform the character to symbol expansion.""" + groups = [] + symbols = [] + for char in descriptor_string: + if char not in INPUT_CHARSET: + raise BTClibValueError() + index = INPUT_CHARSET.find(char) + symbols.append(index & 31) + groups.append(index >> 5) + if len(groups) == 3: + symbols.append(groups[0] * 9 + groups[1] * 3 + groups[2]) + groups = [] + if len(groups) == 1: + symbols.append(groups[0]) + elif len(groups) == 2: + symbols.append(groups[0] * 3 + groups[1]) + return symbols + + +def descriptor_checksum(descriptor: str) -> str: + """Compute the descriptor checksum.""" + symbols = __descsum_expand(descriptor) + [0, 0, 0, 0, 0, 0, 0, 0] + checksum = __descsum_polymod(symbols) ^ 1 + return "".join(CHECKSUM_CHARSET[(checksum >> (5 * (7 - i))) & 31] for i in range(8)) + + +def descriptor_from_address(address: str) -> str: + descriptor = f"addr({address})" + return f"{descriptor}#{descriptor_checksum(descriptor)}" diff --git a/tests/_data/descriptor_checksums.json b/tests/_data/descriptor_checksums.json new file mode 100644 index 00000000..1741596f --- /dev/null +++ b/tests/_data/descriptor_checksums.json @@ -0,0 +1,74 @@ +[ + { + "desc": "pk(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)", + "checksum": "gn28ywm7" + }, + { + "desc": "pkh(02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5)", + "checksum": "8fhd9pwu" + }, + { + "desc": "wpkh(02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9)", + "checksum": "8zl0zxma" + }, + { + "desc": "sh(wpkh(03fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556))", + "checksum": "qkrrc7je" + }, + { + "desc": "combo(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)", + "checksum": "lq9sf04s" + }, + { + "desc": "sh(wsh(pkh(02e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13)))", + "checksum": "2wtr0ej5" + }, + { + "desc": "multi(1,022f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4,025cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc)", + "checksum": "hzhjw406" + }, + { + "desc": "sh(multi(2,022f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01,03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe))", + "checksum": "y9zthqta" + }, + { + "desc": "sh(sortedmulti(2,03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe,022f01e5e15cca351daff3843fb70f3c2f0a1bdd05e5af888a67784ef3e10a2a01))", + "checksum": "qwx6n9lh" + }, + { + "desc": "wsh(multi(2,03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7,03774ae7f858a9411e5ef4246b70c65aac5649980be5c17891bbec17895da008cb,03d01115d548e7561b15c38f004d734633687cf4419620095bc5b0f47070afe85a))", + "checksum": "en3tu306" + }, + { + "desc": "sh(wsh(multi(1,03f28773c2d975288bc7d1d205c3748651b075fbc6610e58cddeeddf8f19405aa8,03499fdf9e895e719cfd64e67f07d38e3226aa7b63678949e6e49b241a60e823e4,02d7924d4f7d43ea965a465ae3095ff41131e5946f3c85f79e44adbcf8e27e080e)))", + "checksum": "ks05yr6p" + }, + { + "desc": "pk(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8)", + "checksum": "axav5m0j" + }, + { + "desc": "pkh(xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw/1/2)", + "checksum": "kczqajcv" + }, + { + "desc": "pkh([d34db33f/44'/0'/0']xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)", + "checksum": "ml40v0wf" + }, + { + "desc": "wsh(multi(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))", + "checksum": "t2zpj2eu" + }, + { + "desc": "wsh(sortedmulti(1,xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1/0/*,xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/0/0/*))", + "checksum": "v66cvalc" + }, + { + "desc": "tr(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,{pk(fff97bd5755eeea420453a14355235d382f6472f8568a18b2f057a1460297556),pk(e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13)})", + "checksum": "2rqrdjrh" + }, + { + "desc": "tr(c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5,sortedmulti_a(2,2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4,5cbdf0646e5db4eaa398f365f2ea7a0e3d419b7e0330e39ce92bddedcac4f9bc))", + "checksum": "tp09wjyq" + } +] diff --git a/tests/test_descriptors.py b/tests/test_descriptors.py new file mode 100644 index 00000000..59d31e7f --- /dev/null +++ b/tests/test_descriptors.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2017-2022 The btclib developers +# +# This file is part of btclib. It is subject to the license terms in the +# LICENSE file found in the top-level directory of this distribution. +# +# No part of btclib including this file, may be copied, modified, propagated, +# or distributed except according to the terms contained in the LICENSE file. +"""Tests for the `btclib.descriptors` module.""" + +import json +from pathlib import Path + +import pytest + +from btclib.descriptors import ( + __descsum_expand, + descriptor_checksum, + descriptor_from_address, +) +from btclib.exceptions import BTClibValueError + + +# descriptors taken from https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md +# checksum calculated using https://docs.rs/bdk/latest/bdk/descriptor/checksum/fn.get_checksum.html +def test_checksum(): + filename = Path(__file__).parent / "_data" / "descriptor_checksums.json" + with open(filename, encoding="utf-8") as file: + data = json.load(file) + + for descriptor_data in data: + descriptor = descriptor_data["desc"] + checksum = descriptor_data["checksum"] + assert descriptor_checksum(descriptor) == checksum + + +def test_invalid_charset(): + with pytest.raises(BTClibValueError): + __descsum_expand("รจ") + + +def test_addr(): + address = "bc1qnehtvnd4fedkwjq6axfgsrxgllwne3k58rhdh0" + descriptor = "addr(bc1qnehtvnd4fedkwjq6axfgsrxgllwne3k58rhdh0)#s2y3vepm" + assert descriptor_from_address(address) == descriptor