diff --git a/backend/migrations/20260824000000_add_plan_loan_lifecycle.down.sql b/backend/migrations/20260824000000_add_plan_loan_lifecycle.down.sql new file mode 100644 index 000000000..6045c2653 --- /dev/null +++ b/backend/migrations/20260824000000_add_plan_loan_lifecycle.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS plan_loan_lifecycle; diff --git a/backend/migrations/20260824000000_add_plan_loan_lifecycle.up.sql b/backend/migrations/20260824000000_add_plan_loan_lifecycle.up.sql new file mode 100644 index 000000000..c341fa687 --- /dev/null +++ b/backend/migrations/20260824000000_add_plan_loan_lifecycle.up.sql @@ -0,0 +1,17 @@ +-- Issue #1035: persist freeze / recall / liquidation progress for +-- yield-bearing vaults so the loan-lifecycle endpoints and trigger-info +-- dashboard have a source of truth besides the chain. + +CREATE TABLE IF NOT EXISTS plan_loan_lifecycle ( + plan_id UUID PRIMARY KEY REFERENCES plans (id) ON DELETE CASCADE, + freeze_status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (freeze_status IN ('PENDING', 'PROCESSING', 'FROZEN')), + recall_progress INTEGER NOT NULL DEFAULT 0 + CHECK (recall_progress >= 0 AND recall_progress <= 100), + settlement_status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (settlement_status IN ('PENDING', 'PROCESSING', 'LIQUIDATED', 'SETTLED')), + outstanding_loaned BIGINT NOT NULL DEFAULT 0 + CHECK (outstanding_loaned >= 0), + last_tx_hash TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/api.rs b/backend/src/api.rs index b0d7f0142..0c702daaa 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -22,7 +22,9 @@ use tower_http::cors::CorsLayer; use tracing::{error, warn}; use uuid::Uuid; -use crate::auth::{jwt_auth_middleware, signature_auth_middleware, Claims}; +use crate::auth::{ + jwt_auth_middleware, jwt_or_signature_auth_middleware, signature_auth_middleware, Claims, +}; use crate::cache::PlanCache; use crate::kyc_webhook::kyc_webhook_handler; #[cfg(feature = "metrics")] @@ -67,6 +69,7 @@ pub struct AppState { pub plan_cache: PlanCache, pub apy_cache: dashmap::DashMap, pub kyc_tx: tokio::sync::broadcast::Sender, + pub status_tx: tokio::sync::broadcast::Sender, pub stellar_submit: StellarSubmitClient, } @@ -263,6 +266,8 @@ pub fn create_router(state: Arc) -> Router { .allow_headers([ axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION, + HeaderName::from_static("x-public-key"), + HeaderName::from_static("x-signature"), ]) .max_age(std::time::Duration::from_secs(3600)); @@ -285,12 +290,32 @@ pub fn create_router(state: Arc) -> Router { .route("/api/plans/{id}/report", get(get_plan_report)) .route_layer(from_fn(jwt_auth_middleware)); + // Loan lifecycle: admin JWT or wallet signature. + let loan_lifecycle_routes = Router::new() + .route( + "/api/plans/{id}/freeze-loans", + post(crate::loan_lifecycle::freeze_loans), + ) + .route( + "/api/plans/{id}/recall-loans", + post(crate::loan_lifecycle::recall_loans), + ) + .route( + "/api/plans/{id}/liquidate-settle", + post(crate::loan_lifecycle::liquidate_and_settle), + ) + .route_layer(from_fn(jwt_or_signature_auth_middleware)); + // Public or admin routes let public_routes = Router::new() .route("/api/plans", get(get_plans)) .route("/api/plans/due-for-claim", get(get_plans_due_for_claim)) .route("/api/plans/due-for-claim/{id}", get(get_plan_due_for_claim)) .route("/api/plans/{id}", get(get_plan_by_id)) + .route( + "/api/plans/{id}/trigger-info", + get(crate::loan_lifecycle::get_trigger_info), + ) .route("/api/anchor/payout-status", get(get_anchor_payouts)) .route("/api/lending/current-rate", get(get_current_lending_rate)) .route("/api/kyc/webhook", post(kyc_webhook_handler)) @@ -307,6 +332,7 @@ pub fn create_router(state: Arc) -> Router { let router = Router::new() .merge(user_routes) .merge(admin_routes) + .merge(loan_lifecycle_routes) .merge(public_routes) .layer(axum::middleware::from_fn(move |req, next| { rate_limit_middleware(req, next, store.clone(), config.clone()) diff --git a/backend/src/auth.rs b/backend/src/auth.rs index beb7eeddb..d5d5f0764 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -106,6 +106,27 @@ pub async fn jwt_auth_middleware( Ok(next.run(req).await) } +/// Accepts either an admin JWT (`Authorization: Bearer`) or an ed25519 +/// request signature (`X-Public-Key` + `X-Signature`). JWT takes precedence +/// when both are present so browser clients that attach a token keep working +/// even if signature headers are incomplete (empty POST bodies are not signed). +pub async fn jwt_or_signature_auth_middleware( + req: Request, + next: Next, +) -> Result { + let has_bearer = req + .headers() + .get("Authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("Bearer ")); + + if has_bearer { + jwt_auth_middleware(req, next).await + } else { + signature_auth_middleware(req, next).await + } +} + pub async fn signature_auth_middleware( req: Request, next: Next, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 167328400..6bcc184d3 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -6,6 +6,7 @@ pub mod db; pub mod inactivity_watchdog; pub mod kyc_webhook; +pub mod loan_lifecycle; #[cfg(feature = "metrics")] pub mod metrics; pub mod middleware; diff --git a/backend/src/loan_lifecycle.rs b/backend/src/loan_lifecycle.rs new file mode 100644 index 000000000..6c37dedb8 --- /dev/null +++ b/backend/src/loan_lifecycle.rs @@ -0,0 +1,742 @@ +#![allow(clippy::result_large_err)] + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::sync::Arc; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::api::AppState; +use crate::stellar_submit::{event_u64_field, find_event, InvocationOutcome, StellarSubmitError}; +use crate::ws::PlanStatusEvent; + +const FREEZE_EVENT_TOPICS: [&str; 2] = ["LOAN", "FREEZE"]; +const RECALL_EVENT_TOPICS: [&str; 2] = ["LOAN", "RECALL"]; +const LIQUIDATE_EVENT_TOPICS: [&str; 2] = ["LOAN", "LIQUIDAT"]; + +const ALLOWED_STATUSES: [&str; 2] = ["TRIGGERED", "CLAIMABLE"]; + +#[derive(Debug, Default, Deserialize)] +pub struct LoanLifecycleRequest { + pub recall_amount: Option, +} + +#[derive(Debug, Clone, FromRow)] +struct PlanRef { + id: Uuid, + owner_address: String, + status: String, + is_active: bool, + onchain_plan_id: Option, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +struct LoanLifecycleRow { + plan_id: Uuid, + freeze_status: String, + recall_progress: i32, + settlement_status: String, + outstanding_loaned: i64, + last_tx_hash: Option, + updated_at: DateTime, +} + +#[derive(Debug, Serialize)] +struct OutstandingLoan { + pool: String, + amount: String, + status: String, +} + +pub async fn freeze_loans( + State(state): State>, + Path(plan_id): Path, + Json(_payload): Json, +) -> Response { + let plan = match load_plan(&state, plan_id).await { + Ok(plan) => plan, + Err(response) => return response, + }; + if let Err(response) = require_triggered(&plan) { + return response; + } + + let mut tx_hash = None; + let mut remaining = None; + + if state.stellar_submit.soroban_enabled() { + let onchain_id = match require_onchain_id(&plan) { + Ok(id) => id, + Err(response) => return response, + }; + match state.stellar_submit.freeze_loans(onchain_id).await { + Ok(outcome) => { + if let Err(response) = verify_loan_event( + &state, + &outcome, + onchain_id, + &FREEZE_EVENT_TOPICS, + "LOAN/FREEZE", + ) { + return response; + } + tx_hash = Some(outcome.tx_hash); + } + Err(error) => return stellar_error_response(&error, "freeze loans"), + } + remaining = outstanding_after(&state, onchain_id).await; + } + + let remaining_loaned = remaining.unwrap_or(0); + if let Err(response) = upsert_lifecycle( + &state, + plan.id, + Some("FROZEN"), + None, + None, + remaining_loaned, + tx_hash.as_deref(), + ) + .await + { + return response; + } + + invalidate_cache(&state, &plan).await; + emit_status( + &state, + PlanStatusEvent { + event_type: "plan.loans_frozen".into(), + plan_id: plan.id, + status: "FROZEN".into(), + message: "Loans frozen successfully".into(), + tx_hash: tx_hash.clone(), + freeze_status: Some("FROZEN".into()), + recall_progress: None, + settlement_status: None, + remaining_loaned: Some(remaining_loaned), + }, + "plan.loans_frozen", + &plan, + tx_hash.as_deref(), + ) + .await; + + ok_response( + "Loans frozen successfully", + serde_json::json!({ + "plan_id": plan.id, + "tx_hash": tx_hash, + "on_chain": state.stellar_submit.soroban_enabled(), + "freeze_status": "FROZEN", + "remaining_loaned": remaining_loaned, + }), + ) +} + +pub async fn recall_loans( + State(state): State>, + Path(plan_id): Path, + Json(payload): Json, +) -> Response { + let plan = match load_plan(&state, plan_id).await { + Ok(plan) => plan, + Err(response) => return response, + }; + if let Err(response) = require_triggered(&plan) { + return response; + } + + let mut tx_hash = None; + let mut remaining_loaned = 0u64; + let mut recalled_amount = 0u64; + + if state.stellar_submit.soroban_enabled() { + let onchain_id = match require_onchain_id(&plan) { + Ok(id) => id, + Err(response) => return response, + }; + + let outstanding = + match resolve_recall_amount(&state, onchain_id, payload.recall_amount).await { + Ok(amount) => amount, + Err(response) => return response, + }; + + if outstanding == 0 { + remaining_loaned = 0; + } else { + match state + .stellar_submit + .recall_loan(onchain_id, outstanding) + .await + { + Ok(outcome) => { + if let Err(response) = verify_loan_event( + &state, + &outcome, + onchain_id, + &RECALL_EVENT_TOPICS, + "LOAN/RECALL", + ) { + return response; + } + if let Some(contract) = state.stellar_submit.contract() { + if let Some(event) = + find_event(&outcome.events, contract, &RECALL_EVENT_TOPICS) + { + remaining_loaned = + event_u64_field(event, "remaining_loaned").unwrap_or(0); + recalled_amount = + event_u64_field(event, "recalled_amount").unwrap_or(outstanding); + } + } + tx_hash = Some(outcome.tx_hash); + } + Err(error) => return stellar_error_response(&error, "recall loans"), + } + } + } else if let Some(amount) = payload.recall_amount { + recalled_amount = amount; + } + + let recall_progress = if remaining_loaned == 0 { 100 } else { 50 }; + + if let Err(response) = upsert_lifecycle( + &state, + plan.id, + None, + Some(recall_progress), + None, + remaining_loaned, + tx_hash.as_deref(), + ) + .await + { + return response; + } + + invalidate_cache(&state, &plan).await; + emit_status( + &state, + PlanStatusEvent { + event_type: "plan.loans_recalled".into(), + plan_id: plan.id, + status: if remaining_loaned == 0 { + "RECALLED".into() + } else { + "PARTIAL".into() + }, + message: "Loans recalled successfully".into(), + tx_hash: tx_hash.clone(), + freeze_status: None, + recall_progress: Some(recall_progress), + settlement_status: None, + remaining_loaned: Some(remaining_loaned), + }, + "plan.loans_recalled", + &plan, + tx_hash.as_deref(), + ) + .await; + + ok_response( + "Loans recalled successfully", + serde_json::json!({ + "plan_id": plan.id, + "tx_hash": tx_hash, + "on_chain": state.stellar_submit.soroban_enabled(), + "recalled_amount": recalled_amount, + "recall_progress": recall_progress, + "remaining_loaned": remaining_loaned, + }), + ) +} + +pub async fn liquidate_and_settle( + State(state): State>, + Path(plan_id): Path, + Json(_payload): Json, +) -> Response { + let plan = match load_plan(&state, plan_id).await { + Ok(plan) => plan, + Err(response) => return response, + }; + if let Err(response) = require_triggered(&plan) { + return response; + } + + let mut tx_hash = None; + let mut remaining_loaned = 0u64; + let mut settled_amount = 0u64; + + if state.stellar_submit.soroban_enabled() { + let onchain_id = match require_onchain_id(&plan) { + Ok(id) => id, + Err(response) => return response, + }; + + let outstanding = match state.stellar_submit.outstanding_loaned(onchain_id).await { + Ok(value) => value.unwrap_or(0), + Err(error) => return stellar_error_response(&error, "read outstanding loans"), + }; + + if outstanding > 0 { + match state.stellar_submit.liquidation_fallback(onchain_id).await { + Ok(outcome) => { + if let Err(response) = verify_loan_event( + &state, + &outcome, + onchain_id, + &LIQUIDATE_EVENT_TOPICS, + "LOAN/LIQUIDAT", + ) { + return response; + } + if let Some(contract) = state.stellar_submit.contract() { + if let Some(event) = + find_event(&outcome.events, contract, &LIQUIDATE_EVENT_TOPICS) + { + settled_amount = + event_u64_field(event, "settled_amount").unwrap_or(outstanding); + } + } + tx_hash = Some(outcome.tx_hash); + } + Err(error) => return stellar_error_response(&error, "liquidate and settle"), + } + } + remaining_loaned = 0; + } + + if let Err(response) = upsert_lifecycle( + &state, + plan.id, + None, + Some(100), + Some("SETTLED"), + remaining_loaned, + tx_hash.as_deref(), + ) + .await + { + return response; + } + + if let Err(e) = + sqlx::query("UPDATE plans SET status = 'CLAIMABLE' WHERE id = $1 AND status = 'TRIGGERED'") + .bind(plan.id) + .execute(&state.db_pool) + .await + { + error!(plan_id = %plan.id, error = %e, "Failed to mark plan claimable after settlement"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to update plan status: {e}") })), + ) + .into_response(); + } + + invalidate_cache(&state, &plan).await; + emit_status( + &state, + PlanStatusEvent { + event_type: "plan.liquidated".into(), + plan_id: plan.id, + status: "SETTLED".into(), + message: "Collateral liquidated and plan settled successfully".into(), + tx_hash: tx_hash.clone(), + freeze_status: None, + recall_progress: Some(100), + settlement_status: Some("SETTLED".into()), + remaining_loaned: Some(remaining_loaned), + }, + "plan.settled", + &plan, + tx_hash.as_deref(), + ) + .await; + + ok_response( + "Collateral liquidated and plan settled successfully", + serde_json::json!({ + "plan_id": plan.id, + "tx_hash": tx_hash, + "on_chain": state.stellar_submit.soroban_enabled(), + "settled_amount": settled_amount, + "settlement_status": "SETTLED", + "remaining_loaned": remaining_loaned, + }), + ) +} + +pub async fn get_trigger_info( + State(state): State>, + Path(plan_id): Path, +) -> Response { + match load_plan(&state, plan_id).await { + Ok(_) => {} + Err(response) => return response, + } + + let row = match sqlx::query_as::<_, LoanLifecycleRow>( + r#" + SELECT plan_id, freeze_status, recall_progress, settlement_status, + outstanding_loaned, last_tx_hash, updated_at + FROM plan_loan_lifecycle + WHERE plan_id = $1 + "#, + ) + .bind(plan_id) + .fetch_optional(&state.db_pool) + .await + { + Ok(row) => row, + Err(e) => { + error!(plan_id = %plan_id, error = %e, "Failed to load loan lifecycle"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Database query failed: {e}") })), + ) + .into_response(); + } + }; + + let (freeze_status, recall_progress, settlement_status, outstanding, timestamp) = match row { + Some(row) => ( + row.freeze_status, + row.recall_progress, + row.settlement_status, + row.outstanding_loaned, + Some(row.updated_at.to_rfc3339()), + ), + None => ("PENDING".to_string(), 0, "PENDING".to_string(), 0, None), + }; + + let loan_status = match freeze_status.as_str() { + "FROZEN" if recall_progress >= 100 => "Recalled", + "FROZEN" => "Frozen", + _ => "Active", + }; + + let outstanding_loans = if outstanding > 0 { + vec![OutstandingLoan { + pool: "Soroban inheritance vault".into(), + amount: outstanding.to_string(), + status: loan_status.into(), + }] + } else { + Vec::new() + }; + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "data": { + "timestamp": timestamp, + "freeze_status": freeze_status, + "recall_progress": recall_progress, + "settlement_status": settlement_status, + "outstanding_loans": outstanding_loans, + } + })), + ) + .into_response() +} + +async fn load_plan(state: &AppState, plan_id: Uuid) -> Result { + match sqlx::query_as::<_, PlanRef>( + r#" + SELECT id, owner_address, status, is_active, onchain_plan_id + FROM plans + WHERE id = $1 + "#, + ) + .bind(plan_id) + .fetch_optional(&state.db_pool) + .await + { + Ok(Some(row)) => Ok(row), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Plan not found" })), + ) + .into_response()), + Err(e) => { + error!(plan_id = %plan_id, error = %e, "Failed to fetch plan for loan lifecycle"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Database query failed: {e}") })), + ) + .into_response()) + } + } +} + +fn require_triggered(plan: &PlanRef) -> Result<(), Response> { + if !plan.is_active { + return Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "Plan is no longer active" })), + ) + .into_response()); + } + if !ALLOWED_STATUSES.contains(&plan.status.as_str()) { + return Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": format!( + "Plan must be in the triggered state. Current status: {}", + plan.status + ) + })), + ) + .into_response()); + } + Ok(()) +} + +fn require_onchain_id(plan: &PlanRef) -> Result { + match plan.onchain_plan_id { + Some(id) if id >= 0 => Ok(id as u64), + _ => Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "Plan has no on-chain identifier; it cannot invoke the lending vault" + })), + ) + .into_response()), + } +} + +async fn resolve_recall_amount( + state: &AppState, + onchain_id: u64, + requested: Option, +) -> Result { + if let Some(amount) = requested { + if amount == 0 { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "recall_amount must be greater than zero" })), + ) + .into_response()); + } + return Ok(amount); + } + + match state.stellar_submit.outstanding_loaned(onchain_id).await { + Ok(Some(amount)) => Ok(amount), + Ok(None) => Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "Inheritance has not been triggered on-chain" + })), + ) + .into_response()), + Err(error) => Err(stellar_error_response(&error, "read outstanding loans")), + } +} + +async fn outstanding_after(state: &AppState, onchain_id: u64) -> Option { + match state.stellar_submit.outstanding_loaned(onchain_id).await { + Ok(value) => value, + Err(error) => { + warn!(error = %error, "Failed to read outstanding loans after freeze"); + None + } + } +} + +fn verify_loan_event( + state: &AppState, + outcome: &InvocationOutcome, + expected_plan_id: u64, + topics: &[&str], + label: &str, +) -> Result<(), Response> { + let Some(contract) = state.stellar_submit.contract() else { + return Ok(()); + }; + let Some(event) = find_event(&outcome.events, contract, topics) else { + return Err(( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ + "error": format!( + "transaction {} succeeded but emitted no {label} event", + outcome.tx_hash + ) + })), + ) + .into_response()); + }; + match event_u64_field(event, "plan_id") { + Some(plan_id) if plan_id == expected_plan_id => Ok(()), + Some(plan_id) => Err(( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ + "error": format!( + "transaction {} targeted plan {plan_id}, expected {expected_plan_id}", + outcome.tx_hash + ) + })), + ) + .into_response()), + None => Ok(()), + } +} + +async fn upsert_lifecycle( + state: &AppState, + plan_id: Uuid, + freeze_status: Option<&str>, + recall_progress: Option, + settlement_status: Option<&str>, + outstanding_loaned: u64, + tx_hash: Option<&str>, +) -> Result<(), Response> { + if let Err(e) = sqlx::query( + r#" + INSERT INTO plan_loan_lifecycle ( + plan_id, freeze_status, recall_progress, settlement_status, + outstanding_loaned, last_tx_hash, updated_at + ) + VALUES ( + $1, + COALESCE($2, 'PENDING'), + COALESCE($3, 0), + COALESCE($4, 'PENDING'), + $5, $6, NOW() + ) + ON CONFLICT (plan_id) DO UPDATE SET + freeze_status = COALESCE($2, plan_loan_lifecycle.freeze_status), + recall_progress = COALESCE($3, plan_loan_lifecycle.recall_progress), + settlement_status = COALESCE($4, plan_loan_lifecycle.settlement_status), + outstanding_loaned = $5, + last_tx_hash = COALESCE($6, plan_loan_lifecycle.last_tx_hash), + updated_at = NOW() + "#, + ) + .bind(plan_id) + .bind(freeze_status) + .bind(recall_progress) + .bind(settlement_status) + .bind(outstanding_loaned as i64) + .bind(tx_hash) + .execute(&state.db_pool) + .await + { + error!(plan_id = %plan_id, error = %e, "Failed to persist loan lifecycle"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to persist loan status: {e}") })), + ) + .into_response()); + } + Ok(()) +} + +async fn invalidate_cache(state: &AppState, plan: &PlanRef) { + let addresses: Vec = + sqlx::query_scalar("SELECT wallet_address FROM beneficiaries WHERE plan_id = $1") + .bind(plan.id) + .fetch_all(&state.db_pool) + .await + .unwrap_or_default(); + if let Err(err) = state + .plan_cache + .invalidate_queries(&plan.owner_address, &addresses) + .await + { + warn!( + plan_id = %plan.id, + error = %err, + "Failed to invalidate plan cache after loan lifecycle update" + ); + } +} + +async fn emit_status( + state: &AppState, + event: PlanStatusEvent, + webhook_type: &str, + plan: &PlanRef, + tx_hash: Option<&str>, +) { + if let Err(e) = state.status_tx.send(event.clone()) { + tracing::debug!("No WebSocket subscribers for plan status: {e}"); + } + + let payload = serde_json::json!({ + "plan_id": plan.id, + "owner_address": plan.owner_address, + "onchain_plan_id": plan.onchain_plan_id, + "status": event.status, + "tx_hash": tx_hash, + "remaining_loaned": event.remaining_loaned, + }); + if let Err(e) = + crate::WebhookDispatcherService::enqueue_event(&state.db_pool, webhook_type, &payload).await + { + warn!("Failed to enqueue webhook for {webhook_type}: {e:?}"); + } +} + +fn stellar_error_response(error: &StellarSubmitError, action: &str) -> Response { + let (status, message) = match error { + StellarSubmitError::NotConfigured => ( + StatusCode::SERVICE_UNAVAILABLE, + "Soroban contract invocation is not configured".to_string(), + ), + StellarSubmitError::Simulation(detail) => { + let conflict = detail.to_lowercase(); + if conflict.contains("nottriggered") + || conflict.contains("not triggered") + || conflict.contains("#43") + { + ( + StatusCode::CONFLICT, + format!("Inheritance has not been triggered: {detail}"), + ) + } else { + ( + StatusCode::BAD_REQUEST, + format!("On-chain {action} simulation failed: {detail}"), + ) + } + } + StellarSubmitError::Network(_) + | StellarSubmitError::Rpc(_) + | StellarSubmitError::Timeout { .. } + | StellarSubmitError::TransactionFailed { .. } + | StellarSubmitError::Rejected(_) => ( + StatusCode::BAD_GATEWAY, + format!("On-chain {action} failed: {error}"), + ), + StellarSubmitError::Config(_) | StellarSubmitError::Xdr(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("On-chain {action} failed: {error}"), + ), + }; + (status, Json(serde_json::json!({ "error": message }))).into_response() +} + +fn ok_response(message: &str, data: serde_json::Value) -> Response { + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "message": message, + "data": data, + })), + ) + .into_response() +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 6d7cff5ad..0ee283b70 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -89,6 +89,7 @@ async fn main() -> Result<(), Box> { } let (kyc_tx, _) = tokio::sync::broadcast::channel(100); + let (status_tx, _) = tokio::sync::broadcast::channel(100); // Initialize state let state = Arc::new(AppState { anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new( @@ -100,6 +101,7 @@ async fn main() -> Result<(), Box> { plan_cache: plan_cache.clone(), apy_cache: dashmap::DashMap::new(), kyc_tx: kyc_tx.clone(), + status_tx, stellar_submit: stellar_submit.clone(), }); diff --git a/backend/src/stellar_submit.rs b/backend/src/stellar_submit.rs index e1afafcfd..f8f94ff81 100644 --- a/backend/src/stellar_submit.rs +++ b/backend/src/stellar_submit.rs @@ -236,12 +236,72 @@ impl StellarSubmitClient { plan_id: u64, ) -> Result { let ctx = self.soroban()?; - let caller = ScVal::Address(ScAddress::Account(stellar_xdr::AccountId( - stellar_xdr::PublicKey::PublicKeyTypeEd25519(Uint256(ctx.public_key)), - ))); + self.invoke_contract( + "trigger_inheritance", + vec![signer_account_scval(ctx), ScVal::U64(plan_id)], + ) + .await + } - self.invoke_contract("trigger_inheritance", vec![caller, ScVal::U64(plan_id)]) - .await + /// Calls `freeze_loans(admin, plan_id)` to halt new borrowing against the + /// plan's vault collateral after inheritance has been triggered. + pub async fn freeze_loans( + &self, + plan_id: u64, + ) -> Result { + let ctx = self.soroban()?; + self.invoke_contract( + "freeze_loans", + vec![signer_account_scval(ctx), ScVal::U64(plan_id)], + ) + .await + } + + /// Calls `recall_loan(admin, plan_id, recall_amount)` to pull loaned + /// capital (and any already-harvested yield sitting in `total_loaned`) + /// back into the vault. + pub async fn recall_loan( + &self, + plan_id: u64, + recall_amount: u64, + ) -> Result { + let ctx = self.soroban()?; + self.invoke_contract( + "recall_loan", + vec![ + signer_account_scval(ctx), + ScVal::U64(plan_id), + ScVal::U64(recall_amount), + ], + ) + .await + } + + /// Calls `liquidation_fallback(admin, plan_id)` to write off unrecoverable + /// loaned amounts so settlement can complete. + pub async fn liquidation_fallback( + &self, + plan_id: u64, + ) -> Result { + let ctx = self.soroban()?; + self.invoke_contract( + "liquidation_fallback", + vec![signer_account_scval(ctx), ScVal::U64(plan_id)], + ) + .await + } + + /// Simulates `get_inheritance_trigger(plan_id)` and, when the plan has + /// been triggered, returns the outstanding loaned amount still sitting + /// against the vault. + pub async fn outstanding_loaned( + &self, + plan_id: u64, + ) -> Result, StellarSubmitError> { + let return_value = self + .simulate_contract("get_inheritance_trigger", vec![ScVal::U64(plan_id)]) + .await?; + Ok(parse_outstanding_loaned(&return_value)) } /// Builds, simulates, signs and submits a contract invocation, then polls @@ -307,6 +367,47 @@ impl StellarSubmitClient { self.await_transaction(ctx, &tx_hash).await } + /// Simulates a contract invocation without submitting it. Used for view + /// functions such as `get_inheritance_trigger`. + pub async fn simulate_contract( + &self, + function_name: &str, + args: Vec, + ) -> Result { + let ctx = self.soroban()?; + + let function_name = ScSymbol( + function_name + .try_into() + .map_err(|_| StellarSubmitError::Config(format!("{function_name} is too long")))?, + ); + let args: VecM = args + .try_into() + .map_err(|e| StellarSubmitError::Xdr(format!("invocation arguments: {e:?}")))?; + + let sequence = self.next_sequence(ctx).await?; + let valid_until = unix_now() + TX_VALID_FOR_SECS; + + let invocation = InvokeContractArgs { + contract_address: ScAddress::Contract(ctx.contract.clone()), + function_name, + args, + }; + let unsigned = build_transaction( + ctx, + sequence, + valid_until, + BASE_FEE_STROOPS, + invocation, + VecM::default(), + TransactionExt::V0, + )?; + let simulation = self.simulate(ctx, &unsigned).await?; + simulation + .return_value + .ok_or_else(|| StellarSubmitError::Simulation("no return value".into())) + } + fn soroban(&self) -> Result<&SorobanContext, StellarSubmitError> { self.soroban .as_deref() @@ -420,6 +521,7 @@ impl StellarSubmitClient { #[derive(Deserialize)] struct SimulateResult { auth: Option>, + xdr: Option, } let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { @@ -452,11 +554,18 @@ impl StellarSubmitClient { .parse::() .map_err(|_| StellarSubmitError::Simulation("malformed minResourceFee".into()))?; - let auth = response - .results - .unwrap_or_default() - .into_iter() - .next() + let first_result = response.results.unwrap_or_default().into_iter().next(); + + let return_value = first_result + .as_ref() + .and_then(|result| result.xdr.as_deref()) + .map(|xdr| { + ScVal::from_xdr_base64(xdr.trim(), Limits::none()) + .map_err(|e| StellarSubmitError::Xdr(format!("simulate return value: {e}"))) + }) + .transpose()?; + + let auth = first_result .and_then(|result| result.auth) .unwrap_or_default() .iter() @@ -473,6 +582,7 @@ impl StellarSubmitClient { transaction_data, min_resource_fee, auth, + return_value, }) } @@ -596,6 +706,7 @@ struct Simulation { transaction_data: SorobanTransactionData, min_resource_fee: i64, auth: VecM, + return_value: Option, } #[allow(clippy::too_many_arguments)] @@ -771,6 +882,82 @@ fn matches_symbol(value: &ScVal, expected: &str) -> bool { matches!(value, ScVal::Symbol(symbol) if symbol.0.as_vec().as_slice() == expected.as_bytes()) } +fn signer_account_scval(ctx: &SorobanContext) -> ScVal { + ScVal::Address(ScAddress::Account(stellar_xdr::AccountId( + stellar_xdr::PublicKey::PublicKeyTypeEd25519(Uint256(ctx.public_key)), + ))) +} + +/// Outstanding loaned amount still encumbering the vault, derived from a +/// `get_inheritance_trigger` return value. `None` means the plan has not +/// been triggered (the contract returns a Soroban `Option::None`). +pub fn parse_outstanding_loaned(value: &ScVal) -> Option { + let inner = unwrap_option_scval(value)?; + let original = scval_u64_field(inner, "original_loaned")?; + let recalled = scval_u64_field(inner, "recalled_amount").unwrap_or(0); + let settled = scval_u64_field(inner, "settled_amount").unwrap_or(0); + let liquidation = scval_bool_field(inner, "liquidation_triggered").unwrap_or(false); + if liquidation { + return Some(0); + } + Some(original.saturating_sub(recalled).saturating_sub(settled)) +} + +fn unwrap_option_scval(value: &ScVal) -> Option<&ScVal> { + match value { + ScVal::Void => None, + ScVal::Vec(Some(vec)) => { + let items = vec.as_slice(); + if items.is_empty() { + return None; + } + if matches_symbol(&items[0], "None") { + return None; + } + if matches_symbol(&items[0], "Some") { + return items.get(1); + } + Some(value) + } + ScVal::Map(Some(_)) => Some(value), + _ => Some(value), + } +} + +fn scval_map(value: &ScVal) -> Option<&stellar_xdr::ScMap> { + match value { + ScVal::Map(Some(map)) => Some(map), + _ => None, + } +} + +pub fn scval_u64_field(value: &ScVal, field: &str) -> Option { + let map = scval_map(value)?; + map.0.iter().find_map(|entry| { + if !matches_symbol(&entry.key, field) { + return None; + } + match entry.val { + ScVal::U64(v) => Some(v), + ScVal::U32(v) => Some(u64::from(v)), + _ => None, + } + }) +} + +pub fn scval_bool_field(value: &ScVal, field: &str) -> Option { + let map = scval_map(value)?; + map.0.iter().find_map(|entry| { + if !matches_symbol(&entry.key, field) { + return None; + } + match entry.val { + ScVal::Bool(v) => Some(v), + _ => None, + } + }) +} + fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -969,4 +1156,118 @@ mod tests { .to_string(); assert_eq!(source, expected); } + + fn trigger_info_map( + original_loaned: u64, + recalled_amount: u64, + settled_amount: u64, + liquidation_triggered: bool, + ) -> ScVal { + ScVal::Map(Some(ScMap( + vec![ + ScMapEntry { + key: symbol("original_loaned"), + val: ScVal::U64(original_loaned), + }, + ScMapEntry { + key: symbol("recalled_amount"), + val: ScVal::U64(recalled_amount), + }, + ScMapEntry { + key: symbol("settled_amount"), + val: ScVal::U64(settled_amount), + }, + ScMapEntry { + key: symbol("liquidation_triggered"), + val: ScVal::Bool(liquidation_triggered), + }, + ] + .try_into() + .unwrap(), + ))) + } + + fn loan_event( + contract_id: ContractId, + topics: [&str; 2], + fields: Vec<(&str, u64)>, + ) -> ContractEvent { + ContractEvent { + ext: ExtensionPoint::V0, + contract_id: Some(contract_id), + type_: stellar_xdr::ContractEventType::Contract, + body: ContractEventBody::V0(stellar_xdr::ContractEventV0 { + topics: vec![symbol(topics[0]), symbol(topics[1])] + .try_into() + .unwrap(), + data: ScVal::Map(Some(ScMap( + fields + .into_iter() + .map(|(key, val)| ScMapEntry { + key: symbol(key), + val: ScVal::U64(val), + }) + .collect::>() + .try_into() + .unwrap(), + ))), + }), + } + } + + #[test] + fn parse_outstanding_loaned_treats_void_as_not_triggered() { + assert_eq!(parse_outstanding_loaned(&ScVal::Void), None); + } + + #[test] + fn parse_outstanding_loaned_subtracts_recalled_and_settled() { + let value = trigger_info_map(50_000, 30_000, 0, false); + assert_eq!(parse_outstanding_loaned(&value), Some(20_000)); + } + + #[test] + fn parse_outstanding_loaned_is_zero_after_liquidation() { + let value = trigger_info_map(40_000, 10_000, 30_000, true); + assert_eq!(parse_outstanding_loaned(&value), Some(0)); + } + + #[test] + fn finds_loan_freeze_recall_and_liquidate_events() { + let events = vec![ + loan_event( + contract(1), + ["LOAN", "FREEZE"], + vec![("plan_id", 7), ("frozen_at", 1)], + ), + loan_event( + contract(1), + ["LOAN", "RECALL"], + vec![ + ("plan_id", 7), + ("recalled_amount", 1_000), + ("remaining_loaned", 500), + ], + ), + loan_event( + contract(1), + ["LOAN", "LIQUIDAT"], + vec![ + ("plan_id", 7), + ("settled_amount", 500), + ("claimable_amount", 9_500), + ], + ), + ]; + + let freeze = find_event(&events, &contract(1), &["LOAN", "FREEZE"]).unwrap(); + assert_eq!(event_u64_field(freeze, "plan_id"), Some(7)); + + let recall = find_event(&events, &contract(1), &["LOAN", "RECALL"]).unwrap(); + assert_eq!(event_u64_field(recall, "recalled_amount"), Some(1_000)); + assert_eq!(event_u64_field(recall, "remaining_loaned"), Some(500)); + + let liquidate = find_event(&events, &contract(1), &["LOAN", "LIQUIDAT"]).unwrap(); + assert_eq!(event_u64_field(liquidate, "settled_amount"), Some(500)); + } } diff --git a/backend/src/ws.rs b/backend/src/ws.rs index b8360891b..216bd5c58 100644 --- a/backend/src/ws.rs +++ b/backend/src/ws.rs @@ -8,6 +8,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{info, warn}; +use uuid::Uuid; use crate::api::AppState; @@ -18,6 +19,25 @@ pub struct KycUpdateEvent { pub event_type: String, } +/// Loan-lifecycle (and other plan) status updates pushed to WebSocket clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanStatusEvent { + pub event_type: String, + pub plan_id: Uuid, + pub status: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tx_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub freeze_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recall_progress: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub settlement_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remaining_loaned: Option, +} + pub async fn ws_handler( ws: WebSocketUpgrade, State(state): State>, @@ -26,28 +46,35 @@ pub async fn ws_handler( } async fn handle_socket(mut socket: WebSocket, state: Arc) { - info!("WebSocket client connected for KYC updates"); - let mut rx = state.kyc_tx.subscribe(); + info!("WebSocket client connected"); + let mut kyc_rx = state.kyc_tx.subscribe(); + let mut status_rx = state.status_tx.subscribe(); loop { tokio::select! { - result = rx.recv() => { + result = kyc_rx.recv() => { + match result { + Ok(event) => { + if send_json(&mut socket, &event).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!("WebSocket KYC receiver lagged by {} messages", n); + continue; + } + Err(_) => break, + } + } + result = status_rx.recv() => { match result { Ok(event) => { - let msg = match serde_json::to_string(&event) { - Ok(s) => s, - Err(e) => { - warn!(error = %e, "Failed to serialize KYC event"); - continue; - } - }; - if socket.send(Message::Text(msg.into())).await.is_err() { - info!("WebSocket client disconnected"); + if send_json(&mut socket, &event).await.is_err() { break; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!("WebSocket receiver lagged by {} messages", n); + warn!("WebSocket plan-status receiver lagged by {} messages", n); continue; } Err(_) => break, @@ -78,5 +105,16 @@ async fn handle_socket(mut socket: WebSocket, state: Arc) { } } - info!("WebSocket client disconnected from KYC updates"); + info!("WebSocket client disconnected"); +} + +async fn send_json(socket: &mut WebSocket, event: &T) -> Result<(), ()> { + let msg = match serde_json::to_string(event) { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "Failed to serialize WebSocket event"); + return Ok(()); + } + }; + socket.send(Message::Text(msg.into())).await.map_err(|_| ()) } diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 8f5d7c115..96352c773 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -47,6 +47,7 @@ fn setup_app_with_cache(plan_cache: PlanCache) -> axum::Router { )), db_pool, kyc_tx: tokio::sync::broadcast::channel(16).0, + status_tx: tokio::sync::broadcast::channel(16).0, kyc_webhook_secret: None, apy_config: inheritx_backend::yield_calculator::ApyConfig::default(), plan_cache, @@ -517,6 +518,7 @@ async fn test_health_endpoint_without_db_yields_service_unavailable() { )), db_pool, kyc_tx: tokio::sync::broadcast::channel(16).0, + status_tx: tokio::sync::broadcast::channel(16).0, kyc_webhook_secret: None, apy_config: inheritx_backend::yield_calculator::ApyConfig::default(), plan_cache: PlanCache::disabled(), @@ -570,6 +572,7 @@ async fn test_get_current_rate_cached() { )), db_pool, kyc_tx: tokio::sync::broadcast::channel(16).0, + status_tx: tokio::sync::broadcast::channel(16).0, kyc_webhook_secret: None, apy_config: inheritx_backend::yield_calculator::ApyConfig::default(), plan_cache, @@ -767,3 +770,100 @@ async fn test_calculate_yield_invalid_amount() { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + +#[tokio::test] +async fn test_freeze_loans_requires_auth() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/plans/00000000-0000-0000-0000-000000000001/freeze-loans") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_recall_loans_requires_auth() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/plans/00000000-0000-0000-0000-000000000001/recall-loans") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_liquidate_settle_requires_auth() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/plans/00000000-0000-0000-0000-000000000001/liquidate-settle") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_freeze_loans_valid_signature_reaches_handler() { + let app = setup_app(); + let body = "{}"; + let (public_key, signature) = generate_valid_signature(body, ""); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/plans/00000000-0000-0000-0000-000000000001/freeze-loans") + .header(http::header::CONTENT_TYPE, "application/json") + .header("X-Public-Key", public_key) + .header("X-Signature", signature) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Auth succeeded; the lazy test database is unreachable so the handler + // returns 500 rather than 401/404. + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn test_trigger_info_is_public() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/plans/00000000-0000-0000-0000-000000000001/trigger-info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_ne!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} diff --git a/backend/tests/kyc_webhook_test.rs b/backend/tests/kyc_webhook_test.rs index dd1997c3c..fe35fc784 100644 --- a/backend/tests/kyc_webhook_test.rs +++ b/backend/tests/kyc_webhook_test.rs @@ -34,6 +34,7 @@ fn test_state(secret: Option<&str>) -> std::sync::Arc Result<(), InheritanceError> { + Self::check_not_paused(&env); + Self::require_admin(&env, &admin)?; + + let mut plan = Self::get_plan(&env, plan_id).ok_or(InheritanceError::PlanNotFound)?; + + let mut trigger_info = Self::get_trigger_info(&env, plan_id) + .ok_or(InheritanceError::InheritanceNotTriggered)?; + + plan.is_lendable = false; + Self::store_plan(&env, plan_id, &plan); + + trigger_info.loan_freeze_active = true; + Self::set_trigger_info(&env, plan_id, &trigger_info); + + let now = env.ledger().timestamp(); + env.events().publish( + (symbol_short!("LOAN"), symbol_short!("FREEZE")), + LoanFreezeEvent { + plan_id, + frozen_at: now, + }, + ); + + log!(&env, "Loans frozen for plan {}", plan_id); + Ok(()) + } + /// Attempt to recall loaned funds back to the plan. /// Called by admin after loan repayment has been collected off-chain /// or via cross-contract calls to lending/borrowing contracts. diff --git a/contracts/inheritance-contract/src/test.rs b/contracts/inheritance-contract/src/test.rs index 2318af77e..5f4c69e96 100644 --- a/contracts/inheritance-contract/src/test.rs +++ b/contracts/inheritance-contract/src/test.rs @@ -2280,6 +2280,52 @@ fn test_trigger_inheritance_inactive_plan_fails() { assert!(result.is_err()); } +#[test] +fn test_freeze_loans_after_trigger() { + let env = Env::default(); + let (client, token, admin, owner) = setup_with_token_and_admin(&env); + + let plan_id = client.create_inheritance_plan(&plan_params( + &env, + &owner, + &token, + "Will", + "My will", + 100_000u64, + DistributionMethod::LumpSum, + &one_beneficiary(&env, "Alice", "alice@example.com", 123456), + )); + + client.trigger_inheritance(&admin, &plan_id); + client.freeze_loans(&admin, &plan_id); + + let plan = client.get_plan_details(&plan_id).unwrap(); + assert!(!plan.is_lendable); + + let info = client.get_inheritance_trigger(&plan_id).unwrap(); + assert!(info.loan_freeze_active); +} + +#[test] +fn test_freeze_loans_without_trigger_fails() { + let env = Env::default(); + let (client, token, admin, owner) = setup_with_token_and_admin(&env); + + let plan_id = client.create_inheritance_plan(&plan_params( + &env, + &owner, + &token, + "Will", + "My will", + 100_000u64, + DistributionMethod::LumpSum, + &one_beneficiary(&env, "Alice", "alice@example.com", 123456), + )); + + let result = client.try_freeze_loans(&admin, &plan_id); + assert!(result.is_err()); +} + #[test] fn test_recall_loan_success() { let env = Env::default(); diff --git a/frontend/app/lib/api/plans.ts b/frontend/app/lib/api/plans.ts index c7ea2582b..a0dedbb0e 100644 --- a/frontend/app/lib/api/plans.ts +++ b/frontend/app/lib/api/plans.ts @@ -82,6 +82,30 @@ export interface PlanStatistics { }>; } +export interface LoanLifecycleResponse { + status: string; + message: string; + data?: { + plan_id: string; + tx_hash?: string | null; + on_chain?: boolean; + freeze_status?: string; + recall_progress?: number; + recalled_amount?: number; + settlement_status?: string; + settled_amount?: number; + remaining_loaned?: number; + }; +} + +export interface TriggerInfo { + timestamp: string | null; + freeze_status: string; + recall_progress: number; + settlement_status: string; + outstanding_loans: Array<{ pool: string; amount: string; status: string }>; +} + export class PlansAPI { /** * Create a new plan @@ -181,29 +205,46 @@ export class PlansAPI { /** * Freeze outstanding loans */ - async freezeLoans(planId: string): Promise { - return apiClient.post(`/api/plans/${planId}/freeze-loans`); + async freezeLoans(planId: string): Promise { + return apiClient.post( + `/api/plans/${planId}/freeze-loans`, + {} + ); } /** * Recall loans from lending pool */ - async recallLoans(planId: string): Promise { - return apiClient.post(`/api/plans/${planId}/recall-loans`); + async recallLoans( + planId: string, + recallAmount?: number + ): Promise { + return apiClient.post( + `/api/plans/${planId}/recall-loans`, + recallAmount !== undefined ? { recall_amount: recallAmount } : {} + ); } /** * Liquidate collateral if loans can't be recalled */ - async liquidateAndSettle(planId: string): Promise { - return apiClient.post(`/api/plans/${planId}/liquidate-settle`); + async liquidateAndSettle(planId: string): Promise { + return apiClient.post( + `/api/plans/${planId}/liquidate-settle`, + {} + ); } /** * Get trigger status and progress */ - async getTriggerInfo(planId: string): Promise { - return apiClient.get(`/api/plans/${planId}/trigger-info`); + async getTriggerInfo(planId: string): Promise<{ + status: string; + data: TriggerInfo; + }> { + return apiClient.get<{ status: string; data: TriggerInfo }>( + `/api/plans/${planId}/trigger-info` + ); } /**