From 1192e94294a34535200cdd4250a3d91b9dfcf13b Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 12 May 2025 15:41:53 -0700 Subject: [PATCH 1/9] descriptor: Add CanSelfExpand() CanSelfExpand() reports whether a descriptor can be expanded without needing any caches or private keys to be provided by the caller of Expand(). --- src/script/descriptor.cpp | 25 +++++++++++++++++ src/script/descriptor.h | 3 ++ src/test/descriptor_tests.cpp | 42 +++++++++++++++++----------- src/wallet/test/walletload_tests.cpp | 1 + 4 files changed, 54 insertions(+), 17 deletions(-) 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/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) From a402b9c48eb5a49734ea9e1fe6ef58bc484e7d5b Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 12 May 2025 15:42:51 -0700 Subject: [PATCH 2/9] wallet: Use Descriptor::CanSelfExpand() in CanGetAddresses() If a descriptor does not need any caches or private keys in order to expand, then CanGetAddresses() should return true for that descriptor. --- src/wallet/scriptpubkeyman.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From cf4653dfd1b3d56173c11e895f841959c87a35e6 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Tue, 13 May 2025 14:36:04 -0700 Subject: [PATCH 3/9] wallet: Write new descriptor's cache in AddWalletDescriptor If a new WalletDescriptor is provided to us with a cache, write the cache to disk as well. --- src/wallet/wallet.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 7cfe6102ecf..1ab9e1c0866 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 From 6cbb698149b21f9b73d340a14d02f72c245b9bf1 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 12 May 2025 12:35:53 -0700 Subject: [PATCH 4/9] wallet: Move listdescriptors retrieving from RPC to CWallet When listdescriptors retrieves the descriptors from the wallet, instead of having this logic in the RPC, move it into CWallet itself. This will enable other functions to get the descriptors in an exportable form. --- src/wallet/CMakeLists.txt | 1 + src/wallet/export.cpp | 39 +++++++++++++++++++++++++++++++++++++++ src/wallet/export.h | 32 ++++++++++++++++++++++++++++++++ src/wallet/rpc/backup.cpp | 36 +++++------------------------------- src/wallet/wallet.cpp | 1 - src/wallet/wallet.h | 1 + 6 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 src/wallet/export.cpp create mode 100644 src/wallet/export.h 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..9c042d3b99e --- /dev/null +++ b/src/wallet/export.cpp @@ -0,0 +1,39 @@ +// 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 + +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; +} +} // namespace wallet diff --git a/src/wallet/export.h b/src/wallet/export.h new file mode 100644 index 00000000000..d83600be2a6 --- /dev/null +++ b/src/wallet/export.h @@ -0,0 +1,32 @@ +// 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); +} // namespace wallet + +#endif // BITCOIN_WALLET_EXPORT_H 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/wallet.cpp b/src/wallet/wallet.cpp index 1ab9e1c0866..c2b5120d0b9 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -4637,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..5b9be5dfac0 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include From 039ec2ca303c819242d4cc6e2de923be5c7eee14 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 12 May 2025 13:39:26 -0700 Subject: [PATCH 5/9] wallet: Add CWallet::ExportWatchOnly ExportWatchOnly produces a watchonly wallet file from a CWallet. This can be restored onto another instance of Bitcoin Core to allow that instance to watch the same descriptors, and also have all of the same initial address book and transactions. --- src/wallet/export.cpp | 176 ++++++++++++++++++++++++++++++++++++++++++ src/wallet/export.h | 4 + src/wallet/wallet.h | 2 +- 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/wallet/export.cpp b/src/wallet/export.cpp index 9c042d3b99e..83ce244dc81 100644 --- a/src/wallet/export.cpp +++ b/src/wallet/export.cpp @@ -4,10 +4,15 @@ #include +#include +#include #include #include +#include #include +#include + namespace wallet { util::Expected, std::string> ExportDescriptors(const CWallet& wallet, bool export_private) { @@ -36,4 +41,175 @@ util::Expected, std::string> ExportDescriptors(const } 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 index d83600be2a6..5f2289302cc 100644 --- a/src/wallet/export.h +++ b/src/wallet/export.h @@ -27,6 +27,10 @@ struct WalletDescInfo { //! Export the descriptors from a wallet so that they can be imported elsewhere util::Expected, std::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/wallet.h b/src/wallet/wallet.h index 5b9be5dfac0..204ce403582 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -882,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; From 4329024967b9519371b9170e383f62a90b5cacff Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 12 May 2025 14:16:28 -0700 Subject: [PATCH 6/9] wallet, rpc: Add exportwatchonlywallet RPC --- src/wallet/rpc/wallet.cpp | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) 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}, From ee20a17aba9a9e77f502a8ce54534b38da0355d1 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Mon, 12 May 2025 15:45:11 -0700 Subject: [PATCH 7/9] test: Test for exportwatchonlywallet --- test/functional/test_runner.py | 1 + test/functional/wallet_exported_watchonly.py | 273 +++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100755 test/functional/wallet_exported_watchonly.py 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() From 4f1f33fa17901b826475cf18aeb454744c6870a1 Mon Sep 17 00:00:00 2001 From: Pol Espinasa Date: Wed, 27 Aug 2025 23:56:24 +0200 Subject: [PATCH 8/9] doc: update offline-signing-tutorial to use exportwatchonlywallet rpc --- doc/offline-signing-tutorial.md | 58 +++++---------------------------- 1 file changed, 8 insertions(+), 50 deletions(-) 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. From 59fd6f901d8445d1c425ec0b961ee1a1a57d9c39 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Tue, 13 May 2025 16:26:18 -0700 Subject: [PATCH 9/9] gui: Menu action for exporting a watchonly wallet --- src/interfaces/wallet.h | 3 +++ src/qt/bitcoingui.cpp | 16 ++++++++++++++++ src/qt/bitcoingui.h | 1 + src/wallet/interfaces.cpp | 6 ++++++ 4 files changed, 26 insertions(+) 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/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; };