diff --git a/doc/offline-signing-tutorial.md b/doc/offline-signing-tutorial.md index 2b21af2441a..5454075018f 100644 --- a/doc/offline-signing-tutorial.md +++ b/doc/offline-signing-tutorial.md @@ -15,7 +15,7 @@ In this tutorial we have two hosts, both running Bitcoin v25.0 * `offline` host which is disconnected from all networks (internet, Tor, wifi, bluetooth etc.) and does not have, or need, a copy of the blockchain. * `online` host which is a regular online node with a synced blockchain. -We are going to first create an `offline_wallet` on the offline host. We will then create a `watch_only_wallet` on the online host using public key descriptors exported from the `offline_wallet`. Next we will receive some coins into the wallet. In order to spend these coins we'll create an unsigned PSBT using the `watch_only_wallet`, sign the PSBT using the private keys in the `offline_wallet`, and finally broadcast the signed PSBT using the online host. +We are going to first create an `offline_wallet` on the offline host. We will then create a `watch_only_wallet` on the online host using a wallet file exported from the `offline_wallet`. Next we will receive some coins into the wallet. In order to spend these coins we'll create an unsigned PSBT using the `watch_only_wallet`, sign the PSBT using the private keys in the `offline_wallet`, and finally broadcast the signed PSBT using the online host. ### Requirements - [jq](https://jqlang.github.io/jq/) installation - This tutorial uses jq to process certain fields from JSON RPC responses, but this convenience is optional. @@ -39,72 +39,30 @@ We are going to first create an `offline_wallet` on the offline host. We will th > [!NOTE] > The use of a passphrase is crucial to encrypt the wallet.dat file. This encryption ensures that even if an unauthorized individual gains access to the offline host, they won't be able to access the wallet's contents. Further details about securing your wallet can be found in [Managing the Wallet](https://github.com/bitcoin/bitcoin/blob/master/doc/managing-wallets.md#12-encrypting-the-wallet) -2. Export the public key-only descriptors from the offline host to a JSON file named `descriptors.json`. We use `jq` here to extract the `.descriptors` field from the full RPC response. - +2. Export the wallet in a watch-only format to a .dat file named `watch_only_wallet.dat`. ```sh -[offline]$ ./build/bin/bitcoin-cli -signet -rpcwallet="offline_wallet" listdescriptors \ - | jq -r '.descriptors' \ - >> /path/to/descriptors.json +[offline]$ ./build/bin/bitcoin-cli -signet -named exportwatchonlywallet \ + destination=/path/to/watch_only_wallet.dat ``` > [!NOTE] -> The `descriptors.json` file will be transferred to the online machine (e.g. using a USB flash drive) where it can be imported to create a related watch-only wallet. +> The `watch_only_wallet.dat` file will be transferred to the online machine (e.g. using a USB flash drive) where it can be imported to create a related watch-only wallet. ### Create the online `watch_only_wallet` -1. On the online machine create a blank watch-only wallet which has private keys disabled and is named `watch_only_wallet`. This is achieved by using the `createwallet` options: `disable_private_keys=true, blank=true`. - +On the online machine import the watch-only wallet. This wallet will have the private keys disabled and is named `watch_only_wallet`. This is achieved by using the `restorewallet` rpc call. The `watch_only_wallet` wallet will be used to track and validate incoming transactions, create unsigned PSBTs when spending coins, and broadcast signed and finalized PSBTs. -> [!NOTE] -> `disable_private_keys` indicates that the wallet should refuse to import private keys, i.e. will be a dedicated watch-only wallet. - ```sh -[online]$ ./build/bin/bitcoin-cli -signet -named createwallet \ +[online]$ ./build/bin/bitcoin-cli -signet -named restorewallet \ wallet_name="watch_only_wallet" \ - disable_private_keys=true \ - blank=true + backup_file=/path/to/watch_only_wallet.dat { "name": "watch_only_wallet" } ``` -2. Import the `offline_wallet`s public key descriptors to the online `watch_only_wallet` using the `descriptors.json` file created on the offline wallet. - -```sh -[online]$ ./build/bin/bitcoin-cli -signet -rpcwallet="watch_only_wallet" importdescriptors "$(cat /path/to/descriptors.json)" - -[ - { - "success": true - }, - { - "success": true - }, - { - "success": true - }, - { - "success": true - }, - { - "success": true - }, - { - "success": true - }, - { - "success": true - }, - { - "success": true - } -] -``` -> [!NOTE] -> Multiple success values indicate that multiple descriptors, for different address types, have been successfully imported. This allows generating different address types on the `watch_only_wallet`. - ### Fund the `offline_wallet` At this point, it's important to understand that both the `offline_wallet` and online `watch_only_wallet` share the same public keys. As a result, they generate the same addresses. Transactions can be created using either wallet, but valid signatures can only be added by the `offline_wallet` as only it has the private keys. diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 412cbb613e3..a7fa6c1f5d9 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -310,6 +310,9 @@ class Wallet //! Return pointer to internal wallet class, useful for testing. virtual wallet::CWallet* wallet() { return nullptr; } + + //! Export a watchonly wallet file. See CWallet::ExportWatchOnlyWallet + virtual util::Result exportWatchOnlyWallet(const fs::path& destination) = 0; }; //! Wallet chain client that in addition to having chain client methods for diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 9413356b412..d515e06dce0 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -371,6 +371,10 @@ void BitcoinGUI::createActions() m_mask_values_action->setStatusTip(tr("Mask the values in the Overview tab")); m_mask_values_action->setCheckable(true); + m_export_watchonly_action = new QAction(tr("Export watch-only wallet"), this); + m_export_watchonly_action->setEnabled(false); + m_export_watchonly_action->setStatusTip(tr("Export a watch-only version of the current wallet that can be restored onto another node.")); + connect(quitAction, &QAction::triggered, this, &BitcoinGUI::quitRequested); connect(aboutAction, &QAction::triggered, this, &BitcoinGUI::aboutClicked); connect(aboutQtAction, &QAction::triggered, qApp, QApplication::aboutQt); @@ -488,6 +492,11 @@ void BitcoinGUI::createActions() }); connect(m_mask_values_action, &QAction::toggled, this, &BitcoinGUI::setPrivacy); connect(m_mask_values_action, &QAction::toggled, this, &BitcoinGUI::enableHistoryAction); + connect(m_export_watchonly_action, &QAction::triggered, [this] { + QString destination = GUIUtil::getSaveFileName(this, tr("Save Watch-only Wallet Export"), QString(), QString(), nullptr); + if (destination.isEmpty()) return; + walletFrame->currentWalletModel()->wallet().exportWatchOnlyWallet(GUIUtil::QStringToPath(destination)); + }); } #endif // ENABLE_WALLET @@ -511,6 +520,7 @@ void BitcoinGUI::createMenuBar() file->addSeparator(); file->addAction(backupWalletAction); file->addAction(m_restore_wallet_action); + file->addAction(m_export_watchonly_action); file->addSeparator(); file->addAction(openAction); file->addAction(signMessageAction); @@ -719,6 +729,7 @@ void BitcoinGUI::setWalletController(WalletController* wallet_controller, bool s m_restore_wallet_action->setEnabled(true); m_migrate_wallet_action->setEnabled(true); m_migrate_wallet_action->setMenu(m_migrate_wallet_menu); + m_export_watchonly_action->setEnabled(true); GUIUtil::ExceptionSafeConnect(wallet_controller, &WalletController::walletAdded, this, &BitcoinGUI::addWallet); connect(wallet_controller, &WalletController::walletRemoved, this, &BitcoinGUI::removeWallet); diff --git a/src/qt/bitcoingui.h b/src/qt/bitcoingui.h index 32fb7488fb0..acb69b3f674 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -163,6 +163,7 @@ class BitcoinGUI : public QMainWindow QAction* m_mask_values_action{nullptr}; QAction* m_migrate_wallet_action{nullptr}; QMenu* m_migrate_wallet_menu{nullptr}; + QAction* m_export_watchonly_action{nullptr}; QLabel *m_wallet_selector_label = nullptr; QComboBox* m_wallet_selector = nullptr; diff --git a/src/script/descriptor.cpp b/src/script/descriptor.cpp index bd819d365ae..6201ac05c89 100644 --- a/src/script/descriptor.cpp +++ b/src/script/descriptor.cpp @@ -225,6 +225,9 @@ struct PubkeyProvider /** Whether this PubkeyProvider is a BIP 32 extended key that can be derived from */ virtual bool IsBIP32() const = 0; + + /** Whether this PubkeyProvider can always provide a public key without cache or private key arguments */ + virtual bool CanSelfExpand() const = 0; }; class OriginPubkeyProvider final : public PubkeyProvider @@ -295,6 +298,7 @@ class OriginPubkeyProvider final : public PubkeyProvider { return std::make_unique(m_expr_index, m_origin, m_provider->Clone(), m_apostrophe); } + bool CanSelfExpand() const override { return m_provider->CanSelfExpand(); } }; /** An object representing a parsed constant public key in a descriptor. */ @@ -356,6 +360,7 @@ class ConstPubkeyProvider final : public PubkeyProvider { return std::make_unique(m_expr_index, m_pubkey, m_xonly); } + bool CanSelfExpand() const final { return true; } }; enum class DeriveType { @@ -579,6 +584,7 @@ class BIP32PubkeyProvider final : public PubkeyProvider { return std::make_unique(m_expr_index, m_root_extkey, m_path, m_derive, m_apostrophe); } + bool CanSelfExpand() const override { return !IsHardened(); } }; /** PubkeyProvider for a musig() expression */ @@ -787,6 +793,13 @@ class MuSigPubkeyProvider final : public PubkeyProvider // musig() can only be a BIP 32 key if all participants are bip32 too return std::all_of(m_participants.begin(), m_participants.end(), [](const auto& pubkey) { return pubkey->IsBIP32(); }); } + bool CanSelfExpand() const override + { + for (const auto& key : m_participants) { + if (!key->CanSelfExpand()) return false; + } + return true; + } }; /** Base class for all Descriptor implementations. */ @@ -996,6 +1009,18 @@ class DescriptorImpl : public Descriptor } virtual std::unique_ptr Clone() const = 0; + + // NOLINTNEXTLINE(misc-no-recursion) + bool CanSelfExpand() const override + { + for (const auto& key : m_pubkey_args) { + if (!key->CanSelfExpand()) return false; + } + for (const auto& sub : m_subdescriptor_args) { + if (!sub->CanSelfExpand()) return false; + } + return true; + } }; /** A parsed addr(A) descriptor. */ diff --git a/src/script/descriptor.h b/src/script/descriptor.h index 473649a3144..e1147e64f8f 100644 --- a/src/script/descriptor.h +++ b/src/script/descriptor.h @@ -117,6 +117,9 @@ struct Descriptor { /** Convert the descriptor to a normalized string. Normalized descriptors have the xpub at the last hardened step. This fails if the provided provider does not have the private keys to derive that xpub. */ virtual bool ToNormalizedString(const SigningProvider& provider, std::string& out, const DescriptorCache* cache = nullptr) const = 0; + /** Whether the descriptor can be used to get more addresses without needing a cache or private keys. */ + virtual bool CanSelfExpand() const = 0; + /** Expand a descriptor at a specified position. * * @param[in] pos The position at which to expand the descriptor. If IsRange() is false, this is ignored. diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 36f8fd9a77a..2c30db8a0e8 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -538,6 +538,11 @@ class WalletImpl : public Wallet } CWallet* wallet() override { return m_wallet.get(); } + util::Result exportWatchOnlyWallet(const fs::path& destination) override { + LOCK(m_wallet->cs_wallet); + return m_wallet->ExportWatchOnlyWallet(destination, m_context); + } + WalletContext& m_context; std::shared_ptr m_wallet; }; diff --git a/src/wallet/rpc/backup.cpp b/src/wallet/rpc/backup.cpp index e8109214856..2cd8a9ad127 100644 --- a/src/wallet/rpc/backup.cpp +++ b/src/wallet/rpc/backup.cpp @@ -495,40 +495,11 @@ RPCHelpMan listdescriptors() } LOCK(wallet->cs_wallet); - - const auto active_spk_mans = wallet->GetActiveScriptPubKeyMans(); - - struct WalletDescInfo { - std::string descriptor; - uint64_t creation_time; - bool active; - std::optional internal; - std::optional> range; - int64_t next_index; - }; - - std::vector wallet_descriptors; - for (const auto& spk_man : wallet->GetAllScriptPubKeyMans()) { - const auto desc_spk_man = dynamic_cast(spk_man); - if (!desc_spk_man) { - throw JSONRPCError(RPC_WALLET_ERROR, "Unexpected ScriptPubKey manager type."); - } - LOCK(desc_spk_man->cs_desc_man); - const auto& wallet_descriptor = desc_spk_man->GetWalletDescriptor(); - std::string descriptor; - if (!desc_spk_man->GetDescriptorString(descriptor, priv)) { - throw JSONRPCError(RPC_WALLET_ERROR, "Can't get descriptor string."); - } - const bool is_range = wallet_descriptor.descriptor->IsRange(); - wallet_descriptors.push_back({ - descriptor, - wallet_descriptor.creation_time, - active_spk_mans.count(desc_spk_man) != 0, - wallet->IsInternalScriptPubKeyMan(desc_spk_man), - is_range ? std::optional(std::make_pair(wallet_descriptor.range_start, wallet_descriptor.range_end)) : std::nullopt, - wallet_descriptor.next_index - }); + util::Result> exported = wallet->ExportDescriptors(priv); + if (!exported) { + throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(exported).original); } + std::vector wallet_descriptors = *exported; std::sort(wallet_descriptors.begin(), wallet_descriptors.end(), [](const auto& a, const auto& b) { return a.descriptor < b.descriptor; diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp index 555a24c98ae..1b397486830 100644 --- a/src/wallet/rpc/wallet.cpp +++ b/src/wallet/rpc/wallet.cpp @@ -846,6 +846,49 @@ static RPCHelpMan createwalletdescriptor() }; } +static RPCHelpMan exportwatchonlywallet() +{ + return RPCHelpMan{"exportwatchonlywallet", + "Creates a wallet file at the specified destination containing a watchonly version " + "of the current wallet. This watchonly wallet contains the wallet's public descriptors, " + "its transactions, and address book data. Descriptors that use hardened derivation will " + "only have a limited number of derived keys included in the export due to hardened " + "derivation requiring private keys. Descriptors with unhardened derivation do not have " + "this limitation. The watchonly wallet can be imported into another node using 'restorewallet'.", + { + {"destination", RPCArg::Type::STR, RPCArg::Optional::NO, "The path to the filename the exported watchonly wallet will be saved to"}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR, "exported_file", "The full path that the file has been exported to"}, + }, + }, + RPCExamples{ + HelpExampleCli("exportwatchonlywallet", "\"home\\user\\export.dat\"") + + HelpExampleRpc("exportwatchonlywallet", "\"home\\user\\export.dat\"") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue + { + std::shared_ptr const pwallet = GetWalletForJSONRPCRequest(request); + if (!pwallet) return UniValue::VNULL; + WalletContext& context = EnsureWalletContext(request.context); + + std::string dest = request.params[0].get_str(); + + LOCK(pwallet->cs_wallet); + pwallet->TopUpKeyPool(); + util::Result exported = pwallet->ExportWatchOnlyWallet(fs::PathFromString(dest), context); + if (!exported) { + throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(exported).original); + } + UniValue out{UniValue::VOBJ}; + out.pushKV("exported_file", *exported); + return out; + } + }; +} + // addresses RPCHelpMan getaddressinfo(); RPCHelpMan getnewaddress(); @@ -921,6 +964,7 @@ std::span GetWalletRPCCommands() {"wallet", &createwalletdescriptor}, {"wallet", &restorewallet}, {"wallet", &encryptwallet}, + {"wallet", &exportwatchonlywallet}, {"wallet", &getaddressesbylabel}, {"wallet", &getaddressinfo}, {"wallet", &getbalance}, diff --git a/src/wallet/scriptpubkeyman.cpp b/src/wallet/scriptpubkeyman.cpp index e90573a3392..ea0ae6bc1d0 100644 --- a/src/wallet/scriptpubkeyman.cpp +++ b/src/wallet/scriptpubkeyman.cpp @@ -1176,7 +1176,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()); } bool DescriptorScriptPubKeyMan::HavePrivateKeys() const diff --git a/src/wallet/test/walletload_tests.cpp b/src/wallet/test/walletload_tests.cpp index 0c69849d0b6..79da5ca268b 100644 --- a/src/wallet/test/walletload_tests.cpp +++ b/src/wallet/test/walletload_tests.cpp @@ -35,6 +35,7 @@ class DummyDescriptor final : public Descriptor { std::optional MaxSatisfactionWeight(bool) const override { return {}; } std::optional MaxSatisfactionElems() const override { return {}; } void GetPubKeys(std::set& pubkeys, std::set& ext_pubs) const override {} + bool CanSelfExpand() const final { return false; } }; BOOST_FIXTURE_TEST_CASE(wallet_load_descriptors, TestingSetup) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 1eae6a971a0..43b55cb66bb 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -75,6 +75,7 @@ #include #include #include +#include #include #include #include @@ -3704,6 +3705,12 @@ util::Result> CWallet::AddWall // Save the descriptor to memory uint256 id = new_spk_man->GetID(); AddScriptPubKeyMan(id, std::move(new_spk_man)); + + // Write the existing cache to disk + WalletBatch batch(GetDatabase()); + if (!batch.WriteDescriptorCacheItems(id, desc.cache)) { + return util::Error{_("Unable to write descriptor cache")}; + } } // Add the private keys to the descriptor @@ -4474,4 +4481,196 @@ std::optional CWallet::GetTXO(const COutPoint& outpoint) const } return it->second; } + +util::Result> CWallet::ExportDescriptors(bool export_private) const +{ + AssertLockHeld(cs_wallet); + std::vector wallet_descriptors; + for (const auto& spk_man : GetAllScriptPubKeyMans()) { + const auto desc_spk_man = dynamic_cast(spk_man); + if (!desc_spk_man) { + return util::Error{Untranslated("Unexpected ScriptPubKey manager type.")}; + } + LOCK(desc_spk_man->cs_desc_man); + const auto& wallet_descriptor = desc_spk_man->GetWalletDescriptor(); + std::string descriptor; + if (!desc_spk_man->GetDescriptorString(descriptor, export_private)) { + return util::Error{Untranslated("Can't get descriptor string.")}; + } + const bool is_range = wallet_descriptor.descriptor->IsRange(); + wallet_descriptors.push_back({ + descriptor, + wallet_descriptor.creation_time, + IsActiveScriptPubKeyMan(*desc_spk_man), + IsInternalScriptPubKeyMan(desc_spk_man), + is_range ? std::optional(std::make_pair(wallet_descriptor.range_start, wallet_descriptor.range_end)) : std::nullopt, + wallet_descriptor.next_index + }); + } + return wallet_descriptors; +} + +util::Result CWallet::ExportWatchOnlyWallet(const fs::path& destination, WalletContext& context) const +{ + AssertLockHeld(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{destination}) { + return util::Error{strprintf(_("Error: Could not create file '%s'"), fs::PathToString(destination))}; + } + fs::remove(destination); + + // Get the descriptors from this wallet + util::Result> exported = ExportDescriptors(/*export_private=*/false); + if (!exported) { + return util::Error{util::ErrorString(exported)}; + } + + // 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 = GetWalletFlags() | WALLET_FLAG_DISABLE_PRIVATE_KEYS; + + // Make the watchonly wallet + DatabaseStatus status; + std::vector warnings; + std::string wallet_name = GetName() + "_watchonly_temp"; + bilingual_str error; + std::unique_ptr database = MakeWalletDatabase(wallet_name, options, status, error); + if (!database) { + return util::Error{strprintf(_("Wallet file creation failed: %s"), error)}; + } + WalletContext empty_context; + empty_context.args = context.args; + std::shared_ptr watchonly_wallet = CWallet::Create(empty_context, wallet_name, std::move(database), options.create_flags, error, warnings); + if (!watchonly_wallet) { + return util::Error{_("Error: Failed to create new watchonly wallet")}; + } + + { + LOCK(watchonly_wallet->cs_wallet); + + // Parse the descriptors and add them to the new wallet + for (const WalletDescInfo& desc_info : *exported) { + // Parse the descriptor + FlatSigningProvider dummy_keys; + std::string dummy_err; + std::vector> descs = Parse(desc_info.descriptor, dummy_keys, dummy_err, /*require_checksum=*/true); + assert(descs.size() == 1); // All of our descriptors should be valid, and not multipath + assert(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(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, *w_desc.descriptor->GetOutputType(), internal); + } + } + + // Copy locked coins that are persisted + for (const auto& [coin, persisted] : 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(watchonly_wallet->nOrderPosNext); + + // Write the best block locator to avoid rescanning on reload + CBlockLocator best_block_locator; + { + WalletBatch local_wallet_batch(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] : mapWallet) { + const CWalletTx& to_copy_wtx = wtx; + 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(to_copy_wtx.tx); + ins_wtx.CopyFrom(to_copy_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] : 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")}; + } + } + + // 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); + + return fs::PathToString(destination); +} } // namespace wallet diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 8a97abc3f5e..ba75cb43eb1 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -303,6 +303,18 @@ struct CRecipient bool fSubtractFeeFromAmount; }; +// Struct containing all of the info from WalletDescriptor, except with the descriptor as a string, +// and without its ID or cache. +// Used when exporting descriptors from the wallet. +struct WalletDescInfo { + std::string descriptor; + uint64_t creation_time; + bool active; + std::optional internal; + std::optional> range; + int64_t next_index; +}; + class WalletRescanReserver; //forward declarations for ScanForWalletTransactions/RescanFromTime /** * A CWallet maintains a set of transactions and balances, and provides the ability to create new transactions. @@ -883,7 +895,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati */ 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; @@ -1065,6 +1077,13 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati //! Find the private key for the given key id from the wallet's descriptors, if available //! Returns nullopt when no descriptor has the key or if the wallet is locked. std::optional GetKey(const CKeyID& keyid) const; + + //! Export the descriptors from this wallet so that they can be imported elsewhere + util::Result> ExportDescriptors(bool export_private) const EXCLUSIVE_LOCKS_REQUIRED(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 ExportWatchOnlyWallet(const fs::path& destination, WalletContext& context) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); }; /** diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index f2e514a7a46..f40f2ab7c5b 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -162,6 +162,7 @@ 'wallet_fast_rescan.py', 'wallet_gethdkeys.py', 'wallet_createwalletdescriptor.py', + 'wallet_exported_watchonly.py', 'interface_zmq.py', 'rpc_invalid_address_message.py', 'rpc_validateaddress.py', diff --git a/test/functional/wallet_exported_watchonly.py b/test/functional/wallet_exported_watchonly.py new file mode 100755 index 00000000000..579ab03f31a --- /dev/null +++ b/test/functional/wallet_exported_watchonly.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025-present The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php. + +import os + +from test_framework.descriptors import descsum_create +from test_framework.key import H_POINT +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + assert_not_equal, + assert_raises_rpc_error, +) +from test_framework.wallet_util import generate_keypair + +KEYPOOL_SIZE = 10 + +class WalletExportedWatchOnly(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 2 + self.extra_args = [[], [f"-keypool={KEYPOOL_SIZE}"]] + + def setup_network(self): + # Setup the nodes but don't connect them to each other + self.setup_nodes() + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def export_and_restore(self, wallet, export_name): + export_path = os.path.join(self.export_path, f"{export_name}.dat") + res = wallet.exportwatchonlywallet(export_path) + assert_equal(res["exported_file"], export_path) + self.online.restorewallet(export_name, res["exported_file"]) + return self.online.get_wallet_rpc(export_name) + + def test_basic_export(self): + self.log.info("Test basic watchonly wallet export") + self.offline.createwallet("basic") + offline_wallet = self.offline.get_wallet_rpc("basic") + + # Bad RPC args + assert_raises_rpc_error(-4, "Error: Export ", offline_wallet.exportwatchonlywallet, "") + assert_raises_rpc_error(-4, "Error: Export destination '.' already exists", offline_wallet.exportwatchonlywallet, ".") + assert_raises_rpc_error(-4, f"Error: Export destination '{self.export_path}' already exists", offline_wallet.exportwatchonlywallet, self.export_path) + + # Export the watchonly wallet file and load onto online node + online_wallet = self.export_and_restore(offline_wallet, "basic_watchonly") + + # Exporting watchonly from a watchonly also works + online_wallet2 = self.export_and_restore(online_wallet, "basic_watchonly2") + + # Verify that the wallets have the same descriptors + addr = offline_wallet.getnewaddress() + assert_equal(addr, online_wallet.getnewaddress()) + assert_equal(addr, online_wallet2.getnewaddress()) + assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet.listdescriptors()["descriptors"]) + assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet2.listdescriptors()["descriptors"]) + + # Verify that online wallet cannot spend, but offline can + self.funder.sendtoaddress(online_wallet.getnewaddress(), 10) + self.generate(self.online, 1, sync_fun=self.no_op) + assert_equal(online_wallet.getbalances()["mine"]["trusted"], 10) + assert_equal(offline_wallet.getbalances()["mine"]["trusted"], 0) + funds_addr = self.funder.getnewaddress() + send_res = online_wallet.send([{funds_addr: 5}]) + assert_equal(send_res["complete"], False) + assert "psbt" in send_res + signed_psbt = offline_wallet.walletprocesspsbt(send_res["psbt"])["psbt"] + finalized = self.online.finalizepsbt(signed_psbt)["hex"] + self.online.sendrawtransaction(finalized) + + # Verify that the change address is known to both wallets + dec_tx = self.online.decoderawtransaction(finalized) + for txout in dec_tx["vout"]: + if txout["scriptPubKey"]["address"] == funds_addr: + continue + assert_equal(online_wallet.getaddressinfo(txout["scriptPubKey"]["address"])["ismine"], True) + assert_equal(offline_wallet.getaddressinfo(txout["scriptPubKey"]["address"])["ismine"], True) + + self.generate(self.online, 1, sync_fun=self.no_op) + offline_wallet.unloadwallet() + online_wallet.unloadwallet() + + def test_export_with_address_book(self): + self.log.info("Test all address book entries appear in the exported wallet") + self.offline.createwallet("addrbook") + offline_wallet = self.offline.get_wallet_rpc("addrbook") + + # Create some address book entries + receive_addr = offline_wallet.getnewaddress(label="addrbook_receive") + send_addr = self.funder.getnewaddress() + offline_wallet.setlabel(send_addr, "addrbook_send") # Sets purpose "send" + + # Export the watchonly wallet file and load onto online node + online_wallet = self.export_and_restore(offline_wallet, "addrbook_watchonly") + + # Verify the labels are in both wallets + for wallet in [online_wallet, offline_wallet]: + for purpose in ["receive", "send"]: + label = f"addrbook_{purpose}" + assert_equal(wallet.listlabels(purpose), [label]) + addr = send_addr if purpose == "send" else receive_addr + assert_equal(wallet.getaddressesbylabel(label), {addr: {"purpose": purpose}}) + + offline_wallet.unloadwallet() + online_wallet.unloadwallet() + + def test_export_with_txs_and_locked_coins(self): + self.log.info("Test all transactions and locked coins appear in the exported wallet") + self.offline.createwallet("txs") + offline_wallet = self.offline.get_wallet_rpc("txs") + + # In order to make transactions in the offline wallet, briefly connect offline to online + self.connect_nodes(self.offline.index, self.online.index) + txids = [self.funder.sendtoaddress(offline_wallet.getnewaddress("funds"), i) for i in range(1, 4)] + self.generate(self.online, 1) + self.disconnect_nodes(self.offline.index ,self.online.index) + + # lock some coins + persistent_lock = [{"txid": txids[0], "vout": 0}] + temp_lock = [{"txid": txids[1], "vout": 0}] + offline_wallet.lockunspent(unlock=False, transactions=persistent_lock, persistent=True) + offline_wallet.lockunspent(unlock=False, transactions=temp_lock, persistent=False) + + # Export the watchonly wallet file and load onto online node + online_wallet = self.export_and_restore(offline_wallet, "txs_watchonly") + + # Verify the transactions are in both wallets + for txid in txids: + assert_equal(online_wallet.gettransaction(txid), offline_wallet.gettransaction(txid)) + + # Verify that the persistent locked coin is locked in both wallets + assert_equal(online_wallet.listlockunspent(), persistent_lock) + assert_equal(sorted(offline_wallet.listlockunspent(), key=lambda x: x["txid"]), sorted(persistent_lock + temp_lock, key=lambda x: x["txid"])) + + offline_wallet.unloadwallet() + online_wallet.unloadwallet() + + def test_export_imported_descriptors(self): + self.log.info("Test imported descriptors are exported to the watchonly wallet") + self.offline.createwallet("imports") + offline_wallet = self.offline.get_wallet_rpc("imports") + + import_res = offline_wallet.importdescriptors( + [ + # A single key, non-ranged + {"desc": descsum_create(f"pkh({generate_keypair(wif=True)[0]})"), "timestamp": "now"}, + # hardened derivation + {"desc": descsum_create("sh(wpkh(tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/0'/*'))"), "timestamp": "now", "active": True}, + # multisig + {"desc": descsum_create("wsh(multi(1,tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/*,tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/*))"), "timestamp": "now", "active": True, "internal": True}, + # taproot multi scripts + {"desc": descsum_create(f"tr({H_POINT},{{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/*),pk(tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/0h/*)}})"), "timestamp": "now", "active": True}, + # miniscript + {"desc": descsum_create(f"tr({H_POINT},or_b(pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/1/2/*),s:pk(tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/1h/2/*)))"), "timestamp": "now", "active": True, "internal": True}, + ] + ) + assert_equal(all([r["success"] for r in import_res]), True) + + # Export the watchonly wallet file and load onto online node + online_wallet = self.export_and_restore(offline_wallet, "imports_watchonly") + + # Verify public descriptors are the same + assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet.listdescriptors()["descriptors"]) + + # Verify all the addresses are the same + for address_type in ["legacy", "p2sh-segwit", "bech32", "bech32m"]: + for internal in [False, True]: + if internal: + addr = offline_wallet.getrawchangeaddress(address_type=address_type) + assert_equal(addr, online_wallet.getrawchangeaddress(address_type=address_type)) + else: + addr = offline_wallet.getnewaddress(address_type=address_type) + assert_equal(addr, online_wallet.getnewaddress(address_type=address_type)) + self.funder.sendtoaddress(addr, 1) + self.generate(self.online, 1, sync_fun=self.no_op) + + # The hardened derivation should have KEYPOOL_SIZE - 1 remaining addresses + for _ in range(KEYPOOL_SIZE - 1): + online_wallet.getnewaddress(address_type="p2sh-segwit") + assert_raises_rpc_error(-12, "No addresses available", online_wallet.getnewaddress, address_type="p2sh-segwit") + + # Verify that the offline wallet can sign and send + send_res = online_wallet.sendall([self.funder.getnewaddress()]) + assert_equal(send_res["complete"], False) + assert "psbt" in send_res + signed_psbt = offline_wallet.walletprocesspsbt(send_res["psbt"])["psbt"] + finalized = self.online.finalizepsbt(signed_psbt)["hex"] + self.online.sendrawtransaction(finalized) + + self.generate(self.online, 1, sync_fun=self.no_op) + offline_wallet.unloadwallet() + online_wallet.unloadwallet() + + def test_avoid_reuse(self): + self.log.info("Test that the avoid reuse flag appears in the exported wallet") + self.offline.createwallet(wallet_name="avoidreuse", avoid_reuse=True) + offline_wallet = self.offline.get_wallet_rpc("avoidreuse") + assert_equal(offline_wallet.getwalletinfo()["avoid_reuse"], True) + + # The avoid_reuse flag also sets some specific address book entries to track reused addresses + # In order for these to be set, a few transactions need to be made, so briefly connect offline to online + self.connect_nodes(self.offline.index, self.online.index) + addr = offline_wallet.getnewaddress() + self.funder.sendtoaddress(addr, 1) + self.generate(self.online, 1) + # Spend funds in order to mark addr as previously spent + offline_wallet.sendall([self.funder.getnewaddress()]) + self.funder.sendtoaddress(addr, 1) + self.generate(self.online, 1) + assert_equal(offline_wallet.listunspent(addresses=[addr])[0]["reused"], True) + self.disconnect_nodes(self.offline.index ,self.online.index) + + # Export the watchonly wallet file and load onto online node + online_wallet = self.export_and_restore(offline_wallet, "avoidreuse_watchonly") + + # check avoid_reuse is still set + assert_equal(online_wallet.getwalletinfo()["avoid_reuse"], True) + assert_equal(online_wallet.listunspent(addresses=[addr])[0]["reused"], True) + + offline_wallet.unloadwallet() + online_wallet.unloadwallet() + + def test_encrypted_wallet(self): + self.log.info("Test that a watchonly wallet can be exported from a locked wallet") + self.offline.createwallet(wallet_name="encrypted", passphrase="pass") + offline_wallet = self.offline.get_wallet_rpc("encrypted") + assert_equal(offline_wallet.getwalletinfo()["unlocked_until"], 0) + + # Export the watchonly wallet file and load onto online node + online_wallet = self.export_and_restore(offline_wallet, "encrypted_watchonly") + + # watchonly wallet does not have encryption because it doesn't have private keys + assert "unlocked_until" not in online_wallet.getwalletinfo() + # But it still has all of the public descriptors + assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet.listdescriptors()["descriptors"]) + + offline_wallet.unloadwallet() + online_wallet.unloadwallet() + + def run_test(self): + self.online = self.nodes[0] + self.offline = self.nodes[1] + self.funder = self.online.get_wallet_rpc(self.default_wallet_name) + self.export_path = os.path.join(self.options.tmpdir, "exported_wallets") + os.makedirs(self.export_path, exist_ok=True) + + # Mine some blocks, and verify disconnected + self.generate(self.online, 101, sync_fun=self.no_op) + assert_not_equal(self.online.getbestblockhash(), self.offline.getbestblockhash()) + assert_equal(self.online.getblockcount(), 101) + assert_equal(self.offline.getblockcount(), 0) + + self.test_basic_export() + self.test_export_with_address_book() + self.test_export_with_txs_and_locked_coins() + self.test_export_imported_descriptors() + self.test_avoid_reuse() + self.test_encrypted_wallet() + +if __name__ == '__main__': + WalletExportedWatchOnly(__file__).main()