From 5cca2da0bb83ea4bcc08abf0fd1ad43f7494a4db Mon Sep 17 00:00:00 2001 From: pixels26 Date: Wed, 29 Jul 2026 09:36:44 +0100 Subject: [PATCH 1/2] feat: trigger fiat payout to Stellar Anchors via API (#943) --- backend/.env.example | 4 + backend/src/api.rs | 2 +- backend/src/config.rs | 8 ++ backend/src/main.rs | 4 +- backend/src/stellar_anchor.rs | 141 +++++++++++++++++++++++++----- backend/tests/api_tests.rs | 12 ++- backend/tests/kyc_webhook_test.rs | 2 +- 7 files changed, 146 insertions(+), 27 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index e2007dd26..2f228dd2a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,3 +17,7 @@ INACTIVITY_WATCHDOG_BATCH_SIZE=500 # webhooks (X-KYC-Signature header). Required: without it /api/kyc/webhook # rejects every request with 503. KYC_WEBHOOK_SECRET= + +# Base URL for the Stellar Anchor API (SEP-31). Used for triggering fiat payouts. +# Defaults to http://localhost:8081 if not set. +ANCHOR_API_URL=http://localhost:8081 diff --git a/backend/src/api.rs b/backend/src/api.rs index e0e877929..a3c32cd37 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1442,7 +1442,7 @@ async fn trigger_payout( bank_name, account_number, }; - state.anchor.create_payout(req); + state.anchor.create_payout(req).await; if b.fiat_daily_limit > Decimal::ZERO { let today = chrono::Utc::now().naive_utc().date(); diff --git a/backend/src/config.rs b/backend/src/config.rs index d8d79585a..865235886 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -10,6 +10,7 @@ pub struct Config { /// provider webhooks. When unset, `/api/kyc/webhook` rejects every request. pub kyc_webhook_secret: Option, pub stellar_horizon_url: String, + pub anchor_api_url: String, pub fiat_daily_limit_default: rust_decimal::Decimal, } @@ -44,6 +45,12 @@ impl Config { .filter(|value| !value.is_empty()) .unwrap_or_else(|| "https://horizon-testnet.stellar.org".to_string()); + let anchor_api_url = std::env::var("ANCHOR_API_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "http://localhost:8081".to_string()); + Ok(Config { port, database_url, @@ -51,6 +58,7 @@ impl Config { plan_cache_ttl_secs, kyc_webhook_secret, stellar_horizon_url, + anchor_api_url, fiat_daily_limit_default, }) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 3d416ac24..8fc4bdb45 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -60,7 +60,9 @@ async fn main() -> Result<(), Box> { let (kyc_tx, _) = tokio::sync::broadcast::channel(100); // Initialize state let state = Arc::new(AppState { - anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new()), + anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new( + config.anchor_api_url.clone(), + )), db_pool: db_pool.clone(), kyc_webhook_secret: config.kyc_webhook_secret.clone(), apy_config: inheritx_backend::yield_calculator::ApyConfig::from_env(), diff --git a/backend/src/stellar_anchor.rs b/backend/src/stellar_anchor.rs index cae88c840..83c3c3713 100644 --- a/backend/src/stellar_anchor.rs +++ b/backend/src/stellar_anchor.rs @@ -1,5 +1,7 @@ +use reqwest::Client; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use tracing::{error, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnchorPayoutRequest { @@ -32,39 +34,136 @@ pub struct AnchorPayout { pub updated_at: String, } -#[derive(Default)] -pub struct AnchorRegistry; +#[derive(Deserialize)] +struct AnchorApiResponse { + id: Option, + transaction_id: Option, + status: Option, + exchange_rate: Option, + fiat_amount: Option, + fee: Option, + message: Option, +} + +pub struct AnchorRegistry { + client: Client, + api_url: String, +} impl AnchorRegistry { - pub fn new() -> Self { - Self + pub fn new(api_url: String) -> Self { + Self { + client: Client::new(), + api_url, + } } - /// Simulate creating an anchor payout request. - /// Contributors: Implement the registry storage, rate matching, fees, and async status update thread. - pub fn create_payout(self: &Arc, req: AnchorPayoutRequest) -> AnchorPayout { - // TODO: Implement anchor payout off-ramp creation and state machine transition - AnchorPayout { - id: "".to_string(), - request: req, - exchange_rate: 1.0, - fiat_amount: 0.0, - anchor_fee_usd: 0.0, - status: AnchorPayoutStatus::Pending, - created_at: "".to_string(), - updated_at: "".to_string(), + pub async fn create_payout(self: &Arc, req: AnchorPayoutRequest) -> AnchorPayout { + let url = format!("{}/transactions/send", self.api_url.trim_end_matches('/')); + + let payload = serde_json::json!({ + "beneficiary_address": req.beneficiary_address, + "beneficiary_name": req.beneficiary_name, + "token": req.token, + "token_amount": req.token_amount, + "fiat_currency": req.fiat_currency, + "bank_name": req.bank_name, + "account_number": req.account_number, + }); + + match self.client.post(&url).json(&payload).send().await { + Ok(resp) => { + if resp.status().is_success() { + match resp.json::().await { + Ok(api_resp) => { + let status = match api_resp.status.as_deref() { + Some("completed") => AnchorPayoutStatus::Completed, + Some("processing") | Some("pending") => { + AnchorPayoutStatus::Processing + } + Some("failed") => AnchorPayoutStatus::Failed, + _ => AnchorPayoutStatus::Processing, + }; + + let now = chrono::Utc::now().to_rfc3339(); + AnchorPayout { + id: api_resp.id.or(api_resp.transaction_id).unwrap_or_default(), + request: req, + exchange_rate: api_resp.exchange_rate.unwrap_or(1.0), + fiat_amount: api_resp.fiat_amount.unwrap_or(0.0), + anchor_fee_usd: api_resp.fee.unwrap_or(0.0), + status, + created_at: now.clone(), + updated_at: now, + } + } + Err(e) => { + let now = chrono::Utc::now().to_rfc3339(); + warn!( + anchor_url = %url, + error = %e, + "Failed to parse anchor API response" + ); + AnchorPayout { + id: String::new(), + request: req, + exchange_rate: 1.0, + fiat_amount: 0.0, + anchor_fee_usd: 0.0, + status: AnchorPayoutStatus::Failed, + created_at: now.clone(), + updated_at: now, + } + } + } + } else { + let status_code = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let now = chrono::Utc::now().to_rfc3339(); + error!( + anchor_url = %url, + status = %status_code, + body = %body, + "Anchor API returned error" + ); + AnchorPayout { + id: String::new(), + request: req, + exchange_rate: 1.0, + fiat_amount: 0.0, + anchor_fee_usd: 0.0, + status: AnchorPayoutStatus::Failed, + created_at: now.clone(), + updated_at: now, + } + } + } + Err(e) => { + let now = chrono::Utc::now().to_rfc3339(); + error!( + anchor_url = %url, + error = %e, + "Failed to reach anchor API" + ); + AnchorPayout { + id: String::new(), + request: req, + exchange_rate: 1.0, + fiat_amount: 0.0, + anchor_fee_usd: 0.0, + status: AnchorPayoutStatus::Failed, + created_at: now.clone(), + updated_at: now, + } + } } } - /// Retrieve anchor payout by transaction ID. pub fn get_payout(&self, _id: &str) -> Option { - // TODO: Implement get payout logic None } - /// List all anchor payouts. pub fn list_payouts(&self, _address: Option) -> Vec { - // TODO: Implement listing payouts Vec::new() } } diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 52ba0da78..227d15939 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -42,7 +42,9 @@ fn setup_app_with_cache(plan_cache: PlanCache) -> axum::Router { .connect_lazy(&database_url) .unwrap(); let state = Arc::new(AppState { - anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new()), + anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new( + "http://localhost:8081".to_string(), + )), db_pool, kyc_tx: tokio::sync::broadcast::channel(16).0, kyc_webhook_secret: None, @@ -509,7 +511,9 @@ async fn test_health_endpoint_without_db_yields_service_unavailable() { .connect_lazy("postgres://localhost:1/nonexistent") .unwrap(); let state = Arc::new(AppState { - anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new()), + anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new( + "http://localhost:8081".to_string(), + )), db_pool, kyc_tx: tokio::sync::broadcast::channel(16).0, kyc_webhook_secret: None, @@ -560,7 +564,9 @@ async fn test_get_current_rate_cached() { .connect_lazy("postgres://postgres:password@localhost:5432/test") .unwrap(); let state = Arc::new(AppState { - anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new()), + anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new( + "http://localhost:8081".to_string(), + )), db_pool, kyc_tx: tokio::sync::broadcast::channel(16).0, kyc_webhook_secret: None, diff --git a/backend/tests/kyc_webhook_test.rs b/backend/tests/kyc_webhook_test.rs index a061afd3f..dd1997c3c 100644 --- a/backend/tests/kyc_webhook_test.rs +++ b/backend/tests/kyc_webhook_test.rs @@ -27,7 +27,7 @@ fn test_state(secret: Option<&str>) -> std::sync::Arc Date: Thu, 30 Jul 2026 13:17:37 +0100 Subject: [PATCH 2/2] fix: read api_resp.message to resolve dead_code clippy error --- backend/src/stellar_anchor.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/stellar_anchor.rs b/backend/src/stellar_anchor.rs index 83c3c3713..1fcd4170a 100644 --- a/backend/src/stellar_anchor.rs +++ b/backend/src/stellar_anchor.rs @@ -76,6 +76,9 @@ impl AnchorRegistry { if resp.status().is_success() { match resp.json::().await { Ok(api_resp) => { + if let Some(msg) = &api_resp.message { + warn!(message = %msg, "Anchor API response message"); + } let status = match api_resp.status.as_deref() { Some("completed") => AnchorPayoutStatus::Completed, Some("processing") | Some("pending") => {