Skip to content

Conversation

@Eunovo
Copy link
Contributor

@Eunovo Eunovo commented May 12, 2025

TLDR:
Currently, listdescriptors [private=true] will fail for a non-watch-only wallet if any descriptor has a missing private key(e.g tr(), multi(), etc.). This PR changes that while making sure listdescriptors [private=true] still fails if there no private keys. Closes #32078

In non-watch-only wallets, it's possible to import descriptors as long as at least one private key is included. It's important that users can still view these descriptors when they need to create a backup—even if some private keys are missing (#32078 (comment)). This change makes it possible to do so.

This change also helps prevent listdescriptors true from failing completely, because one descriptor is missing some private keys.

Notes

  • The new behaviour is applied to all descriptors including miniscript descriptors
  • listdescriptors true still fails for watch-only wallets to preserve existing behaviour Descriptor unit tests and simplifications #24361 (comment)
  • Wallet migration logic previously used Descriptor::ToPrivateString() to determine which descriptor was watchonly. This means that modifying the ToPrivateString() behaviour caused descriptors that were previously recognized as "watchonly" to be "non-watchonly". In order to keep the scope of this PR limited to the RPC behaviour, this PR uses a different method to determine watchonly descriptors for the purpose of wallet migration. A follow-up PR can be opened to update migration logic to exclude descriptors with some private keys from the watchonly migration wallet.

Relevant PRs

#24361
#32186

Testing

Functional tests were added to test the new behaviour

EDIT
listdescriptors [private=true] will still fail when there are no private keys because non-watchonly wallets must have private keys and calling listdescriptors [private=true] for watchonly wallet returns an error

@DrahtBot
Copy link
Contributor

DrahtBot commented May 12, 2025

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/32471.

Reviews

See the guideline for information on the review process.

Type Reviewers
ACK Sjors
Concept NACK vicjuma
Concept ACK achow101
Stale ACK w0xlt, rkrux

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:

  • #32489 (wallet: Add exportwatchonlywallet RPC to export a watchonly version of a wallet by achow101)
  • #31713 (miniscript refactor: Remove unique_ptr-indirection by hodlinator)

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 typos and grammar issues:

  • at least one private key available. -> at least one private key is available. [Missing verb "is" makes the sentence ungrammatical.]

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):

  • assert_raises_rpc_error(-4, 'Can't get private descriptor string for watch-only wallets', watch_only_wallet.listdescriptors, True) in test/functional/wallet_listdescriptors.py

2026-01-07

@fanquake
Copy link
Member

/Users/runner/work/bitcoin/bitcoin/src/test/fuzz/miniscript.cpp:1040:42: error: no matching member function for call to 'ToString'
    std::optional<std::string> str{node->ToString(parser_ctx)};
                                   ~~~~~~^~~~~~~~
/Users/runner/work/bitcoin/bitcoin/src/script/miniscript.h:830:17: note: candidate function template not viable: requires 2 arguments, but 1 was provided
    std::string ToString(const CTx& ctx, bool& has_priv_key) const {
                ^
/Users/runner/work/bitcoin/bitcoin/src/test/fuzz/miniscript.cpp:1248:31: error: no matching member function for call to 'ToString'
    const auto str2 = parsed->ToString(parser_ctx);
                      ~~~~~~~~^~~~~~~~
/Users/runner/work/bitcoin/bitcoin/src/script/miniscript.h:830:17: note: candidate function template not viable: requires 2 arguments, but 1 was provided
    std::string ToString(const CTx& ctx, bool& has_priv_key) const {
                ^
2 errors generated.

@DrahtBot
Copy link
Contributor

🚧 At least one of the CI tasks failed.
Task tidy: https://github.com/bitcoin/bitcoin/runs/42059781761
LLM reason (✨ experimental): The CI failure is due to build errors in miniscript.cpp related to the ToString function call.

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

@Eunovo Eunovo marked this pull request as draft May 12, 2025 13:50
@Eunovo
Copy link
Contributor Author

Eunovo commented May 12, 2025

Putting draft while I fix some issues with the fuzz tests

@Eunovo
Copy link
Contributor Author

Eunovo commented May 13, 2025

cc @rkrux @sipa

@achow101
Copy link
Member

Concept NACK

The purpose of the private argument is to show the private keys. If there are no private keys to show, I think it is a reasonable error to say that, rather than just showing the public descriptors. I think changing the behavior could result in a footgun where people use it thinking that they are being shown private keys when they actually are not.

@vicjuma
Copy link

vicjuma commented May 13, 2025

Concept NACK

It's unclear why someone should receive public keys with private=true. Why not just use the default command, bitcoin-cli listdescriptors, which already shows public keys? I assume the user is certain about the nature of the descriptors they have in their wallet. Maybe if I do not fully understand this PR.

@rkrux
Copy link
Contributor

rkrux commented May 13, 2025

If there are no private keys to show, I think it is a reasonable error to say that, rather than just showing the public descriptors.

I have not checked this PR yet but the original intention came from this issue: #32078 (comment)

The idea was to show results for descriptors that don't have all the private keys but do have at least one. This is not for the case when the descriptor doesn't have any private key.

I do like how the importdescriptors RPC returns a warning when the descriptor contains partial private keys: #32078 (comment). Maybe a similar warning can be returned in this case for listdescriptors true as well - to avoid the footgun.

[
  {
    "success": true,
    "warnings": [
      "Not all private keys provided. Some wallet functionality may return unexpected errors"
    ]
  }
]

Also, if the importdescriptors RPC allows importing a descriptor with partial private keys, then it seems reasonable to me to list that descriptor in the response of listdescriptors true with those partial private keys. Is there any other way to see those partial private keys?

Regardless of the above point, I do feel that preventing listdescriptors true from failing completely in the case a descriptor is missing few private keys is helpful for the user as the other descriptors with all the private keys will be returned in the response.

This change also helps prevent listdescriptors true from failing completely, because one descriptor is missing some private keys.

@vicjuma
Copy link

vicjuma commented May 13, 2025

Maybe -rpcwallet=<wallet> will come in handy in this situation where you would have to use a different wallet for these situations to prevent the conflicts.

@achow101
Copy link
Member

The idea was to show results for descriptors that don't have all the private keys but do have at least one.

I think that's fine to show a result, but I don't think we should show any result if there are no private keys at all.

@Eunovo
Copy link
Contributor Author

Eunovo commented May 14, 2025

Concept NACK

The purpose of the private argument is to show the private keys. If there are no private keys to show, I think it is a reasonable error to say that, rather than just showing the public descriptors. I think changing the behavior could result in a footgun where people use it thinking that they are being shown private keys when they actually are not.

I think there has been a misunderstanding. There are private keys to show. This PR doesn't change the behaviour of listdescriptors true RPC to show any result when there are no private keys. Non-watchonly wallet descriptors must have at least one private key. The RPC still fails for watch only wallets

@Eunovo
Copy link
Contributor Author

Eunovo commented May 14, 2025

I think that's fine to show a result, but I don't think we should show any result if there are no private keys at all.

No result is shown if there are no private keys

@Eunovo
Copy link
Contributor Author

Eunovo commented May 14, 2025

It's unclear why someone should receive public keys with private=true. Why not just use the default command, bitcoin-cli listdescriptors, which already shows public keys? I assume the user is certain about the nature of the descriptors they have in their wallet. Maybe if I do not fully understand this PR.

It is possible to have descriptors with missing private keys in a non-watchonly wallet. importdescriptors only rejects descriptors without any private keys. Trying listdescriptors private=true in such a wallet will throw an error, preventing the user from being able to backup their descriptors. The default command only returns public information, I believe that modifying the default command to return private information sometimes will be the wrong decision. It's much better to allow private=true to work for these descriptors with some missing private keys.

@Eunovo Eunovo changed the title Fix listdescriptors true fails with 'Can't get descriptor string' in non-watch-only descriptor wallet wallet/rpc: fix listdescriptors RPC fails to return descriptors with private key information when wallet contains descriptors missing any key May 14, 2025
@rkrux
Copy link
Contributor

rkrux commented May 21, 2025

A similar problem happens in gethdkeys private=true as well and I think it's due to partial private keys when the following error is thrown:

➜  bitcoin git:(list-descriptors-with-partial-keys) ✗ bitcoinclireg -named gethdkeys private=true
error code: -1
error message:
map::at:  key not found

It appears to be coming from here when the xprv corresponding to the xpub can't be found:

xpub_info.pushKV("xprv", EncodeExtKey(wallet_xprvs.at(xpub)));

It's due to mismatch in the count of wallet_xprvs and wallet_xpubs calculated in the previous loop over spkms.
I can create a separate issue later.

@DrahtBot DrahtBot mentioned this pull request Jun 11, 2025
@DrahtBot DrahtBot requested a review from rkrux October 22, 2025 07:53
Copy link
Contributor

@rkrux rkrux left a comment

Choose a reason for hiding this comment

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

reACK 2b16014

git range-diff 8b4d4b3...2b16014

Copy link
Member

@Sjors Sjors left a comment

Choose a reason for hiding this comment

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

Was in the middle of a review, but had to leave. Some comments.

@Eunovo
Copy link
Contributor Author

Eunovo commented Nov 20, 2025

Rebased on master. I updated the Miniscript implementation slightly due to changes from #31734

Copy link
Member

@Sjors Sjors left a comment

Choose a reason for hiding this comment

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

I still need to (re-)review the non-test changes of 4dc37d8, but everything else looks fine.

I wrote an expansion of the fuzzer so it actually covers has_priv_key, see Eunovo#3, but it can wait for a followup.

The commit message for 5837973 walletrpc: reject listdes with priv key on w-only wallets could mention that this improves the error message.

std::string desc_str;
bool watchonly = !desc->ToPrivateString(*this, desc_str);
if (watchonly && !m_storage.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
if (!desc->HavePrivateKeys(*this) && !m_storage.IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
Copy link
Member

Choose a reason for hiding this comment

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

In 7e2af06 wallet/migration: use HavePrivateKeys in place of ToPrivateString: we should have a comment here to explain what this if condition if trying to achieve. The original comment (now deleted) wasn't great either. Try:

// If we can't provide all private keys for this inferred descriptor,
// but this wallet is not watch-only, migrate it to the watch-only wallet.
if (...

It's useful to know, as you write in the PR description:

In order to keep the scope of this PR limited to the RPC behaviour, this PR uses a different method to determine watchonly descriptors for the purpose of wallet migration. A follow-up PR can be opened to update migration logic to exclude descriptors with some private keys from the watchonly migration wallet.

Not sure if we want to further expand the comment, but let's add that to the commit message. That way future devs can understand why migration is inconsistent with RPC behaviour.

I do agree it's good to limit the PR scope in this way.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added your comment as is and updated the commit message.

Eunovo and others added 3 commits January 7, 2026 09:34
Previously, to determine if a desc is watchonly, `ToPrivateString()`, was used.
It returns `false` if there is at least one pubkey in the descriptor for which
the provider  does not have a private key.

ToPrivateString() behaviour will change in the following commits to only
return `false` if no priv keys could be found for the pub keys in the descriptor.

HavePrivateKeys() is added here to replace the use of ToPrivateString() for determining
if a descriptor is 'watchonly'.

Co-authored-by: rkrux <[email protected]>
ToPrivateString() behaviour will be modified in the following commits.

In order to keep the scope of this PR limited to the RPC behaviour,
this commit updates wallet migration to use 'Descriptor::HavePrivateKeys()'
in place of 'Descriptor::ToPrivateString()' to determine watchonly descriptors.

A follow-up PR can be opened to update migration logic to exclude
descriptors with some private keys from the watchonly migration wallet.
This commit modifies the Pubkey providers to return the public string
if private data is not available.
This is setup for a future commit to make Descriptor::ToPrivateString
return strings with missing private key information.

Co-authored-by: rkrux <[email protected]>
bool has_priv_key{false};
auto res = m_node->ToString(StringMaker(arg, m_pubkey_args, type, cache), has_priv_key);
if (res) out = *res;
return type == StringType::PRIVATE ? has_priv_key : res.has_value();
Copy link
Member

@Sjors Sjors Jan 7, 2026

Choose a reason for hiding this comment

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

In 4dc37d8 descriptor: ToPrivateString() pass if at least 1 priv key exists:

The following makes it more clear why for private strings we only look at has_priv_key and don't care about whether res has a value:

if (type == StringType::PRIVATE) {
    Assume(res.has_value());
    return has_priv_key;
} else {
    return res.has_value();
}

Or a bit shorter:

return res.has_value() && (type != StringType::PRIVATE || has_priv_key);

Though in the edge case of StringType::PRIVATE with no res, this would return false which is a bit unexpected.

Copy link
Contributor Author

@Eunovo Eunovo Jan 7, 2026

Choose a reason for hiding this comment

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

The Assume check here is useful because we expect res to always have a value, so I think the longer version is better. I have rebased the PR with the change.

Copy link
Member

@Sjors Sjors Jan 7, 2026

Choose a reason for hiding this comment

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

Sounds good, I rebased Sjors#108 on top of your latest push to see if it survives the current fuzz corpus sanity check.

@Eunovo Eunovo force-pushed the list-descriptors-with-partial-keys branch from 3297f24 to 165f728 Compare January 7, 2026 09:34
@Sjors
Copy link
Member

Sjors commented Jan 7, 2026

ACK 165f728

Just left one more (vague) suggestion above.

@DrahtBot DrahtBot requested review from rkrux and w0xlt January 7, 2026 09:42
Eunovo and others added 3 commits January 7, 2026 10:44
- Refactor Descriptor::ToPrivateString() to allow descriptors with
  missing private keys to be printed. Useful in descriptors with
  multiple keys e.g tr() etc.
- The existing behaviour of listdescriptors is preserved as much as
  possible, if no private keys are availablle ToPrivateString will
  return false
@Eunovo Eunovo force-pushed the list-descriptors-with-partial-keys branch from 165f728 to 9c7e477 Compare January 7, 2026 09:51
@Sjors
Copy link
Member

Sjors commented Jan 9, 2026

ACK 9c7e477

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

listdescriptors true fails with 'Can't get descriptor string' in non-watch-only descriptor wallet

8 participants