{
 "number": 35989,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/35989",
 "title": "wallet: fix crash on importdescriptors with a range ending at 2^31-1",
 "author": "shuv-amp",
 "author_association": "CONTRIBUTOR",
 "created_at": "2026-08-16T20:15:09Z",
 "updated_at": "2026-09-17T06:10:59Z",
 "age_days": 31,
 "draft": false,
 "labels": [
  "Wallet",
  "Needs rebase"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
 "head_ref": "wallet-descriptor-range-overflow",
 "head_repo": "shuv-amp/bitcoin",
 "head_history": [
  {
   "t": "2026-08-25T19:25:12Z",
   "sha": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca"
  }
 ],
 "additions": 65,
 "deletions": 2,
 "changed_files": 4,
 "commit_count": 3,
 "size_bucket": "S",
 "mergeable_state": "dirty",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "ack": [
     {
      "login": "molnard",
      "url": "https://github.com/bitcoin/bitcoin/pull/35989#pullrequestreview-5092869558"
     },
     {
      "login": "kriss39",
      "url": "https://github.com/bitcoin/bitcoin/pull/35989#issuecomment-5663222534"
     }
    ],
    "concept_ack": [
     {
      "login": "jeanpablojp",
      "url": "https://github.com/bitcoin/bitcoin/pull/35989#pullrequestreview-5002207884"
     },
     {
      "login": "vicjuma",
      "url": "https://github.com/bitcoin/bitcoin/pull/35989#pullrequestreview-5197217777"
     }
    ]
   },
   "conflicts": [
    {
     "number": 36245,
     "title": "wallet: start descriptor top up at range_start instead of index 0",
     "author": "kriss39"
    },
    {
     "number": 36236,
     "title": "wallet, rpc: add verify_balance option to importdescriptors",
     "author": "musaHaruna"
    },
    {
     "number": 35444,
     "title": "wallet: make descriptor SPKM mutex non-recursive",
     "author": "w0xlt"
    },
    {
     "number": 35377,
     "title": "wallet: Allow importing of descriptors without private keys when the wallet has the private keys",
     "author": "achow101"
    },
    {
     "number": 34861,
     "title": "wallet: Add importdescriptors interface",
     "author": "polespinasa"
    }
   ]
  }
 },
 "acks_parsed": {
  "jeanpablojp": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-08-23T10:47:41Z",
   "stale": false
  },
  "molnard": {
   "kind": "ack",
   "hash": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
   "t": "2026-09-02T18:03:00Z",
   "stale": false
  },
  "vicjuma": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-09-14T11:45:58Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 1,
  "stale_ack": 0,
  "concept_ack": 2,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "jeanpablojp",
   "kriss39",
   "molnard",
   "polespinasa",
   "vicjuma"
  ]
 },
 "signals": {
  "needs_rebase": true,
  "ci_failed": false,
  "mergeable_state": "dirty",
  "last_author_activity": "2026-09-05T06:47:20Z",
  "last_reviewer_activity": "2026-09-17T06:10:52Z",
  "last_reviewer": "polespinasa",
  "author_silent_days": 12,
  "waiting_on_author_days": 0,
  "days_since_update": 0
 },
 "refs": {
  "mentioned": [
   35872,
   36245
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 35872,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-08-06",
    "title": "rpc: avoid descriptor range counter overflow"
   },
   {
    "number": 36245,
    "type": "pull",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "wallet: start descriptor top up at range_start instead of index 0"
   }
  ],
  "conflicts": [
   36245,
   36236,
   35444,
   35377,
   34861
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/wallet/scriptpubkeyman.cpp"
 ],
 "body": "`WalletDescriptor` stores the descriptor range in `int32_t` fields, with `range_end`\nexclusive. `ProcessDescriptorImport` computes that end in `int64_t` and passes it to the\nconstructor with no bound in between. `ParseDescriptorRange` accepts an inclusive\nendpoint of `2^31 - 1`, so the exclusive end can be `2^31`. That does not fit in\n`int32_t` and truncates to `INT32_MIN`, which leaves the descriptor with an inverted\nrange. The node then aborts while filling the keypool:\n\n```\n$ bitcoin-cli -named createwallet wallet_name=w disable_private_keys=true\n$ bitcoin-cli -rpcwallet=w importdescriptors '[{\"desc\":\"wpkh([728986fc/84h/1h/0h]tpubDDLEJ5Q3Tu8hn4BBEn5bnd2cYecWFwPpse1o9rx8uds47cP2bfr62sZM9LaYuNYPGnWmSKmiPeXinV2YxjHC4kFf3UUShMziLazstZbGRXU/0/*)#0gv2ddmk\",\"range\":[2147483647,2147483647],\"timestamp\":\"now\"}]'\nerror: Error while attempting to communicate with server 127.0.0.1:8332 (EOF)\n```\n\n```\nAssertion failed: (m_wallet_descriptor.range_end - 1 == m_max_cached_index),\nfunction TopUpWithDB, file scriptpubkeyman.cpp, line 1109.\n```\n\nThe same crash happens with no `range` argument at all when `-keypool` is set above\n`INT32_MAX`, because that branch takes the end from `m_keypool_size`, which is only\nbounded from below (`wallet.cpp:3092`). Guarding the `range` argument alone is not\nenough.\n\nBy the time the assert fires the descriptor has already been written to disk.\n`CreateFromImport` calls `TopUpWithDB` directly and skips the transaction wrapper that\n`TopUp` has, so the record survives the abort. The wallet still loads afterwards, but\n`listdescriptors` reports the inverted range:\n\n```json\n\"range\": [2147483647, -2147482650]\n```\n\nThe first commit bounds the end after both branches, so every field that reaches the\n`WalletDescriptor` constructor is representable. I did not put the check in\n`ParseDescriptorRange`. #35872 made an endpoint of `2^31 - 1` valid for the scanning\nRPCs and added a test asserting it succeeds, so tightening the shared parser would\nregress that. This limit comes from how the wallet stores the range, not from descriptor\nranges in general.",
 "commits": [
  {
   "sha": "e65a1516784d9b82e66bb1cb4f15bcf35b19148a",
   "date": "2026-08-25T19:22:31Z",
   "message": "wallet: reject unrepresentable descriptor import ranges\n\n`WalletDescriptor` stores the range in `int32_t` with an exclusive end,\nbut `ProcessDescriptorImport` computes that end in `int64_t` and passes\nit straight to the constructor. An inclusive endpoint of `INT32_MAX`,\nwhich `ParseDescriptorRange` accepts, truncates to `INT32_MIN` and\nleaves an inverted range that aborts the node in `TopUpWithDB`. The same\nhappens with no range given when `-keypool` is larger than `INT32_MAX`.\n\nBound the end after both branches. Every field that reaches the\n`WalletDescriptor` constructor is then representable.\n`ParseDescriptorRange` is left alone since an endpoint of `INT32_MAX` is\nvalid for the scanning RPCs."
  },
  {
   "sha": "5a8d4749ef956168e1bf5c8550bf3ed08b897f26",
   "date": "2026-08-25T19:22:31Z",
   "message": "wallet: fix integer overflow in descriptor keypool top up\n\n`TopUpWithDB` adds `target_size` to `next_index` in `int32_t`.\n`next_index` is user controlled for imported descriptors and\n`target_size` comes from `-keypool` or the `keypoolrefill` argument, so\nthe two can add up to more than `INT32_MAX`:\n\n    wallet/scriptpubkeyman.cpp: runtime error: signed integer overflow:\n    1 + 2147483647 cannot be represented in type 'int32_t' (aka 'int')\n\nCompute the end in `int64_t` and leave the range alone when it does not\nfit in the `int32_t` the range end is stored in. The wrapped sum was\nnegative, so `std::max()` discarded it anyway and the resulting range is\nunchanged. Only the undefined behavior goes away."
  },
  {
   "sha": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
   "date": "2026-08-25T19:22:32Z",
   "message": "test: add coverage for descriptor range end bounds\n\nEach case fails without the previous commits.\n\nwallet_importdescriptors.py covers the two import paths that abort the\nnode: a range ending at `INT32_MAX`, and an import with no range under a\n`-keypool` larger than `INT32_MAX`. The first uses an xpub because the\nwatch-only wallet rejects the xpriv the surrounding cases use, before\nthe descriptor is added at all.\n\nwallet_keypool.py covers the overflow that `keypoolrefill` reaches\nwithout any import. It needs every active descriptor to have advanced\npast index 0 first, since at index 0 the sum is exactly `INT32_MAX`.\nThat case is only visible under UBSan, where the node aborts on the\naddition."
  }
 ],
 "timeline": [
  {
   "t": "2026-08-23T10:47:41Z",
   "kind": "review",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "16b31cae72d91a6c5433edf41737b45a3b84418a",
   "text": "Concept ACK\n\nI reproduced the crash both ways on master."
  },
  {
   "t": "2026-08-23T10:47:41Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/wallet/scriptpubkeyman.cpp",
   "commit": "16b31cae72d91a6c5433edf41737b45a3b84418a",
   "in_reply_to": null,
   "text": "Only the overflow case changes behaviour here, and I couldn't find a test for it. A `keypoolrefill 2147483647` reaches this addition without going through an import, as long as every active descriptor has already advanced one index. On master, UBSan flags it. Worth a case so it doesn't come back?"
  },
  {
   "t": "2026-08-25T19:25:12Z",
   "kind": "force_push",
   "who": "shuv-amp",
   "commit": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca"
  },
  {
   "t": "2026-08-25T19:48:25Z",
   "kind": "review_comment",
   "who": "shuv-amp",
   "assoc": "CONTRIBUTOR",
   "path": "src/wallet/scriptpubkeyman.cpp",
   "commit": "16b31cae72d91a6c5433edf41737b45a3b84418a",
   "in_reply_to": 3838310543,
   "text": "Added. It only fails under UBSan though: the wrapped sum is negative, so `std::max()`\ndiscarded it anyway and `range_end` comes out the same either way."
  },
  {
   "t": "2026-09-02T17:18:51Z",
   "kind": "review_comment",
   "who": "molnard",
   "assoc": "NONE",
   "path": "src/wallet/scriptpubkeyman.cpp",
   "commit": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
   "in_reply_to": null,
   "text": "nit , readability choice:\n\n```suggestion\n        new_range_end = std::max(static_cast<int32_t>(target_end), new_range_end);\n```"
  },
  {
   "t": "2026-09-02T18:03:00Z",
   "kind": "review",
   "who": "molnard",
   "assoc": "NONE",
   "state": "COMMENTED",
   "commit": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
   "text": "ACK def4fb0101b73f825db7f20ff6d6dd7170b5d5ca\n\nI reviewed the code. The changes are straightforward and focused on fixing the reported issues.\n\n### Testing\n\nThe following results are from local test runs.\n\nCompared master `b811aeabad94ef48cd0f0fb1d2fcc456594aeedb` with the PR tip:\n\n- Importing `[2147483647,2147483647]` terminated master. The PR returned error `-8` and remained responsive.\n- Importing without a range under `-keypool=3000000000` terminated master. The PR returned error `-8` and remained responsive.\n- Calling `keypoolrefill 2147483647` after advancing every active descriptor to index 1 terminated master. The PR returned error `-4` and remained responsive.\n\n### Non-blocking follow-ups\n\nPossbile follow-ups, worth considering separately from this PR:\n\n- Detecting/recovering invalid descriptor ranges already persisted by the old bug.\n- Centralizing validation of oversized `-keypool` values. For example: accepted as a wallet configuration option without an upper-bound check (wallet.cpp)\n- Clarifying whether `TopUpWithDB` should return failure when the requested endpoint is unrepresentable, instead of leaving the range unchanged and returning `true` (plus writes the unchanged descriptor). This is separate from the tested `keypoolrefill` RPC, which correctly returns an error."
  },
  {
   "t": "2026-09-05T06:17:34Z",
   "kind": "review_comment",
   "who": "shuv-amp",
   "assoc": "CONTRIBUTOR",
   "path": "src/wallet/scriptpubkeyman.cpp",
   "commit": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
   "in_reply_to": 3916763020,
   "text": "I'd keep `GetEnd()` here, it's explicit about the lower bound."
  },
  {
   "t": "2026-09-05T06:47:20Z",
   "kind": "comment",
   "who": "shuv-amp",
   "assoc": "CONTRIBUTOR",
   "text": "[quoted text omitted]\n\nFollow-up, agreed. On the reported import the descriptor write happens inside\n`AddWalletDescriptor` with the assert after it, and activation is back in\n`ProcessDescriptorImport` once that call returns, so the record persists unactivated even with\n`active=true`. On reload `Load()` runs zero iterations over the inverted range and\n`TopUpKeyPool` skips it for being inactive. The wallet reopens, but none of that descriptor's\nscripts are registered.\n\nI thought `UpdateWalletDescriptor` might put the assert back in play since it resets\n`m_max_cached_index`, but it also assigns the incoming descriptor over `m_wallet_descriptor`, so\nthe old range is gone before the top up. Haven't tested recovery on an affected wallet.\n\n[quoted text omitted]\nThere's narrowing before the addition too: `TopUpWithDB` assigns `m_keypool_size` to an\n`unsigned int`, so `-keypool=4294968296` gives a `target_size` of 1000. The member keeps the full\nvalue, so the no-range import path still rejects it with -8 here. A bound of `INT32_MAX` would be\nrepresentable and still ask for two billion derivations, so the limit has to be about work, not\njust the type.\n\n[quoted text omitted]\nI'd keep it as is here and look at the return value with its callers separately. `keypoolrefill`\ndiscards the bool, the -4 you saw is `GetKeyPoolSize() < kpSize`, which sums across active spkms\nrather than reporting the descriptor that failed. `UpdateWithSigningProvider` throws\n`std::runtime_error(\"Could not top up scriptPubKeys\")` on false and `MarkUnusedAddresses` logs\n\"Topping up keypool failed (locked wallet)\", so changing it means touching those too. The\nunchanged write is pre-existing, `keypoolrefill 1` on a full keypool does the same, though that\nonly says it isn't new, not that `true` is the right answer."
  },
  {
   "t": "2026-09-07T19:10:07Z",
   "kind": "comment",
   "who": "molnard",
   "assoc": "NONE",
   "text": "I agree with these points and think they can be handled in follow-up PRs. They don't block this fix, so this PR can be merged as is."
  },
  {
   "t": "2026-09-14T11:29:13Z",
   "kind": "comment",
   "who": "kriss39",
   "assoc": "CONTRIBUTOR",
   "text": "tACK def4fb0101\n\nBuilt and ran both tests. Also tried it by hand: importing `[2147483647, 2147483647]` into a watch-only wallet is rejected with \"End of range is too high\", the node stays up and the wallet is still empty after a restart. Without the first two commits the new importdescriptors case takes the node down as expected.\n\nOne note on the wallet_keypool.py case: it also passes on master in a non-sanitizer build. The wrapped sum ends up below GetEnd(), so std::max just keeps the old end and keypoolrefill fails with the same -4. So it only catches the overflow under UBSan. Still worth having, maybe with a comment in the test saying that.\n\nNot something this PR needs to solve, but `[2147482648, 2147482648]` now gets accepted instead of crashing, and the top up then derives every index from 0, so the RPC doesn't come back for a very long time. That's the existing O(range_start) cost in TopUpWithDB, #36245 addresses it."
  },
  {
   "t": "2026-09-14T11:45:58Z",
   "kind": "review",
   "who": "vicjuma",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
   "text": "Concept ACK\n\n**Before**\n\n**After**"
  },
  {
   "t": "2026-09-17T06:10:52Z",
   "kind": "comment",
   "who": "polespinasa",
   "assoc": "MEMBER",
   "text": "There is another (unraelated) overflow that this PR could fix, see https://github.com/bitcoin/bitcoin/pull/34861#discussion_r4021705766"
  }
 ],
 "labels_log": [
  {
   "t": "2026-08-16T20:15:13Z",
   "action": "labeled",
   "label": "Wallet",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-24T22:22:22Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-25T19:59:52Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-17T02:29:42Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 9317,
 "text_tokens_estimate": 2329,
 "changed_paths": [
  "src/wallet/rpc/backup.cpp",
  "src/wallet/scriptpubkeyman.cpp",
  "test/functional/wallet_importdescriptors.py",
  "test/functional/wallet_keypool.py"
 ],
 "files": [
  {
   "path": "src/wallet/rpc/backup.cpp",
   "add": 6,
   "del": 0
  },
  {
   "path": "src/wallet/scriptpubkeyman.cpp",
   "add": 9,
   "del": 2
  },
  {
   "path": "test/functional/wallet_importdescriptors.py",
   "add": 28,
   "del": 0
  },
  {
   "path": "test/functional/wallet_keypool.py",
   "add": 22,
   "del": 0
  }
 ],
 "test_lines": 50,
 "git": {
  "head": "def4fb0101b73f825db7f20ff6d6dd7170b5d5ca",
  "head_matches_backup": true,
  "base": "0f5c6d0b64a2ba221ced59a2d4a135c54884bd9c",
  "commits": [
   {
    "sha": "e65a151678",
    "subject": "wallet: reject unrepresentable descriptor import ranges",
    "files": 1,
    "add": 6,
    "del": 0
   },
   {
    "sha": "5a8d4749ef",
    "subject": "wallet: fix integer overflow in descriptor keypool top up",
    "files": 1,
    "add": 9,
    "del": 2
   },
   {
    "sha": "def4fb0101",
    "subject": "test: add coverage for descriptor range end bounds",
    "files": 2,
    "add": 50,
    "del": 0
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "6f7466994ca053ae",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}