diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 575e94a61..302bcce5c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -20,3 +20,4 @@ rand = "0.8" sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "macros", "migrate"] } rust_decimal = { version = "1.33", features = ["serde-float"] } dotenvy = "0.15" +jsonwebtoken = "9.0" diff --git a/backend/migrations/20260625000000_add_metrics_indexes.down.sql b/backend/migrations/20260625000000_add_metrics_indexes.down.sql new file mode 100644 index 000000000..27736de60 --- /dev/null +++ b/backend/migrations/20260625000000_add_metrics_indexes.down.sql @@ -0,0 +1,3 @@ +-- Remove indexes for admin metrics queries +DROP INDEX IF EXISTS plans_is_active_idx; +DROP INDEX IF EXISTS payouts_status_idx; diff --git a/backend/migrations/20260625000000_add_metrics_indexes.up.sql b/backend/migrations/20260625000000_add_metrics_indexes.up.sql new file mode 100644 index 000000000..1841502b8 --- /dev/null +++ b/backend/migrations/20260625000000_add_metrics_indexes.up.sql @@ -0,0 +1,3 @@ +-- Add indexes for optimized admin metrics queries +CREATE INDEX IF NOT EXISTS plans_is_active_idx ON plans (is_active); +CREATE INDEX IF NOT EXISTS payouts_status_idx ON payouts (status); diff --git a/backend/src/admin_auth.rs b/backend/src/admin_auth.rs new file mode 100644 index 000000000..55db89966 --- /dev/null +++ b/backend/src/admin_auth.rs @@ -0,0 +1,62 @@ +use axum::{ + extract::Request, + http::{header::AUTHORIZATION, StatusCode}, + middleware::Next, + response::Response, +}; +use jsonwebtoken::{decode, Validation, DecodingKey}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct AdminClaims { + pub sub: String, + pub role: String, + pub exp: usize, +} + +pub async fn admin_auth_middleware( + req: Request, + next: Next, +) -> Result { + let auth_header = req + .headers() + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let jwt_secret = std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "default-secret-change-in-production".to_string()); + + // Check for Bearer token + if auth_header.starts_with("Bearer ") { + let token = &auth_header[7..]; + + match decode::( + token, + &DecodingKey::from_secret(jwt_secret.as_ref()), + &Validation::default(), + ) { + Ok(token_data) => { + if token_data.claims.role != "admin" { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) + } + Err(_) => Err(StatusCode::UNAUTHORIZED), + } + } + // Check for API key as fallback + else if auth_header.starts_with("ApiKey ") { + let api_key = &auth_header[7..]; + let admin_api_key = std::env::var("ADMIN_API_KEY") + .unwrap_or_else(|_| "default-api-key-change-in-production".to_string()); + + if api_key == admin_api_key { + Ok(next.run(req).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } else { + Err(StatusCode::UNAUTHORIZED) + } +} diff --git a/backend/src/api.rs b/backend/src/api.rs index d92a5e0e5..326b7ad95 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -8,8 +8,10 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; +use axum::middleware; use crate::stellar_anchor::{AnchorPayout, AnchorRegistry}; +use crate::admin_auth::admin_auth_middleware; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlanBeneficiary { @@ -57,6 +59,15 @@ pub struct AnchorQuery { pub beneficiary_address: Option, } +#[derive(Debug, Serialize)] +pub struct AdminMetrics { + pub total_value_locked: i64, + pub active_users: i64, + pub active_plans: i64, + pub accumulated_yield_paid: i64, + pub generated_at: String, +} + pub fn create_router(state: Arc) -> Router { let cors = CorsLayer::new() .allow_origin(Any) @@ -68,6 +79,8 @@ pub fn create_router(state: Arc) -> Router { .route("/api/plans/ping", post(ping_plan)) .route("/api/plans/payout", post(trigger_payout)) .route("/api/anchor/payout-status", get(get_anchor_payouts)) + .route("/api/admin/metrics", get(get_admin_metrics)) + .route_layer(middleware::from_fn(admin_auth_middleware)) .layer(cors) .with_state(state) } @@ -122,3 +135,53 @@ async fn get_anchor_payouts( let empty_list: Vec = Vec::new(); (StatusCode::OK, Json(empty_list)) } + +// Handler: Get Admin Metrics +// Aggregates platform statistics including TVL, active users, active plans, and accumulated yield +async fn get_admin_metrics( + State(state): State>, +) -> impl IntoResponse { + // Execute all queries in parallel for better performance + let (tvl_result, active_users_result, active_plans_result, yield_result) = tokio::try_join!( + // TVL: Sum of all active plan amounts + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM plans WHERE is_active = TRUE" + ) + .fetch_one(&state.db_pool), + + // Active users: Count of unique owners with active plans + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(DISTINCT owner_address) FROM plans WHERE is_active = TRUE" + ) + .fetch_one(&state.db_pool), + + // Active plans: Count of active plans + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM plans WHERE is_active = TRUE" + ) + .fetch_one(&state.db_pool), + + // Accumulated yield: Sum of completed payouts + sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount), 0) FROM payouts WHERE status = 'completed'" + ) + .fetch_one(&state.db_pool) + ); + + match (tvl_result, active_users_result, active_plans_result, yield_result) { + (Ok(tvl), Ok(active_users), Ok(active_plans), Ok(yield)) => { + let metrics = AdminMetrics { + total_value_locked: tvl, + active_users, + active_plans, + accumulated_yield_paid: yield, + generated_at: chrono::Utc::now().to_rfc3339(), + }; + (StatusCode::OK, Json(metrics)) + } + Err(e) => { + tracing::error!("Failed to fetch admin metrics: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch metrics") + } + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 04c48d5d6..f0197559d 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,3 +1,4 @@ +pub mod admin_auth; pub mod api; pub mod config; pub mod db;