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
1 change: 1 addition & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -86,4 +87,4 @@ httpmock = "0.7"
# Binaries
[[bin]]
name = "new_migration"
path = "src/bin/new_migration.rs"
path = "src/bin/new_migration.rs"
10 changes: 10 additions & 0 deletions backend/migrations/20260221000000_anchor_kyc.sql
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions backend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
142 changes: 142 additions & 0 deletions backend/src/http/anchor.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[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<Arc<ServiceContainer>>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, Json<WebhookAck>), 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<ServiceContainer>,
anchor_tx_id: &str,
) -> Option<String> {
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"))
}
2 changes: 2 additions & 0 deletions backend/src/http/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod admin;
pub mod anchor;
pub mod audit;
pub mod auth;
pub mod files;
Expand All @@ -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::*;
Expand Down
Loading