Skip to content
Closed
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
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ INACTIVITY_WATCHDOG_BATCH_SIZE=500
# webhooks (X-KYC-Signature header). Required: without it /api/kyc/webhook
# rejects every request with 503.
KYC_WEBHOOK_SECRET=

# Stellar Anchor API for fiat payouts
# Base URL of the Stellar Anchor's fiat off-ramp API
ANCHOR_API_URL=
# API key for authenticating with the anchor endpoint
ANCHOR_API_KEY=
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS payouts_anchor_payout_id_idx;

ALTER TABLE payouts
DROP COLUMN IF EXISTS anchor_payout_id;
7 changes: 7 additions & 0 deletions backend/migrations/20260726000000_add_anchor_payout_id.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Add anchor_payout_id column to payouts table for tracking Stellar Anchor
-- transaction references. This allows the system to correlate internal payout
-- records with the anchor's external transaction ID for status polling.
ALTER TABLE payouts
ADD COLUMN anchor_payout_id TEXT;

CREATE INDEX payouts_anchor_payout_id_idx ON payouts (anchor_payout_id);
67 changes: 46 additions & 21 deletions backend/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tracing::{error, warn};
use tracing::{error, info, warn};
use uuid::Uuid;

use crate::auth::{jwt_auth_middleware, signature_auth_middleware, Claims};
Expand Down Expand Up @@ -122,6 +122,7 @@ pub struct PayoutRow {
pub amount: String,
pub payout_type: String,
pub status: String,
pub anchor_payout_id: Option<String>,
pub created_at: DateTime<Utc>,
}

Expand Down Expand Up @@ -1251,8 +1252,8 @@ async fn ping_plan(
.into_response()
}
// Handler: Trigger Payout
// Contributors: Implement calculating final payout with yield, parsing fiat payout details,
// submitting fiat payouts to AnchorRegistry, and marking the plan inactive
// Initiates payouts for all beneficiaries — calls AnchorRegistry for fiat payouts
// and marks the plan as PAID_OUT.
async fn trigger_payout(
State(state): State<Arc<AppState>>,
Json(payload): Json<PayoutRequest>,
Expand Down Expand Up @@ -1404,18 +1405,56 @@ async fn trigger_payout(
}
}

let anchor_payout_id: Option<String> = if is_fiat {
let (beneficiary_name, fiat_currency, bank_name, account_number) =
parse_fiat_anchor_info(&b.fiat_anchor_info, &b.wallet_address);
let token_amount_f64 = share.to_string().parse::<f64>().unwrap_or(0.0);
let req = crate::stellar_anchor::AnchorPayoutRequest {
beneficiary_address: b.wallet_address.clone(),
beneficiary_name,
token: plan.token_address.clone(),
token_amount: token_amount_f64,
fiat_currency,
bank_name,
account_number,
};
match state.anchor.create_payout(req).await {
Ok(anchor_payout) => {
info!(
anchor_payout_id = %anchor_payout.id,
beneficiary = %b.wallet_address,
plan_id = %plan.id,
"Anchor payout initiated"
);
Some(anchor_payout.id)
}
Err(e) => {
error!(
error = %e,
beneficiary = %b.wallet_address,
plan_id = %plan.id,
"Failed to initiate anchor payout"
);
None
}
}
} else {
None
};

let payout_row = match sqlx::query_as::<_, PayoutRow>(
r#"
INSERT INTO payouts (plan_id, beneficiary_address, amount, payout_type, status)
VALUES ($1, $2, $3, $4::payout_type, $5::payout_status)
RETURNING id, plan_id, beneficiary_address, amount::text, payout_type::text, status::text, created_at
INSERT INTO payouts (plan_id, beneficiary_address, amount, payout_type, status, anchor_payout_id)
VALUES ($1, $2, $3, $4::payout_type, $5::payout_status, $6)
RETURNING id, plan_id, beneficiary_address, amount::text, payout_type::text, status::text, anchor_payout_id, created_at
"#,
)
.bind(plan.id)
.bind(&b.wallet_address)
.bind(share)
.bind(payout_type_str)
.bind(payout_status_str)
.bind(&anchor_payout_id)
.fetch_one(&mut *tx)
.await {
Ok(row) => row,
Expand All @@ -1428,22 +1467,7 @@ async fn trigger_payout(
}
};

// Initiate payout distribution
if is_fiat {
let (beneficiary_name, fiat_currency, bank_name, account_number) =
parse_fiat_anchor_info(&b.fiat_anchor_info, &b.wallet_address);
let token_amount_f64 = share.to_string().parse::<f64>().unwrap_or(0.0);
let req = crate::stellar_anchor::AnchorPayoutRequest {
beneficiary_address: b.wallet_address.clone(),
beneficiary_name,
token: plan.token_address.clone(),
token_amount: token_amount_f64,
fiat_currency,
bank_name,
account_number,
};
state.anchor.create_payout(req);

if b.fiat_daily_limit > Decimal::ZERO {
let today = chrono::Utc::now().naive_utc().date();
if let Err(e) = sqlx::query(
Expand Down Expand Up @@ -1659,6 +1683,7 @@ async fn get_anchor_payouts(
amount::text AS amount,
payout_type::text AS payout_type,
status::text AS status,
anchor_payout_id,
created_at
FROM payouts
WHERE ($1::text IS NULL OR beneficiary_address = $1)
Expand Down
14 changes: 14 additions & 0 deletions backend/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub struct Config {
pub kyc_webhook_secret: Option<String>,
pub stellar_horizon_url: String,
pub fiat_daily_limit_default: rust_decimal::Decimal,
pub anchor_api_url: Option<String>,
pub anchor_api_key: Option<String>,
}

impl Config {
Expand Down Expand Up @@ -44,6 +46,16 @@ 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());

let anchor_api_key = std::env::var("ANCHOR_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());

Ok(Config {
port,
database_url,
Expand All @@ -52,6 +64,8 @@ impl Config {
kyc_webhook_secret,
stellar_horizon_url,
fiat_daily_limit_default,
anchor_api_url,
anchor_api_key,
})
}
}
5 changes: 4 additions & 1 deletion backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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(),
config.anchor_api_key.clone(),
)),
db_pool: db_pool.clone(),
kyc_webhook_secret: config.kyc_webhook_secret.clone(),
apy_config: inheritx_backend::yield_calculator::ApyConfig::from_env(),
Expand Down
Loading
Loading