Skip to content

Commit 7ea8b63

Browse files
committed
Merge #2182: fix: full_scan covers revealed range before applying stop_gap
187b007 test(esplora): allow(dead_code) at file level in common (Noah Joeris) 79d1f45 fix: full_scan covers revealed range before applying stop_gap (Noah Joeris) Pull request description: Fixes #2057. `full_scan` now always covers the revealed range before applying the stop_gap ### Notes to the reviewers Replaces #2181. That PR unified full_scan and sync into one path; per @evanlinjin's feedback we're keeping them separate and doing the minimal fix here instead ### Changelog notice Fixed: full_scan now always scans the full revealed range before applying stop_gap past last_revealed. ### Checklists #### All Submissions: * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature #### Bugfixes: * [ ] This pull request breaks the existing API * [x] I've added tests to reproduce the issue which are now passing * [x] I'm linking the issue being fixed by this PR ACKs for top commit: evanlinjin: ACK 187b007 Tree-SHA512: 9899e5d857d3cc3e86d35fd0e6a49204d4029053893b98fe1d8eb2bbdb9ea85b204997d87a72c9d485544286a0107936f048e54a7c152ab66b2106c897a1d900
2 parents 8760d87 + 187b007 commit 7ea8b63

9 files changed

Lines changed: 278 additions & 69 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: 26 additions & 3 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
@@ -474,15 +486,17 @@ impl<K: Ord, D> FullScanRequestBuilder<K, D> {
474486
/// Data required to perform a spk-based blockchain client full scan.
475487
///
476488
/// A client full scan iterates through all the scripts for the given keychains, fetching relevant
477-
/// data until some stop gap number of scripts is found that have no data. This operation is
478-
/// generally only used when importing or restoring previously used keychains in which the list of
479-
/// used scripts is not known. The full scan process also updates the chain from the given
489+
/// data. It always scans the revealed range (up to the last-revealed index), then keeps going until
490+
/// a run of `stop_gap` consecutive scripts with no data is found. This operation is generally only
491+
/// used when importing or restoring previously used keychains in which the list of used scripts is
492+
/// not known. The full scan process also updates the chain from the given
480493
/// [`chain_tip`](FullScanRequestBuilder::chain_tip) (if provided).
481494
#[must_use]
482495
pub struct FullScanRequest<K, D = BlockHash> {
483496
start_time: u64,
484497
chain_tip: Option<CheckPoint<D>>,
485498
spks_by_keychain: BTreeMap<K, Box<dyn Iterator<Item = Indexed<ScriptBuf>> + Send>>,
499+
last_revealed: BTreeMap<K, u32>,
486500
inspect: Box<InspectFullScan<K>>,
487501
}
488502

@@ -507,6 +521,7 @@ impl<K: Ord + Clone, D> FullScanRequest<K, D> {
507521
start_time,
508522
chain_tip: None,
509523
spks_by_keychain: BTreeMap::new(),
524+
last_revealed: BTreeMap::new(),
510525
inspect: Box::new(|_, _, _| ()),
511526
},
512527
}
@@ -541,6 +556,14 @@ impl<K: Ord + Clone, D> FullScanRequest<K, D> {
541556
self.spks_by_keychain.keys().cloned().collect()
542557
}
543558

