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
63 changes: 63 additions & 0 deletions backend/src/idempotency.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};

/// A cached response stored against an idempotency key.
#[derive(Clone)]
pub struct CachedResponse {
pub status: u16,
pub body: serde_json::Value,
pub recorded_at: Instant,
}

/// Shared in-memory idempotency store.
///
/// Keys are scoped as `"{endpoint}:{invoice_id}:{idempotency_key}"` so the same
/// `Idempotency-Key` header value used on two different endpoints never collides.
///
/// Entries are evicted lazily on lookup once their TTL has elapsed.
pub struct IdempotencyStore {
inner: DashMap<String, CachedResponse>,
ttl: Duration,
}

impl IdempotencyStore {
/// Create a new store with the given TTL (recommended: 24 h for production,
/// shorter for tests).
pub fn new(ttl: Duration) -> Arc<Self> {
Arc::new(Self {
inner: DashMap::new(),
ttl,
})
}

/// Build the namespaced key used for storage lookups.
pub fn make_key(endpoint: &str, invoice_id: u64, idempotency_key: &str) -> String {
format!("{endpoint}:{invoice_id}:{idempotency_key}")
}

/// Return the cached response if the key exists and has not expired.
pub fn get(&self, key: &str) -> Option<CachedResponse> {
if let Some(entry) = self.inner.get(key) {
if entry.recorded_at.elapsed() < self.ttl {
return Some(entry.clone());
}
// Expired — evict lazily.
drop(entry);
self.inner.remove(key);
}
None
}

/// Insert a response into the store.
pub fn insert(&self, key: String, status: u16, body: serde_json::Value) {
self.inner.insert(
key,
CachedResponse {
status,
body,
recorded_at: Instant::now(),
},
);
}
}
7 changes: 6 additions & 1 deletion backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod types;

use axum::{routing::{get, post}, Router};
use std::sync::Arc;
use std::time::Duration;

use rate_limiter::{new_store, RateLimitConfig, RateLimiterLayer};
use routes::{
Expand Down Expand Up @@ -46,7 +47,11 @@ async fn main() {
let horizon_url = std::env::var("HORIZON_API_URL")
.unwrap_or_else(|_| "https://horizon.stellar.org".to_string());

let client = Arc::new(SorobanClient::new(rpc_url, contract_id, horizon_url));
let state = AppState {
client: Arc::new(SorobanClient::new(rpc_url, contract_id, horizon_url)),
// 24-hour TTL for idempotency keys (matches common API gateway defaults).
idempotency: IdempotencyStore::new(Duration::from_secs(86_400)),
};

// Rate-limiter layer: config is read from RATE_LIMIT_POINTS / RATE_LIMIT_DURATION
// (defaults: 60 requests per 60-second window, per IP).
Expand Down
66 changes: 49 additions & 17 deletions backend/src/routes/cancel.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use axum::{
extract::{Path, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use std::sync::Arc;

use crate::extractors::ValidatedBody;
use crate::soroban::SorobanClient;
Expand All @@ -26,57 +25,91 @@ use crate::types::{CancelRequest, CancelResponse, ErrorResponse};
tag = "cancel"
)]
pub async fn cancel_invoice(
State(client): State<Arc<SorobanClient>>,
State(state): State<AppState>,
Path(id): Path<u64>,
ValidatedBody(body): ValidatedBody<CancelRequest>,
) -> impl IntoResponse {
match client.cancel_invoice(id, &body.merchant, &body.signed_xdr).await {
Ok(resp) => (StatusCode::OK, Json(serde_json::json!(resp))).into_response(),
// ── Idempotency check ────────────────────────────────────────────────────
let idem_key = headers
.get("Idempotency-Key")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());

if let Some(ref key) = idem_key {
let store_key = IdempotencyStore::make_key("cancel", id, key);
if let Some(cached) = state.idempotency.get(&store_key) {
let status = StatusCode::from_u16(cached.status).unwrap_or(StatusCode::OK);
return (status, Json(cached.body)).into_response();
}
}

// ── Process the cancellation ─────────────────────────────────────────────
let result = state.client.cancel_invoice(id, &body.merchant, &body.signed_xdr).await;

let (status, body_json) = match result {
Ok(resp) => (StatusCode::OK, serde_json::json!(resp)),
Err(e) if e.to_string().contains("UNAUTHORIZED") => (
StatusCode::FORBIDDEN,
Json(ErrorResponse {
serde_json::json!(ErrorResponse {
error: "Only the invoice merchant is authorised to cancel this invoice"
.to_string(),
code: Some(1),
}),
)
.into_response(),
),
Err(e) if e.to_string().contains("NOT_FOUND") => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
serde_json::json!(ErrorResponse {
error: format!("Invoice {} not found", id),
code: Some(4),
}),
)
.into_response(),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
serde_json::json!(ErrorResponse {
error: e.to_string(),
code: None,
}),
)
.into_response(),
),
};

// ── Cache the result ─────────────────────────────────────────────────────
if let Some(ref key) = idem_key {
let store_key = IdempotencyStore::make_key("cancel", id, key);
state.idempotency.insert(store_key, status.as_u16(), body_json.clone());
}

(status, Json(body_json)).into_response()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::idempotency::IdempotencyStore;
use crate::routes::invoices::get_invoice;
use crate::routes::pay::pay_invoice;
use crate::soroban::SorobanClient;
use axum::{
routing::{get, post},
Router,
};
use axum_test::TestServer;
use std::sync::Arc;
use std::time::Duration;

fn make_app(client: SorobanClient) -> Router {
fn make_app() -> Router {
let state = AppState {
client: Arc::new(SorobanClient::new(
"http://127.0.0.1:19999/soroban/rpc".to_string(),
"CONTRACT_ID".to_string(),
"https://horizon.stellar.org".to_string(),
)),
idempotency: IdempotencyStore::new(Duration::from_secs(86_400)),
};
Router::new()
.route("/invoices/:id", get(get_invoice))
.route("/invoices/:id/pay", post(pay_invoice))
.route("/invoices/:id/cancel", post(cancel_invoice))
.with_state(Arc::new(client))
.with_state(state)
}

#[tokio::test]
Expand Down Expand Up @@ -136,7 +169,6 @@ mod tests {
"signed_xdr": "AAAA=="
}))
.await;

