• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bitcoindevkit / bdk / 9581697888

19 Jun 2024 12:08PM UTC coverage: 83.462% (+0.2%) from 83.312%
9581697888

Pull #1478

github

web-flow
Merge 8da3fab4d into 054380178
Pull Request #1478: Make `bdk_esplora` more modular

358 of 422 new or added lines in 2 files covered. (84.83%)

214 existing lines in 7 files now uncovered.

11229 of 13454 relevant lines covered (83.46%)

16367.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.24
/crates/esplora/src/blocking_ext.rs
1
use std::collections::BTreeSet;
2
use std::thread::JoinHandle;
3
use std::usize;
4

5
use bdk_chain::collections::BTreeMap;
6
use bdk_chain::spk_client::{FullScanRequest, FullScanResult, SyncRequest, SyncResult};
7
use bdk_chain::{
8
    bitcoin::{Amount, BlockHash, OutPoint, ScriptBuf, TxOut, Txid},
9
    local_chain::CheckPoint,
10
    BlockId, ConfirmationTimeHeightAnchor, TxGraph,
11
};
12
use bdk_chain::{Anchor, Indexed};
13
use esplora_client::{OutputStatus, Tx, TxStatus};
14

15
use crate::anchor_from_status;
16

17
/// [`esplora_client::Error`] wrapped in a [`Box`].
18
pub type Error = Box<esplora_client::Error>;
19

20
/// Trait to extend the functionality of [`esplora_client::BlockingClient`].
21
///
22
/// Refer to [crate-level documentation](crate) for more.
23
pub trait EsploraExt {
24
    /// Scan keychain scripts for transactions against Esplora, returning an update that can be
25
    /// applied to the receiving structures.
26
    ///
27
    /// `request` provides the data required to perform a script-pubkey-based full scan
28
    /// (see [`FullScanRequest`]). The full scan for each keychain (`K`) stops after a gap of
29
    /// `stop_gap` script pubkeys with no associated transactions. `parallel_requests` specifies
30
    /// the maximum number of HTTP requests to make in parallel.
31
    ///
32
    /// Refer to [crate-level docs](crate) for more.
33
    fn full_scan<K: Ord + Clone>(
34
        &self,
35
        request: FullScanRequest<K>,
36
        stop_gap: usize,
37
        parallel_requests: usize,
38
    ) -> Result<FullScanResult<K>, Error>;
39

40
    /// Sync a set of scripts, txids, and/or outpoints against Esplora.
41
    ///
42
    /// `request` provides the data required to perform a script-pubkey-based sync (see
43
    /// [`SyncRequest`]). `parallel_requests` specifies the maximum number of HTTP requests to make
44
    /// in parallel.
45
    ///
46
    /// Refer to [crate-level docs](crate) for more.
47
    fn sync(&self, request: SyncRequest, parallel_requests: usize) -> Result<SyncResult, Error>;
48

49
    /// Populate the `tx_graph` with transactions and associated [`ConfirmationTimeHeightAnchor`]s
50
    /// by scanning `keychain_spks` against Esplora.
51
    ///
52
    /// `keychain_spks` is an *unbounded* indexed-[`ScriptBuf`] iterator that represents scripts
53
    /// derived from a keychain. The scanning logic stops after a `stop_gap` number of consecutive
54
    /// scripts with no transaction history is reached. `parallel_requests` specifies the maximum
55
    /// number of HTTP requests to make in parallel.
56
    ///
57
    /// The last active keychain index (if any) is returned. This is the keychain index of the last
58
    /// script that contains a non-empty transaction history.
59
    ///
60
    /// Refer to [crate-level docs](crate) for more.
61
    fn populate_using_keychain_spks<I: Iterator<Item = Indexed<ScriptBuf>>>(
62
        &self,
63
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
64
        keychain_spks: I,
65
        stop_gap: usize,
66
        parallel_requests: usize,
67
    ) -> Result<Option<u32>, Error>;
68

69
    /// Populate the `tx_graph` with transactions and associated [`ConfirmationTimeHeightAnchor`]s
70
    /// by scanning `spks` against Esplora.
71
    ///
72
    /// Unlike with [`EsploraExt::populate_using_keychain_spks`], `spks` must be *bounded* as all
73
    /// contained scripts will be scanned. `parallel_requests` specifies the maximum number of HTTP
74
    /// requests to make in parallel.
75
    ///
76
    /// Refer to [crate-level docs](crate) for more.
77
    fn populate_using_spks<I: IntoIterator<Item = ScriptBuf>>(
78
        &self,
79
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
80
        spks: I,
81
        parallel_requests: usize,
82
    ) -> Result<(), Error>
83
    where
84
        I::IntoIter: ExactSizeIterator;
85

86
    /// Populate the `tx_graph` with transactions and associated [`ConfirmationTimeHeightAnchor`]s
87
    /// by scanning `txids` against Esplora.
88
    ///
89
    /// `parallel_requests` specifies the maximum number of HTTP requests to make in parallel.
90
    ///
91
    /// Refer to [crate-level docs](crate) for more.
92
    fn populate_using_txids<I: IntoIterator<Item = Txid>>(
93
        &self,
94
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
95
        txids: I,
96
        parallel_requests: usize,
97
    ) -> Result<(), Error>
98
    where
99
        I::IntoIter: ExactSizeIterator;
100

101
    /// Populate the `tx_graph` with residing and spending transactions and
102
    /// [`ConfirmationTimeHeightAnchor`]s by scanning `outpoints` against Esplora.
103
    ///
104
    /// `parallel_requests` specifies the maximum number of HTTP requests to make in parallel.
105
    ///
106
    /// Refer to [crate-level docs](crate) for more.
107
    fn populate_using_outpoints<I: IntoIterator<Item = OutPoint>>(
108
        &self,
109
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
110
        outpoints: I,
111
        parallel_requests: usize,
112
    ) -> Result<(), Error>
113
    where
114
        I::IntoIter: ExactSizeIterator;
115
}
116

