From 8323074be96acabd870756a322464f28d9c21329 Mon Sep 17 00:00:00 2001 From: ndii-dev Date: Fri, 21 Aug 2026 19:33:19 +0100 Subject: [PATCH] Add /api/v1/stats/summary endpoint (issue #1) New handler in src/api/analytics_dashboard.rs mirroring its existing cached/utoipa-annotated style: DAA, 24h tx count, 24h USD payment volume (aggregated by asset and converted via the existing price feed, not pulled row-by-row), and active Soroban contract count, each with a vs-yesterday delta (folds in #3). Cached via cached_query with the existing CACHE_DASHBOARD_STATS_TTL (folds in #4) -- errors are never cached since cached_query only writes on success. Registered in v1's cached_routes and documented in src/openapi.rs under a new 'Overview' tag (folds in #6). --- src/api/analytics_dashboard.rs | 214 +++++++++++++++++++++++++++++++++ src/api/v1/mod.rs | 4 + src/cache.rs | 9 ++ src/openapi.rs | 5 + 4 files changed, 232 insertions(+) diff --git a/src/api/analytics_dashboard.rs b/src/api/analytics_dashboard.rs index 5e760ec..6a7950b 100644 --- a/src/api/analytics_dashboard.rs +++ b/src/api/analytics_dashboard.rs @@ -1,9 +1,15 @@ use axum::{extract::State, routing::get, Json, Router}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::sync::Arc; +use utoipa::ToSchema; use crate::cache::helpers::cached_query; use crate::cache::{keys, CacheManager}; +use crate::database::Database; +use crate::error::{ApiError, ApiResult}; +use crate::rpc::StellarRpcClient; +use crate::services::price_feed::PriceFeedClient; #[derive(Serialize, Deserialize, Clone)] pub struct NetworkVolumeDataPoint { @@ -287,3 +293,211 @@ pub fn routes(cache: Arc) -> Router { .route("/dashboard", get(analytics_dashboard)) .with_state(cache) } + +// --------------------------------------------------------------------------- +// /api/v1/stats/summary (issue #1) +// --------------------------------------------------------------------------- +// +// Lighter-weight sibling of `/analytics/dashboard` for the homepage stat-tile +// row: daily active accounts, 24h transaction count, 24h payment volume, and +// active Soroban contract count. This is the single most-hit endpoint in the +// pivot (every homepage load calls it), so it's cached (issue #4, folded in +// here) from day one rather than retrofitted later. +// +// **Day-boundary convention:** "24h" here means the *current UTC calendar +// day* (`date('now')` in SQLite), matching #2's DAA job and #3's snapshot +// delta convention -- not a rolling 24h window from request time. Picking one +// and being consistent is what #1's issue text calls out explicitly. + +/// A stat value plus its "vs yesterday" delta (issue #3, folded in here so +/// the summary endpoint ships complete). +#[derive(Debug, Serialize, Deserialize, Clone, Copy, ToSchema)] +pub struct StatWithDelta { + pub value: f64, + /// Percent change vs. the same stat yesterday. `None` when there's no + /// prior-day row to diff against (day one in production) or yesterday's + /// value was exactly zero -- never a fabricated 0%, `inf`, or `NaN`. + pub delta_percent: Option, +} + +impl StatWithDelta { + fn compute(today: f64, yesterday: Option) -> Self { + let delta_percent = match yesterday { + Some(y) if y != 0.0 => Some(((today - y) / y) * 100.0), + _ => None, + }; + Self { + value: today, + delta_percent, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct StatsSummary { + pub daily_active_accounts: StatWithDelta, + pub tx_count_24h: StatWithDelta, + pub payment_volume_24h_usd: StatWithDelta, + pub active_soroban_contracts: StatWithDelta, + /// UTC calendar day these figures were computed for (RFC3339 date). + pub as_of: String, +} + +#[derive(sqlx::FromRow, Default)] +struct DailyFigures { + daa: i64, + tx_count: i64, + active_contracts: i64, +} + +#[derive(sqlx::FromRow)] +struct AssetVolumeRow { + asset_type: String, + asset_code: Option, + asset_issuer: Option, + total_amount: f64, +} + +fn asset_identifier(asset_type: &str, code: Option<&str>, issuer: Option<&str>) -> String { + if asset_type == "native" { + "XLM:native".to_string() + } else { + format!( + "{}:{}", + code.unwrap_or("UNKNOWN"), + issuer.unwrap_or("unknown") + ) + } +} + +/// Sums a UTC calendar day's payment volume converted to USD via the price +/// feed. Aggregates by asset first (`GROUP BY`) rather than pulling every +/// individual payment row, so this stays cheap regardless of how many +/// payments land in a busy day -- cost scales with distinct assets, not +/// transaction count. +async fn payment_volume_usd_for( + db: &Database, + price_feed: &PriceFeedClient, + date_expr: &str, +) -> anyhow::Result { + let sql = format!( + "SELECT asset_type, asset_code, asset_issuer, SUM(amount) as total_amount + FROM payments + WHERE date(created_at) = date('now', '{date_expr}') + GROUP BY asset_type, asset_code, asset_issuer" + ); + let rows = sqlx::query_as::<_, AssetVolumeRow>(&sql) + .fetch_all(db.pool()) + .await?; + + let asset_ids: Vec = { + let mut seen = HashSet::new(); + rows.iter() + .map(|r| asset_identifier(&r.asset_type, r.asset_code.as_deref(), r.asset_issuer.as_deref())) + .filter(|id| seen.insert(id.clone())) + .collect() + }; + let prices = price_feed.get_prices(&asset_ids).await; + + let total = rows + .iter() + .filter_map(|r| { + let id = asset_identifier(&r.asset_type, r.asset_code.as_deref(), r.asset_issuer.as_deref()); + prices.get(&id).map(|price| r.total_amount * price) + }) + .sum(); + Ok(total) +} + +async fn daily_figures_for(db: &Database, date_expr: &str) -> anyhow::Result { + let sql = format!( + "SELECT + (SELECT COUNT(DISTINCT source_account) FROM payments WHERE date(created_at) = date('now', '{date_expr}')) as daa, + (SELECT COUNT(*) FROM payments WHERE date(created_at) = date('now', '{date_expr}')) as tx_count, + (SELECT COUNT(DISTINCT contract_id) FROM contract_events WHERE date(created_at) = date('now', '{date_expr}')) as active_contracts" + ); + let figures = sqlx::query_as::<_, DailyFigures>(&sql) + .fetch_optional(db.pool()) + .await? + .unwrap_or_default(); + Ok(figures) +} + +async fn compute_stats_summary( + db: &Database, + price_feed: &PriceFeedClient, +) -> anyhow::Result { + let today = daily_figures_for(db, "+0 days").await?; + let yesterday = daily_figures_for(db, "-1 days").await?; + let volume_today = payment_volume_usd_for(db, price_feed, "+0 days").await?; + let volume_yesterday = payment_volume_usd_for(db, price_feed, "-1 days").await?; + + // `yesterday`'s query always returns a (possibly all-zero) row rather + // than NULL, so an explicit "was there really a prior day" check isn't + // available here the way #3's dedicated snapshot table has one; a + // same-day network with genuinely zero activity yesterday and a cold + // start both read as "yesterday == 0", which is exactly the case + // `StatWithDelta::compute` already treats as "no meaningful delta". + let yesterday_daa = if yesterday.daa == 0 && today.daa == 0 { + None + } else { + Some(yesterday.daa as f64) + }; + let yesterday_tx = if yesterday.tx_count == 0 && today.tx_count == 0 { + None + } else { + Some(yesterday.tx_count as f64) + }; + let yesterday_contracts = if yesterday.active_contracts == 0 && today.active_contracts == 0 { + None + } else { + Some(yesterday.active_contracts as f64) + }; + + Ok(StatsSummary { + daily_active_accounts: StatWithDelta::compute(today.daa as f64, yesterday_daa), + tx_count_24h: StatWithDelta::compute(today.tx_count as f64, yesterday_tx), + payment_volume_24h_usd: StatWithDelta::compute(volume_today, Some(volume_yesterday)), + active_soroban_contracts: StatWithDelta::compute( + today.active_contracts as f64, + yesterday_contracts, + ), + as_of: chrono::Utc::now().format("%Y-%m-%d").to_string(), + }) +} + +/// Get homepage stat-tile summary: DAA, 24h tx count, 24h payment volume, +/// active Soroban contracts, each with a vs-yesterday delta. +/// +/// Cached with the dashboard TTL (`CACHE_DASHBOARD_STATS_TTL`, default 60s) +/// -- see issue #4. A transient DB/RPC failure is never cached: `cached_query` +/// only writes to the cache after `query_fn` succeeds, so an error always +/// propagates as a fresh 500 rather than being served stale for the full TTL. +#[utoipa::path( + get, + path = "/api/v1/stats/summary", + responses( + (status = 200, description = "Homepage stat-tile summary", body = StatsSummary), + (status = 500, description = "Internal server error") + ), + tag = "Overview" +)] +pub async fn get_stats_summary( + State((db, cache, _rpc_client, price_feed)): State<( + Arc, + Arc, + Arc, + Arc, + )>, +) -> ApiResult> { + let key = keys::stats_summary(); + let ttl = cache.config.get_ttl("dashboard"); + + let summary = cached_query(&cache, &key, ttl, || async { + compute_stats_summary(&db, &price_feed).await + }) + .await + .map_err(|e| ApiError::internal("stats_summary_failed", format!("{e}")))?; + + Ok(Json(summary)) +} diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index eacbc09..a3677c8 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -90,6 +90,10 @@ pub fn routes( "/corridors/:corridor_key", get(corridors::get_corridor_detail), ) + .route( + "/stats/summary", + get(crate::api::analytics_dashboard::get_stats_summary), + ) .with_state(cached_state); // Captured before `app_state` is moved into `protected_routes` below. diff --git a/src/cache.rs b/src/cache.rs index 093a29b..26870f3 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -475,6 +475,15 @@ pub mod keys { "analytics:dashboard".to_string() } + /// Cache key for `/api/v1/stats/summary` (issue #1/#4). No request + /// parameters vary this endpoint's response today, so a single static + /// key is correct -- if a per-network or per-user dimension is ever + /// added, this key must be extended to include it. + #[must_use] + pub fn stats_summary() -> String { + "stats:summary".to_string() + } + /// Pattern for invalidating all anchor-related caches #[must_use] pub fn anchor_pattern() -> String { diff --git a/src/openapi.rs b/src/openapi.rs index 71771d6..956b3d1 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -165,6 +165,8 @@ use utoipa::OpenApi; // Wallets crate::api::wallets::get_activity_calendar, crate::api::wallets::get_largest_transfers, + // Overview + crate::api::analytics_dashboard::get_stats_summary, ), components( schemas( @@ -190,9 +192,12 @@ use utoipa::OpenApi; crate::api::wallets::TransferDirection, crate::api::wallets::LargestTransfer, crate::api::wallets::LargestTransfersResponse, + crate::api::analytics_dashboard::StatWithDelta, + crate::api::analytics_dashboard::StatsSummary, ) ), tags( + (name = "Overview", description = "Homepage summary/stat-tile endpoints"), (name = "Alerts", description = "Alert management and notification endpoints"), (name = "Analytics", description = "API analytics endpoints"), (name = "Anchors", description = "Anchor management and metrics endpoints"),