{
 "number": 36133,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36133",
 "title": "wallet: store multipath descriptor",
 "author": "Sjors",
 "author_association": "MEMBER",
 "created_at": "2026-08-31T18:12:07Z",
 "updated_at": "2026-09-17T02:28:46Z",
 "age_days": 16,
 "draft": true,
 "labels": [
  "Wallet",
  "Needs rebase"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
 "head_ref": "2026/08/multipath-tag",
 "head_repo": "Sjors/bitcoin",
 "head_history": [
  {
   "t": "2026-09-01T18:31:05Z",
   "sha": "912bf553f58d498c3bc765ed83638cc5bda7c069"
  },
  {
   "t": "2026-09-02T11:23:46Z",
   "sha": "2bd57be0d6a1dbe55d09011ece7b5287e40b7346"
  },
  {
   "t": "2026-09-02T13:53:47Z",
   "sha": "1cdbfecb877440b06527cb78f80d7deac85f4d94"
  },
  {
   "t": "2026-09-15T09:19:29Z",
   "sha": "521c2f68389856939a8784b24048fc3f19b23d3e"
  },
  {
   "t": "2026-09-15T09:58:21Z",
   "sha": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff"
  }
 ],
 "additions": 1071,
 "deletions": 362,
 "changed_files": 24,
 "commit_count": 18,
 "size_bucket": "XL",
 "mergeable_state": "dirty",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "concept_ack": [
     {
      "login": "jeanpablojp",
      "url": "https://github.com/bitcoin/bitcoin/pull/36133#pullrequestreview-5079561909"
     }
    ]
   },
   "conflicts": [
    {
     "number": 36143,
     "title": "descriptor: add CreateMultisigDescriptor()",
     "author": "rxbryan"
    },
    {
     "number": 36013,
     "title": "test: Descriptor roundtrip and raw()/ addr() coverage",
     "author": "pablomartin4btc"
    },
    {
     "number": 35998,
     "title": "wallet: Handle or explicitly ignore `WalletBatch` write failures",
     "author": "achow101"
    },
    {
     "number": 35377,
     "title": "wallet: Allow importing of descriptors without private keys when the wallet has the private keys",
     "author": "achow101"
    },
    {
     "number": 35041,
     "title": "descriptor: speed-up `Parse` (xpub/xpriv) in ~30%",
     "author": "brunoerg"
    },
    {
     "number": 34909,
     "title": "wallet, refactor: modularise wallet by extracting out legacy wallet migration",
     "author": "rkrux"
    },
    {
     "number": 34861,
     "title": "wallet: Add importdescriptors interface",
     "author": "polespinasa"
    },
    {
     "number": 32861,
     "title": "Have createwalletdescriptor auto-detect an unused(KEY)",
     "author": "Sjors"
    }
   ]
  }
 },
 "acks_parsed": {
  "jeanpablojp": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-09-01T14:53:15Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 0,
  "concept_ack": 1,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "achow101",
   "jeanpablojp"
  ]
 },
 "signals": {
  "needs_rebase": true,
  "ci_failed": false,
  "mergeable_state": "dirty",
  "last_author_activity": "2026-09-15T09:58:21Z",
  "last_reviewer_activity": "2026-09-15T18:21:53Z",
  "last_reviewer": "achow101",
  "author_silent_days": 2,
  "waiting_on_author_days": 1,
  "days_since_update": 0
 },
 "refs": {
  "mentioned": [
   35377,
   35445,
   36075,
   36230
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 36075,
    "type": "issue",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "wallet: reconstruct or natively support multipath descriptors"
   },
   {
    "number": 35377,
    "type": "pull",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "wallet: Allow importing of descriptors without private keys when the wallet has the private keys"
   },
   {
    "number": 35445,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-09-09",
    "title": "wallet, descriptor: Revert `StringType::COMPAT` for Miniscript expressions and drop the concept of a Descriptor ID that can be validated"
   },
   {
    "number": 36230,
    "type": "pull",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "wallet: Improve `HasWalletDescriptor` performance and other canonical descriptor string followups"
   }
  ],
  "conflicts": [
   36143,
   36013,
   35998,
   35377,
   35041,
   34909,
   34861,
   32861
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/script/descriptor.cpp",
  "src/wallet/wallet.cpp",
  "src/wallet/walletdb.cpp"
 ],
 "body": "**Status: this PR needs further conceptual review / discussion, before going too deep into code review.**\n\nHWI recently added a `registerdescriptor` command (https://github.com/bitcoin-core/HWI/pull/842) which some hardware wallets require in order to (more safely) support multisig. This registration can then be used with `displayaddress` and `signtx` (using the `--registration` argument, see https://github.com/bitcoin-core/HWI/pull/841 and https://github.com/bitcoin-core/HWI/pull/841). Internally HWI converts the descriptor to a [BIP388](https://github.com/bitcoin/bips/blob/master/bip-0388.mediawiki) policy. The only catch is that we need to provide a _multipath_ descriptor, rather than separate receive and change descriptors. This PR makes that possible.\n\nOur wallet already supported importing multipath descriptors, but upon import they are irreversibly turned into change and receive descriptors. We don't overhaul this fundamental descriptor wallet design. Rather (as per approach 5 from #36075) we store an additional record to hold on to the multipath descriptor strings and point to its matching receive and change descriptor.\n\nExisting wallets are not impacted (and not \"upgraded\").\n\nThe main commits are:\n\n- _refactor: introduce descriptor ParseState_: the parser first collects keys, generates replacements (not yet normalized, just converting to public keys) and then applies those replacements (in reverse order).\n- _descriptor: normalize keys in the multipath descriptor string_: no record is generated if hardened derivation blocks normalization (those would be useless).\n- _descriptor: let Parse return the multipath descriptor string_\n- _wallet: store multipath descriptor record on import_\n\nThese commits make wallet handling consistent and expose the record through RPC:\n\n- _wallet: store multipath descriptor record on wallet creation_: covers default wallets and `createwalletdescriptor`.\n- _wallet: expand multipath descriptors with 'h' hardened marker_: we don't preserve the exact imported string anyway, so might as well make it consistent\n- _wallet: copy multipath record in exportwatchonlywallet_: remaps descriptor IDs to the destination wallet.\n- _rpc: return multipath descriptor from getaddressinfo_\n- _rpc: return multipath descriptor from listdescriptors_\n\nThe remaining commits provide parser plumbing, tests, and refactors that make the main changes easier to follow and keep the descriptor code maintainable:\n\n- _miniscript: let FromString take a string_view_\n- _test: add MULTIPATH flag to descriptor test vectors_: tags existing vectors\n- _refactor: use util::Expected for descriptor parsers_: replaces `&error` and introduces `ParsePubkeyResult` and other aliases that are expanded later (with less churn).\n- Four small helper extractions, kept as separate commits to make each change easier to review:\n  - _refactor: extract LastHardenedIndex helper_\n  - _refactor: extract OriginKeyString helper_\n  - _refactor: extract MergeNormalizedOrigin helper_\n  - _refactor: extract HardenedPrefix helper_\n- _refactor: setup wallet descriptors per output type pair_ (dropping the loop over `internal`)\n\nBased on:\n- https://github.com/bitcoin/bitcoin/pull/36230",
 "commits": [
  {
   "sha": "435f4ea598725725cc0ecb43345a182dc10517aa",
   "date": "2026-09-15T09:34:57Z",
   "message": "wallet: Compare descriptors by hash of canonical string\n\nThe canonical string comparison was slow because it would compute the\ncanonical string for each comparison. This can be sped up by holding the\ncanonical string in memory, computed upon construction of\nWalletDescriptor. To reduce memory usage, this string is further hashed\nso that the comparison operates over the hash of the canonical string."
  },
  {
   "sha": "8d4407ba3fc411214ef18b592a7252c5e71d9e6f",
   "date": "2026-09-15T09:34:57Z",
   "message": "miniscript: let FromString take a string_view\n\nThis lets the next commit record key positions in the descriptor string\nwhile its miniscript expression is parsed in place."
  },
  {
   "sha": "67669c54870cfb8909d1f852fdc53f16b0411732",
   "date": "2026-09-15T09:34:57Z",
   "message": "test: add MULTIPATH flag to descriptor test vectors\n\nUse it to check whether parsing expands a descriptor into multiple descriptor."
  },
  {
   "sha": "417d6f350cf6ae51708eb3299b62ab53eb6d11b7",
   "date": "2026-09-15T09:34:58Z",
   "message": "wallet: expand multipath descriptors with 'h' hardened marker\n\nThe descriptors that a multipath descriptor expands to are new, derived\ndescriptors rather than user input, so give them canonical form.\n\nWhen a multipath descriptor contains an apostrophe, replace them all\nwith 'h' and parse again."
  },
  {
   "sha": "6b684d593bdb41054c69f713c45604fd13422397",
   "date": "2026-09-15T09:34:58Z",
   "message": "refactor: use util::Expected for descriptor parsers\n\nUse util::Expected for ParsePubkeyInner, ParsePubkey, and ParseScript, so errors are returned directly instead of passed through an output argument.\n\nThe only behavior change is that a preexisting error string is cleared when nested descriptor parsing fails without producing an error message. Previously, the stale error was left intact."
  },
  {
   "sha": "8712918dbcdb432ae789522e7cede7b8ed639aa7",
   "date": "2026-09-15T09:34:58Z",
   "message": "refactor: introduce descriptor ParseState\n\nGroup the key expression index and signing provider shared by ParsePubkeyInner, ParsePubkey, and ParseScript in ParseState.\n\nA later commit extends this state with key replacement collection."
  },
  {
   "sha": "d2bba09b3fc2b92f49452ee0b1ee0242f6bfbbd7",
   "date": "2026-09-15T09:34:58Z",
   "message": "descriptor: let Parse return the multipath descriptor string\n\nAdd an optional Parse() output for the unexpanded multipath descriptor. This is always a public descriptor, so ParseState optionally collects KeyReplacement entries tracking key locations in the input for private-to-public conversion.\n\nThe replacement sink is null unless the caller requests this output."
  },
  {
   "sha": "120db58fcf33e21b4991c321ccd7b1cdc71f13dc",
   "date": "2026-09-15T09:34:58Z",
   "message": "refactor: extract LastHardenedIndex helper\n\nSplit finding the last hardened derivation step out of\nBIP32PubkeyProvider::ToNormalizedString. No behavior change. A later\ncommit reuses it when normalizing the keys of a multipath descriptor\nstring."
  },
  {
   "sha": "8d5686535cb094a2d13a73f2525539495ac33503",
   "date": "2026-09-15T09:34:58Z",
   "message": "refactor: extract OriginKeyString helper\n\nSplit formatting the normalized public form of a derived key, i.e. its\norigin followed by the extended public key at the last hardened step,\nout of BIP32PubkeyProvider::ToNormalizedString. No behavior change. A\nlater commit reuses it when normalizing the keys of a multipath\ndescriptor string."
  },
  {
   "sha": "c98c74df18ea4eef39dccb9ee6201f337dd16f7f",
   "date": "2026-09-15T09:34:58Z",
   "message": "refactor: extract MergeNormalizedOrigin helper\n\nSplit merging an outer key origin into an already normalized key\nexpression out of OriginPubkeyProvider::ToNormalizedString. No behavior\nchange. A later commit reuses it when normalizing the keys of a\nmultipath descriptor string."
  },
  {
   "sha": "163fa349e83f4f17ee6117cd62ba6d23a91f2cc9",
   "date": "2026-09-15T09:34:58Z",
   "message": "refactor: extract HardenedPrefix helper"
  },
  {
   "sha": "68cf669f90f4185f40a020b5e3e31a4d057d64cd",
   "date": "2026-09-15T09:34:58Z",
   "message": "descriptor: normalize keys in the multipath descriptor string\n\nPrivate extended keys followed by a fixed hardened prefix are now replaced by their key origin and the extended public key at the last hardened step, merging any explicit origin. This matches Descriptor::ToNormalizedString and produces BIP 388 wallet policy key expressions suitable for registering the multipath descriptor on a signing device.\n\nA hardened multipath element or wildcard cannot be represented by a single publicly derivable multipath descriptor. Leave the Parse multipath output unset in those cases.\n\nParsePubkeyInner now returns an optional replacement alongside parsed providers so ParsePubkey can merge explicit key origin information before collecting it."
  },
  {
   "sha": "1423d9e126a4678b26f36fe17bb30abb5bbf6a60",
   "date": "2026-09-15T09:34:59Z",
   "message": "wallet: store multipath descriptor record on import\n\nWhen importdescriptors expands a BIP 389 multipath descriptor into\nseparate receive and change descriptors, additionally store a record\ncontaining the multipath descriptor string, as reported by the parser\nin public form, along with the IDs of the wallet descriptors it\nexpanded to.\n\nReject overlap with a different multipath record before importing any of\nthe expanded descriptors.\n\nOlder wallet software ignores the new record."
  },
  {
   "sha": "df3420fc6768dfe44228e8ab18983f7cf2d5a66f",
   "date": "2026-09-15T09:34:59Z",
   "message": "rpc: return multipath descriptor from getaddressinfo\n\nAdd an optional multipath field to getaddressinfo, containing the\nmultipath descriptor that this address' parent descriptor was expanded\nfrom, in public form."
  },
  {
   "sha": "55d153388182edc521ce83387b8c3725f00a86b6",
   "date": "2026-09-15T09:34:59Z",
   "message": "rpc: return multipath descriptor from listdescriptors\n\nAdd an optional multipath field to each listdescriptors entry that was\nexpanded from a multipath descriptor, containing the stored multipath\ndescriptor record. Every descriptor from the same multipath descriptor\nreturns the same value, always in public form."
  },
  {
   "sha": "e2306d6c38ef3b3ca87c44c30b0e07c2b3d007cc",
   "date": "2026-09-15T09:34:59Z",
   "message": "wallet: copy multipath record in exportwatchonlywallet\n\nThe descriptor IDs in the record are translated to those of the\nwatchonly wallet."
  },
  {
   "sha": "3ef8688f2acb904c2f847fc3f866ccb50490ab04",
   "date": "2026-09-15T09:34:59Z",
   "message": "refactor: setup wallet descriptors per output type pair\n\nAdd SetupDescriptorScriptPubKeyManPair, used by default wallet creation\nand createwalletdescriptor, which derives the receive and change\ndescriptors of an output type from a single multipath descriptor\nstring, built by a new GenerateMultipathDescriptorString.\n\nThis commit does not change behavior."
  },
  {
   "sha": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "date": "2026-09-15T09:34:59Z",
   "message": "wallet: store multipath descriptor record on wallet creation\n\nWhen SetupDescriptorScriptPubKeyManPair creates a descriptor pair, store\na record with the normalized multipath descriptor they derive from.\n\nWhen createwalletdescriptor is used with the internal option, the record\nis stored by whichever call completes the pair."
  }
 ],
 "timeline": [
  {
   "t": "2026-09-01T14:53:15Z",
   "kind": "review",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "0948b924354d2e3c79810833087c03a8f8b7e269",
   "text": "Concept ACK\n\nThe matching approaches in #36075 work off descriptors a wallet already has, and storing a record instead means wallets created before this get none on their own.\n\nThose get no record, and `createwalletdescriptor` on a pair that exists just answers `Descriptor already exists`. You can still rebuild one by hand, dumping the private descriptor, turning `/0/*` into `/<0;1>/*` and re-importing, which stores exactly what the new code would have. That needs the xprv though, since the public form is rejected on a wallet with private keys. Is that the intended path for existing wallets?"
  },
  {
   "t": "2026-09-01T14:53:15Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/wallet/walletdb.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": null,
   "text": "The two checks above reject a record whose id doesn't match its own string, and one that names a descriptor the wallet doesn't have. The string itself is never checked against those descriptors, so a record saying `wpkh(record/<0;1>/*)` next to an unrelated descriptor loads fine and both RPCs serve it. Is this deliberate?"
  },
  {
   "t": "2026-09-01T14:53:15Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/wallet/wallet.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": null,
   "text": "`<0;1>` and `<0;2>` are different policies that share their `/0/*` expansion, so one descriptor can sit in two records and this hands back whichever the map reaches first. Would it be worth returning all of them, or refusing the ambiguity?"
  },
  {
   "t": "2026-09-01T14:53:15Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/wallet/wallet.cpp",
   "commit": "0948b924354d2e3c79810833087c03a8f8b7e269",
   "in_reply_to": null,
   "text": "The other two callers check this return, one warns and the other errors out. Dropped here, a failed write leaves the wallet created and reporting success with no record, and the transaction wrapping wallet setup never learns of it. Is this intended?"
  },
  {
   "t": "2026-09-01T14:53:15Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/script/descriptor.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": null,
   "text": "The origin comes through as typed here when the key is an xpub, and normalized when it's the xprv, so `desc` and `multipath` can end up disagreeing on `84h` versus `84'` for the same key. Is this intended?"
  },
  {
   "t": "2026-09-01T16:00:45Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\nCorrect, I'll clarify in the description that this doesn't \"upgrade\" existing wallets. I think that's fine, because any multisig wallet using BIP388 registration is going to be new - since we don't support it yet.\n\n[quoted text omitted]\nSee #35377."
  },
  {
   "t": "2026-09-01T16:05:59Z",
   "kind": "review_comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": 3905364971,
   "text": "It doesn't seem realistic for someone to import a `<0;1>` and `<0;2>` descriptor, so I'm not too worried about supporting that. Is there a more realistic scenario?"
  },
  {
   "t": "2026-09-01T16:13:35Z",
   "kind": "review_comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "path": "src/wallet/walletdb.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": 3905364955,
   "text": "IIUC you're asking if we should `Parse()` the multipath string at load time and verify that it expands as expected. That's more heavy-lifting than we typically do at wallet load.\n\nIt could make sense to introduce a `bitcoin-wallet` command to perform a more thorough integrity check.."
  },
  {
   "t": "2026-09-01T18:27:42Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/wallet/wallet.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": 3905364971,
   "text": "Fair enough, I don't have a more realistic one. The idea was to avoid an arbitrary pick, but I take the point that it's over-protective for a case nobody hits."
  },
  {
   "t": "2026-09-01T18:31:05Z",
   "kind": "force_push",
   "who": "Sjors",
   "commit": "912bf553f58d498c3bc765ed83638cc5bda7c069"
  },
  {
   "t": "2026-09-01T18:31:37Z",
   "kind": "review_comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": 3905364971,
   "text": "This is now prevented. But what you _can_ do is import a multipath descriptor into a pre-existing wallet that lacked it; this then adds the record. Not a documented feature, but it's tested."
  },
  {
   "t": "2026-09-01T18:31:45Z",
   "kind": "review_comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "path": "src/wallet/wallet.cpp",
   "commit": "0948b924354d2e3c79810833087c03a8f8b7e269",
   "in_reply_to": 3905364980,
   "text": "Added the missing handling and a `[[nodiscard]]`."
  },
  {
   "t": "2026-09-01T18:32:06Z",
   "kind": "review_comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "path": "src/script/descriptor.cpp",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
   "in_reply_to": 3905364989,
   "text": "Good catch, fixed multipath normalization to always uses `h`.\n\nAdditionally, 620bd8ffa57dbda36d0e3a21d0eba952e2406e09 now uses `h` when expanding multipath, so the expanded descriptors are consistent with the parent."
  },
  {
   "t": "2026-09-02T02:36:30Z",
   "kind": "comment",
   "who": "achow101",
   "assoc": "MEMBER",
   "text": "Concept NACKish\n\nMy suggestion was not to store an extra record, but rather an extra field in `WalletDescriptor` which contains the ids of the other descriptors in the multipath. Reconstructing the multipath can be done by doing a pairwise traversal in `Descriptor::ToString()`, i.e. traverse all of the descriptors at the same time, entering the sub descriptors and `PubkeyProviders` located at the same indexes. The only difference should be a single derivation path element, so it should not be that hard to figure out where the multipath is. I'm pretty sure it would be less code too to do that. Currently on vacation, but I can look at implementing this method once I'm back.\n\nAdditionally, multipath descriptors do not inherently have a concept of receive and change descriptors. They are not limited to 2 descriptors once expanded. There should be no mention of \"receive\" or \"change\" when discussing multipath descriptors in the code."
  },
  {
   "t": "2026-09-02T11:23:46Z",
   "kind": "force_push",
   "who": "Sjors",
   "commit": "2bd57be0d6a1dbe55d09011ece7b5287e40b7346"
  },
  {
   "t": "2026-09-02T11:23:48Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "IIUC you're objecting to two separate things:\n\n1. Storing the multipath record. You prefer to reconstruct it on the fly.\n2. The method of deriving the (normalized) multipath descriptor. You prefer to reconstruct it from individual descriptors, rather than have `Parse` yield it based on the original.\n\nGiven that we know the actual intended multipath descriptor at import (and wallet creation) time, it seems better to store it then. But doing so is indeed more involved, and storing the multipath becomes pointless if we do (1).\n\n[quoted text omitted]\nThe meat of the `Parse` change is not big, i.e. `descriptor: let Parse return the multipath descriptor string` and `descriptor: normalize keys in the multipath descriptor string`. Most of the churn in this PR comes from prep refactors.\n\nThe easiest implementation is just a regex. But having a mix of parser strategies in the already hard to understand Descriptor class, does not sound appealing.\n\nMy approach tries to stick to the existing recursive parser design.\n\n[quoted text omitted]\nSo we give a multipath descriptor to Parse, it gets split apart and then we reconstruct it? It feels wrong to throw away information this way, and essentially treat `Parse` as a black box. It's a sign we need to reconsider the entire `Descriptor` class.\n\nI tried the approach in [2026/09/multipath-reconstruct](https://github.com/Sjors/bitcoin/commits/2026/09/multipath-reconstruct/), commit `descriptor: reconstruct a multipath descriptor from its expansion` 5694a99992fbe314779b1ce2c127dbcb50644025 (unpolished), with one additional prep refactor `descriptor: let ToNormalizedString reject forms that are not publicly derivable` fcd48f8a31e34bf6cbb653aeae98ce19d9573f7f. For that branch, I dropped the refactor commits it doesn't need, but I did keep the approach of storing a wallet record.\n\n---\n\n[quoted text omitted]\nIndeed, the Descriptor class should be agnostic, so I adjusted `MultipathExtKeyNormalizationPrefix` to handle more than two paths.\n\nAlso fixed the CI fuzzer failure."
  },
  {
   "t": "2026-09-02T13:53:47Z",
   "kind": "force_push",
   "who": "Sjors",
   "commit": "1cdbfecb877440b06527cb78f80d7deac85f4d94"
  },
  {
   "t": "2026-09-07T22:07:52Z",
   "kind": "comment",
   "who": "achow101",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\nNo one is suggesting a separate implementation, especially not one using regex.\n\n[quoted text omitted]\nYes\n\n[quoted text omitted]\nWhat information is being thrown away? `Parse` returns multiple descriptors when it's a multipath descriptor. We don't lose any information.\n\n[quoted text omitted]\nHow so?"
  },
  {
   "t": "2026-09-15T07:35:37Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\n[quoted text omitted]\nThat throws the `Parse` design out the window and sticks a second parser into the Descriptor class (even if we hide this second pass inside `Parse`).\n\n[quoted text omitted]\nThen I don't understand what you have in mind.\n\n[quoted text omitted]\nInside `Parse` we already have the `<0;1>` notation. That's lost in the multiple descriptor strings, and then reconstructed. It's true that the original can still be reconstructed, at least I can't think of an obvious edge case where it can't, but that wasn't my point.\n\nImo `Parse` should be able to take a multipath descriptor with private keys and produce a (normalized) public key descriptor, without the intermediate state of two individual descriptors. It has all the information it needs.\n\n[quoted text omitted]\nBecause we keep making it more complicated, and then duct-taping fixes. E.g. we grew to four different `ToString()` formats, in part to deal with bugs, and then https://github.com/bitcoin/bitcoin/pull/35445#discussion_r3925716625 mixes their implementations to fix another downgrade bug.\n\nAlso, while working on this PR, I found it quite challenging to wrap my head\naround all the moving parts. Especially the miniscript stuff, which was also added in a later stage, is really hard to understand.\n\nThat said, I don't have a concrete redesign in mind that's easier to understand and maintain."
  },
  {
   "t": "2026-09-15T09:19:29Z",
   "kind": "force_push",
   "who": "Sjors",
   "commit": "521c2f68389856939a8784b24048fc3f19b23d3e"
  },
  {
   "t": "2026-09-15T09:21:38Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "Rebased after #35445, cherry-picked _wallet: Compare descriptors by hash of canonical string_ from #36230 and adjusted things accordingly.\n\nAdded emphasis to the PR description that this needs conceptual review / discussion before diving too deep into code review, given @achow101's objection so far.\n\nAlso marked draft since we use part of https://github.com/bitcoin/bitcoin/pull/36230."
  },
  {
   "t": "2026-09-15T09:58:21Z",
   "kind": "force_push",
   "who": "Sjors",
   "commit": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff"
  },
  {
   "t": "2026-09-15T18:21:53Z",
   "kind": "comment",
   "who": "achow101",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\n`Parse` should never return strings. it's job is to **parse** strings, not produce them. I think that having it produce a multipath string so that it can be stored is fundamentally the wrong way to go about this.\n\nEven if parse could produce multipath descriptor strings, that doesn't help. We need to go from individual separated descriptor **objects** (not strings) to a multipath string. The wallet **must not** use multipath strings internally, it **must not** make any assumptions about the purposes of derivation paths.\n\n[quoted text omitted]\nWhat I have in mind is that given multiple descriptor **objects** (not strings), you can walk them in simultaneously (a la `ToStringHelper`) to reconstruct a multipath descriptor."
  }
 ],
 "labels_log": [
  {
   "t": "2026-08-31T18:12:11Z",
   "action": "labeled",
   "label": "Wallet",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-01T19:37:44Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-02T15:04:52Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-09T11:31:11Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-15T09:59:09Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-15T11:00:55Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-15T11:26:26Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-17T02:28:45Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [
  {
   "t": "2026-09-15T09:22:14Z",
   "kind": "convert_to_draft",
   "who": "Sjors"
  }
 ],
 "text_chars": 17497,
 "text_tokens_estimate": 4374,
 "changed_paths": [
  "src/script/descriptor.cpp",
  "src/script/descriptor.h",
  "src/script/miniscript.h",
  "src/test/descriptor_tests.cpp",
  "src/wallet/export.cpp",
  "src/wallet/export.h",
  "src/wallet/rpc/addresses.cpp",
  "src/wallet/rpc/backup.cpp",
  "src/wallet/rpc/wallet.cpp",
  "src/wallet/scriptpubkeyman.cpp",
  "src/wallet/scriptpubkeyman.h",
  "src/wallet/test/walletload_tests.cpp",
  "src/wallet/wallet.cpp",
  "src/wallet/wallet.h",
  "src/wallet/walletdb.cpp",
  "src/wallet/walletdb.h",
  "src/wallet/walletutil.cpp",
  "src/wallet/walletutil.h",
  "test/functional/wallet_backwards_compatibility.py",
  "test/functional/wallet_createwalletdescriptor.py",
  "test/functional/wallet_descriptor.py",
  "test/functional/wallet_exported_watchonly.py",
  "test/functional/wallet_importdescriptors.py",
  "test/functional/wallet_listdescriptors.py"
 ],
 "files": [
  {
   "path": "src/script/descriptor.cpp",
   "add": 372,
   "del": 242
  },
  {
   "path": "src/script/descriptor.h",
   "add": 16,
   "del": 6
  },
  {
   "path": "src/script/miniscript.h",
   "add": 2,
   "del": 2
  },
  {
   "path": "src/test/descriptor_tests.cpp",
   "add": 154,
   "del": 38
  },
  {
   "path": "src/wallet/export.cpp",
   "add": 23,
   "del": 0
  },
  {
   "path": "src/wallet/export.h",
   "add": 5,
   "del": 2
  },
  {
   "path": "src/wallet/rpc/addresses.cpp",
   "add": 4,
   "del": 0
  },
  {
   "path": "src/wallet/rpc/backup.cpp",
   "add": 44,
   "del": 7
  },
  {
   "path": "src/wallet/rpc/wallet.cpp",
   "add": 8,
   "del": 22
  },
  {
   "path": "src/wallet/scriptpubkeyman.cpp",
   "add": 3,
   "del": 5
  },
  {
   "path": "src/wallet/scriptpubkeyman.h",
   "add": 1,
   "del": 1
  },
  {
   "path": "src/wallet/test/walletload_tests.cpp",
   "add": 61,
   "del": 0
  },
  {
   "path": "src/wallet/wallet.cpp",
   "add": 84,
   "del": 11
  },
  {
   "path": "src/wallet/wallet.h",
   "add": 27,
   "del": 2
  },
  {
   "path": "src/wallet/walletdb.cpp",
   "add": 36,
   "del": 1
  },
  {
   "path": "src/wallet/walletdb.h",
   "add": 2,
   "del": 0
  },
  {
   "path": "src/wallet/walletutil.cpp",
   "add": 20,
   "del": 19
  },
  {
   "path": "src/wallet/walletutil.h",
   "add": 51,
   "del": 2
  },
  {
   "path": "test/functional/wallet_backwards_compatibility.py",
   "add": 17,
   "del": 0
  },
  {
   "path": "test/functional/wallet_createwalletdescriptor.py",
   "add": 23,
   "del": 0
  },
  {
   "path": "test/functional/wallet_descriptor.py",
   "add": 35,
   "del": 0
  },
  {
   "path": "test/functional/wallet_exported_watchonly.py",
   "add": 8,
   "del": 1
  },
  {
   "path": "test/functional/wallet_importdescriptors.py",
   "add": 64,
   "del": 1
  },
  {
   "path": "test/functional/wallet_listdescriptors.py",
   "add": 11,
   "del": 0
  }
 ],
 "test_lines": 413,
 "git": {
  "head": "f26ecc2e31f598070a7c2e163a4e3dda073e1cff",
  "head_matches_backup": true,
  "base": "51ddab532cb38213e2258c24c492bc8a392ffc90",
  "commits": [
   {
    "sha": "435f4ea598",
    "subject": "wallet: Compare descriptors by hash of canonical string",
    "files": 3,
    "add": 27,
    "del": 4
   },
   {
    "sha": "8d4407ba3f",
    "subject": "miniscript: let FromString take a string_view",
    "files": 2,
    "add": 3,
    "del": 3
   },
   {
    "sha": "67669c5487",
    "subject": "test: add MULTIPATH flag to descriptor test vectors",
    "files": 1,
    "add": 20,
    "del": 15
   },
   {
    "sha": "417d6f350c",
    "subject": "wallet: expand multipath descriptors with 'h' hardened marker",
    "files": 2,
    "add": 42,
    "del": 19
   },
   {
    "sha": "6b684d593b",
    "subject": "refactor: use util::Expected for descriptor parsers",
    "files": 2,
    "add": 149,
    "del": 193
   },
   {
    "sha": "8712918dbc",
    "subject": "refactor: introduce descriptor ParseState",
    "files": 1,
    "add": 43,
    "del": 34
   },
   {
    "sha": "d2bba09b3f",
    "subject": "descriptor: let Parse return the multipath descriptor string",
    "files": 3,
    "add": 105,
    "del": 22
   },
   {
    "sha": "120db58fcf",
    "subject": "refactor: extract LastHardenedIndex helper",
    "files": 1,
    "add": 15,
    "del": 19
   },
   {
    "sha": "8d5686535c",
    "subject": "refactor: extract OriginKeyString helper",
    "files": 1,
    "add": 8,
    "del": 2
   },
   {
    "sha": "c98c74df18",
    "subject": "refactor: extract MergeNormalizedOrigin helper",
    "files": 1,
    "add": 13,
    "del": 9
   },
   {
    "sha": "163fa349e8",
    "subject": "refactor: extract HardenedPrefix helper",
    "files": 1,
    "add": 17,
    "del": 4
   },
   {
    "sha": "68cf669f90",
    "subject": "descriptor: normalize keys in the multipath descriptor string",
    "files": 3,
    "add": 194,
    "del": 33
   },
   {
    "sha": "1423d9e126",
    "subject": "wallet: store multipath descriptor record on import",
    "files": 8,
    "add": 260,
    "del": 8
   },
   {
    "sha": "df3420fc67",
    "subject": "rpc: return multipath descriptor from getaddressinfo",
    "files": 4,
    "add": 40,
    "del": 0
   },
   {
    "sha": "55d1533881",
    "subject": "rpc: return multipath descriptor from listdescriptors",
    "files": 5,
    "add": 42,
    "del": 2
   },
   {
    "sha": "e2306d6c38",
    "subject": "wallet: copy multipath record in exportwatchonlywallet",
    "files": 3,
    "add": 29,
    "del": 3
   },
   {
    "sha": "3ef8688f2a",
    "subject": "refactor: setup wallet descriptors per output type pair",
    "files": 7,
    "add": 58,
    "del": 58
   },
   {
    "sha": "f26ecc2e31",
    "subject": "wallet: store multipath descriptor record on wallet creation",
    "files": 6,
    "add": 78,
    "del": 6
   }
  ],
  "patch_truncated": true
 },
 "input_hash": "89115e0776c21dc4",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}