117
impl EsploraExt for esplora_client::BlockingClient {
118
    fn full_scan<K: Ord + Clone>(
4✔
119
        &self,
4✔
120
        request: FullScanRequest<K>,
4✔
121
        stop_gap: usize,
4✔
122
        parallel_requests: usize,
4✔
123
    ) -> Result<FullScanResult<K>, Error> {
4✔
124
        let latest_blocks = fetch_latest_blocks(self)?;
4✔
125
        let mut graph_update = TxGraph::default();
4✔
126
        let mut last_active_indices = BTreeMap::<K, u32>::new();
4✔
127
        for (keychain, keychain_spks) in request.spks_by_keychain {
8✔
128
            if let Some(last_active_index) = self.populate_using_keychain_spks(
4✔
129
                &mut graph_update,
4✔
130
                keychain_spks,
4✔
131
                stop_gap,
4✔
132
                parallel_requests,
4✔
133
            )? {
4✔
134
                last_active_indices.insert(keychain, last_active_index);
3✔
135
            };
3✔
136
        }
137
        let chain_update = chain_update(
4✔
138
            self,
4✔
139
            &latest_blocks,
4✔
140
            &request.chain_tip,
4✔
141
            graph_update.all_anchors(),
4✔
142
        )?;
4✔
143
        Ok(FullScanResult {
4✔
144
            chain_update,
4✔
145
            graph_update,
4✔
146
            last_active_indices,
4✔
147
        })
4✔
148
    }
4✔
149

150
    fn sync(&self, request: SyncRequest, parallel_requests: usize) -> Result<SyncResult, Error> {
2✔
151
        let latest_blocks = fetch_latest_blocks(self)?;
2✔
152
        let mut graph_update = TxGraph::default();
2✔
153
        self.populate_using_spks(&mut graph_update, request.spks, parallel_requests)?;
2✔
154
        self.populate_using_txids(&mut graph_update, request.txids, parallel_requests)?;
2✔
155
        self.populate_using_outpoints(&mut graph_update, request.outpoints, parallel_requests)?;
2✔
156
        let chain_update = chain_update(
2✔
157
            self,
2✔
158
            &latest_blocks,
2✔
159
            &request.chain_tip,
2✔
160
            graph_update.all_anchors(),
2✔
161
        )?;
2✔
162
        Ok(SyncResult {
2✔
163
            chain_update,
2✔
164
            graph_update,
2✔
165
        })
2✔
166
    }
2✔
167

168
    fn populate_using_keychain_spks<S: Iterator<Item = Indexed<ScriptBuf>>>(
6✔
169
        &self,
6✔
170
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
6✔
171
        mut keychain_spks: S,
6✔
172
        stop_gap: usize,
6✔
173
        parallel_requests: usize,
6✔
174
    ) -> Result<Option<u32>, Error> {
6✔
175
        type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>);
6✔
176

6✔
177
        let mut last_index = Option::<u32>::None;
6✔
178
        let mut last_active_index = Option::<u32>::None;
6✔
179

180
        loop {
37✔
181
            let handles = keychain_spks
37✔
182
                .by_ref()
37✔
183
                .take(parallel_requests)
37✔
184
                .map(|(spk_index, spk)| {
37✔
185
                    std::thread::spawn({
34✔
186
                        let client = self.clone();
34✔
187
                        move || -> Result<TxsOfSpkIndex, Error> {
34✔
188
                            let mut last_seen = None;
34✔
189
                            let mut spk_txs = Vec::new();
34✔
190
                            loop {
191
                                let txs = client.scripthash_txs(&spk, last_seen)?;
34✔
192
                                let tx_count = txs.len();
34✔
193
                                last_seen = txs.last().map(|tx| tx.txid);
34✔
194
                                spk_txs.extend(txs);
34✔
195
                                if tx_count < 25 {
34✔
196
                                    break Ok((spk_index, spk_txs));
34✔
NEW
UNCOV
197
                                }
×
198
                            }
199
                        }
34✔
200
                    })
34✔
201
                })
37✔
202
                .collect::<Vec<JoinHandle<Result<TxsOfSpkIndex, Error>>>>();
37✔
203

37✔
204
            if handles.is_empty() {
37✔
205
                break;
3✔
206
            }
34✔
207

208
            for handle in handles {
68✔
209
                let (index, txs) = handle.join().expect("thread must not panic")?;
34✔
210
                last_index = Some(index);
34✔
211
                if !txs.is_empty() {
34✔
212
                    last_active_index = Some(index);
8✔
213
                }
28✔
214
                for tx in txs {
42✔
215
                    let _ = tx_graph.insert_tx(tx.to_tx());
8✔
216
                    if let Some(anchor) = anchor_from_status(&tx.status) {
8✔
217
                        let _ = tx_graph.insert_anchor(tx.txid, anchor);
8✔
218
                    }
8✔
219

220
                    let previous_outputs = tx.vin.iter().filter_map(|vin| {
8✔
221
                        let prevout = vin.prevout.as_ref()?;
8✔
222
                        Some((
8✔
223
                            OutPoint {
8✔
224
                                txid: vin.txid,
8✔
225
                                vout: vin.vout,
8✔
226
                            },
8✔
227
                            TxOut {
8✔
228
                                script_pubkey: prevout.scriptpubkey.clone(),
8✔
229
                                value: Amount::from_sat(prevout.value),
8✔
230
                            },
8✔
231
                        ))
8✔
232
                    });
8✔
233

234
                    for (outpoint, txout) in previous_outputs {
16✔
235
                        let _ = tx_graph.insert_txout(outpoint, txout);
8✔
236
                    }
8✔
237
                }
238
            }
239

240
            let last_index = last_index.expect("Must be set since handles wasn't empty.");
34✔
241
            let gap_limit_reached = if let Some(i) = last_active_index {
34✔
242
                last_index >= i.saturating_add(stop_gap as u32)
22✔
243
            } else {
244
                last_index + 1 >= stop_gap as u32
12✔
245
            };
246
            if gap_limit_reached {
34✔
247
                break;
3✔
248
            }
31✔
249
        }
250

251
        Ok(last_active_index)
6✔
252
    }
