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 ("attempt to add with overflow") when called with target_index == u32::MAX, by replacing unchecked target_index + 1 with checked_add/checked_sub.

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 that could panic when called with target_index == u32::MAX.

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

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.08%. Comparing base (acc06e5) to head (a04c57a).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2265      +/-   ##
==========================================
+ Coverage   78.71%   79.08%   +0.37%     
==========================================
  Files          31       31              
  Lines        5966     6062      +96     
  Branches      282      282              
==========================================
+ Hits         4696     4794      +98     
  Misses       1194     1194              
+ Partials       76       74       -2     
Flag Coverage Δ
rust 79.08% <100.00%> (+0.37%) ⬆️

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.

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.
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.

Clamp to BIP32_MAX_INDEX.saturating_add(1) instead, so the boundary
value is included as documented.
lookahead_to_target added 1 to target_index without an overflow
check, panicking when called with target_index == u32::MAX.

Use checked_add/checked_sub so this is a no-op instead of a panic.
@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?

}
}
for (did, index) in changeset.last_revealed {
let index = index.min(BIP32_MAX_INDEX);

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.

I propose adding debug_assert!(index < BIP32_MAX_INDEX) above this line so that overflows can be caught during development (it shouldn't really happen).

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