559+
/// Get the last revealed script pubkey index for `keychain` (if set).
560+
///
561+
/// Chain sources use this to scan `0..=last_revealed` before applying
562+
/// `stop_gap` to further discovery.
563+
pub fn last_revealed(&self, keychain: &K) -> Option<u32> {
564+
self.last_revealed.get(keychain).copied()
565+
}
566+
544567
/// Advances the full scan request and returns the next indexed [`ScriptBuf`] of the given
545568
/// `keychain`.
546569
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
@@ -136,6 +136,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
136136
let mut last_active_indices = BTreeMap::<K, u32>::default();
137137
let mut pending_anchors = Vec::new();
138138
for keychain in request.keychains() {
139+
let last_revealed = request.last_revealed(&keychain);
139140
let spks = request
140141
.iter_spks(keychain.clone())
141142
.map(|(spk_i, spk)| (spk_i, SpkWithExpectedTxids::from(spk)));
@@ -144,6 +145,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
144145
&mut tx_update,
145146
spks,
146147
stop_gap,
148+
last_revealed,
147149
batch_size,
148150
&mut pending_anchors,
149151
)? {
@@ -225,6 +227,7 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
225227
.enumerate()
226228
.map(|(i, spk)| (i as u32, spk)),
227229
usize::MAX,
230+
None,
228231
batch_size,
229232
&mut pending_anchors,
230233
)?;
@@ -273,12 +276,14 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
273276
/// Transactions that contains an output with requested spk, or spends form an output with
274277
/// requested spk will be added to `tx_update`. Anchors of the aforementioned transactions are
275278
/// also included.
279+
#[allow(clippy::too_many_arguments)]
276280
fn populate_with_spks(
277281
&self,
278282
start_time: u64,
279283
tx_update: &mut TxUpdate<ConfirmationBlockTime>,
280284
mut spks_with_expected_txids: impl Iterator<Item = (u32, SpkWithExpectedTxids)>,
281285
stop_gap: usize,
286+
last_revealed: Option<u32>,
282287
batch_size: usize,
283288
pending_anchors: &mut Vec<(Txid, usize)>,
284289
) -> Result<Option<u32>, Error> {
@@ -298,14 +303,16 @@ impl<E: ElectrumApi> BdkElectrumClient<E> {
298303
.batch_script_get_history(spks.iter().map(|(_, s)| s.spk.as_script()))?;
299304

300305
for ((spk_index, spk), spk_history) in spks.into_iter().zip(spk_histories) {
301-
if spk_history.is_empty() {
302-
match unused_spk_count.checked_add(1) {
303-
Some(i) if i < stop_gap => unused_spk_count = i,
304-
_ => return Ok(last_active_index),
305-
};
306-
} else {
306+
let beyond_revealed = last_revealed.is_none_or(|lr| spk_index > lr);
307+
308+
if !spk_history.is_empty() {
307309
last_active_index = Some(spk_index);
308310
unused_spk_count = 0;
311+
} else if beyond_revealed {
312+
unused_spk_count = unused_spk_count.saturating_add(1);
313+
if unused_spk_count >= stop_gap {
314+
return Ok(last_active_index);
315+
}
309316
}
310317

311318
let spk_history_set = spk_history

crates/electrum/tests/test_electrum.rs

Lines changed: 76 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,63 @@ pub fn test_update_tx_graph_stop_gap() -> anyhow::Result<()> {
490492
Ok(())
491493
}
492494

495+
/// Test that `full_scan` scans the revealed range before applying `stop_gap`, and that `stop_gap`
496+
/// is still enforced beyond `last_revealed`.
497+
///
498+
/// With a tx at index 9, a scan with `stop_gap=3` and `last_revealed=9` must find the tx.
499+
/// A scan with `stop_gap=3` and `last_revealed=3` must not return it
500+
/// as 9 is beyond `last_revealed + stop_gap`, so the scan stops first.
501+
#[test]
502+
pub fn test_stop_gap_past_last_revealed() -> anyhow::Result<()> {
503+
let env = TestEnv::new()?;
504+
let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?;
505+
let client = BdkElectrumClient::new(electrum_client);
506+
let _block_hashes = env.mine_blocks(101, None)?;
507+
508+
let addresses = test_addresses();
509+
let spks: Vec<_> = addresses
510+
.iter()
511+
.enumerate()
512+
.map(|(i, addr)| (i as u32, addr.script_pubkey()))
513+
.collect();
514+
515+
// Receive coins at index 9.
516+
let txid_last_addr = env
517+
.bitcoind
518+
.client
519+
.send_to_address(&addresses[9], Amount::from_sat(10000))?
520+
.txid()?;
521+
env.mine_blocks(1, None)?;
522+
env.wait_until_electrum_sees_block(Duration::from_secs(6))?;
523+
524+
let cp_tip = env.make_checkpoint_tip();
525+
526+
// Revealed range covers the tx: it must be found.
527+
let request = FullScanRequest::builder()
528+
.chain_tip(cp_tip.clone())
529+
.spks_for_keychain(0, spks.clone())
530+
.last_revealed_for_keychain(0, 9);
531+
let response = client.full_scan(request, 3, 1, false)?;
532+
533+
assert_eq!(
534+
response.tx_update.txs.first().unwrap().compute_txid(),
535+
txid_last_addr
536+
);
537+
assert_eq!(response.last_active_indices[&0], 9);
538+
539+
// Tx sits beyond `last_revealed + stop_gap`. So tx should not be found.
540+
let request = FullScanRequest::builder()
541+
.chain_tip(cp_tip)
542+
.spks_for_keychain(0, spks)
543+
.last_revealed_for_keychain(0, 3);
544+
let response = client.full_scan(request, 3, 1, false)?;
545+
546+
assert!(response.tx_update.txs.is_empty());
547+
assert!(response.last_active_indices.is_empty());
548+
549+
Ok(())
550+
}
551+
493552
/// Ensure that [`BdkElectrumClient::sync`] can confirm previously unconfirmed transactions in both
494553
/// reorg and no-reorg situations. After the transaction is confirmed after reorg, check if floating
495554
/// 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)

0 commit comments

Comments
 (0)