1use crate::{
8 config::GasPriceCacheConfig,
9 constants::{GAS_PRICE_CACHE_REFRESH_TIMEOUT_SECS, HISTORICAL_BLOCKS},
10 models::{EvmNetwork, TransactionError},
11 services::{gas::fetchers::GasPriceFetcherFactory, 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 = crate::services::get_network_provider(&network, None).ok()?;
262
263 let fresh_gas_price = GasPriceFetcherFactory::fetch_gas_price(&provider, &network)
265 .await
266 .ok()?;
267
268 let block = provider.get_block_by_number().await.ok()?;
269 let fresh_base_fee: u128 = block.header.base_fee_per_gas.unwrap_or(0).into();
270 let fee_hist = provider
271 .get_fee_history(
272 HISTORICAL_BLOCKS,
273 BlockNumberOrTag::Latest,
274 reward_percentiles,
275 )
276 .await
277 .ok()?;
278
279 let cfg = network_configs.get(&network.chain_id)?;
282 if !cfg.enabled {
283 return None;
284 }
285
286 let entry = GasPriceCacheEntry::new(
287 fresh_gas_price,
288 fresh_base_fee,
289 fee_hist,
290 Duration::from_millis(cfg.stale_after_ms),
291 Duration::from_millis(cfg.expire_after_ms),
292 );
293
294 let entry = Arc::new(RwLock::new(entry));
295 entries.insert(network.chain_id, entry);
296 info!(
297 "Updated gas price snapshot for chain_id {} in background",
298 network.chain_id
299 );
300 Some(())
301 };
302
303 let _ = refresh.await;
305 refreshing_networks.remove(&network.chain_id);
306 });
307
308 true
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use alloy::rpc::types::FeeHistory;
316
317 fn create_test_components() -> (u128, u128, FeeHistory) {
318 (
319 20_000_000_000,
320 10_000_000_000,
321 FeeHistory {
322 oldest_block: 100,
323 base_fee_per_gas: vec![10_000_000_000],
324 gas_used_ratio: vec![0.5],
325 reward: Some(vec![vec![
326 1_000_000_000,
327 2_000_000_000,
328 3_000_000_000,
329 4_000_000_000,
330 ]]),
331 base_fee_per_blob_gas: vec![],
332 blob_gas_used_ratio: vec![],
333 },
334 )
335 }
336
337 #[tokio::test]
338 async fn test_cache_entry_freshness() {
339 let (gas_price, base_fee, fee_history) = create_test_components();
340 let entry = GasPriceCacheEntry::new(
341 gas_price,
342 base_fee,
343 fee_history,
344 Duration::from_secs(30),
345 Duration::from_secs(120),
346 );
347
348 assert!(entry.is_fresh());
349 assert!(!entry.is_stale());
350 assert!(!entry.is_expired());
351 }
352
353 #[tokio::test]
354 async fn test_cache_basic_operations() {
355 let cache = GasPriceCache::new_instance();
356 let chain_id = 1u64;
357
358 assert!(cache.get(chain_id).await.is_none());
360 assert!(cache.is_empty());
361
362 let (gas_price, base_fee, fee_history) = create_test_components();
364 let entry = GasPriceCacheEntry::new(
365 gas_price,
366 base_fee,
367 fee_history,
368 Duration::from_secs(30),
369 Duration::from_secs(120),
370 );
371
372 cache.set(chain_id, entry.clone()).await;
373 assert_eq!(cache.len(), 1);
374
375 let retrieved = cache.get(chain_id).await.unwrap();
376 assert_eq!(retrieved.gas_price, entry.gas_price);
377 }
378
379 #[tokio::test]
380 async fn test_cache_update() {
381 let cache = GasPriceCache::new_instance();
382 let chain_id = 1u64;
383
384 let (gas_price, base_fee, fee_history) = create_test_components();
385 let entry = GasPriceCacheEntry::new(
386 gas_price,
387 base_fee,
388 fee_history,
389 Duration::from_secs(30),
390 Duration::from_secs(120),
391 );
392
393 cache.set(chain_id, entry).await;
394
395 cache
397 .update(chain_id, |entry| {
398 entry.gas_price = 25_000_000_000;
399 })
400 .await
401 .unwrap();
402
403 let updated = cache.get(chain_id).await.unwrap();
404 assert_eq!(updated.gas_price, 25_000_000_000);
405 }
406
407 #[tokio::test]
408 async fn test_cache_clear() {
409 let cache = GasPriceCache::new_instance();
410
411 for chain_id in 1..=3 {
413 let (gas_price, base_fee, fee_history) = create_test_components();
414 let entry = GasPriceCacheEntry::new(
415 gas_price,
416 base_fee,
417 fee_history,
418 Duration::from_secs(30),
419 Duration::from_secs(120),
420 );
421 cache.set(chain_id, entry).await;
422 }
423
424 assert_eq!(cache.len(), 3);
425
426 cache.clear();
428 assert!(cache.is_empty());
429 }
430
431 #[tokio::test]
432 async fn test_network_management() {
433 use crate::config::GasPriceCacheConfig;
434
435 let cache = GasPriceCache::new_instance();
436 let chain_id = 1u64;
437
438 assert!(!cache.has_configuration_for_network(chain_id));
440
441 let config = GasPriceCacheConfig {
443 enabled: true,
444 stale_after_ms: 30000,
445 expire_after_ms: 120000,
446 };
447 cache.configure_network(chain_id, config);
448
449 assert!(cache.has_configuration_for_network(chain_id));
450
451 let (gas_price, base_fee, fee_history) = create_test_components();
453 let entry = GasPriceCacheEntry::new(
454 gas_price,
455 base_fee,
456 fee_history,
457 Duration::from_secs(30),
458 Duration::from_secs(120),
459 );
460 cache.set(chain_id, entry).await;
461
462 assert!(cache.has_configuration_for_network(chain_id));
464 assert_eq!(cache.len(), 1);
465
466 assert!(cache.remove_network(chain_id));
468
469 assert!(!cache.has_configuration_for_network(chain_id));
471 assert!(cache.is_empty());
472
473 assert!(!cache.remove_network(chain_id));
475 }
476
477 #[tokio::test]
478 async fn test_polygon_zkevm_network_detection() {
479 use crate::constants::POLYGON_ZKEVM_TAG;
480
481 let mut zkevm_network = EvmNetwork {
483 network: "polygon-zkevm".to_string(),
484 rpc_urls: vec!["https://zkevm-rpc.com".to_string()],
485 explorer_urls: None,
486 average_blocktime_ms: 2000,
487 is_testnet: false,
488 tags: vec![POLYGON_ZKEVM_TAG.to_string()],
489 chain_id: 1101,
490 required_confirmations: 1,
491 features: vec!["eip1559".to_string()],
492 symbol: "ETH".to_string(),
493 gas_price_cache: None,
494 };
495
496 assert!(zkevm_network.is_polygon_zkevm());
498
499 zkevm_network.tags = vec!["rollup".to_string()];
501 assert!(!zkevm_network.is_polygon_zkevm());
502 }
503}