Skip to content

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

Merged
sedited merged 6 commits into
bitcoin:masterfrom
achow101:sethdseed-void-descriptor
May 14, 2026
Merged

wallet: addhdkey RPC to add just keys to wallets via new unused(KEY) descriptor#29136
sedited merged 6 commits into
bitcoin:masterfrom
achow101:sethdseed-void-descriptor

Conversation

@achow101

@achow101 achow101 commented Dec 22, 2023

Copy link
Copy Markdown
Member

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 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: #26728 (comment)

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

@DrahtBot

DrahtBot commented Dec 22, 2023

Copy link
Copy Markdown
Contributor

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/29136.

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK Sjors, rkrux
Concept ACK fjahr

If your review is incorrectly listed, please copy-paste <!--meta-tag:bot-skip--> into the comment that the bot should ignore.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #bitcoin-core/gui/872 (Menu action to export a watchonly wallet by achow101)
  • #35092 (wallet: stop materializing skipped descriptor addresses after high-index detection by takeshikurosawaa)
  • #34861 (wallet: Add importdescriptors interface by polespinasa)
  • #33392 (wallet, rpc: add UTXO set check and incremental rescan to importdescriptors by musaHaruna)
  • #32861 (Have createwalletdescriptor auto-detect an unused(KEY) by Sjors)
  • #32489 (wallet: Add exportwatchonlywallet RPC to export a watchonly version of a wallet by achow101)
  • #31668 (Added rescan option for import descriptors by saikiran57)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

LLM Linter (✨ experimental)

Possible places where named args for integral literals may be used (e.g. func(x, /*named_arg=*/0) in C++, and func(x, named_arg=0) in Python):

  • Parse(desc_str, keys, error, false) in src/wallet/rpc/wallet.cpp
  • WalletDescriptor(std::move(descs.at(0)), GetTime(), 0, 0, 0) in src/wallet/rpc/wallet.cpp

2026-04-29 22:16:01

@ryanofsky

ryanofsky commented Dec 23, 2023

Copy link
Copy Markdown
Contributor

This is great, amazed you could implement this so quickly!

I was thinking about the void(KEY) idea more today, and think it would be good to make 3 tweaks:

1 - rename "void(KEY)" to "unused(KEY)"
2 - add logic to importdescriptors and generatewalletdescriptor to delete any unused(KEY) descriptor as soon as the KEY which it references is used by an new descriptor (taking into account both public and private parts of the key)
3 - add logic to importdescriptors to disallow importing an unused(KEY) if KEY is used by another descriptor.

I think these changes would help make these descriptors easier to understand and also enhance backwards compatibility, because IIUC wallets containing unknown descriptor types can't be opened by older software. Also keeping redundant descriptors in the wallet that were only temporarily needed is confusing.

If making these tweaks isn't possible or is not a good idea. I still think probably we should rename void(KEY) to data(KEY) or something like that. I think while "void" makes a certain amount of sense as programming jargon, it's not really a familiar term and doesn't describe the purpose of these descriptors, which is just to hold an inert piece of data, and not be used for generating or matching scriptpubkeys.