6✔
253

254
    fn populate_using_spks<I: IntoIterator<Item = ScriptBuf>>(
2✔
255
        &self,
2✔
256
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
2✔
257
        spks: I,
2✔
258
        parallel_requests: usize,
2✔
259
    ) -> Result<(), Error>
2✔
260
    where
2✔
261
        I::IntoIter: ExactSizeIterator,
2✔
262
    {
2✔
263
        self.populate_using_keychain_spks(
2✔
264
            tx_graph,
2✔
265
            spks.into_iter().enumerate().map(|(i, spk)| (i as u32, spk)),
4✔
266
            usize::MAX,
2✔
267
            parallel_requests,
2✔
268
        )
2✔
269
        .map(|_| ())
2✔
270
    }
2✔
271

272
    fn populate_using_txids<I: IntoIterator<Item = Txid>>(
6✔
273
        &self,
6✔
274
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
6✔
275
        txids: I,
6✔
276
        parallel_requests: usize,
6✔
277
    ) -> Result<(), Error>
6✔
278
    where
6✔
279
        I::IntoIter: ExactSizeIterator,
6✔
280
    {
6✔
281
        enum EsploraResp {
6✔
282
            TxStatus(TxStatus),
6✔
283
            Tx(Option<Tx>),
6✔
284
        }
6✔
285

6✔
286
        let mut txids = txids.into_iter();
6✔
287
        loop {
6✔
288
            let handles = txids
6✔
289
                .by_ref()
6✔
290
                .take(parallel_requests)
6✔
291
                .map(|txid| {
6✔
NEW
292
                    let client = self.clone();
×
NEW
293
                    let tx_already_exists = tx_graph.get_tx(txid).is_some();
×
NEW
294
                    std::thread::spawn(move || {
×
NEW
UNCOV
295
                        if tx_already_exists {
×
NEW
UNCOV
296
                            client
×
NEW
UNCOV
297
                                .get_tx_status(&txid)
×
NEW
298
                                .map_err(Box::new)
×
NEW
299
                                .map(|s| (txid, EsploraResp::TxStatus(s)))
×
300
                        } else {
NEW
301
                            client
×
NEW
302
                                .get_tx_info(&txid)
×
NEW
303
                                .map_err(Box::new)
×
NEW
304
                                .map(|t| (txid, EsploraResp::Tx(t)))
×
305
                        }
NEW
UNCOV
306
                    })
×
307
                })
6✔
308
                .collect::<Vec<JoinHandle<Result<(Txid, EsploraResp), Error>>>>();
6✔
309

6✔
310
            if handles.is_empty() {
6✔
311
                break;
6✔
NEW
312
            }
×
313

NEW
314
            for handle in handles {
×
NEW
315
                let (txid, resp) = handle.join().expect("thread must not panic")?;
×
NEW
316
                match resp {
×
NEW
317
                    EsploraResp::TxStatus(status) => {
×
NEW
318
                        if let Some(anchor) = anchor_from_status(&status) {
×
NEW
319
                            let _ = tx_graph.insert_anchor(txid, anchor);
×
NEW
320
                        }
×
321
                    }
NEW
322
                    EsploraResp::Tx(Some(tx_info)) => {
×
NEW
UNCOV
323
                        let _ = tx_graph.insert_tx(tx_info.to_tx());
×
NEW
UNCOV
324
                        if let Some(anchor) = anchor_from_status(&tx_info.status) {
×
NEW
325
                            let _ = tx_graph.insert_anchor(txid, anchor);
×
NEW
UNCOV
326
                        }
×
327
                    }
NEW
UNCOV
328
                    _ => continue,
×
329
                }
330
            }
331
        }
332
        Ok(())
6✔
333
    }