assert!(
resp.status_code() == StatusCode::INTERNAL_SERVER_ERROR
|| resp.status_code() == StatusCode::NOT_FOUND
Expand Down
16 changes: 8 additions & 8 deletions backend/src/routes/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ use axum::{
response::IntoResponse,
Json,
};
use std::{collections::BTreeMap, sync::Arc};
use std::collections::BTreeMap;

use crate::{
soroban::SorobanClient,
AppState,
types::{DependencyHealth, HealthStatus, RpcHealthResponse},
};

Expand All @@ -21,10 +21,10 @@ use crate::{
tag = "health"
)]
pub async fn get_rpc_health(
State(client): State<Arc<SorobanClient>>,
State(state): State<AppState>,
) -> impl IntoResponse {
let soroban_rpc = client.check_rpc_health().await;
let horizon = client.check_horizon_health().await;
let soroban_rpc = state.client.check_rpc_health().await;
let horizon = state.client.check_horizon_health().await;

let soroban_health = match soroban_rpc {
Ok(()) => DependencyHealth {
Expand Down Expand Up @@ -136,7 +136,7 @@ mod tests {

let app = Router::new()
.route("/health/rpc", get(get_rpc_health))
.with_state(client);
.with_state(state);

let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let health_addr = listener.local_addr().unwrap();
Expand Down Expand Up @@ -193,12 +193,12 @@ mod tests {

let app = Router::new()
.route("/health/rpc", get(get_rpc_health))
.with_state(client);
.with_state(state);

let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let health_addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
axum::serve(listener, app.into_make_service()).await.unwrap();
});

let response = reqwest::get(format!("http://{health_addr}/health/rpc"))
Expand Down
7 changes: 3 additions & 4 deletions backend/src/routes/invoices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ use axum::{
response::IntoResponse,
Json,
};
use std::sync::Arc;

use crate::soroban::SorobanClient;
use crate::AppState;
use crate::types::ErrorResponse;

#[utoipa::path(
Expand All @@ -23,10 +22,10 @@ use crate::types::ErrorResponse;
tag = "invoices"
)]
pub async fn get_invoice(
State(client): State<Arc<SorobanClient>>,
State(state): State<AppState>,
Path(id): Path<u64>,
) -> impl IntoResponse {
match client.get_invoice(id).await {
match state.client.get_invoice(id).await {
Ok(invoice) => (StatusCode::OK, Json(serde_json::json!(invoice))).into_response(),
Err(e) if e.to_string().contains("NOT_FOUND") => (
StatusCode::NOT_FOUND,
Expand Down
Loading
Loading