diff --git a/doc/offline-signing-tutorial.md b/doc/offline-signing-tutorial.md index ffe156225b0..4293228a215 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](/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 d21163171d5..6c89a4a3ae0 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -306,6 +306,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 96ea3f4a706..b7d73bbfff3 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -377,6 +377,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); @@ -524,6 +528,16 @@ 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; + util::Result export_res = walletFrame->currentWalletModel()->wallet().exportWatchOnlyWallet(GUIUtil::QStringToPath(destination)); + if (export_res) { + QMessageBox::information(nullptr, tr("Export Successful"), tr("The wallet has been exported to ") + QString::fromStdString(*export_res)); + } else { + QMessageBox::critical(nullptr, tr("Export Error"), QString::fromStdString(util::ErrorString(export_res).translated)); + } + }); } #endif // ENABLE_WALLET @@ -547,6 +561,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); @@ -755,6 +770,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 2322860a710..867a97fc28b 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 67770ada78e..e5c7c5a7a3a 100644 --- a/src/script/descriptor.cpp +++ b/src/script/descriptor.cpp @@ -231,6 +231,9 @@ struct PubkeyProvider /** Get the count of keys known by this PubkeyProvider. Usually one, but may be more for key aggregation schemes */ virtual size_t GetKeyCount() const { return 1; } + + /** 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 @@ -301,6 +304,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. */ @@ -365,6 +369,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 { @@ -591,6 +596,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 */ @@ -794,6 +800,13 @@ class MuSigPubkeyProvider final : public PubkeyProvider { return 1 + m_participants.size(); } + bool CanSelfExpand() const override + { + for (const auto& key : m_participants) { + if (!key->CanSelfExpand()) return false; + } + return true; + } }; /** Base class for all Descriptor implementations. */ @@ -1083,6 +1096,18 @@ class DescriptorImpl : public Descriptor } return count; } + + // 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 0f1e799e8f1..9f426df8858 100644 --- a/src/script/descriptor.h +++ b/src/script/descriptor.h @@ -130,6 +130,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/test/descriptor_tests.cpp b/src/test/descriptor_tests.cpp index 347efd77449..a3c1895089b 100644 --- a/src/test/descriptor_tests.cpp +++ b/src/test/descriptor_tests.cpp @@ -476,6 +476,9 @@ void DoCheck(std::string prv, std::string pub, const std::string& norm_pub, int BOOST_CHECK_MESSAGE(paths.contains(origin.second.second.path), "Unexpected key path: " + prv); left_paths.erase(origin.second.second.path); } + + // Check that descriptors that have no hardened steps can self expand + BOOST_CHECK_EQUAL((t ? parse_priv : parse_pub)->CanSelfExpand(), !(flags & HARDENED)); } } @@ -509,7 +512,7 @@ void CheckMultipath(const std::string& prv, const std::vector& expanded_prvs, const std::vector& expanded_pubs, const std::vector& expanded_norm_pubs, - int flags, + const std::vector& flags, const std::vector>>& scripts, const std::optional& type, const std::vector>>& paths) @@ -519,7 +522,11 @@ void CheckMultipath(const std::string& prv, assert(expanded_prvs.size() == scripts.size()); assert(expanded_prvs.size() == paths.size()); for (size_t i = 0; i < expanded_prvs.size(); ++i) { - Check(prv, pub, expanded_norm_pubs.at(i), flags, scripts.at(i), type, std::nullopt, paths.at(i), + int flag = flags.at(0); + if (flags.size() > 1) { + flag = flags.at(i); + } + Check(prv, pub, expanded_norm_pubs.at(i), flag, scripts.at(i), type, std::nullopt, paths.at(i), /*spender_nlocktime=*/0, /*spender_nsequence=*/CTxIn::SEQUENCE_FINAL, /*preimages=*/{}, expanded_prvs.at(i), expanded_pubs.at(i), i); } @@ -659,7 +666,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "pk(xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0)", "pk(xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/1)", }, - DEFAULT, + {DEFAULT}, { {{"210379e45b3cf75f9c5f9befd8e9506fb962f6a9d185ac87001ec44a8d3df8d4a9e3ac"}}, {{"21034f8d02282ac6786737d0f37f0df7655f49daa24843bc7de3f4ea88603d26d10aac"}}, @@ -684,7 +691,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "pkh([bd16bee5/2147483647h]xpub69H7F5dQzmVd3vPuLKtcXJziMEQByuDidnX3YdwgtNsecY5HRGtAAQC5mXTt4dsv9RzyjgDjAQs9VGVV6ydYCHnprc9vvaA5YtqWyL6hyds/0)", "pkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/0/0)", }, - HARDENED, + {HARDENED, DEFAULT}, { {{"76a914ebdc90806a9c4356c1c88e42216611e1cb4c1c1788ac"}}, {{"76a914f103317b9f0b758a62cb3879281d23e3b1deb90d88ac"}}, @@ -709,7 +716,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "wpkh([ffffffff/13h]xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/1/2/*)", "wpkh([ffffffff/13h]xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH/3/2/*)", }, - RANGE, + {RANGE}, { {{"0014326b2249e3a25d5dc60935f044ee835d090ba859"},{"0014af0bd98abc2f2cae66e36896a39ffe2d32984fb7"},{"00141fa798efd1cbf95cebf912c031b8a4a6e9fb9f27"}}, {{"001426183882ef9c76b9a44386e9b387f33cee7c3a2d"},{"001447c1b9dc215c3f8b47e572981eb97528768cde4e"},{"00146e92cbaa397f9caeccf9a049460258af6ccd67e2"}}, @@ -734,7 +741,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "sh(wpkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/10/20/30/40/*h))", "sh(wpkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/100h/20/30/40/*h))", }, - RANGE | HARDENED | DERIVE_HARDENED, + {RANGE | HARDENED | DERIVE_HARDENED}, { {{"a9149a4d9901d6af519b2a23d4a2f51650fcba87ce7b87"},{"a914bed59fc0024fae941d6e20a3b44a109ae740129287"},{"a9148483aa1116eb9c05c482a72bada4b1db24af654387"}}, {{"a91470192039cb9529aadf4e53e46d9ac6a13790865787"},{"a914855859faffabf1e4ed2bb7411ab66f4599b1abd287"},{"a9148f2cfd4b486de247c44684160da164617ccf2c2687"}}, @@ -759,7 +766,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/3/0/*)", "multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/2/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/4/0/*)", }, - RANGE, + {RANGE}, { {{"522103095e95d8c50ae3f3fea93fa8e983f710489f60ff681a658c06eba64622c824b121020443e9e729b42628913f1a69b46b7d43ff87c46e86140e12ee420d7e2e8caf8c52ae"},{"5221027512d6bd74e24eeb1ad752d5be800adc5886ded11c5293a9a701db83658b526a2102371e912dea5fefa56158908fe4c9f66bc925a8939b10f3821e8f8be797b9ca8252ae"},{"522102cc9fd211dc0a1c8bb7a106ff831be0e253bc992f21d08fb8a6fd43fae51b9b892103e43eddc68afc9746c9d09ce0bf8067b4f2416287abbc422ed1ac300673b1104952ae"}}, {{"5221031c0517fff3d483f06ca769bd2326bf30aca1c4de278e676e6ef760c3301244c6210316e171ff4f82dc62ad3f0d84c97865034fc5041eaa508b48c1d7af77f301c8bd52ae"},{"52210240f010ccff4202ade2ef87756f6b9af57bbf5ebcb0393b949e6e5d45d30bff36210229057a7e03510b8cb66727fab3f47a52a02ea94eae03e7c2e81b72a26781bfde52ae"},{"5221034052522058a07b647bd08fa1a9eaedae0222eac76ddd122ff8096ec969398de721038cb8180dd4c956848bcf191e45aaf297146207559fb8737881156aadaf13704152ae"}}, @@ -787,7 +794,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "pkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/1)", "pkh(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/2)", }, - DEFAULT, + {DEFAULT}, { {{"76a9145a61ff8eb7aaca3010db97ebda76121610b7809688ac"}}, {{"76a9142f792a782cf4adbb321fe646c8e220563649b8fa88ac"}}, @@ -817,7 +824,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "sh(multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/2/0/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/*,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/4/*))", "sh(multi(2,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/3/0/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/*,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/5/*))", }, - RANGE, + {RANGE}, { {{"a914689cdf7de5836ec04fb971d128cc84858f73e11487"},{"a9142ea7dbaf0a77ee19f080cdacb3e13560e3cd9cf587"},{"a9143da854021f58f5e2d3ff6bb4fcd0ced877deb34987"}}, {{"a9143dd613d162e89b83369bbf08e5f1977cfdc9b02787"},{"a91449eef5d3df5c465b20a630c66058fe689082d8e187"},{"a91492be56babf54ea2109c577f799ba6d73948e8c3287"}}, @@ -847,7 +854,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "tr(xpub661MyMwAqRbcF3yVrV2KyYetLMYA5mCbv4BhrKwUrFE9LZM6JRR1AEt8Jq4V4C8LwtTke6YEEdCZqgXp85YRk2j74EfJKhe3QybQ9kcUjs4/7/*,{pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/2/0/*),pk(xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/4/*)})", "tr(xpub661MyMwAqRbcF3yVrV2KyYetLMYA5mCbv4BhrKwUrFE9LZM6JRR1AEt8Jq4V4C8LwtTke6YEEdCZqgXp85YRk2j74EfJKhe3QybQ9kcUjs4/8/*,{pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/3/0/*),pk(xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/5/*)})", }, - XONLY_KEYS | RANGE, + {XONLY_KEYS | RANGE}, { {{"5120993e5b1d71d14cbb0a90c57ea0fed1d5bf77d5804cee206c3dbd7e4d2c67d869"},{"51207b8f629f6d406b92ffa6284f5545085eafb837c469018b715755f619b587163b"},{"512061f52925826e51e4615007557ddbea55b22c817909d7ebcfd3c454c634643ece"}}, {{"5120633808b2156d0a6597e8b07f59c387bb4c2d5c02c4cb98f1802748e64c6abf5f"},{"5120fc5f06ded29328c170bf7e49e71c9cc8699befa2bf0a2a80802a1f32ab72d291"},{"5120fd05e2227e0dac972dff9941e332db8461bedc320c2a74def44e469ddbad9d21"}}, @@ -877,7 +884,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "tr(xpub661MyMwAqRbcF3yVrV2KyYetLMYA5mCbv4BhrKwUrFE9LZM6JRR1AEt8Jq4V4C8LwtTke6YEEdCZqgXp85YRk2j74EfJKhe3QybQ9kcUjs4/6/*,{pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/2/0/*),pk(xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/4/*)})", "tr(xpub661MyMwAqRbcF3yVrV2KyYetLMYA5mCbv4BhrKwUrFE9LZM6JRR1AEt8Jq4V4C8LwtTke6YEEdCZqgXp85YRk2j74EfJKhe3QybQ9kcUjs4/6/*,{pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/3/0/*),pk(xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/5/*)})", }, - XONLY_KEYS | RANGE, + {XONLY_KEYS | RANGE}, { {{"5120993e5b1d71d14cbb0a90c57ea0fed1d5bf77d5804cee206c3dbd7e4d2c67d869"},{"51207b8f629f6d406b92ffa6284f5545085eafb837c469018b715755f619b587163b"},{"512061f52925826e51e4615007557ddbea55b22c817909d7ebcfd3c454c634643ece"}}, {{"5120c481a8ada38d1070094f62af526d4f8aae2eb1e44d1fd961be6a25198b4da77b"},{"512034a2d31c091905e62def62b575b88beff41723d83acb02dfada2e73d9c529b40"},{"5120e0ecc278655b092962ded92a5781bd8e86e8408055de05f121e107fa211e5dfb"}}, @@ -904,7 +911,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "wsh(or_d(pk([2557c640/48h/1h/0h/2h]xpub6ArU6mnJDxmynaVKLV8FiFDaA9bVUw6efLEqt99qGK6B4QVWiNjc21JNFKkXNjgT8NCUmpFpSSBrYFtWEAqGirbqT4J1bRFpWyAnYdzmZUm/0/*),and_v(v:pkh([00aabb22/48h/1h/0h/2h]xpub6ArU6mnJDxmyogzqia2fdnD3gWHvfDtZAHdmKx4ccJwUBZd3rpgQM9qgmPAn1mqT2yh81uvGGohMkg3fNLoXZzn7sRo4a1X3KnCAVot2yuS/0/*),older(2))))", "wsh(or_d(pk([2557c640/48h/1h/0h/2h]xpub6ArU6mnJDxmynaVKLV8FiFDaA9bVUw6efLEqt99qGK6B4QVWiNjc21JNFKkXNjgT8NCUmpFpSSBrYFtWEAqGirbqT4J1bRFpWyAnYdzmZUm/1/*),and_v(v:pkh([00aabb22/48h/1h/0h/2h]xpub6ArU6mnJDxmyogzqia2fdnD3gWHvfDtZAHdmKx4ccJwUBZd3rpgQM9qgmPAn1mqT2yh81uvGGohMkg3fNLoXZzn7sRo4a1X3KnCAVot2yuS/1/*),older(2))))" }, - RANGE, + {RANGE}, { {{"0020538436a60f2a638ea9e1e1342e9b93374aa7ec559ff0a805b3a185d4ba855d7f"},{"00203a588d107d604b6913201c7c1e1722f07a0f8fb3a382744f17b9ae5f6ccfcdd7"},{"0020d30fb375f7c491a208e77c7b5d0996ca14cf4a770c2ab5981f915c0e4565c74a"}}, {{"002072b5fc3a691c48fdbaf485f27e787b4094055d4b434c90c81ed1090f3d48733b"},{"0020a9ccdf4496e5d60db4704b27494d9d74f54a16c180ff954a43ce5e3aa465113a"},{"0020d17e21820a0069ca87049513eca763f08a74b586724441e7d76fc5142bcc327c"}}, @@ -929,7 +936,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "tr(xpub6CY33ZvKuS63kwDnpNCCo5YrrgkUXgdUKdCVhd4Dru5gCB3z8wGnqFiUP98Za5pYSYF5KmvBHTY3Ra8FAJGggzBjuHS69WzN8gscPupuZwK,l:pk(xpub6E9a95u27Qqa13XEz62FUgaFMWzHnHrb54dWfMHU7K9A33eDccvUkbu1sHYoByHAgJdR326rWqn9pGZgZHz1afDprW5gGwS4gUX8Ri6aGPZ/2))", "tr(xpub6CY33ZvKuS63kwDnpNCCo5YrrgkUXgdUKdCVhd4Dru5gCB3z8wGnqFiUP98Za5pYSYF5KmvBHTY3Ra8FAJGggzBjuHS69WzN8gscPupuZwK,l:pk(xpub6E9a95u27Qqa13XEz62FUgaFMWzHnHrb54dWfMHU7K9A33eDccvUkbu1sHYoByHAgJdR326rWqn9pGZgZHz1afDprW5gGwS4gUX8Ri6aGPZ/3))", }, - XONLY_KEYS, + {XONLY_KEYS}, { {{"512094cb097990da64eebbad7b979b1326f3cbe356357abf4deb4c4ff80c7acbe902"}}, {{"5120f091450b88c606f5cbc3f0cebe89e00bc5dd27f92e22f54da06439bc0c401f41"}}, @@ -954,7 +961,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "tr(xpub6CY33ZvKuS63kwDnpNCCo5YrrgkUXgdUKdCVhd4Dru5gCB3z8wGnqFiUP98Za5pYSYF5KmvBHTY3Ra8FAJGggzBjuHS69WzN8gscPupuZwK/2,l:pk(xpub6E9a95u27Qqa13XEz62FUgaFMWzHnHrb54dWfMHU7K9A33eDccvUkbu1sHYoByHAgJdR326rWqn9pGZgZHz1afDprW5gGwS4gUX8Ri6aGPZ))", "tr(xpub6CY33ZvKuS63kwDnpNCCo5YrrgkUXgdUKdCVhd4Dru5gCB3z8wGnqFiUP98Za5pYSYF5KmvBHTY3Ra8FAJGggzBjuHS69WzN8gscPupuZwK/3,l:pk(xpub6E9a95u27Qqa13XEz62FUgaFMWzHnHrb54dWfMHU7K9A33eDccvUkbu1sHYoByHAgJdR326rWqn9pGZgZHz1afDprW5gGwS4gUX8Ri6aGPZ))", }, - XONLY_KEYS, + {XONLY_KEYS}, { {{"51200e3c14456bfa30f9f0bed6e55f35e1e9ca17c835e9f71b25bac0dfaab38ff2cd"}}, {{"51202bdda29337ecaf8fcd5aa395febac6f99b8a866a0e8fb3f7bde2e24b1a7df2ba"}}, @@ -1165,6 +1172,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) Check("tr(musig(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*,pk(KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU74sHUHy8S))","tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*,pk(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9))","tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*,pk(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9))", XONLY_KEYS | RANGE | MUSIG | MUSIG_DERIVATION, {{"51201d377b637b5c73f670f5c8a96a2c0bb0d1a682a1fca6aba91fe673501a189782"}, {"51208950c83b117a6c208d5205ffefcf75b187b32512eb7f0d8577db8d9102833036"}, {"5120a49a477c61df73691b77fcd563a80a15ea67bb9c75470310ce5c0f25918db60d"}}, OutputType::BECH32M, /*op_desc_id=*/std::nullopt, {{}, {0, 0}, {0, 1}, {0, 2}}); Check("tr(KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU74sHUHy8S,pk(musig(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*))","tr(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,pk(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*))","tr(f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9,pk(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/0/*))", XONLY_KEYS | RANGE | MUSIG | MUSIG_DERIVATION, {{"512068983d461174afc90c26f3b2821d8a9ced9534586a756763b68371a404635cc8"}, {"5120368e2d864115181bdc8bb5dc8684be8d0760d5c33315570d71a21afce4afd43e"}, {"512097a1e6270b33ad85744677418bae5f59ea9136027223bc6e282c47c167b471d5"}}, OutputType::BECH32M, /*op_desc_id=*/std::nullopt, {{}, {0, 0}, {0, 1}, {0, 2}}); Check("tr(musig(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/1,xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/1)/2)", "tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1)/2)", "tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1,xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1)/2)", XONLY_KEYS | MUSIG | MUSIG_DERIVATION | UNIQUE_XPUBS, {{"5120a17ceacd6422bd5ffd9f165807b254b7d68ad39f179cc4f11545a6835227e97c"}}, OutputType::BECH32M, /*op_desc_id=*/std::nullopt, {{1}, {2}}); + Check("rawtr(musig(xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U/2147483647'/0,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/1)","rawtr(musig(xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/2147483647'/0,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/1)","rawtr(musig([bd16bee5/2147483647h]xpub69H7F5dQzmVd3vPuLKtcXJziMEQByuDidnX3YdwgtNsecY5HRGtAAQC5mXTt4dsv9RzyjgDjAQs9VGVV6ydYCHnprc9vvaA5YtqWyL6hyds/0,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y)/1)", HARDENED | XONLY_KEYS | MUSIG | MUSIG_DERIVATION, {{"5120ebf2bcce516ef6567a9001ce6e5dc43a02bb62d37b51d86d773fa96dcd3a8d4c"}}, OutputType::BECH32M, /*op_desc_id=*/std::nullopt, {{}, {0xFFFFFFFFUL,0}, {1}}); CheckMultipath("rawtr(musig(xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc/<1;2;3>/0/*,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0/*,xprv9s21ZrQH143K3jUwNHoqQNrtzJnJmx4Yup8NkNLdVQCymYbPbJXnPhwkfTfxZfptcs3rLAPUXS39oDLgrNKQGwbGsEmJJ8BU3RzQuvShEG4/0/0/<3;4;5>/*))", "rawtr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/<1;2;3>/0/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/*,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/<3;4;5>/*))", { @@ -1182,7 +1190,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "rawtr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/2/0/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/*,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/4/*))", "rawtr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/3/0/*,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/*,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t/0/0/5/*))", }, - XONLY_KEYS | RANGE | MUSIG, + {XONLY_KEYS | RANGE | MUSIG}, { {{"51204ba445a411bd8500476ef916e6d4dd7c137a77e0637e5b0e98339210d78d595a"},{"5120800394c4f39743734c9a15eaa171476814bed0ea19ad771037c5f1ceb20244a9"},{"512011658c4e00fae6f22b9adc2b3823ff3ec6367599783788f4aa8fe1ab3dd0a7ea"}}, {{"5120b977ae89f221762a61ee986fed7a493426462483afef46f7225765e015934961"},{"5120b70bf732ed38fcc2052075f83901f8588f1016f6741aaacce6e439a02235e5ed"},{"5120d7fa329159ae543b41ca81c7b0e916824ce5d13f61de5b6246dc55a3367f8596"}}, @@ -1212,7 +1220,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "rawtr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t)/4/*)", "rawtr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t)/5/*)", }, - XONLY_KEYS | RANGE | MUSIG | MUSIG_DERIVATION, + {XONLY_KEYS | RANGE | MUSIG | MUSIG_DERIVATION}, { {{"51204a0fecdd99c67eb2afca0efa9a008c8bbc4dbb5ccb094b3eee273127b1ababee"},{"512006120155e6bfd6a3abf8a697caaf5669058395ae0052283a1c6e852d373ceccd"},{"5120d46831206710fca12ef7b562a0812250fdda110146dc1b9ac3a099c81ebcef82"}}, {{"5120f2b491de0be3b53482253865a5e0f2d2dbdc425d59db0c48f01c6bed9c6687c2"},{"5120601daf543e702b9c28a02f33961dfddfad666d9218b3b0b80177420b37619683"},{"512081dc64aac07811399defde8c959e3a66c56b621360e55ff01c2d43dfe7928b66"}}, @@ -1242,7 +1250,7 @@ BOOST_AUTO_TEST_CASE(descriptor_test) "tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t)/6/*,pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/8/*))", "tr(musig(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y,xpub661MyMwAqRbcGDZQUKLqmWodYLcoBQnQH33yYkkF3jjxeLvY8qr2wWGEWkiKFaaQfJCoi3HeEq3Dc5DptfbCyjD38fNhSqtKc1UHaP4ba3t)/6/*,pk(xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/9/*))", }, - XONLY_KEYS | RANGE | MUSIG | MUSIG_DERIVATION | MIXED_MUSIG, + {XONLY_KEYS | RANGE | MUSIG | MUSIG_DERIVATION | MIXED_MUSIG}, { {{"5120682fd07aa0f18643fd2c8a2411a0525b5391d7ad54e1904c9c0d7f524e49b39c"},{"51200410a642ea6b9ab884ba892205f484ad716cf0d8426dd739bd67d1636cdce870"},{"5120971dc3136dd90be8aa879d9b0f5c449b62e738cbf7129623098bb3b3aa57eb7d"}}, {{"51208e4158b3e54e32b4ad22f6dffb9f7968c92dfe96fd0b8fd3d30d3e2558a2a694"},{"512094273ac6b6f8ac060ba5681ba0906e54a51ab67fd08092e9b2af568b82aa1c7c"},{"5120ef0c7915708eb5baa95125e75417306391f339cfc533a19000ab8f2f53da78c1"}}, diff --git a/src/wallet/CMakeLists.txt b/src/wallet/CMakeLists.txt index 36fd3ef95ae..040569f7d65 100644 --- a/src/wallet/CMakeLists.txt +++ b/src/wallet/CMakeLists.txt @@ -10,6 +10,7 @@ add_library(bitcoin_wallet STATIC EXCLUDE_FROM_ALL crypter.cpp db.cpp dump.cpp + export.cpp external_signer_scriptpubkeyman.cpp feebumper.cpp fees.cpp diff --git a/src/wallet/export.cpp b/src/wallet/export.cpp new file mode 100644 index 00000000000..83ce244dc81 --- /dev/null +++ b/src/wallet/export.cpp @@ -0,0 +1,215 @@ +// Copyright (c) 2026-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. + +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace wallet { +util::Expected, std::string> ExportDescriptors(const CWallet& wallet, bool export_private) +{ + AssertLockHeld(wallet.cs_wallet); + std::vector wallet_descriptors; + for (const auto& spk_man : wallet.GetAllScriptPubKeyMans()) { + const auto desc_spk_man = dynamic_cast(spk_man); + if (!desc_spk_man) { + return util::Unexpected{"Unexpected ScriptPubKey manager type."}; + } + LOCK(desc_spk_man->cs_desc_man); + const auto& wallet_descriptor = desc_spk_man->GetWalletDescriptor(); + std::string descriptor; + if (!Assume(desc_spk_man->GetDescriptorString(descriptor, export_private))) { + return util::Unexpected{"Can't get descriptor string."}; + } + const bool is_range = wallet_descriptor.descriptor->IsRange(); + wallet_descriptors.emplace_back( + descriptor, + wallet_descriptor.creation_time, + wallet.IsActiveScriptPubKeyMan(*desc_spk_man), + 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 + ); + } + return wallet_descriptors; +} + +util::Result 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))}; + } + fs::remove(destination); + + // Get the descriptors from this wallet + util::Expected, std::string> exported = ExportDescriptors(wallet, /*export_private=*/false); + if (!exported) { + return util::Error{Untranslated(exported.error())}; + } + + // 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 warnings; + std::string wallet_name = wallet.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::CreateNew(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")}; + } + + // Always remove the temporary wallet files, even when returning early on error. + auto cleanup = interfaces::MakeCleanupHandler([&watchonly_wallet] { + if (!watchonly_wallet) return; + fs::path wallet_path = fs::PathFromString(watchonly_wallet->GetDatabase().Filename()).parent_path(); + std::vector files = watchonly_wallet->GetDatabase().Files(); + watchonly_wallet.reset(); + for (const auto& file : files) { + fs::remove(file); + } + fs::remove(wallet_path); + }); + + { + 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> 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(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) { + 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] : 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")}; + } + } + + return fs::PathToString(destination); +} +} // namespace wallet diff --git a/src/wallet/export.h b/src/wallet/export.h new file mode 100644 index 00000000000..5f2289302cc --- /dev/null +++ b/src/wallet/export.h @@ -0,0 +1,36 @@ +// Copyright (c) 2026-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. + +#ifndef BITCOIN_WALLET_EXPORT_H +#define BITCOIN_WALLET_EXPORT_H + +#include +#include +#include + +#include +#include + +namespace wallet { +// 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; +}; + +//! Export the descriptors from a wallet so that they can be imported elsewhere +util::Expected, 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 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/interfaces.cpp b/src/wallet/interfaces.cpp index 4eee155ce21..54752d20927 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -521,6 +522,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 ExportWatchOnlyWallet(*m_wallet, 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 396be628259..9c58e0fe3f7 100644 --- a/src/wallet/rpc/backup.cpp +++ b/src/wallet/rpc/backup.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -526,38 +527,11 @@ RPCMethod 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; - CHECK_NONFATAL(desc_spk_man->GetDescriptorString(descriptor, priv)); - const bool is_range = wallet_descriptor.descriptor->IsRange(); - wallet_descriptors.push_back({ - descriptor, - wallet_descriptor.creation_time, - active_spk_mans.contains(desc_spk_man), - 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::Expected, std::string> exported = ExportDescriptors(*wallet, priv); + if (!exported) { + throw JSONRPCError(RPC_WALLET_ERROR, exported.error()); } + 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 8aa15c7ec5c..c1e3b972222 100644 --- a/src/wallet/rpc/wallet.cpp +++ b/src/wallet/rpc/wallet.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -916,6 +917,49 @@ RPCMethod addhdkey() }; } +static RPCMethod exportwatchonlywallet() +{ + return RPCMethod{"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 RPCMethod& 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 = ExportWatchOnlyWallet(*pwallet, 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 RPCMethod getaddressinfo(); RPCMethod getnewaddress(); @@ -991,6 +1035,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 0241fe481f6..b8077b21f06 100644 --- a/src/wallet/scriptpubkeyman.cpp +++ b/src/wallet/scriptpubkeyman.cpp @@ -1172,7 +1172,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 8648af6b6c2..66a34deed72 100644 --- a/src/wallet/test/walletload_tests.cpp +++ b/src/wallet/test/walletload_tests.cpp @@ -41,6 +41,7 @@ class DummyDescriptor final : public Descriptor { std::vector Warnings() const override { return {}; } uint32_t GetMaxKeyExpr() const override { return 0; } size_t GetKeyCount() const override { return 0; } + 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 7cfe6102ecf..c2b5120d0b9 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -3815,6 +3815,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 @@ -4631,5 +4637,4 @@ void CWallet::DisconnectChainNotifications() m_chain_notifications_handler.reset(); } } - } // namespace wallet diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 2b2064c7608..204ce403582 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -881,7 +882,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; diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 698742bc79e..876fa7137b1 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -174,6 +174,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..aeb7f76428d --- /dev/null +++ b/test/functional/wallet_exported_watchonly.py @@ -0,0 +1,273 @@ +#!/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) + + # Both wallets should agree on addresses generated past the end of the keypool + offline_wallet.getnewaddress() + offline_wallet.getrawchangeaddress() + for _ in range(KEYPOOL_SIZE * 2): + assert_equal(online_wallet.getnewaddress(), offline_wallet.getnewaddress()) + assert_equal(online_wallet.getrawchangeaddress(), offline_wallet.getrawchangeaddress()) + + 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()