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 [`FeeCache`] 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::{FeeCache, Urgency, GasLimits};
11//!
12//! let mut cache = FeeCache::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
19use std::collections::HashMap;
20
21use serde::{Deserialize, Serialize};
22
23/// 4-byte function selector (first 4 bytes of calldata).
24type Selector = [u8; 4];
25
26/// Pre-empirically derived gas limits for PerpCity operations.
27///
28/// Each limit includes ~20% margin over observed mainnet usage.
29#[derive(Debug, Clone, Copy)]
30pub struct GasLimits;
31
32impl GasLimits {
33    /// Simple ETH transfer — protocol-defined, always 21,000 gas.
34    pub const ETH_TRANSFER: u64 = 21_000;
35    /// ERC-20 `approve` call.
36    pub const APPROVE: u64 = 60_000;
37    /// Open a taker position (market order).
38    pub const OPEN_TAKER: u64 = 700_000;
39    /// Open a maker position (range order).
40    pub const OPEN_MAKER: u64 = 800_000;
41    /// Close any position.
42    pub const CLOSE_POSITION: u64 = 600_000;
43    /// Adjust position notional (add/remove exposure).
44    pub const ADJUST_NOTIONAL: u64 = 500_000;
45    /// Adjust position margin (add/remove collateral).
46    pub const ADJUST_MARGIN: u64 = 500_000;
47    /// ERC-20 `transfer` call.
48    pub const TRANSFER: u64 = 65_000;
49}
50
51/// Cached gas estimates from `eth_estimateGas`, keyed by function selector.
52///
53/// On cache miss, the caller performs an `eth_estimateGas` RPC call and stores
54/// the result with a safety buffer. On cache hit, the stored value is returned
55/// with no RPC. Entries expire after a configurable TTL (default: 1 hour).
56///
57/// This replaces the hardcoded [`GasLimits`] as the default gas source. HFT
58/// users can still bypass estimation by passing an explicit gas limit.
59#[derive(Debug)]
60pub struct GasLimitCache {
61    estimates: HashMap<Selector, CachedEstimate>,
62    ttl_ms: u64,
63    /// Buffer multiplied onto raw estimates (e.g. 1.2 = 20% margin).
64    buffer: f64,
65}
66
67#[derive(Debug, Clone, Copy)]
68struct CachedEstimate {
69    gas_limit: u64,
70    cached_at_ms: u64,
71}
72
73/// Default gas estimate TTL: 1 hour.
74const DEFAULT_ESTIMATE_TTL_MS: u64 = 3_600_000;
75
76/// Default buffer: 20% above the raw estimate.
77const DEFAULT_ESTIMATE_BUFFER: f64 = 1.2;
78
79impl GasLimitCache {
80    /// Create a new cache with default TTL (1 hour) and buffer (20%).
81    pub fn new() -> Self {
82        Self {
83            estimates: HashMap::new(),
84            ttl_ms: DEFAULT_ESTIMATE_TTL_MS,
85            buffer: DEFAULT_ESTIMATE_BUFFER,
86        }
87    }
88
89    /// Create a cache with custom TTL and buffer.
90    pub fn with_config(ttl_ms: u64, buffer: f64) -> Self {
91        Self {
92            estimates: HashMap::new(),
93            ttl_ms,
94            buffer,
95        }
96    }
97
98    /// Look up a cached estimate by function selector.
99    ///
100    /// Returns `None` if no estimate exists or the cached value has expired.
101    pub fn get(&self, selector: &Selector, now_ms: u64) -> Option<u64> {
102        let entry = self.estimates.get(selector)?;
103        if now_ms.saturating_sub(entry.cached_at_ms) < self.ttl_ms {
104            Some(entry.gas_limit)
105        } else {
106            None
107        }
108    }
109
110    /// Store an estimate. Applies the buffer (e.g. raw 580K → stored 696K at 1.2×).
111    pub fn put(&mut self, selector: Selector, raw_estimate: u64, now_ms: u64) {
112        let buffered = (raw_estimate as f64 * self.buffer) as u64;
113        self.estimates.insert(
114            selector,
115            CachedEstimate {
116                gas_limit: buffered,
117                cached_at_ms: now_ms,
118            },
119        );
120    }
121
122    /// Override the TTL.
123    pub fn set_ttl(&mut self, ttl_ms: u64) {
124        self.ttl_ms = ttl_ms;
125    }
126}
127
128impl Default for GasLimitCache {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134/// Transaction urgency level, controlling EIP-1559 fee scaling.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
136pub enum Urgency {
137    /// `maxFee = baseFee + priorityFee`. Cost-optimized, may be slow.
138    Low,
139    /// `maxFee = 2 * baseFee + priorityFee`. Standard EIP-1559 headroom.
140    Normal,
141    /// `maxFee = 3 * baseFee + 2 * priorityFee`. Faster inclusion.
142    High,
143    /// `maxFee = 4 * baseFee + 5 * priorityFee`. For liquidations / time-critical.
144    Critical,
145}
146
147/// EIP-1559 gas fees ready to attach to a transaction.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub struct GasFees {
150    /// The block base fee this was computed from (wei).
151    pub base_fee: u64,
152    /// Miner tip (wei).
153    pub max_priority_fee_per_gas: u64,
154    /// Fee cap (wei). Always ≥ `base_fee + max_priority_fee_per_gas`.
155    pub max_fee_per_gas: u64,
156    /// Timestamp (ms) when the underlying base fee was observed.
157    pub updated_at_ms: u64,
158}
159
160/// Cached EIP-1559 gas fees with TTL-based staleness detection.
161///
162/// Updated from block headers (typically via subscription or polling).
163/// All methods that check freshness take an explicit `now_ms` parameter
164/// for deterministic testing.
165#[derive(Debug)]
166pub struct FeeCache {
167    current: Option<GasFees>,
168    ttl_ms: u64,
169    default_priority_fee: u64,
170}
171
172impl FeeCache {
173    /// Create a new cache.
174    ///
175    /// - `ttl_ms`: how long cached fees are valid (2000 = 2 Base L2 blocks)
176    /// - `default_priority_fee`: miner tip in wei (e.g. 1_000_000_000 = 1 gwei)
177    pub fn new(ttl_ms: u64, default_priority_fee: u64) -> Self {
178        Self {
179            current: None,
180            ttl_ms,
181            default_priority_fee,
182        }
183    }
184
185    /// Update the cache from a new block header's base fee.
186    pub fn update(&mut self, base_fee: u64, now_ms: u64) {
187        tracing::debug!(base_fee, "gas cache updated");
188        self.current = Some(GasFees {
189            base_fee,
190            max_priority_fee_per_gas: self.default_priority_fee,
191            // Store the "Normal" urgency as the default cached value
192            max_fee_per_gas: 2u64
193                .saturating_mul(base_fee)
194                .saturating_add(self.default_priority_fee),
195            updated_at_ms: now_ms,
196        });
197    }
198
199    /// Check if the cache has valid (non-stale) fees.
200    #[inline]
201    pub fn is_valid(&self, now_ms: u64) -> bool {
202        self.current
203            .map(|f| now_ms.saturating_sub(f.updated_at_ms) < self.ttl_ms)
204            .unwrap_or(false)
205    }
206
207    /// Get the raw cached fees if still within TTL.
208    #[inline]
209    pub fn get(&self, now_ms: u64) -> Option<&GasFees> {
210        self.current
211            .as_ref()
212            .filter(|f| now_ms.saturating_sub(f.updated_at_ms) < self.ttl_ms)
213    }
214
215    /// Override the cache TTL (milliseconds).
216    ///
217    /// Use this when gas is managed externally (e.g. a shared poller
218    /// distributing base fees via [`crate::PerpClient::set_base_fee`]). Set the
219    /// TTL to match the poller's cadence with some headroom.
220    pub fn set_ttl(&mut self, ttl_ms: u64) {
221        self.ttl_ms = ttl_ms;
222    }
223
224    /// Return the current cached base fee (ignoring TTL).
225    #[inline]
226    pub fn base_fee(&self) -> Option<u64> {
227        self.current.map(|f| f.base_fee)
228    }
229
230    /// Compute fees scaled for the given [`Urgency`], or `None` if stale/empty.
231    ///
232    /// Fee formulas:
233    /// - **Low**: `base + priority`
234    /// - **Normal**: `2*base + priority`
235    /// - **High**: `3*base + 2*priority`
236    /// - **Critical**: `4*base + 5*priority`
237    #[inline]
238    pub fn fees_for(&self, urgency: Urgency, now_ms: u64) -> Option<GasFees> {
239        let base = self.get(now_ms)?;
240        let bf = base.base_fee;
241        let pf = self.default_priority_fee;
242
243        let (max_fee, priority) = match urgency {
244            Urgency::Low => (bf.saturating_add(pf), pf),
245            Urgency::Normal => (2u64.saturating_mul(bf).saturating_add(pf), pf),
246            Urgency::High => (
247                3u64.saturating_mul(bf)
248                    .saturating_add(2u64.saturating_mul(pf)),
249                2u64.saturating_mul(pf),
250            ),
251            Urgency::Critical => (
252                4u64.saturating_mul(bf)
253                    .saturating_add(5u64.saturating_mul(pf)),
254                5u64.saturating_mul(pf),
255            ),
256        };
257
258        Some(GasFees {
259            base_fee: bf,
260            max_priority_fee_per_gas: priority,
261            max_fee_per_gas: max_fee,
262            updated_at_ms: base.updated_at_ms,
263        })
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    const BASE: u64 = 50_000_000; // 50 Mwei ~ typical Base L2
272    const TIP: u64 = 1_000_000_000; // 1 gwei
273
274    fn cache_with_fees(now_ms: u64) -> FeeCache {
275        let mut c = FeeCache::new(2000, TIP);
276        c.update(BASE, now_ms);
277        c
278    }
279
280    #[test]
281    fn empty_cache_is_invalid() {
282        let c = FeeCache::new(2000, TIP);
283        assert!(!c.is_valid(0));
284        assert!(c.get(0).is_none());
285        assert!(c.fees_for(Urgency::Normal, 0).is_none());
286    }
287
288    #[test]
289    fn update_makes_cache_valid() {
290        let c = cache_with_fees(1000);
291        assert!(c.is_valid(1000));
292        assert!(c.is_valid(2999)); // within 2000ms TTL
293    }
294
295    #[test]
296    fn cache_expires_after_ttl() {
297        let c = cache_with_fees(1000);
298        assert!(c.is_valid(2999));
299        assert!(!c.is_valid(3000)); // exactly at TTL boundary
300        assert!(!c.is_valid(5000));
301    }
302
303    #[test]
304    fn low_urgency_fees() {
305        let c = cache_with_fees(0);
306        let f = c.fees_for(Urgency::Low, 0).unwrap();
307        assert_eq!(f.max_fee_per_gas, BASE + TIP);
308        assert_eq!(f.max_priority_fee_per_gas, TIP);
309        assert_eq!(f.base_fee, BASE);
310    }
311
312    #[test]
313    fn normal_urgency_fees() {
314        let c = cache_with_fees(0);
315        let f = c.fees_for(Urgency::Normal, 0).unwrap();
316        assert_eq!(f.max_fee_per_gas, 2 * BASE + TIP);
317        assert_eq!(f.max_priority_fee_per_gas, TIP);
318    }
319
320    #[test]
321    fn high_urgency_fees() {
322        let c = cache_with_fees(0);
323        let f = c.fees_for(Urgency::High, 0).unwrap();
324        assert_eq!(f.max_fee_per_gas, 3 * BASE + 2 * TIP);
325        assert_eq!(f.max_priority_fee_per_gas, 2 * TIP);
326    }
327
328    #[test]
329    fn critical_urgency_fees() {
330        let c = cache_with_fees(0);
331        let f = c.fees_for(Urgency::Critical, 0).unwrap();
332        assert_eq!(f.max_fee_per_gas, 4 * BASE + 5 * TIP);
333        assert_eq!(f.max_priority_fee_per_gas, 5 * TIP);
334    }
335
336    #[test]
337    fn urgency_ordering() {
338        let c = cache_with_fees(0);
339        let low = c.fees_for(Urgency::Low, 0).unwrap().max_fee_per_gas;
340        let normal = c.fees_for(Urgency::Normal, 0).unwrap().max_fee_per_gas;
341        let high = c.fees_for(Urgency::High, 0).unwrap().max_fee_per_gas;
342        let critical = c.fees_for(Urgency::Critical, 0).unwrap().max_fee_per_gas;
343        assert!(low < normal);
344        assert!(normal < high);
345        assert!(high < critical);
346    }
347
348    #[test]
349    fn fees_for_stale_returns_none() {
350        let c = cache_with_fees(0);
351        assert!(c.fees_for(Urgency::Normal, 3000).is_none());
352    }
353
354    #[test]
355    fn update_replaces_old_fees() {
356        let mut c = cache_with_fees(0);
357        c.update(100_000_000, 5000); // new base fee
358        let f = c.fees_for(Urgency::Low, 5000).unwrap();
359        assert_eq!(f.base_fee, 100_000_000);
360    }
361
362    #[test]
363    fn saturating_arithmetic_on_huge_values() {
364        let mut c = FeeCache::new(2000, u64::MAX / 2);
365        c.update(u64::MAX / 2, 0);
366        // Should not panic, uses saturating math
367        let f = c.fees_for(Urgency::Critical, 0).unwrap();
368        assert_eq!(f.max_fee_per_gas, u64::MAX);
369    }
370
371    #[test]
372    fn preserves_timestamp_across_urgency() {
373        let c = cache_with_fees(42);
374        for urgency in [
375            Urgency::Low,
376            Urgency::Normal,
377            Urgency::High,
378            Urgency::Critical,
379        ] {
380            let f = c.fees_for(urgency, 42).unwrap();
381            assert_eq!(f.updated_at_ms, 42);
382        }
383    }
384
385    #[test]
386    #[allow(clippy::assertions_on_constants)]
387    fn gas_limits_are_reasonable() {
388        // Ensure limits are in a sane range (not accidentally 0 or astronomical)
389        assert!(GasLimits::APPROVE > 20_000 && GasLimits::APPROVE < 200_000);
390        assert!(GasLimits::OPEN_TAKER > 200_000 && GasLimits::OPEN_TAKER < 2_000_000);
391        assert!(GasLimits::CLOSE_POSITION > 100_000 && GasLimits::CLOSE_POSITION < 2_000_000);
392        // Maker is more expensive than taker (more Uniswap V4 work)
393        assert!(GasLimits::OPEN_MAKER > GasLimits::OPEN_TAKER);
394    }
395
396    // ── GasLimitCache tests ───────────────────────────────────────
397
398    #[test]
399    fn estimate_cache_applies_buffer_and_expires() {
400        let mut cache = GasLimitCache::with_config(1000, 1.5);
401        let selector = [0x01, 0x02, 0x03, 0x04];
402
403        assert!(cache.get(&selector, 0).is_none());
404
405        cache.put(selector, 100_000, 0);
406        assert_eq!(cache.get(&selector, 0), Some(150_000)); // 1.5× buffer
407        assert_eq!(cache.get(&selector, 999), Some(150_000)); // within TTL
408        assert!(cache.get(&selector, 1000).is_none()); // expired
409    }
410
411    #[test]
412    fn estimate_cache_selectors_are_independent() {
413        let mut cache = GasLimitCache::new();
414        let open = [0xAA, 0xBB, 0xCC, 0xDD];
415        let close = [0x11, 0x22, 0x33, 0x44];
416
417        cache.put(open, 500_000, 0);
418        cache.put(close, 800_000, 0);
419
420        assert_eq!(cache.get(&open, 0), Some(600_000));
421        assert_eq!(cache.get(&close, 0), Some(960_000));
422    }
423}