mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-18 16:01:33 +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:
@@ -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;
|
||||
|
||||
@@ -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)."),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user