1use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Mutex;
9
10#[derive(Debug, Clone)]
14pub struct CuCostTable {
15 costs: HashMap<String, u32>,
16 default_cost: u32,
17}
18
19impl CuCostTable {
20 pub fn new(default_cost: u32) -> Self {
22 Self {
23 costs: HashMap::new(),
24 default_cost,
25 }
26 }
27
28 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 pub fn set_cost(&mut self, method: &str, cost: u32) {
60 self.costs.insert(method.to_string(), cost);
61 }
62
63 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#[derive(Debug, Clone)]
77pub struct CuBudgetConfig {
78 pub monthly_budget: u64,
80 pub alert_threshold: f64,
82 pub throttle_near_limit: bool,
84}
85
86impl Default for CuBudgetConfig {
87 fn default() -> Self {
88 Self {
89 monthly_budget: 0, alert_threshold: 0.8,
91 throttle_near_limit: false,
92 }
93 }
94}
95
96pub struct CuTracker {
98 url: String,
100 cost_table: CuCostTable,
102 config: CuBudgetConfig,
104 consumed: AtomicU64,
106 per_method: Mutex<HashMap<String, u64>>,
108}
109
110impl CuTracker {
111 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 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 pub fn cost_for(&self, method: &str) -> u32 {
132 self.cost_table.cost_for(method)
133 }
134
135 pub fn consumed(&self) -> u64 {
137 self.consumed.load(Ordering::Relaxed)
138 }
139
140 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 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 pub fn is_alert(&self) -> bool {
160 self.config.monthly_budget > 0 && self.usage_fraction() >= self.config.alert_threshold
161 }
162
163 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 pub fn should_throttle(&self) -> bool {
171 self.config.throttle_near_limit && self.is_alert()
172 }
173
174 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 pub fn per_method_usage(&self) -> HashMap<String, u64> {
183 let pm = self.per_method.lock().unwrap();
184 pm.clone()
185 }
186
187 pub fn url(&self) -> &str {
189 &self.url
190 }
191
192 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#[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); }
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"); tracker.record("eth_call"); tracker.record("eth_getLogs"); 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 for _ in 0..75 {
261 tracker.record("eth_blockNumber"); }
263 assert_eq!(tracker.consumed(), 750);
264 assert!(!tracker.is_alert());
265 assert!(!tracker.should_throttle());
266 assert_eq!(tracker.remaining(), 250);
267
268 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"); }
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(), );
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}