1use 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#[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 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 pub fn is_fresh(&self) -> bool {
63 self.fetched_at.elapsed() < self.stale_after
64 }
65
66 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 pub fn is_expired(&self) -> bool {
74 self.fetched_at.elapsed() >= self.expire_after
75 }
76
77 pub fn age(&self) -> Duration {
79 self.fetched_at.elapsed()
80 }
81}
82
83#[derive(Debug)]
85pub struct GasPriceCache {
86 entries: Arc<DashMap<u64, Arc<RwLock<GasPriceCacheEntry>>>>,
88 network_configs: Arc<DashMap<u64, GasPriceCacheConfig>>,
90 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 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 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 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 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 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 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 pub fn remove(&self, chain_id: u64) -> Option<()> {
211 self.entries.remove(&chain_id).map(|_| ())
212 }
213
214 pub fn clear(&self) {
216 self.entries.clear();
217 }
218
219 pub fn len(&self) -> usize {
221 self.entries.len()
222 }
223
224 pub fn is_empty(&self) -> bool {
226 self.entries.is_empty()
227 }
228
229 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 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 let network = network.clone();
252
253 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 let provider =
262 crate::services::provider::get_network_provider(&network, None).ok()?;
263
264 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 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 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 assert!(cache.get(chain_id).await.is_none());
361 assert!(cache.is_empty());
362
363 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 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 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 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 assert!(!cache.has_configuration_for_network(chain_id));
441
442 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 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 assert!(cache.has_configuration_for_network(chain_id));
465 assert_eq!(cache.len(), 1);
466
467 assert!(cache.remove_network(chain_id));
469
470 assert!(!cache.has_configuration_for_network(chain_id));
472 assert!(cache.is_empty());
473
474 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 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 assert!(zkevm_network.is_polygon_zkevm());
499
500 zkevm_network.tags = vec!["rollup".to_string()];
502 assert!(!zkevm_network.is_polygon_zkevm());
503 }
504}