Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wallet: Add createwalletdescriptor and gethdkeys RPCs for adding new automatically generated descriptors #29130

Merged

Conversation

achow101
Copy link
Member

@achow101 achow101 commented Dec 22, 2023

This PR adds a createwalletdescriptor RPC which allows users to add new automatically generated descriptors to their wallet, e.g. to upgrade a 0.21.x wallet to contain a taproot descriptor. This RPC takes 3 arguments: the output type to create a descriptor for, whether the descriptor will be internal or external, and the HD key to use if the user wishes to use a specific key. The HD key is an optional parameter. If it is not specified, the wallet will use the key shared by the active descriptors, if they are all single key. For most users in the expected upgrade scenario, this should be sufficient. In more advanced cases, the user must specify the HD key to use.

Currently, specified HD keys must already exist in the wallet. To make it easier for the user to know, gethdkeys is also added to list out the HD keys in use by all of the descriptors in the wallet. This will include all HD keys, whether we have the private key, for it, which descriptors use it and their activeness, and optionally the extended private key. In this way, users with more complex wallets will be still be able to get HD keys from their wallet for use in other scenarios, and if they want to use createwalletdescriptor, they can easily get the keys that they can specify to it.

See also #26728 (comment)

@DrahtBot
Copy link
Contributor

DrahtBot commented Dec 22, 2023

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

Code Coverage

For detailed information about the code coverage, see the test coverage report.

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK furszy, Sjors, ryanofsky

If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #29675 (wallet: Be able to receive and spend inputs involving MuSig2 aggregate keys by achow101)
  • #29136 (wallet: addhdkey RPC to add just keys to wallets via new void(KEY) descriptor by achow101)
  • #28724 (wallet: Cleanup accidental encryption keys in watchonly wallets by achow101)
  • #28574 (wallet: optimize migration process, batch db transactions by furszy)
  • #28333 (wallet: Construct ScriptPubKeyMans with all data rather than loaded progressively by achow101)
  • #25979 ([WIP] wallet: standardize change output detection process by furszy)
  • #22838 (descriptors: Be able to specify change and receiving in a single descriptor string by achow101)

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.

@achow101 achow101 force-pushed the createwalletdescriptor-without-new-records branch 2 times, most recently from 7207185 to 3b7a264 Compare December 22, 2023 19:46
@achow101 achow101 force-pushed the createwalletdescriptor-without-new-records branch 2 times, most recently from e55e228 to 08b9b41 Compare December 22, 2023 22:11
Copy link
Contributor

@ryanofsky ryanofsky left a comment

Choose a reason for hiding this comment

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

Concept ACK 08b9b41. I think I'm maybe halfway through reviewing, but here are my comments so far.

src/wallet/scriptpubkeyman.cpp Outdated Show resolved Hide resolved
src/wallet/rpc/wallet.cpp Outdated Show resolved Hide resolved
"gethdkeys",
"\nList all BIP 32 HD keys in the wallet and which descriptors use them.\n",
{
{"active_only", RPCArg::Type::BOOL, RPCArg::Default{false}, "Show the keys for only active descriptors"},
Copy link
Contributor

Choose a reason for hiding this comment

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

In commit "wallet, rpc: Add gethdkeys RPC" (5deafad)

Note: This is better than what I suggested. I was originally thinking this default should be true, to make it more convenient to get active hd key and ignore other keys. But defaulting to false is actually better, because it's still easy to get the active hd key with an option, and it's probably more confusing to see missing keys than extra keys. Also this default makes gethdkeys output more consistent with listdescriptors output.

src/wallet/rpc/wallet.cpp Show resolved Hide resolved
src/wallet/rpc/wallet.cpp Outdated Show resolved Hide resolved
src/wallet/wallet.cpp Outdated Show resolved Hide resolved
src/wallet/scriptpubkeyman.h Outdated Show resolved Hide resolved
src/wallet/wallet.h Outdated Show resolved Hide resolved
src/wallet/wallet.cpp Outdated Show resolved Hide resolved
src/wallet/rpc/wallet.cpp Outdated Show resolved Hide resolved
@achow101 achow101 force-pushed the createwalletdescriptor-without-new-records branch 4 times, most recently from 8286b43 to e0ad5bc Compare December 23, 2023 02:32
};
}

static RPCHelpMan createwalletdescriptor()
Copy link
Contributor

@ryanofsky ryanofsky Dec 23, 2023

Choose a reason for hiding this comment

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

