Skip to main content

perpcity_sdk/hft/
state_cache.rs

1//! Multi-layer TTL-based state cache for frequently-read on-chain data.
2//!
3//! Two TTL tiers match the data's expected rate of change on Base L2:
4//!
5//! | Layer | TTL | Data | Why |
6//! |---|---|---|---|
7//! | **Slow** | 60 s | Fees, bounds | Change only via governance |
8//! | **Fast** | 2 s (1 block) | Mark prices, funding rates, USDC balance | Change every block |
9//!
10//! All methods take an explicit `now_ts` (Unix seconds) for deterministic
11//! testing — no hidden clock dependencies.
12//!
13//! # Example
14//!
15//! ```
16//! use perpcity_sdk::hft::state_cache::{StateCache, StateCacheConfig, CachedFees};
17//!
18//! let mut cache = StateCache::new(StateCacheConfig::default());
19//! let addr = [0xAA; 20];
20//! let fees = CachedFees {
21//!     creator_fee: 0.001,
22//!     insurance_fee: 0.0005,
23//!     lp_fee: 0.003,
24//!     liquidation_fee: 0.01,
25//! };
26//! cache.put_fees(addr, fees, 1000);
27//! assert!(cache.get_fees(&addr, 1050).is_some()); // within 60s TTL
28//! assert!(cache.get_fees(&addr, 1061).is_none()); // expired
29//! ```
30
31use std::collections::HashMap;
32
33/// A cached value with an expiration timestamp.
34#[derive(Debug, Clone, Copy)]
35pub struct CachedValue<T> {
36    /// The cached value.
37    pub value: T,
38    /// Unix timestamp (seconds) when this entry expires.
39    pub expires_at: u64,
40}
41
42impl<T> CachedValue<T> {
43    /// Check if the cached value is still valid at the given time.
44    #[inline]
45    pub fn is_valid(&self, now_ts: u64) -> bool {
46        now_ts < self.expires_at
47    }
48}
49
50/// Cached fee configuration for a perpetual market.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct CachedFees {
53    /// Creator fee (e.g. 0.001 = 0.1%).
54    pub creator_fee: f64,
55    /// Insurance fund fee.
56    pub insurance_fee: f64,
57    /// LP fee.
58    pub lp_fee: f64,
59    /// Liquidation fee.
60    pub liquidation_fee: f64,
61}
62
63/// Cached position/leverage bounds for a perpetual market.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct CachedBounds {
66    /// Minimum margin in USDC.
67    pub min_margin: f64,
68    /// Minimum taker leverage.
69    pub min_taker_leverage: f64,
70    /// Maximum taker leverage.
71    pub max_taker_leverage: f64,
72    /// Liquidation margin ratio for takers.
73    pub liquidation_taker_ratio: f64,
74}
75
76/// Configuration for [`StateCache`] TTL tiers.
77#[derive(Debug, Clone, Copy)]
78pub struct StateCacheConfig {
79    /// TTL for slowly-changing data: fees, bounds (seconds).
80    pub slow_ttl: u64,
81    /// TTL for fast-changing data: prices, funding rates, balances (seconds).
82    pub fast_ttl: u64,
83}
84
85impl Default for StateCacheConfig {
86    fn default() -> Self {
87        Self {
88            slow_ttl: 60,
89            fast_ttl: 2,
90        }
91    }
92}
93
94/// Multi-layer TTL cache for on-chain state.
95///
96/// Keyed by address (`[u8; 20]`) for per-market data, or by perp ID
97/// (`[u8; 32]`) for per-perp data. The USDC balance is a singleton.
98#[derive(Debug)]
99pub struct StateCache {
100    // Slow layer (60s TTL): governance-controlled
101    fees: HashMap<[u8; 20], CachedValue<CachedFees>>,
102    bounds: HashMap<[u8; 20], CachedValue<CachedBounds>>,
103
104    // Fast layer (2s TTL): changes every block
105    mark_prices: HashMap<[u8; 32], CachedValue<f64>>,
106    funding_rates: HashMap<[u8; 32], CachedValue<f64>>,
107    usdc_balance: Option<CachedValue<f64>>,
108
109    slow_ttl: u64,
110    fast_ttl: u64,
111}
112
113impl StateCache {
114    /// Create a new cache with the given TTL configuration.
115    pub fn new(config: StateCacheConfig) -> Self {
116        Self {
117            fees: HashMap::new(),
118            bounds: HashMap::new(),
119            mark_prices: HashMap::new(),
120            funding_rates: HashMap::new(),
121            usdc_balance: None,
122            slow_ttl: config.slow_ttl,
123            fast_ttl: config.fast_ttl,
124        }
125    }
126
127    // ── Slow layer: fees ───────────────────────────────────────────
128
129    /// Get cached fees for a market address, or `None` if stale/absent.
130    #[inline]
131    pub fn get_fees(&self, addr: &[u8; 20], now_ts: u64) -> Option<&CachedFees> {
132        self.fees
133            .get(addr)
134            .filter(|cv| cv.is_valid(now_ts))
135            .map(|cv| &cv.value)
136    }
137
138    /// Cache fees for a market address.
139    pub fn put_fees(&mut self, addr: [u8; 20], value: CachedFees, now_ts: u64) {
140        self.fees.insert(
141            addr,
142            CachedValue {
143                value,
144                expires_at: now_ts.saturating_add(self.slow_ttl),
145            },
146        );
147    }
148
149    // ── Slow layer: bounds ─────────────────────────────────────────
150
151    /// Get cached bounds for a market address, or `None` if stale/absent.
152    #[inline]
153    pub fn get_bounds(&self, addr: &[u8; 20], now_ts: u64) -> Option<&CachedBounds> {
154        self.bounds
155            .get(addr)
156            .filter(|cv| cv.is_valid(now_ts))
157            .map(|cv| &cv.value)
158    }
159
160    /// Cache bounds for a market address.
161    pub fn put_bounds(&mut self, addr: [u8; 20], value: CachedBounds, now_ts: u64) {
162        self.bounds.insert(
163            addr,
164            CachedValue {
165                value,
166                expires_at: now_ts.saturating_add(self.slow_ttl),
167            },
168        );
169    }
170
171    // ── Fast layer: mark prices ────────────────────────────────────
172
173    /// Get cached mark price for a perp, or `None` if stale/absent.
174    #[inline]
175    pub fn get_mark_price(&self, perp_id: &[u8; 32], now_ts: u64) -> Option<f64> {
176        self.mark_prices
177            .get(perp_id)
178            .filter(|cv| cv.is_valid(now_ts))
179            .map(|cv| cv.value)
180    }
181
182    /// Cache a mark price for a perp.
183    pub fn put_mark_price(&mut self, perp_id: [u8; 32], price: f64, now_ts: u64) {
184        self.mark_prices.insert(
185            perp_id,
186            CachedValue {
187                value: price,
188                expires_at: now_ts.saturating_add(self.fast_ttl),
189            },
190        );
191    }
192
193    // ── Fast layer: funding rates ──────────────────────────────────
194
195    /// Get cached funding rate for a perp, or `None` if stale/absent.
196    #[inline]
197    pub fn get_funding_rate(&self, perp_id: &[u8; 32], now_ts: u64) -> Option<f64> {
198        self.funding_rates
199            .get(perp_id)
200            .filter(|cv| cv.is_valid(now_ts))
201            .map(|cv| cv.value)
202    }
203
204    /// Cache a funding rate for a perp.
205    pub fn put_funding_rate(&mut self, perp_id: [u8; 32], rate: f64, now_ts: u64) {
206        self.funding_rates.insert(
207            perp_id,
208            CachedValue {
209                value: rate,
210                expires_at: now_ts.saturating_add(self.fast_ttl),
211            },
212        );
213    }
214
215    // ── Fast layer: USDC balance ───────────────────────────────────
216
217    /// Get cached USDC balance, or `None` if stale/absent.
218    #[inline]
219    pub fn get_usdc_balance(&self, now_ts: u64) -> Option<f64> {
220        self.usdc_balance
221            .filter(|cv| cv.is_valid(now_ts))
222            .map(|cv| cv.value)
223    }
224
225    /// Cache the USDC balance.
226    pub fn put_usdc_balance(&mut self, balance: f64, now_ts: u64) {
227        self.usdc_balance = Some(CachedValue {
228            value: balance,
229            expires_at: now_ts.saturating_add(self.fast_ttl),
230        });
231    }
232
233    // ── Invalidation ───────────────────────────────────────────────
234
235    /// Invalidate all fast-layer data (prices, funding, balance).
236    ///
237    /// Call on new-block events. The slow layer (fees, bounds) is preserved.
238    pub fn invalidate_fast_layer(&mut self) {
239        self.mark_prices.clear();
240        self.funding_rates.clear();
241        self.usdc_balance = None;
242    }
243
244    /// Invalidate everything (both layers).
245    pub fn invalidate_all(&mut self) {
246        self.fees.clear();
247        self.bounds.clear();
248        self.invalidate_fast_layer();
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn sample_fees() -> CachedFees {
257        CachedFees {
258            creator_fee: 0.001,
259            insurance_fee: 0.0005,
260            lp_fee: 0.003,
261            liquidation_fee: 0.01,
262        }
263    }
264
265    fn sample_bounds() -> CachedBounds {
266        CachedBounds {
267            min_margin: 5.0,
268            min_taker_leverage: 1.0,
269            max_taker_leverage: 100.0,
270            liquidation_taker_ratio: 0.05,
271        }
272    }
273
274    #[test]
275    fn empty_cache_returns_none() {
276        let c = StateCache::new(StateCacheConfig::default());
277        assert!(c.get_fees(&[0; 20], 0).is_none());
278        assert!(c.get_bounds(&[0; 20], 0).is_none());
279        assert!(c.get_mark_price(&[0; 32], 0).is_none());
280        assert!(c.get_funding_rate(&[0; 32], 0).is_none());
281        assert!(c.get_usdc_balance(0).is_none());
282    }
283
284    #[test]
285    fn slow_layer_respects_ttl() {
286        let mut c = StateCache::new(StateCacheConfig::default()); // slow_ttl = 60
287        let addr = [0xAA; 20];
288
289        c.put_fees(addr, sample_fees(), 1000);
290        // Valid at 1059 (59s elapsed < 60s TTL)
291        assert!(c.get_fees(&addr, 1059).is_some());
292        // Expired at 1060 (60s elapsed >= 60s TTL)
293        assert!(c.get_fees(&addr, 1060).is_none());
294    }
295
296    #[test]
297    fn fast_layer_respects_ttl() {
298        let mut c = StateCache::new(StateCacheConfig::default()); // fast_ttl = 2
299        let perp = [0xBB; 32];
300
301        c.put_mark_price(perp, 42000.0, 1000);
302        assert_eq!(c.get_mark_price(&perp, 1001), Some(42000.0));
303        assert!(c.get_mark_price(&perp, 1002).is_none());
304    }
305
306    #[test]
307    fn funding_rate_ttl() {
308        let mut c = StateCache::new(StateCacheConfig::default());
309        let perp = [0xCC; 32];
310
311        c.put_funding_rate(perp, 0.0001, 500);
312        assert_eq!(c.get_funding_rate(&perp, 501), Some(0.0001));
313        assert!(c.get_funding_rate(&perp, 502).is_none());
314    }
315
316    #[test]
317    fn usdc_balance_ttl() {
318        let mut c = StateCache::new(StateCacheConfig::default());
319
320        c.put_usdc_balance(10_000.0, 100);
321        assert_eq!(c.get_usdc_balance(101), Some(10_000.0));
322        assert!(c.get_usdc_balance(102).is_none());
323    }
324
325    #[test]
326    fn bounds_caching() {
327        let mut c = StateCache::new(StateCacheConfig::default());
328        let addr = [0xDD; 20];
329
330        c.put_bounds(addr, sample_bounds(), 0);
331        let b = c.get_bounds(&addr, 30).unwrap();
332        assert_eq!(b.max_taker_leverage, 100.0);
333        assert_eq!(b.min_margin, 5.0);
334    }
335
336    #[test]
337    fn invalidate_fast_preserves_slow() {
338        let mut c = StateCache::new(StateCacheConfig::default());
339        let addr = [0xAA; 20];
340        let perp = [0xBB; 32];
341
342        c.put_fees(addr, sample_fees(), 0);
343        c.put_bounds(addr, sample_bounds(), 0);
344        c.put_mark_price(perp, 42000.0, 0);
345        c.put_funding_rate(perp, 0.0001, 0);
346        c.put_usdc_balance(1000.0, 0);
347
348        c.invalidate_fast_layer();
349
350        // Slow layer survives
351        assert!(c.get_fees(&addr, 0).is_some());
352        assert!(c.get_bounds(&addr, 0).is_some());
353
354        // Fast layer cleared
355        assert!(c.get_mark_price(&perp, 0).is_none());
356        assert!(c.get_funding_rate(&perp, 0).is_none());
357        assert!(c.get_usdc_balance(0).is_none());
358    }
359
360    #[test]
361    fn invalidate_all_clears_everything() {
362        let mut c = StateCache::new(StateCacheConfig::default());
363        let addr = [0xAA; 20];
364        let perp = [0xBB; 32];
365
366        c.put_fees(addr, sample_fees(), 0);
367        c.put_mark_price(perp, 42000.0, 0);
368
369        c.invalidate_all();
370
371        assert!(c.get_fees(&addr, 0).is_none());
372        assert!(c.get_mark_price(&perp, 0).is_none());
373    }
374
375    #[test]
376    fn overwrite_updates_value_and_ttl() {
377        let mut c = StateCache::new(StateCacheConfig::default());
378        let perp = [0xBB; 32];
379
380        c.put_mark_price(perp, 42000.0, 100);
381        c.put_mark_price(perp, 43000.0, 200);
382
383        // Old value gone (would have expired at 102), new value valid
384        assert_eq!(c.get_mark_price(&perp, 201), Some(43000.0));
385        assert!(c.get_mark_price(&perp, 202).is_none());
386    }
387
388    #[test]
389    fn custom_config_ttls() {
390        let config = StateCacheConfig {
391            slow_ttl: 10,
392            fast_ttl: 1,
393        };
394        let mut c = StateCache::new(config);
395        let addr = [0xAA; 20];
396        let perp = [0xBB; 32];
397
398        c.put_fees(addr, sample_fees(), 0);
399        c.put_mark_price(perp, 100.0, 0);
400
401        // Custom slow TTL: 10s
402        assert!(c.get_fees(&addr, 9).is_some());
403        assert!(c.get_fees(&addr, 10).is_none());
404
405        // Custom fast TTL: 1s
406        assert!(c.get_mark_price(&perp, 0).is_some());
407        assert!(c.get_mark_price(&perp, 1).is_none());
408    }
409
410    #[test]
411    fn different_keys_independent() {
412        let mut c = StateCache::new(StateCacheConfig::default());
413        let perp_a = [0xAA; 32];
414        let perp_b = [0xBB; 32];
415
416        c.put_mark_price(perp_a, 100.0, 0);
417        c.put_mark_price(perp_b, 200.0, 0);
418
419        assert_eq!(c.get_mark_price(&perp_a, 0), Some(100.0));
420        assert_eq!(c.get_mark_price(&perp_b, 0), Some(200.0));
421    }
422
423    #[test]
424    fn cached_value_is_valid_boundary() {
425        let cv = CachedValue {
426            value: 42,
427            expires_at: 100,
428        };
429        assert!(cv.is_valid(99)); // 1 second before
430        assert!(!cv.is_valid(100)); // exactly at expiry
431        assert!(!cv.is_valid(101)); // after expiry
432    }
433}