Merge #117: Descriptor support for displayaddress
a71658cdisplayaddress: descriptor support, make --path a named argument (Sjors Provoost)163f926Add descriptor class (Sjors Provoost)6fc1524HardwareWalletClient: store fingerprint (Sjors Provoost) Pull request description: Adds a `Descriptor` class, which currently handles some of the descriptor functionality. Changes `displayaddress` to allow a descriptor argument. This means `--path` and `--desc` are now named arguments. When using a descriptor `displayaddress` performs additional checks to make sure the fingerprint and xpub match. To enable the fingerprint check, I'm storing the fingerprint on `HardwareWalletClient` instances. Tree-SHA512: f149481203c340b8e5347f42fd2924f1b2095197d6c25f39f94acfb11f430881412cce48c510260db4e773b662e8b2a0a747c413ba7fadfc6eb78a85f1ec4838
This commit is contained in:
commit
03b31bec00
@ -22,7 +22,7 @@ def backup_device_handler(args, client):
|
||||
return backup_device(client, label=args.label, backup_passphrase=args.backup_passphrase)
|
||||
|
||||
def displayaddress_handler(args, client):
|
||||
return displayaddress(client, path=args.path, sh_wpkh=args.sh_wpkh, wpkh=args.wpkh)
|
||||
return displayaddress(client, desc=args.desc, path=args.path, sh_wpkh=args.sh_wpkh, wpkh=args.wpkh)
|
||||
|
||||
def enumerate_handler(args):
|
||||
return enumerate(password=args.password)
|
||||
@ -102,7 +102,9 @@ def process_commands(args):
|
||||
getkeypool_parser.set_defaults(func=getkeypool_handler)
|
||||
|
||||
displayaddr_parser = subparsers.add_parser('displayaddress', help='Display an address')
|
||||
displayaddr_parser.add_argument('path', help='The BIP 32 derivation path of the key embedded in the address')
|
||||
group = displayaddr_parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument('--desc', help='Output Descriptor. E.g. wpkh([00000000/84h/0h/0h]xpub.../0/0), where 00000000 must match --fingerprint and xpub can be obtained with getxpub. See doc/descriptors.md in Bitcoin Core')
|
||||
group.add_argument('--path', help='The BIP 32 derivation path of the key embedded in the address, default follows BIP43 convention, e.g. m/84h/0h/0h/1/*')
|
||||
displayaddr_parser.add_argument('--sh_wpkh', action='store_true', help='Display the p2sh-nested segwit address associated with this key path')
|
||||
displayaddr_parser.add_argument('--wpkh', action='store_true', help='Display the bech32 version of the address associated with this key path')
|
||||
displayaddr_parser.set_defaults(func=displayaddress_handler)
|
||||
|
||||
@ -6,9 +6,10 @@ import glob
|
||||
import importlib
|
||||
|
||||
from .serializations import PSBT, Base64ToHex, HexToBase64, hash160
|
||||
from .base58 import get_xpub_fingerprint_as_id, get_xpub_fingerprint_hex
|
||||
from .base58 import get_xpub_fingerprint_as_id, get_xpub_fingerprint_hex, xpub_to_pub_hex
|
||||
from os.path import dirname, basename, isfile
|
||||
from .errors import NoPasswordError, UnavailableActionError, DeviceAlreadyInitError, DeviceAlreadyUnlockedError, UnknownDeviceError, BAD_ARGUMENT, NOT_IMPLEMENTED
|
||||
from .descriptor import Descriptor
|
||||
|
||||
# Get the client for the device
|
||||
def get_client(device_type, device_path, password=''):
|
||||
@ -58,6 +59,7 @@ def find_device(device_path, password='', device_type=None, fingerprint=None):
|
||||
client.close()
|
||||
continue
|
||||
else:
|
||||
client.fingerprint = master_fpr
|
||||
return client
|
||||
except:
|
||||
if client:
|
||||
@ -154,10 +156,25 @@ def getkeypool(client, path, start, end, internal=False, keypool=False, account=
|
||||
import_data.append(this_import)
|
||||
return import_data
|
||||
|
||||
def displayaddress(client, path, sh_wpkh=False, wpkh=False):
|
||||
if sh_wpkh == True and wpkh == True:
|
||||
return {'error':'Both `--wpkh` and `--sh_wpkh` can not be selected at the same time.','code':BAD_ARGUMENT}
|
||||
return client.display_address(path, sh_wpkh, wpkh)
|
||||
def displayaddress(client, path=None, desc=None, sh_wpkh=False, wpkh=False):
|
||||
if path is not None:
|
||||
if sh_wpkh == True and wpkh == True:
|
||||
return {'error':'Both `--wpkh` and `--sh_wpkh` can not be selected at the same time.','code':BAD_ARGUMENT}
|
||||
return client.display_address(path, sh_wpkh, wpkh)
|
||||
elif desc is not None:
|
||||
if sh_wpkh == True or wpkh == True:
|
||||
return {'error':' `--wpkh` and `--sh_wpkh` 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.m_path is None:
|
||||
return {'error':'Descriptor missing origin info: ' + desc,'code':BAD_ARGUMENT}
|
||||
if descriptor.origin_fingerprint != client.fingerprint:
|
||||
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)
|
||||
|
||||
def setup_device(client, label='', backup_passphrase=''):
|
||||
return client.setup_device(label, backup_passphrase)
|
||||
|
||||
77
hwilib/descriptor.py
Normal file
77
hwilib/descriptor.py
Normal file
@ -0,0 +1,77 @@
|
||||
import re
|
||||
|
||||
class Descriptor:
|
||||
def __init__(self, origin_fingerprint, origin_path, base_key, path_suffix, testnet, sh_wpkh, wpkh):
|
||||
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.m_path = None
|
||||
|
||||
if origin_path:
|
||||
self.m_path_base = "m" + origin_path
|
||||
self.m_path = "m" + origin_path + (path_suffix or "")
|
||||
|
||||
@classmethod
|
||||
def parse(cls, desc, testnet = False):
|
||||
sh_wpkh = None
|
||||
wpkh = None
|
||||
origin_fingerprint = None
|
||||
origin_path = None
|
||||
base_key_and_path_match = None
|
||||
base_key = None
|
||||
path_suffix = None
|
||||
|
||||
if desc.startswith("sh(wpkh("):
|
||||
sh_wpkh = True
|
||||
elif desc.startswith("wpkh("):
|
||||
wpkh = True
|
||||
|
||||
origin_match = re.search(r"\[(.*)\]", desc)
|
||||
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', '\'')
|
||||
|
||||
base_key_and_path_match = re.search(r"\[.*\](\w+)([\/\)][\d'\/\*]*)", desc)
|
||||
else:
|
||||
base_key_and_path_match = re.search(r"\((\w+)([\/\)][\d'\/\*]*)", desc)
|
||||
|
||||
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 == None:
|
||||
return None
|
||||
|
||||
return cls(origin_fingerprint, origin_path, base_key, path_suffix, testnet, sh_wpkh, wpkh)
|
||||
|
||||
|
||||
def serialize(self):
|
||||
descriptor_open = 'pkh('
|
||||
descriptor_close = ')'
|
||||
origin = ''
|
||||
path_suffix = ''
|
||||
|
||||
if self.wpkh == True:
|
||||
descriptor_open = 'wpkh('
|
||||
elif self.sh_wpkh == True:
|
||||
descriptor_open = 'sh(wpkh('
|
||||
descriptor_close = '))'
|
||||
|
||||
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 descriptor_open + origin + self.base_key + path_suffix + descriptor_close
|
||||
@ -8,6 +8,7 @@ class HardwareWalletClient(object):
|
||||
self.password = password
|
||||
self.message_magic = b"\x18Bitcoin Signed Message:\n"
|
||||
self.is_testnet = False
|
||||
self.fingerprint = None
|
||||
|
||||
# Get the master BIP 44 pubkey
|
||||
def get_master_xpub(self):
|
||||
|
||||
@ -7,6 +7,7 @@ import unittest
|
||||
|
||||
from test_bech32 import TestSegwitAddress
|
||||
from test_coldcard import coldcard_test_suite
|
||||
from test_descriptor import TestDescriptor
|
||||
from test_device import start_bitcoind
|
||||
from test_psbt import TestPSBT
|
||||
from test_trezor import trezor_test_suite
|
||||
@ -35,6 +36,7 @@ args = parser.parse_args()
|
||||
|
||||
# Run tests
|
||||
suite = unittest.TestSuite()
|
||||
suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestDescriptor))
|
||||
suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestSegwitAddress))
|
||||
suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestPSBT))
|
||||
|
||||
|
||||
80
test/test_descriptor.py
Normal file
80
test/test_descriptor.py
Normal file
@ -0,0 +1,80 @@
|
||||
#! /usr/bin/env python3
|
||||
|
||||
from hwilib.descriptor import Descriptor
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
def test_parse_empty_descriptor(self):
|
||||
desc = Descriptor.parse("", True)
|
||||
self.assertIsNone(desc)
|
||||
|
||||
def test_parse_descriptor_replace_h(self):
|
||||
desc = Descriptor.parse("wpkh([00000001/84h/1h/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True)
|
||||
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)"
|
||||
desc = Descriptor.parse(descriptor, True)
|
||||
self.assertEqual(desc.serialize(), descriptor)
|
||||
|
||||
def test_serialize_descriptor_without_origin(self):
|
||||
descriptor = "wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)"
|
||||
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)"
|
||||
desc = Descriptor.parse(descriptor, True)
|
||||
self.assertEqual(desc.serialize(), descriptor)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -9,6 +9,7 @@ import time
|
||||
import unittest
|
||||
|
||||
from bitcoinrpc.authproxy import AuthServiceProxy, JSONRPCException
|
||||
from hwilib.base58 import xpub_to_pub_hex
|
||||
from hwilib.cli import process_commands
|
||||
from hwilib.serializations import PSBT
|
||||
|
||||
@ -391,20 +392,55 @@ class TestDisplayAddress(DeviceTestCase):
|
||||
self.emulator.stop()
|
||||
|
||||
def test_display_address_bad_args(self):
|
||||
result = process_commands(self.dev_args + ['displayaddress', '--sh_wpkh', '--wpkh', 'm/49h/1h/0h/0/0'])
|
||||
result = process_commands(self.dev_args + ['displayaddress', '--sh_wpkh', '--wpkh', '--path', 'm/49h/1h/0h/0/0'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('code', result)
|
||||
self.assertEqual(result['code'], -7)
|
||||
|
||||
def test_display_address(self):
|
||||
process_commands(self.dev_args + ['displayaddress', 'm/44h/1h/0h/0/0'])
|
||||
process_commands(self.dev_args + ['displayaddress', '--sh_wpkh', 'm/49h/1h/0h/0/0'])
|
||||
process_commands(self.dev_args + ['displayaddress', '--wpkh', 'm/84h/1h/0h/0/0'])
|
||||
def test_display_address_path(self):
|
||||
process_commands(self.dev_args + ['displayaddress', '--path', 'm/44h/1h/0h/0/0'])
|
||||
process_commands(self.dev_args + ['displayaddress', '--sh_wpkh', '--path', 'm/49h/1h/0h/0/0'])
|
||||
process_commands(self.dev_args + ['displayaddress', '--wpkh', '--path', 'm/84h/1h/0h/0/0'])
|
||||
|
||||
def test_bad_path(self):
|
||||
result = process_commands(self.dev_args + ['displayaddress', 'f'])
|
||||
def test_display_address_bad_path(self):
|
||||
result = process_commands(self.dev_args + ['displayaddress', '--path', 'f'])
|
||||
self.assertEquals(result['code'], -7)
|
||||
|
||||
def test_display_address_descriptor(self):
|
||||
account_xpub = process_commands(self.dev_args + ['getxpub', 'm/84h/1h/0h'])['xpub']
|
||||
p2sh_segwit_account_xpub = process_commands(self.dev_args + ['getxpub', 'm/49h/1h/0h'])['xpub']
|
||||
legacy_account_xpub = process_commands(self.dev_args + ['getxpub', 'm/44h/1h/0h'])['xpub']
|
||||
|
||||
# Native SegWit address using xpub:
|
||||
process_commands(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h)]' + account_xpub + '/0/0)'])
|
||||
|
||||
# Native SegWit address using hex encoded pubkey:
|
||||
process_commands(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h)]' + xpub_to_pub_hex(account_xpub) + '/0/0)'])
|
||||
|
||||
# P2SH wrapped SegWit address using xpub:
|
||||
process_commands(self.dev_args + ['displayaddress', '--desc', 'sh(wpkh([' + self.fingerprint + '/49h/1h/0h)]' + p2sh_segwit_account_xpub + '/0/0))'])
|
||||
|
||||
# Legacy address
|
||||
process_commands(self.dev_args + ['displayaddress', '--desc', 'pkh([' + self.fingerprint + '/44h/1h/0h)]' + legacy_account_xpub + '/0/0)'])
|
||||
|
||||
# Should check xpub
|
||||
result = process_commands(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h)]' + "not_and_xpub" + '/0/0)'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('code', result)
|
||||
self.assertEqual(result['code'], -7)
|
||||
|
||||
# Should check hex pub
|
||||
result = process_commands(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h)]' + "not_and_xpub" + '/0/0)'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('code', result)
|
||||
self.assertEqual(result['code'], -7)
|
||||
|
||||
# Should check fingerprint
|
||||
process_commands(self.dev_args + ['displayaddress', '--desc', 'wpkh([00000000/84h/1h/0h)]' + account_xpub + '/0/0)'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('code', result)
|
||||
self.assertEqual(result['code'], -7)
|
||||
|
||||
class TestSignMessage(DeviceTestCase):
|
||||
def setUp(self):
|
||||
self.emulator.start()
|
||||
|
||||
@ -64,7 +64,7 @@ def digitalbitbox_test_suite(rpc, userpass, simulator):
|
||||
self.assertEqual(result['code'], -9)
|
||||
|
||||
def test_display(self):
|
||||
result = process_commands(self.dev_args + ['displayaddress', 'm/0h'])
|
||||
result = process_commands(self.dev_args + ['displayaddress', '--path', 'm/0h'])
|
||||
self.assertIn('error', result)
|
||||
self.assertIn('code', result)
|
||||
self.assertEqual(result['error'], 'The Digital Bitbox does not have a screen to display addresses on')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user