Skip to content

fix(chain): sanitize and bound-check derivation indices near BIP32_MAX_INDEX - #2265

Open
busayo-OD wants to merge 3 commits into
bitcoindevkit:masterfrom
busayo-OD:clamp-derivation-index
Open

fix(chain): sanitize and bound-check derivation indices near BIP32_MAX_INDEX#2265
busayo-OD wants to merge 3 commits into
bitcoindevkit:masterfrom
busayo-OD:clamp-derivation-index

Conversation

@busayo-OD

@busayo-OD busayo-OD commented Aug 25, 2026

Copy link
Copy Markdown

Fixes bdk_wallet/issues/60

Description

KeychainTxOutIndex::apply_changeset accepted ChangeSet::last_revealed values with no upper bound, allowing an index greater than BIP32_MAX_INDEX (2^31 - 1) to be stored, violating an invariant relied on by next_index().

The changes in this PR address boundary issues around derivation indices:

  1. apply_changeset: clamps last_revealed to BIP32_MAX_INDEX before storing it, instead of storing the raw value.
  2. replenish_inner_index: fixes an off-by-one in the stop_index calculation that caused a panic ("we just inserted it") when a keychain's derivation index was revealed exactly up to BIP32_MAX_INDEX. The exclusive upper bound was clamped to BIP32_MAX_INDEX instead of BIP32_MAX_INDEX + 1, silently excluding the boundary value itself.
  3. lookahead_to_target: fixes an integer overflow panic when called with target_index == u32::MAX, and changes out of range targets to clamp and replenish up to BIP32_MAX_INDEX instead of silently doing nothing.

Notes to the reviewers

This revisits the earlier implementation attempt (#1792) and implements the clamp-in-apply_changeset approach from that review discussion.

The second and third fixes were found while adding the boundary tests requested in review of an earlier version of this PR.

Also added a test documenting current saturation behavior: reveal_next_spk/next_unused_spk return the last revealed script with an empty ChangeSet, not None, once the index is saturated. This matches reveal_next_spk's existing doc comment.

Went through the other index-taking methods (spk_at_index, is_used, mark_used, unmark_used) as well. These are plain lookups with no boundary-sensitive logic, so no dedicated tests were added there.

Changelog notice

  • Clamped last_revealed to BIP32_MAX_INDEX in KeychainTxOutIndex::apply_changeset to prevent it accepting an out-of-range derivation index.
  • Fixed an off-by-one in replenish_inner_index that could panic when revealing up to BIP32_MAX_INDEX.
  • Fixed an integer overflow in lookahead_to_target when called with target_index == u32::MAX, and changed out of range targets to clamp to BIP32_MAX_INDEX instead of silently doing nothing.

Checklists

All Submissions

Bugfixes

  • This pull request breaks the existing API
  • I've added tests to cover the issue which are now passing
  • I'm linking the issue being fixed by this PR

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.82353% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.05%. Comparing base (acc06e5) to head (1ca8d29).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
crates/chain/src/indexer/keychain_txout.rs 98.82% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2265      +/-   ##
==========================================
+ Coverage   78.71%   79.05%   +0.34%     
==========================================
  Files          31       31              
  Lines        5966     6054      +88     
  Branches      282      284       +2     
==========================================
+ Hits         4696     4786      +90     
+ Misses       1194     1193       -1     
+ Partials       76       75       -1     
Flag Coverage Δ
rust 79.05% <98.82%> (+0.34%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@evanlinjin evanlinjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR.

Given you have already started the work, we should probably also test:

  • Given the derivation index is saturated, next_unused_spk should return None and reveal_next_spk should return None.
  • What happens if we use an index greater than BIP32_MAX_INDEX for reveal_to_target.
  • What happens if we use BIP32_MAX_INDEX for reveal_to_target.
  • Any method that takes an index value should be tested with BIP32_MAX_INDEX and > BIP32_MAX_INDEX.

@busayo-OD
busayo-OD force-pushed the clamp-derivation-index branch from b4935c0 to a04c57a Compare September 3, 2026 21:03
@busayo-OD busayo-OD changed the title fix(chain): clamp derivation index before apply_changeset fix(chain): sanitize and bound-check derivation indices near BIP32_MAX_INDEX Sep 3, 2026
@busayo-OD

Copy link
Copy Markdown
Author

Given you have already started the work, we should probably also test:

* Given the derivation index is saturated, `next_unused_spk` should return `None` and `reveal_next_spk` should return `None`.

* What happens if we use an index greater than `BIP32_MAX_INDEX` for `reveal_to_target`.

* What happens if we use `BIP32_MAX_INDEX` for `reveal_to_target`.

* Any method that takes an index value should be tested with `BIP32_MAX_INDEX` and `> BIP32_MAX_INDEX`.

Thanks for the review. I’ve added the requested boundary tests. Two additional issues surfaced while adding them and have been fixed too.

For saturation, the current behavior is to return the last revealed SPK with an empty ChangeSet, rather than None, which matches the existing reveal_next_spk documentation. Is None expected here?

Comment thread crates/chain/src/indexer/keychain_txout.rs
KeychainTxOutIndex::apply_changeset accepts ChangeSet::last_revealed
values above BIP32_MAX_INDEX, violating an invariant relied on by
the indexer.

Clamp each last_revealed value to BIP32_MAX_INDEX before storing
it, keeping changeset application infallible and monotone.
@busayo-OD
busayo-OD force-pushed the clamp-derivation-index branch from a04c57a to 713586e Compare September 6, 2026 15:51
Comment thread crates/chain/src/indexer/keychain_txout.rs Outdated
Comment thread crates/chain/src/indexer/keychain_txout.rs Outdated
replenish_inner_index clamped stop_index (an exclusive bound) to
BIP32_MAX_INDEX, excluding the boundary itself and causing a panic
when a keychain's index was revealed exactly up to BIP32_MAX_INDEX.
The addition feeding into that clamp was also unchecked, risking
overflow if lookahead was large relative to an already-high
last_revealed.

Use saturating_add throughout, clamping to BIP32_MAX_INDEX + 1 so
the boundary value is included as documented.
lookahead_to_target treated an out-of-range target_index as a
silent no-op, which is wrong — it should clamp to BIP32_MAX_INDEX
and replenish up to that point, matching reveal_to_target's
best-effort semantics.

Use saturating_add/saturating_sub, which no longer needs checked
arithmetic since replenish_inner_index's stop_index now clamps
correctly on its own.
@busayo-OD
busayo-OD force-pushed the clamp-derivation-index branch from 713586e to 1ca8d29 Compare September 10, 2026 23:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

'apply_changeset' the derivation index aren't sanitized

2 participants