Skip to main content

bitcoin_rs_rpc/
context.rs

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/// Block data made available to RPC handlers without forcing storage I/O.
20#[derive(Clone, Debug)]
21pub struct BlockRecord {
22    /// Block hash in conventional big-endian hex order.
23    pub hash: Hash256,
24    /// Height in the active chain.
25    pub height: u32,
26    /// Serialized block bytes as lowercase hex.
27    pub block_hex: String,
28    /// Serialized block byte length.
29    pub body_size: usize,
30    /// Serialized block header bytes as lowercase hex.
31    pub header_hex: String,
32    /// Transaction count in the block.
33    pub tx_count: usize,
34    /// Block header timestamp (UNIX seconds).
35    pub time: u32,
36}
37
38/// Storage-backed block body reader used when block records keep only metadata.
39pub trait BlockBodySource: Send + Sync {
40    /// Returns serialized block bytes for `height` and `hash`, if available.
41    fn block_body(&self, height: u32, hash: Hash256) -> Option<Vec<u8>>;
42}
43
44impl BlockRecord {
45    /// Builds a record from a decoded Bitcoin block.
46    #[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    /// Builds a record from a decoded Bitcoin block and its serialized bytes.
53    ///
54    /// Callers on hot paths can pass bytes already produced for persistence or
55    /// indexes instead of serializing the full block a second time.
56    #[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    /// Builds a metadata-only record for nodes that serve block bodies from storage.
74    #[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    /// Builds a metadata-only record from bytes already serialized by the caller.
81    #[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    /// Builds a synthetic record used by tests and empty-state scaffolds.
98    #[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/// Network counters and peer metadata exposed by network RPCs.
120#[derive(Clone, Debug, Default)]
121pub struct NetworkState {
122    /// Number of connected peers.
123    pub connection_count: u64,
124    /// Total bytes received since startup.
125    pub bytes_recv: u64,
126    /// Total bytes sent since startup.
127    pub bytes_sent: u64,
128    /// Unix timestamp for the counters.
129    pub timestamp: u64,
130}
131
132/// Current pruning state reported by chain RPCs.
133#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
134pub struct PruneStatus {
135    /// Whether block pruning is enabled for this node.
136    pub pruned: bool,
137    /// Highest manual prune height completed by the backing service.
138    pub pruneheight: Option<u32>,
139}
140
141/// Summary of one completed manual prune request.
142#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
143pub struct PruneResult {
144    /// Height requested by the RPC caller.
145    pub requested_height: u32,
146    /// Highest prune height now recorded by the service.
147    pub pruneheight: u32,
148    /// Serialized block-body rows removed from storage.
149    pub block_rows_removed: u64,
150    /// Serialized undo rows removed from storage.
151    pub undo_rows_removed: u64,
152    /// Payload bytes removed from storage.
153    pub bytes_freed: u64,
154}
155
156/// One active ZMQ notification reported by `getzmqnotifications`.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct ZmqNotification {
159    /// Core notifier type (`pubhashblock`, `pubhashtx`, `pubrawblock`, `pubrawtx`).
160    pub notification_type: CompactString,
161    /// Bound ZMQ endpoint address.
162    pub address: String,
163    /// PUB socket high-water mark.
164    pub hwm: u32,
165}
166
167impl ZmqNotification {
168    /// Builds immutable RPC metadata for an active ZMQ publisher.
169    #[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/// Error returned by the node-owned pruning implementation.
184#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
185pub enum PruneServiceError {
186    /// Storage or backend-specific pruning failure.
187    #[error("{0}")]
188    Failed(String),
189}
190
191impl PruneServiceError {
192    /// Wraps a concrete backend error message without coupling RPC to a storage crate.
193    #[must_use]
194    pub fn failed(message: impl Into<String>) -> Self {
195        Self::Failed(message.into())
196    }
197}
198
199/// Node-owned storage mutator used by `pruneblockchain`.
200pub trait PruneService: Send + Sync {
201    /// Deletes persisted block/undo data below `requested_height`.
202    fn prune_to_height(&self, requested_height: u32) -> Result<PruneResult, PruneServiceError>;
203
204    /// Reports whether pruning is enabled and the highest completed prune height.
205    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
240/// Shared state consumed by JSON-RPC handlers.
241pub struct Context {
242    /// Best-chain tip snapshot published by chain validation.
243    pub chain_tip: Arc<ArcSwapOption<TipSnapshot>>,
244    /// Best-applied-block tip snapshot published after block application.
245    pub applied_tip: Arc<ArcSwapOption<TipSnapshot>>,
246    /// In-memory mempool handle.
247    pub mempool: Arc<RwLock<Mempool>>,
248    /// Block records already available without blocking storage readers.
249    pub blocks: Arc<RwLock<Vec<BlockRecord>>>,
250    /// Raw transactions indexed by txid for Core transaction RPCs.
251    pub transactions: Arc<RwLock<HashMap<Txid, Transaction>>>,
252    /// UTXO set snapshot handle used by chain metadata RPCs.
253    pub utxo: Arc<bitcoin_rs_utxo::UtxoSet>,
254    /// Incremental UTXO-set statistics.
255    pub coin_stats: Arc<bitcoin_rs_coinstats::CoinStatsListener>,
256    /// BIP157/158 compact-filter index used by filter RPCs.
257    pub filter_index: Arc<Box<dyn bitcoin_rs_filters::FilterIndexLike>>,
258    /// Optional storage pruning mutator.
259    pub prune_service: Option<Arc<dyn PruneService>>,
260    /// Optional shared confirmed-block indexer used to resolve prevout values for fee statistics.
261    /// `None` for embedded/test callers without txindex.
262    pub indexer: Option<Arc<Mutex<Box<dyn bitcoin_rs_index::IndexerLike>>>>,
263    /// Network counters and peers.
264    pub network: Arc<RwLock<NetworkState>>,
265    /// Network selector used by handlers needing consensus parameters (e.g.
266    /// difficulty calculation).
267    pub chain_network: Network,
268    /// Shared registry of currently-handshook peers.
269    pub peers: Arc<RwLock<Vec<bitcoin_rs_p2p::PeerInfo>>>,
270    /// Shared in-memory block tree.
271    pub block_tree: Arc<parking_lot::RwLock<bitcoin_rs_chain::BlockTree>>,
272    /// Optional durable block body reader for metadata-only block records.
273    pub block_body_source: Option<Arc<dyn BlockBodySource>>,
274    /// Current getblocktemplate long-poll id.
275    pub mining_template_id: Arc<ArcSwap<CompactString>>,
276    /// Receiver notified when mining template inputs change.
277    pub mining_notifications: Receiver<()>,
278    /// Optional outbound channel that submits decoded blocks back to the node's
279    /// `BlockSync::tick` for the canonical apply path. `None` when no node is
280    /// wired (tests, embedded callers).
281    pub inbound_blocks_sender: Option<crossbeam_channel::Sender<bitcoin_rs_p2p::InboundBlock>>,
282    /// Optional outbound channel for `addnode` to request new P2P connections.
283    /// `None` for embedded/test callers without a live P2P listener.
284    pub p2p_outbound_sender: Option<crossbeam_channel::Sender<std::net::SocketAddr>>,
285    /// Manual IP/CIDR bans shared with P2P enforcement.
286    pub banned: Arc<parking_lot::RwLock<Vec<bitcoin_rs_p2p::BannedSubnet>>>,
287    /// Persisted `addnode add` entries.
288    pub added_nodes: Arc<parking_lot::RwLock<Vec<std::net::SocketAddr>>>,
289    /// Active ZMQ PUB notifications.
290    pub zmq_notifications: Arc<[ZmqNotification]>,
291    mining_sender: Sender<()>,
292}
293// SAFETY: `Context` is shared by RPC worker threads. Each mutable subsystem
294// handle behind it uses atomics, channels, or locks for interior mutation.
295// `UtxoSet` is likewise internally sharded behind locks; RPC currently only
296// calls read-only aggregate counters through this handle.
297#[allow(clippy::non_send_fields_in_send_ty)]
298unsafe impl Send for Context {}
299
300// SAFETY: See the `Send` impl above. Shared access to all contained mutable
301// state is mediated by thread-safe primitives or UTXO shard locks.
302unsafe 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    /// Builds an empty context suitable for tests and early startup.
318    #[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    /// Builds a context that shares pre-existing handles owned elsewhere
355    /// (typically by `bitcoin-rs-node::state::NodeState`).
356    ///
357    /// This is the wiring path for the integration layer: subsystem owners
358    /// pass in their authoritative Arc handles, and RPC handlers observe
359    /// the same state.
360    #[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    /// Returns `self` with a durable block body source.
411    #[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    /// Attaches the node-owned pruning mutator used by `pruneblockchain`.
418    #[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    /// Attaches active ZMQ notification metadata reported by `getzmqnotifications`.
425    #[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    /// Returns active ZMQ notification metadata.
432    #[must_use]
433    pub fn zmq_notifications(&self) -> &[ZmqNotification] {
434        self.zmq_notifications.as_ref()
435    }
436
437    /// Returns the pruning state reported by `getblockchaininfo`.
438    #[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    /// Returns the f64 difficulty for `bits`, computed against the network's
446    /// `PoW` limit. Returns `0.0` on any conversion failure.
447    #[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    /// Publishes a new best-chain tip and wakes getblocktemplate long polls.
459    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    /// Publishes a new best-applied-block tip.
467    pub fn set_applied_tip(&self, tip: TipSnapshot) {
468        self.applied_tip.store(Some(Arc::new(tip)));
469    }
470
471    /// Stores a block record for block and header RPCs.
472    pub fn add_block(&self, record: BlockRecord) {
473        self.blocks.write().push(record);
474    }
475
476    /// Stores a decoded transaction for transaction lookup RPCs.
477    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    /// Returns the current tip height, or zero before initial sync publishes one.
484    #[must_use]
485    pub fn height(&self) -> u32 {
486        self.chain_tip.load_full().map_or(0, |tip| tip.height)
487    }
488
489    /// Returns the current best-applied-block height (lags `height()` when
490    /// headers are ahead of downloaded blocks).
491    #[must_use]
492    pub fn applied_height(&self) -> u32 {
493        self.applied_tip.load_full().map_or(0, |tip| tip.height)
494    }
495
496    /// Returns the current best-applied-block hash.
497    #[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    /// Returns the current best block hash, or all-zero before initial sync.
505    #[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    /// Returns the current best-chain chainwork as a 64-character lowercase
513    /// big-endian hex string. Returns "00" when no tip is published yet (a
514    /// 2-char placeholder matching `bitcoind`'s pre-genesis behavior).
515    #[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    /// Returns the block hash for `height` when known without blocking I/O.
531    #[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    /// Returns a known block by hash.
561    #[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    /// Returns the `BlockRecord` at the given height, if known.
571    ///
572    /// Linear scan over the in-memory block log. Returns the first matching
573    /// record. Suitable for handlers and Electrum resolvers needing a block
574    /// reference; not a hot path on an indexed store.
575    #[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    /// Returns serialized block bytes from the record or durable storage.
585    #[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    /// Returns lowercase serialized block hex from the record or durable storage.
596    #[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    /// Returns the median-time-past at the block with `hash`, or `None` if the
605    /// block is not in the tree.
606    #[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    /// Returns the block height for `hash` via the in-memory `BlockTree`, or
614    /// `None` if no node with that hash is known to the tree.
615    ///
616    /// Composes `BlockTree::height_of_hash` (chain crate commit `ef9ff41`).
617    #[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    /// Returns the 64-char lowercase hex chainwork at the block with `hash`.
623    #[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    /// Returns the hash of the block at `height + 1` on the active chain.
632    #[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}