use std::collections::HashMap;
use serde::{Deserialize, Serialize};
type Selector = [u8; 4];
#[derive(Debug, Clone, Copy)]
pub struct GasLimits;
impl GasLimits {
pub const ETH_TRANSFER: u64 = 21_000;
pub const APPROVE: u64 = 60_000;
pub const OPEN_TAKER: u64 = 700_000;
pub const OPEN_MAKER: u64 = 800_000;
pub const CLOSE_POSITION: u64 = 600_000;
pub const ADJUST_NOTIONAL: u64 = 500_000;
pub const ADJUST_MARGIN: u64 = 500_000;
pub const TRANSFER: u64 = 65_000;
}
#[derive(Debug)]
pub struct GasLimitCache {
estimates: HashMap<Selector, CachedEstimate>,
ttl_ms: u64,
buffer: f64,
}
#[derive(Debug, Clone, Copy)]
struct CachedEstimate {
gas_limit: u64,
cached_at_ms: u64,
}
const DEFAULT_ESTIMATE_TTL_MS: u64 = 3_600_000;
const DEFAULT_ESTIMATE_BUFFER: f64 = 1.2;
impl GasLimitCache {
pub fn new() -> Self {
Self {
estimates: HashMap::new(),
ttl_ms: DEFAULT_ESTIMATE_TTL_MS,
buffer: DEFAULT_ESTIMATE_BUFFER,
}
}
pub fn with_config(ttl_ms: u64, buffer: f64) -> Self {
Self {
estimates: HashMap::new(),
ttl_ms,
buffer,
}
}
pub fn get(&self, selector: &Selector, now_ms: u64) -> Option<u64> {
let entry = self.estimates.get(selector)?;
if now_ms.saturating_sub(entry.cached_at_ms) < self.ttl_ms {
Some(entry.gas_limit)
} else {
None
}
}
pub fn put(&mut self, selector: Selector, raw_estimate: u64, now_ms: u64) {
let buffered = (raw_estimate as f64 * self.buffer) as u64;
self.estimates.insert(
selector,
CachedEstimate {
gas_limit: buffered,
cached_at_ms: now_ms,
},
);
}
pub fn set_ttl(&mut self, ttl_ms: u64) {
self.ttl_ms = ttl_ms;
}
}
impl Default for GasLimitCache {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Urgency {
Low,
Normal,
High,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct GasFees {
pub base_fee: u64,
pub max_priority_fee_per_gas: u64,
pub max_fee_per_gas: u64,
pub updated_at_ms: u64,
}
#[derive(Debug)]
pub struct FeeCache {
current: Option<GasFees>,
ttl_ms: u64,
default_priority_fee: u64,
}
impl FeeCache {
pub fn new(ttl_ms: u64, default_priority_fee: u64) -> Self {
Self {
current: None,
ttl_ms,
default_priority_fee,
}
}
pub fn update(&mut self, base_fee: u64, now_ms: u64) {
tracing::debug!(base_fee, "gas cache updated");
self.current = Some(GasFees {
base_fee,
max_priority_fee_per_gas: self.default_priority_fee,
max_fee_per_gas: 2u64
.saturating_mul(base_fee)
.saturating_add(self.default_priority_fee),
updated_at_ms: now_ms,
});
}
#[inline]
pub fn is_valid(&self, now_ms: u64) -> bool {
self.current
.map(|f| now_ms.saturating_sub(f.updated_at_ms) < self.ttl_ms)
.unwrap_or(false)
}
#[inline]
pub fn get(&self, now_ms: u64) -> Option<&GasFees> {
self.current
.as_ref()
.filter(|f| now_ms.saturating_sub(f.updated_at_ms) < self.ttl_ms)
}
pub fn set_ttl(&mut self, ttl_ms: u64) {
self.ttl_ms = ttl_ms;
}
#[inline]
pub fn base_fee(&self) -> Option<u64> {
self.current.map(|f| f.base_fee)
}
#[inline]
pub fn fees_for(&self, urgency: Urgency, now_ms: u64) -> Option<GasFees> {
let base = self.get(now_ms)?;
let bf = base.base_fee;
let pf = self.default_priority_fee;
let (max_fee, priority) = match urgency {
Urgency::Low => (bf.saturating_add(pf), pf),
Urgency::Normal => (2u64.saturating_mul(bf).saturating_add(pf), pf),
Urgency::High => (
3u64.saturating_mul(bf)
.saturating_add(2u64.saturating_mul(pf)),
2u64.saturating_mul(pf),
),
Urgency::Critical => (
4u64.saturating_mul(bf)
.saturating_add(5u64.saturating_mul(pf)),
5u64.saturating_mul(pf),
),
};
Some(GasFees {
base_fee: bf,
max_priority_fee_per_gas: priority,
max_fee_per_gas: max_fee,
updated_at_ms: base.updated_at_ms,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: u64 = 50_000_000; const TIP: u64 = 1_000_000_000;
fn cache_with_fees(now_ms: u64) -> FeeCache {
let mut c = FeeCache::new(2000, TIP);
c.update(BASE, now_ms);
c
}
#[test]
fn empty_cache_is_invalid() {
let c = FeeCache::new(2000, TIP);
assert!(!c.is_valid(0));
assert!(c.get(0).is_none());
assert!(c.fees_for(Urgency::Normal, 0).is_none());
}
#[test]
fn update_makes_cache_valid() {
let c = cache_with_fees(1000);
assert!(c.is_valid(1000));
assert!(c.is_valid(2999)); }
#[test]
fn cache_expires_after_ttl() {
let c = cache_with_fees(1000);
assert!(c.is_valid(2999));
assert!(!c.is_valid(3000)); assert!(!c.is_valid(5000));
}
#[test]
fn low_urgency_fees() {
let c = cache_with_fees(0);
let f = c.fees_for(Urgency::Low, 0).unwrap();
assert_eq!(f.max_fee_per_gas, BASE + TIP);
assert_eq!(f.max_priority_fee_per_gas, TIP);
assert_eq!(f.base_fee, BASE);
}
#[test]
fn normal_urgency_fees() {
let c = cache_with_fees(0);
let f = c.fees_for(Urgency::Normal, 0).unwrap();
assert_eq!(f.max_fee_per_gas, 2 * BASE + TIP);
assert_eq!(f.max_priority_fee_per_gas, TIP);
}
#[test]
fn high_urgency_fees() {
let c = cache_with_fees(0);
let f = c.fees_for(Urgency::High, 0).unwrap();
assert_eq!(f.max_fee_per_gas, 3 * BASE + 2 * TIP);
assert_eq!(f.max_priority_fee_per_gas, 2 * TIP);
}
#[test]
fn critical_urgency_fees() {
let c = cache_with_fees(0);
let f = c.fees_for(Urgency::Critical, 0).unwrap();
assert_eq!(f.max_fee_per_gas, 4 * BASE + 5 * TIP);
assert_eq!(f.max_priority_fee_per_gas, 5 * TIP);
}
#[test]
fn urgency_ordering() {
let c = cache_with_fees(0);
let low = c.fees_for(Urgency::Low, 0).unwrap().max_fee_per_gas;
let normal = c.fees_for(Urgency::Normal, 0).unwrap().max_fee_per_gas;
let high = c.fees_for(Urgency::High, 0).unwrap().max_fee_per_gas;
let critical = c.fees_for(Urgency::Critical, 0).unwrap().max_fee_per_gas;
assert!(low < normal);
assert!(normal < high);
assert!(high < critical);
}
#[test]
fn fees_for_stale_returns_none() {
let c = cache_with_fees(0);
assert!(c.fees_for(Urgency::Normal, 3000).is_none());
}
#[test]
fn update_replaces_old_fees() {
let mut c = cache_with_fees(0);
c.update(100_000_000, 5000); let f = c.fees_for(Urgency::Low, 5000).unwrap();
assert_eq!(f.base_fee, 100_000_000);
}
#[test]
fn saturating_arithmetic_on_huge_values() {
let mut c = FeeCache::new(2000, u64::MAX / 2);
c.update(u64::MAX / 2, 0);
let f = c.fees_for(Urgency::Critical, 0).unwrap();
assert_eq!(f.max_fee_per_gas, u64::MAX);
}
#[test]
fn preserves_timestamp_across_urgency() {
let c = cache_with_fees(42);
for urgency in [
Urgency::Low,
Urgency::Normal,
Urgency::High,
Urgency::Critical,
] {
let f = c.fees_for(urgency, 42).unwrap();
assert_eq!(f.updated_at_ms, 42);
}
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn gas_limits_are_reasonable() {
assert!(GasLimits::APPROVE > 20_000 && GasLimits::APPROVE < 200_000);
assert!(GasLimits::OPEN_TAKER > 200_000 && GasLimits::OPEN_TAKER < 2_000_000);
assert!(GasLimits::CLOSE_POSITION > 100_000 && GasLimits::CLOSE_POSITION < 2_000_000);
assert!(GasLimits::OPEN_MAKER > GasLimits::OPEN_TAKER);
}
#[test]
fn estimate_cache_applies_buffer_and_expires() {
let mut cache = GasLimitCache::with_config(1000, 1.5);
let selector = [0x01, 0x02, 0x03, 0x04];
assert!(cache.get(&selector, 0).is_none());
cache.put(selector, 100_000, 0);
assert_eq!(cache.get(&selector, 0), Some(150_000)); assert_eq!(cache.get(&selector, 999), Some(150_000)); assert!(cache.get(&selector, 1000).is_none()); }
#[test]
fn estimate_cache_selectors_are_independent() {
let mut cache = GasLimitCache::new();
let open = [0xAA, 0xBB, 0xCC, 0xDD];
let close = [0x11, 0x22, 0x33, 0x44];
cache.put(open, 500_000, 0);
cache.put(close, 800_000, 0);
assert_eq!(cache.get(&open, 0), Some(600_000));
assert_eq!(cache.get(&close, 0), Some(960_000));
}
}