bdk_esplora/
blocking_ext.rs

1use bdk_core::collections::{BTreeMap, BTreeSet, HashSet};
2use bdk_core::spk_client::{FullScanRequest, FullScanResponse, SyncRequest, SyncResponse};
3use bdk_core::{
4    bitcoin::{BlockHash, OutPoint, ScriptBuf, Txid},
5    BlockId, CheckPoint, ConfirmationBlockTime, Indexed, TxUpdate,
6};
7use esplora_client::{OutputStatus, Tx};
8use std::thread::JoinHandle;
9
10use crate::{insert_anchor_from_status, insert_prevouts};
11
12/// [`esplora_client::Error`]
13pub 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.
18pub 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<FullScanResponse<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<SyncResponse, Error>;
47}
48
49impl EsploraExt for esplora_client::BlockingClient {
50    fn full_scan<K: Ord + Clone, R: Into<FullScanRequest<K>>>(
51        &self,
52        request: R,
53        stop_gap: usize,
54        parallel_requests: usize,
55    ) -> Result<FullScanResponse<K>, Error> {
56        let mut request = request.into();
57
58        let chain_tip = request.chain_tip();
59        let latest_blocks = if chain_tip.is_some() {
60            Some(fetch_latest_blocks(self)?)
61        } else {
62            None
63        };
64
65        let mut tx_update = TxUpdate::default();
66        let mut inserted_txs = HashSet::<Txid>::new();
67        let mut last_active_indices = BTreeMap::<K, u32>::new();
68        for keychain in request.keychains() {
69            let keychain_spks = request.iter_spks(keychain.clone());
70            let (update, last_active_index) = fetch_txs_with_keychain_spks(
71                self,
72                &mut inserted_txs,
73                keychain_spks,
74                stop_gap,
75                parallel_requests,
76            )?;
77            tx_update.extend(update);
78            if let Some(last_active_index) = last_active_index {
79                last_active_indices.insert(keychain, last_active_index);
80            }
81        }
82
83        let chain_update = match (chain_tip, latest_blocks) {
84            (Some(chain_tip), Some(latest_blocks)) => Some(chain_update(
85                self,
86                &latest_blocks,
87                &chain_tip,
88                &tx_update.anchors,
89            )?),
90            _ => None,
91        };
92
93        Ok(FullScanResponse {
94            chain_update,
95            tx_update,
96            last_active_indices,
97        })
98    }
99
100    fn sync<I: 'static, R: Into<SyncRequest<I>>>(
101        &self,
102        request: R,
103        parallel_requests: usize,
104    ) -> Result<SyncResponse, Error> {
105        let mut request: SyncRequest<I> = request.into();
106
107        let chain_tip = request.chain_tip();
108        let latest_blocks = if chain_tip.is_some() {
109            Some(fetch_latest_blocks(self)?)
110        } else {
111            None
112        };
113
114        let mut tx_update = TxUpdate::<ConfirmationBlockTime>::default();
115        let mut inserted_txs = HashSet::<Txid>::new();
116        tx_update.extend(fetch_txs_with_spks(
117            self,
118            &mut inserted_txs,
119            request.iter_spks(),
120            parallel_requests,
121        )?);
122        tx_update.extend(fetch_txs_with_txids(
123            self,
124            &mut inserted_txs,
125            request.iter_txids(),
126            parallel_requests,
127        )?);
128        tx_update.extend(fetch_txs_with_outpoints(
129            self,
130            &mut inserted_txs,
131            request.iter_outpoints(),
132            parallel_requests,
133        )?);
134
135        let chain_update = match (chain_tip, latest_blocks) {
136            (Some(chain_tip), Some(latest_blocks)) => Some(chain_update(
137                self,
138                &latest_blocks,
139                &chain_tip,
140                &tx_update.anchors,
141            )?),
142            _ => None,
143        };
144
145        Ok(SyncResponse {
146            chain_update,
147            tx_update,
148        })
149    }
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.
159fn fetch_latest_blocks(
160    client: &esplora_client::BlockingClient,
161) -> Result<BTreeMap<u32, BlockHash>, Error> {
162    Ok(client
163        .get_blocks(None)?
164        .into_iter()
165        .map(|b| (b.time.height, b.id))
166        .collect())
167}
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.
172fn fetch_block(
173    client: &esplora_client::BlockingClient,
174    latest_blocks: &BTreeMap<u32, BlockHash>,
175    height: u32,
176) -> Result<Option<BlockHash>, Error> {
177    if let Some(&hash) = latest_blocks.get(&height) {
178        return Ok(Some(hash));
179    }
180
181    // We avoid fetching blocks higher than previously fetched `latest_blocks` as the local chain
182    // tip is used to signal for the last-synced-up-to-height.
183    let &tip_height = latest_blocks
184        .keys()
185        .last()
186        .expect("must have atleast one entry");
187    if height > tip_height {
188        return Ok(None);
189    }
190
191    Ok(Some(client.get_block_hash(height)?))
192}
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`.
198fn chain_update(
199    client: &esplora_client::BlockingClient,
200    latest_blocks: &BTreeMap<u32, BlockHash>,
201    local_tip: &CheckPoint,
202    anchors: &BTreeSet<(ConfirmationBlockTime, Txid)>,
203) -> Result<CheckPoint, Error> {
204    let mut point_of_agreement = None;
205    let mut conflicts = vec![];
206    for local_cp in local_tip.iter() {
207        let remote_hash = match fetch_block(client, latest_blocks, local_cp.height())? {
208            Some(hash) => hash,
209            None => continue,
210        };
211        if remote_hash == local_cp.hash() {
212            point_of_agreement = Some(local_cp.clone());
213            break;
214        } else {
215            // it is not strictly necessary to include all the conflicted heights (we do need the
216            // first one) but it seems prudent to make sure the updated chain's heights are a
217            // superset of the existing chain after update.
218            conflicts.push(BlockId {
219                height: local_cp.height(),
220                hash: remote_hash,
221            });
222        }
223    }
224
225    let mut tip = point_of_agreement.expect("remote esplora should have same genesis block");
226
227    tip = tip
228        .extend(conflicts.into_iter().rev())
229        .expect("evicted are in order");
230
231    for (anchor, _) in anchors {
232        let height = anchor.block_id.height;
233        if tip.get(height).is_none() {
234            let hash = match fetch_block(client, latest_blocks, height)? {
235                Some(hash) => hash,
236                None => continue,
237            };
238            tip = tip.insert(BlockId { height, hash });
239        }
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() {
245        tip = tip.insert(BlockId { height, hash });
246    }
247
248    Ok(tip)
249}
250
251fn fetch_txs_with_keychain_spks<I: Iterator<Item = Indexed<ScriptBuf>>>(
252    client: &esplora_client::BlockingClient,
253    inserted_txs: &mut HashSet<Txid>,
254    mut keychain_spks: I,
255    stop_gap: usize,
256    parallel_requests: usize,
257) -> Result<(TxUpdate<ConfirmationBlockTime>, Option<u32>), Error> {
258    type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>);
259
260    let mut update = TxUpdate::<ConfirmationBlockTime>::default();
261    let mut last_index = Option::<u32>::None;
262    let mut last_active_index = Option::<u32>::None;
263
264    loop {
265        let handles = keychain_spks
266            .by_ref()
267            .take(parallel_requests)
268            .map(|(spk_index, spk)| {
269                std::thread::spawn({
270                    let client = client.clone();
271                    move || -> Result<TxsOfSpkIndex, Error> {
272                        let mut last_seen = None;
273                        let mut spk_txs = Vec::new();
274                        loop {
275                            let txs = client.scripthash_txs(&spk, last_seen)?;
276                            let tx_count = txs.len();
277                            last_seen = txs.last().map(|tx| tx.txid);
278                            spk_txs.extend(txs);
279                            if tx_count < 25 {
280                                break Ok((spk_index, spk_txs));
281                            }
282                        }
283                    }
284                })
285            })
286            .collect::<Vec<JoinHandle<Result<TxsOfSpkIndex, Error>>>>();
287
288        if handles.is_empty() {
289            break;
290        }
291
292        for handle in handles {
293            let (index, txs) = handle.join().expect("thread must not panic")?;
294            last_index = Some(index);
295            if !txs.is_empty() {
296                last_active_index = Some(index);
297            }
298            for tx in txs {
299                if inserted_txs.insert(tx.txid) {
300                    update.txs.push(tx.to_tx().into());
301                }
302                insert_anchor_from_status(&mut update, tx.txid, tx.status);
303                insert_prevouts(&mut update, tx.vin);
304            }
305        }
306
307        let last_index = last_index.expect("Must be set since handles wasn't empty.");
308        let gap_limit_reached = if let Some(i) = last_active_index {
309            last_index >= i.saturating_add(stop_gap as u32)
310        } else {
311            last_index + 1 >= stop_gap as u32
312        };
313        if gap_limit_reached {
314            break;
315        }
316    }
317
318    Ok((update, last_active_index))
319}
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.
329fn fetch_txs_with_spks<I: IntoIterator<Item = ScriptBuf>>(
330    client: &esplora_client::BlockingClient,
331    inserted_txs: &mut HashSet<Txid>,
332    spks: I,
333    parallel_requests: usize,
334) -> Result<TxUpdate<ConfirmationBlockTime>, Error> {
335    fetch_txs_with_keychain_spks(
336        client,
337        inserted_txs,
338        spks.into_iter().enumerate().map(|(i, spk)| (i as u32, spk)),
339        usize::MAX,
340        parallel_requests,
341    )
342    .map(|(update, _)| update)
343}
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.
351fn fetch_txs_with_txids<I: IntoIterator<Item = Txid>>(
352    client: &esplora_client::BlockingClient,
353    inserted_txs: &mut HashSet<Txid>,
354    txids: I,
355    parallel_requests: usize,
356) -> Result<TxUpdate<ConfirmationBlockTime>, Error> {
357    let mut update = TxUpdate::<ConfirmationBlockTime>::default();
358    // Only fetch for non-inserted txs.
359    let mut txids = txids
360        .into_iter()
361        .filter(|txid| !inserted_txs.contains(txid))
362        .collect::<Vec<Txid>>()
363        .into_iter();
364    loop {
365        let handles = txids
366            .by_ref()
367            .take(parallel_requests)
368            .map(|txid| {
369                let client = client.clone();
370                std::thread::spawn(move || {
371                    client
372                        .get_tx_info(&txid)
373                        .map_err(Box::new)
374                        .map(|t| (txid, t))
375                })
376            })
377            .collect::<Vec<JoinHandle<Result<(Txid, Option<Tx>), Error>>>>();
378
379        if handles.is_empty() {
380            break;
381        }
382
383        for handle in handles {
384            let (txid, tx_info) = handle.join().expect("thread must not panic")?;
385            if let Some(tx_info) = tx_info {
386                if inserted_txs.insert(txid) {
387                    update.txs.push(tx_info.to_tx().into());
388                }
389                insert_anchor_from_status(&mut update, txid, tx_info.status);
390                insert_prevouts(&mut update, tx_info.vin);
391            }
392        }
393    }
394    Ok(update)
395}
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.
403fn fetch_txs_with_outpoints<I: IntoIterator<Item = OutPoint>>(
404    client: &esplora_client::BlockingClient,
405    inserted_txs: &mut HashSet<Txid>,
406    outpoints: I,
407    parallel_requests: usize,
408) -> Result<TxUpdate<ConfirmationBlockTime>, Error> {
409    let outpoints = outpoints.into_iter().collect::<Vec<_>>();
410    let mut update = TxUpdate::<ConfirmationBlockTime>::default();
411
412    // make sure txs exists in graph and tx statuses are updated
413    // TODO: We should maintain a tx cache (like we do with Electrum).
414    update.extend(fetch_txs_with_txids(
415        client,
416        inserted_txs,
417        outpoints.iter().map(|op| op.txid),
418        parallel_requests,
419    )?);
420
421    // get outpoint spend-statuses
422    let mut outpoints = outpoints.into_iter();
423    let mut missing_txs = Vec::<Txid>::with_capacity(outpoints.len());
424    loop {
425        let handles = outpoints
426            .by_ref()
427            .take(parallel_requests)
428            .map(|op| {
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            })
436            .collect::<Vec<JoinHandle<Result<Option<OutputStatus>, Error>>>>();
437
438        if handles.is_empty() {
439            break;
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                };
448                if !inserted_txs.contains(&spend_txid) {
449                    missing_txs.push(spend_txid);
450                }
451                if let Some(spend_status) = op_status.status {
452                    insert_anchor_from_status(&mut update, spend_txid, spend_status);
453                }
454            }
455        }
456    }
457
458    update.extend(fetch_txs_with_txids(
459        client,
460        inserted_txs,
461        missing_txs,
462        parallel_requests,
463    )?);
464    Ok(update)
465}
466
467#[cfg(test)]
468mod 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<()> {
497        struct TestCase<'a> {
498            #[allow(dead_code)]
499            name: &'a str,
500            /// Initial blockchain height to start the env with.
501            initial_env_height: u32,
502            /// Initial checkpoint heights to start with in the local chain.
503            initial_cps: &'a [u32],
504            /// The final blockchain height of the env.
505            final_env_height: u32,
506            /// The anchors to test with: `(height, txid)`. Only the height is provided as we can fetch
507            /// the blockhash from the env.
508            anchors: &'a [(u32, Txid)],
509        }
510
511        let test_cases = [
512            TestCase {
513                name: "chain_extends",
514                initial_env_height: 60,
515                initial_cps: &[59, 60],
516                final_env_height: 90,
517                anchors: &[],
518            },
519            TestCase {
520                name: "introduce_older_heights",
521                initial_env_height: 50,
522                initial_cps: &[10, 15],
523                final_env_height: 50,
524                anchors: &[(11, h!("A")), (14, h!("B"))],
525            },
526            TestCase {
527                name: "introduce_older_heights_after_chain_extends",
528                initial_env_height: 50,
529                initial_cps: &[10, 15],
530                final_env_height: 100,
531                anchors: &[(11, h!("A")), (14, h!("B"))],
532            },
533        ];
534
535        for t in test_cases.into_iter() {
536            let env = TestEnv::new()?;
537            let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
538            let client = Builder::new(base_url.as_str()).build_blocking();
539
540            // set env to `initial_env_height`
541            if let Some(to_mine) = t
542                .initial_env_height
543                .checked_sub(env.make_checkpoint_tip().height())
544            {
545                env.mine_blocks(to_mine as _, None)?;
546            }
547            while client.get_height()? < t.initial_env_height {
548                std::thread::sleep(Duration::from_millis(10));
549            }
550
551            // craft initial `local_chain`
552            let local_chain = {
553                let (mut chain, _) = LocalChain::from_genesis_hash(env.genesis_hash()?);
554                // force `chain_update_blocking` to add all checkpoints in `t.initial_cps`
555                let anchors = t
556                    .initial_cps
557                    .iter()
558                    .map(|&height| -> anyhow::Result<_> {
559                        Ok((
560                            ConfirmationBlockTime {
561                                block_id: BlockId {
562                                    height,
563                                    hash: env.bitcoind.client.get_block_hash(height as _)?,
564                                },
565                                confirmation_time: height as _,
566                            },
567                            Txid::all_zeros(),
568                        ))
569                    })
570                    .collect::<anyhow::Result<BTreeSet<_>>>()?;
571                let update = chain_update(
572                    &client,
573                    &fetch_latest_blocks(&client)?,
574                    &chain.tip(),
575                    &anchors,
576                )?;
577                chain.apply_update(update)?;
578                chain
579            };
580
581            // extend env chain
582            if let Some(to_mine) = t
583                .final_env_height
584                .checked_sub(env.make_checkpoint_tip().height())
585            {
586                env.mine_blocks(to_mine as _, None)?;
587            }
588            while client.get_height()? < t.final_env_height {
589                std::thread::sleep(Duration::from_millis(10));
590            }
591
592            // craft update
593            let update = {
594                let anchors = t
595                    .anchors
596                    .iter()
597                    .map(|&(height, txid)| -> anyhow::Result<_> {
598                        Ok((
599                            ConfirmationBlockTime {
600                                block_id: BlockId {
601                                    height,
602                                    hash: env.bitcoind.client.get_block_hash(height as _)?,
603                                },
604                                confirmation_time: height as _,
605                            },
606                            txid,
607                        ))
608                    })
609                    .collect::<anyhow::Result<_>>()?;
610                chain_update(
611                    &client,
612                    &fetch_latest_blocks(&client)?,
613                    &local_chain.tip(),
614                    &anchors,
615                )?
616            };
617
618            // apply update
619            let mut updated_local_chain = local_chain.clone();
620            updated_local_chain.apply_update(update)?;
621
622            assert!(
623                {
624                    let initial_heights = local_chain
625                        .iter_checkpoints()
626                        .map(|cp| cp.height())
627                        .collect::<BTreeSet<_>>();
628                    let updated_heights = updated_local_chain
629                        .iter_checkpoints()
630                        .map(|cp| cp.height())
631                        .collect::<BTreeSet<_>>();
632                    updated_heights.is_superset(&initial_heights)
633                },
634                "heights from the initial chain must all be in the updated chain",
635            );
636
637            assert!(
638                {
639                    let exp_anchor_heights = t
640                        .anchors
641                        .iter()
642                        .map(|(h, _)| *h)
643                        .chain(t.initial_cps.iter().copied())
644                        .collect::<BTreeSet<_>>();
645                    let anchor_heights = updated_local_chain
646                        .iter_checkpoints()
647                        .map(|cp| cp.height())
648                        .collect::<BTreeSet<_>>();
649                    anchor_heights.is_superset(&exp_anchor_heights)
650                },
651                "anchor heights must all be in updated chain",
652            );
653        }
654
655        Ok(())
656    }
657
658    #[test]
659    fn update_local_chain() -> anyhow::Result<()> {
660        const TIP_HEIGHT: u32 = 50;
661
662        let env = TestEnv::new()?;
663        let blocks = {
664            let bitcoind_client = &env.bitcoind.client;
665            assert_eq!(bitcoind_client.get_block_count()?, 1);
666            [
667                (0, bitcoind_client.get_block_hash(0)?),
668                (1, bitcoind_client.get_block_hash(1)?),
669            ]
670            .into_iter()
671            .chain((2..).zip(env.mine_blocks((TIP_HEIGHT - 1) as usize, None)?))
672            .collect::<BTreeMap<_, _>>()
673        };
674        // so new blocks can be seen by Electrs
675        let env = env.reset_electrsd()?;
676        let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
677        let client = Builder::new(base_url.as_str()).build_blocking();
678
679        struct TestCase {
680            name: &'static str,
681            /// Original local chain to start off with.
682            chain: LocalChain,
683            /// Heights of floating anchors. [`chain_update_blocking`] will request for checkpoints
684            /// of these heights.
685            request_heights: &'static [u32],
686            /// The expected local chain result (heights only).
687            exp_update_heights: &'static [u32],
688        }
689
690        let test_cases = [
691            TestCase {
692                name: "request_later_blocks",
693                chain: local_chain![(0, blocks[&0]), (21, blocks[&21])],
694                request_heights: &[22, 25, 28],
695                exp_update_heights: &[21, 22, 25, 28],
696            },
697            TestCase {
698                name: "request_prev_blocks",
699                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (5, blocks[&5])],
700                request_heights: &[4],
701                exp_update_heights: &[4, 5],
702            },
703            TestCase {
704                name: "request_prev_blocks_2",
705                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (10, blocks[&10])],
706                request_heights: &[4, 6],
707                exp_update_heights: &[4, 6, 10],
708            },
709            TestCase {
710                name: "request_later_and_prev_blocks",
711                chain: local_chain![(0, blocks[&0]), (7, blocks[&7]), (11, blocks[&11])],
712                request_heights: &[8, 9, 15],
713                exp_update_heights: &[8, 9, 11, 15],
714            },
715            TestCase {
716                name: "request_tip_only",
717                chain: local_chain![(0, blocks[&0]), (5, blocks[&5]), (49, blocks[&49])],
718                request_heights: &[TIP_HEIGHT],
719                exp_update_heights: &[49],
720            },
721            TestCase {
722                name: "request_nothing",
723                chain: local_chain![(0, blocks[&0]), (13, blocks[&13]), (23, blocks[&23])],
724                request_heights: &[],
725                exp_update_heights: &[23],
726            },
727            TestCase {
728                name: "request_nothing_during_reorg",
729                chain: local_chain![(0, blocks[&0]), (13, blocks[&13]), (23, h!("23"))],
730                request_heights: &[],
731                exp_update_heights: &[13, 23],
732            },
733            TestCase {
734                name: "request_nothing_during_reorg_2",
735                chain: local_chain![
736                    (0, blocks[&0]),
737                    (21, blocks[&21]),
738                    (22, h!("22")),
739                    (23, h!("23"))
740                ],
741                request_heights: &[],
742                exp_update_heights: &[21, 22, 23],
743            },
744            TestCase {
745                name: "request_prev_blocks_during_reorg",
746                chain: local_chain![
747                    (0, blocks[&0]),
748                    (21, blocks[&21]),
749                    (22, h!("22")),
750                    (23, h!("23"))
751                ],
752                request_heights: &[17, 20],
753                exp_update_heights: &[17, 20, 21, 22, 23],
754            },
755            TestCase {
756                name: "request_later_blocks_during_reorg",
757                chain: local_chain![
758                    (0, blocks[&0]),
759                    (9, blocks[&9]),
760                    (22, h!("22")),
761                    (23, h!("23"))
762                ],
763                request_heights: &[25, 27],
764                exp_update_heights: &[9, 22, 23, 25, 27],
765            },
766            TestCase {
767                name: "request_later_blocks_during_reorg_2",
768                chain: local_chain![(0, blocks[&0]), (9, h!("9"))],
769                request_heights: &[10],
770                exp_update_heights: &[0, 9, 10],
771            },
772            TestCase {
773                name: "request_later_and_prev_blocks_during_reorg",
774                chain: local_chain![(0, blocks[&0]), (1, blocks[&1]), (9, h!("9"))],
775                request_heights: &[8, 11],
776                exp_update_heights: &[1, 8, 9, 11],
777            },
778        ];
779
780        for (i, t) in test_cases.into_iter().enumerate() {
781            let mut chain = t.chain;
782
783            let mock_anchors = t
784                .request_heights
785                .iter()
786                .map(|&h| {
787                    let anchor_blockhash: BlockHash = bdk_chain::bitcoin::hashes::Hash::hash(
788                        &format!("hash_at_height_{}", h).into_bytes(),
789                    );
790                    let txid: Txid = bdk_chain::bitcoin::hashes::Hash::hash(
791                        &format!("txid_at_height_{}", h).into_bytes(),
792                    );
793                    let anchor = ConfirmationBlockTime {
794                        block_id: BlockId {
795                            height: h,
796                            hash: anchor_blockhash,
797                        },
798                        confirmation_time: h as _,
799                    };
800                    (anchor, txid)
801                })
802                .collect::<BTreeSet<_>>();
803            let chain_update = chain_update(
804                &client,
805                &fetch_latest_blocks(&client)?,
806                &chain.tip(),
807                &mock_anchors,
808            )?;
809
810            let update_blocks = chain_update
811                .iter()
812                .map(|cp| cp.block_id())
813                .collect::<BTreeSet<_>>();
814
815            let exp_update_blocks = t
816                .exp_update_heights
817                .iter()
818                .map(|&height| {
819                    let hash = blocks[&height];
820                    BlockId { height, hash }
821                })
822                .chain(
823                    // Electrs Esplora `get_block` call fetches 10 blocks which is included in the
824                    // update
825                    blocks
826                        .range(TIP_HEIGHT - 9..)
827                        .map(|(&height, &hash)| BlockId { height, hash }),
828                )
829                .collect::<BTreeSet<_>>();
830
831            assert!(
832                update_blocks.is_superset(&exp_update_blocks),
833                "[{}:{}] unexpected update",
834                i,
835                t.name
836            );
837
838            let _ = chain
839                .apply_update(chain_update)
840                .unwrap_or_else(|err| panic!("[{}:{}] update failed to apply: {}", i, t.name, err));
841
842            // all requested heights must exist in the final chain
843            for height in t.request_heights {
844                let exp_blockhash = blocks.get(height).expect("block must exist in bitcoind");
845                assert_eq!(
846                    chain.get(*height).map(|cp| cp.hash()),
847                    Some(*exp_blockhash),
848                    "[{}:{}] block {}:{} must exist in final chain",
849                    i,
850                    t.name,
851                    height,
852                    exp_blockhash
853                );
854            }
855        }
856
857        Ok(())
858    }
859}