Every proxied request that costs money is logged in the spend database (spend.db):
CREATE TABLE spend_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, service TEXT NOT NULL, -- e.g., "openai", "binance" date TEXT NOT NULL, -- YYYY-MM-DD cost_usd REAL NOT NULL, -- Dollar amount cost_micros INTEGER NOT NULL, -- USD * 1,000,000 (exact math) request_count INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL -- Unix timestamp);
Costs are stored in both floating-point (cost_usd) and integer micros (cost_micros = usd * 1,000,000) to avoid floating-point precision errors in budget checks.
#[tokio::test]async fn test_binance_daily_volume_cap_enforced_under_concurrency() { // ... setup ... let req1 = Request::builder() .uri("/binance/api/v3/order") .body(Body::from("symbol=BTCUSDT"eOrderQty=10")); let req2 = Request::builder() .uri("/binance/api/v3/order") .body(Body::from("symbol=BTCUSDT"eOrderQty=10")); let (resp1, resp2) = tokio::join!(app.clone().oneshot(req1), app.clone().oneshot(req2)); // One succeeds, one is denied let ok_count = statuses.iter().filter(|s| **s == StatusCode::OK).count(); let forbidden_count = statuses.iter().filter(|s| **s == StatusCode::FORBIDDEN).count(); assert_eq!(ok_count, 1); assert_eq!(forbidden_count, 1); // Exactly $10 spent (one request) let spent_today = state.spend_store.get_spent_today("binance").await.unwrap(); assert!((spent_today - 10.0).abs() < 1e-9);}
Fishnet uses a global spend lock to prevent concurrent requests from both succeeding when only one fits within the budget. This ensures exactly-once spend recording.
For Binance, cost = order value in USD:From proxy.rs (Binance handler):
let order_value_usd = if let Some(qty) = params.get("quoteOrderQty") { qty.parse::<f64>().unwrap_or(0.0)} else if let Some(qty) = params.get("quantity") { // Estimate from base quantity × price qty.parse::<f64>().unwrap_or(0.0) * estimated_price} else { 0.0};
pub async fn query_spend(&self, days: u32) -> Result<Vec<SpendRecord>, SpendError> { let cutoff = chrono::Utc::now() .date_naive() .checked_sub_days(chrono::Days::new(days as u64)) .map(|d| d.format("%Y-%m-%d").to_string()) .unwrap_or_default(); let mut stmt = conn.prepare( "SELECT service, date, SUM(cost_usd) as total_cost, SUM(request_count) as total_requests FROM spend_records WHERE date >= ?1 GROUP BY service, date ORDER BY date DESC, service ASC" )?; // ...}