openzeppelin_relayer/services/gas/
cache.rs

1//! Gas Price Cache Module
2//!
3//! This module provides caching functionality for EVM gas prices to reduce RPC calls
4//! and improve performance. It implements a stale-while-revalidate pattern for optimal
5//! response times.
6
7use crate::{
8    config::GasPriceCacheConfig,
9    constants::{GAS_PRICE_CACHE_REFRESH_TIMEOUT_SECS, HISTORICAL_BLOCKS},
10    models::{EvmNetwork, TransactionError},
11    services::{gas::fetchers::GasPriceFetcherFactory, provider::EvmProviderTrait},
12};
13use alloy::rpc::types::{BlockNumberOrTag, FeeHistory};
14use dashmap::DashMap;
15use std::{
16    sync::{Arc, OnceLock},
17    time::{Duration, Instant},
18};
19use tokio::sync::RwLock;
20use tracing::info;
21
22#[derive(Debug, Clone)]
23pub struct GasPriceSnapshot {
24    pub gas_price: u128,
25    pub base_fee_per_gas: u128,
26    pub fee_history: FeeHistory,
27    pub is_stale: bool,
28}
29
30/// Represents an entry in the gas price cache.
31#[derive(Clone, Debug)]
32pub struct GasPriceCacheEntry {
33    pub gas_price: u128,
34    pub base_fee_per_gas: u128,
35    pub fee_history: FeeHistory,
36
37    pub fetched_at: Instant,
38    pub stale_after: Duration,
39    pub expire_after: Duration,
40}
41
42impl GasPriceCacheEntry {
43    /// Creates a new cache entry.
44    pub fn new(
45        gas_price: u128,
46        base_fee_per_gas: u128,
47        fee_history: FeeHistory,
48        stale_after: Duration,
49        expire_after: Duration,
50    ) -> Self {
51        Self {
52            gas_price,
53            base_fee_per_gas,
54            fee_history,
55            fetched_at: Instant::now(),
56            stale_after,
57            expire_after,
58        }
59    }
60
61    /// Checks if the cache entry is still fresh
62    pub fn is_fresh(&self) -> bool {
63        self.fetched_at.elapsed() < self.stale_after
64    }
65
66    /// Checks if the cache entry is stale but not expired
67    pub fn is_stale(&self) -> bool {
68        let elapsed = self.fetched_at.elapsed();
69        elapsed >= self.stale_after && elapsed < self.expire_after
70    }
71
72    /// Checks if the cache entry has expired
73    pub fn is_expired(&self) -> bool {
74        self.fetched_at.elapsed() >= self.expire_after
75    }
76
77    /// Returns the age of the cache entry
78    pub fn age(&self) -> Duration {
79        self.fetched_at.elapsed()
80    }
81}
82
83/// Thread-safe gas price cache supporting multiple networks
84#[derive(Debug)]
85pub struct GasPriceCache {
86    /// Cache storage mapping chain_id to cached entries
87    entries: Arc<DashMap<u64, Arc<RwLock<GasPriceCacheEntry>>>>,
88    /// Network-specific cache configurations
89    network_configs: Arc<DashMap<u64, GasPriceCacheConfig>>,
90    /// Track ongoing refresh operations to prevent duplicates
91    refreshing_networks: Arc<DashMap<u64, Instant>>,
92}
93
94impl GasPriceCache {
95    pub fn global() -> &'static Arc<Self> {
96        static GLOBAL_CACHE: OnceLock<Arc<GasPriceCache>> = OnceLock::new();
97        GLOBAL_CACHE.get_or_init(|| Arc::new(Self::create_instance()))
98    }
99
100    #[cfg(test)]
101    pub fn new_instance() -> Self {
102        Self::create_instance()
103    }
104
105    fn create_instance() -> Self {
106        Self {
107            entries: Arc::new(DashMap::new()),
108            network_configs: Arc::new(DashMap::new()),
109            refreshing_networks: Arc::new(DashMap::new()),
110        }
111    }
112
113    pub fn configure_network(&self, chain_id: u64, config: GasPriceCacheConfig) {
114        self.network_configs.insert(chain_id, config);
115    }
116
117    pub fn has_configuration_for_network(&self, chain_id: u64) -> bool {
118        self.network_configs.contains_key(&chain_id)
119    }
120
121    /// Removes all data for a specific network (both config and cached entries)
122    pub fn remove_network(&self, chain_id: u64) -> bool {
123        let config_removed = self.network_configs.remove(&chain_id).is_some();
124        let entries_removed = self.entries.remove(&chain_id).is_some();
125        config_removed || entries_removed
126    }
127
128    /// Returns a snapshot of cached gas pricing components if present and not expired.
129    /// Includes stale flag for stale-while-revalidate strategies.
130    pub async fn get_snapshot(&self, chain_id: u64) -> Option<GasPriceSnapshot> {
131        let config = self.network_configs.get(&chain_id)?;
132        if !config.enabled {
133            return None;
134        }
135
136        if let Some(entry) = self.entries.get(&chain_id) {
137            let cached = entry.read().await;
138            if cached.is_fresh() || cached.is_stale() {
139                return Some(GasPriceSnapshot {
140                    gas_price: cached.gas_price,
141                    base_fee_per_gas: cached.base_fee_per_gas,
142                    fee_history: cached.fee_history.clone(),
143                    is_stale: cached.is_stale(),
144                });
145            }
146        }
147        None
148    }
149
150    pub async fn set_snapshot(
151        &self,
152        chain_id: u64,
153        gas_price: u128,
154        base_fee_per_gas: u128,
155        fee_history: FeeHistory,
156    ) {
157        // If caching is disabled or missing config, ignore the update
158        let Some(cfg) = self.network_configs.get(&chain_id) else {
159            return;
160        };
161        if !cfg.enabled {
162            return;
163        }
164
165        let entry = GasPriceCacheEntry::new(
166            gas_price,
167            base_fee_per_gas,
168            fee_history,
169            Duration::from_millis(cfg.stale_after_ms),
170            Duration::from_millis(cfg.expire_after_ms),
171        );
172
173        self.set(chain_id, entry).await;
174        info!("Updated gas price snapshot for chain_id {}", chain_id);
175    }
176
177    /// Gets a cached entry for the given chain ID
178    pub async fn get(&self, chain_id: u64) -> Option<GasPriceCacheEntry> {
179        if let Some(entry) = self.entries.get(&chain_id) {
180            let cached = entry.read().await;
181            Some(cached.clone())
182        } else {
183            None
184        }
185    }
186
187    /// Sets a cache entry for the given chain ID
188    pub async fn set(&self, chain_id: u64, entry: GasPriceCacheEntry) {
189        let entry = Arc::new(RwLock::new(entry));
190        self.entries.insert(chain_id, entry);
191    }
192
193    /// Updates an existing cache entry
194    pub async fn update<F>(&self, chain_id: u64, updater: F) -> Result<(), TransactionError>
195    where
196        F: FnOnce(&mut GasPriceCacheEntry),
197    {
198        if let Some(entry) = self.entries.get(&chain_id) {
199            let mut cached = entry.write().await;
200            updater(&mut cached);
201            Ok(())
202        } else {
203            Err(TransactionError::NetworkConfiguration(
204                "Cache entry not found".into(),
205            ))
206        }
207    }
208
209    /// Removes a cache entry
210    pub fn remove(&self, chain_id: u64) -> Option<()> {
211        self.entries.remove(&chain_id).map(|_| ())
212    }
213
214    /// Clears all cache entries
215    pub fn clear(&self) {
216        self.entries.clear();
217    }
218
219    /// Returns the number of cached entries
220    pub fn len(&self) -> usize {
221        self.entries.len()
222    }
223
224    /// Checks if the cache is empty
225    pub fn is_empty(&self) -> bool {
226        self.entries.is_empty()
227    }
228
229    /// Triggers a background refresh for the specified network if not already refreshing.
230    pub fn refresh_network_in_background(
231        &self,
232        network: &EvmNetwork,
233        reward_percentiles: Vec<f64>,
234    ) -> bool {
235        let now = Instant::now();
236
237        // Clean up old refresh entries (probably stuck)
238        let cleanup_threshold = Duration::from_secs(GAS_PRICE_CACHE_REFRESH_TIMEOUT_SECS);
239        self.refreshing_networks
240            .retain(|_, started_at| now.duration_since(*started_at) < cleanup_threshold);
241
242        let already_refreshing = self
243            .refreshing_networks
244            .insert(network.chain_id, now)
245            .is_some();
246        if already_refreshing {
247            return false;
248        }
249
250        // Start the background refresh
251        let network = network.clone();
252
253        // Clone the Arc references
254        let entries = self.entries.clone();
255        let network_configs = self.network_configs.clone();
256        let refreshing_networks = self.refreshing_networks.clone();
257
258        tokio::spawn(async move {
259            let refresh = async {
260                // Get network provider and fetch fresh data
261                let provider =
262                    crate::services::provider::get_network_provider(&network, None).ok()?;
263
264                // Use the generic fetcher factory to get the best gas price for this network
265                let fresh_gas_price = GasPriceFetcherFactory::fetch_gas_price(&provider, &network)
266                    .await
267                    .ok()?;
268
269                let block = provider.get_block_by_number().await.ok()?;
270                let fresh_base_fee: u128 = block.header.base_fee_per_gas.unwrap_or(0).into();
271                let fee_hist = provider
272                    .get_fee_history(
273                        HISTORICAL_BLOCKS,
274                        BlockNumberOrTag::Latest,
275                        reward_percentiles,
276                    )
277                    .await
278                    .ok()?;
279
280                // Update the cache using the cloned Arc references
281                // Check if caching is enabled for this network
282                let cfg = network_configs.get(&network.chain_id)?;
283                if !cfg.enabled {
284                    return None;
285                }
286
287                let entry = GasPriceCacheEntry::new(
288                    fresh_gas_price,
289                    fresh_base_fee,
290                    fee_hist,
291                    Duration::from_millis(cfg.stale_after_ms),
292                    Duration::from_millis(cfg.expire_after_ms),
293                );
294
295                let entry = Arc::new(RwLock::new(entry));
296                entries.insert(network.chain_id, entry);
297                info!(
298                    "Updated gas price snapshot for chain_id {} in background",
299                    network.chain_id
300                );
301                Some(())
302            };
303
304            // Execute refresh and clean up tracking
305            let _ = refresh.await;
306            refreshing_networks.remove(&network.chain_id);
307        });
308
309        true
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use alloy::rpc::types::FeeHistory;
317
318    fn create_test_components() -> (u128, u128, FeeHistory) {
319        (
320            20_000_000_000,
321            10_000_000_000,
322            FeeHistory {
323                oldest_block: 100,
324                base_fee_per_gas: vec![10_000_000_000],
325                gas_used_ratio: vec![0.5],
326                reward: Some(vec![vec![
327                    1_000_000_000,
328                    2_000_000_000,
329                    3_000_000_000,
330                    4_000_000_000,
331                ]]),
332                base_fee_per_blob_gas: vec![],
333                blob_gas_used_ratio: vec![],
334            },
335        )
336    }
337
338    #[tokio::test]
339    async fn test_cache_entry_freshness() {
340        let (gas_price, base_fee, fee_history) = create_test_components();
341        let entry = GasPriceCacheEntry::new(
342            gas_price,
343            base_fee,
344            fee_history,
345            Duration::from_secs(30),
346            Duration::from_secs(120),
347        );
348
349        assert!(entry.is_fresh());
350        assert!(!entry.is_stale());
351        assert!(!entry.is_expired());
352    }
353
354    #[tokio::test]
355    async fn test_cache_basic_operations() {
356        let cache = GasPriceCache::new_instance();
357        let chain_id = 1u64;
358
359        // Test empty cache
360        assert!(cache.get(chain_id).await.is_none());
361        assert!(cache.is_empty());
362
363        // Test set and get
364        let (gas_price, base_fee, fee_history) = create_test_components();
365        let entry = GasPriceCacheEntry::new(
366            gas_price,
367            base_fee,
368            fee_history,
369            Duration::from_secs(30),
370            Duration::from_secs(120),
371        );
372
373        cache.set(chain_id, entry.clone()).await;
374        assert_eq!(cache.len(), 1);
375
376        let retrieved = cache.get(chain_id).await.unwrap();
377        assert_eq!(retrieved.gas_price, entry.gas_price);
378    }
379
380    #[tokio::test]
381    async fn test_cache_update() {
382        let cache = GasPriceCache::new_instance();
383        let chain_id = 1u64;
384
385        let (gas_price, base_fee, fee_history) = create_test_components();
386        let entry = GasPriceCacheEntry::new(
387            gas_price,
388            base_fee,
389            fee_history,
390            Duration::from_secs(30),
391            Duration::from_secs(120),
392        );
393
394        cache.set(chain_id, entry).await;
395
396        // Update the entry
397        cache
398            .update(chain_id, |entry| {
399                entry.gas_price = 25_000_000_000;
400            })
401            .await
402            .unwrap();
403
404        let updated = cache.get(chain_id).await.unwrap();
405        assert_eq!(updated.gas_price, 25_000_000_000);
406    }
407
408    #[tokio::test]
409    async fn test_cache_clear() {
410        let cache = GasPriceCache::new_instance();
411
412        // Add multiple entries
413        for chain_id in 1..=3 {
414            let (gas_price, base_fee, fee_history) = create_test_components();
415            let entry = GasPriceCacheEntry::new(
416                gas_price,
417                base_fee,
418                fee_history,
419                Duration::from_secs(30),
420                Duration::from_secs(120),
421            );
422            cache.set(chain_id, entry).await;
423        }
424
425        assert_eq!(cache.len(), 3);
426
427        // Clear cache
428        cache.clear();
429        assert!(cache.is_empty());
430    }
431
432    #[tokio::test]
433    async fn test_network_management() {
434        use crate::config::GasPriceCacheConfig;
435
436        let cache = GasPriceCache::new_instance();
437        let chain_id = 1u64;
438
439        // Initially no entries or config
440        assert!(!cache.has_configuration_for_network(chain_id));
441
442        // Add configuration
443        let config = GasPriceCacheConfig {
444            enabled: true,
445            stale_after_ms: 30000,
446            expire_after_ms: 120000,
447        };
448        cache.configure_network(chain_id, config);
449
450        assert!(cache.has_configuration_for_network(chain_id));
451
452        // Add cache entry
453        let (gas_price, base_fee, fee_history) = create_test_components();
454        let entry = GasPriceCacheEntry::new(
455            gas_price,
456            base_fee,
457            fee_history,
458            Duration::from_secs(30),
459            Duration::from_secs(120),
460        );
461        cache.set(chain_id, entry).await;
462
463        // Now we have entries
464        assert!(cache.has_configuration_for_network(chain_id));
465        assert_eq!(cache.len(), 1);
466
467        // Remove all network data
468        assert!(cache.remove_network(chain_id));
469
470        // Verify everything is removed
471        assert!(!cache.has_configuration_for_network(chain_id));
472        assert!(cache.is_empty());
473
474        // Removing again should return false (nothing to remove)
475        assert!(!cache.remove_network(chain_id));
476    }
477
478    #[tokio::test]
479    async fn test_polygon_zkevm_network_detection() {
480        use crate::constants::POLYGON_ZKEVM_TAG;
481
482        // Create a mock zkEVM network
483        let mut zkevm_network = EvmNetwork {
484            network: "polygon-zkevm".to_string(),
485            rpc_urls: vec!["https://zkevm-rpc.com".to_string()],
486            explorer_urls: None,
487            average_blocktime_ms: 2000,
488            is_testnet: false,
489            tags: vec![POLYGON_ZKEVM_TAG.to_string()],
490            chain_id: 1101,
491            required_confirmations: 1,
492            features: vec!["eip1559".to_string()],
493            symbol: "ETH".to_string(),
494            gas_price_cache: None,
495        };
496
497        // Test zkEVM detection
498        assert!(zkevm_network.is_polygon_zkevm());
499
500        // Test non-zkEVM network
501        zkevm_network.tags = vec!["rollup".to_string()];
502        assert!(!zkevm_network.is_polygon_zkevm());
503    }
504}