From 3a6795e8baa54032fd7d30fa45928865556d1d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 16 Sep 2026 05:24:19 +0000 Subject: [PATCH] feat(core)!: Make `last_active_indices` a `Vec<(K, u32)>` `FullScanResponse::last_active_indices` was a `BTreeMap`, which forced every producer to keep a running per-keychain max before reporting, since a chain source learns of activity one spk at a time in arbitrary order. Producers now push each observation as they make it, and merging two responses is a concatenation. `KeychainTxOutIndex::reveal_to_target_multi` now takes `impl IntoIterator`. It only ever reveals upwards, so unordered and repeated entries are already handled correctly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VATvTCqPsmqxZuvzm7WZ9s --- crates/chain/src/indexer/keychain_txout.rs | 12 ++++++-- .../chain/tests/test_keychain_txout_index.rs | 4 +-- crates/core/src/spk_client.rs | 7 ++++- crates/electrum/src/bdk_electrum_client.rs | 4 +-- crates/electrum/tests/test_electrum.rs | 29 ++++++++++++++++--- crates/esplora/src/async_ext.rs | 4 +-- crates/esplora/src/blocking_ext.rs | 4 +-- crates/esplora/tests/async_ext.rs | 20 ++++++++++--- crates/esplora/tests/blocking_ext.rs | 20 ++++++++++--- crates/esplora/tests/common/mod.rs | 12 ++++++++ 10 files changed, 92 insertions(+), 24 deletions(-) diff --git a/crates/chain/src/indexer/keychain_txout.rs b/crates/chain/src/indexer/keychain_txout.rs index 33b5c97270..c08fe3e71b 100644 --- a/crates/chain/src/indexer/keychain_txout.rs +++ b/crates/chain/src/indexer/keychain_txout.rs @@ -816,11 +816,17 @@ impl KeychainTxOutIndex { } /// Convenience method to call [`Self::reveal_to_target`] on multiple keychains. - pub fn reveal_to_target_multi(&mut self, keychains: &BTreeMap) -> ChangeSet { + /// + /// `keychains` may repeat a keychain and need not be ordered, as revealing to a target at or + /// below the keychain's last revealed index is a no-op. + pub fn reveal_to_target_multi( + &mut self, + keychains: impl IntoIterator, + ) -> ChangeSet { let mut changeset = ChangeSet::default(); - for (keychain, &index) in keychains { - self._reveal_to_target(&mut changeset, keychain.clone(), index); + for (keychain, index) in keychains { + self._reveal_to_target(&mut changeset, keychain, index); } self._empty_stage_into_changeset(&mut changeset); diff --git a/crates/chain/tests/test_keychain_txout_index.rs b/crates/chain/tests/test_keychain_txout_index.rs index 263a0fa86f..4a9106f371 100644 --- a/crates/chain/tests/test_keychain_txout_index.rs +++ b/crates/chain/tests/test_keychain_txout_index.rs @@ -139,7 +139,7 @@ fn test_set_all_derivation_indices() { ] .into(); assert_eq!( - txout_index.reveal_to_target_multi(&derive_to), + txout_index.reveal_to_target_multi(derive_to.clone()), ChangeSet { last_revealed: last_revealed.clone(), spk_cache: spk_cache.clone(), @@ -147,7 +147,7 @@ fn test_set_all_derivation_indices() { ); assert_eq!(txout_index.last_revealed_indices(), derive_to); assert_eq!( - txout_index.reveal_to_target_multi(&derive_to), + txout_index.reveal_to_target_multi(derive_to.clone()), ChangeSet::default(), "no changes if we set to the same thing" ); diff --git a/crates/core/src/spk_client.rs b/crates/core/src/spk_client.rs index 64fe3dd8cb..b99da3eb1b 100644 --- a/crates/core/src/spk_client.rs +++ b/crates/core/src/spk_client.rs @@ -592,7 +592,12 @@ pub struct FullScanResponse { pub tx_update: crate::TxUpdate, /// Last active indices for the corresponding keychains (`K`). An index is active if it had a /// transaction associated with the script pubkey at that index. - pub last_active_indices: BTreeMap, + /// + /// Entries are observations, so a keychain may appear multiple times and entries need not be + /// ordered. The consumer only ever reveals upwards (see + /// [`reveal_to_target_multi`](https://docs.rs/bdk_chain/latest/bdk_chain/indexer/keychain_txout/struct.KeychainTxOutIndex.html#method.reveal_to_target_multi)), + /// so an entry that targets an index at or below what is already revealed is a no-op. + pub last_active_indices: Vec<(K, u32)>, /// Changes to the chain discovered during the scan. pub chain_update: Option>, } diff --git a/crates/electrum/src/bdk_electrum_client.rs b/crates/electrum/src/bdk_electrum_client.rs index a7c943150a..cb2967d4d4 100644 --- a/crates/electrum/src/bdk_electrum_client.rs +++ b/crates/electrum/src/bdk_electrum_client.rs @@ -137,7 +137,7 @@ impl BdkElectrumClient { }; let mut tx_update = TxUpdate::::default(); - let mut last_active_indices = BTreeMap::::default(); + let mut last_active_indices = Vec::<(K, u32)>::new(); let mut pending_anchors = Vec::new(); for keychain in request.keychains() { let last_revealed = request.last_revealed(&keychain); @@ -153,7 +153,7 @@ impl BdkElectrumClient { batch_size, &mut pending_anchors, )? { - last_active_indices.insert(keychain, last_active_index); + last_active_indices.push((keychain, last_active_index)); } } diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index d0632db422..ae3043f531 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -23,6 +23,18 @@ use std::str::FromStr; // Batch size for `sync_with_electrum`. const BATCH_SIZE: usize = 5; +/// The last active index reported for `keychain`. +/// +/// Entries of `last_active_indices` are observations, so they may repeat a keychain and need not +/// be ordered. The highest index reported for the keychain is the meaningful one. +fn last_active_index(last_active_indices: &[(u32, u32)], keychain: u32) -> Option { + last_active_indices + .iter() + .filter(|(k, _)| *k == keychain) + .map(|(_, i)| *i) + .max() +} + pub fn get_test_spk() -> ScriptBuf { const PK_BYTES: &[u8] = &[ 12, 244, 72, 4, 163, 4, 211, 81, 159, 82, 153, 123, 125, 74, 142, 40, 55, 237, 191, 231, @@ -449,7 +461,10 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> { .compute_txid(), txid_4th_addr ); - assert_eq!(full_scan_update.last_active_indices[&0], 3); + assert_eq!( + last_active_index(&full_scan_update.last_active_indices, 0), + Some(3) + ); // Now receive a coin on the last address. let txid_last_addr = env @@ -476,7 +491,10 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> { .collect(); assert_eq!(txs.len(), 1); assert!(txs.contains(&txid_4th_addr)); - assert_eq!(full_scan_update.last_active_indices[&0], 3); + assert_eq!( + last_active_index(&full_scan_update.last_active_indices, 0), + Some(3) + ); let full_scan_update = { let request = FullScanRequest::builder() .chain_tip(cp_tip.clone()) @@ -491,7 +509,10 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> { .collect(); assert_eq!(txs.len(), 2); assert!(txs.contains(&txid_4th_addr) && txs.contains(&txid_last_addr)); - assert_eq!(full_scan_update.last_active_indices[&0], 9); + assert_eq!( + last_active_index(&full_scan_update.last_active_indices, 0), + Some(9) + ); Ok(()) } @@ -538,7 +559,7 @@ pub fn test_stop_gap_past_last_revealed() -> anyhow::Result<()> { response.tx_update.txs.first().unwrap().compute_txid(), txid_last_addr ); - assert_eq!(response.last_active_indices[&0], 9); + assert_eq!(last_active_index(&response.last_active_indices, 0), Some(9)); // Tx sits beyond `last_revealed + stop_gap`. So tx should not be found. let request = FullScanRequest::builder() diff --git a/crates/esplora/src/async_ext.rs b/crates/esplora/src/async_ext.rs index 2b38289526..311682f4cc 100644 --- a/crates/esplora/src/async_ext.rs +++ b/crates/esplora/src/async_ext.rs @@ -77,7 +77,7 @@ where let mut tx_update = TxUpdate::::default(); let mut inserted_txs = HashSet::::new(); - let mut last_active_indices = BTreeMap::::new(); + let mut last_active_indices = Vec::<(K, u32)>::new(); for keychain in keychains { let last_revealed = request.last_revealed(&keychain); let keychain_spks = request @@ -95,7 +95,7 @@ where .await?; tx_update.extend(update); if let Some(last_active_index) = last_active_index { - last_active_indices.insert(keychain, last_active_index); + last_active_indices.push((keychain, last_active_index)); } } diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index 225b574ea4..71a911baa5 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -67,7 +67,7 @@ impl EsploraExt for esplora_client::BlockingClient { let mut tx_update = TxUpdate::default(); let mut inserted_txs = HashSet::::new(); - let mut last_active_indices = BTreeMap::::new(); + let mut last_active_indices = Vec::<(K, u32)>::new(); for keychain in request.keychains() { let last_revealed = request.last_revealed(&keychain); let keychain_spks = request @@ -84,7 +84,7 @@ impl EsploraExt for esplora_client::BlockingClient { )?; tx_update.extend(update); if let Some(last_active_index) = last_active_index { - last_active_indices.insert(keychain, last_active_index); + last_active_indices.push((keychain, last_active_index)); } } diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index 2941e2a669..8211433d04 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -303,7 +303,10 @@ pub async fn test_async_update_tx_graph_stop_gap() -> anyhow::Result<()> { .compute_txid(), txid_4th_addr ); - assert_eq!(full_scan_update.last_active_indices[&0], 3); + assert_eq!( + common::last_active_index(&full_scan_update.last_active_indices, 0), + Some(3) + ); // Now receive a coin on the last address. let txid_last_addr = env @@ -332,7 +335,10 @@ pub async fn test_async_update_tx_graph_stop_gap() -> anyhow::Result<()> { .collect(); assert_eq!(txs.len(), 1); assert!(txs.contains(&txid_4th_addr)); - assert_eq!(full_scan_update.last_active_indices[&0], 3); + assert_eq!( + common::last_active_index(&full_scan_update.last_active_indices, 0), + Some(3) + ); let full_scan_update = { let request = FullScanRequest::builder() .chain_tip(cp_tip.clone()) @@ -347,7 +353,10 @@ pub async fn test_async_update_tx_graph_stop_gap() -> anyhow::Result<()> { .collect(); assert_eq!(txs.len(), 2); assert!(txs.contains(&txid_4th_addr) && txs.contains(&txid_last_addr)); - assert_eq!(full_scan_update.last_active_indices[&0], 9); + assert_eq!( + common::last_active_index(&full_scan_update.last_active_indices, 0), + Some(9) + ); Ok(()) } @@ -396,7 +405,10 @@ pub async fn test_async_stop_gap_past_last_revealed() -> anyhow::Result<()> { response.tx_update.txs.first().unwrap().compute_txid(), txid_last_addr ); - assert_eq!(response.last_active_indices[&0], 9); + assert_eq!( + common::last_active_index(&response.last_active_indices, 0), + Some(9) + ); // Tx sits beyond `last_revealed + stop_gap`. So `stop_gap` must cut the scan off. let request = FullScanRequest::builder() diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index 8db59947fa..df65127008 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -302,7 +302,10 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> { .compute_txid(), txid_4th_addr ); - assert_eq!(full_scan_update.last_active_indices[&0], 3); + assert_eq!( + common::last_active_index(&full_scan_update.last_active_indices, 0), + Some(3) + ); // Now receive a coin on the last address. let txid_last_addr = env @@ -331,7 +334,10 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> { .collect(); assert_eq!(txs.len(), 1); assert!(txs.contains(&txid_4th_addr)); - assert_eq!(full_scan_update.last_active_indices[&0], 3); + assert_eq!( + common::last_active_index(&full_scan_update.last_active_indices, 0), + Some(3) + ); let full_scan_update = { let request = FullScanRequest::builder() .chain_tip(cp_tip.clone()) @@ -346,7 +352,10 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> { .collect(); assert_eq!(txs.len(), 2); assert!(txs.contains(&txid_4th_addr) && txs.contains(&txid_last_addr)); - assert_eq!(full_scan_update.last_active_indices[&0], 9); + assert_eq!( + common::last_active_index(&full_scan_update.last_active_indices, 0), + Some(9) + ); Ok(()) } @@ -395,7 +404,10 @@ pub fn test_stop_gap_past_last_revealed() -> anyhow::Result<()> { response.tx_update.txs.first().unwrap().compute_txid(), txid_last_addr ); - assert_eq!(response.last_active_indices[&0], 9); + assert_eq!( + common::last_active_index(&response.last_active_indices, 0), + Some(9) + ); // Tx sits beyond `last_revealed + stop_gap`. So `stop_gap` must cut the scan off. let request = FullScanRequest::builder() diff --git a/crates/esplora/tests/common/mod.rs b/crates/esplora/tests/common/mod.rs index 9fe6dfd9ec..8d643e58c5 100644 --- a/crates/esplora/tests/common/mod.rs +++ b/crates/esplora/tests/common/mod.rs @@ -32,3 +32,15 @@ pub fn test_addresses() -> Vec
{ .map(|s| Address::from_str(s).unwrap().assume_checked()) .collect() } + +/// The last active index reported for `keychain`. +/// +/// Entries of `last_active_indices` are observations, so they may repeat a keychain and need not +/// be ordered. The highest index reported for the keychain is the meaningful one. +pub fn last_active_index(last_active_indices: &[(u32, u32)], keychain: u32) -> Option { + last_active_indices + .iter() + .filter(|(k, _)| *k == keychain) + .map(|(_, i)| *i) + .max() +}