mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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."
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)."),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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?;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user