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

pull Sjors wants to merge 6 commits into bitcoin:master from Sjors:2025/06/gethdkey changing 10 files +353 −46
  1. Sjors commented at 11:04 AM on June 20, 2025: member

    Given a (blank) wallet with an unused(KEY) descriptor, derivehdkey "m/87h/0h/0h" gets the xpub or xprv at any given path.

    This is particularly useful for multisig setup where it's not desirable to use our default derivations (e.g. 44h).

    I updated the multisig tutorial and the functional test.

  2. DrahtBot renamed this:
    wallet: derivehdkey RPC to get xpub at arbitrary path
    wallet: derivehdkey RPC to get xpub at arbitrary path
    on Jun 20, 2025
  3. DrahtBot added the label Wallet on Jun 20, 2025
  4. DrahtBot commented at 11:04 AM on June 20, 2025: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

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

    <!--021abf342d371248e50ceaed478a90ca-->

    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 <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #bitcoin-core/gui/872 (Menu action to export a watchonly wallet by achow101)
    • #35462 (test: remove unnecessary nodes from wallet_multisig_descriptor_psbt by rkrux)
    • #35069 (Refactor keypath parser by pythcoiner)
    • #32489 (wallet: Add exportwatchonlywallet RPC to export a watchonly version of a wallet by achow101)
    • #31260 (scripted-diff: Type-safe settings retrieval by ryanofsky)

    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.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

    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 here; “active” is needed for the intended meaning.]

    <sup>2026-05-14 12:47:27</sup>

  5. DrahtBot added the label CI failed on Jun 20, 2025
  6. DrahtBot commented at 11:47 AM on June 20, 2025: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

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

    <details><summary>Hints</summary>

    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.

    </details>

  7. Sjors force-pushed on Jun 20, 2025
  8. Sjors force-pushed on Jun 20, 2025
  9. Sjors force-pushed on Jun 20, 2025
  10. DrahtBot removed the label CI failed on Jun 20, 2025
  11. in src/rpc/util.h:None in 30d5e5ccf7 outdated
     151 | @@ -152,6 +152,14 @@ std::pair<int64_t, int64_t> ParseDescriptorRange(const UniValue& value);
     152 |  /** Evaluate a descriptor given as a string, or as a {"desc":...,"range":...} object, with default range of 1000. */
     153 |  std::vector<CScript> EvalDescriptorStringOrObject(const UniValue& scanobject, FlatSigningProvider& provider, const bool expand_priv = false);
     154 |  
     155 | +//! Get extended key and origin info for a given path
     156 | +//! @params[in] path The BIP 32 path
    


    DrahtBot commented at 9:41 AM on June 25, 2025:
    In util.h: “@params[in] path” → “@param[in] path” [Doxygen tag typo]
  12. Sjors force-pushed on Jun 26, 2025
  13. Sjors force-pushed on Jun 27, 2025
  14. in doc/multisig-tutorial.md:None in 35eec44491 outdated
     137 |  
     138 | -./build/bin/bitcoin-cli  -signet -rpcwallet="multisig_wallet_01" getwalletinfo
     139 | +./build/bin/bitcoin rpc  -signet -rpcwallet="multisig_wallet_01" listdescriptors
     140 |  ```
     141 |  
     142 | +The `<0;1>` notation in `desc` caused the creation of two descriptors. One uses the chain 0 for external addressesd the other the chain 1 for internal ones (change).
    


    DrahtBot commented at 4:37 AM on July 2, 2025:
    addressesd -> addresses [extra “d” makes “addresses” misspelled]
  15. rkrux commented at 11:11 AM on July 18, 2025: contributor

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

  16. Sjors force-pushed on Aug 1, 2025
  17. Sjors force-pushed on Aug 1, 2025
  18. DrahtBot added the label Needs rebase on Sep 25, 2025
  19. Sjors commented at 10:15 AM on September 26, 2025: member

    (will rebase this later)

  20. Sjors force-pushed on Jan 5, 2026
  21. Sjors commented at 4:05 AM on January 5, 2026: member

    Rebased after #32821 landed and #29136 was rebased. Re-tested by having an LLM follow the tutorial.

  22. DrahtBot removed the label Needs rebase on Jan 5, 2026
  23. Sjors force-pushed on Feb 2, 2026
  24. DrahtBot added the label CI failed on Apr 28, 2026
  25. DrahtBot commented at 12:39 PM on April 28, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/25052455086/job/73383604351</sub> <sub>LLM reason (✨ experimental): CI failed because the CMake build errored while compiling src/wallet (bitcoin_wallet target), stopping with make/gmake exit code 2.</sub>

    <details><summary>Hints</summary>

    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.

    </details>

  26. Sjors commented at 3:30 PM on April 28, 2026: member

    I pushed an extra commit 9814b3350df3ec8381a04189d66e5a20d9414ab0 to pre-empt IWYU warnings (at least in https://github.com/Sjors/bitcoin/pull/91, probably here too). That caused CI to pick up the latest master, which fails as expected. I'll push again after #29136 is updated.

  27. Sjors force-pushed on May 1, 2026
  28. Sjors force-pushed on May 1, 2026
  29. Sjors commented at 11:28 AM on May 1, 2026: member

    Rebased after #29136 updated and squashed 9814b3350df3ec8381a04189d66e5a20d9414ab0 IWYU fixup into the first commit.

  30. DrahtBot removed the label CI failed on May 1, 2026
  31. rpc: add DeriveExtKey() helper 6b5fae37e5
  32. Have ParseHDKeypath handle h derivation marker dec9fbb261
  33. rpc: ParsePathBIP32 helper 280cf86f05
  34. rpc: add derivehdkey 00e25f6c28
  35. test: use derivehdkey in M-of-N multisig demo
    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.
    78483814b5
  36. doc: use derivehdkey in multisig tutorial
    Use derivehdkey instead of extracting each participant xpub
    from  the listdescriptors output.
    
    Additionally use the new <0;1> descriptor syntax.
    
    Also use bitcoin rpc instead of bitcoin-cli.
    a45777992b
  37. Sjors force-pushed on May 14, 2026
  38. Sjors commented at 12:47 PM on May 14, 2026: member

    Rebased now that #29136 landed.

  39. Sjors marked this as ready for review on May 14, 2026
  40. sedited requested review from polespinasa on May 22, 2026
  41. pseudoramdom commented at 5:27 PM on June 1, 2026: none

    Concept ACK

    Hi @Sjors, could the implementation move out of the RPC into a wallet::DeriveHDKey() helper, similar to #34861 ? The use case I have in mind is multisig setup in gui-qml, where each cosigner needs to share a derived xpub. A single interfaces::Wallet method that combines addhdkey & derivehdkey would let the GUI get a shareable xpub in one call.

  42. pseudoramdom commented at 9:30 PM on June 1, 2026: none

    A single interfaces::Wallet method that combines addhdkey & derivehdkey would let the GUI get a shareable xpub in one call.

    After chatting with @achow101, these might be separate interfaces. We'll still need the logic to be moved out of the RPC for derivehdkey. I have PR for addHDKey interface https://github.com/bitcoin/bitcoin/pull/35436

  43. in src/rpc/util.cpp:1388 in a45777992b
    1381 | @@ -1379,6 +1382,31 @@ std::vector<CScript> EvalDescriptorStringOrObject(const UniValue& scanobject, Fl
    1382 |      return ret;
    1383 |  }
    1384 |  
    1385 | +std::optional<std::pair<CExtKey, KeyOriginInfo>> DeriveExtKey(const CExtKey& ext_key, const std::vector<uint32_t>& path) {
    1386 | +    CExtKey descendant = ext_key;
    1387 | +    KeyOriginInfo origin;
    1388 | +    origin.clear(); // Prevent spurious uninitialized variable warning
    


    w0xlt commented at 11:16 PM on June 2, 2026:

    derivehdkey("m") currently leaves the origin fingerprint as [00000000] because the fingerprint is only populated inside the child-derivation loop, which does not run for an empty path.

    diff --git a/src/rpc/util.cpp b/src/rpc/util.cpp
    index 9cae5b8ecd..1754d26cfa 100644
    --- a/src/rpc/util.cpp
    +++ b/src/rpc/util.cpp
    @@ -1386,14 +1386,11 @@ std::optional<std::pair<CExtKey, KeyOriginInfo>> DeriveExtKey(const CExtKey& ext
         CExtKey descendant = ext_key;
         KeyOriginInfo origin;
         origin.clear(); // Prevent spurious uninitialized variable warning
    +    const CKeyID id = ext_key.key.GetPubKey().GetID();
    +    std::copy(id.begin(), id.begin() + sizeof(origin.fingerprint), origin.fingerprint);
         origin.path = path;
    -    bool first = true;
         for (uint32_t i : path) {
             if (!descendant.Derive(descendant, i)) return std::nullopt;
    -        if (first) {
    -            memcpy(origin.fingerprint, descendant.vchFingerprint, 4);
    -            first = false;
    -        }
         }
         return std::make_pair(descendant, origin);
     }
    diff --git a/src/rpc/util.h b/src/rpc/util.h
    index d00920a9eb..70bde19dfe 100644
    --- a/src/rpc/util.h
    +++ b/src/rpc/util.h
    @@ -159,7 +159,7 @@ std::vector<CScript> EvalDescriptorStringOrObject(const UniValue& scanobject, Fl
     //! Get extended key and origin info for a given path
     //! [@param](/bitcoin-bitcoin/contributor/param/)[in] ext_key The extended private key to derive from
     //! [@param](/bitcoin-bitcoin/contributor/param/)[in] path The BIP 32 path
    -//! [@return](/bitcoin-bitcoin/contributor/return/) the resulting extended private key and origin info (blank if path is empty)
    +//! [@return](/bitcoin-bitcoin/contributor/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);
     
     //! Parse BIP32 path
    diff --git a/test/functional/wallet_derivehdkey.py b/test/functional/wallet_derivehdkey.py
    index 118726b94b..0bedc985a8 100755
    --- a/test/functional/wallet_derivehdkey.py
    +++ b/test/functional/wallet_derivehdkey.py
    @@ -35,6 +35,8 @@ class WalletDeriveHDKeyTest(BitcoinTestFramework):
             xpub_info = wallet.derivehdkey("m")
             assert "xprv" not in xpub_info
             xpub = xpub_info["xpub"]
    +        root_fingerprint = wallet.derivehdkey("m/0")["origin"][1:9]
    +        assert_equal(xpub_info["origin"], f"[{root_fingerprint}]")
     
             xpub_info = wallet.derivehdkey("m", private=True)
             xprv = xpub_info["xprv"]
    
  44. in src/wallet/rpc/wallet.cpp:1030 in a45777992b
    1025 | +                throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, strprintf("Private key for %s is not known", EncodeExtPubKey(xpub)));
    1026 | +            }
    1027 | +            CExtKey xprv(xpub, *key);
    1028 | +
    1029 | +            std::optional<std::pair<CExtKey, KeyOriginInfo>> child{DeriveExtKey(xprv, path)};
    1030 | +            CHECK_NONFATAL(child);
    


    w0xlt commented at 11:34 PM on June 2, 2026:

    Could derivehdkey() return RPC_INVALID_PARAMETER when DeriveExtKey() returns null for a user-provided path, such as one exceeding BIP32’s maximum depth?

    That would avoid surfacing invalid input through CHECK_NONFATAL(), which looks more like an internal bug path.

    diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp
    index f086ead493..87a860dbf8 100644
    --- a/src/wallet/rpc/wallet.cpp
    +++ b/src/wallet/rpc/wallet.cpp
    @@ -1027,7 +1027,9 @@ RPCMethod derivehdkey()
                 CExtKey xprv(xpub, *key);
     
                 std::optional<std::pair<CExtKey, KeyOriginInfo>> child{DeriveExtKey(xprv, path)};
    -            CHECK_NONFATAL(child);
    +            if (!child) {
    +                throw JSONRPCError(RPC_INVALID_PARAMETER, "Unable to derive HD key at the requested path");
    +            }
     
                 UniValue res{UniValue::VOBJ};
     
    diff --git a/test/functional/wallet_derivehdkey.py b/test/functional/wallet_derivehdkey.py
    index 118726b94b..13220a5aba 100755
    --- a/test/functional/wallet_derivehdkey.py
    +++ b/test/functional/wallet_derivehdkey.py
    @@ -35,6 +35,13 @@ class WalletDeriveHDKeyTest(BitcoinTestFramework):
             xpub_info = wallet.derivehdkey("m")
             assert "xprv" not in xpub_info
             xpub = xpub_info["xpub"]
    +        too_deep_path = "m/" + "/".join(["0"] * 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", private=True)
             xprv = xpub_info["xprv"]
    
  45. w0xlt commented at 11:36 PM on June 2, 2026: contributor

    A few review comments:

  46. Sjors commented at 10:08 AM on June 5, 2026: member

    I'll mark this draft pending #35436.

  47. Sjors marked this as a draft on Jun 5, 2026
  48. DrahtBot added the label Needs rebase on Jun 8, 2026
  49. DrahtBot commented at 10:05 PM on June 8, 2026: contributor

    <!--cf906140f33d8803c4a75a2196329ecb-->

    🐙 This pull request conflicts with the target branch and needs rebase.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-06-11 10:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me