diff --git a/backend/src/main.rs b/backend/src/main.rs index 9047f14..ba6fb04 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -6,11 +6,14 @@ use axum::{ middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, - Router, + Json, Router, }; +use chrono::Utc; +use serde::Serialize; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Instant; use tokio::sync::Mutex; use tower_http::{classify::ServerErrorsFailureClass, trace::TraceLayer}; use tracing::Span; @@ -23,7 +26,61 @@ use zaps_backend::db; use zaps_backend::indexer; use zaps_backend::services; -// Rate limiter state: token bucket per client (IP address) +// ── Health check types ──────────────────────────────────────────────────────── + +#[derive(Clone)] +struct HealthState { + pool: sqlx::PgPool, + stellar_rpc_url: String, +} + +#[derive(Serialize)] +struct DbHealth { + status: &'static str, + latency_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct YieldDbHealth { + status: &'static str, + latency_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + active_yield_accounts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + yield_rate_bps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct RpcHealth { + status: &'static str, + latency_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + latest_ledger: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct HealthComponents { + database: DbHealth, + yield_db: YieldDbHealth, + soroban_rpc: RpcHealth, +} + +#[derive(Serialize)] +struct HealthResponse { + /// "ok" when all components are healthy; "degraded" otherwise. + status: &'static str, + components: HealthComponents, + checked_at: String, +} + +// ── Rate limiter state: token bucket per client (IP address) ────────────────── + #[derive(Clone)] struct RateLimiter { buckets: Arc>>, @@ -121,8 +178,16 @@ async fn main() { let bridge_state = api::bridge::BridgeState::new(pool.clone(), config.allbridge_api_url.clone()); + // Health check state: pool + Soroban RPC URL for live component probing. + let health_state = HealthState { + pool: pool.clone(), + stellar_rpc_url: config.stellar_rpc_url.clone(), + }; + // Setup routes - let public_routes = Router::new().route("/health", get(health_check)); + let public_routes = Router::new() + .route("/health", get(health_check)) + .with_state(health_state); let sensitive_routes = Router::new() .nest("/api/auth", api::auth_routes(pool.clone())) @@ -217,6 +282,153 @@ async fn main() { axum::serve(listener, app).await.unwrap(); } -async fn health_check() -> &'static str { - "OK" +// ── /health handler ─────────────────────────────────────────────────────────── + +async fn health_check(State(state): State) -> impl IntoResponse { + // Run all three probes concurrently so latencies don't stack. + let (db, yield_db, rpc) = tokio::join!( + probe_database(&state.pool), + probe_yield_db(&state.pool), + probe_soroban_rpc(&state.stellar_rpc_url), + ); + + let all_ok = db.status == "ok" && yield_db.status == "ok" && rpc.status == "ok"; + + let body = HealthResponse { + status: if all_ok { "ok" } else { "degraded" }, + components: HealthComponents { + database: db, + yield_db, + soroban_rpc: rpc, + }, + checked_at: Utc::now().to_rfc3339(), + }; + + let code = if all_ok { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + (code, Json(body)) +} + +// ── Component probes ────────────────────────────────────────────────────────── + +/// Basic Postgres connectivity: a single round-trip to the DB pool. +async fn probe_database(pool: &sqlx::PgPool) -> DbHealth { + let start = Instant::now(); + match sqlx::query_scalar::<_, i64>("SELECT 1") + .fetch_one(pool) + .await + { + Ok(_) => DbHealth { + status: "ok", + latency_ms: start.elapsed().as_millis() as u64, + error: None, + }, + Err(e) => DbHealth { + status: "error", + latency_ms: start.elapsed().as_millis() as u64, + error: Some(e.to_string()), + }, + } +} + +/// Yield-specific DB probe: verifies `user_yield_balances` and +/// `yield_rates_history` are reachable and returns live metrics. +async fn probe_yield_db(pool: &sqlx::PgPool) -> YieldDbHealth { + let start = Instant::now(); + + let result: Result<(i64, Option), sqlx::Error> = async { + let active_yield_accounts: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM user_yield_balances") + .fetch_one(pool) + .await?; + + let yield_rate_bps: Option = sqlx::query_scalar( + "SELECT apy FROM yield_rates_history ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(pool) + .await?; + + Ok((active_yield_accounts, yield_rate_bps)) + } + .await; + + let latency_ms = start.elapsed().as_millis() as u64; + + match result { + Ok((count, rate)) => YieldDbHealth { + status: "ok", + latency_ms, + active_yield_accounts: Some(count), + yield_rate_bps: rate, + error: None, + }, + Err(e) => YieldDbHealth { + status: "error", + latency_ms, + active_yield_accounts: None, + yield_rate_bps: None, + error: Some(e.to_string()), + }, + } +} + +/// Soroban RPC probe: issues a real `getLatestLedger` JSON-RPC call. +/// Fails if the node is unreachable, returns a non-2xx status, or the +/// response shape is unexpected. +async fn probe_soroban_rpc(rpc_url: &str) -> RpcHealth { + let start = Instant::now(); + + let result: Result = async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| e.to_string())?; + + let payload = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestLedger", + "params": {} + }); + + let res = client + .post(rpc_url) + .json(&payload) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + return Err(format!("RPC returned HTTP {}", res.status())); + } + + let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + + body["result"]["sequence"] + .as_u64() + .map(|n| n as u32) + .ok_or_else(|| "unexpected RPC response shape".to_string()) + } + .await; + + let latency_ms = start.elapsed().as_millis() as u64; + + match result { + Ok(ledger) => RpcHealth { + status: "ok", + latency_ms, + latest_ledger: Some(ledger), + error: None, + }, + Err(e) => RpcHealth { + status: "error", + latency_ms, + latest_ledger: None, + error: Some(e), + }, + } } diff --git a/dashboard/app/dashboard/page.tsx b/dashboard/app/dashboard/page.tsx index 1d16670..0e59d6b 100644 --- a/dashboard/app/dashboard/page.tsx +++ b/dashboard/app/dashboard/page.tsx @@ -4,16 +4,37 @@ import StatCard from "@/components/StatCard"; import { api } from "@/lib/api"; import { usePolling } from "@/lib/use-polling"; +function fmtUsdc(value: number): string { + return ( + value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + " USDC" + ); +} + export default function OverviewPage() { - const { data, loading, error } = usePolling(() => api.socialFeed(), 15000); + const { data: feedData, loading: feedLoading, error: feedError } = usePolling( + () => api.socialFeed(), + 15000, + ); - const likes = data?.reduce((total, feed) => total + feed.likes_count, 0) ?? 0; - const comments = - data?.reduce((total, feed) => total + feed.comments_count, 0) ?? 0; - const activeFeeds = data?.length ?? 0; + const { data: yieldData, loading: yieldLoading, error: yieldError } = usePolling( + () => api.yieldStats(), + 30000, + ); + + const likes = feedData?.reduce((total, feed) => total + feed.likes_count, 0) ?? 0; + const comments = feedData?.reduce((total, feed) => total + feed.comments_count, 0) ?? 0; + const activeFeeds = feedData?.length ?? 0; + + const tvl = yieldData?.total_value_locked ?? 0; + const yieldDistributed = yieldData?.total_yield_distributed ?? 0; + const apy = yieldData?.apy ?? 0; return (
+ {/* Social Overview */}

