1use alloc::sync::Arc;
2use core::fmt;
3
4use arc_swap::{ArcSwap, ArcSwapOption};
5use bitcoin::consensus::encode::serialize;
6use bitcoin::hashes::Hash as _;
7use bitcoin::hex::{DisplayHex, FromHex as _};
8use bitcoin::{Block, Transaction, Txid};
9use bitcoin_rs_chain::TipSnapshot;
10use bitcoin_rs_mempool::{Mempool, MempoolLimits};
11use bitcoin_rs_primitives::{Hash256, Network};
12use compact_str::CompactString;
13use crossbeam_channel::{Receiver, Sender, unbounded};
14use hashbrown::HashMap;
15use parking_lot::{Mutex, RwLock};
16
17const SERIALIZED_BLOCK_HEADER_LEN: usize = 80;
18
19#[derive(Clone, Debug)]
21pub struct BlockRecord {
22 pub hash: Hash256,
24 pub height: u32,
26 pub block_hex: String,
28 pub body_size: usize,
30 pub header_hex: String,
32 pub tx_count: usize,
34 pub time: u32,
36}
37
38pub trait BlockBodySource: Send + Sync {
40 fn block_body(&self, height: u32, hash: Hash256) -> Option<Vec<u8>>;
42}
43
44impl BlockRecord {
45 #[must_use]
47 pub fn from_block(height: u32, block: &Block) -> Self {
48 let block_bytes = serialize(block);
49 Self::from_block_bytes(height, block, &block_bytes)
50 }
51
52 #[must_use]
57 pub fn from_block_bytes(height: u32, block: &Block, block_bytes: &[u8]) -> Self {
58 let block_hash = block.block_hash();
59 let hash = Hash256::from_le_bytes(block_hash.as_byte_array());
60 let header_hex = header_hex_from_block_bytes(block, block_bytes);
61 let block_hex = block_bytes.to_lower_hex_string();
62 Self {
63 hash,
64 height,
65 block_hex,
66 body_size: block_bytes.len(),
67 header_hex,
68 tx_count: block.txdata.len(),
69 time: block.header.time,
70 }
71 }
72
73 #[must_use]
75 pub fn from_block_metadata(height: u32, block: &Block) -> Self {
76 let block_bytes = serialize(block);
77 Self::from_block_metadata_bytes(height, block, &block_bytes)
78 }
79
80 #[must_use]
82 pub fn from_block_metadata_bytes(height: u32, block: &Block, block_bytes: &[u8]) -> Self {
83 let block_hash = block.block_hash();
84 let hash = Hash256::from_le_bytes(block_hash.as_byte_array());
85 let header_hex = header_hex_from_block_bytes(block, block_bytes);
86 Self {
87 hash,
88 height,
89 block_hex: String::new(),
90 body_size: block_bytes.len(),
91 header_hex,
92 tx_count: block.txdata.len(),
93 time: block.header.time,
94 }
95 }
96
97 #[must_use]
99 pub fn synthetic(height: u32, hash: Hash256) -> Self {
100 Self {
101 hash,
102 height,
103 block_hex: String::new(),
104 body_size: 0,
105 header_hex: String::new(),
106 tx_count: 0,
107 time: 0,
108 }
109 }
110}
111
112fn header_hex_from_block_bytes(block: &Block, block_bytes: &[u8]) -> String {
113 block_bytes.get(..SERIALIZED_BLOCK_HEADER_LEN).map_or_else(
114 || serialize(&block.header).to_lower_hex_string(),
115 DisplayHex::to_lower_hex_string,
116 )
117}
118
119#[derive(Clone, Debug, Default)]
121pub struct NetworkState {
122 pub connection_count: u64,
124 pub bytes_recv: u64,
126 pub bytes_sent: u64,
128 pub timestamp: u64,
130}
131
132#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
134pub struct PruneStatus {
135 pub pruned: bool,
137 pub pruneheight: Option<u32>,
139}
140
141#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
143pub struct PruneResult {
144 pub requested_height: u32,
146 pub pruneheight: u32,
148 pub block_rows_removed: u64,
150 pub undo_rows_removed: u64,
152 pub bytes_freed: u64,
154}
155
156#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct ZmqNotification {
159 pub notification_type: CompactString,
161 pub address: String,
163 pub hwm: u32,
165}
166
167impl ZmqNotification {
168 #[must_use]
170 pub fn new(
171 notification_type: impl Into<CompactString>,
172 address: impl Into<String>,
173 hwm: u32,
174 ) -> Self {
175 Self {
176 notification_type: notification_type.into(),
177 address: address.into(),
178 hwm,
179 }
180 }
181}
182
183#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
185pub enum PruneServiceError {
186 #[error("{0}")]
188 Failed(String),
189}
190
191impl PruneServiceError {
192 #[must_use]
194 pub fn failed(message: impl Into<String>) -> Self {
195 Self::Failed(message.into())
196 }
197}
198
199pub trait PruneService: Send + Sync {
201 fn prune_to_height(&self, requested_height: u32) -> Result<PruneResult, PruneServiceError>;
203
204 fn status(&self) -> PruneStatus;
206}
207#[derive(Debug, Default)]
208struct NoopFilterIndex;
209
210impl bitcoin_rs_filters::FilterIndexLike for NoopFilterIndex {
211 fn put_filter(
212 &self,
213 _block_hash: bitcoin_rs_primitives::Hash256,
214 _prev_header: bitcoin_rs_primitives::Hash256,
215 _filter_bytes: &[u8],
216 ) -> Result<bitcoin_rs_primitives::Hash256, bitcoin_rs_filters::FilterIndexError> {
217 Ok(bitcoin_rs_primitives::Hash256::default())
218 }
219
220 fn filter_header(
221 &self,
222 _block_hash: bitcoin_rs_primitives::Hash256,
223 ) -> Result<Option<bitcoin_rs_primitives::Hash256>, bitcoin_rs_filters::FilterIndexError> {
224 Ok(None)
225 }
226
227 fn filter(
228 &self,
229 _block_hash: bitcoin_rs_primitives::Hash256,
230 ) -> Result<Option<Vec<u8>>, bitcoin_rs_filters::FilterIndexError> {
231 Ok(None)
232 }
233}
234
235fn noop_filter_index() -> Arc<Box<dyn bitcoin_rs_filters::FilterIndexLike>> {
236 let filter_index: Box<dyn bitcoin_rs_filters::FilterIndexLike> = Box::new(NoopFilterIndex);
237 Arc::new(filter_index)
238}
239
240pub struct Context {
242 pub chain_tip: Arc<ArcSwapOption<TipSnapshot>>,
244 pub applied_tip: Arc<ArcSwapOption<TipSnapshot>>,
246 pub mempool: Arc<RwLock<Mempool>>,
248 pub blocks: Arc<RwLock<Vec<BlockRecord>>>,
250 pub transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
252 pub utxo: Arc<bitcoin_rs_utxo::UtxoSet>,
254 pub coin_stats: Arc<bitcoin_rs_coinstats::CoinStatsListener>,
256 pub filter_index: Arc<Box<dyn bitcoin_rs_filters::FilterIndexLike>>,
258 pub prune_service: Option<Arc<dyn PruneService>>,
260 pub indexer: Option<Arc<Mutex<Box<dyn bitcoin_rs_index::IndexerLike>>>>,
263 pub network: Arc<RwLock<NetworkState>>,
265 pub chain_network: Network,
268 pub peers: Arc<RwLock<Vec<bitcoin_rs_p2p::PeerInfo>>>,
270 pub block_tree: Arc<parking_lot::RwLock<bitcoin_rs_chain::BlockTree>>,
272 pub block_body_source: Option<Arc<dyn BlockBodySource>>,
274 pub mining_template_id: Arc<ArcSwap<CompactString>>,
276 pub mining_notifications: Receiver<()>,
278 pub inbound_blocks_sender: Option<crossbeam_channel::Sender<bitcoin_rs_p2p::InboundBlock>>,
282 pub p2p_outbound_sender: Option<crossbeam_channel::Sender<std::net::SocketAddr>>,
285 pub banned: Arc<parking_lot::RwLock<Vec<bitcoin_rs_p2p::BannedSubnet>>>,
287 pub added_nodes: Arc<parking_lot::RwLock<Vec<std::net::SocketAddr>>>,
289 pub zmq_notifications: Arc<[ZmqNotification]>,
291 mining_sender: Sender<()>,
292}
293#[allow(clippy::non_send_fields_in_send_ty)]
298unsafe impl Send for Context {}
299
300unsafe impl Sync for Context {}
303
304impl fmt::Debug for Context {
305 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306 f.debug_struct("Context").finish_non_exhaustive()
307 }
308}
309
310impl Default for Context {
311 fn default() -> Self {
312 Self::new()
313 }
314}
315
316impl Context {
317 #[must_use]
319 #[allow(clippy::arc_with_non_send_sync)]
320 pub fn new() -> Self {
321 let (mining_sender, mining_notifications) = unbounded();
322 let coin_stats_listener = bitcoin_rs_coinstats::CoinStatsListener::new(
323 bitcoin_rs_coinstats::CoinStats::default(),
324 );
325 let mut utxo = bitcoin_rs_utxo::UtxoSet::new();
326 utxo.set_listener(Box::new(coin_stats_listener.clone()));
327 let coin_stats = Arc::new(coin_stats_listener);
328 Self {
329 chain_tip: Arc::new(ArcSwapOption::empty()),
330 applied_tip: Arc::new(ArcSwapOption::empty()),
331 mempool: Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))),
332 blocks: Arc::new(RwLock::new(Vec::new())),
333 transactions: Arc::new(RwLock::new(HashMap::new())),
334 utxo: Arc::new(utxo),
335 coin_stats,
336 filter_index: noop_filter_index(),
337 indexer: None,
338 prune_service: None,
339 network: Arc::new(RwLock::new(NetworkState::default())),
340 chain_network: Network::Mainnet,
341 peers: Arc::new(RwLock::new(Vec::new())),
342 block_tree: Arc::new(parking_lot::RwLock::new(bitcoin_rs_chain::BlockTree::new())),
343 block_body_source: None,
344 mining_template_id: Arc::new(ArcSwap::from_pointee(CompactString::new("0"))),
345 mining_notifications,
346 inbound_blocks_sender: None,
347 p2p_outbound_sender: None,
348 banned: Arc::new(parking_lot::RwLock::new(Vec::new())),
349 added_nodes: Arc::new(parking_lot::RwLock::new(Vec::new())),
350 zmq_notifications: Arc::from(Vec::<ZmqNotification>::new()),
351 mining_sender,
352 }
353 }
354 #[must_use]
361 #[allow(clippy::too_many_arguments)]
362 pub fn from_handles(
363 chain_tip: Arc<ArcSwapOption<TipSnapshot>>,
364 applied_tip: Arc<ArcSwapOption<TipSnapshot>>,
365 mempool: Arc<RwLock<Mempool>>,
366 blocks: Arc<RwLock<Vec<BlockRecord>>>,
367 transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
368 utxo: Arc<bitcoin_rs_utxo::UtxoSet>,
369 coin_stats: Arc<bitcoin_rs_coinstats::CoinStatsListener>,
370 filter_index: Arc<Box<dyn bitcoin_rs_filters::FilterIndexLike>>,
371 network: Arc<RwLock<NetworkState>>,
372 mining_template_id: Arc<ArcSwap<CompactString>>,
373 peers: Arc<RwLock<Vec<bitcoin_rs_p2p::PeerInfo>>>,
374 block_tree: Arc<parking_lot::RwLock<bitcoin_rs_chain::BlockTree>>,
375 chain_network: Network,
376 inbound_blocks_sender: Option<crossbeam_channel::Sender<bitcoin_rs_p2p::InboundBlock>>,
377 p2p_outbound_sender: Option<crossbeam_channel::Sender<std::net::SocketAddr>>,
378 banned: Arc<parking_lot::RwLock<Vec<bitcoin_rs_p2p::BannedSubnet>>>,
379 added_nodes: Arc<parking_lot::RwLock<Vec<std::net::SocketAddr>>>,
380 indexer: Option<Arc<Mutex<Box<dyn bitcoin_rs_index::IndexerLike>>>>,
381 ) -> Self {
382 let (mining_sender, mining_notifications) = unbounded();
383 Self {
384 chain_tip,
385 applied_tip,
386 mempool,
387 blocks,
388 transactions,
389 utxo,
390 coin_stats,
391 filter_index,
392 indexer,
393 network,
394 chain_network,
395 peers,
396 block_tree,
397 block_body_source: None,
398 mining_template_id,
399 mining_notifications,
400 inbound_blocks_sender,
401 p2p_outbound_sender,
402 banned,
403 added_nodes,
404 prune_service: None,
405 zmq_notifications: Arc::from(Vec::<ZmqNotification>::new()),
406 mining_sender,
407 }
408 }
409
410 #[must_use]
412 pub fn with_block_body_source(mut self, source: Arc<dyn BlockBodySource>) -> Self {
413 self.block_body_source = Some(source);
414 self
415 }
416
417 #[must_use]
419 pub fn with_prune_service(mut self, prune_service: Arc<dyn PruneService>) -> Self {
420 self.prune_service = Some(prune_service);
421 self
422 }
423
424 #[must_use]
426 pub fn with_zmq_notifications(mut self, notifications: Vec<ZmqNotification>) -> Self {
427 self.zmq_notifications = Arc::from(notifications);
428 self
429 }
430
431 #[must_use]
433 pub fn zmq_notifications(&self) -> &[ZmqNotification] {
434 self.zmq_notifications.as_ref()
435 }
436
437 #[must_use]
439 pub fn prune_status(&self) -> PruneStatus {
440 self.prune_service
441 .as_ref()
442 .map_or_else(PruneStatus::default, |service| service.status())
443 }
444
445 #[must_use]
448 pub fn difficulty_for_bits(&self, bits: bitcoin::CompactTarget) -> f64 {
449 let params = bitcoin::params::Params::new(bitcoin_network(self.chain_network));
450 let current_target = bitcoin::pow::Target::from_compact(bits);
451 if current_target == bitcoin::pow::Target::ZERO {
452 return 0.0;
453 }
454
455 target_to_f64(params.max_attainable_target) / target_to_f64(current_target)
456 }
457
458 pub fn set_chain_tip(&self, tip: TipSnapshot) {
460 self.mining_template_id
461 .store(Arc::new(CompactString::from(tip.hash.to_string_be())));
462 self.chain_tip.store(Some(Arc::new(tip)));
463 let _ignored = self.mining_sender.send(());
464 }
465
466 pub fn set_applied_tip(&self, tip: TipSnapshot) {
468 self.applied_tip.store(Some(Arc::new(tip)));
469 }
470
471 pub fn add_block(&self, record: BlockRecord) {
473 self.blocks.write().push(record);
474 }
475
476 pub fn add_transaction(&self, tx: Transaction) -> Txid {
478 let txid = tx.compute_txid();
479 self.transactions.write().insert(txid, tx);
480 txid
481 }
482
483 #[must_use]
485 pub fn height(&self) -> u32 {
486 self.chain_tip.load_full().map_or(0, |tip| tip.height)
487 }
488
489 #[must_use]
492 pub fn applied_height(&self) -> u32 {
493 self.applied_tip.load_full().map_or(0, |tip| tip.height)
494 }
495
496 #[must_use]
498 pub fn applied_hash(&self) -> Hash256 {
499 self.applied_tip
500 .load_full()
501 .map_or_else(Hash256::default, |tip| tip.hash)
502 }
503
504 #[must_use]
506 pub fn best_hash(&self) -> Hash256 {
507 self.chain_tip
508 .load_full()
509 .map_or_else(Hash256::default, |tip| tip.hash)
510 }
511
512 #[must_use]
516 pub fn chainwork_hex(&self) -> String {
517 let Some(tip) = self.chain_tip.load_full() else {
518 return "00".to_owned();
519 };
520 let bytes: [u8; 32] = tip.chainwork.to_be_bytes();
521 let mut out = String::with_capacity(bytes.len() * 2);
522 for byte in bytes {
523 use core::fmt::Write as _;
524
525 let _: fmt::Result = write!(&mut out, "{byte:02x}");
526 }
527 out
528 }
529
530 #[must_use]
532 pub fn block_hash_at_height(&self, height: u32) -> Option<Hash256> {
533 self.blocks
534 .read()
535 .iter()
536 .find(|record| record.height == height)
537 .map(|record| record.hash)
538 .or_else(|| {
539 self.chain_tip.load_full().and_then(|tip| {
540 if tip.height == height {
541 Some(tip.hash)
542 } else {
543 None
544 }
545 })
546 })
547 .or_else(|| {
548 if height == 0 {
549 let genesis_hash = bitcoin::blockdata::constants::genesis_block(
550 bitcoin_network(self.chain_network),
551 )
552 .block_hash();
553 Some(Hash256::from_le_bytes(genesis_hash.as_byte_array()))
554 } else {
555 None
556 }
557 })
558 }
559
560 #[must_use]
562 pub fn block_by_hash(&self, hash: Hash256) -> Option<BlockRecord> {
563 self.blocks
564 .read()
565 .iter()
566 .find(|record| record.hash == hash)
567 .cloned()
568 }
569
570 #[must_use]
576 pub fn block_by_height(&self, height: u32) -> Option<BlockRecord> {
577 self.blocks
578 .read()
579 .iter()
580 .find(|record| record.height == height)
581 .cloned()
582 }
583
584 #[must_use]
586 pub fn block_body_bytes(&self, record: &BlockRecord) -> Option<Vec<u8>> {
587 if !record.block_hex.is_empty() {
588 return Vec::<u8>::from_hex(&record.block_hex).ok();
589 }
590 self.block_body_source
591 .as_ref()?
592 .block_body(record.height, record.hash)
593 }
594
595 #[must_use]
597 pub fn block_body_hex(&self, record: &BlockRecord) -> Option<String> {
598 if !record.block_hex.is_empty() {
599 return Some(record.block_hex.clone());
600 }
601 Some(self.block_body_bytes(record)?.to_lower_hex_string())
602 }
603
604 #[must_use]
607 pub fn median_time_past_for_hash(&self, hash: bitcoin_rs_primitives::Hash256) -> Option<u32> {
608 let tree = self.block_tree.read();
609 let node_id = tree.lookup(hash)?;
610 tree.median_time_past_at(node_id, 11)
611 }
612
613 #[must_use]
618 pub fn height_for_hash(&self, hash: bitcoin_rs_primitives::Hash256) -> Option<u32> {
619 self.block_tree.read().height_of_hash(hash)
620 }
621
622 #[must_use]
624 pub fn chain_work_hex_for_hash(&self, hash: bitcoin_rs_primitives::Hash256) -> Option<String> {
625 let tree = self.block_tree.read();
626 let node = tree.node_by_hash(hash)?;
627 let bytes: [u8; 32] = node.chainwork.to_be_bytes();
628 Some(bytes.to_lower_hex_string())
629 }
630
631 #[must_use]
633 pub fn next_block_hash_for_height(
634 &self,
635 height: u32,
636 ) -> Option<bitcoin_rs_primitives::Hash256> {
637 let tree = self.block_tree.read();
638 let tip = tree.tip()?;
639 let next_height = height.checked_add(1)?;
640 let node_id = tree.node_at_height_from(tip.tip_id, next_height)?;
641 let node = tree.node(node_id).ok()?;
642 Some(node.hash)
643 }
644}
645
646impl bitcoin_rs_index::BlockSource for Context {
647 fn block_at_height(&self, height: u32) -> Option<bitcoin::Block> {
648 let record = self
649 .blocks
650 .read()
651 .iter()
652 .find(|record| record.height == height)
653 .cloned()?;
654 let bytes = self.block_body_bytes(&record)?;
655 bitcoin::consensus::encode::deserialize::<bitcoin::Block>(&bytes).ok()
656 }
657}
658
659fn bitcoin_network(network: Network) -> bitcoin::Network {
660 match network {
661 Network::Mainnet => bitcoin::Network::Bitcoin,
662 Network::Testnet3 => bitcoin::Network::Testnet,
663 Network::Testnet4 => bitcoin::Network::Testnet4,
664 Network::Signet => bitcoin::Network::Signet,
665 Network::Regtest => bitcoin::Network::Regtest,
666 }
667}
668
669fn target_to_f64(target: bitcoin::pow::Target) -> f64 {
670 target
671 .to_be_bytes()
672 .iter()
673 .fold(0.0_f64, |acc, &byte| acc.mul_add(256.0, f64::from(byte)))
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 #[test]
681 #[allow(clippy::arc_with_non_send_sync)]
682 fn from_handles_shares_tip_handles_with_caller() {
683 use alloc::sync::Arc;
684
685 let chain_tip = Arc::new(ArcSwapOption::empty());
686 let applied_tip = Arc::new(ArcSwapOption::empty());
687 let utxo = Arc::new(bitcoin_rs_utxo::UtxoSet::new());
688 let coin_stats = Arc::new(bitcoin_rs_coinstats::CoinStatsListener::new(
689 bitcoin_rs_coinstats::CoinStats::default(),
690 ));
691 let filter_index = noop_filter_index();
692 let block_tree = Arc::new(RwLock::new(bitcoin_rs_chain::BlockTree::new()));
693 let banned = Arc::new(RwLock::new(Vec::<bitcoin_rs_p2p::BannedSubnet>::new()));
694 let added_nodes = Arc::new(RwLock::new(Vec::new()));
695 let ctx = Context::from_handles(
696 Arc::clone(&chain_tip),
697 Arc::clone(&applied_tip),
698 Arc::new(RwLock::new(Mempool::new(MempoolLimits::default()))),
699 Arc::new(RwLock::new(Vec::new())),
700 Arc::new(RwLock::new(HashMap::new())),
701 Arc::clone(&utxo),
702 Arc::clone(&coin_stats),
703 Arc::clone(&filter_index),
704 Arc::new(RwLock::new(NetworkState::default())),
705 Arc::new(ArcSwap::from_pointee(CompactString::new("0"))),
706 Arc::new(RwLock::new(Vec::new())),
707 Arc::clone(&block_tree),
708 Network::Mainnet,
709 None,
710 None,
711 Arc::clone(&banned),
712 Arc::clone(&added_nodes),
713 None,
714 );
715 assert!(
716 Arc::ptr_eq(&ctx.chain_tip, &chain_tip),
717 "chain_tip must be shared with caller"
718 );
719 assert!(
720 Arc::ptr_eq(&ctx.applied_tip, &applied_tip),
721 "applied_tip must be shared with caller"
722 );
723 assert!(
724 Arc::ptr_eq(&ctx.utxo, &utxo),
725 "utxo must be shared with caller"
726 );
727 assert!(
728 Arc::ptr_eq(&ctx.coin_stats, &coin_stats),
729 "coin_stats must be shared with caller"
730 );
731 assert!(
732 Arc::ptr_eq(&ctx.filter_index, &filter_index),
733 "filter_index must be shared with caller"
734 );
735 assert!(
736 Arc::ptr_eq(&ctx.block_tree, &block_tree),
737 "block_tree must be shared with caller"
738 );
739 assert!(
740 Arc::ptr_eq(&ctx.banned, &banned),
741 "banned must be shared with caller"
742 );
743 assert!(
744 Arc::ptr_eq(&ctx.added_nodes, &added_nodes),
745 "added_nodes must be shared with caller"
746 );
747 }
748
749 #[test]
750 fn new_context_wires_utxo_commits_to_coin_stats() {
751 use bitcoin::{Amount, ScriptBuf};
752 use bitcoin_rs_primitives::{Hash256, OutPoint, TxOut};
753 use bitcoin_rs_utxo::{BlockChanges, UtxoAdd};
754
755 let ctx = Context::new();
756 let outpoint = OutPoint::new(Hash256::from_le_bytes(&[1_u8; 32]), 0);
757 let txout = TxOut {
758 value: Amount::from_sat(125_000),
759 script_pubkey: ScriptBuf::new(),
760 };
761 let mut changes = BlockChanges::default();
762 changes.add(UtxoAdd::new(outpoint, txout, true, 7));
763
764 ctx.utxo
765 .commit_block(&changes, &Hash256::default())
766 .unwrap_or_else(|err| panic!("commit_block failed: {err}"));
767
768 let snapshot = ctx.coin_stats.snapshot();
769 assert_eq!(snapshot.utxo_count, 1);
770 assert_eq!(snapshot.total_amount, 125_000);
771 }
772
773 #[test]
774 fn block_record_from_block_bytes_matches_from_block() {
775 let block = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest);
776 let block_bytes = serialize(&block);
777
778 let from_block = BlockRecord::from_block(0, &block);
779 let from_bytes = BlockRecord::from_block_bytes(0, &block, &block_bytes);
780
781 assert_eq!(from_bytes.hash, from_block.hash);
782 assert_eq!(from_bytes.height, from_block.height);
783 assert_eq!(from_bytes.block_hex, from_block.block_hex);
784 assert_eq!(from_bytes.body_size, from_block.body_size);
785 assert_eq!(from_bytes.header_hex, from_block.header_hex);
786 assert_eq!(from_bytes.tx_count, from_block.tx_count);
787 assert_eq!(from_bytes.time, from_block.time);
788 }
789
790 #[test]
791 fn context_reads_metadata_only_block_record_from_body_source() {
792 struct SingleBlockSource {
793 height: u32,
794 hash: Hash256,
795 body: Vec<u8>,
796 }
797
798 impl BlockBodySource for SingleBlockSource {
799 fn block_body(&self, height: u32, hash: Hash256) -> Option<Vec<u8>> {
800 (height == self.height && hash == self.hash).then(|| self.body.clone())
801 }
802 }
803
804 let block = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest);
805 let body = serialize(&block);
806 let record = BlockRecord::from_block_metadata(0, &block);
807 let source = Arc::new(SingleBlockSource {
808 height: 0,
809 hash: record.hash,
810 body: body.clone(),
811 });
812 let ctx = Context::new().with_block_body_source(source);
813 ctx.add_block(record.clone());
814
815 assert!(record.block_hex.is_empty());
816 assert_eq!(record.body_size, body.len());
817 assert_eq!(
818 ctx.block_body_bytes(&record).as_deref(),
819 Some(body.as_slice())
820 );
821 let expected_hex = body.to_lower_hex_string();
822 assert_eq!(
823 ctx.block_body_hex(&record).as_deref(),
824 Some(expected_hex.as_str())
825 );
826 }
827
828 #[test]
829 fn block_by_height_returns_record_after_add_block() {
830 use bitcoin_rs_primitives::Hash256;
831
832 let ctx = Context::new();
833 let record = BlockRecord::synthetic(42, Hash256::default());
834 ctx.add_block(record);
835
836 let Some(found) = ctx.block_by_height(42) else {
837 panic!("expected record at height 42");
838 };
839 assert_eq!(found.height, 42);
840 }
841
842 #[test]
843 fn height_for_hash_returns_none_when_tree_empty() {
844 let ctx = Context::new();
845 let unknown = bitcoin_rs_primitives::Hash256::from_le_bytes(&[0xff_u8; 32]);
846
847 assert!(ctx.height_for_hash(unknown).is_none());
848 }
849}