6✔
334

335
    fn populate_using_outpoints<I: IntoIterator<Item = OutPoint>>(
2✔
336
        &self,
2✔
337
        tx_graph: &mut TxGraph<ConfirmationTimeHeightAnchor>,
2✔
338
        outpoints: I,
2✔
339
        parallel_requests: usize,
2✔
340
    ) -> Result<(), Error>
2✔
341
    where
2✔
342
        I::IntoIter: ExactSizeIterator,
2✔
343
    {
2✔
344
        let outpoints = outpoints.into_iter().collect::<Vec<_>>();
2✔
345

2✔
346
        // make sure txs exists in graph and tx statuses are updated
2✔
347
        // TODO: We should maintain a tx cache (like we do with Electrum).
2✔
348
        self.populate_using_txids(
2✔
349
            tx_graph,
2✔
350
            outpoints.iter().map(|op| op.txid),
2✔
351
            parallel_requests,
2✔
352
        )?;
2✔
353

354
        // get outpoint spend-statuses
355
        let mut outpoints = outpoints.into_iter();
2✔
356
        let mut missing_txs = Vec::<Txid>::with_capacity(outpoints.len());
2✔
357
        loop {
2✔
358
            let handles = outpoints
2✔
359
                .by_ref()
2✔
360
                .take(parallel_requests)
2✔
361
                .map(|op| {
2✔
NEW
362
                    let client = self.clone();
×
NEW
363
                    std::thread::spawn(move || {
×
NEW
364
                        client
×
NEW
365
                            .get_output_status(&op.txid, op.vout as _)
×
NEW
366
                            .map_err(Box::new)
×
NEW
367
                    })
×
368
                })
2✔
369
                .collect::<Vec<JoinHandle<Result<Option<OutputStatus>, Error>>>>();
2✔
370

2✔
371
            if handles.is_empty() {
2✔
372
                break;
2✔
NEW
373
            }
×
374

NEW
UNCOV
375
            for handle in handles {
×
NEW
UNCOV
376
                if let Some(op_status) = handle.join().expect("thread must not panic")? {
×
NEW
UNCOV
377
                    let spend_txid = match op_status.txid {
×
NEW
378
                        Some(txid) => txid,
×
NEW
UNCOV
379
                        None => continue,
×
380
                    };
NEW
381
                    if tx_graph.get_tx(spend_txid).is_none() {
×
NEW
382
                        missing_txs.push(spend_txid);
×
NEW
383
                    }
×
NEW
384
                    if let Some(spend_status) = op_status.status {
×
NEW
385
                        if let Some(spend_anchor) = anchor_from_status(&spend_status) {
×
NEW
386
                            let _ = tx_graph.insert_anchor(spend_txid, spend_anchor);
×
NEW
387
                        }
×
NEW
388
                    }
×
NEW
389
                }
×
390
            }
391
        }
392

393
        self.populate_using_txids(tx_graph, missing_txs, parallel_requests)?;
2✔
394
        Ok(())
2✔
395
    }
2✔
396
}
397

398
/// Fetch latest blocks from Esplora in an atomic call.
399
///
400
/// We want to do this before fetching transactions and anchors as we cannot fetch latest blocks AND
401
/// transactions atomically, and the checkpoint tip is used to determine last-scanned block (for
402
/// block-based chain-sources). Therefore it's better to be conservative when setting the tip (use
403
/// an earlier tip rather than a later tip) otherwise the caller may accidentally skip blocks when
404
/// alternating between chain-sources.
405
fn fetch_latest_blocks(
28✔
406
    client: &esplora_client::BlockingClient,
28✔
407
) -> Result<BTreeMap<u32, BlockHash>, Error> {
28✔
408
    Ok(client
28✔
409
        .get_blocks(None)?
28✔
410
        .into_iter()
28✔
411
        .map(|b| (b.time.height, b.id))
280✔
412
        .collect())
28✔
413
}
28✔
414

415
/// Used instead of [`esplora_client::BlockingClient::get_block_hash`].
416
///
417
/// This first checks the previously fetched `latest_blocks` before fetching from Esplora again.
418
fn fetch_block(
66✔
419
    client: &esplora_client::BlockingClient,
66✔
420
    latest_blocks: &BTreeMap<u32, BlockHash>,
66✔
421
    height: u32,
66✔
422
) -> Result<Option<BlockHash>, Error> {
66✔
423
    if let Some(&hash) = latest_blocks.get(&height) {
66✔
424
        return Ok(Some(hash));
17✔
425
    }
49✔
426

49✔
427
    // We avoid fetching blocks higher than previously fetched `latest_blocks` as the local chain
49✔
428
    // tip is used to signal for the last-synced-up-to-height.
49✔
429
    let &tip_height = latest_blocks
49✔
430
        .keys()
49✔
431
        .last()
49✔
432
        .expect("must have atleast one entry");
49✔
433
    if height > tip_height {
49✔
434
        return Ok(None);
×
435
    }
49✔
436

49✔
437
    Ok(Some(client.get_block_hash(height)?))
49✔
438
}
66✔
439

