Skip to main content

chainrpc_core/
cache.rs

1//! Response caching layer for RPC transports.
2//!
3//! `CacheTransport` wraps any `Arc<dyn RpcTransport>` and caches successful
4//! responses for configurable methods. Cache keys are computed as a hash of
5//! `(method, params)` so identical requests share a single cache entry.
6//!
7//! # Tiered Caching
8//!
9//! When a [`CacheTierResolver`] is configured, the cache classifies each
10//! method+params pair into one of four [`CacheTier`]s, each with its own TTL:
11//!
12//! - **Immutable** — results that never change (1 hour TTL).
13//! - **SemiStable** — results that change infrequently (5 minutes TTL).
14//! - **Volatile** — results that change frequently (2 seconds TTL).
15//! - **NeverCache** — write methods and subscriptions (never cached).
16//!
17//! Without a tier resolver, the cache falls back to the flat `default_ttl`
18//! behavior for all methods listed in `cacheable_methods`.
19//!
20//! # Finality Awareness
21//!
22//! The [`CacheTransport::invalidate_for_reorg`] method removes cached entries
23//! that reference blocks at or above a given block number, enabling safe
24//! cache invalidation during chain reorganizations.
25//!
26//! # Design
27//!
28//! - Only explicitly cacheable methods are cached (opt-in via `CacheConfig`).
29//! - Expired entries are evicted lazily on access.
30//! - When the cache exceeds `max_entries`, the oldest entry is evicted (LRU-ish).
31//! - Thread-safe: the cache is behind a `Mutex` and the struct is `Send + Sync`.
32
33use 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// ---------------------------------------------------------------------------
44// CacheTier
45// ---------------------------------------------------------------------------
46
47/// Classification of an RPC method's caching behavior.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CacheTier {
50    /// Results that never change (e.g., finalized block data, confirmed tx
51    /// receipts). Very long TTL (1 hour).
52    Immutable,
53    /// Results that change infrequently (e.g., `eth_chainId`, `eth_getCode`).
54    /// Medium TTL (5 minutes).
55    SemiStable,
56    /// Results that change frequently (e.g., `eth_blockNumber`, `eth_gasPrice`).
57    /// Short TTL (2 seconds).
58    Volatile,
59    /// Write methods and subscription calls. Never cached.
60    NeverCache,
61}
62
63impl CacheTier {
64    /// Return the default TTL associated with this tier.
65    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// ---------------------------------------------------------------------------
76// CacheTierResolver
77// ---------------------------------------------------------------------------
78
79/// Determines the [`CacheTier`] for a given RPC request.
80///
81/// The resolver inspects the method name and, for certain methods, the
82/// parameters to classify the caching behavior. For example,
83/// `eth_getBlockByNumber` with a specific hex block number is `Immutable`,
84/// while the same method with `"latest"` or `"pending"` is `Volatile`.
85#[derive(Debug, Clone)]
86pub struct CacheTierResolver {
87    _private: (),
88}
89
90impl CacheTierResolver {
91    /// Create a new resolver with default classification rules.
92    pub fn new() -> Self {
93        Self { _private: () }
94    }
95
96    /// Determine the cache tier for the given method and params.
97    pub fn tier_for(&self, method: &str, params: &[serde_json::Value]) -> CacheTier {
98        match method {
99            // -- Immutable (confirmed/finalized data) -------------------------
100            "eth_getTransactionByHash" | "eth_getTransactionReceipt" => CacheTier::Immutable,
101
102            // eth_getBlockByNumber: immutable only if the first param is a
103            // concrete hex block number (not a tag like "latest"/"pending").
104            "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            // -- SemiStable ---------------------------------------------------
119            "eth_chainId"
120            | "net_version"
121            | "eth_getCode"
122            | "net_listening"
123            | "web3_clientVersion"
124            | "eth_protocolVersion"
125            | "eth_accounts" => CacheTier::SemiStable,
126
127            // -- Volatile (frequently changing) -------------------------------
128            "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            // -- NeverCache (writes, subscriptions) ---------------------------
139            "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            // Unknown methods — default to NeverCache (safe default).
155            _ => CacheTier::NeverCache,
156        }
157    }
158}
159
160impl Default for CacheTierResolver {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Config
168// ---------------------------------------------------------------------------
169
170/// Configuration for the response cache.
171#[derive(Debug, Clone)]
172pub struct CacheConfig {
173    /// Default time-to-live for cached entries.
174    ///
175    /// When `tier_resolver` is `None`, this TTL is used for all entries in
176    /// `cacheable_methods`. When a tier resolver is present, this is used as
177    /// a fallback only if a tier's default TTL is somehow `None`.
178    pub default_ttl: Duration,
179    /// Maximum number of entries to store.
180    pub max_entries: usize,
181    /// Set of RPC method names that are eligible for caching.
182    ///
183    /// When `tier_resolver` is `None`, only methods in this set are cached.
184    /// When `tier_resolver` is `Some`, this set is ignored and the resolver
185    /// decides cacheability (any tier except `NeverCache`).
186    pub cacheable_methods: HashSet<String>,
187    /// Optional tier resolver for tiered caching.
188    ///
189    /// When `Some`, the resolver classifies each request into a [`CacheTier`]
190    /// and applies tier-specific TTLs. When `None`, the cache uses the flat
191    /// `default_ttl` + `cacheable_methods` behavior.
192    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
216// ---------------------------------------------------------------------------
217// Internal types
218// ---------------------------------------------------------------------------
219
220struct CacheEntry {
221    method: String,
222    response: JsonRpcResponse,
223    inserted_at: Instant,
224    /// The cache tier this entry was classified under.
225    /// Stored for diagnostics and potential future tier-based eviction policies.
226    #[allow(dead_code)]
227    tier: CacheTier,
228    /// The block number this entry references, if detectable from params.
229    /// Used for reorg-based invalidation.
230    block_ref: Option<u64>,
231    /// The TTL for this specific entry (set at insertion time).
232    ttl: Duration,
233}
234
235/// Aggregate cache statistics.
236#[derive(Debug, Clone, Default)]
237pub struct CacheStats {
238    /// Total cache hits since creation.
239    pub hits: u64,
240    /// Total cache misses since creation.
241    pub misses: u64,
242    /// Current number of (non-expired) entries.
243    pub size: usize,
244}
245
246struct CacheInner {
247    entries: HashMap<u64, CacheEntry>,
248    stats: CacheStats,
249}
250
251// ---------------------------------------------------------------------------
252// CacheTransport
253// ---------------------------------------------------------------------------
254
255/// A caching wrapper around an RPC transport.
256///
257/// Only methods listed in `CacheConfig::cacheable_methods` are cached.
258/// All other requests pass straight through to the inner transport.
259pub struct CacheTransport {
260    inner: Arc<dyn RpcTransport>,
261    cache: Mutex<CacheInner>,
262    config: CacheConfig,
263}
264
265impl CacheTransport {
266    /// Create a new caching wrapper.
267    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    /// Send a request, returning a cached response when available.
279    pub async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
280        // Determine cacheability and TTL.
281        let (is_cacheable, tier, ttl) = self.resolve_cacheability(&req);
282
283        // Non-cacheable — pass through immediately.
284        if !is_cacheable {
285            return self.inner.send(req).await;
286        }
287
288        let key = cache_key(&req.method, &req.params);
289
290        // Check cache (under lock).
291        {
292            let mut inner = self.cache.lock().unwrap();
293
294            // Evict expired entries lazily.
295            self.evict_expired(&mut inner);
296
297            // Check for a valid (non-expired) cached entry.
298            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            // Remove expired entry if present.
313            inner.entries.remove(&key);
314
315            inner.stats.misses += 1;
316        }
317
318        // Cache miss — delegate to the inner transport.
319        let response = self.inner.send(req.clone()).await?;
320
321        // Only cache successful responses.
322        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            // Evict LRU if over capacity.
328            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    /// Clear all cached entries.
350    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    /// Clear cached entries for the given method name.
357    ///
358    /// Each `CacheEntry` stores its method name, so we can filter precisely
359    /// and only remove entries belonging to the targeted method.
360    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    /// Invalidate all cached entries that reference blocks at or above
366    /// `from_block`.
367    ///
368    /// This is used during chain reorganizations: when a reorg is detected
369    /// starting at `from_block`, all cached data that might reference the
370    /// now-invalid chain segment must be removed.
371    ///
372    /// Entries without a detectable `block_ref` (i.e., `block_ref` is `None`)
373    /// are **not** removed — they are either block-agnostic (like `eth_chainId`)
374    /// or their block association could not be determined.
375    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, // keep entries without a block ref
382            }
383        });
384        let removed = before - inner.entries.len();
385        tracing::info!(from_block, removed, "cache invalidated for reorg");
386    }
387
388    /// Return a snapshot of cache statistics.
389    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    // -- internal helpers ---------------------------------------------------
399
400    /// Determine whether a request is cacheable, its tier, and TTL.
401    ///
402    /// When a `tier_resolver` is configured, the resolver decides.
403    /// Otherwise, fall back to the flat `cacheable_methods` + `default_ttl`.
404    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            // Legacy flat mode.
416            let is_cacheable = self.config.cacheable_methods.contains(&req.method);
417            (
418                is_cacheable,
419                CacheTier::SemiStable, // default tier for legacy mode
420                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        // Find the key with the oldest `inserted_at`.
436        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
447// ---------------------------------------------------------------------------
448// Helpers
449// ---------------------------------------------------------------------------
450
451/// Compute a deterministic cache key from method + params.
452fn cache_key(method: &str, params: &[serde_json::Value]) -> u64 {
453    let mut hasher = DefaultHasher::new();
454    method.hash(&mut hasher);
455    // Serialize params to a canonical JSON string for hashing.
456    let params_str = serde_json::to_string(params).unwrap_or_default();
457    params_str.hash(&mut hasher);
458    hasher.finish()
459}
460
461/// Check whether a JSON value represents a concrete (hex) block number
462/// rather than a block tag like `"latest"`, `"pending"`, `"earliest"`,
463/// `"safe"`, or `"finalized"`.
464fn is_concrete_block_number(value: &serde_json::Value) -> bool {
465    match value.as_str() {
466        Some(s) => {
467            // Tags are never concrete.
468            let tags = ["latest", "pending", "earliest", "safe", "finalized"];
469            if tags.contains(&s) {
470                return false;
471            }
472            // Accept hex-encoded block numbers (e.g., "0x10d4f").
473            s.starts_with("0x") || s.starts_with("0X")
474        }
475        None => {
476            // Could be a JSON number — treat as concrete.
477            value.is_number()
478        }
479    }
480}
481
482/// Try to extract a block number from the request params, for use in
483/// reorg-based cache invalidation.
484///
485/// This is a best-effort extraction: it covers common patterns like
486/// `eth_getBlockByNumber("0x1a2b3c", ...)` but does not attempt to
487/// parse every possible method's params.
488fn 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
496/// Parse a hex-encoded block number string like `"0x1a2b3c"` into a `u64`.
497fn 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// ---------------------------------------------------------------------------
504// Tests
505// ---------------------------------------------------------------------------
506
507#[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    /// A mock transport that counts how many times `send` is called.
515    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(), // ignored when tier_resolver is Some
562            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    // -----------------------------------------------------------------------
575    // Original tests (backward compatibility with flat mode)
576    // -----------------------------------------------------------------------
577
578    #[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        // Both responses should be identical.
588        assert_eq!(r1.result, r2.result);
589        // Only one actual call to the inner transport.
590        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        // First call is always a miss.
599        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), // very short TTL
613            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        // Wait for TTL to expire.
624        tokio::time::sleep(Duration::from_millis(100)).await;
625
626        cache.send(req).await.unwrap();
627        // Should have hit the inner transport again.
628        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        // eth_blockNumber is NOT in cacheable_methods.
637        let req = make_req("eth_blockNumber");
638        cache.send(req.clone()).await.unwrap();
639        cache.send(req).await.unwrap();
640
641        // Both calls should have hit the inner transport.
642        assert_eq!(transport.calls(), 2);
643        // Cache should be empty.
644        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        // Next send should be a miss.
659        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        // Fill cache to max.
678        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        // One more should evict the oldest.
697        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); // only eth_getCode remains
728
729        // eth_chainId should be a miss now
730        cache.send(make_req("eth_chainId")).await.unwrap();
731        assert_eq!(transport.calls(), 3); // 2 original + 1 new
732    }
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    // -----------------------------------------------------------------------
752    // New tiered caching tests
753    // -----------------------------------------------------------------------
754
755    #[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        // Immutable entries (like tx receipts) survive well past the
805        // default_ttl because they get 1 hour TTL from the tier.
806        let transport = Arc::new(CountingTransport::new());
807        let config = CacheConfig {
808            default_ttl: Duration::from_millis(50), // very short fallback
809            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        // Sleep past the default_ttl (50ms) but well under 1 hour.
823        tokio::time::sleep(Duration::from_millis(100)).await;
824
825        // Should still be a cache hit because immutable TTL is 1 hour.
826        cache.send(req).await.unwrap();
827        assert_eq!(transport.calls(), 1); // still 1 — cache hit
828    }
829
830    #[tokio::test]
831    async fn tier_volatile_short_ttl() {
832        // Volatile entries expire after 2 seconds.
833        let transport = Arc::new(CountingTransport::new());
834        let config = CacheConfig {
835            default_ttl: Duration::from_secs(60), // long fallback
836            max_entries: 128,
837            cacheable_methods: HashSet::new(),
838            // Use a custom-like config: we want volatile to be very short
839            // for testing. We'll use the real resolver but override nothing;
840            // eth_blockNumber is Volatile with 2s TTL. We'll sleep 50ms in
841            // a tight test by using a more controllable approach:
842            // Instead, we use eth_gasPrice which is also volatile.
843            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        // The entry is cached with 2s TTL. Wait past it.
852        tokio::time::sleep(Duration::from_millis(2100)).await;
853
854        cache.send(req).await.unwrap();
855        // Should have been a miss — inner transport called again.
856        assert_eq!(transport.calls(), 2);
857    }
858
859    #[tokio::test]
860    async fn tier_never_cache_bypasses() {
861        // NeverCache methods are never stored in the cache.
862        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        // Both calls hit the inner transport; nothing cached.
870        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        // Cache blocks 100, 200, 300.
880        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        // Reorg at block 200: blocks 200 and 300 should be invalidated.
893        cache.invalidate_for_reorg(200);
894
895        // Only block 100 should remain.
896        assert_eq!(cache.stats().size, 1);
897
898        // Fetching block 200 again should be a cache miss.
899        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        // 3 original + 1 new = 4
908        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        // "latest" should be volatile.
916        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        // "pending" should be volatile.
926        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        // A concrete hex block number should be immutable.
936        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        // Tags are not concrete.
949        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        // Hex numbers are concrete.
966        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        // JSON numbers are concrete.
974        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        // eth_chainId with tiered config should be cached as SemiStable.
1016        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); // second was a cache hit
1024        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        // Entries without a block ref (e.g., eth_chainId) should survive a reorg.
1031        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        // Reorg at block 50: block 100 (0x64) should be removed.
1046        cache.invalidate_for_reorg(50);
1047
1048        // eth_chainId has no block_ref, so it survives.
1049        assert_eq!(cache.stats().size, 1);
1050    }
1051}