1use std::collections::hash_map::DefaultHasher;
34use std::collections::{HashMap, HashSet};
35use std::hash::{Hash, Hasher};
36use std::sync::{Arc, Mutex};
37use std::time::{Duration, Instant};
38
39use crate::error::TransportError;
40use crate::request::{JsonRpcRequest, JsonRpcResponse};
41use crate::transport::RpcTransport;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CacheTier {
50 Immutable,
53 SemiStable,
56 Volatile,
59 NeverCache,
61}
62
63impl CacheTier {
64 pub fn default_ttl(&self) -> Option<Duration> {
66 match self {
67 CacheTier::Immutable => Some(Duration::from_secs(3600)),
68 CacheTier::SemiStable => Some(Duration::from_secs(300)),
69 CacheTier::Volatile => Some(Duration::from_secs(2)),
70 CacheTier::NeverCache => None,
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
86pub struct CacheTierResolver {
87 _private: (),
88}
89
90impl CacheTierResolver {
91 pub fn new() -> Self {
93 Self { _private: () }
94 }
95
96 pub fn tier_for(&self, method: &str, params: &[serde_json::Value]) -> CacheTier {
98 match method {
99 "eth_getTransactionByHash" | "eth_getTransactionReceipt" => CacheTier::Immutable,
101
102 "eth_getBlockByNumber" => {
105 if let Some(block_param) = params.first() {
106 if is_concrete_block_number(block_param) {
107 CacheTier::Immutable
108 } else {
109 CacheTier::Volatile
110 }
111 } else {
112 CacheTier::Volatile
113 }
114 }
115
116 "eth_getBlockByHash" => CacheTier::Immutable,
117
118 "eth_chainId"
120 | "net_version"
121 | "eth_getCode"
122 | "net_listening"
123 | "web3_clientVersion"
124 | "eth_protocolVersion"
125 | "eth_accounts" => CacheTier::SemiStable,
126
127 "eth_blockNumber"
129 | "eth_gasPrice"
130 | "eth_estimateGas"
131 | "eth_getBalance"
132 | "eth_getTransactionCount"
133 | "eth_call"
134 | "eth_feeHistory"
135 | "eth_maxPriorityFeePerGas"
136 | "eth_getStorageAt" => CacheTier::Volatile,
137
138 "eth_sendRawTransaction"
140 | "eth_sendTransaction"
141 | "eth_subscribe"
142 | "eth_unsubscribe"
143 | "eth_newFilter"
144 | "eth_newBlockFilter"
145 | "eth_newPendingTransactionFilter"
146 | "eth_uninstallFilter"
147 | "eth_getFilterChanges"
148 | "eth_getFilterLogs"
149 | "personal_sign"
150 | "eth_sign"
151 | "eth_signTransaction"
152 | "eth_signTypedData_v4" => CacheTier::NeverCache,
153
154 _ => CacheTier::NeverCache,
156 }
157 }
158}
159
160impl Default for CacheTierResolver {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166#[derive(Debug, Clone)]
172pub struct CacheConfig {
173 pub default_ttl: Duration,
179 pub max_entries: usize,
181 pub cacheable_methods: HashSet<String>,
187 pub tier_resolver: Option<CacheTierResolver>,
193}
194
195impl Default for CacheConfig {
196 fn default() -> Self {
197 let cacheable: HashSet<String> = [
198 "eth_chainId",
199 "eth_getBlockByNumber",
200 "eth_getCode",
201 "net_version",
202 ]
203 .iter()
204 .map(|s| (*s).to_string())
205 .collect();
206
207 Self {
208 default_ttl: Duration::from_secs(60),
209 max_entries: 1024,
210 cacheable_methods: cacheable,
211 tier_resolver: None,
212 }
213 }
214}
215
216struct CacheEntry {
221 method: String,
222 response: JsonRpcResponse,
223 inserted_at: Instant,
224 #[allow(dead_code)]
227 tier: CacheTier,
228 block_ref: Option<u64>,
231 ttl: Duration,
233}
234
235#[derive(Debug, Clone, Default)]
237pub struct CacheStats {
238 pub hits: u64,
240 pub misses: u64,
242 pub size: usize,
244}
245
246struct CacheInner {
247 entries: HashMap<u64, CacheEntry>,
248 stats: CacheStats,
249}
250
251pub struct CacheTransport {
260 inner: Arc<dyn RpcTransport>,
261 cache: Mutex<CacheInner>,
262 config: CacheConfig,
263}
264
265impl CacheTransport {
266 pub fn new(inner: Arc<dyn RpcTransport>, config: CacheConfig) -> Self {
268 Self {
269 inner,
270 cache: Mutex::new(CacheInner {
271 entries: HashMap::new(),
272 stats: CacheStats::default(),
273 }),
274 config,
275 }
276 }
277
278 pub async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
280 let (is_cacheable, tier, ttl) = self.resolve_cacheability(&req);
282
283 if !is_cacheable {
285 return self.inner.send(req).await;
286 }
287
288 let key = cache_key(&req.method, &req.params);
289
290 {
292 let mut inner = self.cache.lock().unwrap();
293
294 self.evict_expired(&mut inner);
296
297 let cached = inner.entries.get(&key).and_then(|entry| {
299 if entry.inserted_at.elapsed() < entry.ttl {
300 Some(entry.response.clone())
301 } else {
302 None
303 }
304 });
305
306 if let Some(response) = cached {
307 inner.stats.hits += 1;
308 tracing::debug!(method = %req.method, "cache hit");
309 return Ok(response);
310 }
311
312 inner.entries.remove(&key);
314
315 inner.stats.misses += 1;
316 }
317
318 let response = self.inner.send(req.clone()).await?;
320
321 if response.is_ok() {
323 let block_ref = extract_block_ref(&req.method, &req.params);
324
325 let mut inner = self.cache.lock().unwrap();
326
327 while inner.entries.len() >= self.config.max_entries {
329 self.evict_oldest(&mut inner);
330 }
331
332 inner.entries.insert(
333 key,
334 CacheEntry {
335 method: req.method.clone(),
336 response: response.clone(),
337 inserted_at: Instant::now(),
338 tier,
339 block_ref,
340 ttl,
341 },
342 );
343 tracing::debug!(method = %req.method, ?tier, "cached response");
344 }
345
346 Ok(response)
347 }
348
349 pub fn invalidate(&self) {
351 let mut inner = self.cache.lock().unwrap();
352 inner.entries.clear();
353 tracing::info!("cache invalidated (all entries)");
354 }
355
356 pub fn invalidate_method(&self, method: &str) {
361 let mut inner = self.cache.lock().unwrap();
362 inner.entries.retain(|_, entry| entry.method != method);
363 }
364
365 pub fn invalidate_for_reorg(&self, from_block: u64) {
376 let mut inner = self.cache.lock().unwrap();
377 let before = inner.entries.len();
378 inner.entries.retain(|_, entry| {
379 match entry.block_ref {
380 Some(block) => block < from_block,
381 None => true, }
383 });
384 let removed = before - inner.entries.len();
385 tracing::info!(from_block, removed, "cache invalidated for reorg");
386 }
387
388 pub fn stats(&self) -> CacheStats {
390 let inner = self.cache.lock().unwrap();
391 CacheStats {
392 hits: inner.stats.hits,
393 misses: inner.stats.misses,
394 size: inner.entries.len(),
395 }
396 }
397
398 fn resolve_cacheability(&self, req: &JsonRpcRequest) -> (bool, CacheTier, Duration) {
405 if let Some(ref resolver) = self.config.tier_resolver {
406 let tier = resolver.tier_for(&req.method, &req.params);
407 match tier {
408 CacheTier::NeverCache => (false, tier, Duration::ZERO),
409 _ => {
410 let ttl = tier.default_ttl().unwrap_or(self.config.default_ttl);
411 (true, tier, ttl)
412 }
413 }
414 } else {
415 let is_cacheable = self.config.cacheable_methods.contains(&req.method);
417 (
418 is_cacheable,
419 CacheTier::SemiStable, self.config.default_ttl,
421 )
422 }
423 }
424
425 fn evict_expired(&self, inner: &mut CacheInner) {
426 inner
427 .entries
428 .retain(|_, entry| entry.inserted_at.elapsed() < entry.ttl);
429 }
430
431 fn evict_oldest(&self, inner: &mut CacheInner) {
432 if inner.entries.is_empty() {
433 return;
434 }
435 let oldest_key = inner
437 .entries
438 .iter()
439 .min_by_key(|(_, e)| e.inserted_at)
440 .map(|(k, _)| *k);
441 if let Some(key) = oldest_key {
442 inner.entries.remove(&key);
443 }
444 }
445}
446
447fn cache_key(method: &str, params: &[serde_json::Value]) -> u64 {
453 let mut hasher = DefaultHasher::new();
454 method.hash(&mut hasher);
455 let params_str = serde_json::to_string(params).unwrap_or_default();
457 params_str.hash(&mut hasher);
458 hasher.finish()
459}
460
461fn is_concrete_block_number(value: &serde_json::Value) -> bool {
465 match value.as_str() {
466 Some(s) => {
467 let tags = ["latest", "pending", "earliest", "safe", "finalized"];
469 if tags.contains(&s) {
470 return false;
471 }
472 s.starts_with("0x") || s.starts_with("0X")
474 }
475 None => {
476 value.is_number()
478 }
479 }
480}
481
482fn extract_block_ref(method: &str, params: &[serde_json::Value]) -> Option<u64> {
489 match method {
490 "eth_getBlockByNumber" => params.first().and_then(parse_hex_block),
491 "eth_getTransactionByBlockNumberAndIndex" => params.first().and_then(parse_hex_block),
492 _ => None,
493 }
494}
495
496fn parse_hex_block(value: &serde_json::Value) -> Option<u64> {
498 let s = value.as_str()?;
499 let hex = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
500 u64::from_str_radix(hex, 16).ok()
501}
502
503#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::request::{JsonRpcRequest, JsonRpcResponse, RpcId};
511 use async_trait::async_trait;
512 use std::sync::atomic::{AtomicU64, Ordering};
513
514 struct CountingTransport {
516 call_count: AtomicU64,
517 }
518
519 impl CountingTransport {
520 fn new() -> Self {
521 Self {
522 call_count: AtomicU64::new(0),
523 }
524 }
525
526 fn calls(&self) -> u64 {
527 self.call_count.load(Ordering::SeqCst)
528 }
529 }
530
531 #[async_trait]
532 impl RpcTransport for CountingTransport {
533 async fn send(&self, _req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
534 self.call_count.fetch_add(1, Ordering::SeqCst);
535 Ok(JsonRpcResponse {
536 jsonrpc: "2.0".into(),
537 id: RpcId::Number(1),
538 result: Some(serde_json::Value::String("0x1".into())),
539 error: None,
540 })
541 }
542
543 fn url(&self) -> &str {
544 "mock://counting"
545 }
546 }
547
548 fn default_config() -> CacheConfig {
549 CacheConfig {
550 default_ttl: Duration::from_secs(60),
551 max_entries: 128,
552 cacheable_methods: ["eth_chainId"].iter().map(|s| s.to_string()).collect(),
553 tier_resolver: None,
554 }
555 }
556
557 fn tiered_config() -> CacheConfig {
558 CacheConfig {
559 default_ttl: Duration::from_secs(60),
560 max_entries: 128,
561 cacheable_methods: HashSet::new(), tier_resolver: Some(CacheTierResolver::new()),
563 }
564 }
565
566 fn make_req(method: &str) -> JsonRpcRequest {
567 JsonRpcRequest::new(1, method, vec![])
568 }
569
570 fn make_req_with_params(method: &str, params: Vec<serde_json::Value>) -> JsonRpcRequest {
571 JsonRpcRequest::new(1, method, params)
572 }
573
574 #[tokio::test]
579 async fn cache_hit_returns_same_response() {
580 let transport = Arc::new(CountingTransport::new());
581 let cache = CacheTransport::new(transport.clone(), default_config());
582
583 let req = make_req("eth_chainId");
584 let r1 = cache.send(req.clone()).await.unwrap();
585 let r2 = cache.send(req).await.unwrap();
586
587 assert_eq!(r1.result, r2.result);
589 assert_eq!(transport.calls(), 1);
591 }
592
593 #[tokio::test]
594 async fn cache_miss_delegates_to_inner() {
595 let transport = Arc::new(CountingTransport::new());
596 let cache = CacheTransport::new(transport.clone(), default_config());
597
598 let _r = cache.send(make_req("eth_chainId")).await.unwrap();
600 assert_eq!(transport.calls(), 1);
601
602 let stats = cache.stats();
603 assert_eq!(stats.misses, 1);
604 assert_eq!(stats.hits, 0);
605 assert_eq!(stats.size, 1);
606 }
607
608 #[tokio::test]
609 async fn ttl_expiry_works() {
610 let transport = Arc::new(CountingTransport::new());
611 let config = CacheConfig {
612 default_ttl: Duration::from_millis(50), max_entries: 128,
614 cacheable_methods: ["eth_chainId"].iter().map(|s| s.to_string()).collect(),
615 tier_resolver: None,
616 };
617 let cache = CacheTransport::new(transport.clone(), config);
618
619 let req = make_req("eth_chainId");
620 cache.send(req.clone()).await.unwrap();
621 assert_eq!(transport.calls(), 1);
622
623 tokio::time::sleep(Duration::from_millis(100)).await;
625
626 cache.send(req).await.unwrap();
627 assert_eq!(transport.calls(), 2);
629 }
630
631 #[tokio::test]
632 async fn non_cacheable_methods_bypass_cache() {
633 let transport = Arc::new(CountingTransport::new());
634 let cache = CacheTransport::new(transport.clone(), default_config());
635
636 let req = make_req("eth_blockNumber");
638 cache.send(req.clone()).await.unwrap();
639 cache.send(req).await.unwrap();
640
641 assert_eq!(transport.calls(), 2);
643 assert_eq!(cache.stats().size, 0);
645 }
646
647 #[tokio::test]
648 async fn invalidate_clears_cache() {
649 let transport = Arc::new(CountingTransport::new());
650 let cache = CacheTransport::new(transport.clone(), default_config());
651
652 cache.send(make_req("eth_chainId")).await.unwrap();
653 assert_eq!(cache.stats().size, 1);
654
655 cache.invalidate();
656 assert_eq!(cache.stats().size, 0);
657
658 cache.send(make_req("eth_chainId")).await.unwrap();
660 assert_eq!(transport.calls(), 2);
661 }
662
663 #[tokio::test]
664 async fn max_entries_evicts_oldest() {
665 let transport = Arc::new(CountingTransport::new());
666 let config = CacheConfig {
667 default_ttl: Duration::from_secs(60),
668 max_entries: 2,
669 cacheable_methods: ["eth_chainId", "eth_getCode"]
670 .iter()
671 .map(|s| s.to_string())
672 .collect(),
673 tier_resolver: None,
674 };
675 let cache = CacheTransport::new(transport.clone(), config);
676
677 cache
679 .send(JsonRpcRequest::new(
680 1,
681 "eth_chainId",
682 vec![serde_json::Value::String("a".into())],
683 ))
684 .await
685 .unwrap();
686 cache
687 .send(JsonRpcRequest::new(
688 2,
689 "eth_chainId",
690 vec![serde_json::Value::String("b".into())],
691 ))
692 .await
693 .unwrap();
694 assert_eq!(cache.stats().size, 2);
695
696 cache
698 .send(JsonRpcRequest::new(
699 3,
700 "eth_getCode",
701 vec![serde_json::Value::String("c".into())],
702 ))
703 .await
704 .unwrap();
705 assert_eq!(cache.stats().size, 2);
706 }
707
708 #[tokio::test]
709 async fn invalidate_method_is_targeted() {
710 let transport = Arc::new(CountingTransport::new());
711 let config = CacheConfig {
712 default_ttl: Duration::from_secs(60),
713 max_entries: 128,
714 cacheable_methods: ["eth_chainId", "eth_getCode"]
715 .iter()
716 .map(|s| s.to_string())
717 .collect(),
718 tier_resolver: None,
719 };
720 let cache = CacheTransport::new(transport.clone(), config);
721
722 cache.send(make_req("eth_chainId")).await.unwrap();
723 cache.send(make_req("eth_getCode")).await.unwrap();
724 assert_eq!(cache.stats().size, 2);
725
726 cache.invalidate_method("eth_chainId");
727 assert_eq!(cache.stats().size, 1); cache.send(make_req("eth_chainId")).await.unwrap();
731 assert_eq!(transport.calls(), 3); }
733
734 #[test]
735 fn cache_key_deterministic() {
736 let k1 = cache_key("eth_chainId", &[]);
737 let k2 = cache_key("eth_chainId", &[]);
738 assert_eq!(k1, k2);
739
740 let k3 = cache_key("eth_blockNumber", &[]);
741 assert_ne!(k1, k3);
742 }
743
744 #[test]
745 fn cache_key_differs_by_params() {
746 let k1 = cache_key("eth_getCode", &[serde_json::Value::String("0xabc".into())]);
747 let k2 = cache_key("eth_getCode", &[serde_json::Value::String("0xdef".into())]);
748 assert_ne!(k1, k2);
749 }
750
751 #[test]
756 fn tier_default_ttls() {
757 assert_eq!(
758 CacheTier::Immutable.default_ttl(),
759 Some(Duration::from_secs(3600))
760 );
761 assert_eq!(
762 CacheTier::SemiStable.default_ttl(),
763 Some(Duration::from_secs(300))
764 );
765 assert_eq!(
766 CacheTier::Volatile.default_ttl(),
767 Some(Duration::from_secs(2))
768 );
769 assert_eq!(CacheTier::NeverCache.default_ttl(), None);
770 }
771
772 #[test]
773 fn resolver_classifies_methods() {
774 let resolver = CacheTierResolver::new();
775
776 assert_eq!(
777 resolver.tier_for("eth_getTransactionReceipt", &[]),
778 CacheTier::Immutable
779 );
780 assert_eq!(
781 resolver.tier_for("eth_getTransactionByHash", &[]),
782 CacheTier::Immutable
783 );
784 assert_eq!(resolver.tier_for("eth_chainId", &[]), CacheTier::SemiStable);
785 assert_eq!(resolver.tier_for("net_version", &[]), CacheTier::SemiStable);
786 assert_eq!(resolver.tier_for("eth_getCode", &[]), CacheTier::SemiStable);
787 assert_eq!(
788 resolver.tier_for("eth_blockNumber", &[]),
789 CacheTier::Volatile
790 );
791 assert_eq!(resolver.tier_for("eth_gasPrice", &[]), CacheTier::Volatile);
792 assert_eq!(
793 resolver.tier_for("eth_sendRawTransaction", &[]),
794 CacheTier::NeverCache
795 );
796 assert_eq!(
797 resolver.tier_for("eth_subscribe", &[]),
798 CacheTier::NeverCache
799 );
800 }
801
802 #[tokio::test]
803 async fn tier_immutable_long_ttl() {
804 let transport = Arc::new(CountingTransport::new());
807 let config = CacheConfig {
808 default_ttl: Duration::from_millis(50), max_entries: 128,
810 cacheable_methods: HashSet::new(),
811 tier_resolver: Some(CacheTierResolver::new()),
812 };
813 let cache = CacheTransport::new(transport.clone(), config);
814
815 let req = make_req_with_params(
816 "eth_getTransactionReceipt",
817 vec![serde_json::Value::String("0xabc123def456".into())],
818 );
819 cache.send(req.clone()).await.unwrap();
820 assert_eq!(transport.calls(), 1);
821
822 tokio::time::sleep(Duration::from_millis(100)).await;
824
825 cache.send(req).await.unwrap();
827 assert_eq!(transport.calls(), 1); }
829
830 #[tokio::test]
831 async fn tier_volatile_short_ttl() {
832 let transport = Arc::new(CountingTransport::new());
834 let config = CacheConfig {
835 default_ttl: Duration::from_secs(60), max_entries: 128,
837 cacheable_methods: HashSet::new(),
838 tier_resolver: Some(CacheTierResolver::new()),
844 };
845 let cache = CacheTransport::new(transport.clone(), config);
846
847 let req = make_req("eth_gasPrice");
848 cache.send(req.clone()).await.unwrap();
849 assert_eq!(transport.calls(), 1);
850
851 tokio::time::sleep(Duration::from_millis(2100)).await;
853
854 cache.send(req).await.unwrap();
855 assert_eq!(transport.calls(), 2);
857 }
858
859 #[tokio::test]
860 async fn tier_never_cache_bypasses() {
861 let transport = Arc::new(CountingTransport::new());
863 let cache = CacheTransport::new(transport.clone(), tiered_config());
864
865 let req = make_req("eth_sendRawTransaction");
866 cache.send(req.clone()).await.unwrap();
867 cache.send(req).await.unwrap();
868
869 assert_eq!(transport.calls(), 2);
871 assert_eq!(cache.stats().size, 0);
872 }
873
874 #[tokio::test]
875 async fn reorg_invalidation_removes_affected() {
876 let transport = Arc::new(CountingTransport::new());
877 let cache = CacheTransport::new(transport.clone(), tiered_config());
878
879 for block in [100u64, 200, 300] {
881 let req = make_req_with_params(
882 "eth_getBlockByNumber",
883 vec![
884 serde_json::Value::String(format!("0x{:x}", block)),
885 serde_json::Value::Bool(true),
886 ],
887 );
888 cache.send(req).await.unwrap();
889 }
890 assert_eq!(cache.stats().size, 3);
891
892 cache.invalidate_for_reorg(200);
894
895 assert_eq!(cache.stats().size, 1);
897
898 let req200 = make_req_with_params(
900 "eth_getBlockByNumber",
901 vec![
902 serde_json::Value::String("0xc8".into()),
903 serde_json::Value::Bool(true),
904 ],
905 );
906 cache.send(req200).await.unwrap();
907 assert_eq!(transport.calls(), 4);
909 }
910
911 #[tokio::test]
912 async fn block_param_latest_is_volatile() {
913 let resolver = CacheTierResolver::new();
914
915 let tier_latest = resolver.tier_for(
917 "eth_getBlockByNumber",
918 &[
919 serde_json::Value::String("latest".into()),
920 serde_json::Value::Bool(true),
921 ],
922 );
923 assert_eq!(tier_latest, CacheTier::Volatile);
924
925 let tier_pending = resolver.tier_for(
927 "eth_getBlockByNumber",
928 &[
929 serde_json::Value::String("pending".into()),
930 serde_json::Value::Bool(true),
931 ],
932 );
933 assert_eq!(tier_pending, CacheTier::Volatile);
934
935 let tier_concrete = resolver.tier_for(
937 "eth_getBlockByNumber",
938 &[
939 serde_json::Value::String("0x10d4f".into()),
940 serde_json::Value::Bool(true),
941 ],
942 );
943 assert_eq!(tier_concrete, CacheTier::Immutable);
944 }
945
946 #[test]
947 fn is_concrete_block_number_checks() {
948 assert!(!is_concrete_block_number(&serde_json::Value::String(
950 "latest".into()
951 )));
952 assert!(!is_concrete_block_number(&serde_json::Value::String(
953 "pending".into()
954 )));
955 assert!(!is_concrete_block_number(&serde_json::Value::String(
956 "earliest".into()
957 )));
958 assert!(!is_concrete_block_number(&serde_json::Value::String(
959 "safe".into()
960 )));
961 assert!(!is_concrete_block_number(&serde_json::Value::String(
962 "finalized".into()
963 )));
964
965 assert!(is_concrete_block_number(&serde_json::Value::String(
967 "0x10d4f".into()
968 )));
969 assert!(is_concrete_block_number(&serde_json::Value::String(
970 "0X1A".into()
971 )));
972
973 assert!(is_concrete_block_number(&serde_json::json!(42)));
975 }
976
977 #[test]
978 fn parse_hex_block_works() {
979 assert_eq!(
980 parse_hex_block(&serde_json::Value::String("0x64".into())),
981 Some(100)
982 );
983 assert_eq!(
984 parse_hex_block(&serde_json::Value::String("0xc8".into())),
985 Some(200)
986 );
987 assert_eq!(
988 parse_hex_block(&serde_json::Value::String("latest".into())),
989 None
990 );
991 assert_eq!(parse_hex_block(&serde_json::json!(42)), None);
992 }
993
994 #[test]
995 fn extract_block_ref_for_get_block() {
996 assert_eq!(
997 extract_block_ref(
998 "eth_getBlockByNumber",
999 &[serde_json::Value::String("0x64".into())]
1000 ),
1001 Some(100)
1002 );
1003 assert_eq!(
1004 extract_block_ref(
1005 "eth_getBlockByNumber",
1006 &[serde_json::Value::String("latest".into())]
1007 ),
1008 None
1009 );
1010 assert_eq!(extract_block_ref("eth_chainId", &[]), None);
1011 }
1012
1013 #[tokio::test]
1014 async fn tiered_mode_caches_semi_stable() {
1015 let transport = Arc::new(CountingTransport::new());
1017 let cache = CacheTransport::new(transport.clone(), tiered_config());
1018
1019 let req = make_req("eth_chainId");
1020 cache.send(req.clone()).await.unwrap();
1021 cache.send(req).await.unwrap();
1022
1023 assert_eq!(transport.calls(), 1); assert_eq!(cache.stats().hits, 1);
1025 assert_eq!(cache.stats().misses, 1);
1026 }
1027
1028 #[tokio::test]
1029 async fn reorg_keeps_unrelated_entries() {
1030 let transport = Arc::new(CountingTransport::new());
1032 let cache = CacheTransport::new(transport.clone(), tiered_config());
1033
1034 cache.send(make_req("eth_chainId")).await.unwrap();
1035 let block_req = make_req_with_params(
1036 "eth_getBlockByNumber",
1037 vec![
1038 serde_json::Value::String("0x64".into()),
1039 serde_json::Value::Bool(true),
1040 ],
1041 );
1042 cache.send(block_req).await.unwrap();
1043 assert_eq!(cache.stats().size, 2);
1044
1045 cache.invalidate_for_reorg(50);
1047
1048 assert_eq!(cache.stats().size, 1);
1050 }
1051}