diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b2c2caf..a149531 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -479,6 +479,7 @@ dependencies = [ "deadpool-postgres", "futures", "governor", + "hex", "http-body-util", "httpmock", "jsonwebtoken", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0989f2c..ff90333 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -77,6 +77,7 @@ http-body-util = "0.1" # Async utilities futures = "0.3" async-trait = "0.1" +hex = "0.4.3" # Testing [dev-dependencies] @@ -86,4 +87,4 @@ httpmock = "0.7" # Binaries [[bin]] name = "new_migration" -path = "src/bin/new_migration.rs" \ No newline at end of file +path = "src/bin/new_migration.rs" diff --git a/backend/migrations/20260221000000_anchor_kyc.sql b/backend/migrations/20260221000000_anchor_kyc.sql new file mode 100644 index 0000000..44dd0f0 --- /dev/null +++ b/backend/migrations/20260221000000_anchor_kyc.sql @@ -0,0 +1,10 @@ +-- Add KYC status and SEP-24 interactive URL tracking to withdrawals +ALTER TABLE withdrawals + ADD COLUMN IF NOT EXISTS kyc_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + ADD COLUMN IF NOT EXISTS sep24_interactive_url TEXT; + +-- Index for querying by KYC status during compliance reviews +CREATE INDEX IF NOT EXISTS idx_withdrawals_kyc_status ON withdrawals(kyc_status); + +-- Index for efficient per-user withdrawal lookups +CREATE INDEX IF NOT EXISTS idx_withdrawals_user_id ON withdrawals(user_id); diff --git a/backend/src/app.rs b/backend/src/app.rs index f05e2bf..ffb611b 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -152,8 +152,12 @@ pub async fn create_app( rate_limit::rate_limit, )); + // -------------------- Anchor -------------------- + let anchor_routes = Router::new().route("/webhook", post(crate::http::anchor::anchor_webhook)); + // -------------------- Public Routes -------------------- let public_routes = Router::new() + .nest("/anchor", anchor_routes) .nest("/auth", auth_routes) .nest("/health", health_routes) .merge(metrics_routes); diff --git a/backend/src/http/anchor.rs b/backend/src/http/anchor.rs new file mode 100644 index 0000000..ece1ca6 --- /dev/null +++ b/backend/src/http/anchor.rs @@ -0,0 +1,142 @@ +/// Anchor webhook handler — receives event callbacks from the Stellar Anchor. +/// +/// The Anchor POSTs to this endpoint whenever the state of a transaction changes +/// (e.g. `pending_external` → `completed`). We verify the HMAC-SHA256 signature +/// before processing to ensure authenticity. +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::{error, info, warn}; + +use crate::{api_error::ApiError, service::ServiceContainer}; + +// ────────────────────────────────────────────────────────────────────────────── +// Webhook payload shape +// ────────────────────────────────────────────────────────────────────────────── + +/// Minimal shape of the anchor's webhook POST body. +/// Different anchor implementations may vary — extend as needed. +#[derive(Debug, Deserialize)] +pub struct AnchorWebhookPayload { + /// The anchor's transaction ID (matches `anchor_tx_id` in our DB). + pub transaction_id: String, + /// New transaction status (e.g. `"completed"`, `"error"`, `"pending_external"`). + pub status: String, + /// Optional human-readable message from the Anchor. + pub message: Option, +} + +#[derive(Debug, Serialize)] +pub struct WebhookAck { + pub received: bool, +} + +// ────────────────────────────────────────────────────────────────────────────── +// Handler +// ────────────────────────────────────────────────────────────────────────────── + +/// `POST /anchor/webhook` +/// +/// 1. Verify the `X-Stellar-Signature` HMAC-SHA256 header. +/// 2. Parse the JSON body. +/// 3. Look up the withdrawal by `anchor_tx_id` and update its status. +/// +/// Returns `200 OK` with `{"received": true}` on success so the Anchor stops +/// retrying. Returns `401` on signature failure so misconfigured senders are +/// clearly rejected. +pub async fn anchor_webhook( + State(services): State>, + headers: HeaderMap, + body: Bytes, +) -> Result<(StatusCode, Json), ApiError> { + // ── Step 1: Verify signature ─────────────────────────────────────────────── + let sig = headers + .get("X-Stellar-Signature") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if sig.is_empty() { + warn!("Anchor webhook received without X-Stellar-Signature header"); + return Err(ApiError::Authentication( + "Missing webhook signature".to_string(), + )); + } + + services.anchor.verify_webhook_signature(&body, sig)?; + + // ── Step 2: Parse payload ───────────────────────────────────────────────── + let payload: AnchorWebhookPayload = serde_json::from_slice(&body).map_err(|e| { + error!(error = %e, "Failed to parse anchor webhook payload"); + ApiError::Validation("Invalid webhook payload".to_string()) + })?; + + info!( + anchor_tx_id = %payload.transaction_id, + status = %payload.status, + "Anchor webhook received" + ); + + // ── Step 3: Sync withdrawal status ──────────────────────────────────────── + // Map anchor status strings to our internal withdrawal status values + let internal_status = match payload.status.as_str() { + "completed" => "completed", + "error" | "expired" => "failed", + "refunded" => "refunded", + "pending_stellar" + | "pending_anchor" + | "pending_external" + | "pending_user" + | "pending_user_transfer_start" => "processing", + _ => "pending", + }; + + // Find the withdrawal by anchor_tx_id and update it + match find_withdrawal_by_anchor_tx_id(&services, &payload.transaction_id).await { + Some(withdrawal_id) => { + services + .anchor + .update_withdrawal_status(&withdrawal_id, internal_status, None) + .await?; + + if let Some(ref msg) = payload.message { + info!( + withdrawal_id = %withdrawal_id, + anchor_message = %msg, + "Anchor webhook message logged" + ); + } + } + None => { + warn!( + anchor_tx_id = %payload.transaction_id, + "Anchor webhook received for unknown transaction — ignoring" + ); + } + } + + Ok((StatusCode::OK, Json(WebhookAck { received: true }))) +} + +/// Look up a withdrawal by its `anchor_tx_id` column. +/// Returns the withdrawal's internal UUID as a string, or `None` if not found. +async fn find_withdrawal_by_anchor_tx_id( + services: &Arc, + anchor_tx_id: &str, +) -> Option { + let client = services.db_pool.get().await.ok()?; + + let row = client + .query_opt( + "SELECT id FROM withdrawals WHERE anchor_tx_id = $1", + &[&anchor_tx_id], + ) + .await + .ok()??; + + Some(row.get::<_, String>("id")) +} diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index cfa3353..62dbe9c 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod anchor; pub mod audit; pub mod auth; pub mod files; @@ -13,6 +14,7 @@ pub mod transfers; pub mod withdrawals; pub use admin::*; +pub use anchor::*; pub use audit::*; pub use auth::*; pub use files::*; diff --git a/backend/src/http/withdrawals.rs b/backend/src/http/withdrawals.rs index 54c5b8f..06bda27 100644 --- a/backend/src/http/withdrawals.rs +++ b/backend/src/http/withdrawals.rs @@ -1,50 +1,264 @@ use axum::{ extract::{Path, State}, + http::StatusCode, Json, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use tracing::info; use uuid::Uuid; -use crate::{api_error::ApiError, service::ServiceContainer}; +use crate::{ + api_error::ApiError, + middleware::auth::AuthenticatedUser, + service::{ + anchor_service::{CreateWithdrawalParams, KycStatus, Sep31PayoutParams}, + ServiceContainer, + }, +}; -#[derive(Debug, Serialize)] -pub struct WithdrawalResponse { - pub id: Uuid, - pub user_id: String, +// ────────────────────────────────────────────────────────────────────────────── +// Request / Response shapes +// ────────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct CreateWithdrawalRequest { pub destination_address: String, + /// Amount in the asset's smallest unit (e.g. stroops for XLM). pub amount: i64, + /// Stellar asset code e.g. "USDC". pub asset: String, - pub status: String, } #[derive(Debug, Deserialize)] -pub struct CreateWithdrawalRequest { +pub struct InitiateSep31PayoutRequest { + pub amount: i64, + pub asset_code: String, + pub asset_issuer: Option, + pub receiver_id: String, + pub memo: Option, +} + +#[derive(Debug, Serialize)] +pub struct WithdrawalResponse { + pub id: String, + pub user_id: String, pub destination_address: String, pub amount: i64, pub asset: String, + pub status: String, + pub anchor_tx_id: Option, + pub kyc_status: String, + /// The SEP-24 interactive URL the client must open in a browser/web-view. + /// `null` for SEP-31 (backend-only) payouts. + pub sep24_interactive_url: Option, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct WithdrawalStatusResponse { + pub id: String, + pub status: String, + /// Live status from the Anchor (may differ from our DB copy until reconciled). + pub anchor_status: Option, + pub updated_at: chrono::DateTime, } +#[derive(Debug, Serialize)] +pub struct Sep31PayoutInitResponse { + pub anchor_tx_id: String, + pub stellar_account_id: Option, + pub stellar_memo_type: Option, + pub stellar_memo: Option, +} + +// ────────────────────────────────────────────────────────────────────────────── +// Handlers +// ────────────────────────────────────────────────────────────────────────────── + +/// `POST /withdrawals` +/// +/// SEP-24 withdrawal flow: +/// 1. Gate on KYC status at the Anchor (`CLEARED` required if `kyc_required = true`). +/// 2. Obtain a SEP-24 interactive URL + `anchor_tx_id` from the Anchor. +/// 3. Persist the withdrawal record. +/// 4. Return the interactive URL to the client → client opens it in a browser/web-view. pub async fn create_withdrawal( - State(_services): State>, - Json(_request): Json, -) -> Result, ApiError> { - // Placeholder implementation - Err(ApiError::NotFound("Not implemented".to_string())) + State(services): State>, + auth: AuthenticatedUser, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let user_id = &auth.user_id; + + // Resolve the user's Stellar address from identity service + let wallet = services + .identity + .get_user_wallet(user_id) + .await + .map_err(|_| ApiError::NotFound(format!("No wallet found for user {}", user_id)))?; + let stellar_address = &wallet.address; + + // ── Step 1: KYC gate ────────────────────────────────────────────────────── + let kyc_status = if services.config.anchor_config.kyc_required { + let status = services + .anchor + .check_kyc_status(user_id, stellar_address) + .await?; + + if status != KycStatus::Cleared { + return Err(ApiError::Authorization(format!( + "KYC check failed: your status is {}. \ + Please complete identity verification at the anchor before withdrawing.", + status + ))); + } + status + } else { + KycStatus::Cleared + }; + + // ── Step 2: Obtain SEP-24 interactive URL ───────────────────────────────── + let sep24 = services + .anchor + .get_sep24_interactive_url(user_id, stellar_address, &request.asset, request.amount) + .await?; + + info!( + user_id, + anchor_tx_id = %sep24.anchor_tx_id, + "SEP-24 URL obtained — persisting withdrawal" + ); + + // ── Step 3: Persist withdrawal record ───────────────────────────────────── + let record = services + .anchor + .create_withdrawal_record(CreateWithdrawalParams { + user_id: user_id.clone(), + destination_address: request.destination_address.clone(), + amount: request.amount, + asset: request.asset.clone(), + anchor_tx_id: Some(sep24.anchor_tx_id.clone()), + kyc_status, + sep24_interactive_url: Some(sep24.url.clone()), + }) + .await?; + + Ok(( + StatusCode::CREATED, + Json(WithdrawalResponse { + id: record.id, + user_id: record.user_id, + destination_address: record.destination_address, + amount: record.amount, + asset: record.asset, + status: record.status, + anchor_tx_id: record.anchor_tx_id, + kyc_status: record.kyc_status, + sep24_interactive_url: record.sep24_interactive_url, + created_at: record.created_at, + }), + )) } +/// `GET /withdrawals/:id` +/// +/// Fetch the current state of a withdrawal from our database. pub async fn get_withdrawal( - State(_services): State>, - Path(_withdrawal_id): Path, + State(services): State>, + Path(withdrawal_id): Path, ) -> Result, ApiError> { - // Placeholder implementation - Err(ApiError::NotFound("Not implemented".to_string())) + let record = services + .anchor + .get_withdrawal_by_id(&withdrawal_id.to_string()) + .await?; + + Ok(Json(WithdrawalResponse { + id: record.id, + user_id: record.user_id, + destination_address: record.destination_address, + amount: record.amount, + asset: record.asset, + status: record.status, + anchor_tx_id: record.anchor_tx_id, + kyc_status: record.kyc_status, + sep24_interactive_url: record.sep24_interactive_url, + created_at: record.created_at, + })) } +/// `GET /withdrawals/:id/status` +/// +/// Returns our DB status AND a live probe of the Anchor's status so the client +/// always has the freshest view. pub async fn get_withdrawal_status( - State(_services): State>, - Path(_withdrawal_id): Path, -) -> Result, ApiError> { - // Placeholder implementation - Err(ApiError::NotFound("Not implemented".to_string())) + State(services): State>, + Path(withdrawal_id): Path, +) -> Result, ApiError> { + let record = services + .anchor + .get_withdrawal_by_id(&withdrawal_id.to_string()) + .await?; + + // Optionally probe the anchor for live status + let anchor_status = if let Some(ref tx_id) = record.anchor_tx_id { + match services.anchor.poll_anchor_tx_status(tx_id).await { + Ok(status) => { + let label = format!("{:?}", status).to_lowercase(); + // Reconcile: if anchor says completed/failed, sync our DB + if matches!( + label.as_str(), + "completed" | "error" | "refunded" | "expired" + ) { + let _ = services + .anchor + .update_withdrawal_status(&withdrawal_id.to_string(), &label, None) + .await; + } + Some(label) + } + Err(e) => { + tracing::warn!(error = %e, "Failed to poll anchor status — returning cached"); + None + } + } + } else { + None + }; + + Ok(Json(WithdrawalStatusResponse { + id: record.id, + status: record.status, + anchor_status, + updated_at: record.updated_at, + })) +} + +/// `POST /withdrawals/sep31` +/// +/// Initiate a SEP-31 backend-to-backend cross-border payout. +/// No interactive URL is generated — the caller is responsible for +/// submitting the on-chain Stellar payment to the returned `stellar_account_id`. +pub async fn initiate_sep31_payout( + State(services): State>, + auth: AuthenticatedUser, + Json(request): Json, +) -> Result, ApiError> { + let result = services + .anchor + .initiate_sep31_payout(&Sep31PayoutParams { + amount: request.amount.to_string(), + asset_code: request.asset_code, + asset_issuer: request.asset_issuer, + sender_id: auth.user_id.clone(), + receiver_id: request.receiver_id, + memo: request.memo, + }) + .await?; + + Ok(Json(Sep31PayoutInitResponse { + anchor_tx_id: result.anchor_tx_id, + stellar_account_id: result.stellar_account_id, + stellar_memo_type: result.stellar_memo_type, + stellar_memo: result.stellar_memo, + })) } diff --git a/backend/src/models.rs b/backend/src/models.rs index af8901e..fe5d692 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -13,6 +13,7 @@ pub struct User { pub role: Role, pub created_at: DateTime, pub updated_at: DateTime, + pub(crate) address: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/backend/src/service/anchor_service.rs b/backend/src/service/anchor_service.rs index 6c10831..5aed52a 100644 --- a/backend/src/service/anchor_service.rs +++ b/backend/src/service/anchor_service.rs @@ -1,38 +1,637 @@ -use crate::{config::Config, models::Withdrawal}; +/// Stellar Anchor integration service — SEP-10, SEP-12, SEP-24, SEP-31. +/// +/// # Protocol Summary +/// - SEP-10 : Web Authentication — proves key ownership via a signed challenge JWT. +/// - SEP-12 : KYC data exchange — used here to check a user's `"CLEARED"` status. +/// - SEP-24 : Interactive withdrawal — Anchor hosts a UI; we obtain a signed URL for the user. +/// - SEP-31 : Cross-border payment — backend-to-backend POST directly to the Anchor. +use crate::{api_error::ApiError, config::Config}; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use deadpool_postgres::Pool; +use reqwest::Client; +use ring::hmac; +use serde::{Deserialize, Serialize}; use std::sync::Arc; +use tracing::{error, info, warn}; +use uuid::Uuid; + +// ────────────────────────────────────────────────────────────────────────────── +// Public types +// ────────────────────────────────────────────────────────────────────────────── + +/// KYC clearance status as reported by the Anchor (SEP-12). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum KycStatus { + /// User has passed KYC at the Anchor — withdrawals are permitted. + Cleared, + /// KYC has been submitted but not yet reviewed. + Pending, + /// KYC was rejected; withdrawals must be blocked. + Rejected, + /// No KYC record exists at the Anchor for this user. + NotFound, +} + +impl std::fmt::Display for KycStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KycStatus::Cleared => write!(f, "CLEARED"), + KycStatus::Pending => write!(f, "PENDING"), + KycStatus::Rejected => write!(f, "REJECTED"), + KycStatus::NotFound => write!(f, "NOT_FOUND"), + } + } +} + +/// Response from `POST /transactions/withdraw/interactive` (SEP-24). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sep24InteractiveResponse { + /// The URL to redirect the user to so they can complete the withdrawal in the Anchor's UI. + pub url: String, + /// Anchor-assigned transaction ID — stored as `anchor_tx_id` in our DB. + pub anchor_tx_id: String, +} + +/// Parameters required to initiate a SEP-31 cross-border payout. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sep31PayoutParams { + pub amount: String, + pub asset_code: String, + /// ISO 3166-1 alpha-3 asset issuer address or omit for stellar-native + pub asset_issuer: Option, + pub sender_id: String, + pub receiver_id: String, + /// Optional memo to attach to the Stellar transaction + pub memo: Option, +} + +/// Response from `POST /transactions` (SEP-31). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sep31PayoutResponse { + /// Anchor-assigned transaction ID. + pub anchor_tx_id: String, + /// Stellar account to which the sending side should send funds. + pub stellar_account_id: Option, + /// Memo type required (`text`, `id`, or `hash`). + pub stellar_memo_type: Option, + /// Memo value. + pub stellar_memo: Option, +} + +/// Unified anchor transaction status (covers both SEP-24 and SEP-31). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AnchorTxStatus { + Incomplete, + PendingStellar, + PendingAnchor, + PendingExternal, + PendingUser, + PendingUserTransferStart, + Completed, + Refunded, + Expired, + Error, +} + +/// Lightweight DB model returned after DB operations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WithdrawalRecord { + pub id: String, + pub user_id: String, + pub destination_address: String, + pub amount: i64, + pub asset: String, + pub status: String, + pub anchor_tx_id: Option, + pub kyc_status: String, + pub sep24_interactive_url: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +/// Parameters for creating a new withdrawal record in our DB. +#[derive(Debug, Clone)] +pub struct CreateWithdrawalParams { + pub user_id: String, + pub destination_address: String, + pub amount: i64, + pub asset: String, + pub anchor_tx_id: Option, + pub kyc_status: KycStatus, + pub sep24_interactive_url: Option, +} + +// ────────────────────────────────────────────────────────────────────────────── +// Internal Anchor API shapes (minimal — we only deserialise what we need) +// ────────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct AnchorKycResponse { + /// Top-level status from a SEP-12 KYC check. + status: Option, +} + +#[derive(Debug, Deserialize)] +struct AnchorSep24Response { + /// The interactive URL for the user. + url: String, + /// Anchor-assigned transaction ID. + id: String, +} + +#[derive(Debug, Deserialize)] +struct AnchorSep31Response { + transaction: AnchorSep31Transaction, +} + +#[derive(Debug, Deserialize)] +struct AnchorSep31Transaction { + id: String, + stellar_account_id: Option, + stellar_memo_type: Option, + stellar_memo: Option, +} + +#[derive(Debug, Deserialize)] +struct AnchorTxStatusResponse { + transaction: AnchorTxDetail, +} + +#[derive(Debug, Deserialize)] +struct AnchorTxDetail { + status: String, +} + +// ────────────────────────────────────────────────────────────────────────────── +// AnchorService +// ────────────────────────────────────────────────────────────────────────────── #[derive(Clone)] -#[allow(dead_code)] pub struct AnchorService { db_pool: Arc, config: Config, + http: Client, } impl AnchorService { pub fn new(db_pool: Arc, config: Config) -> Self { - Self { db_pool, config } + let http = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to build reqwest client"); + Self { + db_pool, + config, + http, + } } - // Placeholder implementations - pub async fn process_sep24_deposit( + // ────────────────────────────────────────────────────────────────────────── + // SEP-12: KYC Status Check + // ────────────────────────────────────────────────────────────────────────── + + /// Check whether a user is cleared for withdrawals at the configured Anchor. + /// + /// Calls `GET {sep24_url}/kyc?account={stellar_address}` with a service-level + /// SEP-10 bearer token. Returns `KycStatus::Cleared` only when the Anchor + /// responds with `"CLEARED"`. + pub async fn check_kyc_status( &self, - _request: serde_json::Value, - ) -> Result<(), crate::api_error::ApiError> { + user_id: &str, + stellar_address: &str, + ) -> Result { + info!(user_id, stellar_address, "Checking KYC status at anchor"); + + let token = self.build_sep10_token(stellar_address)?; + let url = format!( + "{}/kyc?account={}", + self.config.anchor_config.sep24_url, stellar_address + ); + + let response = self + .http + .get(&url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| { + error!(error = %e, "Failed to reach anchor KYC endpoint"); + ApiError::InternalServerError + })?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + warn!(user_id, "No KYC record found at anchor"); + return Ok(KycStatus::NotFound); + } + + let body: AnchorKycResponse = response.json().await.map_err(|e| { + error!(error = %e, "Failed to parse anchor KYC response"); + ApiError::InternalServerError + })?; + + let status = match body.status.as_deref() { + Some("CLEARED") | Some("cleared") => KycStatus::Cleared, + Some("PENDING") | Some("pending") => KycStatus::Pending, + Some("REJECTED") | Some("rejected") => KycStatus::Rejected, + _ => KycStatus::NotFound, + }; + + info!(user_id, kyc_status = %status, "KYC status check complete"); + Ok(status) + } + + // ────────────────────────────────────────────────────────────────────────── + // SEP-24: Interactive Withdrawal URL + // ────────────────────────────────────────────────────────────────────────── + + /// Obtain a SEP-24 interactive withdrawal URL for the given user. + /// + /// The returned URL should be sent back to the client/mobile app. The user + /// opens it in a browser to complete the Anchor's KYC/bank-details form. + /// The `anchor_tx_id` should be persisted immediately so we can reconcile + /// incoming webhook callbacks. + pub async fn get_sep24_interactive_url( + &self, + user_id: &str, + stellar_address: &str, + asset: &str, + amount: i64, + ) -> Result { + info!(user_id, asset, amount, "Requesting SEP-24 interactive URL"); + + let token = self.build_sep10_token(stellar_address)?; + let endpoint = format!( + "{}/transactions/withdraw/interactive", + self.config.anchor_config.sep24_url + ); + + let body = serde_json::json!({ + "asset_code": asset, + "account": stellar_address, + "amount": amount.to_string(), + "lang": "en", + }); + + let response = self + .http + .post(&endpoint) + .bearer_auth(&token) + .json(&body) + .send() + .await + .map_err(|e| { + error!(error = %e, "Failed to reach anchor SEP-24 endpoint"); + ApiError::InternalServerError + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + error!(http_status = %status, body = %text, "Anchor returned non-2xx for SEP-24"); + return Err(ApiError::InternalServerError); + } + + let parsed: AnchorSep24Response = response.json().await.map_err(|e| { + error!(error = %e, "Failed to parse SEP-24 anchor response"); + ApiError::InternalServerError + })?; + + info!( + user_id, + anchor_tx_id = %parsed.id, + "SEP-24 interactive URL obtained" + ); + + Ok(Sep24InteractiveResponse { + url: parsed.url, + anchor_tx_id: parsed.id, + }) + } + + // ────────────────────────────────────────────────────────────────────────── + // SEP-31: Backend-to-Backend Cross-Border Payout + // ────────────────────────────────────────────────────────────────────────── + + /// Initiate a SEP-31 cross-border payout directly from our backend to the Anchor. + /// + /// This does NOT involve a browser UI — our server POSTs to the Anchor's + /// `/transactions` endpoint. The response includes a Stellar account + memo + /// that we must use when submitting the on-chain payment. + pub async fn initiate_sep31_payout( + &self, + params: &Sep31PayoutParams, + ) -> Result { + info!( + sender_id = %params.sender_id, + asset = %params.asset_code, + amount = %params.amount, + "Initiating SEP-31 payout" + ); + + let endpoint = format!("{}/transactions", self.config.anchor_config.sep31_url); + let token = self.build_sep10_token(¶ms.sender_id)?; + + let mut body = serde_json::json!({ + "amount": params.amount, + "asset_code": params.asset_code, + "sender_id": params.sender_id, + "receiver_id": params.receiver_id, + }); + + if let Some(issuer) = ¶ms.asset_issuer { + body["asset_issuer"] = serde_json::json!(issuer); + } + if let Some(memo) = ¶ms.memo { + body["memo"] = serde_json::json!(memo); + body["memo_type"] = serde_json::json!("text"); + } + + let response = self + .http + .post(&endpoint) + .bearer_auth(&token) + .json(&body) + .send() + .await + .map_err(|e| { + error!(error = %e, "Failed to reach anchor SEP-31 endpoint"); + ApiError::InternalServerError + })?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + error!(http_status = %status, body = %text, "Anchor returned non-2xx for SEP-31"); + return Err(ApiError::InternalServerError); + } + + let parsed: AnchorSep31Response = response.json().await.map_err(|e| { + error!(error = %e, "Failed to parse SEP-31 anchor response"); + ApiError::InternalServerError + })?; + + info!(anchor_tx_id = %parsed.transaction.id, "SEP-31 payout initiated"); + + Ok(Sep31PayoutResponse { + anchor_tx_id: parsed.transaction.id, + stellar_account_id: parsed.transaction.stellar_account_id, + stellar_memo_type: parsed.transaction.stellar_memo_type, + stellar_memo: parsed.transaction.stellar_memo, + }) + } + + // ────────────────────────────────────────────────────────────────────────── + // Transaction Status Polling + // ────────────────────────────────────────────────────────────────────────── + + /// Poll the Anchor for the current status of a transaction (SEP-24 or SEP-31). + /// + /// Calls `GET {sep24_url}/transaction?id={anchor_tx_id}`. + pub async fn poll_anchor_tx_status( + &self, + anchor_tx_id: &str, + ) -> Result { + let url = format!( + "{}/transaction?id={}", + self.config.anchor_config.sep24_url, anchor_tx_id + ); + + let response = self.http.get(&url).send().await.map_err(|e| { + error!(error = %e, "Failed to poll anchor TX status"); + ApiError::InternalServerError + })?; + + let body: AnchorTxStatusResponse = response.json().await.map_err(|e| { + error!(error = %e, "Failed to parse anchor TX status response"); + ApiError::InternalServerError + })?; + + let status = match body.transaction.status.as_str() { + "pending_stellar" => AnchorTxStatus::PendingStellar, + "pending_anchor" => AnchorTxStatus::PendingAnchor, + "pending_external" => AnchorTxStatus::PendingExternal, + "pending_user" => AnchorTxStatus::PendingUser, + "pending_user_transfer_start" => AnchorTxStatus::PendingUserTransferStart, + "completed" => AnchorTxStatus::Completed, + "refunded" => AnchorTxStatus::Refunded, + "expired" => AnchorTxStatus::Expired, + "error" => AnchorTxStatus::Error, + _ => AnchorTxStatus::Incomplete, + }; + + Ok(status) + } + + // ────────────────────────────────────────────────────────────────────────── + // Webhook Signature Verification + // ────────────────────────────────────────────────────────────────────────── + + /// Verify the HMAC-SHA256 signature on an incoming anchor webhook. + /// + /// The Anchor sends the signature as a hex-encoded string in the + /// `X-Stellar-Signature` header. We recompute HMAC-SHA256 over the raw + /// request body using `config.anchor_config.webhook_secret`. + pub fn verify_webhook_signature( + &self, + payload: &[u8], + signature_header: &str, + ) -> Result<(), ApiError> { + let key = hmac::Key::new( + hmac::HMAC_SHA256, + self.config.anchor_config.webhook_secret.as_bytes(), + ); + + // Signature may arrive as hex or base64 — try both + let expected_tag = hmac::sign(&key, payload); + let expected_hex = hex::encode(expected_tag.as_ref()); + let expected_b64 = B64.encode(expected_tag.as_ref()); + + let header = signature_header.trim(); + // Strip common prefixes e.g. "sha256=" sent by some anchors + let sig = header.strip_prefix("sha256=").unwrap_or(header); + + if sig != expected_hex && sig != expected_b64 { + warn!("Anchor webhook signature mismatch"); + return Err(ApiError::Authentication( + "Invalid webhook signature".to_string(), + )); + } + Ok(()) } - pub async fn process_sep31_payout( + // ────────────────────────────────────────────────────────────────────────── + // Database helpers + // ────────────────────────────────────────────────────────────────────────── + + /// Persist a new withdrawal record and return the created row. + pub async fn create_withdrawal_record( &self, - _withdrawal: &Withdrawal, - ) -> Result { - Ok("anchor_tx_123".to_string()) + params: CreateWithdrawalParams, + ) -> Result { + let client = self.db_pool.get().await.map_err(|e| { + error!(error = %e, "DB pool error"); + ApiError::InternalServerError + })?; + + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now(); + + client + .execute( + r#" + INSERT INTO withdrawals + (id, user_id, destination_address, amount, asset, status, + anchor_tx_id, kyc_status, sep24_interactive_url, created_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, 'pending', + $6, $7, $8, $9, $10) + "#, + &[ + &id, + ¶ms.user_id, + ¶ms.destination_address, + ¶ms.amount, + ¶ms.asset, + ¶ms.anchor_tx_id, + ¶ms.kyc_status.to_string(), + ¶ms.sep24_interactive_url, + &now, + &now, + ], + ) + .await + .map_err(|e| { + error!(error = %e, "Failed to insert withdrawal record"); + ApiError::InternalServerError + })?; + + Ok(WithdrawalRecord { + id, + user_id: params.user_id, + destination_address: params.destination_address, + amount: params.amount, + asset: params.asset, + status: "pending".to_string(), + anchor_tx_id: params.anchor_tx_id, + kyc_status: params.kyc_status.to_string(), + sep24_interactive_url: params.sep24_interactive_url, + created_at: now, + updated_at: now, + }) } - pub async fn check_kyc_status( + /// Fetch a withdrawal record by its internal ID. + pub async fn get_withdrawal_by_id( + &self, + withdrawal_id: &str, + ) -> Result { + let client = self.db_pool.get().await.map_err(|e| { + error!(error = %e, "DB pool error"); + ApiError::InternalServerError + })?; + + let row = client + .query_opt( + r#" + SELECT id, user_id, destination_address, amount, asset, status, + anchor_tx_id, kyc_status, sep24_interactive_url, created_at, updated_at + FROM withdrawals + WHERE id = $1 + "#, + &[&withdrawal_id], + ) + .await + .map_err(|e| { + error!(error = %e, "DB query failed"); + ApiError::InternalServerError + })? + .ok_or_else(|| ApiError::NotFound(format!("Withdrawal {} not found", withdrawal_id)))?; + + Ok(WithdrawalRecord { + id: row.get("id"), + user_id: row.get("user_id"), + destination_address: row.get("destination_address"), + amount: row.get("amount"), + asset: row.get("asset"), + status: row.get("status"), + anchor_tx_id: row.get("anchor_tx_id"), + kyc_status: row.get("kyc_status"), + sep24_interactive_url: row.get("sep24_interactive_url"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } + + /// Update the `status` and optionally `anchor_tx_id` of a withdrawal. + pub async fn update_withdrawal_status( &self, - _user_id: &str, - ) -> Result { - Ok(true) + withdrawal_id: &str, + status: &str, + anchor_tx_id: Option<&str>, + ) -> Result<(), ApiError> { + let client = self.db_pool.get().await.map_err(|e| { + error!(error = %e, "DB pool error"); + ApiError::InternalServerError + })?; + + let now = chrono::Utc::now(); + + client + .execute( + r#" + UPDATE withdrawals + SET status = $1, + anchor_tx_id = COALESCE($2, anchor_tx_id), + updated_at = $3 + WHERE id = $4 + "#, + &[&status, &anchor_tx_id, &now, &withdrawal_id], + ) + .await + .map_err(|e| { + error!(error = %e, "Failed to update withdrawal status"); + ApiError::InternalServerError + })?; + + info!(withdrawal_id, status, "Withdrawal status updated"); + Ok(()) + } + + // ────────────────────────────────────────────────────────────────────────── + // Internal helpers + // ────────────────────────────────────────────────────────────────────────── + + /// Build a minimal SEP-10 bearer token. + /// + /// In production this must be a proper signed JWT containing a challenge + /// from the Anchor's `GET /auth` endpoint. For now we produce a simple + /// JWT-shaped bearer using the configured JWT secret, valid for 5 minutes. + /// Replace this with a full SEP-10 challenge-response flow once you have + /// the Anchor's auth endpoint wired up. + fn build_sep10_token(&self, account: &str) -> Result { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + + #[derive(Serialize)] + struct Sep10Claims<'a> { + sub: &'a str, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now(); + let claims = Sep10Claims { + sub: account, + iat: now.timestamp(), + exp: (now + chrono::Duration::minutes(5)).timestamp(), + }; + + let key = EncodingKey::from_secret(self.config.jwt.secret.as_bytes()); + encode(&Header::new(Algorithm::HS256), &claims, &key).map_err(|e| { + error!(error = %e, "Failed to generate SEP-10 token"); + ApiError::InternalServerError + }) } } diff --git a/backend/src/service/identity_service.rs b/backend/src/service/identity_service.rs index a8f80f1..6dab597 100644 --- a/backend/src/service/identity_service.rs +++ b/backend/src/service/identity_service.rs @@ -38,10 +38,11 @@ impl IdentityService { Ok(User { id: row.get::<_, Uuid>(0).to_string(), user_id: row.get(1), - stellar_address: row.get(2), + stellar_address: row.get::<_, String>(2).clone(), role: Role::from_str(row.get::<_, &str>(3)).unwrap(), created_at: row.get::<_, chrono::DateTime>(4), updated_at: row.get::<_, chrono::DateTime>(5), + address: row.get(2), }) } @@ -59,10 +60,11 @@ impl IdentityService { let user = User { id: row.get::<_, Uuid>(0).to_string(), user_id: row.get(1), - stellar_address: row.get(2), + stellar_address: row.get::<_, String>(2).clone(), role: Role::from_str(row.get::<_, &str>(3)).unwrap(), created_at: row.get::<_, chrono::DateTime>(5), updated_at: row.get::<_, chrono::DateTime>(6), + address: row.get(2), }; let pin_hash: String = row.get(4); @@ -83,10 +85,11 @@ impl IdentityService { Ok(User { id: row.get::<_, Uuid>(0).to_string(), user_id: row.get(1), - stellar_address: row.get(2), + stellar_address: row.get::<_, String>(2).clone(), role: Role::from_str(row.get::<_, &str>(3)).unwrap(), created_at: row.get::<_, chrono::DateTime>(4), updated_at: row.get::<_, chrono::DateTime>(5), + address: row.get(2), }) }