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

bitcoindevkit / bdk / 10028288592

21 Jul 2024 12:25PM UTC coverage: 83.573% (+0.1%) from 83.434%
10028288592

Pull #1478

github

web-flow
Merge 04b364869 into d99b3ef4b
Pull Request #1478: Make `bdk_esplora` more modular

336 of 398 new or added lines in 3 files covered. (84.42%)

190 existing lines in 6 files now uncovered.

11137 of 13326 relevant lines covered (83.57%)

16459.27 hits per line

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

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

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

14
use crate::{insert_anchor_from_status, insert_prevouts};
15

16
/// [`esplora_client::Error`]
17
pub type Error = Box<esplora_client::Error>;
18

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

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

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

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

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

98
    /// Fetch transactions and [`ConfirmationTimeHeightAnchor`]s that contain and spend the provided
99
    /// `outpoints`.
100
    ///
101
    /// `parallel_requests` specifies the maximum number of HTTP requests to make in parallel.
102
    ///
103
    /// Refer to [crate-level docs](crate) for more.
104
    fn fetch_txs_with_outpoints<I: IntoIterator<Item = OutPoint>>(
105
        &self,
106
        outpoints: I,
107
        parallel_requests: usize,
108
    ) -> Result<TxGraph<ConfirmationBlockTime>, Error>
109
    where
110
        I::IntoIter: ExactSizeIterator;
111
}
112

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

144
    fn sync(&self, request: SyncRequest, parallel_requests: usize) -> Result<SyncResult, Error> {
2✔
145
        let latest_blocks = fetch_latest_blocks(self)?;
2✔
146
        let mut graph_update = TxGraph::default();
2✔
147
        let _ =
2✔
148
            graph_update.apply_update(self.fetch_txs_with_spks(request.spks, parallel_requests)?);
2✔
149
        let _ =
150
            graph_update.apply_update(self.fetch_txs_with_txids(request.txids, parallel_requests)?);
2✔
151
        let _ = graph_update
2✔
152
            .apply_update(self.fetch_txs_with_outpoints(request.outpoints, parallel_requests)?);
2✔
153
        let chain_update = chain_update(
2✔
154
            self,
2✔
155
            &latest_blocks,
2✔
156
            &request.chain_tip,
2✔
157
            graph_update.all_anchors(),
2✔
158
        )?;
2✔
159
        Ok(SyncResult {
2✔
160
            chain_update,
2✔
161
            graph_update,
2✔
162
        })
2✔
163
    }
2✔
164

165
    fn fetch_txs_with_keychain_spks<I: Iterator<Item = Indexed<ScriptBuf>>>(
6✔
166
        &self,
6✔
167
        mut keychain_spks: I,
6✔
168
        stop_gap: usize,
6✔
169
        parallel_requests: usize,
6✔
170
    ) -> Result<(TxGraph<ConfirmationBlockTime>, Option<u32>), Error> {
6✔
171
        type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>);
6✔
172

6✔
173
        let mut tx_graph = TxGraph::default();
6✔
174
        let mut last_index = Option::<u32>::None;
6✔
175
        let mut last_active_index = Option::<u32>::None;
6✔
176

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

37✔
201
            if handles.is_empty() {
37✔
202
                break;
3✔
203
            }
34✔
204

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

218
            let last_index = last_index.expect("Must be set since handles wasn't empty.");
34✔
219
            let gap_limit_reached = if let Some(i) = last_active_index {
34✔
220
                last_index >= i.saturating_add(stop_gap as u32)
22✔
221
            } else {
222
                last_index + 1 >= stop_gap as u32
12✔
223
            };
224
            if gap_limit_reached {
34✔
225
                break;
3✔
226
            }
31✔
227
        }
228

229
        Ok((tx_graph, last_active_index))
6✔
230
    }
6✔
231

232
    fn fetch_txs_with_spks<I: IntoIterator<Item = ScriptBuf>>(
2✔
233
        &self,
2✔
234
        spks: I,
2✔
235
        parallel_requests: usize,
2✔
236
    ) -> Result<TxGraph<ConfirmationBlockTime>, Error>
2✔
237
    where
2✔
238
        I::IntoIter: ExactSizeIterator,
2✔
239
    {
2✔
240
        self.fetch_txs_with_keychain_spks(
2✔
241
            spks.into_iter().enumerate().map(|(i, spk)| (i as u32, spk)),
4✔
242
            usize::MAX,
2✔
243
            parallel_requests,
2✔
244
        )
2✔
245
        .map(|(tx_graph, _)| tx_graph)
2✔
246
    }
2✔
247

248
    fn fetch_txs_with_txids<I: IntoIterator<Item = Txid>>(
6✔
249
        &self,
6✔
250
        txids: I,
6✔
251
        parallel_requests: usize,
6✔
252
    ) -> Result<TxGraph<ConfirmationBlockTime>, Error>
6✔
253
    where
6✔
254
        I::IntoIter: ExactSizeIterator,
6✔
255
    {
6✔
256
        enum EsploraResp {
6✔
257
            TxStatus(TxStatus),
6✔
258
            Tx(Option<Tx>),
6✔
259
        }
6✔
260

6✔
261
        let mut tx_graph = TxGraph::default();
6✔
262
        let mut txids = txids.into_iter();
6✔
263
        loop {
6✔
264
            let handles = txids
6✔
265
                .by_ref()
6✔
266
                .take(parallel_requests)
6✔
267
                .map(|txid| {
6✔
NEW
268
                    let client = self.clone();
×
NEW
269
                    let tx_already_exists = tx_graph.get_tx(txid).is_some();
×
NEW
270
                    std::thread::spawn(move || {
×
NEW
271
                        if tx_already_exists {
×
NEW
272
                            client
×
NEW
273
                                .get_tx_status(&txid)
×
NEW
274
                                .map_err(Box::new)
×
NEW
275
                                .map(|s| (txid, EsploraResp::TxStatus(s)))
×
276
                        } else {
NEW
277
                            client
×
NEW
278
                                .get_tx_info(&txid)
×
NEW
279
                                .map_err(Box::new)
×
NEW
UNCOV
280
                                .map(|t| (txid, EsploraResp::Tx(t)))
×
281
                        }
NEW
282
                    })
×
283
                })
6✔
284
                .collect::<Vec<JoinHandle<Result<(Txid, EsploraResp), Error>>>>();
6✔
285

6✔
286
            if handles.is_empty() {
6✔
287
                break;
6✔
NEW
288
            }
×
289

NEW
UNCOV
290
            for handle in handles {
×
NEW
291
                let (txid, resp) = handle.join().expect("thread must not panic")?;
×
NEW
292
                match resp {
×
NEW
293
                    EsploraResp::TxStatus(status) => {
×
NEW
294
                        insert_anchor_from_status(&mut tx_graph, txid, status);
×
NEW
UNCOV
295
                    }
×
NEW
UNCOV
296
                    EsploraResp::Tx(Some(tx_info)) => {
×
NEW
UNCOV
297
                        let _ = tx_graph.insert_tx(tx_info.to_tx());
×
NEW
298
                        insert_anchor_from_status(&mut tx_graph, txid, tx_info.status);
×
NEW
299
                        insert_prevouts(&mut tx_graph, tx_info.vin);
×
NEW
300
                    }
×
NEW
301
                    _ => continue,
×
302
                }
303
            }
304
        }
305
        Ok(tx_graph)
6✔
306
    }
6✔
307

308
    fn fetch_txs_with_outpoints<I: IntoIterator<Item = OutPoint>>(
2✔
309
        &self,
2✔
310
        outpoints: I,
2✔
311
        parallel_requests: usize,
2✔
312
    ) -> Result<TxGraph<ConfirmationBlockTime>, Error>
2✔
313
    where
2✔
314
        I::IntoIter: ExactSizeIterator,
2✔
315
    {
2✔
316
        let outpoints = outpoints.into_iter().collect::<Vec<_>>();
2✔
317

318
        // make sure txs exists in graph and tx statuses are updated
319
        // TODO: We should maintain a tx cache (like we do with Electrum).
320
        let mut tx_graph =
2✔
321
            self.fetch_txs_with_txids(outpoints.iter().map(|op| op.txid), parallel_requests)?;
2✔
322

323
        // get outpoint spend-statuses
324
        let mut outpoints = outpoints.into_iter();
2✔
325
        let mut missing_txs = Vec::<Txid>::with_capacity(outpoints.len());
2✔
326
        loop {
2✔
327
            let handles = outpoints
2✔
328
                .by_ref()
2✔
329
                .take(parallel_requests)
2✔
330
                .map(|op| {
2✔
NEW
331
                    let client = self.clone();
×
NEW
332
                    std::thread::spawn(move || {
×
NEW
UNCOV
333
                        client
×
NEW
334
                            .get_output_status(&op.txid, op.vout as _)
×
NEW
335
                            .map_err(Box::new)
×
NEW
336
                    })
×
337
                })
2✔
338
                .collect::<Vec<JoinHandle<Result<Option<OutputStatus>, Error>>>>();
2✔
339

2✔
340
            if handles.is_empty() {
2✔
341
                break;
2✔
NEW
342
            }
×
343

NEW
344
            for handle in handles {
×
NEW
345
                if let Some(op_status) = handle.join().expect("thread must not panic")? {
×
NEW
346
                    let spend_txid = match op_status.txid {
×
NEW
347
                        Some(txid) => txid,
×
NEW
348
                        None => continue,
×
349
                    };
NEW
350
                    if tx_graph.get_tx(spend_txid).is_none() {
×
NEW
UNCOV
351
                        missing_txs.push(spend_txid);
×
NEW
UNCOV
352
                    }
×
NEW
UNCOV
353
                    if let Some(spend_status) = op_status.status {
×
NEW
UNCOV
354
                        insert_anchor_from_status(&mut tx_graph, spend_txid, spend_status);
×
NEW
UNCOV
355
                    }
×
NEW
UNCOV
356
                }
×
357
            }
358
        }
359

360
        let _ = tx_graph.apply_update(self.fetch_txs_with_txids(missing_txs, parallel_requests)?);
2✔
361
        Ok(tx_graph)
2✔
362
    }
2✔
363
}
364

365
/// Fetch latest blocks from Esplora in an atomic call.
366
///
367
/// We want to do this before fetching transactions and anchors as we cannot fetch latest blocks AND
368
/// transactions atomically, and the checkpoint tip is used to determine last-scanned block (for
369
/// block-based chain-sources). Therefore it's better to be conservative when setting the tip (use
370
/// an earlier tip rather than a later tip) otherwise the caller may accidentally skip blocks when
371
/// alternating between chain-sources.
372
fn fetch_latest_blocks(
28✔
373
    client: &esplora_client::BlockingClient,
28✔
374
) -> Result<BTreeMap<u32, BlockHash>, Error> {
28✔
375
    Ok(client
28✔
376
        .get_blocks(None)?
28✔
377
        .into_iter()
28✔
378
        .map(|b| (b.time.height, b.id))
280✔
379
        .collect())
28✔
380
}
28✔
381

382
/// Used instead of [`esplora_client::BlockingClient::get_block_hash`].
383
///
384
/// This first checks the previously fetched `latest_blocks` before fetching from Esplora again.
385
fn fetch_block(
66✔
386
    client: &esplora_client::BlockingClient,
66✔
387
    latest_blocks: &BTreeMap<u32, BlockHash>,
66✔
388
    height: u32,
66✔
389
) -> Result<Option<BlockHash>, Error> {
66✔
390
    if let Some(&hash) = latest_blocks.get(&height) {
66✔
391
        return Ok(Some(hash));
17✔
392
    }
49✔
393

49✔
394
    // We avoid fetching blocks higher than previously fetched `latest_blocks` as the local chain
49✔
395
    // tip is used to signal for the last-synced-up-to-height.
49✔
396
    let &tip_height = latest_blocks
49✔
397
        .keys()
49✔
398
        .last()
49✔
399
        .expect("must have atleast one entry");
49✔
400
    if height > tip_height {
49✔
401
        return Ok(None);
×
402
    }
49✔
403

49✔
404
    Ok(Some(client.get_block_hash(height)?))
49✔
405
}
66✔
406

407
/// Create the [`local_chain::Update`].
408
///
409
/// We want to have a corresponding checkpoint per anchor height. However, checkpoints fetched
410
/// should not surpass `latest_blocks`.
411
fn chain_update<A: Anchor>(
28✔
412
    client: &esplora_client::BlockingClient,
28✔
413
    latest_blocks: &BTreeMap<u32, BlockHash>,
28✔
414
    local_tip: &CheckPoint,
28✔
415
    anchors: &BTreeSet<(A, Txid)>,
28✔
416
) -> Result<CheckPoint, Error> {
28✔
417
    let mut point_of_agreement = None;
28✔
418
    let mut conflicts = vec![];
28✔
419
    for local_cp in local_tip.iter() {
37✔
420
        let remote_hash = match fetch_block(client, latest_blocks, local_cp.height())? {
37✔
421
            Some(hash) => hash,
37✔
422
            None => continue,
×
423
        };
424
        if remote_hash == local_cp.hash() {
37✔
425
            point_of_agreement = Some(local_cp.clone());
28✔
426
            break;
28✔
427
        } else {
9✔
428
            // it is not strictly necessary to include all the conflicted heights (we do need the
9✔
429
            // first one) but it seems prudent to make sure the updated chain's heights are a
9✔
430
            // superset of the existing chain after update.
9✔
431
            conflicts.push(BlockId {
9✔
432
                height: local_cp.height(),
9✔
433
                hash: remote_hash,
9✔
434
            });
9✔
435
        }
9✔
436
    }
437

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

28✔
440
    tip = tip
28✔
441
        .extend(conflicts.into_iter().rev())
28✔
442
        .expect("evicted are in order");
28✔
443

444
    for anchor in anchors {
67✔
445
        let height = anchor.0.anchor_block().height;
39✔
446
        if tip.get(height).is_none() {
39✔
447
            let hash = match fetch_block(client, latest_blocks, height)? {
29✔
448
                Some(hash) => hash,
29✔
449
                None => continue,
×
450
            };
451
            tip = tip.insert(BlockId { height, hash });
29✔
452
        }
10✔
453
    }
454

455
    // insert the most recent blocks at the tip to make sure we update the tip and make the update
456
    // robust.
457
    for (&height, &hash) in latest_blocks.iter() {
280✔
458
        tip = tip.insert(BlockId { height, hash });
280✔
459
    }
280✔
460

461
    Ok(tip)
28✔
462
}
28✔
463

464
#[cfg(test)]
465
mod test {
466
    use crate::blocking_ext::{chain_update, fetch_latest_blocks};
467
    use bdk_chain::bitcoin::hashes::Hash;
468
    use bdk_chain::bitcoin::Txid;
469
    use bdk_chain::local_chain::LocalChain;
470
    use bdk_chain::BlockId;
471
    use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv};
472
    use esplora_client::{BlockHash, Builder};
473
    use std::collections::{BTreeMap, BTreeSet};
474
    use std::time::Duration;
475

476
    macro_rules! h {
477
        ($index:literal) => {{
478
            bdk_chain::bitcoin::hashes::Hash::hash($index.as_bytes())
479
        }};
480
    }
481

482
    macro_rules! local_chain {
483
        [ $(($height:expr, $block_hash:expr)), * ] => {{
484
            #[allow(unused_mut)]
485
            bdk_chain::local_chain::LocalChain::from_blocks([$(($height, $block_hash).into()),*].into_iter().collect())
486
                .expect("chain must have genesis block")
487
        }};
488
    }
489

490
    /// Ensure that update does not remove heights (from original), and all anchor heights are included.
491
    #[test]
492
    pub fn test_finalize_chain_update() -> anyhow::Result<()> {
1✔
493
        struct TestCase<'a> {
1✔
494
            name: &'a str,
1✔
495
            /// Initial blockchain height to start the env with.
1✔
496
            initial_env_height: u32,
1✔
497
            /// Initial checkpoint heights to start with in the local chain.
1✔
498
            initial_cps: &'a [u32],
1✔
499
            /// The final blockchain height of the env.
1✔
500
            final_env_height: u32,
1✔
501
            /// The anchors to test with: `(height, txid)`. Only the height is provided as we can fetch
1✔
502
            /// the blockhash from the env.
1✔
503
            anchors: &'a [(u32, Txid)],
1✔
504
        }
1✔
505

1✔
506
        let test_cases = [
1✔
507
            TestCase {
1✔
508
                name: "chain_extends",
1✔
509
                initial_env_height: 60,
1✔
510
                initial_cps: &[59, 60],
1✔
511
                final_env_height: 90,
1✔
512
                anchors: &[],
1✔
513
            },
1✔
514
            TestCase {
1✔
515
                name: "introduce_older_heights",
1✔
516
                initial_env_height: 50,
1✔
517
                initial_cps: &[10, 15],
1✔
518
                final_env_height: 50,
1✔
519
                anchors: &[(11, h!("A")), (14, h!("B"))],
1✔
520
            },
1✔
521
            TestCase {
1✔
522
                name: "introduce_older_heights_after_chain_extends",
1✔
523
                initial_env_height: 50,
1✔
524
                initial_cps: &[10, 15],
1✔
525
                final_env_height: 100,
1✔
526
                anchors: &[(11, h!("A")), (14, h!("B"))],
1✔
527
            },
1✔
528
        ];
1✔
529

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

533
            let env = TestEnv::new()?;
3✔
534
            let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
3✔
535
            let client = Builder::new(base_url.as_str()).build_blocking();
3✔
536

537
            // set env to `initial_env_height`
538
            if let Some(to_mine) = t
3✔
539
                .initial_env_height
3✔
540
                .checked_sub(env.make_checkpoint_tip().height())
3✔
541
            {
542
                env.mine_blocks(to_mine as _, None)?;
3✔
UNCOV
543
            }
×
544
            while client.get_height()? < t.initial_env_height {
1,189✔
545
                std::thread::sleep(Duration::from_millis(10));
1,186✔
546
            }
1,186✔
547

548
            // craft initial `local_chain`
549
            let local_chain = {
3✔
550
                let (mut chain, _) = LocalChain::from_genesis_hash(env.genesis_hash()?);
3✔
551
                // force `chain_update_blocking` to add all checkpoints in `t.initial_cps`
552
                let anchors = t
3✔
553
                    .initial_cps
3✔
554
                    .iter()
3✔
555
                    .map(|&height| -> anyhow::Result<_> {
6✔
556
                        Ok((
6✔
557
                            BlockId {
6✔
558
                                height,
6✔
559
                                hash: env.bitcoind.client.get_block_hash(height as _)?,
6✔
560
                            },
561
                            Txid::all_zeros(),
6✔
562
                        ))
563
                    })
6✔
564
                    .collect::<anyhow::Result<BTreeSet<_>>>()?;
3✔
565
                let update = chain_update(
3✔
566
                    &client,
3✔
567
                    &fetch_latest_blocks(&client)?,
3✔
568
                    &chain.tip(),
3✔
569
                    &anchors,
3✔
UNCOV
570
                )?;
×
571
                chain.apply_update(update)?;
3✔
572
                chain
3✔
573
            };
3✔
574
            println!("local chain height: {}", local_chain.tip().height());
3✔
575

576
            // extend env chain
577
            if let Some(to_mine) = t
3✔
578
                .final_env_height
3✔
579
                .checked_sub(env.make_checkpoint_tip().height())
3✔
580
            {
581
                env.mine_blocks(to_mine as _, None)?;
3✔
UNCOV
582
            }
×
583
            while client.get_height()? < t.final_env_height {
963✔
584
                std::thread::sleep(Duration::from_millis(10));
960✔
585
            }
960✔
586

587
            // craft update
588
            let update = {
3✔
589
                let anchors = t
3✔
590
                    .anchors
3✔
591
                    .iter()
3✔
592
                    .map(|&(height, txid)| -> anyhow::Result<_> {
4✔
593
                        Ok((
4✔
594
                            BlockId {
4✔
595
                                height,
4✔
596
                                hash: env.bitcoind.client.get_block_hash(height as _)?,
4✔
597
                            },
598
                            txid,
4✔
599
                        ))
600
                    })
4✔
601
                    .collect::<anyhow::Result<_>>()?;
3✔
602
                chain_update(
603
                    &client,
3✔
604
                    &fetch_latest_blocks(&client)?,
3✔
605
                    &local_chain.tip(),
3✔
606
                    &anchors,
3✔
UNCOV
607
                )?
×
608
            };
609

610
            // apply update
611
            let mut updated_local_chain = local_chain.clone();
3✔
612
            updated_local_chain.apply_update(update)?;
3✔
613
            println!(
3✔
614
                "updated local chain height: {}",
3✔
615
                updated_local_chain.tip().height()
3✔
616
            );
3✔
617

3✔
618
            assert!(
3✔
619
                {
3✔
620
                    let initial_heights = local_chain
3✔
621
                        .iter_checkpoints()
3✔
622
                        .map(|cp| cp.height())
37✔
623
                        .collect::<BTreeSet<_>>();
3✔
624
                    let updated_heights = updated_local_chain
3✔
625
                        .iter_checkpoints()
3✔
626
                        .map(|cp| cp.height())
61✔
627
                        .collect::<BTreeSet<_>>();
3✔
628
                    updated_heights.is_superset(&initial_heights)
3✔
629
                },
UNCOV
630
                "heights from the initial chain must all be in the updated chain",
×
631
            );
632

633
            assert!(
3✔
634
                {
3✔
635
                    let exp_anchor_heights = t
3✔
636
                        .anchors
3✔
637
                        .iter()
3✔
638
                        .map(|(h, _)| *h)
4✔
639
                        .chain(t.initial_cps.iter().copied())
3✔
640
                        .collect::<BTreeSet<_>>();
3✔
641
                    let anchor_heights = updated_local_chain
3✔
642
                        .iter_checkpoints()
3✔
643
                        .map(|cp| cp.height())
61✔
644
                        .collect::<BTreeSet<_>>();
3✔
645
                    anchor_heights.is_superset(&exp_anchor_heights)
3✔
646
                },
UNCOV
647
                "anchor heights must all be in updated chain",
×
648
            );
649
        }
650

651
        Ok(())
1✔
652
    }
1✔
653

654
    #[test]
655
    fn update_local_chain() -> anyhow::Result<()> {
1✔
656
        const TIP_HEIGHT: u32 = 50;
657

658
        let env = TestEnv::new()?;
1✔
659
        let blocks = {
1✔
660
            let bitcoind_client = &env.bitcoind.client;
1✔
661
            assert_eq!(bitcoind_client.get_block_count()?, 1);
1✔
662
            [
663
                (0, bitcoind_client.get_block_hash(0)?),
1✔
664
                (1, bitcoind_client.get_block_hash(1)?),
1✔
665
            ]
666
            .into_iter()
1✔
667
            .chain((2..).zip(env.mine_blocks((TIP_HEIGHT - 1) as usize, None)?))
1✔
668
            .collect::<BTreeMap<_, _>>()
1✔
669
        };
670
        // so new blocks can be seen by Electrs
671
        let env = env.reset_electrsd()?;
1✔
672
        let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
1✔
673
        let client = Builder::new(base_url.as_str()).build_blocking();
1✔
674

1✔
675
        struct TestCase {
1✔
676
            name: &'static str,
1✔
677
            /// Original local chain to start off with.
1✔
678
            chain: LocalChain,
1✔
679
            /// Heights of floating anchors. [`chain_update_blocking`] will request for checkpoints
1✔
680
            /// of these heights.
1✔
681
            request_heights: &'static [u32],
1✔
682
            /// The expected local chain result (heights only).
1✔
683
            exp_update_heights: &'static [u32],
1✔
684
        }
1✔
685

1✔
686
        let test_cases = [
1✔
687
            TestCase {
1✔
688
                name: "request_later_blocks",
1✔
689
                chain: local_chain![(0, blocks[&0]), (21, blocks[&21])],
1✔
690
                request_heights: &[22, 25, 28],
1✔
691
                exp_update_heights: &[21, 22, 25, 28],
1✔
692
            },
1✔
693
            TestCase {
1✔
694
                name: "request_prev_blocks",
1✔
695
                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (5, blocks[&5])],
1✔
696
                request_heights: &[4],
1✔
697
                exp_update_heights: &[4, 5],
1✔
698
            },
1✔
699
            TestCase {
1✔
700
                name: "request_prev_blocks_2",
1✔
701
                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (10, blocks[&10])],
1✔
702
                request_heights: &[4, 6],
1✔
703
                exp_update_heights: &[4, 6, 10],
1✔
704
            },
1✔
705
            TestCase {
1✔
706
                name: "request_later_and_prev_blocks",
1✔
707
                chain: local_chain![(0, blocks[&0]), (7, blocks[&7]), (11, blocks[&11])],
1✔
708
                request_heights: &[8, 9, 15],
1✔
709
                exp_update_heights: &[8, 9, 11, 15],
1✔
710
            },
1✔
711
            TestCase {
1✔
712
                name: "request_tip_only",
1✔
713
                chain: local_chain![(0, blocks[&0]), (5, blocks[&5]), (49, blocks[&49])],
1✔
714
                request_heights: &[TIP_HEIGHT],
1✔
715
                exp_update_heights: &[49],
1✔
716
            },
1✔
717
            TestCase {
1✔
718
                name: "request_nothing",
1✔
719
                chain: local_chain![(0, blocks[&0]), (13, blocks[&13]), (23, blocks[&23])],
1✔
720
                request_heights: &[],
1✔
721
                exp_update_heights: &[23],
1✔
722
            },
1✔
723
            TestCase {
1✔
724
                name: "request_nothing_during_reorg",
1✔
725
                chain: local_chain![(0, blocks[&0]), (13, blocks[&13]), (23, h!("23"))],
1✔
726
                request_heights: &[],
1✔
727
                exp_update_heights: &[13, 23],
1✔
728
            },
1✔
729
            TestCase {
1✔
730
                name: "request_nothing_during_reorg_2",
1✔
731
                chain: local_chain![
1✔
732
                    (0, blocks[&0]),
1✔
733
                    (21, blocks[&21]),
1✔
734
                    (22, h!("22")),
1✔
735
                    (23, h!("23"))
1✔
736
                ],
1✔
737
                request_heights: &[],
1✔
738
                exp_update_heights: &[21, 22, 23],
1✔
739
            },
1✔
740
            TestCase {
1✔
741
                name: "request_prev_blocks_during_reorg",
1✔
742
                chain: local_chain![
1✔
743
                    (0, blocks[&0]),
1✔
744
                    (21, blocks[&21]),
1✔
745
                    (22, h!("22")),
1✔
746
                    (23, h!("23"))
1✔
747
                ],
1✔
748
                request_heights: &[17, 20],
1✔
749
                exp_update_heights: &[17, 20, 21, 22, 23],
1✔
750
            },
1✔
751
            TestCase {
1✔
752
                name: "request_later_blocks_during_reorg",
1✔
753
                chain: local_chain![
1✔
754
                    (0, blocks[&0]),
1✔
755
                    (9, blocks[&9]),
1✔
756
                    (22, h!("22")),
1✔
757
                    (23, h!("23"))
1✔
758
                ],
1✔
759
                request_heights: &[25, 27],
1✔
760
                exp_update_heights: &[9, 22, 23, 25, 27],
1✔
761
            },
1✔
762
            TestCase {
1✔
763
                name: "request_later_blocks_during_reorg_2",
1✔
764
                chain: local_chain![(0, blocks[&0]), (9, h!("9"))],
1✔
765
                request_heights: &[10],
1✔
766
                exp_update_heights: &[0, 9, 10],
1✔
767
            },
1✔
768
            TestCase {
1✔
769
                name: "request_later_and_prev_blocks_during_reorg",
1✔
770
                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (9, h!("9"))],
1✔
771
                request_heights: &[8, 11],
1✔
772
                exp_update_heights: &[1, 8, 9, 11],
1✔
773
            },
1✔
774
        ];
1✔
775

776
        for (i, t) in test_cases.into_iter().enumerate() {
12✔
777
            println!("Case {}: {}", i, t.name);
12✔
778
            let mut chain = t.chain;
12✔
779

12✔
780
            let mock_anchors = t
12✔
781
                .request_heights
12✔
782
                .iter()
12✔
783
                .map(|&h| {
17✔
784
                    let anchor_blockhash: BlockHash = bdk_chain::bitcoin::hashes::Hash::hash(
17✔
785
                        &format!("hash_at_height_{}", h).into_bytes(),
17✔
786
                    );
17✔
787
                    let txid: Txid = bdk_chain::bitcoin::hashes::Hash::hash(
17✔
788
                        &format!("txid_at_height_{}", h).into_bytes(),
17✔
789
                    );
17✔
790
                    let anchor = BlockId {
17✔
791
                        height: h,
17✔
792
                        hash: anchor_blockhash,
17✔
793
                    };
17✔
794
                    (anchor, txid)
17✔
795
                })
17✔
796
                .collect::<BTreeSet<_>>();
12✔
797
            let chain_update = chain_update(
12✔
798
                &client,
12✔
799
                &fetch_latest_blocks(&client)?,
12✔
800
                &chain.tip(),
12✔
801
                &mock_anchors,
12✔
UNCOV
802
            )?;
×
803

804
            let update_blocks = chain_update
12✔
805
                .iter()
12✔
806
                .map(|cp| cp.block_id())
172✔
807
                .collect::<BTreeSet<_>>();
12✔
808

12✔
809
            let exp_update_blocks = t
12✔
810
                .exp_update_heights
12✔
811
                .iter()
12✔
812
                .map(|&height| {
37✔
813
                    let hash = blocks[&height];
37✔
814
                    BlockId { height, hash }
37✔
815
                })
37✔
816
                .chain(
12✔
817
                    // Electrs Esplora `get_block` call fetches 10 blocks which is included in the
12✔
818
                    // update
12✔
819
                    blocks
12✔
820
                        .range(TIP_HEIGHT - 9..)
12✔
821
                        .map(|(&height, &hash)| BlockId { height, hash }),
120✔
822
                )
12✔
823
                .collect::<BTreeSet<_>>();
12✔
824

12✔
825
            assert!(
12✔
826
                update_blocks.is_superset(&exp_update_blocks),
12✔
UNCOV
827
                "[{}:{}] unexpected update",
×
828
                i,
829
                t.name
830
            );
831

832
            let _ = chain
12✔
833
                .apply_update(chain_update)
12✔
834
                .unwrap_or_else(|err| panic!("[{}:{}] update failed to apply: {}", i, t.name, err));
12✔
835

836
            // all requested heights must exist in the final chain
837
            for height in t.request_heights {
29✔
838
                let exp_blockhash = blocks.get(height).expect("block must exist in bitcoind");
17✔
839
                assert_eq!(
17✔
840
                    chain.get(*height).map(|cp| cp.hash()),
17✔
841
                    Some(*exp_blockhash),
17✔
UNCOV
842
                    "[{}:{}] block {}:{} must exist in final chain",
×
843
                    i,
844
                    t.name,
845
                    height,
846
                    exp_blockhash
847
                );
848
            }
849
        }
850

851
        Ok(())
1✔
852
    }
1✔
853
}
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