From 803fd968d7999bda911eb2b968cd54a585fac8b4 Mon Sep 17 00:00:00 2001 From: busayo-OD Date: Sun, 6 Sep 2026 16:20:22 +0100 Subject: [PATCH 1/3] fix(chain): clamp derivation index before apply_changeset 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. --- crates/chain/src/indexer/keychain_txout.rs | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/chain/src/indexer/keychain_txout.rs b/crates/chain/src/indexer/keychain_txout.rs index 33b5c9727..e436ad7ad 100644 --- a/crates/chain/src/indexer/keychain_txout.rs +++ b/crates/chain/src/indexer/keychain_txout.rs @@ -992,6 +992,7 @@ impl KeychainTxOutIndex { } } for (did, index) in changeset.last_revealed { + let index = index.min(BIP32_MAX_INDEX); let v = self.last_revealed.entry(did).or_default(); *v = index.max(*v); self.replenish_inner_index_did(did, self.lookahead); @@ -1226,4 +1227,35 @@ mod test { let index = KeychainTxOutIndex::::from_changeset(lookahead, false, init_cs); assert!(index.spk_cache.is_empty()); } + + #[test] + fn apply_changeset_clamps_out_of_range_index_and_derives_correct_spk() { + 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 seed_index = BIP32_MAX_INDEX - 1; + let spk = desc + .at_derivation_index(seed_index) + .unwrap() + .script_pubkey(); + index.inner.insert_spk((0i32, seed_index), spk); + let changeset = ChangeSet { + last_revealed: [(did, BIP32_MAX_INDEX + 1)].into(), + ..Default::default() + }; + index.apply_changeset(changeset); + assert_eq!(index.last_revealed_index(0i32), Some(BIP32_MAX_INDEX)); + assert_eq!( + index.spk_at_index(0i32, BIP32_MAX_INDEX), + Some( + desc.at_derivation_index(BIP32_MAX_INDEX) + .unwrap() + .script_pubkey() + ) + ); + } } From 22ad1ecefcdea905f265b1a652ff0b545b6ab2f2 Mon Sep 17 00:00:00 2001 From: busayo-OD Date: Sun, 6 Sep 2026 16:26:32 +0100 Subject: [PATCH 2/3] fix(chain): fix off-by-one in replenish_inner_index stop bound 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. --- crates/chain/src/indexer/keychain_txout.rs | 72 +++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/chain/src/indexer/keychain_txout.rs b/crates/chain/src/indexer/keychain_txout.rs index e436ad7ad..01a3d3d99 100644 --- a/crates/chain/src/indexer/keychain_txout.rs +++ b/crates/chain/src/indexer/keychain_txout.rs @@ -601,7 +601,7 @@ impl KeychainTxOutIndex { // 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 + lookahead).min(BIP32_MAX_INDEX.saturating_add(1)) } else { 1 }; @@ -1258,4 +1258,74 @@ mod test { ) ); } + + #[test] + fn reveal_next_spk_and_next_unused_spk_return_last_script_when_saturated() { + 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(BIP32_MAX_INDEX) + .unwrap() + .script_pubkey(); + index.inner.insert_spk((0i32, BIP32_MAX_INDEX), spk); + index.last_revealed.insert(did, 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 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 seed_index = BIP32_MAX_INDEX - 1; + let spk = desc + .at_derivation_index(seed_index) + .unwrap() + .script_pubkey(); + index.inner.insert_spk((0i32, seed_index), spk); + index.last_revealed.insert(did, seed_index); + + 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 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 seed_index = BIP32_MAX_INDEX - 1; + let spk = desc + .at_derivation_index(seed_index) + .unwrap() + .script_pubkey(); + index.inner.insert_spk((0i32, seed_index), spk); + index.last_revealed.insert(did, seed_index); + + 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); + } } From 713586e4309d4181d554a395482cffe58584acf6 Mon Sep 17 00:00:00 2001 From: busayo-OD Date: Sun, 6 Sep 2026 16:27:28 +0100 Subject: [PATCH 3/3] fix(chain): fix integer overflow in lookahead_to_target 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. --- crates/chain/src/indexer/keychain_txout.rs | 41 ++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/chain/src/indexer/keychain_txout.rs b/crates/chain/src/indexer/keychain_txout.rs index 01a3d3d99..1828cbed6 100644 --- a/crates/chain/src/indexer/keychain_txout.rs +++ b/crates/chain/src/indexer/keychain_txout.rs @@ -563,8 +563,9 @@ impl KeychainTxOutIndex { pub fn lookahead_to_target(&mut self, keychain: K, target_index: u32) -> ChangeSet { let mut changeset = ChangeSet::default(); if let Some((next_index, _)) = self.next_index(keychain.clone()) { - let temp_lookahead = (target_index + 1) - .checked_sub(next_index) + let temp_lookahead = target_index + .checked_add(1) + .and_then(|t| t.checked_sub(next_index)) .filter(|&index| index > 0); if let Some(temp_lookahead) = temp_lookahead { @@ -1328,4 +1329,40 @@ mod test { assert_eq!(spks.len(), 1); assert_eq!(spks[0].0, BIP32_MAX_INDEX); } + + #[test] + fn lookahead_to_target_with_u32_max_does_not_overflow() { + let s = DESCRIPTORS[0]; + let desc = Descriptor::parse_descriptor(&Secp256k1::new(), s) + .unwrap() + .0; + let mut index = KeychainTxOutIndex::new(0, false); + let _ = index.insert_descriptor(0i32, desc.clone()); + + let changeset = index.lookahead_to_target(0i32, u32::MAX); + assert!(changeset.is_empty()); + } + + #[test] + fn lookahead_to_target_with_target_at_bip32_max_index() { + 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 seed_index = BIP32_MAX_INDEX - 1; + let spk = desc + .at_derivation_index(seed_index) + .unwrap() + .script_pubkey(); + index.inner.insert_spk((0i32, seed_index), spk); + index.last_revealed.insert(did, seed_index); + + let _changeset = index.lookahead_to_target(0i32, BIP32_MAX_INDEX); + + assert!(index.spk_at_index(0i32, BIP32_MAX_INDEX).is_some()); + } }