Files
bitcoin/test/functional/wallet_derivehdkey.py
Sjors Provoost d9570f0838 rpc: add derivehdkey
Add an UnusedKey filter to GetHDPubKeys() so the new RPC can prefer
unused(KEY) descriptors before falling back to active descriptors.

Co-authored-by: w0xlt <94266259+w0xlt@users.noreply.github.com>
2026-08-07 15:02:18 +02:00

236 lines
9.1 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2025-Present The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test wallet derivehdkey RPC."""
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_raises_rpc_error,
)
from test_framework.wallet_util import WalletUnlock
class WalletDeriveHDKeyTest(BitcoinTestFramework):
def set_test_params(self):
self.setup_clean_chain = True
self.num_nodes = 2
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def run_test(self):
self.test_basic_derivehdkey()
self.test_multiple_unused_keys()
self.test_active_descriptor()
self.test_inactive_descriptor()
self.test_noprivs_blank()
self.test_compare()
def test_basic_derivehdkey(self):
self.log.info("Test derivehdkey basics")
self.nodes[0].createwallet("basic", blank=True)
wallet = self.nodes[0].get_wallet_rpc("basic")
assert_raises_rpc_error(
-8,
"Derivation path requires at least one hardened step",
wallet.derivehdkey,
"m",
)
assert_raises_rpc_error(
-8,
"Derivation path requires at least one hardened step",
wallet.derivehdkey,
"m/87/0/0",
)
# A BIP32 keypath element must be in 0..2^31-1. The
# value 2^31 (2147483648 == 0x80000000) is out of range.
for path in ["m/2147483648", "m/2147483648h", "m/2147483648'"]:
assert_raises_rpc_error(
-8,
"Invalid BIP32 keypath",
wallet.derivehdkey,
path,
)
assert_raises_rpc_error(
-5,
"No active or unused(KEY) descriptor found",
wallet.derivehdkey,
"m/87h",
)
wallet.addhdkey()
xpub_info = wallet.derivehdkey("m/87h")
assert "xprv" not in xpub_info
xpub = xpub_info["xpub"]
root_fingerprint = wallet.derivehdkey("m/87h/0h")["origin"][1:9]
assert_equal(xpub_info["origin"], f"[{root_fingerprint}/87h]")
too_deep_path = "m/" + "/".join(["0h"] * 256)
assert_raises_rpc_error(
-8,
"Unable to derive HD key at the requested path",
wallet.derivehdkey,
too_deep_path,
)
xpub_info = wallet.derivehdkey("m/87h/0h/0h/0")
xpub_priv_info = wallet.derivehdkey("m/87h/0h/0h/0", private=True)
xprv = xpub_priv_info["xprv"]
assert_equal(xpub_priv_info["xpub"], xpub_info["xpub"])
self.log.info("HD pubkey can be retrieved from encrypted wallets")
prev_xprv = xprv
wallet.encryptwallet("pass")
assert_raises_rpc_error(
-13,
"Error: Please enter the wallet passphrase with walletpassphrase first",
wallet.derivehdkey,
"m/87h",
)
with WalletUnlock(wallet, "pass"):
xpub_info = wallet.derivehdkey("m/87h")
# Only automatically generated descriptors are rotated on
# encryption, unused(KEY) is not.
assert_equal(xpub_info["xpub"], xpub)
assert "xprv" not in xpub_info
self.log.info("HD privkey can be retrieved from encrypted wallets")
assert_raises_rpc_error(
-13,
"Error: Please enter the wallet passphrase with walletpassphrase first",
wallet.derivehdkey,
"m/87h/0h/0h/0",
private=True,
)
with WalletUnlock(wallet, "pass"):
xpub_info = wallet.derivehdkey("m/87h/0h/0h/0", private=True)
# Unused(KEY) is preferred over active descriptors and is not
# rotated on encryption.
assert_equal(xpub_info["xprv"], prev_xprv)
def test_multiple_unused_keys(self):
self.log.info("Test multiple unused(KEY) descriptors")
self.nodes[0].createwallet("multiple_unused_keys", blank=True)
wallet = self.nodes[0].get_wallet_rpc("multiple_unused_keys")
master_xpub_1 = wallet.addhdkey()['xpub']
master_xpub_2 = wallet.addhdkey()['xpub']
assert_raises_rpc_error(
-5,
"Unable to determine which HD key to use. Please specify with 'hdkey'",
wallet.derivehdkey,
"m/87h",
)
xpub_info_1 = wallet.derivehdkey("m/87h", hdkey=master_xpub_1)
xpub_info_2 = wallet.derivehdkey("m/87h", hdkey=master_xpub_2)
assert xpub_info_1["xpub"] != xpub_info_2["xpub"]
assert_raises_rpc_error(
-5,
"Unable to parse HD key. Please provide a valid xpub",
wallet.derivehdkey,
"m/87h",
hdkey="bad",
)
def test_active_descriptor(self):
self.log.info("Test derivation in wallet with regular descriptors")
self.nodes[0].createwallet("active_descriptor")
wallet = self.nodes[0].get_wallet_rpc("active_descriptor")
xpub_info = wallet.derivehdkey("m/44h/1h/0h")
active_xpub = wallet.gethdkeys(active_only=True)[0]["xpub"]
assert_equal(wallet.derivehdkey("m/44h/1h/0h", hdkey=active_xpub), xpub_info)
# Get the activate wpkh() receive descriptor
desc = list(filter(lambda d:
d["active"] and not d["internal"] and d["desc"][0:3] == "pkh",
wallet.listdescriptors()["descriptors"])
)[0]["desc"]
self.log.debug(desc)
assert(xpub_info["origin"] in desc)
assert(xpub_info["xpub"] in desc)
self.log.info("Test unused(KEY) descriptors are preferred over active descriptors")
master_xpub = wallet.addhdkey()["xpub"]
assert_equal(
wallet.derivehdkey("m/87h", hdkey=master_xpub)["xpub"],
wallet.derivehdkey("m/87h")["xpub"],
)
def test_inactive_descriptor(self):
self.log.info("Test that the HD key of a used inactive descriptor is rejected")
# Mint a spendable descriptor in a throwaway wallet, so we can import it
# into the wallet under test and deactivate it there.
self.nodes[0].createwallet(wallet_name="hdkey_source")
source = self.nodes[0].get_wallet_rpc("hdkey_source")
desc = next(d["desc"] for d in source.listdescriptors(private=True)["descriptors"]
if d["active"] and not d["internal"] and d["desc"].startswith("pkh("))
self.nodes[0].createwallet(wallet_name="inactive_descriptor", blank=True)
wallet = self.nodes[0].get_wallet_rpc("inactive_descriptor")
request = {"desc": desc, "range": [0, 9], "timestamp": "now", "internal": False}
assert_equal(wallet.importdescriptors([{**request, "active": True}])[0]["success"], True)
# While the descriptor is active its HD key can be used for derivation.
xpub = wallet.gethdkeys(active_only=True)[0]["xpub"]
wallet.derivehdkey("m/87h", hdkey=xpub)
# Once deactivated the descriptor is neither active nor unused(KEY), so
# its HD key is no longer a candidate.
assert_equal(wallet.importdescriptors([{**request, "active": False}])[0]["success"], True)
assert_raises_rpc_error(
-5,
"HD key is not used by an active or unused(KEY) descriptor",
wallet.derivehdkey,
"m/87h",
hdkey=xpub,
)
def test_noprivs_blank(self):
self.log.info("Test derivehdkey on wallet without private keys")
self.nodes[0].createwallet(wallet_name="noprivs", disable_private_keys=True)
wallet = self.nodes[0].get_wallet_rpc("noprivs")
assert_raises_rpc_error(
-4,
"derivehdkey is not available for watch-only wallets",
wallet.derivehdkey,
"m/87h",
)
self.log.info("Test derivehdkey on blank wallet")
self.nodes[0].createwallet(wallet_name="blank", blank=True)
wallet = self.nodes[0].get_wallet_rpc("blank")
assert_raises_rpc_error(
-5,
"No active or unused(KEY) descriptor found",
wallet.derivehdkey,
"m/87h",
)
def test_compare(self):
self.log.info(
"Compare with result of createwalletdescriptor")
self.nodes[0].createwallet(wallet_name="w1", blank=True)
wallet = self.nodes[0].get_wallet_rpc("w1")
master_xpub = wallet.addhdkey()["xpub"]
# Derive xpub for legacy descriptor
xpub_info = wallet.derivehdkey("m/44h/1h/0h")
# Generate legacy descriptor
wallet.createwalletdescriptor(type="legacy", hdkey=master_xpub)
# Get the activate wpkh() receive descriptor
desc = list(filter(lambda d:
d["active"] and not d["internal"] and d["desc"][0:3] == "pkh",
wallet.listdescriptors()["descriptors"])
)[0]["desc"]
self.log.debug(desc)
assert(xpub_info["origin"] in desc)
assert(xpub_info["xpub"] in desc)
if __name__ == '__main__':
WalletDeriveHDKeyTest(__file__).main()