-
Notifications
You must be signed in to change notification settings - Fork 37.7k
wallet: Add exportwatchonlywallet
RPC to export a watchonly version of a wallet
#32489
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
base: master
Are you sure you want to change the base?
Conversation
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code Coverage & BenchmarksFor details see: https://corecheck.dev/bitcoin/bitcoin/pulls/32489. ReviewsSee the guideline for information on the review process.
If your review is incorrectly listed, please react with 👎 to this comment and the bot will ignore it on the next update. ConflictsReviewers, this pull request conflicts with the following ones:
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. |
dc39a64
to
30ebc97
Compare
🚧 At least one of the CI tasks failed. HintsTry to run the tests locally, according to the documentation. However, a CI failure may still
Leave a comment here, if you need help tracking down a confusing failure. |
211dbe3
to
9fd93da
Compare
9fd93da
to
4ef9315
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Concept ACK
4ef9315
to
def9594
Compare
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 |
def9594
to
d41059b
Compare
There is |
There was a problem hiding this 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.
There was a problem hiding this 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()); |
There was a problem hiding this comment.
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"
src/wallet/wallet.cpp
Outdated
// Write the existing cache to disk | ||
WalletBatch batch(GetDatabase()); | ||
batch.WriteDescriptorCacheItems(id, desc.cache); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this 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)); |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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
.
There was a problem hiding this 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")}; | ||
} | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
7d63d08
to
4cc7be4
Compare
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.
4cc7be4
to
cf58860
Compare
There was a problem hiding this 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
assert_equal(online_wallet.getbalances()["mine"]["trusted"], 10) | ||
assert_equal(offline_wallet.getbalances()["mine"]["trusted"], 0) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
cf58860
to
c9eb6c8
Compare
There was a problem hiding this 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.
src/wallet/wallet.cpp
Outdated
} | ||
|
||
// Add to the watchonly wallet | ||
if (auto spkm_res = watchonly_wallet->AddWalletDescriptor(w_desc, keys, "", false); !spkm_res) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
c9eb6c8
to
6e8cddb
Compare
There was a problem hiding this 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.
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.