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
ACK rkrux
Concept ACK shahsb, Sjors, vicjuma, Eunovo, stringintech, enirox001
Stale ACK pablomartin4btc, 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:

  • #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)
  • #32652 (wallet: add codex32 argument to addhdkey by roconnor-blockstream)
  • #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).

@jonatack
Copy link
Member

jonatack commented Aug 4, 2025

For anyone who's interested, I'm hosting a review club meeting about this PR on Wednesday: bitcoincore.reviews/32489

There is Review Club label in this repo that can be added, too.

Copy link
Member

@pablomartin4btc pablomartin4btc left a comment

Choose a reason for hiding this comment

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

re-ACK 84d3bc6

Since my last review CanSelfExpand() has been refactored as @ryanofsky suggested.

We'd need release notes for this new RPC.

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 84d3bc6. This is great. Nice feature cleanly implemented with very good test coverage. I left a bunch of comments but none are critical.

@@ -1174,7 +1174,7 @@ bool DescriptorScriptPubKeyMan::CanGetAddresses(bool internal) const
LOCK(cs_desc_man);
return m_wallet_descriptor.descriptor->IsSingleType() &&
m_wallet_descriptor.descriptor->IsRange() &&
(HavePrivateKeys() || m_wallet_descriptor.next_index < m_wallet_descriptor.range_end);
(HavePrivateKeys() || m_wallet_descriptor.next_index < m_wallet_descriptor.range_end || m_wallet_descriptor.descriptor->CanSelfExpand());
Copy link
Contributor

Choose a reason for hiding this comment

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

re: #32489 (comment)

In commit "wallet: Use Descriptor::CanSelfExpand() in CanGetAddresses()" (1170ade)

Can confirm without this change the test fails as expected with self.funds.sendtoaddress(online_wallet.getnewaddress(), 10) throwing "No addresses available"

Comment on lines 3710 to 3679
// Write the existing cache to disk
WalletBatch batch(GetDatabase());
batch.WriteDescriptorCacheItems(id, desc.cache);
Copy link
Contributor

Choose a reason for hiding this comment

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

re: #32489 (comment)

In commit "wallet: Write new descriptor's cache in AddWalletDescriptor" (fcf7dfe)

Can confirm if I revert this change I see a Wallet corrupted (-4) error from self.online.restorewallet("imports_watchonly", res["exported_file"]) running the test with Error: Unable to expand wallet descriptor from cache in the logs

// 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
Contributor

Choose a reason for hiding this comment

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

In commit "wallet: Add CWallet::ExportWatchOnly" (236970d)

I don’t think fs::remove_all is appropriate here or elsewhere in wallet code. We should have a wallet delete function that explicitly removes known database and log files, then removes the directory, rather than recursively deleting everything. remove_all is fine in the happy case, but if there’s a bug (e.g. an extra parent_path call) or if users mistakenly store other data or bind mounts in the wallet directory, the results could be catastrophic. Seems fine to keep for now since there are other instances, but this would be good to address eventually.

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'll leave that for a followup

Copy link
Contributor

@stringintech stringintech 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. I did a light code review.

// 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()) {
DescriptorScriptPubKeyMan* desc_spkm = dynamic_cast<DescriptorScriptPubKeyMan*>(GetScriptPubKeyMan(desc_id));
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps dynamic_cast could be wrapped by CHECK_NONFATAL?

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 really useful to have a check on that since earlier functions already enforce that the retrieved ScriptPubKeyMan must be a DescriptorScriptPubKeyMan.

Copy link
Contributor

@enirox001 enirox001 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 84d3bc6

Left some nits, comments

if (!watchonly_wallet) {
return util::Error{_("Error: Failed to create new watchonly wallet")};
}

Copy link
Contributor

Choose a reason for hiding this comment

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

can we add a runtime sanity check here to ensure the newly created watch-only wallet does not have private keys enabled (i.e. !watchonly_wallet->HavePrivateKeys())? If the flag didn’t take effect we should fail early with a clear error rather than continuing.

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 that's necessary. It uses code that is well tested to behave correctly.

@achow101 achow101 force-pushed the export-watchonly-wallet branch 2 times, most recently from 7d63d08 to 4cc7be4 Compare August 14, 2025 19:56
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.
@achow101 achow101 force-pushed the export-watchonly-wallet branch from 4cc7be4 to cf58860 Compare August 20, 2025 22:18
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.

Started reviewing.

Code review cf58860

Comment on lines +66 to +67
assert_equal(online_wallet.getbalances()["mine"]["trusted"], 10)
assert_equal(offline_wallet.getbalances()["mine"]["trusted"], 0)
Copy link
Contributor

Choose a reason for hiding this comment

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

In cf58860 "test: Test for exportwatchonlywallet"

In this subtest, can also assert for few getwalletinfo RPC response values being equal as well such as txcount, keypoolsize, keypoolsize_hd_internal, birthtime, lastprocessedblock. For the flags RPC response key, it can be asserted that for the watch-only one, the value is the union of the original flags and disable_private_keys.

Might as well put all these assertions in a assert_common_values function that can be called in all the subtests.

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 any of those are actually useful things to assert. The point of the offline wallet is that it doesn't have access to the blockchain, so actually txcount, and lastprocessedblock should be different. The keypool sizes may be different depending on the node startup options, and birthtime may be different if something was used before being imported to the offline wallet.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fair point.
I can imagine one of the use cases being that the user creates a offline wallet and immediately exports the watchonly version that's to be imported on a connected node.

@achow101 achow101 force-pushed the export-watchonly-wallet branch from cf58860 to c9eb6c8 Compare August 25, 2025 21:09
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.

Code review c9eb6c8

Looking good, mentioned few points.

}

// Add to the watchonly wallet
if (auto spkm_res = watchonly_wallet->AddWalletDescriptor(w_desc, keys, "", false); !spkm_res) {
Copy link
Contributor

Choose a reason for hiding this comment

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

In 3a30ce4 "wallet: Add CWallet::ExportWatchOnly"

All the inactive descriptors are hardcoded to not being internal, is this correct? I can't seem to come up with a test case that fails this assumption.

Copy link
Member Author

Choose a reason for hiding this comment

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

internal is only used for setting the labels in the address book within AddWalletDescriptor, and only if the descriptor is not ranged and that there is a label provided. Since we copy the labels from the address book right after this, there's no need for us to provide label or internal to this call.


LOCK(pwallet->cs_wallet);
pwallet->TopUpKeyPool();
util::Result<std::string> exported = pwallet->ExportWatchOnlyWallet(fs::PathFromString(dest), context);
Copy link
Contributor

Choose a reason for hiding this comment

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

In 34137b3 "wallet, rpc: Add exportwatchonlywallet RPC"

Any particular reason to convert from string to path before passing it to ExportWatchOnlyWallet? Asking because I noticed 4 fs::PathToString(destination) calls inside ExportWatchOnlyWallet that could be avoided if the string is accepted and this conversion is done over there.

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 it conceptually makes more sense to be passing paths as fs::Path, rather than strings. Ideally, BackupWallet should be taking a fs::Path instead of a string, and the other conversions are only necessary for logging.

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.
@achow101 achow101 force-pushed the export-watchonly-wallet branch from c9eb6c8 to 6e8cddb Compare August 26, 2025 18:26
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.

lgtm ACK 0f54714

Thanks for addressing the comments.

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.