feat: fiat price feed with per-feed operator control (GET /api/v1/prices)

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.
This commit is contained in:
jwinterm
2026-06-25 16:41:40 -04:00
parent 1e2bdf3f16
commit d8b6058d40
11 changed files with 464 additions and 1 deletions
+59
View File
@@ -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}": { "/users/by-username/{username}": {
"get": { "get": {
"tags": [ "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": { "PublicKeysInfo": {
"type": "object", "type": "object",
"description": "A user's per-asset receiving public keys, by asset.", "description": "A user's per-asset receiving public keys, by asset.",
@@ -3189,6 +3244,10 @@
"name": "system", "name": "system",
"description": "Service health and metadata." "description": "Service health and metadata."
}, },
{
"name": "prices",
"description": "Fiat price feed (cached; per-feed configurable; feature-flagged)."
},
{ {
"name": "auth", "name": "auth",
"description": "Wallet authentication: registration, restore, refresh, sessions, Nostr identity." "description": "Wallet authentication: registration, restore, refresh, sessions, Nostr identity."
+1
View File
@@ -6,6 +6,7 @@ pub mod health;
pub mod middleware; pub mod middleware;
pub mod nip05; pub mod nip05;
pub mod openapi; pub mod openapi;
pub mod prices;
pub mod users; pub mod users;
pub mod wallet; pub mod wallet;
pub mod website; pub mod website;
+3
View File
@@ -17,6 +17,7 @@ use utoipa::OpenApi;
paths( paths(
crate::api::health::health, crate::api::health::health,
crate::api::capabilities::capabilities, crate::api::capabilities::capabilities,
crate::api::prices::prices,
crate::api::auth::pow_challenge, crate::api::auth::pow_challenge,
crate::api::auth::extension_register, crate::api::auth::extension_register,
crate::api::auth::check_restore, crate::api::auth::check_restore,
@@ -64,6 +65,7 @@ use utoipa::OpenApi;
crate::api::capabilities::ChainCapabilities, crate::api::capabilities::ChainCapabilities,
crate::api::capabilities::ChainCapability, crate::api::capabilities::ChainCapability,
crate::api::capabilities::FeatureCapabilities, crate::api::capabilities::FeatureCapabilities,
crate::api::prices::PricesResponse,
crate::api::auth::AuthResponse, crate::api::auth::AuthResponse,
crate::api::auth::UserInfo, crate::api::auth::UserInfo,
crate::api::auth::AssetPublicKey, crate::api::auth::AssetPublicKey,
@@ -133,6 +135,7 @@ use utoipa::OpenApi;
)), )),
tags( tags(
(name = "system", description = "Service health and metadata."), (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 = "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 = "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)."), (name = "xmr_wow", description = "Monero/Wownero chain access via LWS (register, balance, history, unspent/random outs, submit, height, confirmations)."),
+56
View File
@@ -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<String, f64>,
/// RFC 3339 timestamp of the last successful refresh; `null` until the first.
pub updated_at: Option<String>,
}
/// 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<Arc<AppState>>) -> Result<Json<PricesResponse>, 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<Arc<AppState>> {
Router::new().route("/prices", get(prices))
}
+42
View File
@@ -154,12 +154,25 @@ pub struct SecretConfig {
pub ip_salt: String, 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)] #[derive(Clone)]
pub struct FeatureFlags { pub struct FeatureFlags {
pub chains: ChainFlags, 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: bool,
pub prices_provider: String, pub prices_provider: String,
pub prices_interval_secs: u64, 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<String>,
/// Fiat currency the feed quotes in (e.g. `"usd"`).
pub prices_currency: String,
/// Parked feature; off by default. /// Parked feature; off by default.
pub tips: bool, pub tips: bool,
/// Nostr-native identity (NIP-98 login/link, NIP-05 directory). /// Nostr-native identity (NIP-98 login/link, NIP-05 directory).
@@ -313,6 +326,20 @@ impl Config {
prices: env_bool("FEATURE_PRICES", true), prices: env_bool("FEATURE_PRICES", true),
prices_provider: env_or("PRICES_PROVIDER", "coingecko"), prices_provider: env_or("PRICES_PROVIDER", "coingecko"),
prices_interval_secs: env_parse("PRICES_FETCH_INTERVAL_SECS", 300u64)?, 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), tips: env_bool("FEATURE_TIPS", false),
nostr_identity: env_bool("FEATURE_NOSTR_IDENTITY", true), nostr_identity: env_bool("FEATURE_NOSTR_IDENTITY", true),
grin_relay: env_bool("FEATURE_GRIN_RELAY", 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. // Per-enabled-chain infra secrets: hard error in production, warn in dev.
let mut chain_warnings: Vec<&str> = Vec::new(); let mut chain_warnings: Vec<&str> = Vec::new();
if self.features.chains.xmr && self.chains.xmr.lws_admin_key.is_empty() { if self.features.chains.xmr && self.chains.xmr.lws_admin_key.is_empty() {
@@ -550,6 +590,8 @@ mod tests {
prices: false, prices: false,
prices_provider: "coingecko".into(), prices_provider: "coingecko".into(),
prices_interval_secs: 300, prices_interval_secs: 300,
prices_assets: vec!["btc".into(), "xmr".into()],
prices_currency: "usd".into(),
tips: false, tips: false,
nostr_identity: true, nostr_identity: true,
grin_relay: true, grin_relay: true,
+1
View File
@@ -6,3 +6,4 @@ pub mod db;
pub mod electrum; pub mod electrum;
pub mod grin; pub mod grin;
pub mod lws; pub mod lws;
pub mod prices;
+219
View File
@@ -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<String, f64>,
pub updated_at: Option<DateTime<Utc>>,
}
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<Self, AppError> {
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<HashMap<String, f64>, 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<HashMap<String, f64>, AppError> {
let ids = self
.feeds
.iter()
.map(|(_, id)| *id)
.collect::<Vec<_>>()
.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<String, HashMap<String, f64>> = 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<String> {
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(_)));
}
}
+5
View File
@@ -33,6 +33,7 @@ use crate::config::Config;
use crate::core::session::{SessionManager, WebChallenge}; use crate::core::session::{SessionManager, WebChallenge};
use crate::infra::chains::ChainClients; use crate::infra::chains::ChainClients;
use crate::infra::db::Database; use crate::infra::db::Database;
use crate::infra::prices::PriceSnapshot;
/// Global request-body cap (backstop; handlers validate tighter per field). /// Global request-body cap (backstop; handlers validate tighter per field).
const MAX_BODY_BYTES: usize = 1024 * 1024; 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; /// In-memory website-auth challenges, keyed by nonce. Single-node store;
/// a shared/stateless variant is the load-balanced-fleet path. /// a shared/stateless variant is the load-balanced-fleet path.
pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>, pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>,
/// Latest fetched fiat prices, refreshed by a background task when the price
/// feed is enabled. Stays empty (and `/prices` 404s) otherwise.
pub prices: Arc<RwLock<PriceSnapshot>>,
} }
/// Assemble the full application router with every route mounted and state /// Assemble the full application router with every route mounted and state
@@ -96,6 +100,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
api::users::routes() api::users::routes()
.merge(api::wallet::routes()) .merge(api::wallet::routes())
.merge(api::capabilities::routes()) .merge(api::capabilities::routes())
.merge(api::prices::routes())
.layer(normal), .layer(normal),
); );
+35 -1
View File
@@ -10,7 +10,7 @@ use sqlx::postgres::PgPoolOptions;
use smirk_backend_core::{ use smirk_backend_core::{
build_router, config::Config, core::session::SessionManager, infra::chains::ChainClients, 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] #[tokio::main]
@@ -37,12 +37,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let chains = ChainClients::from_config(&config)?; let chains = ChainClients::from_config(&config)?;
let addr = format!("{}:{}", config.server_host, config.server_port); 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 { let state = Arc::new(AppState {
config, config,
db, db,
sessions, sessions,
chains, chains,
web_challenges: Arc::default(), web_challenges: Arc::default(),
prices: prices_cache,
}); });
// Periodic GC of expired website-auth challenges. Bounds the single-node // Periodic GC of expired website-auth challenges. Bounds the single-node
@@ -76,6 +80,36 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
// 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 app = build_router(state);
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
+4
View File
@@ -70,12 +70,16 @@ pub async fn try_app() -> Option<TestApp> {
); );
let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
let chains = ChainClients::from_config(&config).expect("build chain clients"); 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 { let state = Arc::new(AppState {
config, config,
db, db,
sessions, sessions,
chains, chains,
web_challenges: Arc::default(), web_challenges: Arc::default(),
prices,
}); });
Some(TestApp { Some(TestApp {
+39
View File
@@ -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");
}