Refactor keypath parser #35069

pull pythcoiner wants to merge 2 commits into bitcoin:master from pythcoiner:refactor-keypath-parser changing 7 files +97 −70
  1. pythcoiner commented at 9:35 AM on April 14, 2026: contributor

    The codebase used raw 0x80000000 (and implicit 0) as the bip32 hardened / unhardened flag.

    ParseHDKeypath and ParseKeyPathNum were two separate parsers for BIP32 keypath elements with divergent rules: ParseHDKeypath only accepted ' as hardened marker and silently allowed raw values up to 0xFFFFFFFF, while ParseKeyPathNum accepted both ' and h and rejected values > 0x7FFFFFFF.

    This PR:

    • Define BIP32_HARDENED / BIP32_UNHARDENED constants to replace magic 0x80000000 and 0 literals.
    • Add ParseKeyPathElement as bip32 parsing util and use it consistantly in ParseHDKeyPath and ParseKeyPathNum.
  2. DrahtBot commented at 9:36 AM on April 14, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

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

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    Concept ACK Sjors

    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:

    • #32784 (wallet: derivehdkey RPC to get xpub at arbitrary path 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.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

    LLM Linter (✨ experimental)

    Possible typos and grammar issues:

    • “Parse an HD keypaths like "m/7/0'/2000".” -> “Parse an HD keypath like "m/7/0'/2000".” [“keypaths” is a grammatical error here; should be singular to match the standard term “keypath”.]

    Possible places where named args for integral literals may be used (e.g. func(x, /*named_arg=*/0) in C++, and func(x, named_arg=0) in Python):

    • TestVector(...)("xpub...","xprv...", BIP32_HARDENED_FLAG | 2) in src/test/bip32_tests.cpp
    • TestVector(...)("xpub...","xprv...", BIP32_HARDENED_FLAG | 1) in src/test/bip32_tests.cpp

    <sup>2026-04-18 16:16:17</sup>

  3. in src/util/bip32.cpp:21 in 03f2d97e83 outdated
      17 | @@ -18,7 +18,7 @@ std::optional<uint32_t> ParseKeyPathElement(std::span<const char> elem, bool& is
      18 |      if (elem.empty()) return std::nullopt;
      19 |  
      20 |      const char last = elem.back();
      21 | -    if (last == '\'' || last == 'h') {
      22 | +    if (last == '\'' || last == 'h' || last == 'H') {
    


    l0rinc commented at 10:03 AM on April 14, 2026:

    03f2d97 bip32: accept 'H' as hardened derivation indicator:

    this doesn't seem like a "refactor" as the title claims


    pythcoiner commented at 5:16 AM on April 15, 2026:

    I dropped the commit after noticing H has been disalowed as hardened indicator in BIP-0380.


    Sjors commented at 2:43 PM on April 15, 2026:

    Thanks, this gave me dejavu :-)

  4. l0rinc commented at 10:05 AM on April 14, 2026: contributor

    Please add a proper title and PR description with problem description and motivation and context

  5. pythcoiner commented at 10:07 AM on April 14, 2026: contributor

    Please add a proper title and PR description with problem description and motivation and context

    yes I'll before undraft

  6. DrahtBot added the label CI failed on Apr 14, 2026
  7. pythcoiner force-pushed on Apr 15, 2026
  8. pythcoiner force-pushed on Apr 15, 2026
  9. pythcoiner force-pushed on Apr 15, 2026
  10. pythcoiner force-pushed on Apr 15, 2026
  11. DrahtBot removed the label CI failed on Apr 15, 2026
  12. pythcoiner marked this as ready for review on Apr 15, 2026
  13. Sjors commented at 2:47 PM on April 15, 2026: member

    Concept ACK

    Using constants instead of magic numbers is great. I'll look at the second commit in more detail later.

    There might be overlap with some commits in #32784 (and it's nice if I can shrink that PR).

  14. in src/util/bip32.h:15 in c98498fd50
       8 | @@ -9,6 +9,11 @@
       9 |  #include <string>
      10 |  #include <vector>
      11 |  
      12 | +/** BIP32 unhardened child index (no high bit set) */
      13 | +static constexpr uint32_t BIP32_UNHARDENED = 0x0;
      14 | +/** BIP32 hardened derivation flag (2^31) */
      15 | +static constexpr uint32_t BIP32_HARDENED = 0x80000000;
    


    rkrux commented at 1:47 PM on April 16, 2026:

    In c98498fd507f2f3761d7d69f933986ec9c880f13: It would be less distracting if the term child is not used here. Also, a flag suffix can make it easier to read.

    diff --git a/src/util/bip32.h b/src/util/bip32.h
    index 9ecc5c052b..f1443aff03 100644
    --- a/src/util/bip32.h
    +++ b/src/util/bip32.h
    @@ -11,13 +11,13 @@
     #include <string>
     #include <vector>
     
    -/** BIP32 unhardened child index (no high bit set) */
    -static constexpr uint32_t BIP32_UNHARDENED = 0x0;
    +/** BIP32 unhardened derivation index (no high bit set) */
    +static constexpr uint32_t BIP32_UNHARDENED_FLAG = 0x0;
     /** BIP32 hardened derivation flag (2^31) */
    -static constexpr uint32_t BIP32_HARDENED = 0x80000000;
    +static constexpr uint32_t BIP32_HARDENED_FLAG = 0x80000000;
    

    rkrux commented at 2:28 PM on April 16, 2026:

    There are more places where the hardened constant can be used.

    git grep 0x80000000 src/wallet
    
  15. in src/util/bip32.h:20 in 4ba2d10f5b
      15 | @@ -14,6 +16,11 @@ static constexpr uint32_t BIP32_UNHARDENED = 0x0;
      16 |  /** BIP32 hardened derivation flag (2^31) */
      17 |  static constexpr uint32_t BIP32_HARDENED = 0x80000000;
      18 |  
      19 | +/** Parse a single key path element like "0", "0'", or "0h".
      20 | + *  Returns the child index and sets is_hardened, or nullopt on failure
    


    rkrux commented at 1:49 PM on April 16, 2026:

    Nit In 4ba2d10f5b75f24d3758d56aa5cc4a6c22f8c6e3:

     /** Parse a single key path element like "0", "0'", or "0h".
    - *  Returns the child index and sets is_hardened, or nullopt on failure
    + *  Returns the derivation index and sets is_hardened, or nullopt on failure
      *  (in which case `error` is populated with a human-readable message). */
     std::optional<uint32_t> ParseKeyPathElement(std::span<const char> elem, bool& is_hardened, std::string& error);
    
  16. in src/util/bip32.cpp:39 in 4ba2d10f5b
      35 | +    }
      36 | +    if (*number >= BIP32_HARDENED) {
      37 | +        error = strprintf("Key path value %u is out of range", *number);
      38 | +        return std::nullopt;
      39 | +    }
      40 | +    return *number;
    


    rkrux commented at 1:52 PM on April 16, 2026:

    In 4ba2d10f5b75f24d3758d56aa5cc4a6c22f8c6e3: This can return the binary or'ed portion here, otherwise it's duplicated across the callers.

    -    return *number;
    +    return *number | (is_hardened ? BIP32_HARDENED : BIP32_UNHARDENED);
     }
    
  17. in src/util/bip32.cpp:20 in 4ba2d10f5b outdated
      16 | +std::optional<uint32_t> ParseKeyPathElement(std::span<const char> elem, bool& is_hardened, std::string& error)
      17 | +{
      18 | +    is_hardened = false;
      19 | +    const std::string_view raw{elem.begin(), elem.end()};
      20 | +    if (elem.empty()) {
      21 | +        error = strprintf("Key path value '%s' is not a valid uint32", raw);
    


    rkrux commented at 1:55 PM on April 16, 2026:

    In 4ba2d10:

    A new error flow is added here that was not present earlier. Though it looks fine but the error message at this stage is not entirely correct because a hardened marker can also be present. So maybe the following diff:

         if (elem.empty()) {
    -        error = strprintf("Key path value '%s' is not a valid uint32", raw);
    +        error = strprintf("Key path value '%s' is not valid", raw);
             return std::nullopt;
         }
    

    There would be 3 unit test failures but they can be updated.

  18. in src/script/descriptor.cpp:1754 in 4ba2d10f5b outdated
    1753 | @@ -1754,25 +1754,13 @@ enum class ParseScriptContext {
    1754 |  std::optional<uint32_t> ParseKeyPathNum(std::span<const char> elem, bool& apostrophe, std::string& error, bool& has_hardened)
    


    rkrux commented at 2:12 PM on April 16, 2026:

    In 4ba2d10: This function is only used within ParseKeyPath and after this change, it seems more like a wrapper for ParseKeyPathElem. Can consider making this a lambda and moving it inside ParseKeyPath.

    diff --git a/src/script/descriptor.cpp b/src/script/descriptor.cpp
    index 6a5aafb0bd..b2013d7340 100644
    --- a/src/script/descriptor.cpp
    +++ b/src/script/descriptor.cpp
    @@ -1751,18 +1751,6 @@ enum class ParseScriptContext {
         MUSIG,   //!< Inside musig() (implies P2TR, cannot have nested musig())
     };
     
    -std::optional<uint32_t> ParseKeyPathNum(std::span<const char> elem, bool& apostrophe, std::string& error, bool& has_hardened)
    -{
    -    bool hardened = false;
    -    const auto index{ParseKeyPathElement(elem, hardened, error)};
    -    if (!index.has_value()) return std::nullopt;
    -    if (hardened) {
    -        has_hardened = true;
    -        apostrophe = elem.back() == '\'';
    -    }
    -    return *index | (hardened ? BIP32_HARDENED : BIP32_UNHARDENED);
    -}
    -
     /**
      * Parse a key path, being passed a split list of elements (the first element is ignored because it is always the key).
      *
    @@ -1776,6 +1764,17 @@ std::optional<uint32_t> ParseKeyPathNum(std::span<const char> elem, bool& apostr
      **/
     [[nodiscard]] bool ParseKeyPath(const std::vector<std::span<const char>>& split, std::vector<KeyPath>& out, bool& apostrophe, std::string& error, bool allow_multipath, bool& has_hardened)
     {
    +    auto parserWrapper = [&](std::span<const char> elem) -> std::optional<uint32_t> {
    +        bool hardened = false;
    +        const auto index{ParseKeyPathElement(elem, hardened, error)};
    +        if (!index.has_value()) return std::nullopt;
    +        if (hardened) {
    +            has_hardened = true;
    +            apostrophe = elem.back() == '\'';
    +        }
    +        return *index | (hardened ? BIP32_HARDENED : BIP32_UNHARDENED);
    +    };
    +
         KeyPath path;
         struct MultipathSubstitutes {
             size_t placeholder_index;
    @@ -1808,7 +1807,7 @@ std::optional<uint32_t> ParseKeyPathNum(std::span<const char> elem, bool& apostr
                 substitutes.emplace();
                 std::unordered_set<uint32_t> seen_substitutes;
                 for (const auto& num : nums) {
    -                const auto& op_num = ParseKeyPathNum(num, apostrophe, error, has_hardened);
    +                const auto& op_num = parserWrapper(num);
                     if (!op_num) return false;
                     auto [_, inserted] = seen_substitutes.insert(*op_num);
                     if (!inserted) {
    @@ -1821,7 +1820,7 @@ std::optional<uint32_t> ParseKeyPathNum(std::span<const char> elem, bool& apostr
                 path.emplace_back(); // Placeholder for multipath segment
                 substitutes->placeholder_index = path.size() - 1;
             } else {
    -            const auto& op_num = ParseKeyPathNum(elem, apostrophe, error, has_hardened);
    +            const auto& op_num = parserWrapper(elem);
                 if (!op_num) return false;
                 path.emplace_back(*op_num);
             }
    
    
  19. rkrux commented at 2:48 PM on April 16, 2026: contributor

    As per the PR description, this is already solely not a refactor because it changes the behaviour of ParseHDKeypath.

    Onboard with the first commit.

    However, I'm not confident about the second commit because ParseHDKeypath is used only by legacy wallets and I'm not sure if there are unmigrated legacy wallets where derivation indices more than 0x7FFFFFFF are present in which case the wallet migration could fail after the change in second commit.

  20. pythcoiner force-pushed on Apr 18, 2026
  21. DrahtBot added the label CI failed on Apr 18, 2026
  22. DrahtBot commented at 2:38 AM on April 18, 2026: contributor

    <!--85328a0da195eb286784d51f73fa0af9-->

    🚧 At least one of the CI tasks failed. <sub>Task test ancestor commits: https://github.com/bitcoin/bitcoin/actions/runs/24594754059/job/71922510629</sub> <sub>LLM reason (✨ experimental): CI failed due to a C++ build error: BIP32_HARDENED is an undeclared identifier in script/descriptor.cpp (compilation aborted).</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>

  23. pythcoiner force-pushed on Apr 18, 2026
  24. pythcoiner force-pushed on Apr 18, 2026
  25. pythcoiner force-pushed on Apr 18, 2026
  26. pythcoiner force-pushed on Apr 18, 2026
  27. refactor: define BIP32_HARDENED and BIP32_UNHARDENED constants
    Replace magic 0x80000000 literals with named constants for BIP32
    hardened/unhardened child derivation.
    97ca520104
  28. refactor: deduplicate keypath element parsing
    Extract ParseKeyPathElement() as a shared helper in util/bip32 that
    parses a single BIP32 key path element (e.g. "0", "0'", "0h").
    Both ParseHDKeypath() and the descriptor parser's ParseKeyPathNum()
    now delegate to it, eliminating duplicated parsing logic.
    1ecfd81611
  29. pythcoiner force-pushed on Apr 18, 2026
  30. pythcoiner commented at 7:01 PM on April 18, 2026: contributor

    @rkrux thanks for the review, adressed all your remarks

    However, I'm not confident about the second commit because ParseHDKeypath is used only by legacy wallets and I'm not sure if there are unmigrated legacy wallets where derivation indices more than 0x7FFFFFFF are present in which case the wallet migration could fail after the change in second commit.

    I'm not familiar enough with the codebase to state on this, if the second commit could potentially break a wallet migration maybe I should just drop it and add a comment about why there is 2 parsing logic and why ParseHDKeyPath do not follow the BIP?

  31. DrahtBot removed the label CI failed on Apr 19, 2026

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-04-21 09:12 UTC

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