Merge bitcoin/bitcoin#29136: wallet: addhdkey RPC to add just keys to wallets via new unused(KEY) descriptor

a39cc16b43 doc: Release note for addhdkey (Ava Chow)
89b9a01b4e wallet, rpc: Disallow importing unused() to wallets without privkeys (Ava Chow)
35bbee6374 wallet, rpc: Disallow import of unused() if key already exists (Ava Chow)
f3f8bcbd1d wallet: Add addhdkey RPC (Ava Chow)
82bc280de4 test: Simple test for importing unused(KEY) (Ava Chow)
80c29bc6f1 descriptor: Add unused(KEY) descriptor (Ava Chow)

Pull request description:

  It is sometimes useful for the wallet to have keys that it can sign with but are not (initially) involved in any scripts, e.g. for setting up a multisig. Ryanofsky [suggested](https://github.com/bitcoin/bitcoin/pull/26728#issuecomment-1867721948) A `unused(KEY)` descriptor which allows for a key to be specified, but produces no scripts. These can be imported into the wallet, and subsequently retrieved with `gethdkeys`. Additionally, `listdescriptors` will output these descriptors so that they can be easily backed up.

  In order to make it easier for people to add HD keys to their wallet, and to generate a new one if they want to rotate their descriptors, an `addhdkey` RPC is also added. Without arguments, it will generate a new HD key and add it to the wallet via a `unused(KEY)` descriptor. If provided a private key, it will construct the descriptor and add it to the wallet.

  See also: https://github.com/bitcoin/bitcoin/pull/26728#issuecomment-1866961865

  Based on #29130 as `gethdkeys` is useful for testing this.

ACKs for top commit:
  Sjors:
    utACK a39cc16
  rkrux:
    lgtm ACK a39cc16b43

Tree-SHA512: c1288c792ab01ca2eaddd24b0e7d11c259cd59e79042465d0d1eb656fd559c1200dc19750b4d84acc762b5b599935a06df214c18226e662087842ea91ec3011b
This commit is contained in:
merge-script
2026-05-14 11:40:16 +02:00
10 changed files with 322 additions and 3 deletions

View File

@@ -7,10 +7,12 @@
import shutil
from test_framework.blocktools import COINBASE_MATURITY
from test_framework.descriptors import descsum_create
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
wallet_importprivkey,
assert_raises_rpc_error,
)
@@ -25,6 +27,56 @@ class WalletHDTest(BitcoinTestFramework):
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def test_addhdkey(self):
self.log.info("Test addhdkey")
def_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
self.nodes[0].createwallet("hdkey")
wallet = self.nodes[0].get_wallet_rpc("hdkey")
assert_equal(len(wallet.gethdkeys()), 1)
wallet.addhdkey()
xpub_info = wallet.gethdkeys()
assert_equal(len(xpub_info), 2)
for x in xpub_info:
if len(x["descriptors"]) == 1 and x["descriptors"][0]["desc"].startswith("unused("):
break
else:
assert False, "Did not find HD key with no descriptors"
imp_xpub_info = def_wallet.gethdkeys(private=True)[0]
imp_xpub = imp_xpub_info["xpub"]
imp_xprv = imp_xpub_info["xprv"]
assert_raises_rpc_error(-5, "Extended public key (xpub) provided, but extended private key (xprv) is required", wallet.addhdkey, imp_xpub)
add_res = wallet.addhdkey(imp_xprv)
expected_unused_desc = descsum_create(f"unused({imp_xpub})")
assert_equal(add_res["xpub"], imp_xpub)
xpub_info = wallet.gethdkeys()
assert_equal(len(xpub_info), 3)
for x in xpub_info:
if x["xpub"] == imp_xpub:
assert_equal(len(x["descriptors"]), 1)
assert_equal(x["descriptors"][0]["desc"], expected_unused_desc)
break
else:
assert False, "Added HD key was not found in wallet"
for d in wallet.listdescriptors()["descriptors"]:
if d["desc"] == expected_unused_desc:
assert_equal(d["active"], False)
break
else:
assert False, "Added HD key's descriptor was not found in wallet"
assert_raises_rpc_error(-4, "HD key already exists", wallet.addhdkey, imp_xprv)
def test_addhdkey_noprivs(self):
self.log.info("Test addhdkey is not available for wallets without privkeys")
self.nodes[0].createwallet("hdkey_noprivs", disable_private_keys=True)
wallet = self.nodes[0].get_wallet_rpc("hdkey_noprivs")
assert_raises_rpc_error(-4, "addhdkey is not available for wallets without private keys", wallet.addhdkey)
def run_test(self):
# Make sure we use hd, keep masterkeyid
hd_fingerprint = self.nodes[1].getaddressinfo(self.nodes[1].getnewaddress())['hdmasterfingerprint']
@@ -124,6 +176,8 @@ class WalletHDTest(BitcoinTestFramework):
assert_equal(keypath[0:14], "m/84h/1h/0h/1/")
self.test_addhdkey()
self.test_addhdkey_noprivs()
if __name__ == '__main__':
WalletHDTest(__file__).main()

