Skip to content

Commit 281c3e6

Browse files
committed
fix: full_scan covers revealed range before applying stop_gap
1 parent de7a89f commit 281c3e6

9 files changed

Lines changed: 226 additions & 66 deletions

File tree

crates/chain/src/indexer/keychain_txout.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1128,7 +1128,11 @@ pub trait FullScanRequestBuilderExt<K> {
11281128
impl<K: Clone + Ord + core::fmt::Debug> FullScanRequestBuilderExt<K> for FullScanRequestBuilder<K> {
11291129
fn spks_from_indexer(mut self, indexer: &KeychainTxOutIndex<K>) -> Self {
11301130
for (keychain, spks) in indexer.all_unbounded_spk_iters() {
1131-
self = self.spks_for_keychain(keychain, spks);
1131+
let last_revealed = indexer.last_revealed_index(keychain.clone());
1132+
self = self.spks_for_keychain(keychain.clone(), spks);
1133+
if let Some(index) = last_revealed {
1134+
self = self.last_revealed_for_keychain(keychain, index);
1135+
}
11321136
}
11331137
self
11341138
}

crates/core/src/spk_client.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,18 @@ impl<K: Ord, D> FullScanRequestBuilder<K, D> {
456456
self
457457
}
458458

459+
/// Record the last revealed script pubkey `index` for a given `keychain`.
460+
///
461+
/// `full_scan` covers `0..=index` for this keychain; `stop_gap`
462+
/// applies only to indices past `index`. Keychains without a recorded last revealed
463+
/// index fall back to applying `stop_gap` from index 0.
464+
/// Users working with a `KeychainTxOutIndex` usually don't call this directly,
465+
/// `spks_from_indexer` (from `bdk_chain`) populates it automatically.
466+
pub fn last_revealed_for_keychain(mut self, keychain: K, index: u32) -> Self {
467+
self.inner.last_revealed.insert(keychain, index);
468+
self
469+
}
470+
459471
/// Set the closure that will inspect every sync item visited.
460472
pub fn inspect<F>(mut self, inspect: F) -> Self
461473
where
@@ -483,6 +495,7 @@ pub struct FullScanRequest<K, D = BlockHash> {
483495
start_time: u64,
484496
chain_tip: Option<CheckPoint<D>>,
485497
spks_by_keychain: BTreeMap<K, Box<dyn Iterator<Item = Indexed<ScriptBuf>> + Send>>,
498+
last_revealed: BTreeMap<K, u32>,
486499
inspect: Box<InspectFullScan<K>>,
487500
}
488501

@@ -507,6 +520,7 @@ impl<K: Ord + Clone, D> FullScanRequest<K, D> {
507520
start_time,
508521
chain_tip: None,
509522
spks_by_keychain: BTreeMap::new(),
523+
last_revealed: BTreeMap::new(),
510524
inspect: Box::new(|_, _, _| ()),
511525
},
512526
}
@@ -541,6 +555,14 @@ impl<K: Ord + Clone, D> FullScanRequest<K, D> {
541555
self.spks_by_keychain.keys().cloned().collect()
542556
}
543557

558+
/// Get the last revealed script pubkey index for `keychain` (if set).
559+
///
560+
/// Chain sources use this to scan `0..=last_revealed` before applying
561+
/// `stop_gap` to further discovery.
562+
pub fn last_revealed(&self, keychain: &K) -> Option<u32> {
563+
self.last_revealed.get(keychain).copied()
564+
}
565+
544566
/// Advances the full scan request and returns the next indexed [`ScriptBuf`] of the given
545567
/// `keychain`.
546568
pub fn next_spk(&mut self, keychain: K) -> Option<Indexed<ScriptBuf>> {

crates/electrum/src/bdk_electrum_client.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
130130
let mut last_active_indices = BTreeMap::<K, u32>::default();
131131
let mut pending_anchors = Vec::new();
132132
for keychain in request.keychains() {
133+
let last_revealed = request.last_revealed(&keychain);
133134
let spks = request
134135
.iter_spks(keychain.clone())
135136
.map(|(spk_i, spk)| (spk_i, SpkWithExpectedTxids::from(spk)));
@@ -138,6 +139,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
138139
&mut tx_update,
139140
spks,
140141
stop_gap,
142+
last_revealed,
141143
batch_size,
142144
&mut pending_anchors,
143145
)? {
@@ -219,6 +221,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
219221
.enumerate()
220222
.map(|(i, spk)| (i as u32, spk)),
221223
usize::MAX,
224+
None,
222225
batch_size,
223226
&mut pending_anchors,
224227
)?;
@@ -267,12 +270,14 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
267270
/// Transactions that contains an output with requested spk, or spends form an output with
268271
/// requested spk will be added to `tx_update`. Anchors of the aforementioned transactions are
269272
/// also included.
273+
#[allow(clippy::too_many_arguments)]
270274
fn populate_with_spks(
271275
&self,
272276
start_time: u64,
273277
tx_update: &mut TxUpdate<ConfirmationBlockTime>,
274278
mut spks_with_expected_txids: impl Iterator<Item = (u32, SpkWithExpectedTxids)>,
275279
stop_gap: usize,
280+
last_revealed: Option<u32>,
276281
batch_size: usize,
277282
pending_anchors: &mut Vec<(Txid, usize)>,
278283
) -> Result<Option<u32>, Error> {
@@ -292,14 +297,16 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
292297
.batch_script_get_history(spks.iter().map(|(_, s)| s.spk.as_script()))?;
293298

294299
for ((spk_index, spk), spk_history) in spks.into_iter().zip(spk_histories) {
295-
if spk_history.is_empty() {
296-
match unused_spk_count.checked_add(1) {
297-
Some(i) if i < stop_gap => unused_spk_count = i,
298-
_ => return Ok(last_active_index),
299-
};
300-
} else {
300+
let beyond_revealed = last_revealed.is_none_or(|lr| spk_index > lr);
301+
302+
if !spk_history.is_empty() {
301303
last_active_index = Some(spk_index);
302304
unused_spk_count = 0;
305+
} else if beyond_revealed {
306+
unused_spk_count = unused_spk_count.saturating_add(1);
307+
if unused_spk_count >= stop_gap {
308+
return Ok(last_active_index);
309+
}
303310
}
304311

305312
let spk_history_set = spk_history

crates/electrum/tests/test_electrum.rs

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ pub fn get_test_spk() -> ScriptBuf {
3434
ScriptBuf::new_p2tr(&secp, pk, None)
3535
}
3636

37+
pub fn test_addresses() -> Vec<Address> {
38+
[
39+
"bcrt1qj9f7r8r3p2y0sqf4r3r62qysmkuh0fzep473d2ar7rcz64wqvhssjgf0z4",
40+
"bcrt1qmm5t0ch7vh2hryx9ctq3mswexcugqe4atkpkl2tetm8merqkthas3w7q30",
41+
"bcrt1qut9p7ej7l7lhyvekj28xknn8gnugtym4d5qvnp5shrsr4nksmfqsmyn87g",
42+
"bcrt1qqz0xtn3m235p2k96f5wa2dqukg6shxn9n3txe8arlrhjh5p744hsd957ww",
43+
"bcrt1q9c0t62a8l6wfytmf2t9lfj35avadk3mm8g4p3l84tp6rl66m48sqrme7wu",
44+
"bcrt1qkmh8yrk2v47cklt8dytk8f3ammcwa4q7dzattedzfhqzvfwwgyzsg59zrh",
45+
"bcrt1qvgrsrzy07gjkkfr5luplt0azxtfwmwq5t62gum5jr7zwcvep2acs8hhnp2",
46+
"bcrt1qw57edarcg50ansq8mk3guyrk78rk0fwvrds5xvqeupteu848zayq549av8",
47+
"bcrt1qvtve5ekf6e5kzs68knvnt2phfw6a0yjqrlgat392m6zt9jsvyxhqfx67ef",
48+
"bcrt1qw03ddumfs9z0kcu76ln7jrjfdwam20qtffmkcral3qtza90sp9kqm787uk",
49+
]
50+
.into_iter()
51+
.map(|s| Address::from_str(s).unwrap().assume_checked())
52+
.collect()
53+
}
54+
3755
fn get_balance(
3856
recv_chain: &LocalChain,
3957
recv_graph: &IndexedTxGraph<ConfirmationBlockTime, SpkTxOutIndex<()>>,
@@ -383,23 +401,7 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> {
383401
let client = BdkElectrumClient::new(electrum_client);
384402
let _block_hashes = env.mine_blocks(101, None)?;
385403

386-
// Now let's test the gap limit. First of all get a chain of 10 addresses.
387-
let addresses = [
388-
"bcrt1qj9f7r8r3p2y0sqf4r3r62qysmkuh0fzep473d2ar7rcz64wqvhssjgf0z4",
389-
"bcrt1qmm5t0ch7vh2hryx9ctq3mswexcugqe4atkpkl2tetm8merqkthas3w7q30",
390-
"bcrt1qut9p7ej7l7lhyvekj28xknn8gnugtym4d5qvnp5shrsr4nksmfqsmyn87g",
391-
"bcrt1qqz0xtn3m235p2k96f5wa2dqukg6shxn9n3txe8arlrhjh5p744hsd957ww",
392-
"bcrt1q9c0t62a8l6wfytmf2t9lfj35avadk3mm8g4p3l84tp6rl66m48sqrme7wu",
393-
"bcrt1qkmh8yrk2v47cklt8dytk8f3ammcwa4q7dzattedzfhqzvfwwgyzsg59zrh",
394-
"bcrt1qvgrsrzy07gjkkfr5luplt0azxtfwmwq5t62gum5jr7zwcvep2acs8hhnp2",
395-
"bcrt1qw57edarcg50ansq8mk3guyrk78rk0fwvrds5xvqeupteu848zayq549av8",
396-
"bcrt1qvtve5ekf6e5kzs68knvnt2phfw6a0yjqrlgat392m6zt9jsvyxhqfx67ef",
397-
"bcrt1qw03ddumfs9z0kcu76ln7jrjfdwam20qtffmkcral3qtza90sp9kqm787uk",
398-
];
399-
let addresses: Vec<_> = addresses
400-
.into_iter()
401-
.map(|s| Address::from_str(s).unwrap().assume_checked())
402-
.collect();
404+
let addresses = test_addresses();
403405
let spks: Vec<_> = addresses
404406
.iter()
405407
.enumerate()
@@ -490,6 +492,47 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> {
490492
Ok(())
491493
}
492494

495+
/// Test that `full_scan` always scans the revealed range before applying `stop_gap`.
496+
#[test]
497+
pub fn test_stop_gap_past_last_revealed() -> anyhow::Result<()> {
498+
let env = TestEnv::new()?;
499+
let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?;
500+
let client = BdkElectrumClient::new(electrum_client);
501+
let _block_hashes = env.mine_blocks(101, None)?;
502+
503+
let addresses = test_addresses();
504+
let spks: Vec<_> = addresses
505+
.iter()
506+
.enumerate()
507+
.map(|(i, addr)| (i as u32, addr.script_pubkey()))
508+
.collect();
509+
510+
// Receive coins beyond stop_gap of 3.
511+
let txid_last_addr = env
512+
.bitcoind
513+
.client
514+
.send_to_address(&addresses[9], Amount::from_sat(10000))?
515+
.txid()?;
516+
env.mine_blocks(1, None)?;
517+
env.wait_until_electrum_sees_block(Duration::from_secs(6))?;
518+
519+
let cp_tip = env.make_checkpoint_tip();
520+
521+
let request = FullScanRequest::builder()
522+
.chain_tip(cp_tip.clone())
523+
.spks_for_keychain(0, spks.clone())
524+
.last_revealed_for_keychain(0, 9);
525+
let response = client.full_scan(request, 3, 1, false)?;
526+
527+
assert_eq!(
528+
response.tx_update.txs.first().unwrap().compute_txid(),
529+
txid_last_addr
530+
);
531+
assert_eq!(response.last_active_indices[&0], 9);
532+
533+
Ok(())
534+
}
535+
493536
/// Ensure that [`BdkElectrumClient::sync`] can confirm previously unconfirmed transactions in both
494537
/// reorg and no-reorg situations. After the transaction is confirmed after reorg, check if floating
495538
/// txouts for previous outputs were inserted for transaction fee calculation.

crates/esplora/src/async_ext.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ where
7979
let mut inserted_txs = HashSet::<Txid>::new();
8080
let mut last_active_indices = BTreeMap::<K, u32>::new();
8181
for keychain in keychains {
82+
let last_revealed = request.last_revealed(&keychain);
8283
let keychain_spks = request
8384
.iter_spks(keychain.clone())
8485
.map(|(spk_i, spk)| (spk_i, spk.into()));
@@ -88,6 +89,7 @@ where
8889
&mut inserted_txs,
8990
keychain_spks,
9091
stop_gap,
92+
last_revealed,
9193
parallel_requests,
9294
)
9395
.await?;
@@ -305,6 +307,7 @@ async fn fetch_txs_with_keychain_spks<I, S>(
305307
inserted_txs: &mut HashSet<Txid>,
306308
mut keychain_spks: I,
307309
stop_gap: usize,
310+
last_revealed: Option<u32>,
308311
parallel_requests: usize,
309312
) -> Result<(TxUpdate<ConfirmationBlockTime>, Option<u32>), Error>
310313
where
@@ -355,12 +358,13 @@ where
355358
}
356359

357360
for (index, txs, evicted) in handles.try_collect::<Vec<TxsOfSpkIndex>>().await? {
358-
if txs.is_empty() {
359-
consecutive_unused = consecutive_unused.saturating_add(1);
360-
} else {
361+
if !txs.is_empty() {
361362
consecutive_unused = 0;
362363
last_active_index = Some(index);
364+
} else if last_revealed.is_none_or(|lr| index > lr) {
365+
consecutive_unused = consecutive_unused.saturating_add(1);
363366
}
367+
364368
for tx in txs {
365369
if inserted_txs.insert(tx.txid) {
366370
update.txs.push(tx.to_tx().into());
@@ -407,6 +411,7 @@ where
407411
inserted_txs,
408412
spks.into_iter().enumerate().map(|(i, spk)| (i as u32, spk)),
409413
usize::MAX,
414+
None,
410415
parallel_requests,
411416
)
412417
.await

crates/esplora/src/blocking_ext.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ impl EsploraExt for esplora_client::BlockingClient {
6969
let mut inserted_txs = HashSet::<Txid>::new();
7070
let mut last_active_indices = BTreeMap::<K, u32>::new();
7171
for keychain in request.keychains() {
72+
let last_revealed = request.last_revealed(&keychain);
7273
let keychain_spks = request
7374
.iter_spks(keychain.clone())
7475
.map(|(spk_i, spk)| (spk_i, spk.into()));
@@ -78,6 +79,7 @@ impl EsploraExt for esplora_client::BlockingClient {
7879
&mut inserted_txs,
7980
keychain_spks,
8081
stop_gap,
82+
last_revealed,
8183
parallel_requests,
8284
)?;
8385
tx_update.extend(update);
@@ -277,6 +279,7 @@ fn fetch_txs_with_keychain_spks<I: Iterator<Item = Indexed<SpkWithExpectedTxids>
277279
inserted_txs: &mut HashSet<Txid>,
278280
mut keychain_spks: I,
279281
stop_gap: usize,
282+
last_revealed: Option<u32>,
280283
parallel_requests: usize,
281284
) -> Result<(TxUpdate<ConfirmationBlockTime>, Option<u32>), Error> {
282285
type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>, HashSet<Txid>);
@@ -324,12 +327,13 @@ fn fetch_txs_with_keychain_spks<I: Iterator<Item = Indexed<SpkWithExpectedTxids>
324327

325328
for handle in handles {
326329
let (index, txs, evicted) = handle.join().expect("thread must not panic")?;
327-
if txs.is_empty() {
328-
consecutive_unused = consecutive_unused.saturating_add(1);
329-
} else {
330+
if !txs.is_empty() {
330331
consecutive_unused = 0;
331332
last_active_index = Some(index);
333+
} else if last_revealed.is_none_or(|lr| index > lr) {
334+
consecutive_unused = consecutive_unused.saturating_add(1);
332335
}
336+
333337
for tx in txs {
334338
if inserted_txs.insert(tx.txid) {
335339
update.txs.push(tx.to_tx().into());
@@ -371,6 +375,7 @@ fn fetch_txs_with_spks<I: IntoIterator<Item = SpkWithExpectedTxids>>(
371375
inserted_txs,
372376
spks.into_iter().enumerate().map(|(i, spk)| (i as u32, spk)),
373377
usize::MAX,
378+
None,
374379
parallel_requests,
375380
)
376381
.map(|(update, _)| update)

crates/esplora/tests/async_ext.rs

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -259,23 +259,7 @@ pub async fn test_async_update_tx_graph_stop_gap() -> anyhow::Result<()> {
259259
let client = Builder::new(base_url.as_str()).build_async()?;
260260
let _block_hashes = env.mine_blocks(101, None)?;
261261

262-
// Now let's test the gap limit. First of all get a chain of 10 addresses.
263-
let addresses = [
264-
"bcrt1qj9f7r8r3p2y0sqf4r3r62qysmkuh0fzep473d2ar7rcz64wqvhssjgf0z4",
265-
"bcrt1qmm5t0ch7vh2hryx9ctq3mswexcugqe4atkpkl2tetm8merqkthas3w7q30",
266-
"bcrt1qut9p7ej7l7lhyvekj28xknn8gnugtym4d5qvnp5shrsr4nksmfqsmyn87g",
267-
"bcrt1qqz0xtn3m235p2k96f5wa2dqukg6shxn9n3txe8arlrhjh5p744hsd957ww",
268-
"bcrt1q9c0t62a8l6wfytmf2t9lfj35avadk3mm8g4p3l84tp6rl66m48sqrme7wu",
269-
"bcrt1qkmh8yrk2v47cklt8dytk8f3ammcwa4q7dzattedzfhqzvfwwgyzsg59zrh",
270-
"bcrt1qvgrsrzy07gjkkfr5luplt0azxtfwmwq5t62gum5jr7zwcvep2acs8hhnp2",
271-
"bcrt1qw57edarcg50ansq8mk3guyrk78rk0fwvrds5xvqeupteu848zayq549av8",
272-
"bcrt1qvtve5ekf6e5kzs68knvnt2phfw6a0yjqrlgat392m6zt9jsvyxhqfx67ef",
273-
"bcrt1qw03ddumfs9z0kcu76ln7jrjfdwam20qtffmkcral3qtza90sp9kqm787uk",
274-
];
275-
let addresses: Vec<_> = addresses
276-
.into_iter()
277-
.map(|s| Address::from_str(s).unwrap().assume_checked())
278-
.collect();
262+
let addresses = common::test_addresses();
279263
let spks: Vec<_> = addresses
280264
.iter()
281265
.enumerate()
@@ -369,3 +353,47 @@ pub async fn test_async_update_tx_graph_stop_gap() -> anyhow::Result<()> {
369353

370354
Ok(())
371355
}
356+
357+
/// Test that `full_scan` always scans the revealed range before applying `stop_gap`.
358+
#[tokio::test]
359+
pub async fn test_async_stop_gap_past_last_revealed() -> anyhow::Result<()> {
360+
let env = TestEnv::new()?;
361+
let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
362+
let client = Builder::new(base_url.as_str()).build_async()?;
363+
let _block_hashes = env.mine_blocks(101, None)?;
364+
365+
let addresses = common::test_addresses();
366+
let spks: Vec<_> = addresses
367+
.iter()
368+
.enumerate()
369+
.map(|(i, addr)| (i as u32, addr.script_pubkey()))
370+
.collect();
371+
372+
// Receive coins beyond stop_gap of 3
373+
let txid_last_addr = env
374+
.bitcoind
375+
.client
376+
.send_to_address(&addresses[9], Amount::from_sat(10000))?
377+
.txid()?;
378+
let _block_hashes = env.mine_blocks(1, None)?;
379+
while client.get_height().await.unwrap() < 103 {
380+
sleep(Duration::from_millis(10))
381+
}
382+
383+
let cp_tip = env.make_checkpoint_tip();
384+
385+
// the scan covers 0..=9 despite stop_gap=3
386+
let request = FullScanRequest::builder()
387+
.chain_tip(cp_tip.clone())
388+
.spks_for_keychain(0, spks.clone())
389+
.last_revealed_for_keychain(0, 9);
390+
let response = client.full_scan(request, 3, 1).await?;
391+
392+
assert_eq!(
393+
response.tx_update.txs.first().unwrap().compute_txid(),
394+
txid_last_addr
395+
);
396+
assert_eq!(response.last_active_indices[&0], 9);
397+
398+
Ok(())
399+
}

0 commit comments

Comments
 (0)