1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExplorerTransaction {
52 pub txid: String,
54 pub confirmed: bool,
56 pub block_height: Option<u32>,
58 pub fee_satoshis: Option<u64>,
60 pub value_satoshis: u64,
62 pub timestamp: Option<u64>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ExplorerAddress {
69 pub address: String,
71 pub balance_satoshis: u64,
73 pub tx_count: u32,
75 pub unspent_outputs: Vec<ExplorerUtxo>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ExplorerUtxo {
82 pub txid: String,
84 pub vout: u32,
86 pub value_satoshis: u64,
88 pub confirmed: bool,
90 pub block_height: Option<u32>,
92}
93
94#[async_trait]
100pub trait BlockchainExplorer: Send + Sync {
101 async fn get_transaction(&self, txid: &str) -> Result<ExplorerTransaction, BitcoinError>;
103
104 async fn get_address_info(&self, address: &str) -> Result<ExplorerAddress, BitcoinError>;
106
107 async fn get_utxos(&self, address: &str) -> Result<Vec<ExplorerUtxo>, BitcoinError>;
109
110 async fn broadcast_transaction(&self, hex: &str) -> Result<String, BitcoinError>;
114
115 async fn get_block_height(&self) -> Result<u32, BitcoinError>;
117}
118
119#[derive(Debug)]
127pub struct MockBlockchainExplorer {
128 pub transactions: DashMap<String, ExplorerTransaction>,
130 pub addresses: DashMap<String, ExplorerAddress>,
132 pub block_height: AtomicU32,
134}
135
136impl Default for MockBlockchainExplorer {
137 fn default() -> Self {
138 Self::new()
139 }
140}
141
142impl MockBlockchainExplorer {
143 pub fn new() -> Self {
145 Self {
146 transactions: DashMap::new(),
147 addresses: DashMap::new(),
148 block_height: AtomicU32::new(0),
149 }
150 }
151
152 pub fn add_transaction(&self, tx: ExplorerTransaction) {
154 self.transactions.insert(tx.txid.clone(), tx);
155 }
156
157 pub fn add_address(&self, info: ExplorerAddress) {
159 self.addresses.insert(info.address.clone(), info);
160 }
161
162 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 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#[derive(Debug, Clone)]
207pub struct ExplorerEndpoint {
208 pub url: String,
210 pub priority: u8,
212 pub healthy: bool,
214 pub last_checked: DateTime<Utc>,
216}
217
218#[derive(Debug, Clone)]
220pub struct RotatingExplorerConfig {
221 pub endpoints: Vec<String>,
223 pub timeout_secs: u64,
225 pub max_retries: u32,
227 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#[derive(Debug)]
250pub struct RotatingExplorerClient {
251 pub config: RotatingExplorerConfig,
253 pub endpoints: Vec<ExplorerEndpoint>,
255 current_index: Arc<AtomicUsize>,
257 client: reqwest::Client,
259}
260
261impl RotatingExplorerClient {
262 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 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 self.endpoints
301 .iter()
302 .find(|e| e.healthy)
303 .map(|e| e.url.as_str())
304 }
305 }
306
307 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 if old + 1 >= len {
316 self.current_index.store(0, Ordering::Relaxed);
317 }
318 }
319
320 pub fn mark_unhealthy(&self, index: usize) {
322 if let Some(ep) = self.endpoints.get(index) {
330 let ep_ptr = ep as *const ExplorerEndpoint as *mut ExplorerEndpoint;
332 unsafe {
334 (*ep_ptr).healthy = false;
335 (*ep_ptr).last_checked = Utc::now();
336 }
337 }
338 }
339
340 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#[derive(Debug, Clone)]
407pub struct TorExplorerConfig {
408 pub proxy_url: String,
410 pub endpoint: String,
412 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 endpoint: "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api"
422 .to_string(),
423 timeout_secs: 60,
424 }
425 }
426}
427
428#[derive(Debug)]
433pub struct TorExplorerClient {
434 pub config: TorExplorerConfig,
436 #[allow(dead_code)]
438 client: reqwest::Client,
439}
440
441impl TorExplorerClient {
442 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 let builder_with_proxy = reqwest::Proxy::all(&config.proxy_url)
457 .map(|proxy| builder.proxy(proxy))
458 .unwrap_or_else(|_| {
459 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#[derive(Debug, Clone)]
478pub struct QueryMinimizationConfig {
479 pub cache_capacity: usize,
481 pub cache_ttl_secs: u64,
483 pub rate_limit_rps: f64,
485 pub enable_batching: bool,
487 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, rate_limit_rps: 5.0,
497 enable_batching: true,
498 max_batch_size: 20,
499 }
500 }
501}
502
503#[derive(Debug, Clone)]
509pub struct CachedExplorerEntry<T> {
510 pub value: T,
512 pub fetched_at: std::time::Instant,
514 pub ttl_secs: u64,
516}
517
518impl<T> CachedExplorerEntry<T> {
519 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 pub fn is_expired(&self) -> bool {
530 self.fetched_at.elapsed().as_secs() >= self.ttl_secs
531 }
532}
533
534pub 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 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 pub fn query_count(&self) -> u64 {
567 self.query_count.load(std::sync::atomic::Ordering::Relaxed)
568 }
569
570 pub fn cache_hit_count(&self) -> u64 {
572 self.cache_hit_count
573 .load(std::sync::atomic::Ordering::Relaxed)
574 }
575
576 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 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 {
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 self.query_count
617 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
618 let tx = self.inner.get_transaction(txid).await?;
619
620 if let Ok(mut cache) = self.tx_cache.lock() {
622 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 {
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 self.query_count
656 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
657 let info = self.inner.get_address_info(address).await?;
658
659 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 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 self.inner.broadcast_transaction(hex).await
685 }
686
687 async fn get_block_height(&self) -> Result<u32, BitcoinError> {
688 self.inner.get_block_height().await
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[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 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 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 #[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 assert_ne!(first, second);
847 client.rotate();
849 let third = client.current_endpoint().map(|s| s.to_string());
850 assert_eq!(first, third);
851 }
852
853 #[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 let config = TorExplorerConfig::default();
869 let result = TorExplorerClient::new(config);
870 assert!(result.is_ok());
871 }
872
873 #[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 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 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 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 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 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 assert!((minimizer.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
975
976 let _ = minimizer.get_transaction("ratetx").await.expect("first");
978 let _ = minimizer.get_transaction("ratetx").await.expect("second");
980
981 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 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 std::thread::sleep(std::time::Duration::from_secs(1));
1011 minimizer.evict_expired();
1012 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}