Multi-currency Phase 4 — rate fetcher with Kraken/Coinbase/CoinGecko fallback
src/rates.rs adds an in-memory rate cache (60s TTL) with a 3-source fallback chain. AppState gains `rates: Arc<RateCache>`. Manual pins via the settings table override the chain — used by tests for deterministic conversions and by operators during maintenance windows. Admin endpoints: - GET /v1/admin/rates: cache snapshot - POST /v1/admin/rates/refresh: force re-fetch (audit-logged) Two new tests (network-free, manual-pin path): - rate_cache_honors_manual_pin_from_settings - admin_rates_endpoint_reflects_manual_pin Test count: 36 (was 34).
This commit is contained in:
@@ -71,6 +71,7 @@ pub mod tier;
|
||||
pub mod validate;
|
||||
pub mod community;
|
||||
pub mod db_info;
|
||||
pub mod rates_admin;
|
||||
pub mod recover;
|
||||
pub mod webhook;
|
||||
pub mod webhook_deliveries;
|
||||
@@ -103,6 +104,10 @@ pub struct AppState {
|
||||
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
|
||||
/// operator activates a fresh license via the admin endpoint.
|
||||
pub self_tier: Arc<RwLock<crate::license_self::Tier>>,
|
||||
/// BTC/fiat rate cache for multi-currency products. See
|
||||
/// src/rates.rs. Process-global so cached rates aren't refetched
|
||||
/// per-request.
|
||||
pub rates: Arc<crate::rates::RateCache>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -325,6 +330,12 @@ pub fn router(state: AppState) -> Router {
|
||||
// Database health snapshot — operator-facing sanity check
|
||||
// against the catastrophic-loss risk; see db_info.rs.
|
||||
.route("/v1/admin/db-info", get(db_info::get))
|
||||
// BTC/fiat rate cache — operator-facing view of what the
|
||||
// daemon would quote for fiat-priced products. See
|
||||
// src/rates.rs for the source chain (Kraken → Coinbase
|
||||
// → CoinGecko) and TTL caching semantics.
|
||||
.route("/v1/admin/rates", get(rates_admin::get))
|
||||
.route("/v1/admin/rates/refresh", post(rates_admin::refresh))
|
||||
// Opt-in community analytics. Off by default; toggling on
|
||||
// requires the operator to confirm a collector URL.
|
||||
.route(
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Admin endpoints for the BTC/fiat rate cache.
|
||||
//!
|
||||
//! Two surfaces:
|
||||
//! GET /v1/admin/rates — what's cached right now
|
||||
//! (operators can see what
|
||||
//! the daemon would quote and
|
||||
//! which source it came from)
|
||||
//! POST /v1/admin/rates/refresh — force a fresh fetch for a
|
||||
//! given currency, bypassing
|
||||
//! the TTL cache. Useful
|
||||
//! after a rate-source
|
||||
//! outage to confirm the
|
||||
//! chain works end-to-end.
|
||||
|
||||
use crate::api::admin::{request_context, require_admin};
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::rates;
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
let snapshot = state.rates.snapshot().await;
|
||||
let rates_json: Vec<Value> = snapshot
|
||||
.into_iter()
|
||||
.map(|(currency, cached)| {
|
||||
json!({
|
||||
"currency": currency,
|
||||
"units_per_btc": cached.units_per_btc,
|
||||
"source": cached.source,
|
||||
"fetched_at_secs_ago": cached.fetched_at.elapsed()
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({ "rates": rates_json })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshReq {
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RefreshReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let actor_hash = require_admin(&state, &headers)?;
|
||||
let (ip, ua) = request_context(&headers);
|
||||
let currency = req.currency.to_uppercase();
|
||||
|
||||
// Wipe the cache entry so the next get_rate hits the chain.
|
||||
state.rates.invalidate(¤cy).await;
|
||||
|
||||
// Fetch fresh — bubbles up source errors with full context.
|
||||
let fresh = rates::get_rate(&state, ¤cy).await.map_err(|e| {
|
||||
AppError::Upstream(format!("rate refresh failed: {e:#}"))
|
||||
})?;
|
||||
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
Some(&actor_hash),
|
||||
"rate.refresh",
|
||||
Some("rate"),
|
||||
Some(¤cy),
|
||||
ip.as_deref(),
|
||||
ua.as_deref(),
|
||||
&json!({
|
||||
"currency": currency,
|
||||
"source": fresh.source,
|
||||
"units_per_btc": fresh.units_per_btc,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"currency": currency,
|
||||
"units_per_btc": fresh.units_per_btc,
|
||||
"source": fresh.source,
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user