Skip to content

Conversation

@achow101
Copy link
Member

@achow101 achow101 commented May 13, 2025

Currently, if a user wants to use an airgapped setup, they need to manually create the watchonly wallet that will live on the online node by importing the public descriptors. This PR introduces exportwatchonlywallet which will create a wallet file with the public descriptors to avoid exposing the specific internals to the user. Additionally, this RPC will copy any existing labels, transactions, and wallet flags. This ensures that the exported watchonly wallet is almost entirely a copy of the original wallet but without private keys.

@DrahtBot
Copy link
Contributor

DrahtBot commented May 13, 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/32489.

Reviews

See the guideline for information on the review process.

Type Reviewers
Concept ACK shahsb, Sjors, vicjuma, Eunovo, stringintech, enirox001
Stale ACK pablomartin4btc, ryanofsky, rkrux, polespinasa

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:

  • #33034 (wallet: Store transactions in a separate sqlite table by achow101)
  • #33008 (wallet: support bip388 policy with external signer by Sjors)
  • #32895 (wallet: Prepare for future upgrades by recording versions of last client to open and decrypt by achow101)
  • #32861 (Have createwalletdescriptor auto-detect an unused(KEY) by Sjors)
  • #32784 (wallet: derivehdkey RPC to get xpub at arbitrary path by Sjors)
  • #32471 (wallet/rpc: fix listdescriptors RPC fails to return descriptors with private key information when wallet contains descriptors missing any key by Eunovo)
  • #29136 (wallet: addhdkey RPC to add just keys to wallets via new unused(KEY) descriptor 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 export-watchonly-wallet branch from dc39a64 to 30ebc97 Compare May 13, 2025 22:38
@DrahtBot
Copy link
Contributor

🚧 At least one of the CI tasks failed.
Task lint: https://github.com/bitcoin/bitcoin/runs/42170822821
LLM reason (✨ experimental): The CI failure is due to linting errors in Python and C++ code.

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.

Copy link

@shahsb shahsb 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

@Sjors
Copy link
Member

Sjors commented May 19, 2025

Concept ACK

This might also be useful in a multisig airgapped scenario (where two private keys never go near each other).

E.g. create a 2-of-2 MuSig2 wallet on your offline machine, with one hot key held by Bitcoin Core and the other a hardware wallet. Export the watch-only wallet and put it on the online machine. You can then use the hardware wallet on either the online or offline machine to co-sign.

Though for that to work, the exportwatchonlywallet should have an option to set the external_signer flag (the offline wallet might not have this flag, depending on how you set it up).

@achow101 achow101 force-pushed the export-watchonly-wallet branch from 0f54714 to 6fafa9d Compare September 24, 2025 18:41
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.

Reviewed first commit, will continue later.

{
return std::make_unique<ConstPubkeyProvider>(m_expr_index, m_pubkey, m_xonly);
}
bool CanSelfExpand() const final { return true; }
Copy link
Member

Choose a reason for hiding this comment

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

In a3be2d8 descriptor: Add CanSelfExpand(): this can't expand. Maybe the description can clarify that the return value should be ignored if it can't expand with or without keys.

Alternatively it can return false, though that needs more changes:

diff --git a/src/script/descriptor.cpp b/src/script/descriptor.cpp
index 4ed88277e2..7e978140b0 100644
--- a/src/script/descriptor.cpp
+++ b/src/script/descriptor.cpp
@@ -360,7 +360,7 @@ public:
     {
         return std::make_unique<ConstPubkeyProvider>(m_expr_index, m_pubkey, m_xonly);
     }