I also have a number of ideas to improve the RPC interface for generating keys and descriptors, and will try to post them soon. (EDIT: now posted #29130 (comment)). But this seems like a very good start and very functional.

@achow101

achow101 commented Jan 4, 2024

Copy link
Copy Markdown
Member Author

1 - rename "void(KEY)" to "unused(KEY)"
3 - add logic to importdescriptors to disallow importing an unused(KEY) if KEY is used by another descriptor.

Done these. I've also added a further restriction that unused() cannot be import to a wallet without private keys. It isn't useful in such wallets so I think it makes sense to disallow their import.

2 - add logic to importdescriptors and generatewalletdescriptor to delete any unused(KEY) descriptor as soon as the KEY which it references is used by an new descriptor (taking into account both public and private parts of the key)

Still working and thinking on this. However we've generally held to the policy of not deleting any records from the wallet since that could result in private keys being accidentally deleted. The only exception to that was adding encryption.

@ryanofsky

Copy link
Copy Markdown
Contributor

However we've generally held to the policy of not deleting any records from the wallet since that could result in private keys being accidentally deleted. The only exception to that was adding encryption.

Oh, I didn't know that but it makes sense.

One approach you could take would just be to delete the descriptor from memory without actually deleting it from the database, and ignore it the next time the wallet is loaded. But a drawback of this would be that once addhdkey was used the wallet would be forever incompatible with older versions of the software, when one of the benefits of deleting the descriptor was to make wallets backward compatible.

Another approach that might be acceptable could be to add a DatabaseBatch::MarkErased method to call instead of DatabaseBatch::Erase. This could just prepend a serialized string like const std::string TOMBSTONE{"tombstone"} to the key and otherwise leave the record unchanged.

Or maybe just decide in this case that it is ok to delete a record after verifying all the information it contains is present in other records.

Would also want to think about it more, though.

@Sjors

Sjors commented Jan 5, 2026

Copy link
Copy Markdown
Member

re-utACK 6928cad

Just a rebase.

@DrahtBot DrahtBot requested a review from rkrux January 5, 2026 03:21

@rkrux rkrux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-ACK 6928cad

The range diff looks fine to me - there are few function moving changes here and there that are due to rebase I suppose.

git range-diff 439734e...6928cad

@fjahr fjahr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK

Looks already pretty good to me.

Comment thread src/script/descriptor.cpp Outdated
@Sjors

Sjors commented Apr 28, 2026

Copy link
Copy Markdown
Member

This probably needs a rebase due to RPCHelpMan being renamed in #34049.

@achow101

Copy link
Copy Markdown
Member Author

rebased

@achow101 achow101 force-pushed the sethdseed-void-descriptor branch from 23f5529 to a39cc16 Compare April 29, 2026 22:15
@Sjors

Sjors commented May 1, 2026

Copy link
Copy Markdown
Member

utACK a39cc16

@DrahtBot DrahtBot requested a review from fjahr May 1, 2026 11:16

@rkrux rkrux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm ACK a39cc16

Looking forward to the PRs that are in draft and dependent on this one.

def skip_test_if_missing_module(self):
self.skip_if_no_wallet()

def test_addhdkey(self):

@rkrux rkrux May 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In f3f8bcb "wallet: Add addhdkey RPC"

  • unload wallets after their use is done
  • asserting both the unused desc in listdescriptors RPC
  • calling the RPCs with private option

Can be done in follow-up.

Functional Test Diff
diff --git a/test/functional/wallet_hd.py b/test/functional/wallet_hd.py
index e5227e6f49..548d6b0575 100755
--- a/test/functional/wallet_hd.py
+++ b/test/functional/wallet_hd.py
@@ -40,6 +40,7 @@ class WalletHDTest(BitcoinTestFramework):
         assert_equal(len(xpub_info), 2)
         for x in xpub_info:
             if len(x["descriptors"]) == 1 and x["descriptors"][0]["desc"].startswith("unused("):
+                unused_desc_pub_1 = x["descriptors"][0]["desc"]
                 break
         else:
             assert False, "Did not find HD key with no descriptors"
@@ -50,32 +51,38 @@ class WalletHDTest(BitcoinTestFramework):
 
         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})")
+        unused_desc_pub_2 = 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)
+                assert_equal(x["descriptors"][0]["desc"], unused_desc_pub_2)
                 break
         else:
             assert False, "Added HD key was not found in wallet"
 
+        unused_descs_num = 0
         for d in wallet.listdescriptors()["descriptors"]:
-            if d["desc"] == expected_unused_desc:
+            if d["desc"] == unused_desc_pub_1 or d["desc"] == unused_desc_pub_2:
                 assert_equal(d["active"], False)
-                break
-        else:
-            assert False, "Added HD key's descriptor was not found in wallet"
+                unused_descs_num += 1
+        assert_equal(unused_descs_num, 2)
+
+        # Calling the same RPCs with private=True should not fail
+        wallet.gethdkeys(private=True)
+        wallet.listdescriptors(private=True)
 
         assert_raises_rpc_error(-4, "HD key already exists", wallet.addhdkey, imp_xprv)
+        wallet.unloadwallet()
 
     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)
+        wallet.unloadwallet()
 
     def run_test(self):
         # Make sure we use hd, keep masterkeyid

@sedited sedited merged commit cad5f56 into bitcoin:master May 14, 2026
27 checks passed
@Sjors

Sjors commented May 14, 2026

Copy link
Copy Markdown
Member

I just realized the feedback in #32861 (review) was for this PR, so I implemented both suggestions in #32861.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants