Skip to content

wallet: derivehdkey RPC to get xpub at arbitrary path#32784

Open
Sjors wants to merge 12 commits into
bitcoin:masterfrom
Sjors:2025/06/gethdkey
Open

wallet: derivehdkey RPC to get xpub at arbitrary path#32784
Sjors wants to merge 12 commits into
bitcoin:masterfrom
Sjors:2025/06/gethdkey

Conversation

@Sjors

@Sjors Sjors commented Jun 20, 2025

Copy link
Copy Markdown
Member

Adds a derivehdkey RPC that returns an xpub, or optionally the xprv, at an arbitrary BIP32 path (with at least one hardened step), derived from a wallet HD key.

The main use case is coordinating a multisig setup, where each participant shares an xpub derived at a hardened path (e.g. m/87h/0h/0h) distinct from their default single-signature descriptors. See the (updated) doc/multisig-tutorial.md and (updated) functional test to see how that workflow improves.

The first commits are some helpful helpers:

  • key: add DeriveExtKey() helper - performs the actual derivation
  • test: move parse_hd_keypath test to bip32_tests - from psbt_wallet_tests
  • Have ParseHDKeypath handle h derivation marker
  • util: reject out-of-range BIP32 keypath indices - ParseHDKeypath would previously map overflowing values without h to hardened.
  • fuzz: check ParseHDKeypath/WriteHDKeypath round-trip
  • rpc: ParsePathBIP32 helper
  • refactor: add hardened derivation helper - HasHardenedDerivation(), to enforce the "at least one hardened step" rule
  • wallet: generalize GetActiveHDPubKeys helper - extracts code from gethdkeys which derivehdkey needs
  • wallet: add GetExtKey helper - reconstruct an xprv from a wallet xpub (analog of GetKey()); behavior-preserving prep, also simplifies gethdkeys.

Meat and potatoes:

  • rpc: add derivehdkey - the RPC itself, plus the UnusedKey filter on GetHDPubKeys that drives key selection.
  • test: use derivehdkey in M-of-N multisig demo - rewrites the functional multisig test to use the RPC and <0;1> syntax.
  • doc: use derivehdkey in multisig tutorial - same for the prose tutorial.

@DrahtBot DrahtBot changed the title wallet: derivehdkey RPC to get xpub at arbitrary path wallet: derivehdkey RPC to get xpub at arbitrary path Jun 20, 2025
@DrahtBot

DrahtBot commented Jun 20, 2025

Copy link
Copy Markdown
Contributor

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Code Coverage & Benchmarks

For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/32784.

Reviews

See the guideline for information on the review process.

Type Reviewers
Concept ACK rkrux, pseudoramdom

If your review is incorrectly listed, please copy-paste <!--meta-tag:bot-skip--> into the comment that the bot should ignore.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #35170 (test: Better test coverage for legacy ParseHDKeypath() by optout21)
  • #35069 (Refactor keypath parser by pythcoiner)
  • #34502 (wallet: remove most asserts of WALLET_FLAG_DESCRIPTORS flag by rkrux)
  • #32861 (Have createwalletdescriptor auto-detect an unused(KEY) by Sjors)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

LLM Linter (✨ experimental)

Possible typos and grammar issues:

  • Get the activate wpkh() receive descriptor -> Get the active wpkh() receive descriptor [“activate” is the wrong word; the intended meaning is the active descriptor]

2026-07-03 11:48:38

@DrahtBot

Copy link
Copy Markdown
Contributor

🚧 At least one of the CI tasks failed.
Task lint: https://github.com/bitcoin/bitcoin/runs/44475268764
LLM reason (✨ experimental): The CI failure is caused by errors from the lint check 'py_lint', specifically due to unused imports flagged by ruff.

Hints

Try to run the tests locally, according to the documentation. However, a CI failure may still
happen due to a number of reasons, for example:

  • Possibly due to a silent merge conflict (the changes in this pull request being
    incompatible with the current code in the target branch). If so, make sure to rebase on the latest
    commit of the target branch.

  • A sanitizer issue, which can only be found by compiling with the sanitizer and running the
    affected test.

  • An intermittent issue.

Leave a comment here, if you need help tracking down a confusing failure.

Comment thread src/rpc/util.h Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In util.h: “@params[in] path” → “@param[in] path” [Doxygen tag typo]

Comment thread doc/multisig-tutorial.md Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressesd -> addresses [extra “d” makes “addresses” misspelled]

@rkrux

rkrux commented Jul 18, 2025

Copy link
Copy Markdown
Contributor

Very nice, Concept ACK.
I will review this PR.

@Sjors Sjors force-pushed the 2025/06/gethdkey branch 2 times, most recently from 380a57f to 017fb68 Compare August 1, 2025 10:52
@Sjors

Sjors commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Ok, this should address the last review round: #32784 (review)

Comment thread src/wallet/rpc/wallet.cpp Outdated
}
}

std::optional<CKey> key{wallet->GetKey(xpub.pubkey.GetID())};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 63450ca rpc: add derivehdkey

IIUC this is checking if the wallet has the private key for xpub.pubkey.
Should we verify that the supplied xpub exactly matches one of the wallet’s known descriptor xpubs before using it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CExtPubKey xpub comes from the hdkey argument of the RPC call. It's the xpub for the master xprv. It's not expected to appear in regular wallet descriptors, because those use xpubs at a deeper derivation path (usually account level). The only exception is the new unused() descriptor.

However it does make sense to require that the xpub matches either an unused() descriptor or an xpub corresponding to an hd key. I added that check plus a test (63450ca -> ...). I first extracted two news helpers: GetExtKey in b407258 and GetHDPubKeys in f488a77.

@Sjors Sjors force-pushed the 2025/06/gethdkey branch from aecccdc to d0b9579 Compare June 26, 2026 17:51
@Sjors

Sjors commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Addressed @pseudoramdom's feedback #32784 (comment). This adds a safety check and gets rid of code duplication in the process. I also expanded the PR description.

Some commits could be a standalone refactor pull request if the review burden is too much, but they all towards the goal of adding derivehdkey.

Comment thread src/rpc/util.cpp
std::vector<uint32_t> out;
if (!ParseHDKeypath(path, out)) {
throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid BIP32 keypath");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParsePathBIP32() currently accepts m/2147483648, which is 0x80000000. Since BIP32 uses that high bit to mark hardened derivation, the RPC ends up treating this as m/0h instead of rejecting it.

Suggestion:

diff
diff --git a/src/rpc/util.cpp b/src/rpc/util.cpp
index ab27a84a67..d1770f0bdf 100644
--- a/src/rpc/util.cpp
+++ b/src/rpc/util.cpp
@@ -29,6 +29,7 @@
 
 #include <algorithm>
 #include <iterator>
+#include <sstream>
 #include <string_view>
 #include <tuple>
 #include <utility>
@@ -1381,6 +1382,25 @@ std::vector<uint32_t> ParsePathBIP32(const std::string& path)
     if (!ParseHDKeypath(path, out)) {
         throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid BIP32 keypath");
     }
+
+    // BIP32 child indices must fit in 31 bits; the high bit is reserved for
+    // the hardened derivation marker.
+    std::stringstream ss(path);
+    std::string item;
+    bool first{true};
+    while (std::getline(ss, item, '/')) {
+        if (first && item == "m") {
+            first = false;
+            continue;
+        }
+        if (item.ends_with('\'') || item.ends_with('h')) item.pop_back();
+
+        const auto number{ToIntegral<uint32_t>(item)};
+        if (!number || *number > 0x7fffffffU) {
+            throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid BIP32 keypath");
+        }
+        first = false;
+    }
     return out;
 }
 
diff --git a/test/functional/wallet_derivehdkey.py b/test/functional/wallet_derivehdkey.py
index 5ed8cc0eb0..32287c9464 100755
--- a/test/functional/wallet_derivehdkey.py
+++ b/test/functional/wallet_derivehdkey.py
@@ -44,6 +44,13 @@ class WalletDeriveHDKeyTest(BitcoinTestFramework):
             wallet.derivehdkey,
             "m/87/0/0",
         )
+        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",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Fixed in f086c97 (in bip32.cpp)

@Sjors Sjors force-pushed the 2025/06/gethdkey branch from d0b9579 to 176213b Compare July 1, 2026 10:03
@Sjors

Sjors commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Added two commits:

Sjors and others added 12 commits July 3, 2026 13:41
Co-authored-by: w0xlt <94266259+w0xlt@users.noreply.github.com>
ParseHDKeypath() lives in util/bip32, so its unit test belongs in
bip32_tests rather than psbt_wallet_tests. Pure move, no changes to the
test itself; subsequent commits extend it in its new home.
ParseHDKeypath() parsed each path element with ToIntegral<uint32_t>, so
a bare decimal >= 2^31 (e.g. "m/2147483648" == 0x80000000) was silently
treated as "m/0h".

This commit rejects such overflow instead.
GetHDPubKeys() centralizes the descriptor xpub lookup used by gethdkeys and
createwalletdescriptor, and by the derivehdkey RPC added in a later commit.
The HDKeyFilter argument serves gethdkeys' active_only mode (Active vs All)
and createwalletdescriptor's active descriptor selection.

No behavior change, except the dynamic_cast now uses Assert() instead of
gethdkeys' CHECK_NONFATAL, since it is not a recoverable input.
Reconstruct a descriptor's extended private key from its xpub by looking
up the corresponding private key. This is the extended-key analog of
GetKey().

It is used here to simplify gethdkeys, and again by the derivehdkey RPC
introduced in a later commit.
Add an UnusedKey filter to GetHDPubKeys() so the new RPC can prefer
unused(KEY) descriptors before falling back to active descriptors.

Co-authored-by: w0xlt <94266259+w0xlt@users.noreply.github.com>
Use derivehdkey instead of extracting each participant xpub (and
derivation info) from  the listdescriptors output.

Additionally use the new <0;1> descriptor syntax.

Finally this commits adds a few debug log lines, and expand the
explanation for why we use m/44h/1h/0h.
Use derivehdkey instead of extracting each participant xpub
from  the listdescriptors output.

Additionally use the new <0;1> descriptor syntax.
@Sjors Sjors force-pushed the 2025/06/gethdkey branch from de552d1 to 3ebd59e Compare July 3, 2026 11:48
@Sjors

Sjors commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Rebased after #32489. I moved the new fuzz: check ParseHDKeypath/WriteHDKeypath round-trip closer to the two ParseHDKeypath commits in the stack.

Sjors added a commit to Sjors/bitcoin that referenced this pull request Jul 3, 2026
…tor' into HEAD

Resolve the createwalletdescriptor conflict between bitcoin#32784 and bitcoin#32861 by keeping bitcoin#32861's fallback to unused KEY descriptors, but adapting the active descriptor lookup to bitcoin#32784's GetHDPubKeys(HDKeyFilter::Active) API and extracting the xpub from the map key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants