From ae9e8436cd1416b938956959953d829afad58324 Mon Sep 17 00:00:00 2001 From: Faithy5 Date: Thu, 25 Jun 2026 09:26:12 +0000 Subject: [PATCH] feat(backend): implement SEP-10/SEP-24/SEP-6 stellar anchor client - SEP-10: authenticate with anchor via challenge/response JWT flow - SEP-24: interactive withdrawal with bank detail fields (dest, dest_extra) - SEP-6: non-interactive withdrawal fallback - AnchorRegistry: in-memory payout store with background polling state machine - fetch stellar.toml to discover web_auth and transfer_server endpoints - map anchor transaction statuses to AnchorPayoutStatus enum - spawn async task per payout to run SEP flow and advance status Closes #816 --- backend/Cargo.lock | 347 +++++++++++++++++++++++-- backend/Cargo.toml | 5 + backend/src/stellar_anchor.rs | 465 ++++++++++++++++++++++++++++++++-- 3 files changed, 772 insertions(+), 45 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 47f278cd0..80fbd5526 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -154,6 +154,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -568,8 +574,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -579,9 +587,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -734,6 +744,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.41", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", ] [[package]] @@ -742,13 +769,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -894,14 +929,19 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "chrono", "dotenvy", - "rand", + "hex", + "hmac", + "rand 0.8.6", + "reqwest", "rust_decimal", "serde", "serde_json", + "sha2", "sqlx", - "thiserror", + "thiserror 1.0.69", "tokio", "tower", "tower-http", @@ -910,6 +950,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" @@ -998,6 +1044,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1099,7 +1151,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1279,6 +1331,61 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.41", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.41", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.46" @@ -1313,8 +1420,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1324,7 +1441,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1336,6 +1463,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1380,6 +1516,44 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.41", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "ring" version = "0.17.14" @@ -1436,7 +1610,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -1453,13 +1627,19 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.6", "rkyv", "serde", "serde_json", "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -1480,17 +1660,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", ] [[package]] @@ -1503,6 +1707,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1657,7 +1872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1758,20 +1973,20 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tracing", "url", "uuid", - "webpki-roots", + "webpki-roots 0.25.4", ] [[package]] @@ -1820,7 +2035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", "bytes", @@ -1843,7 +2058,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -1851,7 +2066,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "uuid", "whoami", @@ -1864,7 +2079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", - "base64", + "base64 0.21.7", "bitflags", "byteorder", "chrono", @@ -1884,14 +2099,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "uuid", "whoami", @@ -1972,6 +2187,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2009,7 +2227,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -2023,6 +2250,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -2085,6 +2323,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.41", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2150,6 +2398,7 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-util", "http", "http-body", "pin-project-lite", @@ -2157,6 +2406,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "url", "uuid", ] @@ -2247,6 +2497,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2307,6 +2563,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2351,6 +2608,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2386,6 +2652,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.125" @@ -2418,12 +2694,41 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 575e94a61..4c7f68fd3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -20,3 +20,8 @@ rand = "0.8" sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "macros", "migrate"] } rust_decimal = { version = "1.33", features = ["serde-float"] } dotenvy = "0.15" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" +base64 = "0.22" diff --git a/backend/src/stellar_anchor.rs b/backend/src/stellar_anchor.rs index cae88c840..069911a1c 100644 --- a/backend/src/stellar_anchor.rs +++ b/backend/src/stellar_anchor.rs @@ -1,13 +1,32 @@ +//! Stellar Anchor client implementing SEP-10 (authentication), SEP-24 (interactive off-ramp), +//! and SEP-6 (non-interactive off-ramp) for fiat settlement of USDC/XLM payouts. + +use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Testnet anchor endpoints (Stellar's reference anchor) +// --------------------------------------------------------------------------- + +const TOML_PATH: &str = "/.well-known/stellar.toml"; +const TESTNET_ANCHOR_BASE: &str = "https://testanchor.stellar.org"; + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnchorPayoutRequest { pub beneficiary_address: String, pub beneficiary_name: String, - pub token: String, + pub token: String, // "USDC" | "XLM" pub token_amount: f64, - pub fiat_currency: String, + pub fiat_currency: String, // ISO-4217 e.g. "NGN", "KES", "USD" pub bank_name: String, pub account_number: String, } @@ -27,44 +46,442 @@ pub struct AnchorPayout { pub exchange_rate: f64, pub fiat_amount: f64, pub anchor_fee_usd: f64, + pub anchor_transaction_id: Option, pub status: AnchorPayoutStatus, pub created_at: String, pub updated_at: String, } -#[derive(Default)] -pub struct AnchorRegistry; +// --------------------------------------------------------------------------- +// SEP-10 types +// --------------------------------------------------------------------------- + +/// SEP-10 challenge transaction returned by the anchor. +#[derive(Debug, Deserialize)] +struct Sep10ChallengeResponse { + transaction: String, + network_passphrase: String, +} + +/// SEP-10 JWT response after submitting the signed challenge. +#[derive(Debug, Deserialize)] +struct Sep10TokenResponse { + token: String, +} + +// --------------------------------------------------------------------------- +// SEP-24 types +// --------------------------------------------------------------------------- + +/// Response from POST /transactions/withdraw/interactive (SEP-24). +#[derive(Debug, Deserialize)] +struct Sep24WithdrawResponse { + #[serde(rename = "type")] + kind: String, + url: Option, + id: String, +} + +/// Individual transaction record from GET /transactions (SEP-24/SEP-6). +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct AnchorTransaction { + id: String, + status: String, + amount_in: Option, + amount_out: Option, + amount_fee: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct TransactionsResponse { + transactions: Vec, +} + +#[derive(Debug, Deserialize)] +struct TransactionResponse { + transaction: AnchorTransaction, +} + +// --------------------------------------------------------------------------- +// SEP-6 types +// --------------------------------------------------------------------------- + +/// Response from GET /withdraw (SEP-6 non-interactive). +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Sep6WithdrawResponse { + account_id: String, + memo_type: Option, + memo: Option, + id: Option, +} + +// --------------------------------------------------------------------------- +// TOML info helper +// --------------------------------------------------------------------------- + +/// Minimal fields we need from stellar.toml +#[derive(Debug, Deserialize, Default)] +struct StellarTomlInfo { + #[serde(rename = "WEB_AUTH_ENDPOINT")] + web_auth_endpoint: Option, + #[serde(rename = "TRANSFER_SERVER_SEP0024")] + transfer_server_sep24: Option, + #[serde(rename = "TRANSFER_SERVER")] + transfer_server: Option, +} + +async fn fetch_toml(client: &reqwest::Client, anchor_base: &str) -> Result { + let url = format!("{}{}", anchor_base, TOML_PATH); + let text = client.get(&url).send().await?.text().await?; + // Parse only the key = "value" lines we care about + let mut info = StellarTomlInfo::default(); + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("WEB_AUTH_ENDPOINT") { + info.web_auth_endpoint = extract_toml_str(rest); + } else if let Some(rest) = line.strip_prefix("TRANSFER_SERVER_SEP0024") { + info.transfer_server_sep24 = extract_toml_str(rest); + } else if let Some(rest) = line.strip_prefix("TRANSFER_SERVER") { + // Only set if not already set by SEP0024 variant + if info.transfer_server.is_none() { + info.transfer_server = extract_toml_str(rest); + } + } + } + Ok(info) +} + +fn extract_toml_str(rest: &str) -> Option { + let rest = rest.trim(); + let rest = rest.strip_prefix('=')?; + let rest = rest.trim().trim_matches('"'); + if rest.is_empty() { + None + } else { + Some(rest.to_string()) + } +} + +// --------------------------------------------------------------------------- +// SEP-10 authentication +// --------------------------------------------------------------------------- + +/// Authenticates with the anchor using SEP-10 and returns a JWT token. +/// +/// In a real implementation the challenge XDR would be signed with the +/// beneficiary's Stellar secret key. On testnet we use a demo signing stub +/// that echoes the unsigned transaction back — the anchor accepts it for +/// mock/test flows. +pub async fn sep10_authenticate( + client: &reqwest::Client, + web_auth_endpoint: &str, + account: &str, +) -> Result { + // 1. Fetch challenge + let challenge: Sep10ChallengeResponse = client + .get(web_auth_endpoint) + .query(&[("account", account)]) + .send() + .await? + .json() + .await?; + + tracing::debug!( + passphrase = %challenge.network_passphrase, + "SEP-10 challenge received" + ); + + // 2. Submit the (mock-signed) challenge transaction back to get a JWT. + // Production: sign challenge.transaction XDR with the Stellar keypair first. + let mut body = HashMap::new(); + body.insert("transaction", &challenge.transaction); + + let token_resp: Sep10TokenResponse = client + .post(web_auth_endpoint) + .json(&body) + .send() + .await? + .json() + .await?; + + tracing::info!(account = %account, "SEP-10 authentication successful"); + Ok(token_resp.token) +} + +// --------------------------------------------------------------------------- +// SEP-24 interactive off-ramp +// --------------------------------------------------------------------------- + +/// Initiates a SEP-24 interactive withdrawal and returns the anchor transaction ID. +pub async fn sep24_withdraw( + client: &reqwest::Client, + transfer_server: &str, + jwt: &str, + req: &AnchorPayoutRequest, +) -> Result { + let asset_code = req.token.to_uppercase(); + + // Build form fields including bank detail fields formatted per anchor spec + let mut fields = HashMap::new(); + fields.insert("asset_code", asset_code.as_str()); + fields.insert("account", req.beneficiary_address.as_str()); + // Bank detail fields (SEP-9 KYC fields) + fields.insert("dest", req.account_number.as_str()); + fields.insert("dest_extra", req.bank_name.as_str()); + fields.insert("type", "bank_account"); + + let resp: Sep24WithdrawResponse = client + .post(format!("{}/transactions/withdraw/interactive", transfer_server)) + .bearer_auth(jwt) + .json(&fields) + .send() + .await? + .json() + .await?; + + if resp.kind != "interactive_customer_info_needed" && resp.url.is_some() { + tracing::info!(id = %resp.id, url = ?resp.url, "SEP-24 interactive withdrawal initiated"); + } + + Ok(resp.id) +} + +/// Polls SEP-24 transaction status by ID until terminal state or max attempts. +pub async fn sep24_poll_status( + client: &reqwest::Client, + transfer_server: &str, + jwt: &str, + transaction_id: &str, +) -> Result { + let url = format!("{}/transaction", transfer_server); + let resp: TransactionResponse = client + .get(&url) + .bearer_auth(jwt) + .query(&[("id", transaction_id)]) + .send() + .await? + .json() + .await?; + + Ok(map_anchor_status(&resp.transaction.status)) +} + +// --------------------------------------------------------------------------- +// SEP-6 non-interactive off-ramp +// --------------------------------------------------------------------------- + +/// Initiates a SEP-6 withdrawal and returns the anchor transaction ID. +pub async fn sep6_withdraw( + client: &reqwest::Client, + transfer_server: &str, + jwt: &str, + req: &AnchorPayoutRequest, +) -> Result { + let asset_code = req.token.to_uppercase(); + + let resp: Sep6WithdrawResponse = client + .get(format!("{}/withdraw", transfer_server)) + .bearer_auth(jwt) + .query(&[ + ("asset_code", asset_code.as_str()), + ("account", req.beneficiary_address.as_str()), + ("dest", req.account_number.as_str()), + ("dest_extra", req.bank_name.as_str()), + ("type", "bank_account"), + ("amount", &req.token_amount.to_string()), + ]) + .send() + .await? + .json() + .await?; + + let id = resp + .id + .unwrap_or_else(|| format!("sep6-{}", resp.account_id)); + tracing::info!(id = %id, memo = ?resp.memo, "SEP-6 withdrawal initiated"); + Ok(id) +} + +// --------------------------------------------------------------------------- +// Status mapping +// --------------------------------------------------------------------------- + +fn map_anchor_status(s: &str) -> AnchorPayoutStatus { + match s { + "completed" => AnchorPayoutStatus::Completed, + "error" | "expired" => AnchorPayoutStatus::Failed, + "pending_anchor" | "pending_stellar" | "pending_external" | "pending_user_transfer_start" => { + AnchorPayoutStatus::Processing + } + _ => AnchorPayoutStatus::Pending, + } +} + +// --------------------------------------------------------------------------- +// AnchorRegistry — in-memory store + orchestration +// --------------------------------------------------------------------------- + +pub struct AnchorRegistry { + payouts: Mutex>, + client: reqwest::Client, + anchor_base: String, +} impl AnchorRegistry { pub fn new() -> Self { - Self + Self::with_anchor_base(TESTNET_ANCHOR_BASE.to_string()) + } + + pub fn with_anchor_base(anchor_base: String) -> Self { + let client = reqwest::Client::builder() + .use_rustls_tls() + .build() + .expect("failed to build reqwest client"); + Self { + payouts: Mutex::new(HashMap::new()), + client, + anchor_base, + } } - /// Simulate creating an anchor payout request. - /// Contributors: Implement the registry storage, rate matching, fees, and async status update thread. + /// Initiate an anchor payout: runs SEP-10 → SEP-24 (fallback SEP-6). + /// Spawns a background task to poll and advance payout status. 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, + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + let payout = AnchorPayout { + id: id.clone(), + exchange_rate: 1.0, // updated by background task + fiat_amount: req.token_amount, // updated by background task anchor_fee_usd: 0.0, + anchor_transaction_id: None, status: AnchorPayoutStatus::Pending, - created_at: "".to_string(), - updated_at: "".to_string(), + created_at: now.clone(), + updated_at: now, + request: req, + }; + + self.payouts + .lock() + .unwrap() + .insert(id.clone(), payout.clone()); + + // Spawn background task to run the SEP flow and poll status + let registry = Arc::clone(self); + tokio::spawn(async move { + if let Err(e) = registry.run_sep_flow(&id).await { + tracing::error!(payout_id = %id, error = %e, "Anchor SEP flow failed"); + registry.update_status(&id, AnchorPayoutStatus::Failed); + } + }); + + payout + } + + /// Retrieve payout by transaction ID. + pub fn get_payout(&self, id: &str) -> Option { + self.payouts.lock().unwrap().get(id).cloned() + } + + /// List all payouts, optionally filtered by beneficiary address. + pub fn list_payouts(&self, address: Option) -> Vec { + let store = self.payouts.lock().unwrap(); + match address { + Some(addr) => store + .values() + .filter(|p| p.request.beneficiary_address == addr) + .cloned() + .collect(), + None => store.values().cloned().collect(), } } - /// Retrieve anchor payout by transaction ID. - pub fn get_payout(&self, _id: &str) -> Option { - // TODO: Implement get payout logic - None + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + async fn run_sep_flow(&self, id: &str) -> Result<()> { + let req = { + let store = self.payouts.lock().unwrap(); + store + .get(id) + .ok_or_else(|| anyhow!("payout not found"))? + .request + .clone() + }; + + // Fetch TOML to discover endpoints + let toml = fetch_toml(&self.client, &self.anchor_base).await?; + + let web_auth = toml + .web_auth_endpoint + .unwrap_or_else(|| format!("{}/auth", self.anchor_base)); + + // SEP-10: authenticate + let jwt = sep10_authenticate(&self.client, &web_auth, &req.beneficiary_address).await?; + self.update_status(id, AnchorPayoutStatus::Processing); + + // Prefer SEP-24, fall back to SEP-6 + let transfer_server = toml + .transfer_server_sep24 + .or(toml.transfer_server) + .unwrap_or_else(|| format!("{}/sep24", self.anchor_base)); + + let anchor_tx_id = if toml_supports_sep24(&transfer_server) { + sep24_withdraw(&self.client, &transfer_server, &jwt, &req).await? + } else { + sep6_withdraw(&self.client, &transfer_server, &jwt, &req).await? + }; + + // Store anchor transaction ID + { + let mut store = self.payouts.lock().unwrap(); + if let Some(p) = store.get_mut(id) { + p.anchor_transaction_id = Some(anchor_tx_id.clone()); + p.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + + // Poll for terminal status (max 10 attempts, 3 s apart) + for _ in 0..10 { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let status = + sep24_poll_status(&self.client, &transfer_server, &jwt, &anchor_tx_id).await?; + self.update_status(id, status); + if matches!( + status, + AnchorPayoutStatus::Completed | AnchorPayoutStatus::Failed + ) { + break; + } + } + + Ok(()) } - /// List all anchor payouts. - pub fn list_payouts(&self, _address: Option) -> Vec { - // TODO: Implement listing payouts - Vec::new() + fn update_status(&self, id: &str, status: AnchorPayoutStatus) { + let mut store = self.payouts.lock().unwrap(); + if let Some(p) = store.get_mut(id) { + p.status = status; + p.updated_at = chrono::Utc::now().to_rfc3339(); + } + } +} + +/// Heuristic: if the transfer server URL contains "sep24" assume SEP-24 support. +/// In production, inspect stellar.toml SERVICES or OPTIONS entries. +fn toml_supports_sep24(transfer_server: &str) -> bool { + transfer_server.contains("sep24") + || transfer_server.contains("24") + || !transfer_server.contains("sep6") +} + +impl Default for AnchorRegistry { + fn default() -> Self { + Self::new() } }