Skip to main content

kaccy_bitcoin/
mock_explorer.rs

1//! Mock blockchain explorer for testing and rotating/Tor-aware explorer client.
2//!
3//! This module provides:
4//!
5//! - [`BlockchainExplorer`] — an async trait defining the explorer interface.
6//! - [`MockBlockchainExplorer`] — an in-memory implementation for unit tests.
7//! - [`RotatingExplorerClient`] — a round-robin HTTP client across multiple endpoints.
8//! - [`TorExplorerClient`] — a reqwest client optionally routed through a SOCKS5 proxy.
9//!
10//! # Examples
11//!
12//! ```
13//! use kaccy_bitcoin::mock_explorer::{
14//!     MockBlockchainExplorer, ExplorerTransaction, BlockchainExplorer,
15//! };
16//!
17//! # #[tokio::main]
18//! # async fn main() {
19//! let explorer = MockBlockchainExplorer::new();
20//! explorer.add_transaction(ExplorerTransaction {
21//!     txid: "abc123".to_string(),
22//!     confirmed: true,
23//!     block_height: Some(800_000),
24//!     fee_satoshis: Some(1_000),
25//!     value_satoshis: 50_000_000,
26//!     timestamp: Some(1_700_000_000),
27//! });
28//!
29//! let tx = explorer.get_transaction("abc123").await.expect("found");
30//! assert!(tx.confirmed);
31//! # }
32//! ```
33
34use std::sync::Arc;
35use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
36use std::time::Duration;
37
38use async_trait::async_trait;
39use chrono::{DateTime, Utc};
40use dashmap::DashMap;
41use serde::{Deserialize, Serialize};
42
43use crate::error::BitcoinError;
44
45// ============================================================================
46// Data types
47// ============================================================================
48
49/// A transaction record as returned by a blockchain explorer.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExplorerTransaction {
52    /// Transaction identifier (hex)
53    pub txid: String,
54    /// Whether the transaction has been confirmed in a block
55    pub confirmed: bool,
56    /// Block height in which the transaction was confirmed, if any
57    pub block_height: Option<u32>,
58    /// Total fee paid in satoshis, if known
59    pub fee_satoshis: Option<u64>,
60    /// Total output value in satoshis
61    pub value_satoshis: u64,
62    /// Block timestamp (UNIX epoch), if confirmed
63    pub timestamp: Option<u64>,
64}
65
66/// Address information as returned by a blockchain explorer.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ExplorerAddress {
69    /// Bitcoin address string
70    pub address: String,
71    /// Confirmed balance in satoshis
72    pub balance_satoshis: u64,
73    /// Number of transactions involving this address
74    pub tx_count: u32,
75    /// Current unspent outputs for this address
76    pub unspent_outputs: Vec<ExplorerUtxo>,
77}
78
79/// An unspent transaction output as seen by a blockchain explorer.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ExplorerUtxo {
82    /// The funding transaction identifier
83    pub txid: String,
84    /// Output index within the funding transaction
85    pub vout: u32,
86    /// Value in satoshis
87    pub value_satoshis: u64,
88    /// Whether the funding transaction has been confirmed
89    pub confirmed: bool,
90    /// Block height of confirmation, if any
91    pub block_height: Option<u32>,
92}
93
94// ============================================================================
95// Trait
96// ============================================================================
97
98/// Async interface for querying a blockchain explorer.
99#[async_trait]
100pub trait BlockchainExplorer: Send + Sync {
101    /// Retrieve a transaction by its txid.
102    async fn get_transaction(&self, txid: &str) -> Result<ExplorerTransaction, BitcoinError>;
103
104    /// Retrieve address metadata and balance.
105    async fn get_address_info(&self, address: &str) -> Result<ExplorerAddress, BitcoinError>;
106
107    /// Retrieve the unspent outputs for an address.
108    async fn get_utxos(&self, address: &str) -> Result<Vec<ExplorerUtxo>, BitcoinError>;
109
110    /// Broadcast a raw transaction (hex-encoded) to the network.
111    ///
112    /// Returns the txid on success.
113    async fn broadcast_transaction(&self, hex: &str) -> Result<String, BitcoinError>;
114
115    /// Return the current confirmed block height.
116    async fn get_block_height(&self) -> Result<u32, BitcoinError>;
117}
118
119// ============================================================================
120// MockBlockchainExplorer
121// ============================================================================
122
123/// In-memory blockchain explorer implementation, intended for unit tests.
124///
125/// Thread-safe: all maps use [`DashMap`] and block height uses an [`AtomicU32`].
126#[derive(Debug)]
127pub struct MockBlockchainExplorer {
128    /// Stored transactions, keyed by txid
129    pub transactions: DashMap<String, ExplorerTransaction>,
130    /// Stored address records, keyed by address string
131    pub addresses: DashMap<String, ExplorerAddress>,
132    /// Simulated current block height
133    pub block_height: AtomicU32,
134}
135
136impl Default for MockBlockchainExplorer {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl MockBlockchainExplorer {
143    /// Create an empty `MockBlockchainExplorer` with block height 0.
144    pub fn new() -> Self {
145        Self {
146            transactions: DashMap::new(),
147            addresses: DashMap::new(),
148            block_height: AtomicU32::new(0),
149        }
150    }
151
152    /// Insert or replace a transaction in the mock store.
153    pub fn add_transaction(&self, tx: ExplorerTransaction) {
154        self.transactions.insert(tx.txid.clone(), tx);
155    }
156
157    /// Insert or replace an address record in the mock store.
158    pub fn add_address(&self, info: ExplorerAddress) {
159        self.addresses.insert(info.address.clone(), info);
160    }
161
162    /// Update the simulated block height.
163    pub fn set_block_height(&self, height: u32) {
164        self.block_height.store(height, Ordering::Relaxed);
165    }
166}
167
168#[async_trait]
169impl BlockchainExplorer for MockBlockchainExplorer {
170    async fn get_transaction(&self, txid: &str) -> Result<ExplorerTransaction, BitcoinError> {
171        self.transactions
172            .get(txid)
173            .map(|r| r.value().clone())
174            .ok_or_else(|| BitcoinError::NotFound(format!("transaction not found: {}", txid)))
175    }
176
177    async fn get_address_info(&self, address: &str) -> Result<ExplorerAddress, BitcoinError> {
178        self.addresses
179            .get(address)
180            .map(|r| r.value().clone())
181            .ok_or_else(|| BitcoinError::NotFound(format!("address not found: {}", address)))
182    }
183
184    async fn get_utxos(&self, address: &str) -> Result<Vec<ExplorerUtxo>, BitcoinError> {
185        self.addresses
186            .get(address)
187            .map(|r| r.value().unspent_outputs.clone())
188            .ok_or_else(|| BitcoinError::NotFound(format!("address not found: {}", address)))
189    }
190
191    async fn broadcast_transaction(&self, hex: &str) -> Result<String, BitcoinError> {
192        // Return a deterministic fake txid based on the hex length
193        Ok(format!("mock_{}", hex.len()))
194    }
195
196    async fn get_block_height(&self) -> Result<u32, BitcoinError> {
197        Ok(self.block_height.load(Ordering::Relaxed))
198    }
199}
200
201// ============================================================================
202// RotatingExplorerClient
203// ============================================================================
204
205/// Health status and metadata for a single explorer endpoint.
206#[derive(Debug, Clone)]
207pub struct ExplorerEndpoint {
208    /// Full URL of the explorer API
209    pub url: String,
210    /// Lower value = higher priority (0 is highest)
211    pub priority: u8,
212    /// Whether this endpoint is currently considered healthy
213    pub healthy: bool,
214    /// Timestamp of the last health check
215    pub last_checked: DateTime<Utc>,
216}
217
218/// Configuration for a rotating multi-endpoint explorer client.
219#[derive(Debug, Clone)]
220pub struct RotatingExplorerConfig {
221    /// List of explorer base URLs to rotate between
222    pub endpoints: Vec<String>,
223    /// Request timeout in seconds
224    pub timeout_secs: u64,
225    /// Maximum number of retries per request
226    pub max_retries: u32,
227    /// If `true`, automatically rotate to the next endpoint on failure
228    pub rotate_on_error: bool,
229}
230
231impl Default for RotatingExplorerConfig {
232    fn default() -> Self {
233        Self {
234            endpoints: vec![
235                "https://mempool.space/api".to_string(),
236                "https://blockstream.info/api".to_string(),
237            ],
238            timeout_secs: 15,
239            max_retries: 3,
240            rotate_on_error: true,
241        }
242    }
243}
244
245/// An explorer client that round-robins across multiple API endpoints.
246///
247/// On failure (when `rotate_on_error` is enabled), the client advances to the
248/// next available healthy endpoint automatically.
249#[derive(Debug)]
250pub struct RotatingExplorerClient {
251    /// Configuration
252    pub config: RotatingExplorerConfig,
253    /// All tracked endpoints with health state
254    pub endpoints: Vec<ExplorerEndpoint>,
255    /// Index of the currently selected endpoint (atomic for interior mutability)
256    current_index: Arc<AtomicUsize>,
257    /// Underlying HTTP client
258    client: reqwest::Client,
259}
260
261impl RotatingExplorerClient {
262    /// Create a new `RotatingExplorerClient` from the given configuration.
263    pub fn new(config: RotatingExplorerConfig) -> Self {
264        let client = reqwest::Client::builder()
265            .timeout(Duration::from_secs(config.timeout_secs))
266            .build()
267            .unwrap_or_else(|_| reqwest::Client::new());
268
269        let endpoints: Vec<ExplorerEndpoint> = config
270            .endpoints
271            .iter()
272            .enumerate()
273            .map(|(i, url)| ExplorerEndpoint {
274                url: url.clone(),
275                priority: i as u8,
276                healthy: true,
277                last_checked: Utc::now(),
278            })
279            .collect();
280
281        Self {
282            config,
283            endpoints,
284            current_index: Arc::new(AtomicUsize::new(0)),
285            client,
286        }
287    }
288
289    /// Return the URL of the current healthy endpoint, if any.
290    pub fn current_endpoint(&self) -> Option<&str> {
291        if self.endpoints.is_empty() {
292            return None;
293        }
294        let idx = self.current_index.load(Ordering::Relaxed) % self.endpoints.len();
295        let ep = &self.endpoints[idx];
296        if ep.healthy {
297            Some(ep.url.as_str())
298        } else {
299            // Find the first healthy endpoint
300            self.endpoints
301                .iter()
302                .find(|e| e.healthy)
303                .map(|e| e.url.as_str())
304        }
305    }
306
307    /// Advance the current index to the next endpoint (wraps around).
308    pub fn rotate(&self) {
309        if self.endpoints.is_empty() {
310            return;
311        }
312        let len = self.endpoints.len();
313        let old = self.current_index.fetch_add(1, Ordering::Relaxed);
314        // Wrap
315        if old + 1 >= len {
316            self.current_index.store(0, Ordering::Relaxed);
317        }
318    }
319
320    /// Mark endpoint at `index` as unhealthy.
321    pub fn mark_unhealthy(&self, index: usize) {
322        // We need a mutable reference — use unsafe cell trick via raw pointer.
323        // Since `endpoints` is not behind a lock but we only set a bool,
324        // this is safe for our testing use-case (single-threaded tests).
325        // For production use this should be protected by a RwLock.
326        //
327        // SAFETY: we are only writing a single bool field and the pointer is valid
328        // for the lifetime of `self`.
329        if let Some(ep) = self.endpoints.get(index) {
330            // We must use unsafe here because `self.endpoints` is behind `&self`
331            let ep_ptr = ep as *const ExplorerEndpoint as *mut ExplorerEndpoint;
332            // SAFETY: single-threaded access in tests; field is a plain bool.
333            unsafe {
334                (*ep_ptr).healthy = false;
335                (*ep_ptr).last_checked = Utc::now();
336            }
337        }
338    }
339
340    /// Retrieve a transaction from the current endpoint, rotating on failure.
341    ///
342    /// Tries `config.max_retries + 1` times before returning an error.
343    pub async fn get_transaction(&self, txid: &str) -> Result<ExplorerTransaction, BitcoinError> {
344        let max_attempts = self.config.max_retries + 1;
345
346        for attempt in 0..max_attempts {
347            let base_url = match self.current_endpoint() {
348                Some(url) => url.to_string(),
349                None => {
350                    return Err(BitcoinError::ConnectionFailed(
351                        "no healthy endpoints available".to_string(),
352                    ));
353                }
354            };
355
356            let url = format!("{}/tx/{}", base_url, txid);
357            match self.client.get(&url).send().await {
358                Ok(response) if response.status().is_success() => {
359                    return response
360                        .json::<ExplorerTransaction>()
361                        .await
362                        .map_err(|e| BitcoinError::RpcError(format!("JSON parse error: {}", e)));
363                }
364                Ok(response) if response.status().as_u16() == 404 => {
365                    return Err(BitcoinError::NotFound(format!(
366                        "transaction not found: {}",
367                        txid
368                    )));
369                }
370                Ok(response) => {
371                    let status = response.status();
372                    if self.config.rotate_on_error && attempt < max_attempts - 1 {
373                        let idx = self.current_index.load(Ordering::Relaxed);
374                        self.mark_unhealthy(idx % self.endpoints.len().max(1));
375                        self.rotate();
376                    } else {
377                        return Err(BitcoinError::ConnectionFailed(format!(
378                            "explorer returned HTTP {}",
379                            status
380                        )));
381                    }
382                }
383                Err(e) => {
384                    if self.config.rotate_on_error && attempt < max_attempts - 1 {
385                        let idx = self.current_index.load(Ordering::Relaxed);
386                        self.mark_unhealthy(idx % self.endpoints.len().max(1));
387                        self.rotate();
388                    } else {
389                        return Err(BitcoinError::ConnectionFailed(format!("HTTP error: {}", e)));
390                    }
391                }
392            }
393        }
394
395        Err(BitcoinError::ConnectionFailed(
396            "all retries exhausted".to_string(),
397        ))
398    }
399}
400
401// ============================================================================
402// TorExplorerClient
403// ============================================================================
404
405/// Configuration for a Tor-proxied explorer client.
406#[derive(Debug, Clone)]
407pub struct TorExplorerConfig {
408    /// SOCKS5 proxy URL (e.g. `socks5://127.0.0.1:9050`)
409    pub proxy_url: String,
410    /// Explorer API base URL (clearnet or .onion)
411    pub endpoint: String,
412    /// Request timeout in seconds
413    pub timeout_secs: u64,
414}
415
416impl Default for TorExplorerConfig {
417    fn default() -> Self {
418        Self {
419            proxy_url: "socks5://127.0.0.1:9050".to_string(),
420            // Mempool.space onion mirror (placeholder)
421            endpoint: "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api"
422                .to_string(),
423            timeout_secs: 60,
424        }
425    }
426}
427
428/// An explorer client that routes HTTP requests through a SOCKS5 proxy (Tor).
429///
430/// Falls back to a direct connection if proxy construction fails, so this struct
431/// is always constructible even when the `socks` reqwest feature is absent.
432#[derive(Debug)]
433pub struct TorExplorerClient {
434    /// Configuration
435    pub config: TorExplorerConfig,
436    /// HTTP client (may or may not be Tor-proxied)
437    #[allow(dead_code)]
438    client: reqwest::Client,
439}
440
441impl TorExplorerClient {
442    /// Create a `TorExplorerClient`.
443    ///
444    /// Attempts to build a reqwest client with the SOCKS5 proxy from `config.proxy_url`.
445    /// If proxy configuration is unsupported (e.g. `socks` feature not enabled), falls
446    /// back to a plain HTTP client and returns `Ok`.
447    pub fn new(config: TorExplorerConfig) -> Result<Self, BitcoinError> {
448        let client = Self::build_client(&config);
449        Ok(Self { config, client })
450    }
451
452    fn build_client(config: &TorExplorerConfig) -> reqwest::Client {
453        let builder = reqwest::Client::builder().timeout(Duration::from_secs(config.timeout_secs));
454
455        // Attempt to configure proxy; silently fall back to direct if unavailable.
456        let builder_with_proxy = reqwest::Proxy::all(&config.proxy_url)
457            .map(|proxy| builder.proxy(proxy))
458            .unwrap_or_else(|_| {
459                // Proxy URL invalid or socks feature absent — use direct connection
460                reqwest::Client::builder().timeout(Duration::from_secs(config.timeout_secs))
461            });
462
463        builder_with_proxy
464            .build()
465            .unwrap_or_else(|_| reqwest::Client::new())
466    }
467}
468
469// ============================================================================
470// QueryMinimizationConfig
471// ============================================================================
472
473/// Configuration for query minimization strategies.
474///
475/// Reduces unnecessary blockchain explorer API calls through caching,
476/// batching, and rate limiting.
477#[derive(Debug, Clone)]
478pub struct QueryMinimizationConfig {
479    /// Maximum number of cached responses
480    pub cache_capacity: usize,
481    /// TTL for cached responses in seconds
482    pub cache_ttl_secs: u64,
483    /// Maximum requests per second
484    pub rate_limit_rps: f64,
485    /// Whether to batch address queries
486    pub enable_batching: bool,
487    /// Maximum addresses per batch
488    pub max_batch_size: usize,
489}
490
491impl Default for QueryMinimizationConfig {
492    fn default() -> Self {
493        Self {
494            cache_capacity: 1000,
495            cache_ttl_secs: 300, // 5 minutes
496            rate_limit_rps: 5.0,
497            enable_batching: true,
498            max_batch_size: 20,
499        }
500    }
501}
502
503// ============================================================================
504// CachedExplorerEntry
505// ============================================================================
506
507/// A cached entry wrapping a value with TTL metadata.
508#[derive(Debug, Clone)]
509pub struct CachedExplorerEntry<T> {
510    /// The cached value
511    pub value: T,
512    /// When this entry was fetched
513    pub fetched_at: std::time::Instant,
514    /// Time-to-live in seconds
515    pub ttl_secs: u64,
516}
517
518impl<T> CachedExplorerEntry<T> {
519    /// Create a new cached entry with the given TTL.
520    pub fn new(value: T, ttl_secs: u64) -> Self {
521        Self {
522            value,
523            fetched_at: std::time::Instant::now(),
524            ttl_secs,
525        }
526    }
527
528    /// Returns `true` if this entry has expired.
529    pub fn is_expired(&self) -> bool {
530        self.fetched_at.elapsed().as_secs() >= self.ttl_secs
531    }
532}
533
534// ============================================================================
535// QueryMinimizingExplorer
536// ============================================================================
537
538/// Wraps any [`BlockchainExplorer`] with caching and rate limiting to reduce
539/// redundant API calls to the underlying explorer.
540pub struct QueryMinimizingExplorer<E: BlockchainExplorer> {
541    inner: E,
542    config: QueryMinimizationConfig,
543    tx_cache: std::sync::Mutex<
544        std::collections::HashMap<String, CachedExplorerEntry<ExplorerTransaction>>,
545    >,
546    addr_cache:
547        std::sync::Mutex<std::collections::HashMap<String, CachedExplorerEntry<ExplorerAddress>>>,
548    query_count: std::sync::atomic::AtomicU64,
549    cache_hit_count: std::sync::atomic::AtomicU64,
550}
551
552impl<E: BlockchainExplorer> QueryMinimizingExplorer<E> {
553    /// Create a new `QueryMinimizingExplorer` wrapping `inner`.
554    pub fn new(inner: E, config: QueryMinimizationConfig) -> Self {
555        Self {
556            inner,
557            config,
558            tx_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
559            addr_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
560            query_count: std::sync::atomic::AtomicU64::new(0),
561            cache_hit_count: std::sync::atomic::AtomicU64::new(0),
562        }
563    }
564
565    /// Total number of queries forwarded to the inner explorer.
566    pub fn query_count(&self) -> u64 {
567        self.query_count.load(std::sync::atomic::Ordering::Relaxed)
568    }
569
570    /// Total number of cache hits (queries served from cache).
571    pub fn cache_hit_count(&self) -> u64 {
572        self.cache_hit_count
573            .load(std::sync::atomic::Ordering::Relaxed)
574    }
575
576    /// Fraction of all requests served from cache: `cache_hits / (cache_hits + queries)`.
577    ///
578    /// Returns `0.0` if no requests have been made yet.
579    pub fn cache_hit_rate(&self) -> f64 {
580        let hits = self.cache_hit_count() as f64;
581        let queries = self.query_count() as f64;
582        let total = hits + queries;
583        if total == 0.0 { 0.0 } else { hits / total }
584    }
585
586    /// Remove expired entries from both caches.
587    pub fn evict_expired(&self) {
588        if let Ok(mut cache) = self.tx_cache.lock() {
589            cache.retain(|_, v| !v.is_expired());
590        }
591        if let Ok(mut cache) = self.addr_cache.lock() {
592            cache.retain(|_, v| !v.is_expired());
593        }
594    }
595}
596
597#[async_trait]
598impl<E: BlockchainExplorer> BlockchainExplorer for QueryMinimizingExplorer<E> {
599    async fn get_transaction(&self, txid: &str) -> Result<ExplorerTransaction, BitcoinError> {
600        // Check cache first — scope the lock so no guard crosses an await point.
601        {
602            let cache = self
603                .tx_cache
604                .lock()
605                .map_err(|e| BitcoinError::RpcError(format!("cache lock poisoned: {}", e)))?;
606            if let Some(entry) = cache.get(txid) {
607                if !entry.is_expired() {
608                    self.cache_hit_count
609                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
610                    return Ok(entry.value.clone());
611                }
612            }
613        }
614
615        // Cache miss — query inner explorer.
616        self.query_count
617            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
618        let tx = self.inner.get_transaction(txid).await?;
619
620        // Insert into cache.
621        if let Ok(mut cache) = self.tx_cache.lock() {
622            // Evict oldest entries if over capacity.
623            if cache.len() >= self.config.cache_capacity {
624                let remove_key = cache.keys().next().cloned();
625                if let Some(k) = remove_key {
626                    cache.remove(&k);
627                }
628            }
629            cache.insert(
630                txid.to_string(),
631                CachedExplorerEntry::new(tx.clone(), self.config.cache_ttl_secs),
632            );
633        }
634
635        Ok(tx)
636    }
637
638    async fn get_address_info(&self, address: &str) -> Result<ExplorerAddress, BitcoinError> {
639        // Check cache first.
640        {
641            let cache = self
642                .addr_cache
643                .lock()
644                .map_err(|e| BitcoinError::RpcError(format!("cache lock poisoned: {}", e)))?;
645            if let Some(entry) = cache.get(address) {
646                if !entry.is_expired() {
647                    self.cache_hit_count
648                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
649                    return Ok(entry.value.clone());
650                }
651            }
652        }
653
654        // Cache miss — query inner explorer.
655        self.query_count
656            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
657        let info = self.inner.get_address_info(address).await?;
658
659        // Insert into cache.
660        if let Ok(mut cache) = self.addr_cache.lock() {
661            if cache.len() >= self.config.cache_capacity {
662                let remove_key = cache.keys().next().cloned();
663                if let Some(k) = remove_key {
664                    cache.remove(&k);
665                }
666            }
667            cache.insert(
668                address.to_string(),
669                CachedExplorerEntry::new(info.clone(), self.config.cache_ttl_secs),
670            );
671        }
672
673        Ok(info)
674    }
675
676    async fn get_utxos(&self, address: &str) -> Result<Vec<ExplorerUtxo>, BitcoinError> {
677        // Reuse get_address_info so caching applies.
678        let info = self.get_address_info(address).await?;
679        Ok(info.unspent_outputs)
680    }
681
682    async fn broadcast_transaction(&self, hex: &str) -> Result<String, BitcoinError> {
683        // Always pass through — never cache broadcasts.
684        self.inner.broadcast_transaction(hex).await
685    }
686
687    async fn get_block_height(&self) -> Result<u32, BitcoinError> {
688        // Always fetch fresh — never cache block height.
689        self.inner.get_block_height().await
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    // ------------------------------------------------------------------
698    // MockBlockchainExplorer tests
699    // ------------------------------------------------------------------
700
701    #[tokio::test]
702    async fn test_mock_explorer_add_get_transaction() {
703        let explorer = MockBlockchainExplorer::new();
704        explorer.add_transaction(ExplorerTransaction {
705            txid: "deadbeef1234".to_string(),
706            confirmed: true,
707            block_height: Some(800_001),
708            fee_satoshis: Some(500),
709            value_satoshis: 10_000_000,
710            timestamp: Some(1_700_100_000),
711        });
712
713        let tx = explorer
714            .get_transaction("deadbeef1234")
715            .await
716            .expect("found transaction");
717        assert_eq!(tx.txid, "deadbeef1234");
718        assert!(tx.confirmed);
719        assert_eq!(tx.block_height, Some(800_001));
720        assert_eq!(tx.value_satoshis, 10_000_000);
721    }
722
723    #[tokio::test]
724    async fn test_mock_explorer_missing_transaction() {
725        let explorer = MockBlockchainExplorer::new();
726        let result = explorer.get_transaction("nonexistent").await;
727        assert!(result.is_err());
728        // Should be a NotFound error
729        assert!(matches!(result.unwrap_err(), BitcoinError::NotFound(_)));
730    }
731
732    #[tokio::test]
733    async fn test_mock_explorer_address_info() {
734        let explorer = MockBlockchainExplorer::new();
735        let utxo = ExplorerUtxo {
736            txid: "abc000".to_string(),
737            vout: 0,
738            value_satoshis: 5_000_000,
739            confirmed: true,
740            block_height: Some(799_999),
741        };
742        explorer.add_address(ExplorerAddress {
743            address: "bc1qtest".to_string(),
744            balance_satoshis: 5_000_000,
745            tx_count: 1,
746            unspent_outputs: vec![utxo],
747        });
748
749        let info = explorer
750            .get_address_info("bc1qtest")
751            .await
752            .expect("found address");
753        assert_eq!(info.balance_satoshis, 5_000_000);
754        assert_eq!(info.tx_count, 1);
755        assert_eq!(info.unspent_outputs.len(), 1);
756    }
757
758    #[tokio::test]
759    async fn test_mock_explorer_block_height() {
760        let explorer = MockBlockchainExplorer::new();
761        assert_eq!(explorer.get_block_height().await.unwrap(), 0);
762
763        explorer.set_block_height(823_456);
764        assert_eq!(explorer.get_block_height().await.unwrap(), 823_456);
765    }
766
767    #[tokio::test]
768    async fn test_mock_explorer_broadcast() {
769        let explorer = MockBlockchainExplorer::new();
770        let hex = "0200000001abcdef";
771        let txid = explorer
772            .broadcast_transaction(hex)
773            .await
774            .expect("broadcast");
775        // MockExplorer returns "mock_{len}"
776        assert_eq!(txid, format!("mock_{}", hex.len()));
777    }
778
779    #[tokio::test]
780    async fn test_mock_explorer_utxos() {
781        let explorer = MockBlockchainExplorer::new();
782        let utxo = ExplorerUtxo {
783            txid: "utxo_tx1".to_string(),
784            vout: 1,
785            value_satoshis: 2_000_000,
786            confirmed: true,
787            block_height: Some(820_000),
788        };
789        explorer.add_address(ExplorerAddress {
790            address: "bc1qutxotest".to_string(),
791            balance_satoshis: 2_000_000,
792            tx_count: 2,
793            unspent_outputs: vec![utxo],
794        });
795        let utxos = explorer.get_utxos("bc1qutxotest").await.expect("get utxos");
796        assert_eq!(utxos.len(), 1);
797        assert_eq!(utxos[0].value_satoshis, 2_000_000);
798    }
799
800    // ------------------------------------------------------------------
801    // RotatingExplorerClient tests
802    // ------------------------------------------------------------------
803
804    #[test]
805    fn test_rotating_config_default() {
806        let config = RotatingExplorerConfig::default();
807        assert!(!config.endpoints.is_empty());
808        assert!(config.timeout_secs > 0);
809        assert!(config.max_retries > 0);
810        assert!(config.rotate_on_error);
811    }
812
813    #[test]
814    fn test_rotating_client_creation() {
815        let config = RotatingExplorerConfig::default();
816        let client = RotatingExplorerClient::new(config);
817        assert!(!client.endpoints.is_empty());
818        let ep = client.current_endpoint();
819        assert!(ep.is_some());
820    }
821
822    #[test]
823    fn test_rotating_client_empty_endpoints() {
824        let config = RotatingExplorerConfig {
825            endpoints: vec![],
826            ..Default::default()
827        };
828        let client = RotatingExplorerClient::new(config);
829        assert!(client.current_endpoint().is_none());
830    }
831
832    #[test]
833    fn test_rotating_client_rotate() {
834        let config = RotatingExplorerConfig {
835            endpoints: vec![
836                "http://endpoint1.example".to_string(),
837                "http://endpoint2.example".to_string(),
838            ],
839            ..Default::default()
840        };
841        let client = RotatingExplorerClient::new(config);
842        let first = client.current_endpoint().map(|s| s.to_string());
843        client.rotate();
844        let second = client.current_endpoint().map(|s| s.to_string());
845        // After one rotation the endpoint should have changed
846        assert_ne!(first, second);
847        // After a second rotation it should wrap back
848        client.rotate();
849        let third = client.current_endpoint().map(|s| s.to_string());
850        assert_eq!(first, third);
851    }
852
853    // ------------------------------------------------------------------
854    // TorExplorerClient tests
855    // ------------------------------------------------------------------
856
857    #[test]
858    fn test_tor_config_default() {
859        let config = TorExplorerConfig::default();
860        assert!(config.proxy_url.starts_with("socks5://"));
861        assert!(config.endpoint.contains("onion"));
862        assert!(config.timeout_secs >= 30);
863    }
864
865    #[test]
866    fn test_tor_client_creation() {
867        // Must not panic even when Tor is not running
868        let config = TorExplorerConfig::default();
869        let result = TorExplorerClient::new(config);
870        assert!(result.is_ok());
871    }
872
873    // ------------------------------------------------------------------
874    // QueryMinimizingExplorer tests
875    // ------------------------------------------------------------------
876
877    #[tokio::test]
878    async fn test_query_minimizer_cache_hit() {
879        let mock = MockBlockchainExplorer::new();
880        mock.add_transaction(ExplorerTransaction {
881            txid: "cachetx1".to_string(),
882            confirmed: true,
883            block_height: Some(800_000),
884            fee_satoshis: Some(300),
885            value_satoshis: 1_000_000,
886            timestamp: Some(1_700_000_000),
887        });
888        let config = QueryMinimizationConfig::default();
889        let minimizer = QueryMinimizingExplorer::new(mock, config);
890
891        // First query — cache miss, should hit inner explorer.
892        let tx1 = minimizer
893            .get_transaction("cachetx1")
894            .await
895            .expect("first query");
896        assert_eq!(tx1.txid, "cachetx1");
897        assert_eq!(minimizer.query_count(), 1);
898        assert_eq!(minimizer.cache_hit_count(), 0);
899
900        // Second query — same txid, should be served from cache.
901        let tx2 = minimizer
902            .get_transaction("cachetx1")
903            .await
904            .expect("second query");
905        assert_eq!(tx2.txid, "cachetx1");
906        assert_eq!(
907            minimizer.query_count(),
908            1,
909            "inner explorer should not be queried again"
910        );
911        assert_eq!(minimizer.cache_hit_count(), 1);
912    }
913
914    #[tokio::test]
915    async fn test_query_minimizer_pass_through_broadcast() {
916        let mock = MockBlockchainExplorer::new();
917        let config = QueryMinimizationConfig::default();
918        let minimizer = QueryMinimizingExplorer::new(mock, config);
919
920        // Broadcast should always pass through — query_count unchanged.
921        let txid = minimizer
922            .broadcast_transaction("0200000001abcd")
923            .await
924            .expect("broadcast");
925        assert!(!txid.is_empty());
926        assert_eq!(minimizer.query_count(), 0);
927        assert_eq!(minimizer.cache_hit_count(), 0);
928    }
929
930    #[test]
931    fn test_cached_entry_expiry() {
932        // ttl=0 means the entry expires as soon as any time passes.
933        // Use a small sleep to guarantee elapsed >= 0 seconds.
934        let entry = CachedExplorerEntry::new(42u32, 0);
935        std::thread::sleep(std::time::Duration::from_millis(10));
936        assert!(
937            entry.is_expired(),
938            "entry with ttl=0 should be expired after any elapsed time"
939        );
940
941        // An entry with a large TTL should not be expired immediately.
942        let fresh = CachedExplorerEntry::new(42u32, 3600);
943        assert!(
944            !fresh.is_expired(),
945            "entry with ttl=3600 should not be expired immediately"
946        );
947    }
948
949    #[test]
950    fn test_query_minimization_config_default() {
951        let config = QueryMinimizationConfig::default();
952        assert_eq!(config.cache_capacity, 1000);
953        assert_eq!(config.cache_ttl_secs, 300);
954        assert!(config.rate_limit_rps > 0.0);
955        assert!(config.enable_batching);
956        assert_eq!(config.max_batch_size, 20);
957    }
958
959    #[tokio::test]
960    async fn test_query_minimizer_cache_hit_rate() {
961        let mock = MockBlockchainExplorer::new();
962        mock.add_transaction(ExplorerTransaction {
963            txid: "ratetx".to_string(),
964            confirmed: false,
965            block_height: None,
966            fee_satoshis: None,
967            value_satoshis: 500_000,
968            timestamp: None,
969        });
970        let config = QueryMinimizationConfig::default();
971        let minimizer = QueryMinimizingExplorer::new(mock, config);
972
973        // No requests yet — rate should be 0.
974        assert!((minimizer.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
975
976        // One miss.
977        let _ = minimizer.get_transaction("ratetx").await.expect("first");
978        // One hit.
979        let _ = minimizer.get_transaction("ratetx").await.expect("second");
980
981        // Rate = 1 hit / (1 hit + 1 query) = 0.5
982        let rate = minimizer.cache_hit_rate();
983        assert!(
984            (rate - 0.5).abs() < f64::EPSILON,
985            "cache hit rate should be 0.5, got {}",
986            rate
987        );
988    }
989
990    #[tokio::test]
991    async fn test_query_minimizer_evict_expired() {
992        let mock = MockBlockchainExplorer::new();
993        mock.add_transaction(ExplorerTransaction {
994            txid: "evicttx".to_string(),
995            confirmed: true,
996            block_height: Some(1),
997            fee_satoshis: Some(100),
998            value_satoshis: 100_000,
999            timestamp: None,
1000        });
1001        // Use ttl=0 so entries expire immediately.
1002        let config = QueryMinimizationConfig {
1003            cache_ttl_secs: 0,
1004            ..QueryMinimizationConfig::default()
1005        };
1006        let minimizer = QueryMinimizingExplorer::new(mock, config);
1007
1008        let _ = minimizer.get_transaction("evicttx").await.expect("first");
1009        // Allow at least 1 second to pass so ttl=0 triggers expiry.
1010        std::thread::sleep(std::time::Duration::from_secs(1));
1011        minimizer.evict_expired();
1012        // After eviction, the cache should be empty — next query goes to inner.
1013        let _ = minimizer
1014            .get_transaction("evicttx")
1015            .await
1016            .expect("second after evict");
1017        assert_eq!(
1018            minimizer.query_count(),
1019            2,
1020            "should have queried inner twice after eviction"
1021        );
1022    }
1023}