440
/// Create the [`local_chain::Update`].
441
///
442
/// We want to have a corresponding checkpoint per anchor height. However, checkpoints fetched
443
/// should not surpass `latest_blocks`.
444
fn chain_update<A: Anchor>(
28✔
445
    client: &esplora_client::BlockingClient,
28✔
446
    latest_blocks: &BTreeMap<u32, BlockHash>,
28✔
447
    local_tip: &CheckPoint,
28✔
448
    anchors: &BTreeSet<(A, Txid)>,
28✔
449
) -> Result<CheckPoint, Error> {
28✔
450
    let mut point_of_agreement = None;
28✔
451
    let mut conflicts = vec![];
28✔
452
    for local_cp in local_tip.iter() {
37✔
453
        let remote_hash = match fetch_block(client, latest_blocks, local_cp.height())? {
37✔
454
            Some(hash) => hash,
37✔
455
            None => continue,
×
456
        };
457
        if remote_hash == local_cp.hash() {
37✔
458
            point_of_agreement = Some(local_cp.clone());
28✔
459
            break;
28✔
460
        } else {
9✔
461
            // it is not strictly necessary to include all the conflicted heights (we do need the
9✔
462
            // first one) but it seems prudent to make sure the updated chain's heights are a
9✔
463
            // superset of the existing chain after update.
9✔
464
            conflicts.push(BlockId {
9✔
465
                height: local_cp.height(),
9✔
466
                hash: remote_hash,
9✔
467
            });
9✔
468
        }
9✔
469
    }
470

471
    let mut tip = point_of_agreement.expect("remote esplora should have same genesis block");
28✔
472

28✔
473
    tip = tip
28✔
474
        .extend(conflicts.into_iter().rev())
28✔
475
        .expect("evicted are in order");
28✔
476

477
    for anchor in anchors {
67✔
478
        let height = anchor.0.anchor_block().height;
39✔
479
        if tip.get(height).is_none() {
39✔
480
            let hash = match fetch_block(client, latest_blocks, height)? {
29✔
481
                Some(hash) => hash,
29✔
UNCOV
482
                None => continue,
×
483
            };
484
            tip = tip.insert(BlockId { height, hash });
29✔
485
        }
10✔
486
    }
487

488
    // insert the most recent blocks at the tip to make sure we update the tip and make the update
489
    // robust.
490
    for (&height, &hash) in latest_blocks.iter() {
280✔
491
        tip = tip.insert(BlockId { height, hash });
280✔
492
    }
280✔
493

494
    Ok(tip)
28✔
495
}
28✔
496

497
#[cfg(test)]
498
mod test {
499
    use crate::blocking_ext::{chain_update, fetch_latest_blocks};
500
    use bdk_chain::bitcoin::hashes::Hash;
501
    use bdk_chain::bitcoin::Txid;
502
    use bdk_chain::local_chain::LocalChain;
503
    use bdk_chain::BlockId;
504
    use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv};
505
    use esplora_client::{BlockHash, Builder};
506
    use std::collections::{BTreeMap, BTreeSet};
507
    use std::time::Duration;
508

509
    macro_rules! h {
510
        ($index:literal) => {{
511
            bdk_chain::bitcoin::hashes::Hash::hash($index.as_bytes())
512
        }};
513
    }
514

515
    macro_rules! local_chain {
516
        [ $(($height:expr, $block_hash:expr)), * ] => {{
517
            #[allow(unused_mut)]
518
            bdk_chain::local_chain::LocalChain::from_blocks([$(($height, $block_hash).into()),*].into_iter().collect())
519
                .expect("chain must have genesis block")
520
        }};
521
    }
522

523
    /// Ensure that update does not remove heights (from original), and all anchor heights are included.
524
    #[test]
525
    pub fn test_finalize_chain_update() -> anyhow::Result<()> {
1✔
526
        struct TestCase<'a> {
1✔
527
            name: &'a str,
1✔
528
            /// Initial blockchain height to start the env with.
1✔
529
            initial_env_height: u32,
1✔
530
            /// Initial checkpoint heights to start with in the local chain.
1✔
531
            initial_cps: &'a [u32],
1✔
532
            /// The final blockchain height of the env.
1✔
533
            final_env_height: u32,
1✔
534
            /// The anchors to test with: `(height, txid)`. Only the height is provided as we can fetch
1✔
535
            /// the blockhash from the env.
1✔
536
            anchors: &'a [(u32, Txid)],
1✔
537
        }
1✔
538

1✔
539
        let test_cases = [
1✔
540
            TestCase {
1✔
541
                name: "chain_extends",
1✔
542
                initial_env_height: 60,
1✔
543
                initial_cps: &[59, 60],
1✔
544
                final_env_height: 90,
1✔
545
                anchors: &[],
1✔
546
            },
1✔
547
            TestCase {
1✔
548
                name: "introduce_older_heights",
1✔
549
                initial_env_height: 50,
1✔
550
                initial_cps: &[10, 15],
1✔
551
                final_env_height: 50,
1✔
552
                anchors: &[(11, h!("A")), (14, h!("B"))],
1✔
553
            },
1✔
554
            TestCase {
1✔
555
                name: "introduce_older_heights_after_chain_extends",
1✔
556
                initial_env_height: 50,
1✔
557
                initial_cps: &[10, 15],
1✔
558
                final_env_height: 100,
1✔
559
                anchors: &[(11, h!("A")), (14, h!("B"))],
1✔
560
            },
1✔
561
        ];
1✔
562

563
        for (i, t) in test_cases.into_iter().enumerate() {
3✔
564
            println!("[{}] running test case: {}", i, t.name);
3✔
565

566
            let env = TestEnv::new()?;
3✔
567
            let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
3✔
568
            let client = Builder::new(base_url.as_str()).build_blocking();
3✔
569

570
            // set env to `initial_env_height`
571
            if let Some(to_mine) = t
3✔
572
                .initial_env_height
3✔
573
                .checked_sub(env.make_checkpoint_tip().height())
3✔
574
            {
575
                env.mine_blocks(to_mine as _, None)?;
3✔
UNCOV
576
            }
×
577
            while client.get_height()? < t.initial_env_height {
1,302✔
578
                std::thread::sleep(Duration::from_millis(10));
1,299✔
579
            }
1,299✔
580

581
            // craft initial `local_chain`
582
            let local_chain = {
3✔
583
                let (mut chain, _) = LocalChain::from_genesis_hash(env.genesis_hash()?);
3✔
584
                // force `chain_update_blocking` to add all checkpoints in `t.initial_cps`
585
                let anchors = t
3✔
586
                    .initial_cps
3✔
587
                    .iter()
3✔
588
                    .map(|&height| -> anyhow::Result<_> {
6✔
589
                        Ok((
6✔
590
                            BlockId {
6✔
591
                                height,
6✔
592
                                hash: env.bitcoind.client.get_block_hash(height as _)?,
6✔
593
                            },
594
                            Txid::all_zeros(),
6✔
595
                        ))
596
                    })
6✔
597
                    .collect::<anyhow::Result<BTreeSet<_>>>()?;
3✔
598
                let update = chain_update(
3✔
599
                    &client,
3✔
600
                    &fetch_latest_blocks(&client)?,
3✔
601
                    &chain.tip(),
3✔
602
                    &anchors,
3✔
UNCOV
603
                )?;
×
604
                chain.apply_update(update)?;
3✔
605
                chain
3✔
606
            };
3✔
607
            println!("local chain height: {}", local_chain.tip().height());
3✔
608

609
            // extend env chain
610
            if let Some(to_mine) = t
3✔
611
                .final_env_height
3✔
612
                .checked_sub(env.make_checkpoint_tip().height())
3✔
613
            {
614
                env.mine_blocks(to_mine as _, None)?;
3✔
UNCOV
615
            }
×
616
            while client.get_height()? < t.final_env_height {
961✔
617
                std::thread::sleep(Duration::from_millis(10));
958✔
618
            }
958✔
619

620
            // craft update
621
            let update = {
3✔
622
                let anchors = t
3✔
623
                    .anchors
3✔
624
                    .iter()
3✔
625
                    .map(|&(height, txid)| -> anyhow::Result<_> {
4✔
626
                        Ok((
4✔
627
                            BlockId {
4✔
628
                                height,
4✔
629
                                hash: env.bitcoind.client.get_block_hash(height as _)?,
4✔
630
                            },
631
                            txid,
4✔
632
                        ))
633
                    })
4✔
634
                    .collect::<anyhow::Result<_>>()?;
3✔
635
                chain_update(
636
                    &client,
3✔
637
                    &fetch_latest_blocks(&client)?,
3✔
638
                    &local_chain.tip(),
3✔
639
                    &anchors,
3✔
UNCOV
640
                )?
×
641
            };
642

643
            // apply update
644
            let mut updated_local_chain = local_chain.clone();
3✔
645
            updated_local_chain.apply_update(update)?;
3✔
646
            println!(
3✔
647
                "updated local chain height: {}",
3✔
648
                updated_local_chain.tip().height()
3✔
649
            );
3✔
650

3✔
651
            assert!(
3✔
652
                {
3✔
653
                    let initial_heights = local_chain
3✔
654
                        .iter_checkpoints()
3✔
655
                        .map(|cp| cp.height())
37✔
656
                        .collect::<BTreeSet<_>>();
3✔
657
                    let updated_heights = updated_local_chain
3✔
658
                        .iter_checkpoints()
3✔
659
                        .map(|cp| cp.height())
61✔
660
                        .collect::<BTreeSet<_>>();
3✔
661
                    updated_heights.is_superset(&initial_heights)
3✔
662
                },
UNCOV
663
                "heights from the initial chain must all be in the updated chain",
×
664
            );
665

666
            assert!(
3✔
667
                {
3✔
668
                    let exp_anchor_heights = t
3✔
669
                        .anchors
3✔
670
                        .iter()
3✔
671
                        .map(|(h, _)| *h)
4✔
672
                        .chain(t.initial_cps.iter().copied())
3✔
673
                        .collect::<BTreeSet<_>>();
3✔
674
                    let anchor_heights = updated_local_chain
3✔
675
                        .iter_checkpoints()
3✔
676
                        .map(|cp| cp.height())
61✔
677
                        .collect::<BTreeSet<_>>();
3✔
678
                    anchor_heights.is_superset(&exp_anchor_heights)
3✔
679
                },
UNCOV
680
                "anchor heights must all be in updated chain",
×
681
            );
682
        }