-    bool CanSelfExpand() const final { return true; }
+    bool CanSelfExpand() const final { return false; }
 };

 enum class DeriveType {
@@ -790,7 +790,7 @@ public:
     bool CanSelfExpand() const override
     {
         for (const auto& key : m_participants) {
-            if (!key->CanSelfExpand()) return false;
+            if (key->IsRange() && !key->CanSelfExpand()) return false;
         }
         return true;
     }
@@ -1008,7 +1008,7 @@ public:
     bool CanSelfExpand() const override
     {
         for (const auto& key : m_pubkey_args) {
-            if (!key->CanSelfExpand()) return false;
+            if (key->IsRange() && !key->CanSelfExpand()) return false;
         }
         for (const auto& sub : m_subdescriptor_args) {
             if (!sub->CanSelfExpand()) return false;

Copy link
Member Author

Choose a reason for hiding this comment

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

ConstPubkeyProvider can self expand. Self expand in the context of PubkeyProviders means that pubkeys can always be retrieved, and this is always true for ConstPubkeyProvider. ConstPubkeyProvider cannot be ranged, nor can it be something that requires a private key in order to get the pubkey.

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.

Reviewed 6fafa9dd48a814a929efb31b746dce343a3c96dc and tested it, though not with hardened derivation.

// Delete the watchonly wallet now that it has been exported to the desired location
fs::path watchonly_path = fs::PathFromString(watchonly_wallet->GetDatabase().Filename()).parent_path();
watchonly_wallet.reset();
fs::remove_all(watchonly_path);
Copy link
Member

Choose a reason for hiding this comment

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

After #33032 this intermediate watch-only wallet could in-memory.

LOCK(watchonly_wallet->cs_wallet);

// Parse the descriptors and add them to the new wallet
for (const WalletDescInfo& desc_info : *exported) {
Copy link
Member

Choose a reason for hiding this comment

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

In a453ea6ff06797224adbb1dd839299fe3d8636ae wallet: Add CWallet::ExportWatchOnly: nit *Assert(exported)


// For descriptors that cannot self expand (i.e. needs private keys or cache), retrieve the cache
uint256 desc_id = w_desc.id;
if (!w_desc.descriptor->CanSelfExpand()) {
Copy link
Member

Choose a reason for hiding this comment

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

In a453ea6ff06797224adbb1dd839299fe3d8636ae wallet: Add CWallet::ExportWatchOnly: if you take my earlier suggestion #32489 (comment) for having this return false for non-ranged descriptors, you'll need to add w_desc.descriptor->IsRange() &&

uint256 desc_id = w_desc.id;
if (!w_desc.descriptor->CanSelfExpand()) {
DescriptorScriptPubKeyMan* desc_spkm = dynamic_cast<DescriptorScriptPubKeyMan*>(GetScriptPubKeyMan(desc_id));
w_desc.cache = WITH_LOCK(desc_spkm->cs_desc_man, return desc_spkm->GetWalletDescriptor().cache);
Copy link
Member

Choose a reason for hiding this comment

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

In a453ea6ff06797224adbb1dd839299fe3d8636ae wallet: Add CWallet::ExportWatchOnly: at the moment DescriptorCache doesn't contain any private key material, but if that ever changes this line would put that into the watch-only wallet.

Maybe add a note to the DescriptorCache description that if private key material is added, ExportWatchOnlyWallet needs to be modified to avoid it.

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 think that is unnecessary, and unlikely to happen.

@achow101 achow101 force-pushed the export-watchonly-wallet branch 2 times, most recently from e3f7111 to 7822a56 Compare December 10, 2025 20:10
@DrahtBot
Copy link
Contributor

🚧 At least one of the CI tasks failed.
Task fuzzer,address,undefined,integer: https://github.com/bitcoin/bitcoin/actions/runs/20111780022/job/57710791590
LLM reason (✨ experimental): Compilation failed due to a type mismatch: ExportDescriptors returns util::Expected<vector, string>, which cannot be converted to util::Result<vector> in wallet.cpp.

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.

achow101 and others added 8 commits January 2, 2026 16:53
CanSelfExpand() reports whether a descriptor can be expanded without
needing any caches or private keys to be provided by the caller of
Expand().
If a descriptor does not need any caches or private keys in order to
expand, then CanGetAddresses() should return true for that descriptor.
If a new WalletDescriptor is provided to us with a cache, write the
cache to disk as well.
When listdescriptors retrieves the descriptors from the wallet, instead
of having this logic in the RPC, move it into CWallet itself. This
will enable other functions to get the descriptors in an exportable
form.
ExportWatchOnly produces a watchonly wallet file from a CWallet. This
can be restored onto another instance of Bitcoin Core to allow that
instance to watch the same descriptors, and also have all of the same
initial address book and transactions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.