Since this PR is adding the createwalletdescriptor method, maybe this is a good place to list some ways it could be extended in the future:

  • Probably it would be good not to require wallet to be unlocked when dealing with public keys. Currently the specified hdkey is unencrypted and reencrypted, but this shouldn't be necessary because the key is already in the wallet. (The only reason this seems to happen now is to copy the key, because internally we store keys in a slightly denormalized format, once per descriptor.)

  • It would be nice if hdkey parameter accepted not just public hd keys, but also private hd keys, and a "generate" value in a blank wallet so users don't need to chain together multiple commands to accomplish simple tasks, and so we only need two RPC methods which add descriptors and keys to the wallet: importdescriptors and createwalletdescriptor, without a third addhdkey method. Examples:

    # In a blank wallet, generate an hd key and use it in a new descriptor
    createwalletdescriptor bech32 generate
    
     # In a blank wallet, import an hd key and use it in a new descriptor
    createwalletdescriptor bech32 xprv...
  • It sounds like we want to discourage having multiple hd keys per wallet, and encourage having separate wallets instead. But if we did want to allow these, we could add a force option to allow creating descriptors with new hd keys even when existing hd keys are present. We could also allow a rotate option to allow this and additionally set descriptors using old keys to be inactive.

  • The hdkey parameter could accept hd master keys in different formats, for example as seed keys like the current sethdseed method, or seed hex strings like #29054, or seed shares like #27351.

  • It would be nice if type parameter accepted a "defaults" value to set up descriptors for all default output types. Examples:

    # In a blank wallet, generate an hd key and generate default set of descriptors
    createwalletdescriptor defaults generate
    
    # Add missing descriptors to an existing wallet. For example, upgrade
    # an older wallet not supporting bech32m to support it.
    createwalletdescriptor defaults
  • To support multisig, type parameter could accept an "hdkey" value to generate an unused hd key in a blank wallet:

    # In a blank wallet, generate an hd key in an unused descriptor and output unused(xpub...)
    createwalletdescriptor hdkey generate
    
    # Alternately, import an unused key
    createwalletdescriptor hdkey xprv...
    
    # Import multisig descriptor with wallet public keys
    importdescriptors [...]
  • Maybe in the future to make multisig setup easier, type parameter could accept a "multisig" value and additional options to make it easier to create the descriptor using the right keys without doing extra work or using an outside tool.

  • Not recommending it, but I could imagine type and hdkey parameters being extended to accept other values in the future. For example maybe with #27351, a "codex32" type could be useful. And if a wallet didn't have the same hdkey for every output type, it might be useful to be able specify "default' for the hdkey parameter to use the hd key from the default output type, like getnewaddress.

  • It could make sense for createwalletdescriptor and importdescriptors to have other options in common. For example, it might make sense for createwalletdescriptor to accept a timestamp option when adding a descriptor with an existing hdkey, to rescan for transactions with the new descriptor. In general createwalletdescriptor could be a higher level alternative to importdescriptors that is less flexible but easier to use for common tasks.

  • It could make sense to add an "external" type and hd_account option (see #29129 (comment)) for external signer wallets :

    # Add descriptors from the external signer with the specified BIP44 account
    createwalletdescriptor type=external hd_account=123

Given a ScriptPubKeyMan, it's useful to ask the wallet whether it is
currently active.
Copy link
Member

@furszy furszy left a comment

Choose a reason for hiding this comment

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

Users will not be able to derive the last hardened child only with the root xpub info on the descriptor. Is there any rationale behind this or was just an overlook?

This was intentional since the last hardened child is different for each script type. I think future work, such as a revival of #22341, can make it possible to get keys at other derivation paths.

But how the current output is useful? No outputted descriptor can be imported in another wallet. The wallet will throw a "Cannot expand descriptor. Probably because of hardened derivations without private keys provided". What do you think about also retrieving the normalized descriptor in another field desc_normalized?

@achow101 achow101 force-pushed the createwalletdescriptor-without-new-records branch from bb1b405 to d7fc8a8 Compare March 19, 2024 16:25
@achow101
Copy link
Member Author

But how the current output is useful? No outputted descriptor can be imported in another wallet. The wallet will throw a "Cannot expand descriptor. Probably because of hardened derivations without private keys provided". What do you think about also retrieving the normalized descriptor in another field desc_normalized?

Sorry, misread your comment. The key is supposed to be the root, but the descriptors should be normalized. I've changed that.

Copy link
Member

@furszy furszy left a comment

Choose a reason for hiding this comment

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

Code review ACK d7fc8a8

src/wallet/rpc/wallet.cpp Outdated Show resolved Hide resolved
Comment on lines +899 to +906
UniValue response(UniValue::VARR);
for (const auto& [xpub, descs] : wallet_xpubs) {
bool has_xprv = false;
UniValue descriptors(UniValue::VARR);
for (const auto& [desc, active, has_priv] : descs) {
UniValue d(UniValue::VOBJ);
d.pushKV("desc", desc);
Copy link
Member

Choose a reason for hiding this comment

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

In 21d0bc3:

Better to return the descriptors sorted:

diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp
--- a/src/wallet/rpc/wallet.cpp	(revision d7fc8a8bec5edb657f65ef35042d801e2913ab81)
+++ b/src/wallet/rpc/wallet.cpp	(date 1710943877002)
@@ -874,7 +874,7 @@
                 spkms = wallet->GetAllScriptPubKeyMans();
             }
 
-            std::map<CExtPubKey, std::set<std::tuple<std::string, bool, bool>>> wallet_xpubs;
+            std::map<CExtPubKey, std::vector<std::tuple<std::string, bool, bool>>> wallet_xpubs;
             std::map<CExtPubKey, CExtKey> wallet_xprvs;
             for (auto* spkm : spkms) {
                 auto* desc_spkm{dynamic_cast<DescriptorScriptPubKeyMan*>(spkm)};
@@ -889,7 +889,7 @@
                 for (const CExtPubKey& xpub : desc_xpubs) {
                     std::string desc_str;
                     desc_spkm->GetDescriptorString(desc_str, false);
-                    wallet_xpubs[xpub].emplace(desc_str, wallet->IsActiveScriptPubKeyMan(*spkm), desc_spkm->HasPrivKey(xpub.pubkey.GetID()));
+                    wallet_xpubs[xpub].emplace_back(desc_str, wallet->IsActiveScriptPubKeyMan(*spkm), desc_spkm->HasPrivKey(xpub.pubkey.GetID()));
                     if (std::optional<CKey> key = priv ? desc_spkm->GetKey(xpub.pubkey.GetID()) : std::nullopt) {
                         wallet_xprvs[xpub] = CExtKey(xpub, *key);
                     }
@@ -897,9 +897,15 @@
             }
 
             UniValue response(UniValue::VARR);
-            for (const auto& [xpub, descs] : wallet_xpubs) {
-                bool has_xprv = false;
+            for (auto& [xpub, descs] : wallet_xpubs) {
                 UniValue descriptors(UniValue::VARR);
+
+                // Sort descriptors
+                std::sort(descs.begin(), descs.end(), [](const auto& a, const auto& b) {
+                    return std::get<0>(a) < std::get<0>(b);
+                });
+
+                bool has_xprv = false;
                 for (const auto& [desc, active, has_priv] : descs) {
                     UniValue d(UniValue::VOBJ);
                     d.pushKV("desc", desc);

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think it's meaningfully useful to explicitly sort them, and we don't do that anywhere else. Also, std::set does sorting itself, so I don't think this would even change the output.

Copy link
Member

Choose a reason for hiding this comment

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

I don't think it's meaningfully useful to explicitly sort them, and we don't do that anywhere else.

We sort them in listdescriptors: src/wallet/rpc/backup.cpp#L1827.

Also, std::set does sorting itself, so I don't think this would even change the output.

std::set sorts based on the top element, the std::tuple in this case. Not based on the descriptor string.


Yet, this was just an UX nit. Either way is fine for me.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorting on std::tuple will sort lexicographically on the items in the tuple. Since the descriptor string is the first item in the tuple, and are unique, it should be sorted by the descriptor strings.

Copy link
Member

Choose a reason for hiding this comment

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

Sorting on std::tuple will sort lexicographically on the items in the tuple. Since the descriptor string is the first item in the tuple, and are unique, it should be sorted by the descriptor strings.

Hmm, I was sure I saw a different ordering this morning. But 🤷🏼 ..

Copy link
Member

Choose a reason for hiding this comment

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

Just tried and they're neatly grouped by type. tr() is listed before wpkh which takes getting used to since we introduced tr() later, but such is the alphabet.

@@ -942,6 +942,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati

//! Returns all unique ScriptPubKeyMans in m_internal_spk_managers and m_external_spk_managers
std::set<ScriptPubKeyMan*> GetActiveScriptPubKeyMans() const;
bool IsActiveScriptPubKeyMan(const ScriptPubKeyMan& spkm) const;
Copy link
Member

Choose a reason for hiding this comment

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

tiny nit:
To follow the same pattern used across this class, should pass the spkm pointer instead of the const ref (and return early if it is nullptr).

Side note:
It is not really something that we should tackle nor discuss here but I think it would be better to return the is_active, and other spkm information, on the initial lookup function instead of introducing multiple functions that retrieves the information after wise. E.g. GetAllScriptPubKeyMansInfo() could return a struct instead of the bare spkm pointers.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's probably not a great idea to be passing around raw pointers and we should be moving away from doing that.

@@ -4486,4 +4486,25 @@ void CWallet::TopUpCallback(const std::set<CScript>& spks, ScriptPubKeyMan* spkm
// Update scriptPubKey cache
CacheNewScriptPubKeys(spks, spkm);
}

std::set<CExtPubKey> CWallet::GetActiveHDPubKeys() const
Copy link
Member

Choose a reason for hiding this comment

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

I do agree on encapsulating this function but have to say that it's inconsistent to how previous commits on this PR were implemented (gethdkeys accesses the wallet internals to obtain the same information).

Yet, this is not blocking. Just a comment. We lack of an structure here.

Copy link
Member Author

Choose a reason for hiding this comment

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

gethdkeys can get pubkeys for inactive descriptors, so it can't use this function.

@achow101 achow101 force-pushed the createwalletdescriptor-without-new-records branch from d7fc8a8 to 746b6d8 Compare March 20, 2024 20:15
Copy link
Member

@furszy furszy left a comment

Choose a reason for hiding this comment

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

ACK 746b6d8

@bitcoin bitcoin deleted a comment from Pelambrds Mar 24, 2024
@Sjors
Copy link
Member

Sjors commented Mar 25, 2024

re-utACK 746b6d8

The key is supposed to be the root, but the descriptors should be normalized. I've changed that.

That's much better.

It would be nice if you can call createwalletdescriptor on a blank wallet and it just creates a seed and adds the descriptor. @ryanofsky above suggested an addition generate keyword for that: #29130 (comment)

But we might as well do that in a followup along with some of the other suggestions in the that comment. For setting a up a multisig it's useful to have a seed without descriptor and a way to get an xpub at an arbitrary path from it. Right now, with or without this PR, you can only achieve that by creating a dummy wallet and taking the root xpriv. You then manually create the multisig descriptor, with the xpriv as your key and the xpub from your counterpary and import that that in the eventual multisig wallet. Then you use listdescriptors to get the xpub to hand to your counterparty.

But if they also use Bitcoin Core you have a chicken-egg problem (which involves more workarounds).

@furszy
Copy link
Member

furszy commented Mar 25, 2024

It would be nice if you can call createwalletdescriptor on a blank wallet and it just creates a seed and adds the descriptor. @ryanofsky above suggested an addition generate keyword for that: #29130 (comment)

Agree. Also @Sjors, another possibility could be adding a 'generate-seed' command to the bitcoin-wallet binary.

Copy link
Contributor

@ryanofsky ryanofsky left a comment

Choose a reason for hiding this comment

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

Code review ACK 746b6d8, and this looks ready to merge. There were various suggested changes since last review where main change seems to be switching gethdkeys output to use normalized descriptors (removing hardened path components).

It does seem good to normalize descriptors so they can be imported without private keys, and to make gethdkeys consistent with listdescriptors. But I'm a little unclear about whether we always normalize descriptors in RPC output, or if there are cases where they are not normalized? It does seem like it could be potentially confusing to normalize descriptors and make them look different, but I'm not sure if we always do this.

@ryanofsky ryanofsky merged commit 4373414 into bitcoin:master Mar 29, 2024
16 checks passed
@ryanofsky
Copy link
Contributor

But I'm a little unclear about whether we always normalize descriptors in RPC output, or if there are cases where they are not normalized?

Merged but still curious about this. I wonder if we should rename descriptor ToString() method to something like ToInternalString() or OriginalString() to prevent this mistake from happening again, if preference is to output normalized descriptors.

@Sjors
Copy link
Member

Sjors commented Apr 2, 2024

🎉

Normalized strings (and with public keys only) should be the default behavior imo. There's (at least) one reason to deviate:

  • when you import a descriptor from an external source, and then that external source asks for it again, it may end up confused (and the checksum won't match). An example of this might be (though haven't tested recently) Specter Desktop, which composes a multisig descriptors and gives it to Bitcoin Core. It also stores them in its own storage. If it then later needs to use the wallet, it needs some way to sanity check that it loaded the right one, and calling listdescriptors is one way. Similarly the displayaddress call in HWI expects a descriptor and (last time I checked) it's a bit picky about the format.

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.

None yet

8 participants