Skip to main content

perpcity_sdk/hft/
gas.rs

1//! EIP-1559 gas fee caching with urgency-based scaling.
2//!
3//! Pre-computed gas limits eliminate `estimateGas` RPC calls on the hot path.
4//! The [`GasCache`] stores the latest base fee from block headers and
5//! computes EIP-1559 fees scaled by [`Urgency`].
6//!
7//! # Example
8//!
9//! ```
10//! use perpcity_sdk::hft::gas::{GasCache, Urgency, GasLimits};
11//!
12//! let mut cache = GasCache::new(2_000, 1_000_000_000);
13//! cache.update(50_000_000, 1000); // base_fee from block header, at t=1000ms
14//!
15//! let fees = cache.fees_for(Urgency::Normal, 1500).unwrap(); // within TTL
16//! assert!(fees.max_fee_per_gas >= fees.max_priority_fee_per_gas);
17//! ```
18
19/// Pre-empirically derived gas limits for PerpCity operations.
20///
21/// Each limit includes ~20% margin over observed mainnet usage.
22#[derive(Debug, Clone, Copy)]
23pub struct GasLimits;
24
25impl GasLimits {
26    /// ERC-20 `approve` call.
27    pub const APPROVE: u64 = 60_000;
28    /// Open a taker position (market order).
29    pub const OPEN_TAKER: u64 = 700_000;
30    /// Open a maker position (range order).
31    pub const OPEN_MAKER: u64 = 800_000;
32    /// Close any position.
33    pub const CLOSE_POSITION: u64 = 600_000;
34    /// Adjust position notional (add/remove exposure).
35    pub const ADJUST_NOTIONAL: u64 = 350_000;
36    /// Adjust position margin (add/remove collateral).
37    pub const ADJUST_MARGIN: u64 = 250_000;
38}
39
40/// Transaction urgency level, controlling EIP-1559 fee scaling.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum Urgency {
43    /// `maxFee = baseFee + priorityFee`. Cost-optimized, may be slow.
44    Low,
45    /// `maxFee = 2 * baseFee + priorityFee`. Standard EIP-1559 headroom.
46    Normal,
47    /// `maxFee = 3 * baseFee + 2 * priorityFee`. Faster inclusion.
48    High,
49    /// `maxFee = 4 * baseFee + 5 * priorityFee`. For liquidations / time-critical.
50    Critical,
51}
52
53/// EIP-1559 gas fees ready to attach to a transaction.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct GasFees {
56    /// The block base fee this was computed from (wei).
57    pub base_fee: u64,
58    /// Miner tip (wei).
59    pub max_priority_fee_per_gas: u64,
60    /// Fee cap (wei). Always ≥ `base_fee + max_priority_fee_per_gas`.
61    pub max_fee_per_gas: u64,
62    /// Timestamp (ms) when the underlying base fee was observed.
63    pub updated_at_ms: u64,
64}
65
66/// Cached EIP-1559 gas fees with TTL-based staleness detection.
67///
68/// Updated from block headers (typically via subscription or polling).
69/// All methods that check freshness take an explicit `now_ms` parameter
70/// for deterministic testing.
71#[derive(Debug)]
72pub struct GasCache {
73    current: Option<GasFees>,
74    ttl_ms: u64,
75    default_priority_fee: u64,
76}
77
78impl GasCache {
79    /// Create a new cache.
80    ///
81    /// - `ttl_ms`: how long cached fees are valid (2000 = 2 Base L2 blocks)
82    /// - `default_priority_fee`: miner tip in wei (e.g. 1_000_000_000 = 1 gwei)
83    pub fn new(ttl_ms: u64, default_priority_fee: u64) -> Self {
84        Self {
85            current: None,
86            ttl_ms,
87            default_priority_fee,
88        }
89    }
90
91    /// Update the cache from a new block header's base fee.
92    pub fn update(&mut self, base_fee: u64, now_ms: u64) {
93        self.current = Some(GasFees {
94            base_fee,
95            max_priority_fee_per_gas: self.default_priority_fee,
96            // Store the "Normal" urgency as the default cached value
97            max_fee_per_gas: 2u64
98                .saturating_mul(base_fee)
99                .saturating_add(self.default_priority_fee),
100            updated_at_ms: now_ms,
101        });
102    }
103
104    /// Check if the cache has valid (non-stale) fees.
105    #[inline]
106    pub fn is_valid(&self, now_ms: u64) -> bool {
107        self.current
108            .map(|f| now_ms.saturating_sub(f.updated_at_ms) < self.ttl_ms)
109            .unwrap_or(false)
110    }
111
112    /// Get the raw cached fees if still within TTL.
113    #[inline]
114    pub fn get(&self, now_ms: u64) -> Option<&GasFees> {
115        self.current
116            .as_ref()
117            .filter(|f| now_ms.saturating_sub(f.updated_at_ms) < self.ttl_ms)
118    }
119
120    /// Compute fees scaled for the given [`Urgency`], or `None` if stale/empty.
121    ///
122    /// Fee formulas:
123    /// - **Low**: `base + priority`
124    /// - **Normal**: `2*base + priority`
125    /// - **High**: `3*base + 2*priority`
126    /// - **Critical**: `4*base + 5*priority`
127    #[inline]
128    pub fn fees_for(&self, urgency: Urgency, now_ms: u64) -> Option<GasFees> {
129        let base = self.get(now_ms)?;
130        let bf = base.base_fee;
131        let pf = self.default_priority_fee;
132
133        let (max_fee, priority) = match urgency {
134            Urgency::Low => (bf.saturating_add(pf), pf),
135            Urgency::Normal => (2u64.saturating_mul(bf).saturating_add(pf), pf),
136            Urgency::High => (
137                3u64.saturating_mul(bf)
138                    .saturating_add(2u64.saturating_mul(pf)),
139                2u64.saturating_mul(pf),
140            ),
141            Urgency::Critical => (
142                4u64.saturating_mul(bf)
143                    .saturating_add(5u64.saturating_mul(pf)),
144                5u64.saturating_mul(pf),
145            ),
146        };
147
148        Some(GasFees {
149            base_fee: bf,
150            max_priority_fee_per_gas: priority,
151            max_fee_per_gas: max_fee,
152            updated_at_ms: base.updated_at_ms,
153        })
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    const BASE: u64 = 50_000_000; // 50 Mwei ~ typical Base L2
162    const TIP: u64 = 1_000_000_000; // 1 gwei
163
164    fn cache_with_fees(now_ms: u64) -> GasCache {
165        let mut c = GasCache::new(2000, TIP);
166        c.update(BASE, now_ms);
167        c
168    }
169
170    #[test]
171    fn empty_cache_is_invalid() {
172        let c = GasCache::new(2000, TIP);
173        assert!(!c.is_valid(0));
174        assert!(c.get(0).is_none());
175        assert!(c.fees_for(Urgency::Normal, 0).is_none());
176    }
177
178    #[test]
179    fn update_makes_cache_valid() {
180        let c = cache_with_fees(1000);
181        assert!(c.is_valid(1000));
182        assert!(c.is_valid(2999)); // within 2000ms TTL
183    }
184
185    #[test]
186    fn cache_expires_after_ttl() {
187        let c = cache_with_fees(1000);
188        assert!(c.is_valid(2999));
189        assert!(!c.is_valid(3000)); // exactly at TTL boundary
190        assert!(!c.is_valid(5000));
191    }
192
193    #[test]
194    fn low_urgency_fees() {
195        let c = cache_with_fees(0);
196        let f = c.fees_for(Urgency::Low, 0).unwrap();
197        assert_eq!(f.max_fee_per_gas, BASE + TIP);
198        assert_eq!(f.max_priority_fee_per_gas, TIP);
199        assert_eq!(f.base_fee, BASE);
200    }
201
202    #[test]
203    fn normal_urgency_fees() {
204        let c = cache_with_fees(0);
205        let f = c.fees_for(Urgency::Normal, 0).unwrap();
206        assert_eq!(f.max_fee_per_gas, 2 * BASE + TIP);
207        assert_eq!(f.max_priority_fee_per_gas, TIP);
208    }
209
210    #[test]
211    fn high_urgency_fees() {
212        let c = cache_with_fees(0);
213        let f = c.fees_for(Urgency::High, 0).unwrap();
214        assert_eq!(f.max_fee_per_gas, 3 * BASE + 2 * TIP);
215        assert_eq!(f.max_priority_fee_per_gas, 2 * TIP);
216    }
217
218    #[test]
219    fn critical_urgency_fees() {
220        let c = cache_with_fees(0);
221        let f = c.fees_for(Urgency::Critical, 0).unwrap();
222        assert_eq!(f.max_fee_per_gas, 4 * BASE + 5 * TIP);
223        assert_eq!(f.max_priority_fee_per_gas, 5 * TIP);
224    }
225
226    #[test]
227    fn urgency_ordering() {
228        let c = cache_with_fees(0);
229        let low = c.fees_for(Urgency::Low, 0).unwrap().max_fee_per_gas;
230        let normal = c.fees_for(Urgency::Normal, 0).unwrap().max_fee_per_gas;
231        let high = c.fees_for(Urgency::High, 0).unwrap().max_fee_per_gas;
232        let critical = c.fees_for(Urgency::Critical, 0).unwrap().max_fee_per_gas;
233        assert!(low < normal);
234        assert!(normal < high);
235        assert!(high < critical);
236    }
237
238    #[test]
239    fn fees_for_stale_returns_none() {
240        let c = cache_with_fees(0);
241        assert!(c.fees_for(Urgency::Normal, 3000).is_none());
242    }
243
244    #[test]
245    fn update_replaces_old_fees() {
246        let mut c = cache_with_fees(0);
247        c.update(100_000_000, 5000); // new base fee
248        let f = c.fees_for(Urgency::Low, 5000).unwrap();
249        assert_eq!(f.base_fee, 100_000_000);
250    }
251
252    #[test]
253    fn saturating_arithmetic_on_huge_values() {
254        let mut c = GasCache::new(2000, u64::MAX / 2);
255        c.update(u64::MAX / 2, 0);
256        // Should not panic, uses saturating math
257        let f = c.fees_for(Urgency::Critical, 0).unwrap();
258        assert_eq!(f.max_fee_per_gas, u64::MAX);
259    }
260
261    #[test]
262    fn preserves_timestamp_across_urgency() {
263        let c = cache_with_fees(42);
264        for urgency in [
265            Urgency::Low,
266            Urgency::Normal,
267            Urgency::High,
268            Urgency::Critical,
269        ] {
270            let f = c.fees_for(urgency, 42).unwrap();
271            assert_eq!(f.updated_at_ms, 42);
272        }
273    }
274
275    #[test]
276    #[allow(clippy::assertions_on_constants)]
277    fn gas_limits_are_reasonable() {
278        // Ensure limits are in a sane range (not accidentally 0 or astronomical)
279        assert!(GasLimits::APPROVE > 20_000 && GasLimits::APPROVE < 200_000);
280        assert!(GasLimits::OPEN_TAKER > 200_000 && GasLimits::OPEN_TAKER < 2_000_000);
281        assert!(GasLimits::CLOSE_POSITION > 100_000 && GasLimits::CLOSE_POSITION < 2_000_000);
282        // Maker is more expensive than taker (more Uniswap V4 work)
283        assert!(GasLimits::OPEN_MAKER > GasLimits::OPEN_TAKER);
284    }
285}