View File

@@ -65,6 +65,59 @@ class ImportDescriptorsTest(BitcoinTestFramework):
assert_equal(result[0]['error']['code'], error_code)
assert_equal(result[0]['error']['message'], error_message)
def test_import_unused_key(self):
self.log.info("Test import of unused(KEY)")
self.nodes[0].createwallet(wallet_name="import_unused", blank=True)
wallet = self.nodes[0].get_wallet_rpc("import_unused")
assert_equal(len(wallet.gethdkeys()), 0)
xprv = "tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg"
xpub = "tpubD6NzVbkrYhZ4YNXVQbNhMK1WqguFsUXceaVJKbmno2aZ3B6QfbMeraaYvnBSGpV3vxLyTTK9DYT1yoEck4XUScMzXoQ2U2oSmE2JyMedq3H"
self.test_importdesc({"desc":descsum_create(f"unused({xpub})"),
"timestamp": "now"},
success=False,
error_code=-4,
error_message='Cannot import descriptor without private keys to a wallet with private keys enabled',
wallet=wallet)
self.test_importdesc({"timestamp": "now", "desc": descsum_create(f"unused({xprv})")},
success=True,
wallet=wallet)
hdkeys = wallet.gethdkeys()
assert_equal(len(hdkeys), 1)
assert_equal(hdkeys[0]["xpub"], xpub)
wallet.unloadwallet()
def test_import_unused_key_existing(self):
self.log.info("Test import of unused(KEY) with existing KEY")
self.nodes[0].createwallet(wallet_name="import_existing_unused")
wallet = self.nodes[0].get_wallet_rpc("import_existing_unused")
hdkeys = wallet.gethdkeys(private=True)
assert_equal(len(hdkeys), 1)
xprv = hdkeys[0]["xprv"]
self.test_importdesc({"timestamp": "now", "desc": descsum_create(f"unused({xprv})")},
success=False,
error_code=-4,
error_message="Cannot import an unused() descriptor when its private key is already in the wallet",
wallet=wallet)
wallet.unloadwallet()
def test_import_unused_noprivs(self):
self.log.info("Test import of unused(KEY) to wallet without privkeys")
self.nodes[0].createwallet(wallet_name="import_unused_noprivs", disable_private_keys=True)
wallet = self.nodes[0].get_wallet_rpc("import_unused_noprivs")
xpub = "tpubD6NzVbkrYhZ4YNXVQbNhMK1WqguFsUXceaVJKbmno2aZ3B6QfbMeraaYvnBSGpV3vxLyTTK9DYT1yoEck4XUScMzXoQ2U2oSmE2JyMedq3H"
self.test_importdesc({"timestamp": "now", "desc": descsum_create(f"unused({xpub})")},
success=False,
error_code=-4,
error_message="Cannot import unused() to wallet without private keys enabled",
wallet=wallet)
wallet.unloadwallet()
def run_test(self):
self.log.info('Setting up wallets')
self.nodes[0].createwallet(wallet_name='w0', disable_private_keys=False)
@@ -822,5 +875,9 @@ class ImportDescriptorsTest(BitcoinTestFramework):
)
self.test_import_unused_key()
self.test_import_unused_key_existing()
self.test_import_unused_noprivs()
if __name__ == '__main__':
ImportDescriptorsTest(__file__).main()