What changed, and why it matters
This commit adds a new Bitcoin Core feature that lets a user export a copy of their wallet containing only public information (watch-only). It creates a separate wallet file with descriptors, address book, transactions, and locked coins, but explicitly excludes private keys. The change is a feature addition, not a fix for a known vulnerability. There is no evidence in the commit or supplied references that this is a security patch or that it addresses any reported security issue.
No immediate security action required. Treat as normal feature code review. If deploying, verify that exported watch-only wallets do not contain unexpected private material and that destination path permissions prevent unauthorized reads of the exported file.
Security signals we found
Feature addition for watch-only wallet export
Private keys explicitly excluded via WALLET_FLAG_DISABLE_PRIVATE_KEYS and export_private=false
Exported descriptors validated to contain no private keys (CHECK_NONFATAL dummy_keys.keys.size() == 0)
Destination file existence and writability checked before creating wallet
Cleanup handlers remove partial/temporary files on failure
No vendor disclosure of security relevance in commit message or diff
Evidence from the diff
The commit introduces CWallet::ExportWatchOnlyWallet(), which builds a new SQLite wallet from an existing wallet using only public descriptors (ExportDescriptors with export_private=false), WALLET_FLAG_DISABLE_PRIVATE_KEYS, and copies of address book, transactions, locked coins, order position, and best block locator. It uses cleanup handlers to remove temporary files on failure and checks that destination does not already exist. The only modification outside export.cpp/export.h is marking BackupWallet as [[nodiscard]] in wallet.h. No bug fixes, input sanitization beyond empty/exists checks, or security-hardening changes are visible.
Changed components
src/wallet/export.cppsrc/wallet/export.hsrc/wallet/wallet.hInspect captured patch +188 / −1
diff --git a/src/wallet/export.cpp b/src/wallet/export.cpp
index 9c042d3b..6f170dd7 100644
--- a/src/wallet/export.cpp
+++ b/src/wallet/export.cpp
@@ -4,10 +4,15 @@
#include <wallet/export.h>
+#include <key_io.h>
+#include <util/fs.h>
#include <util/expected.h>
#include <wallet/scriptpubkeyman.h>
+#include <wallet/context.h>
#include <wallet/wallet.h>
+#include <fstream>
+
namespace wallet {
util::Expected<std::vector<WalletDescInfo>, std::string> ExportDescriptors(const CWallet& wallet, bool export_private)
{
@@ -36,4 +41,182 @@ util::Expected<std::vector<WalletDescInfo>, std::string> ExportDescriptors(const
}
return wallet_descriptors;
}
+
+util::Result<std::string> ExportWatchOnlyWallet(const CWallet& wallet, const fs::path& destination, WalletContext& context)
+{
+ AssertLockHeld(wallet.cs_wallet);
+
+ if (destination.empty()) {
+ return util::Error{_("Error: Export destination cannot be empty")};
+ }
+ if (fs::exists(destination)) {
+ return util::Error{strprintf(_("Error: Export destination '%s' already exists"), fs::PathToString(destination))};
+ }
+ if (!std::ofstream{fs::PathToString(destination)}) {
+ return util::Error{strprintf(_("Error: Could not create file '%s'"), fs::PathToString(destination))};
+ }
+ bool success = false;
+ auto cleanup_destination = interfaces::MakeCleanupHandler([&success, &destination] {
+ if (!success) fs::remove(destination);
+ });
+
+ // Get the descriptors from this wallet
+ util::Expected<std::vector<WalletDescInfo>, std::string> exported = ExportDescriptors(wallet, /*export_private=*/false);
+ if (!exported) {
+ return util::Error{Untranslated(exported.error())};
+ }
+ if (exported->empty()) {
+ return util::Error{_("Error: Wallet has no descriptors to export")};
+ }
+
+ // Setup DatabaseOptions to create a new sqlite database
+ DatabaseOptions options;
+ options.require_existing = false;
+ options.require_create = true;
+ options.require_format = DatabaseFormat::SQLITE;
+
+ // Make the wallet with the same flags as this wallet, but without private keys
+ options.create_flags = wallet.GetWalletFlags() | WALLET_FLAG_DISABLE_PRIVATE_KEYS;
+
+ // Make the watchonly wallet
+ DatabaseStatus status;
+ std::vector<bilingual_str> warnings;
+ std::string wallet_name = wallet.GetName() + "_watchonly_temp";
+ bilingual_str error;
+ std::unique_ptr<WalletDatabase> database = MakeWalletDatabase(wallet_name, options, status, error);
+ if (!database) {
+ return util::Error{strprintf(_("Wallet file creation failed: %s"), error)};
+ }
+
+ // Always remove the temporary wallet files, even when returning early on error.
+ std::shared_ptr<CWallet> watchonly_wallet;
+ fs::path wallet_path = fs::PathFromString(database->Filename()).parent_path();
+ std::vector<fs::path> cleanup_files = database->Files();
+ auto cleanup_watchonly_wallet = interfaces::MakeCleanupHandler([&watchonly_wallet, &wallet_path, &cleanup_files] {
+ if (watchonly_wallet) watchonly_wallet.reset();
+ for (const auto& file : cleanup_files) {
+ fs::remove(file);
+ }
+ fs::remove(wallet_path);
+ });
+
+ WalletContext empty_context;
+ empty_context.args = context.args;
+ watchonly_wallet = CWallet::CreateNew(empty_context, wallet_name, std::move(database), options.create_flags, /*born_encrypted=*/false, error, warnings);
+ if (!watchonly_wallet) {
+ return util::Error{strprintf(_("Error: Failed to create new watchonly wallet. %s"), error)};
+ }
+
+ {
+ LOCK(watchonly_wallet->cs_wallet);
+
+ // Parse the descriptors and add them to the new wallet
+ for (const WalletDescInfo& desc_info : *Assert(exported)) {
+ // Parse the descriptor
+ FlatSigningProvider dummy_keys;
+ std::string dummy_err;
+ std::vector<std::unique_ptr<Descriptor>> descs = Parse(desc_info.descriptor, dummy_keys, dummy_err, /*require_checksum=*/true);
+ CHECK_NONFATAL(descs.size() == 1); // All of our descriptors should be valid, and not multipath
+ CHECK_NONFATAL(dummy_keys.keys.size() == 0); // No private keys should be present in our exported descriptors
+
+ // Get the range if there is one
+ int32_t range_start = 0;
+ int32_t range_end = 0;
+ if (desc_info.range) {
+ range_start = desc_info.range->first;
+ range_end = desc_info.range->second;
+ }
+
+ WalletDescriptor w_desc(std::move(descs.at(0)), desc_info.creation_time, range_start, range_end, desc_info.next_index);
+
+ // 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*>(wallet.GetScriptPubKeyMan(desc_id));
+ w_desc.cache = WITH_LOCK(desc_spkm->cs_desc_man, return desc_spkm->GetWalletDescriptor().cache);
+ }
+
+ // Add to the watchonly wallet
+ if (auto spkm_res = watchonly_wallet->AddWalletDescriptor(w_desc, dummy_keys, /*label=*/"", /*internal=*/false); !spkm_res) {
+ return util::Error{util::ErrorString(spkm_res)};
+ }
+
+ // Set active spkms as active
+ if (desc_info.active) {
+ // Determine whether this descriptor is internal
+ // This is only set for active spkms
+ bool internal = false;
+ if (desc_info.internal) {
+ internal = *desc_info.internal;
+ }
+ watchonly_wallet->AddActiveScriptPubKeyMan(desc_id, *Assert(w_desc.descriptor->GetOutputType()), internal);
+ }
+ }
+
+ // Copy locked coins that are persisted
+ for (const auto& [coin, persisted] : wallet.m_locked_coins) {
+ if (!persisted) continue;
+ watchonly_wallet->LockCoin(coin, persisted);
+ }
+
+ {
+ // Make a WalletBatch for the watchonly wallet so that everything else can be written atomically
+ WalletBatch watchonly_batch(watchonly_wallet->GetDatabase());
+ if (!watchonly_batch.TxnBegin()) {
+ return util::Error{strprintf(_("Error: database transaction cannot be executed for new watchonly wallet %s"), watchonly_wallet->GetName())};
+ }
+
+ // Copy orderPosNext
+ watchonly_batch.WriteOrderPosNext(wallet.nOrderPosNext);
+
+ // Write the best block locator to avoid rescanning on reload
+ CBlockLocator best_block_locator;
+ {
+ WalletBatch local_wallet_batch(wallet.GetDatabase());
+ if (!local_wallet_batch.ReadBestBlock(best_block_locator)) {
+ return util::Error{_("Error: Unable to read wallet's best block locator record")};
+ }
+ }
+ if (!watchonly_batch.WriteBestBlock(best_block_locator)) {
+ return util::Error{_("Error: Unable to write watchonly wallet best block locator record")};
+ }
+
+ // Copy the transactions
+ for (const auto& [txid, wtx] : wallet.mapWallet) {
+ if (!watchonly_wallet->LoadToWallet(txid, [&](CWalletTx& ins_wtx, bool new_tx) EXCLUSIVE_LOCKS_REQUIRED(watchonly_wallet->cs_wallet) {
+ if (!new_tx) return false;
+ ins_wtx.SetTx(wtx.tx);
+ ins_wtx.CopyFrom(wtx);
+ return true;
+ })) {
+ return util::Error{strprintf(_("Error: Could not add tx %s to watchonly wallet"), txid.GetHex())};
+ }
+ watchonly_batch.WriteTx(watchonly_wallet->mapWallet.at(txid));
+ }
+
+ // Copy address book
+ for (const auto& [dest, entry] : wallet.m_address_book) {
+ auto address{EncodeDestination(dest)};
+ if (entry.purpose) watchonly_batch.WritePurpose(address, PurposeToString(*entry.purpose));
+ if (entry.label) watchonly_batch.WriteName(address, *entry.label);
+ for (const auto& [id, request] : entry.receive_requests) {
+ watchonly_batch.WriteAddressReceiveRequest(dest, id, request);
+ }
+ if (entry.previously_spent) watchonly_batch.WriteAddressPreviouslySpent(dest, true);
+ }
+
+ if (!watchonly_batch.TxnCommit()) {
+ return util::Error{_("Error: cannot commit db transaction for watchonly wallet export")};
+ }
+ }
+
+ // Make a backup of this wallet at the specified destination directory
+ if (!watchonly_wallet->BackupWallet(fs::PathToString(destination))) {
+ return util::Error{_("Error: Unable to write the exported wallet")};
+ }
+ success = true;
+ }
+
+ return fs::PathToString(destination);
+}
} // namespace wallet
diff --git a/src/wallet/export.h b/src/wallet/export.h
index d83600be..5f228930 100644
--- a/src/wallet/export.h
+++ b/src/wallet/export.h
@@ -27,6 +27,10 @@ struct WalletDescInfo {
//! Export the descriptors from a wallet so that they can be imported elsewhere
util::Expected<std::vector<WalletDescInfo>, std::string> ExportDescriptors(const CWallet& wallet, bool export_private) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
+
+//! Make a new watchonly wallet file containing the public descriptors from this wallet
+//! The exported watchonly wallet file will be named and placed at the path specified in 'destination'
+util::Result<std::string> ExportWatchOnlyWallet(const CWallet& wallet, const fs::path& destination, WalletContext& context) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
} // namespace wallet
#endif // BITCOIN_WALLET_EXPORT_H
diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h
index 9f964e50..7c65383b 100644
--- a/src/wallet/wallet.h
+++ b/src/wallet/wallet.h
@@ -882,7 +882,7 @@ public:
*/
void postInitProcess();
- bool BackupWallet(const std::string& strDest) const;
+ [[nodiscard]] bool BackupWallet(const std::string& strDest) const;
/* Returns true if HD is enabled */
bool IsHDEnabled() const;
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.