Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions backend/migrations/20260625000000_add_metrics_indexes.up.sql
Original file line number Diff line number Diff line change
@@ -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);
62 changes: 62 additions & 0 deletions backend/src/admin_auth.rs
Original file line number Diff line number Diff line change
@@ -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<Response, StatusCode> {
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::<AdminClaims>(
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)
}
}
63 changes: 63 additions & 0 deletions backend/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -57,6 +59,15 @@ pub struct AnchorQuery {
pub beneficiary_address: Option<String>,
}

#[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<AppState>) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
Expand All @@ -68,6 +79,8 @@ pub fn create_router(state: Arc<AppState>) -> 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)
}
Expand Down Expand Up @@ -122,3 +135,53 @@ async fn get_anchor_payouts(
let empty_list: Vec<AnchorPayout> = 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<Arc<AppState>>,
) -> 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")
}
}
}
1 change: 1 addition & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod admin_auth;
pub mod api;
pub mod config;
pub mod db;
Expand Down
Loading