From d8b6058d4007077d5df81369157c97aeda5da4bf Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 25 Jun 2026 16:41:40 -0400 Subject: [PATCH] feat: fiat price feed with per-feed operator control (GET /api/v1/prices) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cached crypto→fiat price feed, refreshed by a background task and served from an in-memory snapshot — no upstream call on the request path. Public and feature-flagged: when FEATURE_PRICES is off, /prices is 404 and no upstream is ever contacted. Per-feed control, as a public server needs: PRICES_ASSETS narrows which feeds an instance quotes (unset = all supported; an explicit list serves a subset; empty serves none), validated fail-closed against SUPPORTED_PRICE_ASSETS. Provider (PRICES_PROVIDER, coingecko) and currency (PRICES_CURRENCY, usd) are configurable; the refresh interval is clamped to a provider-friendly minimum. - infra/prices: PriceClient (feed whitelist → provider coin ids), bounded by the HTTP client timeout; non-finite/negative/missing quotes are skipped, not faked. A fetch failure keeps the last good snapshot. Drift guard: a test asserts the provider mapping covers exactly SUPPORTED_PRICE_ASSETS. - main: background refresh task only when enabled and at least one feed is set. - AppState.prices snapshot; handler 404s when disabled. - openapi (prices tag); unit tests + L1 test over both feature-flag states. --- openapi.json | 59 ++++++++++++ src/api/mod.rs | 1 + src/api/openapi.rs | 3 + src/api/prices.rs | 56 +++++++++++ src/config.rs | 42 +++++++++ src/infra/mod.rs | 1 + src/infra/prices.rs | 219 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 + src/main.rs | 36 +++++++- tests/common/mod.rs | 4 + tests/prices.rs | 39 ++++++++ 11 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 src/api/prices.rs create mode 100644 src/infra/prices.rs create mode 100644 tests/prices.rs diff --git a/openapi.json b/openapi.json index 07b4db6..a418322 100644 --- a/openapi.json +++ b/openapi.json @@ -429,6 +429,30 @@ } } }, + "/prices": { + "get": { + "tags": [ + "prices" + ], + "summary": "Current fiat prices for the enabled feeds.", + "operationId": "prices", + "responses": { + "200": { + "description": "Latest cached prices", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PricesResponse" + } + } + } + }, + "404": { + "description": "Price feed disabled on this server" + } + } + } + }, "/users/by-username/{username}": { "get": { "tags": [ @@ -2449,6 +2473,37 @@ } } }, + "PricesResponse": { + "type": "object", + "required": [ + "currency", + "prices" + ], + "properties": { + "currency": { + "type": "string", + "description": "Fiat currency the quotes are in (e.g. `\"usd\"`)." + }, + "prices": { + "type": "object", + "description": "Asset symbol → price in `currency`. Only enabled feeds appear.", + "additionalProperties": { + "type": "number", + "format": "double" + }, + "propertyNames": { + "type": "string" + } + }, + "updated_at": { + "type": [ + "string", + "null" + ], + "description": "RFC 3339 timestamp of the last successful refresh; `null` until the first." + } + } + }, "PublicKeysInfo": { "type": "object", "description": "A user's per-asset receiving public keys, by asset.", @@ -3189,6 +3244,10 @@ "name": "system", "description": "Service health and metadata." }, + { + "name": "prices", + "description": "Fiat price feed (cached; per-feed configurable; feature-flagged)." + }, { "name": "auth", "description": "Wallet authentication: registration, restore, refresh, sessions, Nostr identity." diff --git a/src/api/mod.rs b/src/api/mod.rs index 0011409..6a74688 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod health; pub mod middleware; pub mod nip05; pub mod openapi; +pub mod prices; pub mod users; pub mod wallet; pub mod website; diff --git a/src/api/openapi.rs b/src/api/openapi.rs index 9ec438a..288929e 100644 --- a/src/api/openapi.rs +++ b/src/api/openapi.rs @@ -17,6 +17,7 @@ use utoipa::OpenApi; paths( crate::api::health::health, crate::api::capabilities::capabilities, + crate::api::prices::prices, crate::api::auth::pow_challenge, crate::api::auth::extension_register, crate::api::auth::check_restore, @@ -64,6 +65,7 @@ use utoipa::OpenApi; crate::api::capabilities::ChainCapabilities, crate::api::capabilities::ChainCapability, crate::api::capabilities::FeatureCapabilities, + crate::api::prices::PricesResponse, crate::api::auth::AuthResponse, crate::api::auth::UserInfo, crate::api::auth::AssetPublicKey, @@ -133,6 +135,7 @@ use utoipa::OpenApi; )), tags( (name = "system", description = "Service health and metadata."), + (name = "prices", description = "Fiat price feed (cached; per-feed configurable; feature-flagged)."), (name = "auth", description = "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."), (name = "btc_ltc", description = "Bitcoin/Litecoin chain access via Electrum (balance, UTXOs, history, tip, fee, broadcast)."), (name = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)."), diff --git a/src/api/prices.rs b/src/api/prices.rs new file mode 100644 index 0000000..7f1dee6 --- /dev/null +++ b/src/api/prices.rs @@ -0,0 +1,56 @@ +//! Fiat price feed endpoint. +//! +//! Serves the in-memory snapshot maintained by the background refresh task (see +//! [`crate::infra::prices`]). Public and cached — no upstream call happens on the +//! request path. When the feed is disabled this route returns `404`, so a client +//! reading [`crate::api::capabilities`] never has to special-case it. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::{extract::State, routing::get, Json, Router}; +use serde::Serialize; +use tracing::instrument; + +use crate::error::AppError; +use crate::AppState; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PricesResponse { + /// Fiat currency the quotes are in (e.g. `"usd"`). + pub currency: String, + /// Asset symbol → price in `currency`. Only enabled feeds appear. + pub prices: HashMap, + /// RFC 3339 timestamp of the last successful refresh; `null` until the first. + pub updated_at: Option, +} + +/// Current fiat prices for the enabled feeds. +#[utoipa::path( + get, + path = "/prices", + responses( + (status = 200, description = "Latest cached prices", body = PricesResponse), + (status = 404, description = "Price feed disabled on this server") + ), + tag = "prices" +)] +#[instrument(skip(state))] +pub async fn prices(State(state): State>) -> Result, AppError> { + if !state.config.features.prices { + return Err(AppError::NotFound( + "price feed is not enabled on this server".into(), + )); + } + let snap = state.prices.read().await; + Ok(Json(PricesResponse { + currency: snap.currency.clone(), + prices: snap.prices.clone(), + updated_at: snap.updated_at.map(|t| t.to_rfc3339()), + })) +} + +/// Price route, RELATIVE to the `/api/v1` mount point. Public (no auth). +pub fn routes() -> Router> { + Router::new().route("/prices", get(prices)) +} diff --git a/src/config.rs b/src/config.rs index f909ccd..36879c8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -154,12 +154,25 @@ pub struct SecretConfig { pub ip_salt: String, } +/// Assets the price feed can quote. Source of truth for which `PRICES_ASSETS` +/// values are accepted; the provider mapping (symbol→coin id) lives in +/// `infra::prices` and a test there asserts it covers exactly this set. +pub const SUPPORTED_PRICE_ASSETS: &[&str] = &["btc", "ltc", "xmr", "wow", "grin"]; + #[derive(Clone)] pub struct FeatureFlags { pub chains: ChainFlags, + /// Master switch for the price feed. When off, `/prices` is `404` and no + /// upstream is ever contacted. pub prices: bool, pub prices_provider: String, pub prices_interval_secs: u64, + /// Per-feed control: exactly which assets this instance quotes. Unset = + /// all supported; an explicit (possibly empty) `PRICES_ASSETS` list narrows + /// it — so an operator can serve a subset or none at all. + pub prices_assets: Vec, + /// Fiat currency the feed quotes in (e.g. `"usd"`). + pub prices_currency: String, /// Parked feature; off by default. pub tips: bool, /// Nostr-native identity (NIP-98 login/link, NIP-05 directory). @@ -313,6 +326,20 @@ impl Config { prices: env_bool("FEATURE_PRICES", true), prices_provider: env_or("PRICES_PROVIDER", "coingecko"), prices_interval_secs: env_parse("PRICES_FETCH_INTERVAL_SECS", 300u64)?, + // Unset = all supported feeds; an explicit list (even empty) + // narrows it, so operators can serve a subset or none. + prices_assets: match env_opt("PRICES_ASSETS") { + Some(s) => s + .split(',') + .map(|x| x.trim().to_lowercase()) + .filter(|x| !x.is_empty()) + .collect(), + None => SUPPORTED_PRICE_ASSETS + .iter() + .map(|s| s.to_string()) + .collect(), + }, + prices_currency: env_or("PRICES_CURRENCY", "usd").to_lowercase(), tips: env_bool("FEATURE_TIPS", false), nostr_identity: env_bool("FEATURE_NOSTR_IDENTITY", true), grin_relay: env_bool("FEATURE_GRIN_RELAY", true), @@ -474,6 +501,19 @@ impl Config { ); } + // Price feed: every configured asset must be one we can quote. Fail + // closed on a typo rather than silently dropping a feed. + if self.features.prices { + for asset in &self.features.prices_assets { + if !SUPPORTED_PRICE_ASSETS.contains(&asset.as_str()) { + return Err(cfg_err(format!( + "PRICES_ASSETS contains unsupported asset {asset:?}; supported: {}", + SUPPORTED_PRICE_ASSETS.join(", ") + ))); + } + } + } + // Per-enabled-chain infra secrets: hard error in production, warn in dev. let mut chain_warnings: Vec<&str> = Vec::new(); if self.features.chains.xmr && self.chains.xmr.lws_admin_key.is_empty() { @@ -550,6 +590,8 @@ mod tests { prices: false, prices_provider: "coingecko".into(), prices_interval_secs: 300, + prices_assets: vec!["btc".into(), "xmr".into()], + prices_currency: "usd".into(), tips: false, nostr_identity: true, grin_relay: true, diff --git a/src/infra/mod.rs b/src/infra/mod.rs index f28fab5..04dff35 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -6,3 +6,4 @@ pub mod db; pub mod electrum; pub mod grin; pub mod lws; +pub mod prices; diff --git a/src/infra/prices.rs b/src/infra/prices.rs new file mode 100644 index 0000000..9dd0fbb --- /dev/null +++ b/src/infra/prices.rs @@ -0,0 +1,219 @@ +//! Fiat price feed. +//! +//! Fetches current crypto→fiat prices from a configurable provider (CoinGecko by +//! default), into an in-memory snapshot refreshed on a background interval. The +//! handler serves the cached snapshot; a fetch failure logs and keeps the last +//! good values rather than blanking them. Prices are display-only quotes — `f64` +//! is appropriate here (this is not on-chain money math). + +use std::collections::HashMap; +use std::time::Duration; + +use chrono::{DateTime, Utc}; + +use crate::error::AppError; + +/// Per-request timeout for the upstream price API. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +/// CoinGecko simple-price endpoint (fixed, trusted host). +const COINGECKO_URL: &str = "https://api.coingecko.com/api/v3/simple/price"; +/// Lower bound on the refresh interval, to stay within free-tier rate limits. +const MIN_INTERVAL_SECS: u64 = 60; + +/// Our asset symbols mapped to their CoinGecko coin ids. +const COIN_IDS: &[(&str, &str)] = &[ + ("btc", "bitcoin"), + ("ltc", "litecoin"), + ("xmr", "monero"), + ("wow", "wownero"), + ("grin", "grin"), +]; + +/// The latest fetched prices. `updated_at` is `None` until the first successful +/// refresh, so clients can tell "not yet available" from a real zero. +#[derive(Debug, Clone)] +pub struct PriceSnapshot { + pub currency: String, + pub prices: HashMap, + pub updated_at: Option>, +} + +impl PriceSnapshot { + /// Empty snapshot for the given fiat currency (no prices yet). + pub fn empty(currency: &str) -> Self { + Self { + currency: currency.to_string(), + prices: HashMap::new(), + updated_at: None, + } + } +} + +/// Fetches prices from the configured provider, for a fixed set of feeds. +pub struct PriceClient { + http: reqwest::Client, + provider: String, + currency: String, + /// (asset symbol, provider coin id) for exactly the enabled feeds. + feeds: Vec<(String, &'static str)>, +} + +impl PriceClient { + /// Build a client for `provider` (e.g. `"coingecko"`), quoting `assets` in + /// `currency` (e.g. `"usd"`). Assets not in [`COIN_IDS`] are dropped here + /// (config validation rejects them earlier). The provider is validated + /// lazily on first fetch. + pub fn new(provider: &str, currency: &str, assets: &[String]) -> Result { + let http = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|_| AppError::ConfigError("failed to build price HTTP client".into()))?; + let feeds = assets + .iter() + .filter_map(|a| { + COIN_IDS + .iter() + .find(|(sym, _)| *sym == a) + .map(|(sym, id)| (sym.to_string(), *id)) + }) + .collect(); + Ok(Self { + http, + provider: provider.to_string(), + currency: currency.to_string(), + feeds, + }) + } + + pub fn currency(&self) -> &str { + &self.currency + } + + /// Whether any feed is enabled (an empty whitelist quotes nothing). + pub fn is_empty(&self) -> bool { + self.feeds.is_empty() + } + + /// Fetch the current price for every enabled feed. Assets the provider omits + /// (or returns non-finite/negative) are skipped, not faked. + pub async fn fetch(&self) -> Result, AppError> { + if self.feeds.is_empty() { + return Ok(HashMap::new()); + } + match self.provider.as_str() { + "coingecko" => self.fetch_coingecko().await, + other => Err(AppError::ConfigError(format!( + "unsupported prices provider: {other}" + ))), + } + } + + async fn fetch_coingecko(&self) -> Result, AppError> { + let ids = self + .feeds + .iter() + .map(|(_, id)| *id) + .collect::>() + .join(","); + let resp = self + .http + .get(COINGECKO_URL) + .query(&[ + ("ids", ids.as_str()), + ("vs_currencies", self.currency.as_str()), + ]) + .send() + .await + .map_err(|_| AppError::NodeError("price fetch failed".into()))?; + if !resp.status().is_success() { + return Err(AppError::NodeError(format!( + "price provider returned HTTP {}", + resp.status().as_u16() + ))); + } + // Shape: { "bitcoin": { "usd": 12345.6 }, ... }. The host is fixed and + // the payload is a handful of numbers, so `json()` (timeout-bounded) is + // sufficient here without the streaming cap the chain clients need. + let raw: HashMap> = resp + .json() + .await + .map_err(|_| AppError::NodeError("invalid price response".into()))?; + + let mut out = HashMap::new(); + for (asset, id) in &self.feeds { + if let Some(&price) = raw.get(*id).and_then(|m| m.get(&self.currency)) { + if price.is_finite() && price >= 0.0 { + out.insert(asset.clone(), price); + } + } + } + Ok(out) + } +} + +/// Clamp the configured refresh interval to a provider-friendly minimum. +pub fn refresh_interval(configured_secs: u64) -> Duration { + Duration::from_secs(configured_secs.max(MIN_INTERVAL_SECS)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_assets() -> Vec { + crate::config::SUPPORTED_PRICE_ASSETS + .iter() + .map(|s| s.to_string()) + .collect() + } + + #[test] + fn empty_snapshot_has_no_timestamp() { + let snap = PriceSnapshot::empty("usd"); + assert_eq!(snap.currency, "usd"); + assert!(snap.prices.is_empty()); + assert!(snap.updated_at.is_none()); + } + + #[test] + fn refresh_interval_enforces_minimum() { + assert_eq!(refresh_interval(5), Duration::from_secs(MIN_INTERVAL_SECS)); + assert_eq!(refresh_interval(300), Duration::from_secs(300)); + } + + #[test] + fn coin_ids_cover_exactly_the_supported_assets() { + // Guards against config/provider drift: every configurable asset must + // have a provider mapping, and vice versa. + let mapped: std::collections::HashSet<&str> = + COIN_IDS.iter().map(|(sym, _)| *sym).collect(); + let supported: std::collections::HashSet<&str> = crate::config::SUPPORTED_PRICE_ASSETS + .iter() + .copied() + .collect(); + assert_eq!(mapped, supported); + } + + #[test] + fn unknown_and_empty_whitelists_yield_no_feeds() { + let client = PriceClient::new("coingecko", "usd", &["doge".to_string()]).unwrap(); + assert!(client.is_empty()); + let client = PriceClient::new("coingecko", "usd", &[]).unwrap(); + assert!(client.is_empty()); + } + + #[tokio::test] + async fn empty_whitelist_fetches_nothing_without_contacting_provider() { + // No feeds => Ok(empty), even though "nonesuch" is an invalid provider: + // the short-circuit happens before provider dispatch. + let client = PriceClient::new("nonesuch", "usd", &[]).unwrap(); + assert!(client.fetch().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn unsupported_provider_is_rejected_when_feeds_exist() { + let client = PriceClient::new("nonesuch", "usd", &all_assets()).unwrap(); + let err = client.fetch().await.unwrap_err(); + assert!(matches!(err, AppError::ConfigError(_))); + } +} diff --git a/src/lib.rs b/src/lib.rs index e5bb349..83bd486 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,7 @@ use crate::config::Config; use crate::core::session::{SessionManager, WebChallenge}; use crate::infra::chains::ChainClients; use crate::infra::db::Database; +use crate::infra::prices::PriceSnapshot; /// Global request-body cap (backstop; handlers validate tighter per field). const MAX_BODY_BYTES: usize = 1024 * 1024; @@ -60,6 +61,9 @@ pub struct AppState { /// In-memory website-auth challenges, keyed by nonce. Single-node store; /// a shared/stateless variant is the load-balanced-fleet path. pub web_challenges: Arc>>, + /// Latest fetched fiat prices, refreshed by a background task when the price + /// feed is enabled. Stays empty (and `/prices` 404s) otherwise. + pub prices: Arc>, } /// Assemble the full application router with every route mounted and state @@ -96,6 +100,7 @@ pub fn build_router(state: Arc) -> Router { api::users::routes() .merge(api::wallet::routes()) .merge(api::capabilities::routes()) + .merge(api::prices::routes()) .layer(normal), ); diff --git a/src/main.rs b/src/main.rs index 9d0088e..96319f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use sqlx::postgres::PgPoolOptions; use smirk_backend_core::{ build_router, config::Config, core::session::SessionManager, infra::chains::ChainClients, - infra::db::Database, AppState, + infra::db::Database, infra::prices, infra::prices::PriceSnapshot, AppState, }; #[tokio::main] @@ -37,12 +37,16 @@ async fn main() -> Result<(), Box> { let chains = ChainClients::from_config(&config)?; let addr = format!("{}:{}", config.server_host, config.server_port); + let prices_cache = Arc::new(tokio::sync::RwLock::new(PriceSnapshot::empty( + &config.features.prices_currency, + ))); let state = Arc::new(AppState { config, db, sessions, chains, web_challenges: Arc::default(), + prices: prices_cache, }); // Periodic GC of expired website-auth challenges. Bounds the single-node @@ -76,6 +80,36 @@ async fn main() -> Result<(), Box> { }); } + // Background price refresh (only when the feed is enabled). On each tick we + // fetch the configured feeds and replace the snapshot; a failure logs and + // keeps the last good values rather than blanking them. The first interval + // tick fires immediately, so prices populate at startup. + if state.config.features.prices { + let f = &state.config.features; + match prices::PriceClient::new(&f.prices_provider, &f.prices_currency, &f.prices_assets) { + Ok(client) if !client.is_empty() => { + let cache = state.prices.clone(); + let period = prices::refresh_interval(f.prices_interval_secs); + tokio::spawn(async move { + let mut tick = tokio::time::interval(period); + loop { + tick.tick().await; + match client.fetch().await { + Ok(prices) => { + let mut snap = cache.write().await; + snap.prices = prices; + snap.updated_at = Some(chrono::Utc::now()); + } + Err(e) => tracing::warn!(error = %e, "price refresh failed"), + } + } + }); + } + Ok(_) => tracing::info!("price feed enabled but no assets configured; serving none"), + Err(e) => tracing::warn!(error = %e, "price client init failed; feed disabled"), + } + } + let app = build_router(state); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0ccc38f..d044147 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -70,12 +70,16 @@ pub async fn try_app() -> Option { ); let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let chains = ChainClients::from_config(&config).expect("build chain clients"); + let prices = Arc::new(tokio::sync::RwLock::new( + smirk_backend_core::infra::prices::PriceSnapshot::empty(&config.features.prices_currency), + )); let state = Arc::new(AppState { config, db, sessions, chains, web_challenges: Arc::default(), + prices, }); Some(TestApp { diff --git a/tests/prices.rs b/tests/prices.rs new file mode 100644 index 0000000..6037465 --- /dev/null +++ b/tests/prices.rs @@ -0,0 +1,39 @@ +//! L1 integration: the public prices endpoint, both feature-flag states. +//! +//! Drives `FEATURE_PRICES` explicitly and rebuilds the app for each state, so +//! both paths are deterministic without any network call. (The background +//! refresh task lives in `main`, not `build_router`, so under the harness the +//! enabled feed simply serves an empty, not-yet-populated snapshot.) The live +//! upstream fetch is covered by `infra::prices` unit tests and is an L3 concern. + +mod common; + +use axum::http::StatusCode; + +#[tokio::test] +async fn prices_endpoint_respects_the_feature_flag() { + // Disabled → 404, so a client reading /capabilities never special-cases it. + std::env::set_var("FEATURE_PRICES", "false"); + let Some(app) = common::try_app().await else { + return; + }; + let (status, body) = app.request("GET", "/api/v1/prices", None, None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "NOT_FOUND"); + + // Enabled → 200 with the documented shape. No background task in the harness, + // so prices are empty and updated_at is null until the first refresh. + std::env::set_var("FEATURE_PRICES", "true"); + std::env::set_var("PRICES_ASSETS", "btc,xmr"); + let app = common::try_app() + .await + .expect("app builds with prices enabled"); + let (status, body) = app.request("GET", "/api/v1/prices", None, None).await; + assert_eq!(status, StatusCode::OK); + assert!(body["currency"].as_str().is_some(), "currency present"); + assert!(body["prices"].is_object(), "prices is a map"); + assert!(body["updated_at"].is_null(), "not yet refreshed"); + + std::env::remove_var("FEATURE_PRICES"); + std::env::remove_var("PRICES_ASSETS"); +}