Social Overview

@@ -21,13 +42,13 @@ export default function OverviewPage() {

- {error && ( + {feedError && (
- {error} — showing the most recently loaded values + {feedError} — showing the most recently loaded values
)} - {loading && !data ? ( + {feedLoading && !feedData ? (
{Array.from({ length: 3 }).map((_, index) => (
)} + {/* Yield Metrics */} +
+

Yield Vault

+

+ Aggregate metrics from the on-chain yield vault. +

+
+ + {yieldError && ( +
+ {yieldError} — showing the most recently loaded values +
+ )} + + {yieldLoading && !yieldData ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ) : ( +
+ + + +
+ )} +

- Auto-refreshes every 15 seconds + Social stats refresh every 15 s · Vault stats refresh every 30 s

); diff --git a/dashboard/lib/api.ts b/dashboard/lib/api.ts index 1b410cc..5d4bddb 100644 --- a/dashboard/lib/api.ts +++ b/dashboard/lib/api.ts @@ -102,6 +102,9 @@ export const api = { body: JSON.stringify({ fee_coefficient }), }, ), + + // Yield vault aggregate metrics + yieldStats: () => req("/admin/vault/stats"), }; async function serverReq(path: string, init?: RequestInit): Promise { @@ -253,3 +256,9 @@ export interface ContractAlert { export interface ContractConfig { fee_coefficient: number; } + +export interface YieldStats { + total_value_locked: number; + total_yield_distributed: number; + apy: number; +} diff --git a/docker-compose.yml b/docker-compose.yml index 0aa668f..a887877 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,15 @@ version: '3.8' +# Shared environment block for application services. +# Both backend and yield-scheduler inherit these; override per-service as needed. +x-app-env: &app-env + DATABASE_URL: postgres://postgres:password@db:5432/zaps_dev + REDIS_URL: redis://redis:6379 + STELLAR_RPC_URL: http://soroban-rpc:8000 + ALLBRIDGE_API_URL: https://core-api.allbridge.io + JWT_SECRET: dev-jwt-secret-change-in-production + RUST_LOG: info + services: # --- PostgreSQL Database --- db: @@ -54,6 +64,47 @@ services: timeout: 5s retries: 6 + # --- Rust API Backend --- + backend: + image: ghcr.io/fracverse/zaps-backend:latest + container_name: zaps-backend + restart: unless-stopped + environment: + <<: *app-env + ports: + - "8080:8080" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + soroban-rpc: + condition: service_healthy + + # --- Yield Scheduler (background sweep + yield calculation cron) --- + # Runs the same binary as `backend` in scheduler-only mode. + # ROLE=scheduler is the hook for the binary to suppress the HTTP listener + # and run only the tokio-spawned cron workers (sweep_worker, notifications). + # Set SWEEP_POLL_INTERVAL_SECS / YIELD_REPORT_*_INTERVAL_SECS to tune cadence. + yield-scheduler: + image: ghcr.io/fracverse/zaps-backend:latest + container_name: zaps-yield-scheduler + restart: unless-stopped + environment: + <<: *app-env + ROLE: scheduler + SWEEP_POLL_INTERVAL_SECS: "300" + SWEEP_MIN_IDLE_AMOUNT: "100000" + YIELD_REPORT_DAILY_INTERVAL_SECS: "86400" + YIELD_REPORT_WEEKLY_INTERVAL_SECS: "604800" + YIELD_REPORT_THRESHOLD: "1000" + # EXPO_ACCESS_TOKEN: "" # set via .env or secret manager in production + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + volumes: postgres_data: driver: local