Skip to content
Merged
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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion backend/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions backend/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub struct Config {
/// provider webhooks. When unset, `/api/kyc/webhook` rejects every request.
pub kyc_webhook_secret: Option<String>,
pub stellar_horizon_url: String,
pub anchor_api_url: String,
pub fiat_daily_limit_default: rust_decimal::Decimal,
}

Expand Down Expand Up @@ -44,13 +45,20 @@ 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,
redis_url,
plan_cache_ttl_secs,
kyc_webhook_secret,
stellar_horizon_url,
anchor_api_url,
fiat_daily_limit_default,
})
}
Expand Down
4 changes: 3 additions & 1 deletion backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ 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(),
)),
db_pool: db_pool.clone(),
kyc_webhook_secret: config.kyc_webhook_secret.clone(),
apy_config: inheritx_backend::yield_calculator::ApyConfig::from_env(),
Expand Down
144 changes: 123 additions & 21 deletions backend/src/stellar_anchor.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,39 +34,139 @@ pub struct AnchorPayout {
pub updated_at: String,
}

#[derive(Default)]
pub struct AnchorRegistry;
#[derive(Deserialize)]
struct AnchorApiResponse {
id: Option<String>,
transaction_id: Option<String>,
status: Option<String>,
exchange_rate: Option<f64>,
fiat_amount: Option<f64>,
fee: Option<f64>,
message: Option<String>,
}

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<Self>, 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<Self>, 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::<AnchorApiResponse>().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") => {
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<AnchorPayout> {
// TODO: Implement get payout logic
None
}

/// List all anchor payouts.
pub fn list_payouts(&self, _address: Option<String>) -> Vec<AnchorPayout> {
// TODO: Implement listing payouts
Vec::new()
}
}
12 changes: 9 additions & 3 deletions backend/tests/api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/kyc_webhook_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ fn test_state(secret: Option<&str>) -> std::sync::Arc<inheritx_backend::AppState
.unwrap();

std::sync::Arc::new(inheritx_backend::AppState {
anchor: std::sync::Arc::new(AnchorRegistry::new()),
anchor: std::sync::Arc::new(AnchorRegistry::new("http://localhost:8081".to_string())),
db_pool: pool,
kyc_webhook_secret: secret.map(str::to_string),
apy_config: inheritx_backend::yield_calculator::ApyConfig::default(),
Expand Down
Loading