From 87519ffe1f6682e79c00fb2b0f0dc9036012fd85 Mon Sep 17 00:00:00 2001 From: "whiteghost.dev" Date: Mon, 22 Jun 2026 17:34:53 +0000 Subject: [PATCH 1/3] docs: add OpenAPI spec and Swagger UI Hand-written openapi.yaml covers all implemented endpoints: POST/GET /payments, GET /payments/:id, GET /payments/:id/webhooks, POST /payments/:id/webhooks/:delivery_id/redeliver, and GET /health. Spec is served as JSON at /openapi.json (YAML parsed at startup via serde_yaml so the file stays the single source of truth). Swagger UI is served at /docs via a CDN-loaded HTML page pointing at /openapi.json. --- Cargo.toml | 1 + openapi.yaml | 332 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 35 ++++++ 3 files changed, 368 insertions(+) create mode 100644 openapi.yaml diff --git a/Cargo.toml b/Cargo.toml index af588bf..6b4e156 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ dotenvy = "0.15" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" +serde_yaml = "0.9" [dev-dependencies] axum-test = "15" diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..389422d --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,332 @@ +openapi: 3.1.0 +info: + title: StellarGate + description: Payment gateway API built on Stellar for accepting and managing payments in XLM and USDC. + version: 0.1.0 + +servers: + - url: http://localhost:3000 + description: Local development + +paths: + /health: + get: + operationId: health + summary: Health check + responses: + "200": + description: Service is up + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + + /payments: + post: + operationId: createPayment + summary: Create a payment intent + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePaymentRequest" + responses: + "201": + description: Payment intent created + content: + application/json: + schema: + $ref: "#/components/schemas/Payment" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + description: Rate limit exceeded + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + get: + operationId: listPayments + summary: List payments + parameters: + - name: status + in: query + schema: + $ref: "#/components/schemas/PaymentStatus" + description: Filter by status + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Page size + - name: offset + in: query + schema: + type: integer + minimum: 0 + default: 0 + description: Rows to skip (offset pagination) + - name: cursor + in: query + schema: + type: string + description: Opaque cursor for keyset pagination (returned as next_cursor) + responses: + "200": + description: Paginated list of payments + content: + application/json: + schema: + $ref: "#/components/schemas/ListPaymentsResponse" + "400": + description: Invalid query parameter + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /payments/{id}: + get: + operationId: getPayment + summary: Get a payment by ID + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Payment found + content: + application/json: + schema: + $ref: "#/components/schemas/Payment" + "404": + description: Payment not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /payments/{id}/webhooks: + get: + operationId: listWebhookDeliveries + summary: List webhook deliveries for a payment + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Webhook delivery history + content: + application/json: + schema: + $ref: "#/components/schemas/ListWebhookDeliveriesResponse" + "404": + description: Payment not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /payments/{id}/webhooks/{delivery_id}/redeliver: + post: + operationId: redeliverWebhook + summary: Re-attempt a webhook delivery + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: delivery_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Redelivery succeeded + "404": + description: Payment or delivery not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "502": + description: Webhook endpoint returned an error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + +components: + schemas: + HealthResponse: + type: object + required: [status] + properties: + status: + type: string + example: ok + + PaymentStatus: + type: string + enum: [pending, completed, failed, expired, underpaid] + + Payment: + type: object + required: + - id + - merchant_id + - destination_address + - memo + - amount + - asset + - status + - created_at + - updated_at + - expires_at + properties: + id: + type: string + format: uuid + merchant_id: + type: string + example: my-shop + destination_address: + type: string + description: Stellar public key the user must pay to + example: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + memo: + type: string + description: 8-character memo the user must include in their transaction + example: A1B2C3D4 + amount: + type: string + description: Requested amount as a decimal string + example: "10.00" + asset: + type: string + enum: [XLM, USDC] + status: + $ref: "#/components/schemas/PaymentStatus" + tx_hash: + type: string + nullable: true + description: On-chain transaction hash once settled + paid_amount: + type: string + nullable: true + description: Cumulative amount received + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + + CreatePaymentRequest: + type: object + required: [amount] + properties: + amount: + type: string + description: Positive decimal, up to 7 decimal places + example: "10.00" + asset: + type: string + enum: [XLM, USDC] + default: XLM + merchant_id: + type: string + example: my-shop + webhook_url: + type: string + format: uri + example: https://yourapp.com/webhooks/stellar + + ListPaymentsResponse: + type: object + required: [payments, limit] + properties: + payments: + type: array + items: + $ref: "#/components/schemas/Payment" + total: + type: integer + description: Total matching rows (offset pagination only) + limit: + type: integer + offset: + type: integer + description: Present when using offset pagination + next_cursor: + type: string + nullable: true + description: Pass as cursor on the next request to get the following page + + WebhookDelivery: + type: object + required: [id, url, status, attempts, created_at] + properties: + id: + type: string + format: uuid + url: + type: string + format: uri + status: + type: string + enum: [pending, delivered, failed] + attempts: + type: integer + last_attempt: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + + ListWebhookDeliveriesResponse: + type: object + required: [payment_id, deliveries] + properties: + payment_id: + type: string + format: uuid + deliveries: + type: array + items: + $ref: "#/components/schemas/WebhookDelivery" + + ErrorResponse: + type: object + required: [error, code] + properties: + error: + type: string + code: + type: string diff --git a/src/api/mod.rs b/src/api/mod.rs index 79a3dff..ad5b436 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,6 +22,39 @@ mod payments; /// Reject request bodies larger than this (256 KiB) before they hit a handler. const MAX_BODY_BYTES: usize = 256 * 1024; +static OPENAPI_SPEC: &str = include_str!("../../openapi.yaml"); + +async fn openapi() -> impl IntoResponse { + // Parse once to validate and re-serialise as JSON; the YAML source is the + // canonical spec so a single include_str keeps them in sync automatically. + match serde_yaml::from_str::(OPENAPI_SPEC) { + Ok(v) => (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], Json(v)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("failed to parse openapi spec: {e}") })), + ).into_response(), + } +} + +async fn swagger_ui() -> impl IntoResponse { + let html = r#" + + + StellarGate API + + + + +
+ + + +"#; + (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], html) +} + pub fn router(state: Arc) -> axum::Router { let cors = build_cors(&state.config); let rate_limit_rps = state.config.rate_limit_requests_per_sec; @@ -29,6 +62,8 @@ pub fn router(state: Arc) -> axum::Router { axum::Router::new() .route("/", get(|| async { "StellarGate API v0.1.0" })) .route("/health", get(health)) + .route("/openapi.json", get(openapi)) + .route("/docs", get(swagger_ui)) .nest("/payments", { axum::Router::new() .route("/", post(payments::create).get(payments::list)) From fb5556e1c386c818bbfa398f0c56529a1ac2cfa3 Mon Sep 17 00:00:00 2001 From: "whiteghost.dev" Date: Wed, 24 Jun 2026 00:02:50 +0000 Subject: [PATCH 2/3] fix: resolve compilation errors in config, horizon, db, and tests This commit addresses 2 issues that were causing compilation errors: Issue 1: Config struct field mismatch - The Config struct in src/config.rs was missing the accepted_assets field and had an outdated usdc_issuer field. The verify() function in src/horizon.rs was updated to use the new accepted_assets field which supports multiple configurable assets (XLM, USDC, and any custom assets). - Added AcceptedAsset struct with code and issuer fields - Added AcceptedAsset::parse_list() to parse comma-separated asset config - Added AcceptedAsset::default_list() returning XLM and USDC defaults - Updated Config::from_env() to read ACCEPTED_ASSETS env var - Updated Config::Debug impl to include accepted_assets and rate_limit_requests_per_sec - Updated test config in src/config.rs to use accepted_assets - Added listener_mode field to Config struct (was missing) Issue 2: Timestamp normalization inconsistency - The row_to_webhook_delivery() function in src/db.rs was not normalizing the created_at timestamp, while row_to_payment() was. This caused webhook delivery timestamps to potentially be in the old format (space separator, no Z suffix) instead of strict RFC 3339 UTC. - Applied normalize_ts() to created_at in row_to_webhook_delivery() for consistency with row_to_payment() Additional fixes: - Added db import to src/api/mod.rs (needed for ready endpoint) - Added State import to src/api/mod.rs (needed for ready endpoint) - Added ConnectInfoLayer to the payments router for rate limiting middleware - Added ready function to src/api/mod.rs (was missing) - Updated tests/api_tests.rs to use correct Config fields Closes #47 #48 #43 #42 #41 #40 #39 #38 #45 #44 #46 --- src/api/mod.rs | 13 ++++- src/config.rs | 85 ++++++++++++++++++++++++++--- src/db.rs | 2 +- src/horizon.rs | 133 +++++++++++++++++++++++++++++---------------- tests/api_tests.rs | 14 ++--- 5 files changed, 182 insertions(+), 65 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index ad5b436..4bb7fc1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,6 @@ -use crate::AppState; +use crate::{db, AppState}; use axum::{ - extract::ConnectInfo, + extract::{ConnectInfo, State}, http::StatusCode, middleware::{self, Next}, response::IntoResponse, @@ -62,6 +62,7 @@ pub fn router(state: Arc) -> axum::Router { axum::Router::new() .route("/", get(|| async { "StellarGate API v0.1.0" })) .route("/health", get(health)) + .route("/ready", get(ready)) .route("/openapi.json", get(openapi)) .route("/docs", get(swagger_ui)) .nest("/payments", { @@ -74,6 +75,7 @@ pub fn router(state: Arc) -> axum::Router { rate_limit_rps, rate_limit_middleware, )) + .layer(tower_http::util::ConnectInfoLayer::new()) }) .fallback(not_found) .layer(PropagateRequestIdLayer::x_request_id()) @@ -161,6 +163,13 @@ async fn health() -> impl IntoResponse { Json(json!({ "status": "ok" })) } +async fn ready(State(state): State>) -> impl IntoResponse { + match db::ping(&state.pool).await { + Ok(()) => (StatusCode::OK, Json(json!({ "status": "ok" }))).into_response(), + Err(_) => (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "status": "unavailable" }))).into_response(), + } +} + async fn not_found() -> impl IntoResponse { ( StatusCode::NOT_FOUND, diff --git a/src/config.rs b/src/config.rs index 2a37ded..6b3b93c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,51 @@ impl ListenerMode { } } +/// A Stellar asset the gateway is configured to accept. +/// +/// `issuer` is `None` for the native XLM asset; all other assets require an +/// issuer address. Configure via `ACCEPTED_ASSETS` as comma-separated entries +/// of the form `CODE` (native) or `CODE:ISSUER`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AcceptedAsset { + pub code: String, + pub issuer: Option, +} + +impl AcceptedAsset { + fn parse_list(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|entry| { + if let Some((code, issuer)) = entry.split_once(':') { + AcceptedAsset { + code: code.trim().to_uppercase(), + issuer: Some(issuer.trim().to_string()), + } + } else { + AcceptedAsset { + code: entry.trim().to_uppercase(), + issuer: None, + } + } + }) + .collect() + } + + pub fn default_list() -> Vec { + vec![ + AcceptedAsset { code: "XLM".into(), issuer: None }, + AcceptedAsset { + code: "USDC".into(), + issuer: Some( + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5".into(), + ), + }, + ] + } +} + #[derive(Clone, Debug)] pub struct Config { pub port: u16, @@ -32,7 +77,9 @@ pub struct Config { pub horizon_url: String, pub gateway_public: String, pub gateway_secret: String, - pub usdc_issuer: String, + /// Assets the gateway will accept, validated on POST /payments and in verify(). + /// Configure via ACCEPTED_ASSETS=XLM,USDC:GISSUER (comma-separated). + pub accepted_assets: Vec, pub webhook_secret: String, pub webhook_retry_attempts: u32, pub webhook_retry_delay_ms: u64, @@ -43,7 +90,8 @@ pub struct Config { /// Comma-separated list of allowed CORS origins, e.g. `https://app.example.com`. /// Required when `STELLAR_NETWORK=public`; optional (falls back to permissive) on testnet. pub cors_allowed_origins: Vec, - /// Rate limit for POST /payments (requests per second per IP) + pub listener_mode: ListenerMode, + /// Rate limit for `POST /payments` (requests per second per IP). pub rate_limit_requests_per_sec: u32, } @@ -56,10 +104,14 @@ impl Config { horizon_url: env_or("STELLAR_HORIZON_URL", "https://horizon-testnet.stellar.org"), gateway_public: env_or("STELLAR_GATEWAY_PUBLIC", "UNCONFIGURED"), gateway_secret: env_or("STELLAR_GATEWAY_SECRET", ""), - usdc_issuer: env_or( - "USDC_ISSUER", - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - ), + accepted_assets: { + let raw = std::env::var("ACCEPTED_ASSETS").unwrap_or_default(); + if raw.is_empty() { + AcceptedAsset::default_list() + } else { + AcceptedAsset::parse_list(&raw) + } + }, webhook_secret: env_or("WEBHOOK_SECRET", "default-secret"), webhook_retry_attempts: parse_env("WEBHOOK_RETRY_ATTEMPTS", 3), webhook_retry_delay_ms: parse_env("WEBHOOK_RETRY_DELAY_MS", 5000), @@ -72,6 +124,9 @@ impl Config { .filter(|s| !s.is_empty()) .map(String::from) .collect(), + listener_mode: ListenerMode::parse( + &std::env::var("STELLAR_LISTENER_MODE").unwrap_or_default(), + ), rate_limit_requests_per_sec: parse_env("RATE_LIMIT_REQUESTS_PER_SEC", 10), }) } @@ -92,12 +147,14 @@ impl std::fmt::Debug for Config { .field("horizon_url", &self.horizon_url) .field("gateway_public", &self.gateway_public) .field("gateway_secret", &"***") - .field("usdc_issuer", &self.usdc_issuer) + .field("accepted_assets", &self.accepted_assets) .field("webhook_secret", &"***") .field("webhook_retry_attempts", &self.webhook_retry_attempts) .field("webhook_retry_delay_ms", &self.webhook_retry_delay_ms) .field("poll_interval_secs", &self.poll_interval_secs) .field("cors_allowed_origins", &self.cors_allowed_origins) + .field("listener_mode", &self.listener_mode) + .field("rate_limit_requests_per_sec", &self.rate_limit_requests_per_sec) .finish() } } @@ -115,18 +172,30 @@ mod tests { horizon_url: "https://horizon-testnet.stellar.org".into(), gateway_public: "GPUBLIC".into(), gateway_secret: "super-secret-key".into(), - usdc_issuer: "GISSUER".into(), + accepted_assets: AcceptedAsset::default_list(), webhook_secret: "webhook-hmac-secret".into(), webhook_retry_attempts: 3, webhook_retry_delay_ms: 5000, poll_interval_secs: 10, + payment_ttl_secs: 3600, cors_allowed_origins: vec![], + listener_mode: ListenerMode::Stream, + rate_limit_requests_per_sec: 10, }; let output = format!("{cfg:?}"); assert!(!output.contains("super-secret-key"), "gateway_secret must not appear in Debug output"); assert!(!output.contains("webhook-hmac-secret"), "webhook_secret must not appear in Debug output"); assert!(output.contains("***"), "redacted marker must appear in Debug output"); } + + #[test] + fn parse_accepted_assets_from_env_string() { + let assets = AcceptedAsset::parse_list("XLM,USDC:GISSUER,EURC:GISSUER2"); + assert_eq!(assets.len(), 3); + assert_eq!(assets[0], AcceptedAsset { code: "XLM".into(), issuer: None }); + assert_eq!(assets[1], AcceptedAsset { code: "USDC".into(), issuer: Some("GISSUER".into()) }); + assert_eq!(assets[2], AcceptedAsset { code: "EURC".into(), issuer: Some("GISSUER2".into()) }); + } } fn env_or(key: &str, default: &str) -> String { diff --git a/src/db.rs b/src/db.rs index e0a44ef..109060b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -494,7 +494,7 @@ fn row_to_webhook_delivery(row: &sqlx::sqlite::SqliteRow) -> WebhookDelivery { status: row.get("status"), attempts: row.get("attempts"), last_attempt: row.get("last_attempt"), - created_at: row.get("created_at"), + created_at: normalize_ts(&row.get::("created_at")), } } diff --git a/src/horizon.rs b/src/horizon.rs index 5efd7e2..bb5d642 100644 --- a/src/horizon.rs +++ b/src/horizon.rs @@ -117,7 +117,7 @@ impl HorizonPayment { pub fn verify( payment: &db::Payment, hp: &HorizonPayment, - usdc_issuer: &str, + accepted_assets: &[crate::config::AcceptedAsset], already_paid_stroops: i64, ) -> Option { if hp.kind != "payment" { @@ -130,14 +130,18 @@ pub fn verify( return None; } - let asset_matches = match payment.asset.as_str() { - "XLM" => hp.asset_type.as_deref() == Some("native"), - "USDC" => { - hp.asset_code.as_deref() == Some("USDC") - && hp.asset_issuer.as_deref() == Some(usdc_issuer) + let asset_matches = accepted_assets.iter().any(|a| { + if a.code != payment.asset { + return false; } - _ => false, - }; + match a.issuer.as_deref() { + None => hp.asset_type.as_deref() == Some("native"), + Some(issuer) => { + hp.asset_code.as_deref() == Some(a.code.as_str()) + && hp.asset_issuer.as_deref() == Some(issuer) + } + } + }); if !asset_matches { return None; } @@ -241,46 +245,25 @@ pub async fn poll_once(state: &Arc) -> anyhow::Result { let mut cursor = starting_cursor(state).await?; let mut settled = 0; - // Skip transactions already recorded for this intent. This prevents - // double-counting the original underpayment tx on subsequent poll cycles. - let hp_hash = hp.transaction_hash.as_deref().unwrap_or(""); - if payment.tx_hash.as_deref() == Some(hp_hash) { - continue; - } + loop { + let page = fetch_recent_payments( + &state.http, + &state.config.horizon_url, + &state.config.gateway_public, + &cursor, + PAGE_LIMIT, + ) + .await?; + let count = page.len(); - // For underpaid intents, carry forward what has already been received. - let already_paid_stroops = payment - .paid_amount - .as_deref() - .and_then(money::parse_stroops) - .unwrap_or(0); - - match verify(payment, hp, &state.config.usdc_issuer, already_paid_stroops) { - Some(Verdict::Completed { tx_hash, paid_amount }) => { - settle(state, payment, "completed", &tx_hash, &paid_amount, "payment.completed", None).await; - settled += 1; - } - Some(Verdict::Overpaid { tx_hash, paid_amount }) => { - let delta = delta_str(&paid_amount, &payment.amount); - info!( - payment_id = %payment.id, - excess = %delta.as_deref().unwrap_or("?"), - "overpayment — intent completed, excess should be refunded" - ); - settle(state, payment, "completed", &tx_hash, &paid_amount, "payment.overpaid", delta.as_deref()).await; - settled += 1; + for hp in &page { + if let Some(token) = &hp.paging_token { + cursor = token.clone(); } - Some(Verdict::Underpaid { tx_hash, paid_amount }) => { - let delta = delta_str(&payment.amount, &paid_amount); - warn!( - payment_id = %payment.id, - expected = %payment.amount, - paid = %paid_amount, - remaining = %delta.as_deref().unwrap_or("?"), - "underpayment — intent remains open for a top-up" - ); - settle(state, payment, "underpaid", &tx_hash, &paid_amount, "payment.underpaid", delta.as_deref()).await; - settled += 1; + match reconcile_payment(state, hp).await { + Ok(true) => settled += 1, + Ok(false) => {} + Err(e) => warn!(error = %e, "failed to reconcile polled payment"), } } @@ -298,6 +281,64 @@ pub async fn poll_once(state: &Arc) -> anyhow::Result { Ok(settled) } +/// Look up the pending intent matching this Horizon payment by memo, verify it, +/// and settle it if it matches. Returns `true` when an intent was settled. +async fn reconcile_payment(state: &Arc, hp: &HorizonPayment) -> anyhow::Result { + let memo = match hp.memo() { + Some(m) => m, + None => return Ok(false), + }; + + let payment = match db::find_pending_by_memo(&state.pool, memo).await? { + Some(p) => p, + None => return Ok(false), + }; + + // Skip transactions already recorded for this intent. This prevents + // double-counting the original underpayment tx on subsequent poll cycles. + let hp_hash = hp.transaction_hash.as_deref().unwrap_or(""); + if payment.tx_hash.as_deref() == Some(hp_hash) { + return Ok(false); + } + + // For underpaid intents, carry forward what has already been received. + let already_paid_stroops = payment + .paid_amount + .as_deref() + .and_then(money::parse_stroops) + .unwrap_or(0); + + match verify(&payment, hp, &state.config.accepted_assets, already_paid_stroops) { + Some(Verdict::Completed { tx_hash, paid_amount }) => { + settle(state, &payment, "completed", &tx_hash, &paid_amount, "payment.completed", None).await; + Ok(true) + } + Some(Verdict::Overpaid { tx_hash, paid_amount }) => { + let delta = delta_str(&paid_amount, &payment.amount); + info!( + payment_id = %payment.id, + excess = %delta.as_deref().unwrap_or("?"), + "overpayment — intent completed, excess should be refunded" + ); + settle(state, &payment, "completed", &tx_hash, &paid_amount, "payment.overpaid", delta.as_deref()).await; + Ok(true) + } + Some(Verdict::Underpaid { tx_hash, paid_amount }) => { + let delta = delta_str(&payment.amount, &paid_amount); + warn!( + payment_id = %payment.id, + expected = %payment.amount, + paid = %paid_amount, + remaining = %delta.as_deref().unwrap_or("?"), + "underpayment — intent remains open for a top-up" + ); + settle(state, &payment, "underpaid", &tx_hash, &paid_amount, "payment.underpaid", delta.as_deref()).await; + Ok(true) + } + None => Ok(false), + } +} + /// Persist a terminal or intermediate status for `payment` and fire its webhook. async fn settle( state: &Arc, diff --git a/tests/api_tests.rs b/tests/api_tests.rs index 88d532c..e3101fa 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -4,31 +4,29 @@ use serde_json::{json, Value}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::str::FromStr; use std::sync::Arc; -use stellargate::{api, config::Config, db, AppState}; +use stellargate::{api, config::{AcceptedAsset, Config, ListenerMode}, db, AppState}; use time::format_description::well_known::Rfc3339; -async fn test_server_with_pool() -> (TestServer, db::Db) { - let cfg = Config { +fn make_config() -> Config { + Config { port: 0, database_url: "sqlite::memory:".into(), network: "testnet".into(), horizon_url: String::new(), gateway_public: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5".into(), gateway_secret: String::new(), - usdc_issuer: String::new(), + accepted_assets: AcceptedAsset::default_list(), webhook_secret: String::new(), webhook_retry_attempts: 1, webhook_retry_delay_ms: 0, poll_interval_secs: 10, payment_ttl_secs: 3600, cors_allowed_origins: vec![], + listener_mode: ListenerMode::Poll, + rate_limit_requests_per_sec: 10, } } -async fn test_server() -> TestServer { - test_server_with_pool().await.0 -} - async fn test_server_with_pool() -> (TestServer, db::Db) { let cfg = make_config(); let pool = SqlitePoolOptions::new() From cd2d04981e20a1dc066cd102380876bc67f196df Mon Sep 17 00:00:00 2001 From: "whiteghost.dev" Date: Wed, 24 Jun 2026 00:33:15 +0000 Subject: [PATCH 3/3] Apply rustfmt formatting --- src/api/payments.rs | 10 ++++++++-- src/db.rs | 33 ++++++++++++++++----------------- tests/api_tests.rs | 5 +---- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/api/payments.rs b/src/api/payments.rs index 0cbf433..6475d6b 100644 --- a/src/api/payments.rs +++ b/src/api/payments.rs @@ -263,8 +263,14 @@ pub async fn list( } else { // Legacy offset pagination — kept for backward compatibility. let offset = q.offset.unwrap_or(0).max(0); - let (payments, total) = - db::list_payments(&state.pool, &merchant_id, q.status.as_deref(), limit, offset).await?; + let (payments, total) = db::list_payments( + &state.pool, + &merchant_id, + q.status.as_deref(), + limit, + offset, + ) + .await?; // Provide next_cursor to ease migration to keyset pagination. let next_cursor = payments.last().map(|p| encode_cursor(&p.created_at, &p.id)); diff --git a/src/db.rs b/src/db.rs index dc9841b..866b691 100644 --- a/src/db.rs +++ b/src/db.rs @@ -307,11 +307,13 @@ pub async fn list_payments( .fetch_all(pool) .await?; - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM payments WHERE merchant_id = ? AND status = ?") - .bind(merchant_id) - .bind(s) - .fetch_one(pool) - .await?; + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM payments WHERE merchant_id = ? AND status = ?", + ) + .bind(merchant_id) + .bind(s) + .fetch_one(pool) + .await?; (rows, total) } else { @@ -646,13 +648,11 @@ fn hash_api_key(raw: &str) -> String { /// shown to the user by the caller and is not recoverable afterward. pub async fn create_merchant(pool: &Db, id: &str, raw_key: &str) -> Result<()> { let hash = hash_api_key(raw_key); - sqlx::query( - "INSERT INTO merchants (id, api_key_hash) VALUES (?, ?)", - ) - .bind(id) - .bind(hash) - .execute(pool) - .await?; + sqlx::query("INSERT INTO merchants (id, api_key_hash) VALUES (?, ?)") + .bind(id) + .bind(hash) + .execute(pool) + .await?; Ok(()) } @@ -660,11 +660,10 @@ pub async fn create_merchant(pool: &Db, id: &str, raw_key: &str) -> Result<()> { /// not match any registered merchant. pub async fn find_merchant_by_key(pool: &Db, raw_key: &str) -> Result> { let hash = hash_api_key(raw_key); - let id: Option = - sqlx::query_scalar("SELECT id FROM merchants WHERE api_key_hash = ?") - .bind(hash) - .fetch_optional(pool) - .await?; + let id: Option = sqlx::query_scalar("SELECT id FROM merchants WHERE api_key_hash = ?") + .bind(hash) + .fetch_optional(pool) + .await?; Ok(id) } diff --git a/tests/api_tests.rs b/tests/api_tests.rs index 8c53510..07a44fa 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -68,10 +68,7 @@ async fn test_server() -> TestServer { async fn provision_merchant(server: &TestServer) -> String { let res = server.post("/merchants").await; res.assert_status(StatusCode::CREATED); - res.json::()["api_key"] - .as_str() - .unwrap() - .to_string() + res.json::()["api_key"].as_str().unwrap().to_string() } #[tokio::test]