Skip to main content

chainrpc_core/
cu_tracker.rs

1//! Compute unit (CU) budget tracking per provider.
2//!
3//! Tracks CU consumption and alerts when approaching budget limits.
4//! Integrates with the rate limiter to throttle when near budget cap.
5
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Mutex;
9
10/// Per-method compute unit costs.
11///
12/// Default costs follow Alchemy's CU pricing model.
13#[derive(Debug, Clone)]
14pub struct CuCostTable {
15    costs: HashMap<String, u32>,
16    default_cost: u32,
17}
18
19impl CuCostTable {
20    /// Create a new cost table with a default cost for unknown methods.
21    pub fn new(default_cost: u32) -> Self {
22        Self {
23            costs: HashMap::new(),
24            default_cost,
25        }
26    }
27
28    /// Create the standard Alchemy-style cost table.
29    pub fn alchemy_defaults() -> Self {
30        let mut table = Self::new(50);
31        let defaults = [
32            ("eth_blockNumber", 10),
33            ("eth_getBalance", 19),
34            ("eth_getTransactionCount", 26),
35            ("eth_call", 26),
36            ("eth_estimateGas", 87),
37            ("eth_sendRawTransaction", 250),
38            ("eth_getTransactionReceipt", 15),
39            ("eth_getBlockByNumber", 16),
40            ("eth_getLogs", 75),
41            ("eth_subscribe", 10),
42            ("eth_getCode", 19),
43            ("eth_getStorageAt", 17),
44            ("eth_gasPrice", 19),
45            ("eth_feeHistory", 10),
46            ("eth_maxPriorityFeePerGas", 10),
47            ("eth_getTransactionByHash", 17),
48            ("debug_traceTransaction", 309),
49            ("trace_block", 500),
50            ("trace_transaction", 309),
51        ];
52        for (method, cost) in defaults {
53            table.costs.insert(method.to_string(), cost);
54        }
55        table
56    }
57
58    /// Set the cost for a specific method.
59    pub fn set_cost(&mut self, method: &str, cost: u32) {
60        self.costs.insert(method.to_string(), cost);
61    }
62
63    /// Get the CU cost for a method.
64    pub fn cost_for(&self, method: &str) -> u32 {
65        self.costs.get(method).copied().unwrap_or(self.default_cost)
66    }
67}
68
69impl Default for CuCostTable {
70    fn default() -> Self {
71        Self::alchemy_defaults()
72    }
73}
74
75/// Budget configuration for a provider.
76#[derive(Debug, Clone)]
77pub struct CuBudgetConfig {
78    /// Monthly CU budget (0 = unlimited).
79    pub monthly_budget: u64,
80    /// Alert threshold as a fraction (0.0-1.0). Default: 0.8 (80%).
81    pub alert_threshold: f64,
82    /// Whether to throttle when approaching the limit.
83    pub throttle_near_limit: bool,
84}
85
86impl Default for CuBudgetConfig {
87    fn default() -> Self {
88        Self {
89            monthly_budget: 0, // unlimited
90            alert_threshold: 0.8,
91            throttle_near_limit: false,
92        }
93    }
94}
95
96/// Per-provider CU consumption tracker.
97pub struct CuTracker {
98    /// Provider identifier.
99    url: String,
100    /// Cost lookup table.
101    cost_table: CuCostTable,
102    /// Budget configuration.
103    config: CuBudgetConfig,
104    /// Total CU consumed in current period.
105    consumed: AtomicU64,
106    /// Per-method CU consumption (for debugging/reporting).
107    per_method: Mutex<HashMap<String, u64>>,
108}
109
110impl CuTracker {
111    /// Create a new tracker for the given provider.
112    pub fn new(url: impl Into<String>, cost_table: CuCostTable, config: CuBudgetConfig) -> Self {
113        Self {
114            url: url.into(),
115            cost_table,
116            config,
117            consumed: AtomicU64::new(0),
118            per_method: Mutex::new(HashMap::new()),
119        }
120    }
121
122    /// Record CU consumption for a method call.
123    pub fn record(&self, method: &str) {
124        let cost = self.cost_table.cost_for(method) as u64;
125        self.consumed.fetch_add(cost, Ordering::Relaxed);
126        let mut pm = self.per_method.lock().unwrap();
127        *pm.entry(method.to_string()).or_insert(0) += cost;
128    }
129
130    /// Get the CU cost that would be charged for this method.
131    pub fn cost_for(&self, method: &str) -> u32 {
132        self.cost_table.cost_for(method)
133    }
134
135    /// Total CU consumed in the current period.
136    pub fn consumed(&self) -> u64 {
137        self.consumed.load(Ordering::Relaxed)
138    }
139
140    /// Remaining CU budget. Returns `u64::MAX` if unlimited.
141    pub fn remaining(&self) -> u64 {
142        if self.config.monthly_budget == 0 {
143            return u64::MAX;
144        }
145        self.config
146            .monthly_budget
147            .saturating_sub(self.consumed.load(Ordering::Relaxed))
148    }
149
150    /// Usage fraction (0.0-1.0). Returns 0.0 if unlimited.
151    pub fn usage_fraction(&self) -> f64 {
152        if self.config.monthly_budget == 0 {
153            return 0.0;
154        }
155        self.consumed.load(Ordering::Relaxed) as f64 / self.config.monthly_budget as f64
156    }
157
158    /// Whether the budget alert threshold has been exceeded.
159    pub fn is_alert(&self) -> bool {
160        self.config.monthly_budget > 0 && self.usage_fraction() >= self.config.alert_threshold
161    }
162
163    /// Whether the budget is exhausted.
164    pub fn is_exhausted(&self) -> bool {
165        self.config.monthly_budget > 0
166            && self.consumed.load(Ordering::Relaxed) >= self.config.monthly_budget
167    }
168
169    /// Whether we should throttle (near limit + throttling enabled).
170    pub fn should_throttle(&self) -> bool {
171        self.config.throttle_near_limit && self.is_alert()
172    }
173
174    /// Reset the consumed counter (e.g. at start of new billing period).
175    pub fn reset(&self) {
176        self.consumed.store(0, Ordering::Relaxed);
177        let mut pm = self.per_method.lock().unwrap();
178        pm.clear();
179    }
180
181    /// Get per-method breakdown of CU consumption.
182    pub fn per_method_usage(&self) -> HashMap<String, u64> {
183        let pm = self.per_method.lock().unwrap();
184        pm.clone()
185    }
186
187    /// Provider URL.
188    pub fn url(&self) -> &str {
189        &self.url
190    }
191
192    /// Produce a snapshot for reporting.
193    pub fn snapshot(&self) -> CuSnapshot {
194        CuSnapshot {
195            url: self.url.clone(),
196            consumed: self.consumed.load(Ordering::Relaxed),
197            budget: self.config.monthly_budget,
198            usage_fraction: self.usage_fraction(),
199            alert: self.is_alert(),
200            exhausted: self.is_exhausted(),
201            per_method: self.per_method_usage(),
202        }
203    }
204}
205
206/// Immutable snapshot of CU tracking state.
207#[derive(Debug, Clone, serde::Serialize)]
208pub struct CuSnapshot {
209    pub url: String,
210    pub consumed: u64,
211    pub budget: u64,
212    pub usage_fraction: f64,
213    pub alert: bool,
214    pub exhausted: bool,
215    pub per_method: HashMap<String, u64>,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn cost_table_defaults() {
224        let table = CuCostTable::alchemy_defaults();
225        assert_eq!(table.cost_for("eth_blockNumber"), 10);
226        assert_eq!(table.cost_for("eth_call"), 26);
227        assert_eq!(table.cost_for("eth_sendRawTransaction"), 250);
228        assert_eq!(table.cost_for("debug_traceTransaction"), 309);
229        assert_eq!(table.cost_for("unknown_method"), 50); // default
230    }
231
232    #[test]
233    fn tracker_records_consumption() {
234        let tracker = CuTracker::new(
235            "https://rpc.example.com",
236            CuCostTable::alchemy_defaults(),
237            CuBudgetConfig::default(),
238        );
239
240        tracker.record("eth_blockNumber"); // 10
241        tracker.record("eth_call"); // 26
242        tracker.record("eth_getLogs"); // 75
243
244        assert_eq!(tracker.consumed(), 10 + 26 + 75);
245    }
246
247    #[test]
248    fn budget_tracking() {
249        let tracker = CuTracker::new(
250            "https://rpc.example.com",
251            CuCostTable::alchemy_defaults(),
252            CuBudgetConfig {
253                monthly_budget: 1000,
254                alert_threshold: 0.8,
255                throttle_near_limit: true,
256            },
257        );
258
259        // Consume 750 CU (75%)
260        for _ in 0..75 {
261            tracker.record("eth_blockNumber"); // 10 each
262        }
263        assert_eq!(tracker.consumed(), 750);
264        assert!(!tracker.is_alert());
265        assert!(!tracker.should_throttle());
266        assert_eq!(tracker.remaining(), 250);
267
268        // Consume 100 more (85% > 80% threshold)
269        for _ in 0..10 {
270            tracker.record("eth_blockNumber");
271        }
272        assert!(tracker.is_alert());
273        assert!(tracker.should_throttle());
274        assert!(!tracker.is_exhausted());
275    }
276
277    #[test]
278    fn budget_exhaustion() {
279        let tracker = CuTracker::new(
280            "https://rpc.example.com",
281            CuCostTable::alchemy_defaults(),
282            CuBudgetConfig {
283                monthly_budget: 100,
284                ..Default::default()
285            },
286        );
287
288        for _ in 0..10 {
289            tracker.record("eth_blockNumber"); // 10 each = 100 total
290        }
291        assert!(tracker.is_exhausted());
292        assert_eq!(tracker.remaining(), 0);
293    }
294
295    #[test]
296    fn unlimited_budget() {
297        let tracker = CuTracker::new(
298            "https://rpc.example.com",
299            CuCostTable::alchemy_defaults(),
300            CuBudgetConfig::default(), // monthly_budget = 0
301        );
302
303        for _ in 0..1000 {
304            tracker.record("eth_call");
305        }
306        assert_eq!(tracker.remaining(), u64::MAX);
307        assert!(!tracker.is_alert());
308        assert!(!tracker.is_exhausted());
309    }
310
311    #[test]
312    fn per_method_breakdown() {
313        let tracker = CuTracker::new(
314            "https://rpc.example.com",
315            CuCostTable::alchemy_defaults(),
316            CuBudgetConfig::default(),
317        );
318
319        tracker.record("eth_blockNumber");
320        tracker.record("eth_blockNumber");
321        tracker.record("eth_call");
322
323        let breakdown = tracker.per_method_usage();
324        assert_eq!(*breakdown.get("eth_blockNumber").unwrap(), 20);
325        assert_eq!(*breakdown.get("eth_call").unwrap(), 26);
326    }
327
328    #[test]
329    fn reset_clears_counters() {
330        let tracker = CuTracker::new(
331            "https://rpc.example.com",
332            CuCostTable::alchemy_defaults(),
333            CuBudgetConfig::default(),
334        );
335
336        tracker.record("eth_blockNumber");
337        assert!(tracker.consumed() > 0);
338
339        tracker.reset();
340        assert_eq!(tracker.consumed(), 0);
341        assert!(tracker.per_method_usage().is_empty());
342    }
343
344    #[test]
345    fn snapshot_serializable() {
346        let tracker = CuTracker::new(
347            "https://rpc.example.com",
348            CuCostTable::alchemy_defaults(),
349            CuBudgetConfig {
350                monthly_budget: 1000,
351                ..Default::default()
352            },
353        );
354        tracker.record("eth_call");
355
356        let snap = tracker.snapshot();
357        let json = serde_json::to_string(&snap).unwrap();
358        assert!(json.contains("\"consumed\":26"));
359        assert!(json.contains("\"budget\":1000"));
360    }
361}