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

bitcoindevkit / bdk / 10539621204

24 Aug 2024 03:32PM UTC coverage: 82.079% (+0.2%) from 81.848%
10539621204

Pull #1569

github

web-flow
Merge 48259deeb into 9e6ac72a6
Pull Request #1569: `bdk_core` WIP WIP WIP

533 of 616 new or added lines in 16 files covered. (86.53%)

5 existing lines in 4 files now uncovered.

11230 of 13682 relevant lines covered (82.08%)

13503.08 hits per line

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

91.81
/crates/esplora/src/blocking_ext.rs
1
use bdk_core::collections::{BTreeMap, BTreeSet, HashSet};
2
use bdk_core::spk_client::{FullScanRequest, FullScanResult, SyncRequest, SyncResult};
3
use bdk_core::{
4
    bitcoin::{BlockHash, OutPoint, ScriptBuf, Txid},
5
    tx_graph, BlockId, CheckPoint, ConfirmationBlockTime, Indexed,
6
};
7
use esplora_client::{OutputStatus, Tx};
8
use std::thread::JoinHandle;
9

10
use crate::{insert_anchor_from_status, insert_prevouts};
11

12
/// [`esplora_client::Error`]
13
pub type Error = Box<esplora_client::Error>;
14

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

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

49
impl EsploraExt for esplora_client::BlockingClient {
50
    fn full_scan<K: Ord + Clone, R: Into<FullScanRequest<K>>>(
4✔
51
        &self,
4✔
52
        request: R,
4✔
53
        stop_gap: usize,
4✔
54
        parallel_requests: usize,
4✔
55
    ) -> Result<FullScanResult<K>, Error> {
4✔
56
        let mut request = request.into();
4✔
57

4✔
58
        let chain_tip = request.chain_tip();
4✔
59
        let latest_blocks = if chain_tip.is_some() {
4✔
60
            Some(fetch_latest_blocks(self)?)
4✔
61
        } else {
62
            None
×
63
        };
64

65
        let mut graph_update = tx_graph::Update::default();
4✔
66
        let mut inserted_txs = HashSet::<Txid>::new();
4✔
67
        let mut last_active_indices = BTreeMap::<K, u32>::new();
4✔
68
        for keychain in request.keychains() {
4✔
69
            let keychain_spks = request.iter_spks(keychain.clone());
4✔
70
            let (update, last_active_index) = fetch_txs_with_keychain_spks(
4✔
71
                self,
4✔
72
                &mut inserted_txs,
4✔
73
                keychain_spks,
4✔
74
                stop_gap,
4✔
75
                parallel_requests,
4✔
76
            )?;
4✔
77
            graph_update.extend(update);
4✔
78
            if let Some(last_active_index) = last_active_index {
4✔
79
                last_active_indices.insert(keychain, last_active_index);
3✔
80
            }
3✔
81
        }
82

83
        let chain_update = match (chain_tip, latest_blocks) {
4✔
84
            (Some(chain_tip), Some(latest_blocks)) => Some(chain_update(
4✔
85
                self,
4✔
86
                &latest_blocks,
4✔
87
                &chain_tip,
4✔
88
                &graph_update.anchors,
4✔
89
            )?),
4✔
90
            _ => None,
×
91
        };
92

93
        Ok(FullScanResult {
4✔
94
            chain_update,
4✔
95
            graph_update,
4✔
96
            last_active_indices,
4✔
97
        })
4✔
98
    }
4✔
99

100
    fn sync<I: 'static, R: Into<SyncRequest<I>>>(
1✔
101
        &self,
1✔
102
        request: R,
1✔
103
        parallel_requests: usize,
1✔
104
    ) -> Result<SyncResult, Error> {
1✔
105
        let mut request: SyncRequest<I> = request.into();
1✔
106

1✔
107
        let chain_tip = request.chain_tip();
1✔
108
        let latest_blocks = if chain_tip.is_some() {
1✔
109
            Some(fetch_latest_blocks(self)?)
1✔
110
        } else {
111
            None
×
112
        };
113

114
        let mut graph_update = tx_graph::Update::<ConfirmationBlockTime>::default();
1✔
115
        let mut inserted_txs = HashSet::<Txid>::new();
1✔
116
        graph_update.extend(fetch_txs_with_spks(
1✔
117
            self,
1✔
118
            &mut inserted_txs,
1✔
119
            request.iter_spks(),
1✔
120
            parallel_requests,
1✔
121
        )?);
1✔
122
        graph_update.extend(fetch_txs_with_txids(
1✔
123
            self,
1✔
124
            &mut inserted_txs,
1✔
125
            request.iter_txids(),
1✔
126
            parallel_requests,
1✔
127
        )?);
1✔
128
        graph_update.extend(fetch_txs_with_outpoints(
1✔
129
            self,
1✔
130
            &mut inserted_txs,
1✔
131
            request.iter_outpoints(),
1✔
132
            parallel_requests,
1✔
133
        )?);
1✔
134

135
        let chain_update = match (chain_tip, latest_blocks) {
1✔
136
            (Some(chain_tip), Some(latest_blocks)) => Some(chain_update(
1✔
137
                self,
1✔
138
                &latest_blocks,
1✔
139
                &chain_tip,
1✔
140
                &graph_update.anchors,
1✔
141
            )?),
1✔
142
            _ => None,
×
143
        };
144

145
        Ok(SyncResult {
1✔
146
            chain_update,
1✔
147
            graph_update,
1✔
148
        })
1✔
149
    }
1✔
150
}
151

152
/// Fetch latest blocks from Esplora in an atomic call.
153
///
154
/// We want to do this before fetching transactions and anchors as we cannot fetch latest blocks AND
155
/// transactions atomically, and the checkpoint tip is used to determine last-scanned block (for
156
/// block-based chain-sources). Therefore it's better to be conservative when setting the tip (use
157
/// an earlier tip rather than a later tip) otherwise the caller may accidentally skip blocks when
158
/// alternating between chain-sources.
159
fn fetch_latest_blocks(
28✔
160
    client: &esplora_client::BlockingClient,
28✔
161
) -> Result<BTreeMap<u32, BlockHash>, Error> {
28✔
162
    Ok(client
28✔
163
        .get_blocks(None)?
28✔
164
        .into_iter()
28✔
165
        .map(|b| (b.time.height, b.id))
280✔
166
        .collect())
28✔
167
}
28✔
168

169
/// Used instead of [`esplora_client::BlockingClient::get_block_hash`].
170
///
171
/// This first checks the previously fetched `latest_blocks` before fetching from Esplora again.
172
fn fetch_block(
66✔
173
    client: &esplora_client::BlockingClient,
66✔
174
    latest_blocks: &BTreeMap<u32, BlockHash>,
66✔
175
    height: u32,
66✔
176
) -> Result<Option<BlockHash>, Error> {
66✔
177
    if let Some(&hash) = latest_blocks.get(&height) {
66✔
178
        return Ok(Some(hash));
17✔
179
    }
49✔
180

49✔
181
    // We avoid fetching blocks higher than previously fetched `latest_blocks` as the local chain
49✔
182
    // tip is used to signal for the last-synced-up-to-height.
49✔
183
    let &tip_height = latest_blocks
49✔
184
        .keys()
49✔
185
        .last()
49✔
186
        .expect("must have atleast one entry");
49✔
187
    if height > tip_height {
49✔
188
        return Ok(None);
×
189
    }
49✔
190

49✔
191
    Ok(Some(client.get_block_hash(height)?))
49✔
192
}
66✔
193

194
/// Create the [`local_chain::Update`].
195
///
196
/// We want to have a corresponding checkpoint per anchor height. However, checkpoints fetched
197
/// should not surpass `latest_blocks`.
198
fn chain_update(
28✔
199
    client: &esplora_client::BlockingClient,
28✔
200
    latest_blocks: &BTreeMap<u32, BlockHash>,
28✔
201
    local_tip: &CheckPoint,
28✔
202
    anchors: &BTreeSet<(ConfirmationBlockTime, Txid)>,
28✔
203
) -> Result<CheckPoint, Error> {
28✔
204
    let mut point_of_agreement = None;
28✔
205
    let mut conflicts = vec![];
28✔
206
    for local_cp in local_tip.iter() {
37✔
207
        let remote_hash = match fetch_block(client, latest_blocks, local_cp.height())? {
37✔
208
            Some(hash) => hash,
37✔
209
            None => continue,
×
210
        };
211
        if remote_hash == local_cp.hash() {
37✔
212
            point_of_agreement = Some(local_cp.clone());
28✔
213
            break;
28✔
214
        } else {
9✔
215
            // it is not strictly necessary to include all the conflicted heights (we do need the
9✔
216
            // first one) but it seems prudent to make sure the updated chain's heights are a
9✔
217
            // superset of the existing chain after update.
9✔
218
            conflicts.push(BlockId {
9✔
219
                height: local_cp.height(),
9✔
220
                hash: remote_hash,
9✔
221
            });
9✔
222
        }
9✔
223
    }
224

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

28✔
227
    tip = tip
28✔
228
        .extend(conflicts.into_iter().rev())
28✔
229
        .expect("evicted are in order");
28✔
230

231
    for (anchor, _) in anchors {
67✔
232
        let height = anchor.block_id.height;
39✔
233
        if tip.get(height).is_none() {
39✔
234
            let hash = match fetch_block(client, latest_blocks, height)? {
29✔
235
                Some(hash) => hash,
29✔
236
                None => continue,
×
237
            };
238
            tip = tip.insert(BlockId { height, hash });
29✔
239
        }
10✔
240
    }
241

242
    // insert the most recent blocks at the tip to make sure we update the tip and make the update
243
    // robust.
244
    for (&height, &hash) in latest_blocks.iter() {
280✔
245
        tip = tip.insert(BlockId { height, hash });
280✔
246
    }
280✔
247

248
    Ok(tip)
28✔
249
}
28✔
250

251
fn fetch_txs_with_keychain_spks<I: Iterator<Item = Indexed<ScriptBuf>>>(
5✔
252
    client: &esplora_client::BlockingClient,
5✔
253
    inserted_txs: &mut HashSet<Txid>,
5✔
254
    mut keychain_spks: I,
5✔
255
    stop_gap: usize,
5✔
256
    parallel_requests: usize,
5✔
257
) -> Result<(tx_graph::Update<ConfirmationBlockTime>, Option<u32>), Error> {
5✔
258
    type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>);
5✔
259

5✔
260
    let mut update = tx_graph::Update::<ConfirmationBlockTime>::default();
5✔
261
    let mut last_index = Option::<u32>::None;
5✔
262
    let mut last_active_index = Option::<u32>::None;
5✔
263

264
    loop {
34✔
265
        let handles = keychain_spks
34✔
266
            .by_ref()
34✔
267
            .take(parallel_requests)
34✔
268
            .map(|(spk_index, spk)| {
34✔
269
                std::thread::spawn({
32✔
270
                    let client = client.clone();
32✔
271
                    move || -> Result<TxsOfSpkIndex, Error> {
32✔
272
                        let mut last_seen = None;
32✔
273
                        let mut spk_txs = Vec::new();
32✔
274
                        loop {
275
                            let txs = client.scripthash_txs(&spk, last_seen)?;
32✔
276
                            let tx_count = txs.len();
32✔
277
                            last_seen = txs.last().map(|tx| tx.txid);
32✔
278
                            spk_txs.extend(txs);
32✔
279
                            if tx_count < 25 {
32✔
280
                                break Ok((spk_index, spk_txs));
32✔
281
                            }
×
282
                        }
283
                    }
32✔
284
                })
32✔
285
            })
34✔
286
            .collect::<Vec<JoinHandle<Result<TxsOfSpkIndex, Error>>>>();
34✔
287

34✔
288
        if handles.is_empty() {
34✔
289
            break;
2✔
290
        }
32✔
291

292
        for handle in handles {
64✔
293
            let (index, txs) = handle.join().expect("thread must not panic")?;
32✔
294
            last_index = Some(index);
32✔
295
            if !txs.is_empty() {
32✔
296
                last_active_index = Some(index);
6✔
297
            }
26✔
298
            for tx in txs {
38✔
299
                if inserted_txs.insert(tx.txid) {
6✔
300
                    update.txs.push(tx.to_tx().into());
6✔
301
                }
6✔
302
                insert_anchor_from_status(&mut update, tx.txid, tx.status);
6✔
303
                insert_prevouts(&mut update, tx.vin);
6✔
304
            }
305
        }
306

307
        let last_index = last_index.expect("Must be set since handles wasn't empty.");
32✔
308
        let gap_limit_reached = if let Some(i) = last_active_index {
32✔
309
            last_index >= i.saturating_add(stop_gap as u32)
20✔
310
        } else {
311
            last_index + 1 >= stop_gap as u32
12✔
312
        };
313
        if gap_limit_reached {
32✔
314
            break;
3✔
315
        }
29✔
316
    }
317

318
    Ok((update, last_active_index))
5✔
319
}
5✔
320

321
/// Fetch transactions and associated [`ConfirmationBlockTime`]s by scanning `spks`
322
/// against Esplora.
323
///
324
/// Unlike with [`EsploraExt::fetch_txs_with_keychain_spks`], `spks` must be *bounded* as all
325
/// contained scripts will be scanned. `parallel_requests` specifies the maximum number of HTTP
326
/// requests to make in parallel.
327
///
328
/// Refer to [crate-level docs](crate) for more.
329
fn fetch_txs_with_spks<I: IntoIterator<Item = ScriptBuf>>(
1✔
330
    client: &esplora_client::BlockingClient,
1✔
331
    inserted_txs: &mut HashSet<Txid>,
1✔
332
    spks: I,
1✔
333
    parallel_requests: usize,
1✔
334
) -> Result<tx_graph::Update<ConfirmationBlockTime>, Error> {
1✔
335
    fetch_txs_with_keychain_spks(
1✔
336
        client,
1✔
337
        inserted_txs,
1✔
338
        spks.into_iter().enumerate().map(|(i, spk)| (i as u32, spk)),
2✔
339
        usize::MAX,
1✔
340
        parallel_requests,
1✔
341
    )
1✔
342
    .map(|(update, _)| update)
1✔
343
}
1✔
344

345
/// Fetch transactions and associated [`ConfirmationBlockTime`]s by scanning `txids`
346
/// against Esplora.
347
///
348
/// `parallel_requests` specifies the maximum number of HTTP requests to make in parallel.
349
///
350
/// Refer to [crate-level docs](crate) for more.
351
fn fetch_txs_with_txids<I: IntoIterator<Item = Txid>>(
3✔
352
    client: &esplora_client::BlockingClient,
3✔
353
    inserted_txs: &mut HashSet<Txid>,
3✔
354
    txids: I,
3✔
355
    parallel_requests: usize,
3✔
356
) -> Result<tx_graph::Update<ConfirmationBlockTime>, Error> {
3✔
357
    let mut update = tx_graph::Update::<ConfirmationBlockTime>::default();
3✔
358
    // Only fetch for non-inserted txs.
3✔
359
    let mut txids = txids
3✔
360
        .into_iter()
3✔
361
        .filter(|txid| !inserted_txs.contains(txid))
3✔
362
        .collect::<Vec<Txid>>()
3✔
363
        .into_iter();
3✔
364
    loop {
3✔
365
        let handles = txids
3✔
366
            .by_ref()
3✔
367
            .take(parallel_requests)
3✔
368
            .map(|txid| {
3✔
369
                let client = client.clone();
×
370
                std::thread::spawn(move || {
×
NEW
371
                    client
×
NEW
372
                        .get_tx_info(&txid)
×
NEW
373
                        .map_err(Box::new)
×
NEW
374
                        .map(|t| (txid, t))
×
UNCOV
375
                })
×
376
            })
3✔
377
            .collect::<Vec<JoinHandle<Result<(Txid, Option<Tx>), Error>>>>();
3✔
378

3✔
379
        if handles.is_empty() {
3✔
380
            break;
3✔
381
        }
×
382

383
        for handle in handles {
×
NEW
384
            let (txid, tx_info) = handle.join().expect("thread must not panic")?;
×
NEW
385
            if let Some(tx_info) = tx_info {
×
NEW
386
                if inserted_txs.insert(txid) {
×
NEW
387
                    update.txs.push(tx_info.to_tx().into());
×
388
                }
×
NEW
389
                insert_anchor_from_status(&mut update, txid, tx_info.status);
×
NEW
390
                insert_prevouts(&mut update, tx_info.vin);
×
UNCOV
391
            }
×
392
        }
393
    }
394
    Ok(update)
3✔
395
}
3✔
396

397
/// Fetch transactions and [`ConfirmationBlockTime`]s that contain and spend the provided
398
/// `outpoints`.
399
///
400
/// `parallel_requests` specifies the maximum number of HTTP requests to make in parallel.
401
///
402
/// Refer to [crate-level docs](crate) for more.
403
fn fetch_txs_with_outpoints<I: IntoIterator<Item = OutPoint>>(
1✔
404
    client: &esplora_client::BlockingClient,
1✔
405
    inserted_txs: &mut HashSet<Txid>,
1✔
406
    outpoints: I,
1✔
407
    parallel_requests: usize,
1✔
408
) -> Result<tx_graph::Update<ConfirmationBlockTime>, Error> {
1✔
409
    let outpoints = outpoints.into_iter().collect::<Vec<_>>();
1✔
410
    let mut update = tx_graph::Update::<ConfirmationBlockTime>::default();
1✔
411

1✔
412
    // make sure txs exists in graph and tx statuses are updated
1✔
413
    // TODO: We should maintain a tx cache (like we do with Electrum).
1✔
414
    update.extend(fetch_txs_with_txids(
1✔
415
        client,
1✔
416
        inserted_txs,
1✔
417
        outpoints.iter().map(|op| op.txid),
1✔
418
        parallel_requests,
1✔
419
    )?);
1✔
420

421
    // get outpoint spend-statuses
422
    let mut outpoints = outpoints.into_iter();
1✔
423
    let mut missing_txs = Vec::<Txid>::with_capacity(outpoints.len());
1✔
424
    loop {
1✔
425
        let handles = outpoints
1✔
426
            .by_ref()
1✔
427
            .take(parallel_requests)
1✔
428
            .map(|op| {
1✔
429
                let client = client.clone();
×
430
                std::thread::spawn(move || {
×
431
                    client
×
432
                        .get_output_status(&op.txid, op.vout as _)
×
433
                        .map_err(Box::new)
×
434
                })
×
435
            })
1✔
436
            .collect::<Vec<JoinHandle<Result<Option<OutputStatus>, Error>>>>();
1✔
437

1✔
438
        if handles.is_empty() {
1✔
439
            break;
1✔
440
        }
×
441

442
        for handle in handles {
×
443
            if let Some(op_status) = handle.join().expect("thread must not panic")? {
×
444
                let spend_txid = match op_status.txid {
×
445
                    Some(txid) => txid,
×
446
                    None => continue,
×
447
                };
NEW
448
                if !inserted_txs.contains(&spend_txid) {
×
449
                    missing_txs.push(spend_txid);
×
450
                }
×
451
                if let Some(spend_status) = op_status.status {
×
NEW
452
                    insert_anchor_from_status(&mut update, spend_txid, spend_status);
×
453
                }
×
454
            }
×
455
        }
456
    }
457

458
    update.extend(fetch_txs_with_txids(
1✔
459
        client,
1✔
460
        inserted_txs,
1✔
461
        missing_txs,
1✔
462
        parallel_requests,
1✔
463
    )?);
1✔
464
    Ok(update)
1✔
465
}
1✔
466

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

480
    macro_rules! h {
481
        ($index:literal) => {{
482
            bdk_chain::bitcoin::hashes::Hash::hash($index.as_bytes())
483
        }};
484
    }
485

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

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

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

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

537
            let env = TestEnv::new()?;
3✔
538
            let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
3✔
539
            let client = Builder::new(base_url.as_str()).build_blocking();
3✔
540

541
            // set env to `initial_env_height`
542
            if let Some(to_mine) = t
3✔
543
                .initial_env_height
3✔
544
                .checked_sub(env.make_checkpoint_tip().height())
3✔
545
            {
546
                env.mine_blocks(to_mine as _, None)?;
3✔
547
            }
×
548
            while client.get_height()? < t.initial_env_height {
1,289✔
549
                std::thread::sleep(Duration::from_millis(10));
1,286✔
550
            }
1,286✔
551

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

583
            // extend env chain
584
            if let Some(to_mine) = t
3✔
585
                .final_env_height
3✔
586
                .checked_sub(env.make_checkpoint_tip().height())
3✔
587
            {
588
                env.mine_blocks(to_mine as _, None)?;
3✔
589
            }
×
590
            while client.get_height()? < t.final_env_height {
951✔
591
                std::thread::sleep(Duration::from_millis(10));
948✔
592
            }
948✔
593

594
            // craft update
595
            let update = {
3✔
596
                let anchors = t
3✔
597
                    .anchors
3✔
598
                    .iter()
3✔
599
                    .map(|&(height, txid)| -> anyhow::Result<_> {
4✔
600
                        Ok((
4✔
601
                            ConfirmationBlockTime {
4✔
602
                                block_id: BlockId {
4✔
603
                                    height,
4✔
604
                                    hash: env.bitcoind.client.get_block_hash(height as _)?,
4✔
605
                                },
606
                                confirmation_time: height as _,
4✔
607
                            },
4✔
608
                            txid,
4✔
609
                        ))
610
                    })
4✔
611
                    .collect::<anyhow::Result<_>>()?;
3✔
612
                chain_update(
613
                    &client,
3✔
614
                    &fetch_latest_blocks(&client)?,
3✔
615
                    &local_chain.tip(),
3✔
616
                    &anchors,
3✔
617
                )?
×
618
            };
619

620
            // apply update
621
            let mut updated_local_chain = local_chain.clone();
3✔
622
            updated_local_chain.apply_update(update)?;
3✔
623
            println!(
3✔
624
                "updated local chain height: {}",
3✔
625
                updated_local_chain.tip().height()
3✔
626
            );
3✔
627

3✔
628
            assert!(
3✔
629
                {
3✔
630
                    let initial_heights = local_chain
3✔
631
                        .iter_checkpoints()
3✔
632
                        .map(|cp| cp.height())
37✔
633
                        .collect::<BTreeSet<_>>();
3✔
634
                    let updated_heights = updated_local_chain
3✔
635
                        .iter_checkpoints()
3✔
636
                        .map(|cp| cp.height())
61✔
637
                        .collect::<BTreeSet<_>>();
3✔
638
                    updated_heights.is_superset(&initial_heights)
3✔
639
                },
640
                "heights from the initial chain must all be in the updated chain",
×
641
            );
642

643
            assert!(
3✔
644
                {
3✔
645
                    let exp_anchor_heights = t
3✔
646
                        .anchors
3✔
647
                        .iter()
3✔
648
                        .map(|(h, _)| *h)
4✔
649
                        .chain(t.initial_cps.iter().copied())
3✔
650
                        .collect::<BTreeSet<_>>();
3✔
651
                    let anchor_heights = updated_local_chain
3✔
652
                        .iter_checkpoints()
3✔
653
                        .map(|cp| cp.height())
61✔
654
                        .collect::<BTreeSet<_>>();
3✔
655
                    anchor_heights.is_superset(&exp_anchor_heights)
3✔
656
                },
657
                "anchor heights must all be in updated chain",
×
658
            );
659
        }
660

661
        Ok(())
1✔
662
    }
1✔
663

664
    #[test]
665
    fn update_local_chain() -> anyhow::Result<()> {
1✔
666
        const TIP_HEIGHT: u32 = 50;
667

668
        let env = TestEnv::new()?;
1✔
669
        let blocks = {
1✔
670
            let bitcoind_client = &env.bitcoind.client;
1✔
671
            assert_eq!(bitcoind_client.get_block_count()?, 1);
1✔
672
            [
673
                (0, bitcoind_client.get_block_hash(0)?),
1✔
674
                (1, bitcoind_client.get_block_hash(1)?),
1✔
675
            ]
676
            .into_iter()
1✔
677
            .chain((2..).zip(env.mine_blocks((TIP_HEIGHT - 1) as usize, None)?))
1✔
678
            .collect::<BTreeMap<_, _>>()
1✔
679
        };
680
        // so new blocks can be seen by Electrs
681
        let env = env.reset_electrsd()?;
1✔
682
        let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
1✔
683
        let client = Builder::new(base_url.as_str()).build_blocking();
1✔
684

1✔
685
        struct TestCase {
1✔
686
            name: &'static str,
1✔
687
            /// Original local chain to start off with.
1✔
688
            chain: LocalChain,
1✔
689
            /// Heights of floating anchors. [`chain_update_blocking`] will request for checkpoints
1✔
690
            /// of these heights.
1✔
691
            request_heights: &'static [u32],
1✔
692
            /// The expected local chain result (heights only).
1✔
693
            exp_update_heights: &'static [u32],
1✔
694
        }
1✔
695

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

786
        for (i, t) in test_cases.into_iter().enumerate() {
12✔
787
            println!("Case {}: {}", i, t.name);
12✔
788
            let mut chain = t.chain;
12✔
789

12✔
790
            let mock_anchors = t
12✔
791
                .request_heights
12✔
792
                .iter()
12✔
793
                .map(|&h| {
17✔
794
                    let anchor_blockhash: BlockHash = bdk_chain::bitcoin::hashes::Hash::hash(
17✔
795
                        &format!("hash_at_height_{}", h).into_bytes(),
17✔
796
                    );
17✔
797
                    let txid: Txid = bdk_chain::bitcoin::hashes::Hash::hash(
17✔
798
                        &format!("txid_at_height_{}", h).into_bytes(),
17✔
799
                    );
17✔
800
                    let anchor = ConfirmationBlockTime {
17✔
801
                        block_id: BlockId {
17✔
802
                            height: h,
17✔
803
                            hash: anchor_blockhash,
17✔
804
                        },
17✔
805
                        confirmation_time: h as _,
17✔
806
                    };
17✔
807
                    (anchor, txid)
17✔
808
                })
17✔
809
                .collect::<BTreeSet<_>>();
12✔
810
            let chain_update = chain_update(
12✔
811
                &client,
12✔
812
                &fetch_latest_blocks(&client)?,
12✔
813
                &chain.tip(),
12✔
814
                &mock_anchors,
12✔
815
            )?;
×
816

817
            let update_blocks = chain_update
12✔
818
                .iter()
12✔
819
                .map(|cp| cp.block_id())
172✔
820
                .collect::<BTreeSet<_>>();
12✔
821

12✔
822
            let exp_update_blocks = t
12✔
823
                .exp_update_heights
12✔
824
                .iter()
12✔
825
                .map(|&height| {
37✔
826
                    let hash = blocks[&height];
37✔
827
                    BlockId { height, hash }
37✔
828
                })
37✔
829
                .chain(
12✔
830
                    // Electrs Esplora `get_block` call fetches 10 blocks which is included in the
12✔
831
                    // update
12✔
832
                    blocks
12✔
833
                        .range(TIP_HEIGHT - 9..)
12✔
834
                        .map(|(&height, &hash)| BlockId { height, hash }),
120✔
835
                )
12✔
836
                .collect::<BTreeSet<_>>();
12✔
837

12✔
838
            assert!(
12✔
839
                update_blocks.is_superset(&exp_update_blocks),
12✔
840
                "[{}:{}] unexpected update",
×
841
                i,
842
                t.name
843
            );
844

845
            let _ = chain
12✔
846
                .apply_update(chain_update)
12✔
847
                .unwrap_or_else(|err| panic!("[{}:{}] update failed to apply: {}", i, t.name, err));
12✔
848

849
            // all requested heights must exist in the final chain
850
            for height in t.request_heights {
29✔
851
                let exp_blockhash = blocks.get(height).expect("block must exist in bitcoind");
17✔
852
                assert_eq!(
17✔
853
                    chain.get(*height).map(|cp| cp.hash()),
17✔
854
                    Some(*exp_blockhash),
17✔
855
                    "[{}:{}] block {}:{} must exist in final chain",
×
856
                    i,
857
                    t.name,
858
                    height,
859
                    exp_blockhash
860
                );
861
            }
862
        }
863

864
        Ok(())
1✔
865
    }
1✔
866
}
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