683

684
        Ok(())
1✔
685
    }
1✔
686

687
    #[test]
688
    fn update_local_chain() -> anyhow::Result<()> {
1✔
689
        const TIP_HEIGHT: u32 = 50;
690

691
        let env = TestEnv::new()?;
1✔
692
        let blocks = {
1✔
693
            let bitcoind_client = &env.bitcoind.client;
1✔
694
            assert_eq!(bitcoind_client.get_block_count()?, 1);
1✔
695
            [
696
                (0, bitcoind_client.get_block_hash(0)?),
1✔
697
                (1, bitcoind_client.get_block_hash(1)?),
1✔
698
            ]
699
            .into_iter()
1✔
700
            .chain((2..).zip(env.mine_blocks((TIP_HEIGHT - 1) as usize, None)?))
1✔
701
            .collect::<BTreeMap<_, _>>()
1✔
702
        };
703
        // so new blocks can be seen by Electrs
704
        let env = env.reset_electrsd()?;
1✔
705
        let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
1✔
706
        let client = Builder::new(base_url.as_str()).build_blocking();
1✔
707

1✔
708
        struct TestCase {
1✔
709
            name: &'static str,
1✔
710
            /// Original local chain to start off with.
1✔
711
            chain: LocalChain,
1✔
712
            /// Heights of floating anchors. [`chain_update_blocking`] will request for checkpoints
1✔
713
            /// of these heights.
1✔
714
            request_heights: &'static [u32],
1✔
715
            /// The expected local chain result (heights only).
1✔
716
            exp_update_heights: &'static [u32],
1✔
717
        }
1✔
718

1✔
719
        let test_cases = [
1✔
720
            TestCase {
1✔
721
                name: "request_later_blocks",
1✔
722
                chain: local_chain![(0, blocks[&0]), (21, blocks[&21])],
1✔
723
                request_heights: &[22, 25, 28],
1✔
724
                exp_update_heights: &[21, 22, 25, 28],
1✔
725
            },
1✔
726
            TestCase {
1✔
727
                name: "request_prev_blocks",
1✔
728
                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (5, blocks[&5])],
1✔
729
                request_heights: &[4],
1✔
730
                exp_update_heights: &[4, 5],
1✔
731
            },
1✔
732
            TestCase {
1✔
733
                name: "request_prev_blocks_2",
1✔
734
                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (10, blocks[&10])],
1✔
735
                request_heights: &[4, 6],
1✔
736
                exp_update_heights: &[4, 6, 10],
1✔
737
            },
1✔
738
            TestCase {
1✔
739
                name: "request_later_and_prev_blocks",
1✔
740
                chain: local_chain![(0, blocks[&0]), (7, blocks[&7]), (11, blocks[&11])],
1✔
741
                request_heights: &[8, 9, 15],
1✔
742
                exp_update_heights: &[8, 9, 11, 15],
1✔
743
            },
1✔
744
            TestCase {
1✔
745
                name: "request_tip_only",
1✔
746
                chain: local_chain![(0, blocks[&0]), (5, blocks[&5]), (49, blocks[&49])],
1✔
747
                request_heights: &[TIP_HEIGHT],
1✔
748
                exp_update_heights: &[49],
1✔
749
            },
1✔
750
            TestCase {
1✔
751
                name: "request_nothing",
1✔
752
                chain: local_chain![(0, blocks[&0]), (13, blocks[&13]), (23, blocks[&23])],
1✔
753
                request_heights: &[],
1✔
754
                exp_update_heights: &[23],
1✔
755
            },
1✔
756
            TestCase {
1✔
757
                name: "request_nothing_during_reorg",
1✔
758
                chain: local_chain![(0, blocks[&0]), (13, blocks[&13]), (23, h!("23"))],
1✔
759
                request_heights: &[],
1✔
760
                exp_update_heights: &[13, 23],
1✔
761
            },
1✔
762
            TestCase {
1✔
763
                name: "request_nothing_during_reorg_2",
1✔
764
                chain: local_chain![
1✔
765
                    (0, blocks[&0]),
1✔
766
                    (21, blocks[&21]),
1✔
767
                    (22, h!("22")),
1✔
768
                    (23, h!("23"))
1✔
769
                ],
1✔
770
                request_heights: &[],
1✔
771
                exp_update_heights: &[21, 22, 23],
1✔
772
            },
