Merge bitcoin/bitcoin#32784: wallet: derivehdkey RPC to get xpub at arbitrary path
What changed, and why it matters
This commit adds a new Bitcoin Core wallet RPC called derivehdkey that lets a user derive an extended public key (xpub) — or optionally the matching extended private key (xprv) — at a chosen BIP32 path from one of the wallet's own HD keys. The main intended use is making multisig setup easier. The commit also fixes a small input-validation bug in BIP32 path parsing where very large numbers could previously be misinterpreted as hardened indices. There is no evidence in the commit of an active vulnerability being exploited; it reads as a feature addition with defensive hardening.
Review the new derivehdkey RPC access controls in production deployments; ensure only authorized callers can call it, especially with private=true. Verify that the hardened-step requirement and descriptor-source restrictions match intended policy. The BIP32 parsing hardening should be backported if older branches parse user-supplied BIP32 paths.
Security signals we found
New RPC exposes xpub/xprv derivation from wallet HD keys
Requires at least one hardened derivation step
Rejects watch-only wallets and locked/encrypted wallets
Only allows HD keys from active or unused(KEY) descriptors, not used inactive descriptors
Fixes BIP32 path parsing to reject out-of-range indices that previously could set the hardened bit
Adds fuzz round-trip test for ParseHDKeypath/WriteHDKeypath
Evidence from the diff
The merge introduces derivehdkey, plus helpers: DeriveExtKey, GetExtKey, GetHDPubKeys filtering, ParsePathBIP32, HasHardenedDerivation, and tighter ParseHDKeypath validation. derivehdkey requires at least one hardened derivation step, rejects watch-only wallets, and only accepts HD keys from active or unused(KEY) descriptors. A notable hardening change is in src/util/bip32.cpp: numeric path components must now be ≤ 0x7fffffff, preventing values ≥ 2^31 from silently setting the hardened bit. Tests and documentation are updated accordingly.
Changed components
src/wallet/rpc/wallet.cppsrc/wallet/wallet.cppsrc/wallet/wallet.hsrc/key.cppsrc/key.hsrc/util/bip32.cppsrc/util/bip32.hsrc/rpc/util.cppsrc/rpc/util.hsrc/rpc/client.cppsrc/script/descriptor.cppdoc/multisig-tutorial.mddoc/release-notes-32784.mdInspect captured patch +633 / −158
### doc/multisig-tutorial.md
@@ -9,18 +9,16 @@ This tutorial uses [jq](https://github.com/stedolan/jq) JSON processor to proces
Before starting this tutorial, start the bitcoin node on the signet network.
```bash
-./build/bin/bitcoind -signet -daemon
+./build/bin/bitcoin node -signet -daemon
```
-This tutorial also uses the default WPKH derivation path to get the xpubs and does not conform to [BIP 45](https://github.com/bitcoin/bips/blob/master/bip-0045.mediawiki) or [BIP 87](https://github.com/bitcoin/bips/blob/master/bip-0087.mediawiki).
-
-At the time of writing, there is no way to extract a specific path from wallets in Bitcoin Core. For this, an external signer/xpub can be used.
+This tutorial also uses the default PKH derivation path to get the xpubs and does not conform to [BIP 45](https://github.com/bitcoin/bips/blob/master/bip-0045.mediawiki) or [BIP 87](https://github.com/bitcoin/bips/blob/master/bip-0087.mediawiki).
## 1.1 Basic Multisig Workflow
### 1.1 Create the Descriptor Wallets
-For a 2-of-3 multisig, create 3 descriptor wallets. It is important that they are of the descriptor type in order to retrieve the wallet descriptors. These wallets contain HD seed and private keys, which will be used to sign the PSBTs and derive the xpub.
+For a 2-of-3 multisig, create 3 wallets. These wallets contain HD seed and private keys, which will be used to sign the PSBTs and derive the xpub.
These three wallets should not be used directly for privacy reasons (public key reuse). They should only be used to sign transactions for the (watch-only) multisig wallet.
@@ -31,16 +29,7 @@ do
done
```
-Extract the xpub of each wallet. To do this, the `listdescriptors` RPC is used. By default, Bitcoin Core single-sig wallets are created using path `m/44'/1'/0'` for PKH, `m/84'/1'/0'` for WPKH, `m/49'/1'/0'` for P2WPKH-nested-in-P2SH and `m/86'/1'/0'` for P2TR based accounts. Each of them uses the chain 0 for external addresses and chain 1 for internal ones, as shown in the example below.
-
-```
-wpkh([1004658e/84'/1'/0']tpubDCBEcmVKbfC9KfdydyLbJ2gfNL88grZu1XcWSW9ytTM6fitvaRmVyr8Ddf7SjZ2ZfMx9RicjYAXhuh3fmLiVLPodPEqnQQURUfrBKiiVZc8/0/*)#g8l47ngv
-
-wpkh([1004658e/84'/1'/0']tpubDCBEcmVKbfC9KfdydyLbJ2gfNL88grZu1XcWSW9ytTM6fitvaRmVyr8Ddf7SjZ2ZfMx9RicjYAXhuh3fmLiVLPodPEqnQQURUfrBKiiVZc8/1/*)#en65rxc5
-```
-
-The suffix (after #) is the checksum. Descriptors can optionally be suffixed with a checksum to protect against typos or copy-paste errors.
-All RPCs in Bitcoin Core will include the checksum in their output.
+Extract the xpub of each wallet. To do this, the `derivehdkey` RPC is used.
Note that previously at least two descriptors were usually used, one for external derivation paths and one for internal ones. Since https://github.com/bitcoin/bitcoin/pull/22838 this redundancy has been eliminated by a multipath descriptor with <code><0;1></code> at the [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#change) change level expanding to external and internal descriptors when imported.
@@ -49,66 +38,63 @@ declare -A xpubs
for ((n=1;n<=3;n++))
do
- xpubs["xpub_${n}"]=$(./build/bin/bitcoin rpc -signet -rpcwallet="participant_${n}" listdescriptors | jq '.descriptors | [.[] | select(.desc | startswith("wpkh") and contains("/0/*") )][0] | .desc' | grep -Po '(?<=\().*(?=\))' | sed 's /0/\* /<0;1>/* ')
+ xpubs["xpub_${n}"]=$(./build/bin/bitcoin rpc -signet -rpcwallet="participant_${n}" derivehdkey "m/44h/1h/0h" | jq -r '.origin + .xpub')
done
```
-`jq` is used to extract the xpub from the `wpkh` descriptor.
-
-The following command can be used to verify if the xpub was generated correctly.
+The following command can be used to verify if the xpubs were obtained successfully:
```bash
for x in "${!xpubs[@]}"; do printf "[%s]=%s\n" "$x" "${xpubs[$x]}" ; done
```
-As previously mentioned, this step extracts the `m/84'/1'/0'` account instead of the path defined in [BIP 45](https://github.com/bitcoin/bips/blob/master/bip-0045.mediawiki) or [BIP 87](https://github.com/bitcoin/bips/blob/master/bip-0087.mediawiki), since there is no way to extract a specific path in Bitcoin Core at the time of writing.
+As previously mentioned, this step extracts the `m/44'/1'/0'` account instead of the path defined in [BIP 45](https://github.com/bitcoin/bips/blob/master/bip-0045.mediawiki) or [BIP 87](https://github.com/bitcoin/bips/blob/master/bip-0087.mediawiki), because the wallet currently can't sign for a derivation path that's not used in one of its descriptors.
### 1.2 Define the Multisig Descriptor
-Define the multisig descriptor, add the checksum and then, wrap it in a JSON array.
+Define the multisig descriptors.
+
+All RPCs in Bitcoin Core will include the checksum in their output.
```bash
-desc="wsh(sortedmulti(2,${xpubs["xpub_1"]},${xpubs["xpub_2"]},${xpubs["xpub_3"]}))"
+desc="wsh(sortedmulti(2,${xpubs["xpub_1"]}/<0;1>/*,${xpubs["xpub_2"]}/<0;1>/*,${xpubs["xpub_3"]}/<0;1>/*))"
-checksum=$(./build/bin/bitcoin rpc -signet getdescriptorinfo $desc | jq -r '.checksum')
+desc_sum=$(./build/bin/bitcoin rpc -signet getdescriptorinfo $desc | jq -r '.checksum')
-multisig_desc="[{\"desc\": \"${desc}#${checksum}\", \"active\": true, \"timestamp\": \"now\"}]"
+multisig_desc="[{\"desc\": \"$desc#$desc_sum\", \"active\": true, \"timestamp\": \"now\"}]"
```
`desc` specifies the output type (`wsh`, in this case) and the xpubs involved. It also uses BIP 67 (`sortedmulti`), so the wallet can be recreated without worrying about the order of xpubs. Conceptually, descriptors describe a list of scriptPubKey (along with information for spending from it) [[source](https://github.com/bitcoin/bitcoin/issues/21199#issuecomment-780772418)].
-After creating the descriptor, it is necessary to add the checksum, which is required by the `importdescriptors` RPC.
+The checksum for a descriptor without one can be computed using the `getdescriptorinfo` RPC. The response has a `checksum` field, which is appended to the descriptor after `#` to protect against typos or copy-paste errors.
-The checksum for a descriptor without one can be computed using the `getdescriptorinfo` RPC. The response has the `checksum` field, which is the checksum for the input descriptor, append "#" and this checksum to the input descriptor.
-
-There are other fields that can be added to the descriptor:
+There are other fields that can be added to the descriptors:
* `active`: Sets the descriptor to be the active one for the corresponding output type (`wsh`, in this case).
-* `internal`: Indicates whether matching outputs should be treated as something other than incoming payments (e.g. change).
* `timestamp`: Sets the time from which to start rescanning the blockchain for the descriptor, in UNIX epoch time.
-Note: when a multipath descriptor is imported, it is expanded into two descriptors which are imported separately, with the second implicitly used for internal (change) addresses.
-
Documentation for these and other parameters can be found by typing `./build/bin/bitcoin rpc -signet help importdescriptors`.
-`multisig_desc` wraps the descriptor in a JSON array and will be used to create the multisig wallet.
+`multisig_desc` concatenates the descriptor in a JSON array and then it will be used to create the multisig wallet.
### 1.3 Create the Multisig Wallet
To create the multisig wallet, first create an empty one (no keys, HD seed and private keys disabled).
Then import the descriptor created in the previous step using the `importdescriptors` RPC.
-After that, `getwalletinfo` can be used to check if the wallet was created successfully.
+After that, `listdescriptors` can be used to check if the wallet was created successfully.
```bash
-./build/bin/bitcoin rpc -signet createwallet "multisig_wallet_01" disable_private_keys=true blank=true
+./build/bin/bitcoin rpc -signet -named createwallet wallet_name="multisig_wallet_01" disable_private_keys=true blank=true
./build/bin/bitcoin rpc -signet -rpcwallet="multisig_wallet_01" importdescriptors "$multisig_desc"
-./build/bin/bitcoin rpc -signet -rpcwallet="multisig_wallet_01" getwalletinfo
+./build/bin/bitcoin rpc -signet -rpcwallet="multisig_wallet_01" listdescriptors
```
+The `<0;1>` notation in `desc` caused the creation of two descriptors. One uses the chain 0 for external addresses, and the other uses chain 1 for internal ones (change).
+
Once the wallets have already been created and this tutorial needs to be repeated or resumed, it is not necessary to recreate them, just load them with the command below:
```bash
@@ -199,7 +185,7 @@ psbt_2=$(./build/bin/bitcoin rpc -signet -rpcwallet="participant_2" walletproces
The PSBT, if signed separately by the co-signers, must be combined into one transaction before being finalized. This is done by `combinepsbt` RPC.
```bash
-combined_psbt=$(./build/bin/bitcoin rpc -signet combinepsbt "[$psbt_1, $psbt_2]")
+combined_psbt=$(./build/bin/bitcoin rpc -signet combinepsbt txs="[$psbt_1, $psbt_2]")
```
There is an RPC called `joinpsbts`, but it has a different purpose than `combinepsbt`. `joinpsbts` joins the inputs from multiple distinct PSBTs into one PSBT.
### doc/release-notes-32784.md
@@ -0,0 +1,9 @@
+Wallet
+------
+
+- A new `derivehdkey` RPC is available to obtain an xpub or xprv for a
+ derivation path with at least one hardened step from an HD key known to the
+ wallet. This can be used to coordinate a multisig setup, where each signer
+ shares an xpub using a
+ different derivation path than the default single-signature descriptors. The
+ example in `doc/multisig-tutorial.md` is updated to use this RPC. (#32784)
### src/key.cpp
@@ -16,6 +16,8 @@
#include <secp256k1_recovery.h>
#include <secp256k1_schnorrsig.h>
+#include <algorithm>
+
static secp256k1_context* secp256k1_context_sign = nullptr;
/** These functions are taken from the libsecp256k1 distribution and are very ugly. */
@@ -364,6 +366,18 @@ bool CExtKey::Derive(CExtKey &out, unsigned int _nChild) const {
return key.Derive(out.key, out.chaincode, _nChild, chaincode);
}
+std::optional<std::pair<CExtKey, KeyOriginInfo>> DeriveExtKey(const CExtKey& ext_key, const std::vector<uint32_t>& path)
+{
+ CExtKey descendant = ext_key;
+ KeyOriginInfo origin;
+ origin.fingerprint = ext_key.id_key_fingerprint();
+ origin.path = path;
+ for (uint32_t i : path) {
+ if (!descendant.Derive(descendant, i)) return std::nullopt;
+ }
+ return std::make_pair(descendant, origin);
+}
+
void CExtKey::SetSeed(std::span<const std::byte> seed)
{
Assert(16 <= seed.size() && seed.size() <= 64);
### src/key.h
@@ -8,11 +8,14 @@
#define BITCOIN_KEY_H
#include <pubkey.h>
+#include <script/keyorigin.h>
#include <serialize.h>
#include <support/allocators/secure.h>
#include <uint256.h>
+#include <optional>
#include <stdexcept>
+#include <utility>
#include <vector>
struct secp256k1_context_struct;
@@ -257,6 +260,12 @@ struct CExtKey {
void SetSeed(std::span<const std::byte> seed);
};
+//! Get extended key and origin info for a given path
+//! @param[in] ext_key The extended private key to derive from
+//! @param[in] path The BIP 32 path
+//! @return the resulting extended private key and origin info
+std::optional<std::pair<CExtKey, KeyOriginInfo>> DeriveExtKey(const CExtKey& ext_key, const std::vector<uint32_t>& path);
+
/** KeyPair
*
* Wraps a `secp256k1_keypair` type, an opaque data structure for holding a secret and public key.
### src/rpc/client.cpp
@@ -339,6 +339,8 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "gethdkeys", 0, "active_only" },
{ "gethdkeys", 0, "options" },
{ "gethdkeys", 0, "private" },
+ { "derivehdkey", 1, "options" },
+ { "derivehdkey", 1, "private" },
{ "createwalletdescriptor", 1, "options" },
{ "createwalletdescriptor", 1, "internal" },
// Echo with conversion (For testing only)
### src/rpc/util.cpp
@@ -20,6 +20,7 @@
#include <tinyformat.h>
#include <uint256.h>
#include <univalue.h>
+#include <util/bip32.h>
#include <util/check.h>
#include <util/result.h>
#include <util/strencodings.h>
@@ -1379,6 +1380,15 @@ std::vector<CScript> EvalDescriptorStringOrObject(const UniValue& scanobject, Fl
return ret;
}
+std::vector<uint32_t> ParsePathBIP32(const std::string& path)
+{
+ std::vector<uint32_t> out;
+ if (!ParseHDKeypath(path, out)) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid BIP32 keypath");
+ }
+ return out;
+}
+
/** Convert a vector of bilingual strings to a UniValue::VARR containing their original untranslated values. */
[[nodiscard]] static UniValue BilingualStringsToUniValue(const std::vector<bilingual_str>& bilingual_strings)
{
### src/rpc/util.h
@@ -156,6 +156,9 @@ std::pair<int64_t, int64_t> ParseDescriptorRange(const UniValue& value);
/** Evaluate a descriptor given as a string, or as a {"desc":...,"range":...} object, with default range of 1000. */
std::vector<CScript> EvalDescriptorStringOrObject(const UniValue& scanobject, FlatSigningProvider& provider, bool expand_priv = false);
+//! Parse BIP32 path
+std::vector<uint32_t> ParsePathBIP32(const std::string& path);
+
/**
* Serializing JSON objects depends on the outer type. Only arrays and
* dictionaries can be nested in json. The top-level outer type is "NONE".
### src/script/descriptor.cpp
@@ -440,10 +440,7 @@ class BIP32PubkeyProvider final : public PubkeyProvider
bool IsHardened() const
{
if (m_derive == DeriveType::HARDENED_RANGED) return true;
- for (auto entry : m_path) {
- if (entry >> 31) return true;
- }
- return false;
+ return HasHardenedDerivation(m_path);
}
public:
### src/test/bip32_tests.cpp
@@ -9,6 +9,7 @@
#include <key_io.h>
#include <streams.h>
#include <test/util/setup_common.h>
+#include <util/bip32.h>
#include <util/strencodings.h>
#include <string>
@@ -184,6 +185,46 @@ BOOST_AUTO_TEST_CASE(bip32_test5) {
}
}
+BOOST_AUTO_TEST_CASE(bip32_derive_ext_key)
+{
+ const CExtKey master{DecodeExtKey(test1.vDerive[0].prv)};
+ const std::vector<uint32_t> path{test1.vDerive[0].nChild, test1.vDerive[1].nChild};
+ const auto derived{DeriveExtKey(master, path)};
+ BOOST_REQUIRE(derived);
+ BOOST_CHECK(EncodeExtKey(derived->first) == test1.vDerive[2].prv);
+
+ KeyOriginInfo expected_origin;
+ expected_origin.fingerprint = master.id_key_fingerprint();
+ expected_origin.path = path;
+ BOOST_CHECK(derived->second == expected_origin);
+
+ const auto root{DeriveExtKey(master, {})};
+ BOOST_REQUIRE(root);
+ BOOST_CHECK(root->first == master);
+ expected_origin.path.clear();
+ BOOST_CHECK(root->second == expected_origin);
+
+ CExtKey max_depth{master};
+ for (auto i{0}; i++ < 255;) {
+ CExtKey next_key;
+ BOOST_REQUIRE(max_depth.Derive(next_key, 0));
+ max_depth = next_key;
+ }
+ BOOST_CHECK(!DeriveExtKey(max_depth, {0}));
+}
+
+BOOST_AUTO_TEST_CASE(bip32_has_hardened_derivation)
+{
+ const std::vector<uint32_t> empty;
+ const std::vector<uint32_t> unhardened{0, 1, 2};
+ const std::vector<uint32_t> hardened{0x80000000U};
+ const std::vector<uint32_t> mixed{0, 1 | 0x80000000U, 2};
+ BOOST_CHECK(!HasHardenedDerivation(empty));
+ BOOST_CHECK(!HasHardenedDerivation(unhardened));
+ BOOST_CHECK(HasHardenedDerivation(hardened));
+ BOOST_CHECK(HasHardenedDerivation(mixed));
+}
+
BOOST_AUTO_TEST_CASE(bip32_max_depth) {
CExtKey key_parent{DecodeExtKey(test1.vDerive[0].prv)}, key_child;
CExtPubKey pubkey_parent{DecodeExtPubKey(test1.vDerive[0].pub)}, pubkey_child;
@@ -203,4 +244,85 @@ BOOST_AUTO_TEST_CASE(bip32_max_depth) {
BOOST_CHECK(!pubkey_parent.Derive(pubkey_child, 0));
}
+BOOST_AUTO_TEST_CASE(parse_hd_keypath)
+{
+ std::vector<uint32_t> keypath;
+
+ BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1", keypath));
+ BOOST_CHECK(!ParseHDKeypath("///////////////////////////", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1'/1", keypath));
+ BOOST_CHECK(!ParseHDKeypath("//////////////////////////'/", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/", keypath));
+ BOOST_CHECK(!ParseHDKeypath("1///////////////////////////", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1'/", keypath));
+ BOOST_CHECK(!ParseHDKeypath("1/'//////////////////////////", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("", keypath));
+ BOOST_CHECK(!ParseHDKeypath(" ", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("0", keypath));
+ BOOST_CHECK(!ParseHDKeypath("O", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("0000'/0000'/0000'", keypath));
+ BOOST_CHECK(!ParseHDKeypath("0000,/0000,/0000,", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("01234", keypath));
+ BOOST_CHECK(!ParseHDKeypath("0x1234", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("1", keypath));
+ BOOST_CHECK(!ParseHDKeypath(" 1", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("42", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m42", keypath));
+
+ // A path element's numeric part is capped at 2^31-1; the top bit is
+ // reserved for the hardened marker (h or ').
+ BOOST_CHECK(ParseHDKeypath("2147483647", keypath)); // 0x7fffffff, largest normal index
+ BOOST_CHECK(!ParseHDKeypath("2147483648", keypath)); // 0x80000000, would set the hardened bit
+ BOOST_CHECK(!ParseHDKeypath("4294967295", keypath)); // 0xffffffff
+ BOOST_CHECK(!ParseHDKeypath("4294967296", keypath)); // uint32_t max + 1
+
+ BOOST_CHECK(ParseHDKeypath("m", keypath));
+ BOOST_CHECK(!ParseHDKeypath("n", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/", keypath));
+ BOOST_CHECK(!ParseHDKeypath("n/", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/0", keypath));
+ BOOST_CHECK(!ParseHDKeypath("n/0", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/0'", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m/0''", keypath));
+
+ keypath.clear();
+ BOOST_REQUIRE(ParseHDKeypath("m/0h/1h/2h", keypath));
+ BOOST_REQUIRE_EQUAL(keypath.size(), 3);
+ BOOST_CHECK_EQUAL(keypath[0], 0x80000000U);
+ BOOST_CHECK_EQUAL(keypath[1], 0x80000001U);
+ BOOST_CHECK_EQUAL(keypath[2], 0x80000002U);
+ BOOST_CHECK(!ParseHDKeypath("m/0hh", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m/h0", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/0'/0'", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m/'0/0'", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/0/0", keypath));
+ BOOST_CHECK(!ParseHDKeypath("n/0/0", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/0/0/00", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m/0/0/f00", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/0/0/000000000000000000000000000000000000000000000000000000000000000000000000000000000000", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m/1/1/111111111111111111111111111111111111111111111111111111111111111111111111111111111111", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/0/00/0", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m/0'/00/'0", keypath));
+
+ BOOST_CHECK(ParseHDKeypath("m/1/", keypath));
+ BOOST_CHECK(!ParseHDKeypath("m/1//", keypath));
+}
+
BOOST_AUTO_TEST_SUITE_END()
### src/test/fuzz/parse_hd_keypath.cpp
@@ -7,6 +7,7 @@
#include <test/fuzz/util.h>
#include <util/bip32.h>
+#include <cassert>
#include <cstdint>
#include <vector>
@@ -18,6 +19,12 @@ FUZZ_TARGET(parse_hd_keypath)
FuzzedDataProvider fuzzed_data_provider(buffer.data(), buffer.size());
const std::vector<uint32_t> random_keypath = ConsumeRandomLengthIntegralVector<uint32_t>(fuzzed_data_provider);
- (void)FormatHDKeypath(random_keypath, /*apostrophe=*/true); // WriteHDKeypath calls this with false
- (void)WriteHDKeypath(random_keypath);
+
+ // Roundtrip WriteHDKeypath() and ParseHDKeypath()
+ for (const bool apostrophe : {false, true}) {
+ std::vector<uint32_t> roundtrip;
+ const std::string written{WriteHDKeypath(random_keypath, apostrophe)};
+ assert(ParseHDKeypath(written, roundtrip));
+ assert(roundtrip == random_keypath);
+ }
}
### src/util/bip32.cpp
@@ -7,17 +7,19 @@
#include <tinyformat.h>
#include <util/strencodings.h>
+#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <optional>
+#include <span>
#include <sstream>
bool ParseHDKeypath(const std::string& keypath_str, std::vector<uint32_t>& keypath)
{
std::stringstream ss(keypath_str);
std::string item;
bool first = true;
- while (std::getline(ss, item, '/')) {
+ while (std::getline(ss, item, '/') || std::getline(ss, item, 'h')) {
if (item.compare("m") == 0) {
if (first) {
first = false;
@@ -28,6 +30,9 @@ bool ParseHDKeypath(const std::string& keypath_str, std::vector<uint32_t>& keypa
// Finds whether it is hardened
uint32_t path = 0;
size_t pos = item.find('\'');
+ if (pos == std::string::npos) {
+ pos = item.find('h');
+ }
if (pos != std::string::npos) {
// The hardened tick can only be in the last index of the string
if (pos != item.size() - 1) {
@@ -42,6 +47,11 @@ bool ParseHDKeypath(const std::string& keypath_str, std::vector<uint32_t>& keypa
if (!number) {
return false;
}
+ // A BIP32 child index is 31 bits; the top bit is reserved for the
+ // hardened marker, so the numeric part must not exceed 2^31 - 1.
+ if (*number > 0x7fffffff) {
+ return false;
+ }
path |= *number;
keypath.push_back(path);
@@ -64,3 +74,10 @@ std::string WriteHDKeypath(const std::vector<uint32_t>& keypath, bool apostrophe
{
return "m" + FormatHDKeypath(keypath, apostrophe);
}
+
+bool HasHardenedDerivation(std::span<const uint32_t> keypath)
+{
+ return std::any_of(keypath.begin(), keypath.end(), [](uint32_t index) {
+ return index >> 31;
+ });
+}
### src/util/bip32.h
@@ -6,6 +6,7 @@
#define BITCOIN_UTIL_BIP32_H
#include <cstdint>
+#include <span>
#include <string>
#include <vector>
@@ -16,4 +17,7 @@
std::string WriteHDKeypath(const std::vector<uint32_t>& keypath, bool apostrophe = false);
std::string FormatHDKeypath(const std::vector<uint32_t>& path, bool apostrophe = false);
+/** Whether a parsed HD keypath contains at least one hardened derivation step. */
+bool HasHardenedDerivation(std::span<const uint32_t> keypath);
+
#endif // BITCOIN_UTIL_BIP32_H
### src/wallet/rpc/wallet.cpp
@@ -9,10 +9,12 @@
#include <coins.h>
#include <core_io.h>
+#include <key.h>
#include <key_io.h>
#include <rpc/server.h>
#include <rpc/util.h>
#include <univalue.h>
+#include <util/bip32.h>
#include <util/translation.h>
#include <wallet/context.h>
#include <wallet/export.h>
@@ -21,12 +23,16 @@
#include <wallet/wallet.h>
#include <wallet/walletutil.h>
+#include <algorithm>
#include <optional>
#include <string_view>
namespace wallet {
+using HDPubKeyMap = CWallet::HDPubKeyMap;
+using HDKeyFilter = CWallet::HDKeyFilter;
+
static const std::map<uint64_t, std::string> WALLET_FLAG_CAVEATS{
{WALLET_FLAG_AVOID_REUSE,
"You need to rescan the blockchain in order to correctly mark used "
@@ -689,31 +695,15 @@ RPCMethod gethdkeys()
EnsureWalletIsUnlocked(*wallet);
}
-
- std::set<ScriptPubKeyMan*> spkms;
- if (active_only) {
- spkms = wallet->GetActiveScriptPubKeyMans();
- } else {
- spkms = wallet->GetAllScriptPubKeyMans();
- }
-
std::map<CExtPubKey, std::set<std::tuple<std::string, bool, bool>>> wallet_xpubs;
std::map<CExtPubKey, CExtKey> wallet_xprvs;
- for (auto* spkm : spkms) {
- auto* desc_spkm{dynamic_cast<DescriptorScriptPubKeyMan*>(spkm)};
- CHECK_NONFATAL(desc_spkm);
- LOCK(desc_spkm->cs_desc_man);
- WalletDescriptor w_desc = desc_spkm->GetWalletDescriptor();
-
- // Retrieve the pubkeys from the descriptor
- std::set<CPubKey> desc_pubkeys;
- std::set<CExtPubKey> desc_xpubs;
- w_desc.descriptor->GetPubKeys(desc_pubkeys, desc_xpubs);
- for (const CExtPubKey& xpub : desc_xpubs) {
+ for (const auto& [xpub, spkms] : wallet->GetHDPubKeys(active_only ? HDKeyFilter::Active : HDKeyFilter::All)) {
+ for (auto* desc_spkm : spkms) {
+ LOCK(desc_spkm->cs_desc_man);
std::string desc_str;
bool ok = desc_spkm->GetDescriptorString(desc_str, /*priv=*/false);
CHECK_NONFATAL(ok);
- wallet_xpubs[xpub].emplace(desc_str, wallet->IsActiveScriptPubKeyMan(*spkm), desc_spkm->HasPrivKey(xpub.pubkey.GetID()));
+ wallet_xpubs[xpub].emplace(desc_str, wallet->IsActiveScriptPubKeyMan(*desc_spkm), desc_spkm->HasPrivKey(xpub.pubkey.GetID()));
if (std::optional<CKey> key = priv ? desc_spkm->GetKey(xpub.pubkey.GetID()) : std::nullopt) {
wallet_xprvs[xpub] = CExtKey(xpub, *key);
}
@@ -800,11 +790,11 @@ static RPCMethod createwalletdescriptor()
CExtPubKey xpub;
if (hdkey.isNull()) {
- std::set<CExtPubKey> active_xpubs = pwallet->GetActiveHDPubKeys();
+ HDPubKeyMap active_xpubs = pwallet->GetHDPubKeys(HDKeyFilter::Active);
if (active_xpubs.size() != 1) {
throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Unable to determine which HD key to use from active descriptors. Please specify with 'hdkey'");
}
- xpub = *active_xpubs.begin();
+ xpub = active_xpubs.begin()->first;
} else {
xpub = DecodeExtPubKey(hdkey.get_str());
if (!xpub.pubkey.IsValid()) {
@@ -965,6 +955,124 @@ static RPCMethod exportwatchonlywallet()
};
}
+RPCMethod derivehdkey()
+{
+ return RPCMethod{
+ "derivehdkey",
+ "Derive extended public or private key from HD key in the wallet at a given path.\n"
+ "Derivation uses wallet private key material.\n"
+ + HELP_REQUIRING_PASSPHRASE,
+ {
+ {"path", RPCArg::Type::STR, RPCArg::Optional::NO, "BIP 32 derivation path with at least one hardened step."},
+ {"options", RPCArg::Type::OBJ_NAMED_PARAMS, RPCArg::Optional::OMITTED, "", {
+ {"private", RPCArg::Type::BOOL, RPCArg::Default{false}, "Show private key"},
+ {"hdkey", RPCArg::Type::STR, RPCArg::DefaultHint{"Either the HD key of an unused(KEY) descriptor, or any other active descriptor."}, "The HD key that the wallet knows the private key of, listed using 'gethdkeys', to use for derivation"},
+ }},
+ },
+ RPCResult{
+ RPCResult::Type::OBJ, "", "", {
+ {RPCResult::Type::STR, "origin", "Fingerprint and path for use in descriptors"},
+ {RPCResult::Type::STR, "xpub", "The extended public key"},
+ {RPCResult::Type::STR, "xprv", /*optional=*/true, "The extended private key if \"private\" is true"},
+ },
+ },
+ RPCExamples{
+ HelpExampleCli("derivehdkey", "m/87h/0h/0h") + HelpExampleRpc("derivehdkey", "\"m/87h/0h/0h\"")
+ + HelpExampleCliNamed("derivehdkey", {{"path", "m/87h/0h/0h"}, {"private", "true"}})
+ + HelpExampleRpcNamed("derivehdkey", {{"path", "m/87h/0h/0h"}, {"private", "true"}})
+ },
+ [](const RPCMethod& self, const JSONRPCRequest& request) -> UniValue
+ {
+ const std::shared_ptr<const CWallet> wallet = GetWalletForJSONRPCRequest(request);
+ if (!wallet) return UniValue::VNULL;
+
+ if (wallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
+ // Watch-only wallets can't contain unused(KEY) descriptors
+ throw JSONRPCError(RPC_WALLET_ERROR, "derivehdkey is not available for watch-only wallets");
+ }
+
+ std::vector<uint32_t> path = ParsePathBIP32(request.params[0].get_str());
+ UniValue options{request.params[1].isNull() ? UniValue::VOBJ : request.params[1]};
+ const bool priv{options.exists("private") ? options["private"].get_bool() : false};
+ UniValue hdkey{options["hdkey"]};
+ if (!HasHardenedDerivation(path)) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Derivation path requires at least one hardened step");
+ }
+
+ LOCK(wallet->cs_wallet);
+
+ // The RPC requires a hardened derivation step, so always unlock
+ // the wallet.
+ EnsureWalletIsUnlocked(*wallet);
+
+ CExtPubKey xpub;
+ if (!hdkey.isNull()) {
+ xpub = DecodeExtPubKey(hdkey.get_str());
+ if (!xpub.pubkey.IsValid()) {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Unable to parse HD key. Please provide a valid xpub");
+ }
+
+ // Accept an xpub from an active or unused(KEY) descriptor, but
+ // not from a (used) inactive one.
+ std::set<CExtPubKey> xpub_candidates;
+ for (const auto& candidate : wallet->GetHDPubKeys(HDKeyFilter::UnusedKey)) {
+ xpub_candidates.insert(candidate.first);
+ }
+ for (const auto& candidate : wallet->GetHDPubKeys(HDKeyFilter::Active)) {
+ xpub_candidates.insert(candidate.first);
+ }
+ if (!xpub_candidates.contains(xpub)) {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "HD key is not used by an active or unused(KEY) descriptor");
+ }
+ }
+
+ // If hdkey was not specified, try to look it up. First consider
+ // unused(KEY) descriptors. Otherwise look for active descriptors.
+ if (hdkey.isNull()) {
+ HDPubKeyMap wallet_xpubs{wallet->GetHDPubKeys(HDKeyFilter::UnusedKey)};
+
+ if (wallet_xpubs.size() > 1) {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Unable to determine which HD key to use. Please specify with 'hdkey'");
+ } else if (wallet_xpubs.size() == 1) {
+ xpub = wallet_xpubs.begin()->first;
+ } else {
+ HDPubKeyMap active_xpubs = wallet->GetHDPubKeys(HDKeyFilter::Active);
+ if (active_xpubs.empty()) {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "No active or unused(KEY) descriptor found");
+ }
+
+ if (active_xpubs.size() > 1) {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Unable to determine which HD key to use from active descriptors. Please specify with 'hdkey'");
+ }
+
+ xpub = active_xpubs.begin()->first;
+ }
+ }
+
+ std::optional<CExtKey> xprv{wallet->GetExtKey(xpub)};
+ if (!xprv) {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, strprintf("Private key for %s is not known", EncodeExtPubKey(xpub)));
+ }
+
+ std::optional<std::pair<CExtKey, KeyOriginInfo>> child{DeriveExtKey(*xprv, path)};
+ if (!child) {
+ throw JSONRPCError(RPC_INVALID_PARAMETER, "Unable to derive HD key at the requested path");
+ }
+
+ UniValue res{UniValue::VOBJ};
+
+ const std::string fingerprint{HexStr(child->second.fingerprint)};
+
+ res.pushKV("origin", strprintf("[%s%s]", fingerprint, FormatHDKeypath(child->second.path)));
+ res.pushKV("xpub", EncodeExtPubKey(child->first.Neuter()));
+ if (priv) {
+ res.pushKV("xprv", EncodeExtKey(child->first));
+ }
+ return res;
+ },
+ };
+}
+
// addresses
RPCMethod getaddressinfo();
RPCMethod getnewaddress();
@@ -1038,6 +1146,7 @@ std::span<const CRPCCommand> GetWalletRPCCommands()
{"wallet", &psbtbumpfee},
{"wallet", &createwallet},
{"wallet", &createwalletdescriptor},
+ {"wallet", &derivehdkey},
{"wallet", &restorewallet},
{"wallet", &encryptwallet},
{"wallet", &exportwatchonlywallet},
### src/wallet/test/psbt_wallet_tests.cpp
@@ -4,7 +4,6 @@
#include <key_io.h>
#include <node/types.h>
-#include <util/bip32.h>
#include <util/strencodings.h>
#include <wallet/wallet.h>
@@ -79,79 +78,5 @@ BOOST_AUTO_TEST_CASE(psbt_updater_test)
BOOST_CHECK(m_wallet.FillPSBT(psbtx, {.sign = true, .bip32_derivs = true}, complete));
}
-BOOST_AUTO_TEST_CASE(parse_hd_keypath)
-{
- std::vector<uint32_t> keypath;
-
- BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1", keypath));
- BOOST_CHECK(!ParseHDKeypath("///////////////////////////", keypath));
-
- BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1'/1", keypath));
- BOOST_CHECK(!ParseHDKeypath("//////////////////////////'/", keypath));
-
- BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/", keypath));
- BOOST_CHECK(!ParseHDKeypath("1///////////////////////////", keypath));
-
- BOOST_CHECK(ParseHDKeypath("1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1/1'/", keypath));
- BOOST_CHECK(!ParseHDKeypath("1/'//////////////////////////", keypath));
-
- BOOST_CHECK(ParseHDKeypath("", keypath));
- BOOST_CHECK(!ParseHDKeypath(" ", keypath));
-
- BOOST_CHECK(ParseHDKeypath("0", keypath));
- BOOST_CHECK(!ParseHDKeypath("O", keypath));
-
- BOOST_CHECK(ParseHDKeypath("0000'/0000'/0000'", keypath));
- BOOST_CHECK(!ParseHDKeypath("0000,/0000,/0000,", keypath));
-
- BOOST_CHECK(ParseHDKeypath("01234", keypath));
- BOOST_CHECK(!ParseHDKeypath("0x1234", keypath));
-
- BOOST_CHECK(ParseHDKeypath("1", keypath));
- BOOST_CHECK(!ParseHDKeypath(" 1", keypath));
-
- BOOST_CHECK(ParseHDKeypath("42", keypath));
- BOOST_CHECK(!ParseHDKeypath("m42", keypath));
-
- BOOST_CHECK(ParseHDKeypath("4294967295", keypath)); // 4294967295 == 0xFFFFFFFF (uint32_t max)
- BOOST_CHECK(!ParseHDKeypath("4294967296", keypath)); // 4294967296 == 0xFFFFFFFF (uint32_t max) + 1
-
- BOOST_CHECK(ParseHDKeypath("m", keypath));
- BOOST_CHECK(!ParseHDKeypath("n", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/", keypath));
- BOOST_CHECK(!ParseHDKeypath("n/", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0", keypath));
- BOOST_CHECK(!ParseHDKeypath("n/0", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0'", keypath));
- BOOST_CHECK(!ParseHDKeypath("m/0''", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0'/0'", keypath));
- BOOST_CHECK(!ParseHDKeypath("m/'0/0'", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0/0", keypath));
- BOOST_CHECK(!ParseHDKeypath("n/0/0", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0/0/00", keypath));
- BOOST_CHECK(!ParseHDKeypath("m/0/0/f00", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0/0/000000000000000000000000000000000000000000000000000000000000000000000000000000000000", keypath));
- BOOST_CHECK(!ParseHDKeypath("m/1/1/111111111111111111111111111111111111111111111111111111111111111111111111111111111111", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0/00/0", keypath));
- BOOST_CHECK(!ParseHDKeypath("m/0'/00/'0", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/1/", keypath));
- BOOST_CHECK(!ParseHDKeypath("m/1//", keypath));
-
- BOOST_CHECK(ParseHDKeypath("m/0/4294967295", keypath)); // 4294967295 == 0xFFFFFFFF (uint32_t max)
- BOOST_CHECK(!ParseHDKeypath("m/0/4294967296", keypath)); // 4294967296 == 0xFFFFFFFF (uint32_t max) + 1
-
- BOOST_CHECK(ParseHDKeypath("m/4294967295", keypath)); // 4294967295 == 0xFFFFFFFF (uint32_t max)
- BOOST_CHECK(!ParseHDKeypath("m/4294967296", keypath)); // 4294967296 == 0xFFFFFFFF (uint32_t max) + 1
-}
-
BOOST_AUTO_TEST_SUITE_END()
} // namespace wallet
### src/wallet/wallet.cpp
@@ -4535,25 +4535,27 @@ void CWallet::TopUpCallback(const std::set<CScript>& spks, ScriptPubKeyMan* spkm
CacheNewScriptPubKeys(spks, spkm);
}
-std::set<CExtPubKey> CWallet::GetActiveHDPubKeys() const
+CWallet::HDPubKeyMap CWallet::GetHDPubKeys(HDKeyFilter filter) const
{
AssertLockHeld(cs_wallet);
Assert(IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS));
- std::set<CExtPubKey> active_xpubs;
- for (const auto& spkm : GetActiveScriptPubKeyMans()) {
- const DescriptorScriptPubKeyMan* desc_spkm = dynamic_cast<DescriptorScriptPubKeyMan*>(spkm);
- assert(desc_spkm);
+ HDPubKeyMap xpubs;
+ for (const auto& spkm : filter == HDKeyFilter::Active ? GetActiveScriptPubKeyMans() : GetAllScriptPubKeyMans()) {
+ auto* desc_spkm = Assert(dynamic_cast<DescriptorScriptPubKeyMan*>(spkm));
LOCK(desc_spkm->cs_desc_man);
WalletDescriptor w_desc = desc_spkm->GetWalletDescriptor();
+ if (filter == HDKeyFilter::UnusedKey && w_desc.descriptor->HasScripts()) continue;
std::set<CPubKey> desc_pubkeys;
std::set<CExtPubKey> desc_xpubs;
w_desc.descriptor->GetPubKeys(desc_pubkeys, desc_xpubs);
- active_xpubs.merge(std::move(desc_xpubs));
+ for (const CExtPubKey& xpub : desc_xpubs) {
+ xpubs[xpub].insert(desc_spkm);
+ }
}
- return active_xpubs;
+ return xpubs;
}
std::optional<CKey> CWallet::GetKey(const CKeyID& keyid) const
@@ -4571,6 +4573,14 @@ std::optional<CKey> CWallet::GetKey(const CKeyID& keyid) const
return std::nullopt;
}
+std::optional<CExtKey> CWallet::GetExtKey(const CExtPubKey& xpub) const
+{
+ if (std::optional<CKey> key = GetKey(xpub.pubkey.GetID())) {
+ return CExtKey{xpub, *key};
+ }
+ return std::nullopt;
+}
+
void CWallet::WriteBestBlock() const
{
AssertLockHeld(cs_wallet);
### src/wallet/wallet.h
@@ -1078,13 +1078,24 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
void TopUpCallback(const std::set<CScript>& spks, ScriptPubKeyMan* spkm) override;
- //! Retrieve the xpubs in use by the active descriptors
- std::set<CExtPubKey> GetActiveHDPubKeys() const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
+ //! Which descriptors GetHDPubKeys() should consider.
+ enum class HDKeyFilter {
+ Active, //!< Only active descriptors
+ All, //!< All descriptors
+ UnusedKey, //!< Only unused(KEY) descriptors
+ };
+ using HDPubKeyMap = std::map<CExtPubKey, std::set<DescriptorScriptPubKeyMan*>>;
+ //! Retrieve descriptor xpubs matching the requested filter.
+ HDPubKeyMap GetHDPubKeys(HDKeyFilter filter) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
//! Find the private key for the given key id from the wallet's descriptors, if available
//! Returns nullopt when no descriptor has the key or if the wallet is locked.
std::optional<CKey> GetKey(const CKeyID& keyid) const;
+ //! Reconstruct the extended private key for an HD xpub. Returns nullopt when
+ //! no descriptor has the private key, or the wallet is locked.
+ std::optional<CExtKey> GetExtKey(const CExtPubKey& xpub) const;
+
//! Disconnect chain notifications and wait for all notifications to be processed
void DisconnectChainNotifications();
};
### test/functional/test_runner.py
@@ -174,6 +174,7 @@
'wallet_blank.py',
'wallet_keypool_topup.py',
'wallet_fast_rescan.py',
+ 'wallet_derivehdkey.py',
'wallet_gethdkeys.py',
'wallet_createwalletdescriptor.py',
'wallet_exported_watchonly.py',
### test/functional/wallet_derivehdkey.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+# Copyright (c) 2025-Present The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""Test wallet derivehdkey RPC."""
+
+from test_framework.test_framework import BitcoinTestFramework
+from test_framework.util import (
+ assert_equal,
+ assert_raises_rpc_error,
+)
+from test_framework.wallet_util import WalletUnlock
+
+
+class WalletDeriveHDKeyTest(BitcoinTestFramework):
+ def set_test_params(self):
+ self.setup_clean_chain = True
+ self.num_nodes = 2
+
+ def skip_test_if_missing_module(self):
+ self.skip_if_no_wallet()
+
+ def run_test(self):
+ self.test_basic_derivehdkey()
+ self.test_multiple_unused_keys()
+ self.test_active_descriptor()
+ self.test_inactive_descriptor()
+ self.test_noprivs_blank()
+ self.test_compare()
+
+ def test_basic_derivehdkey(self):
+ self.log.info("Test derivehdkey basics")
+ self.nodes[0].createwallet("basic", blank=True)
+ wallet = self.nodes[0].get_wallet_rpc("basic")
+ assert_raises_rpc_error(
+ -8,
+ "Derivation path requires at least one hardened step",
+ wallet.derivehdkey,
+ "m",
+ )
+ assert_raises_rpc_error(
+ -8,
+ "Derivation path requires at least one hardened step",
+ wallet.derivehdkey,
+ "m/87/0/0",
+ )
+ # A BIP32 keypath element must be in 0..2^31-1. The
+ # value 2^31 (2147483648 == 0x80000000) is out of range.
+ for path in ["m/2147483648", "m/2147483648h", "m/2147483648'"]:
+ assert_raises_rpc_error(
+ -8,
+ "Invalid BIP32 keypath",
+ wallet.derivehdkey,
+ path,
+ )
+ assert_raises_rpc_error(
+ -5,
+ "No active or unused(KEY) descriptor found",
+ wallet.derivehdkey,
+ "m/87h",
+ )
+ wallet.addhdkey()
+ xpub_info = wallet.derivehdkey("m/87h")
+ assert "xprv" not in xpub_info
+ xpub = xpub_info["xpub"]
+ root_fingerprint = wallet.derivehdkey("m/87h/0h")["origin"][1:9]
+ assert_equal(xpub_info["origin"], f"[{root_fingerprint}/87h]")
+ too_deep_path = "m/" + "/".join(["0h"] * 256)
+ assert_raises_rpc_error(
+ -8,
+ "Unable to derive HD key at the requested path",
+ wallet.derivehdkey,
+ too_deep_path,
+ )
+
+ xpub_info = wallet.derivehdkey("m/87h/0h/0h/0")
+ xpub_priv_info = wallet.derivehdkey("m/87h/0h/0h/0", private=True)
+ xprv = xpub_priv_info["xprv"]
+ assert_equal(xpub_priv_info["xpub"], xpub_info["xpub"])
+
+ self.log.info("HD pubkey can be retrieved from encrypted wallets")
+ prev_xprv = xprv
+ wallet.encryptwallet("pass")
+ assert_raises_rpc_error(
+ -13,
+ "Error: Please enter the wallet passphrase with walletpassphrase first",
+ wallet.derivehdkey,
+ "m/87h",
+ )
+ with WalletUnlock(wallet, "pass"):
+ xpub_info = wallet.derivehdkey("m/87h")
+ # Only automatically generated descriptors are rotated on
+ # encryption, unused(KEY) is not.
+ assert_equal(xpub_info["xpub"], xpub)
+ assert "xprv" not in xpub_info
+
+ self.log.info("HD privkey can be retrieved from encrypted wallets")
+ assert_raises_rpc_error(
+ -13,
+ "Error: Please enter the wallet passphrase with walletpassphrase first",
+ wallet.derivehdkey,
+ "m/87h/0h/0h/0",
+ private=True,
+ )
+ with WalletUnlock(wallet, "pass"):
+ xpub_info = wallet.derivehdkey("m/87h/0h/0h/0", private=True)
+ # Unused(KEY) is preferred over active descriptors and is not
+ # rotated on encryption.
+ assert_equal(xpub_info["xprv"], prev_xprv)
+
+ def test_multiple_unused_keys(self):
+ self.log.info("Test multiple unused(KEY) descriptors")
+ self.nodes[0].createwallet("multiple_unused_keys", blank=True)
+ wallet = self.nodes[0].get_wallet_rpc("multiple_unused_keys")
+ master_xpub_1 = wallet.addhdkey()['xpub']
+ master_xpub_2 = wallet.addhdkey()['xpub']
+ assert_raises_rpc_error(
+ -5,
+ "Unable to determine which HD key to use. Please specify with 'hdkey'",
+ wallet.derivehdkey,
+ "m/87h",
+ )
+ xpub_info_1 = wallet.derivehdkey("m/87h", hdkey=master_xpub_1)
+ xpub_info_2 = wallet.derivehdkey("m/87h", hdkey=master_xpub_2)
+ assert xpub_info_1["xpub"] != xpub_info_2["xpub"]
+
+ assert_raises_rpc_error(
+ -5,
+ "Unable to parse HD key. Please provide a valid xpub",
+ wallet.derivehdkey,
+ "m/87h",
+ hdkey="bad",
+ )
+
+ def test_active_descriptor(self):
+ self.log.info("Test derivation in wallet with regular descriptors")
+ self.nodes[0].createwallet("active_descriptor")
+ wallet = self.nodes[0].get_wallet_rpc("active_descriptor")
+ xpub_info = wallet.derivehdkey("m/44h/1h/0h")
+ active_xpub = wallet.gethdkeys(active_only=True)[0]["xpub"]
+ assert_equal(wallet.derivehdkey("m/44h/1h/0h", hdkey=active_xpub), xpub_info)
+
+ # Get the activate wpkh() receive descriptor
+ desc = list(filter(lambda d:
+ d["active"] and not d["internal"] and d["desc"][0:3] == "pkh",
+ wallet.listdescriptors()["descriptors"])
+ )[0]["desc"]
+ self.log.debug(desc)
+
+ assert(xpub_info["origin"] in desc)
+ assert(xpub_info["xpub"] in desc)
+
+ self.log.info("Test unused(KEY) descriptors are preferred over active descriptors")
+ master_xpub = wallet.addhdkey()["xpub"]
+ assert_equal(
+ wallet.derivehdkey("m/87h", hdkey=master_xpub)["xpub"],
+ wallet.derivehdkey("m/87h")["xpub"],
+ )
+
+ def test_inactive_descriptor(self):
+ self.log.info("Test that the HD key of a used inactive descriptor is rejected")
+ # Mint a spendable descriptor in a throwaway wallet, so we can import it
+ # into the wallet under test and deactivate it there.
+ self.nodes[0].createwallet(wallet_name="hdkey_source")
+ source = self.nodes[0].get_wallet_rpc("hdkey_source")
+ desc = next(d["desc"] for d in source.listdescriptors(private=True)["descriptors"]
+ if d["active"] and not d["internal"] and d["desc"].startswith("pkh("))
+
+ self.nodes[0].createwallet(wallet_name="inactive_descriptor", blank=True)
+ wallet = self.nodes[0].get_wallet_rpc("inactive_descriptor")
+ request = {"desc": desc, "range": [0, 9], "timestamp": "now", "internal": False}
+ assert_equal(wallet.importdescriptors([{**request, "active": True}])[0]["success"], True)
+
+ # While the descriptor is active its HD key can be used for derivation.
+ xpub = wallet.gethdkeys(active_only=True)[0]["xpub"]
+ wallet.derivehdkey("m/87h", hdkey=xpub)
+
+ # Once deactivated the descriptor is neither active nor unused(KEY), so
+ # its HD key is no longer a candidate.
+ assert_equal(wallet.importdescriptors([{**request, "active": False}])[0]["success"], True)
+ assert_raises_rpc_error(
+ -5,
+ "HD key is not used by an active or unused(KEY) descriptor",
+ wallet.derivehdkey,
+ "m/87h",
+ hdkey=xpub,
+ )
+
+ def test_noprivs_blank(self):
+ self.log.info("Test derivehdkey on wallet without private keys")
+ self.nodes[0].createwallet(wallet_name="noprivs", disable_private_keys=True)
+ wallet = self.nodes[0].get_wallet_rpc("noprivs")
+ assert_raises_rpc_error(
+ -4,
+ "derivehdkey is not available for watch-only wallets",
+ wallet.derivehdkey,
+ "m/87h",
+ )
+
+ self.log.info("Test derivehdkey on blank wallet")
+ self.nodes[0].createwallet(wallet_name="blank", blank=True)
+ wallet = self.nodes[0].get_wallet_rpc("blank")
+ assert_raises_rpc_error(
+ -5,
+ "No active or unused(KEY) descriptor found",
+ wallet.derivehdkey,
+ "m/87h",
+ )
+
+ def test_compare(self):
+ self.log.info(
+ "Compare with result of createwalletdescriptor")
+ self.nodes[0].createwallet(wallet_name="w1", blank=True)
+ wallet = self.nodes[0].get_wallet_rpc("w1")
+ master_xpub = wallet.addhdkey()["xpub"]
+
+ # Derive xpub for legacy descriptor
+ xpub_info = wallet.derivehdkey("m/44h/1h/0h")
+
+ # Generate legacy descriptor
+ wallet.createwalletdescriptor(type="legacy", hdkey=master_xpub)
+
+ # Get the activate wpkh() receive descriptor
+ desc = list(filter(lambda d:
+ d["active"] and not d["internal"] and d["desc"][0:3] == "pkh",
+ wallet.listdescriptors()["descriptors"])
+ )[0]["desc"]
+ self.log.debug(desc)
+
+ assert(xpub_info["origin"] in desc)
+ assert(xpub_info["xpub"] in desc)
+
+
+if __name__ == '__main__':
+ WalletDeriveHDKeyTest(__file__).main()
### test/functional/wallet_multisig_descriptor_psbt.py
@@ -13,6 +13,8 @@
assert_equal,
)
+from test_framework.descriptors import descsum_create
+
class WalletMultisigDescriptorPSBTTest(BitcoinTestFramework):
def set_test_params(self):
@@ -25,12 +27,13 @@ def skip_test_if_missing_module(self):
@staticmethod
def _get_xpub(wallet):
- """Extract the wallet's xpubs using `listdescriptors` and pick the one from the `pkh` descriptor since it's least likely to be accidentally reused (legacy addresses)."""
- pkh_descriptor = next(filter(lambda d: d["desc"].startswith("pkh(") and not d["internal"], wallet.listdescriptors()["descriptors"]))
+ """Derive an xpub at m/44h/1h/0h using `derivehdkey`. This derivation matches the `pkh` descriptor since it's least likely to be accidentally reused (legacy addresses)."""
+ # Ideally we would use m/87h/1h/0h but the wallet currently can't sign
+ # for a derivation path that's not used in one of its descriptors.
+ hdkey_info = wallet.derivehdkey("m/44h/1h/0h")
# Keep all key origin information (master key fingerprint and all derivation steps) for proper support of hardware devices
# See section 'Key origin identification' in 'doc/descriptors.md' for more details...
- # Replace the change index with the multipath convention
- return pkh_descriptor["desc"].split("pkh(")[1].split(")")[0].replace("/0/*", "/<0;1>/*")
+ return f"{hdkey_info['origin']}{hdkey_info['xpub']}/<0;1>/*"
@staticmethod
def _check_psbt(psbt, to, value, multisig):
@@ -49,14 +52,14 @@ def participants_create_multisigs(self, xpubs):
for i in range(self.N):
self.node.createwallet(wallet_name=f"{self.name}_{i}", blank=True, disable_private_keys=True)
multisig = self.node.get_wallet_rpc(f"{self.name}_{i}")
- multisig_desc = f"wsh(sortedmulti({self.M},{','.join(xpubs)}))"
- checksum = multisig.getdescriptorinfo(multisig_desc)["checksum"]
+ desc = descsum_create(f"wsh(sortedmulti({self.M},{','.join(xpubs)}))")
+ self.log.debug(desc)
result = multisig.importdescriptors([
- { # Multipath descriptor expands to receive and change
- "desc": f"{multisig_desc}#{checksum}",
+ {
+ "desc": desc,
"active": True,
"timestamp": "now",
- }
+ },
])
assert all(r["success"] for r in result)
yield multisig
@@ -121,6 +124,7 @@ def run_test(self):
self.log.info("Finally, collect the signed PSBTs with combinepsbt, finalizepsbt, then broadcast the resulting transaction...")
combined = coordinator_wallet.combinepsbt(psbts)
+ self.log.debug(coordinator_wallet.analyzepsbt(combined))
finalized = coordinator_wallet.finalizepsbt(combined)
coordinator_wallet.sendrawtransaction(finalized["hex"])
Why this scored 26/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.