]> Untitled Git - bdk/commitdiff
fix(chain): fix off-by-one in replenish_inner_index stop bound
authorbusayo-OD <toyinoluwabusayo@gmail.com>
Thu, 10 Sep 2026 22:41:23 +0000 (23:41 +0100)
committerbusayo-OD <toyinoluwabusayo@gmail.com>
Thu, 10 Sep 2026 22:41:23 +0000 (23:41 +0100)
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.

crates/chain/src/indexer/keychain_txout.rs

index e436ad7ad5521e393a3729d161aa5cef56712f51..c5f3b62e567ad2474eecc4ca3fd56796b35b530f 100644 (file)
@@ -601,7 +601,9 @@ impl<K: Clone + Ord + Debug> KeychainTxOutIndex<K> {
         // Exclusive: index to stop at.
         let stop_index = if descriptor.has_wildcard() {
             let next_reveal_index = self.last_revealed.get(&did).map_or(0, |v| *v + 1);
-            (next_reveal_index + lookahead).min(BIP32_MAX_INDEX)
+            next_reveal_index
+                .saturating_add(lookahead)
+                .min(BIP32_MAX_INDEX + 1)
         } else {
             1
         };
@@ -1258,4 +1260,51 @@ mod test {
             )
         );
     }
+
+    /// Build an index with `last_revealed` (and the matching spk) at `last_revealed_index`.
+    fn indexer_with_last_revealed_at(last_revealed_index: u32) -> KeychainTxOutIndex<i32> {
+        let s = DESCRIPTORS[0];
+        let desc = Descriptor::parse_descriptor(&Secp256k1::new(), s)
+            .unwrap()
+            .0;
+        let mut index = KeychainTxOutIndex::new(0, false);
+        let did = desc.descriptor_id();
+        let _ = index.insert_descriptor(0i32, desc.clone());
+
+        let spk = desc
+            .at_derivation_index(last_revealed_index)
+            .unwrap()
+            .script_pubkey();
+        index.inner.insert_spk((0i32, last_revealed_index), spk);
+        index.last_revealed.insert(did, last_revealed_index);
+        index
+    }
+
+    #[test]
+    fn reveal_next_spk_and_next_unused_spk_return_last_script_when_saturated() {
+        let mut index = indexer_with_last_revealed_at(BIP32_MAX_INDEX);
+        let (i, changeset) = index.reveal_next_spk(0i32).unwrap();
+        assert_eq!(i.0, BIP32_MAX_INDEX);
+        assert!(changeset.is_empty());
+        assert!(index.mark_used(0i32, BIP32_MAX_INDEX));
+        let (i, changeset) = index.next_unused_spk(0i32).unwrap();
+        assert_eq!(i.0, BIP32_MAX_INDEX);
+        assert!(changeset.is_empty());
+    }
+
+    #[test]
+    fn reveal_to_target_with_target_at_bip32_max_index() {
+        let mut index = indexer_with_last_revealed_at(BIP32_MAX_INDEX - 1);
+        let (spks, _changeset) = index.reveal_to_target(0i32, BIP32_MAX_INDEX).unwrap();
+        assert_eq!(spks.len(), 1);
+        assert_eq!(spks[0].0, BIP32_MAX_INDEX);
+    }
+
+    #[test]
+    fn reveal_to_target_with_target_above_bip32_max_index() {
+        let mut index = indexer_with_last_revealed_at(BIP32_MAX_INDEX - 1);
+        let (spks, _changeset) = index.reveal_to_target(0i32, BIP32_MAX_INDEX + 5).unwrap();
+        assert_eq!(spks.len(), 1);
+        assert_eq!(spks[0].0, BIP32_MAX_INDEX);
+    }
 }