1✔
773
            TestCase {
1✔
774
                name: "request_prev_blocks_during_reorg",
1✔
775
                chain: local_chain![
1✔
776
                    (0, blocks[&0]),
1✔
777
                    (21, blocks[&21]),
1✔
778
                    (22, h!("22")),
1✔
779
                    (23, h!("23"))
1✔
780
                ],
1✔
781
                request_heights: &[17, 20],
1✔
782
                exp_update_heights: &[17, 20, 21, 22, 23],
1✔
783
            },
1✔
784
            TestCase {
1✔
785
                name: "request_later_blocks_during_reorg",
1✔
786
                chain: local_chain![
1✔
787
                    (0, blocks[&0]),
1✔
788
                    (9, blocks[&9]),
1✔
789
                    (22, h!("22")),
1✔
790
                    (23, h!("23"))
1✔
791
                ],
1✔
792
                request_heights: &[25, 27],
1✔
793
                exp_update_heights: &[9, 22, 23, 25, 27],
1✔
794
            },
1✔
795
            TestCase {
1✔
796
                name: "request_later_blocks_during_reorg_2",
1✔
797
                chain: local_chain![(0, blocks[&0]), (9, h!("9"))],
1✔
798
                request_heights: &[10],
1✔
799
                exp_update_heights: &[0, 9, 10],
1✔
800
            },
1✔
801
            TestCase {
1✔
802
                name: "request_later_and_prev_blocks_during_reorg",
1✔
803
                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (9, h!("9"))],
1✔
804
                request_heights: &[8, 11],
1✔
805
                exp_update_heights: &[1, 8, 9, 11],
1✔
806
            },
1✔
807
        ];
1✔
808

809
        for (i, t) in test_cases.into_iter().enumerate() {
12✔
810
            println!("Case {}: {}", i, t.name);
12✔
811
            let mut chain = t.chain;
12✔
812

12✔
813
            let mock_anchors = t
12✔
814
                .request_heights
12✔
815
                .iter()
12✔
816
                .map(|&h| {
17✔
817
                    let anchor_blockhash: BlockHash = bdk_chain::bitcoin::hashes::Hash::hash(
17✔
818
                        &format!("hash_at_height_{}", h).into_bytes(),
17✔
819
                    );
17✔
820
                    let txid: Txid = bdk_chain::bitcoin::hashes::Hash::hash(
17✔
821
                        &format!("txid_at_height_{}", h).into_bytes(),
17✔
822
                    );
17✔
823
                    let anchor = BlockId {
17✔
824
                        height: h,
17✔
825
                        hash: anchor_blockhash,
17✔
826
                    };
17✔
827
                    (anchor, txid)
17✔
828
                })
17✔
829
                .collect::<BTreeSet<_>>();
12✔
830
            let chain_update = chain_update(
12✔
831
                &client,
12✔
832
                &fetch_latest_blocks(&client)?,
12✔
833
                &chain.tip(),
12✔
834
                &mock_anchors,
12✔
UNCOV
835
            )?;
×
836

837
            let update_blocks = chain_update
12✔
838
                .iter()
12✔
839
                .map(|cp| cp.block_id())
172✔
840
                .collect::<BTreeSet<_>>();
12✔
841

12✔
842
            let exp_update_blocks = t
12✔
843
                .exp_update_heights
12✔
844
                .iter()
12✔
845
                .map(|&height| {
37✔
846
                    let hash = blocks[&height];
37✔
847
                    BlockId { height, hash }
37✔
848
                })
37✔
849
                .chain(
12✔
850
                    // Electrs Esplora `get_block` call fetches 10 blocks which is included in the
12✔
851
                    // update
12✔
852
                    blocks
12✔
853
                        .range(TIP_HEIGHT - 9..)
12✔
854
                        .map(|(&height, &hash)| BlockId { height, hash }),
120✔
855
                )
12✔
856
                .collect::<BTreeSet<_>>();
12✔
857

12✔
858
            assert!(
12✔
859
                update_blocks.is_superset(&exp_update_blocks),
12✔
UNCOV
860
                "[{}:{}] unexpected update",
×
861
                i,
862
                t.name
863
            );
864

865
            let _ = chain
12✔
866
                .apply_update(chain_update)
12✔
867
                .unwrap_or_else(|err| panic!("[{}:{}] update failed to apply: {}", i, t.name, err));
12✔
868

869
            // all requested heights must exist in the final chain
870
            for height in t.request_heights {
29✔
871
                let exp_blockhash = blocks.get(height).expect("block must exist in bitcoind");
17✔
872
                assert_eq!(
17✔
873
                    chain.get(*height).map(|cp| cp.hash()),
17✔
874
                    Some(*exp_blockhash),
17✔
UNCOV
875
                    "[{}:{}] block {}:{} must exist in final chain",
×
876
                    i,
877
                    t.name,
878
                    height,
879
                    exp_blockhash
880
                );
881
            }
882
        }
883

884
        Ok(())
1✔
885
    }
1✔
886
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc