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
12pub type Error = Box<esplora_client::Error>;
14
15pub trait EsploraExt {
19 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 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
152fn 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
169fn 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 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
194fn 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 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 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
321fn 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
345fn 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 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
397fn 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 update.extend(fetch_txs_with_txids(
415 client,
416 inserted_txs,
417 outpoints.iter().map(|op| op.txid),
418 parallel_requests,
419 )?);
420
421 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 #[test]
496 pub fn test_finalize_chain_update() -> anyhow::Result<()> {
497 struct TestCase<'a> {
498 #[allow(dead_code)]
499 name: &'a str,
500 initial_env_height: u32,
502 initial_cps: &'a [u32],
504 final_env_height: u32,
506 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 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 let local_chain = {
553 let (mut chain, _) = LocalChain::from_genesis_hash(env.genesis_hash()?);
554 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 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 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 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 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 chain: LocalChain,
683 request_heights: &'static [u32],
686 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 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 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}