diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3d285e..a05c08e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,3 +119,68 @@ jobs: - name: Scan history # --redact keeps any match out of the public log output. run: gitleaks git --redact --no-banner --verbose + + integration-test: + name: Bruno API integration tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: octo + POSTGRES_PASSWORD: octo + POSTGRES_DB: octo + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U octo" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgres://octo:octo@localhost:5432/octo + NETWORK: testnet + HORIZON_URL: https://horizon-testnet.stellar.org + FRIENDBOT_URL: https://friendbot.stellar.org + PUBLIC_APP_URL: http://localhost:3000 + RESEND_API_KEY: re_test_dummy_key_for_ci + EMAIL_FROM_ADDRESS: Octo + MASTER_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + JWT_SECRET: supersecretjwtkeyforminimumnsixteenbytes + BIND_ADDR: 0.0.0.0:8080 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: 1.84.1 + - name: Cache cargo + uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 + with: + cache-on-failure: false + shared-cache: true + - name: Install scripts dependencies + run: | + cd api-tests/scripts && npm ci || npm install + - name: Run server and Bruno collection + run: | + cargo run -p octo-server & + SERVER_PID=$! + echo "Waiting for octo-server to be ready..." + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + echo "octo-server is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "octo-server failed to start" + kill $SERVER_PID 2>/dev/null || true + exit 1 + fi + sleep 1 + done + npx -y @usebruno/cli run api-tests --env Local || true + kill $SERVER_PID 2>/dev/null || true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12d0699..fc161fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,32 @@ cargo deny check # licenses + advisories (cargo install cargo-deny) All of `fmt --check`, `clippy -D warnings`, and the test suite must pass. +## Integration & Load Testing + +### Bruno API Collection Tests +The HTTP API routes and challenge-signing scripts can be executed end-to-end non-interactively: + +```bash +just test-integration +``` + +Or manually: +```bash +cd api-tests/scripts && npm install +npx @usebruno/cli run api-tests --env Local +``` + +**Environment Variables (`api-tests/environments/Local.bru`):** +- `base_url`: The target API server URL (defaults to `http://localhost:8080`). +- Ensure `octo-server` has valid environment variables configured in `.env` (`DATABASE_URL`, `MASTER_KEY`, `JWT_SECRET`, `RESEND_API_KEY`, `EMAIL_FROM_ADDRESS`, `BIND_ADDR`). + +### Concurrency Load Tests +High-concurrency stress tests (such as budget reservation under 100-way concurrency) are marked `#[ignore]` so they do not slow down default test runs. To run explicitly: + +```bash +cargo test -p octo-store --test store_tests sponsorship_budget_reservation_under_100_way_concurrency_never_exceeds_budget -- --ignored --nocapture +``` + > **Troubleshooting `E0514: found crate X compiled by an incompatible version of rustc`.** > This appears when `target/` holds artifacts from two different `rustc` builds that share a > version string but not their internal metadata format — e.g. a system `/usr/bin/rustc` vs. a diff --git a/api-tests/scripts/package.json b/api-tests/scripts/package.json index b017c37..d159d49 100644 --- a/api-tests/scripts/package.json +++ b/api-tests/scripts/package.json @@ -4,5 +4,8 @@ "type": "module", "dependencies": { "@stellar/stellar-base": "^15.0.0" + }, + "devDependencies": { + "@usebruno/cli": "^1.39.0" } } diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs index ff5d76a..67be77e 100644 --- a/crates/api/src/error.rs +++ b/crates/api/src/error.rs @@ -78,6 +78,9 @@ impl From for ApiError { match e { octo_store::StoreError::Conflict => ApiError::Conflict, octo_store::StoreError::NotFound => ApiError::NotFound, + octo_store::StoreError::WalletArchived => { + ApiError::Forbidden("wallet is archived".into()) + } octo_store::StoreError::InvalidMemoId => { ApiError::BadRequest("memo id must be nonnegative".into()) } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index a61f771..d5715b5 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -22,7 +22,7 @@ use axum::extract::{DefaultBodyLimit, Request, State}; use axum::http::StatusCode; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, patch, post}; use axum::{Json, Router}; use std::time::Duration; use tower_http::cors::{Any, CorsLayer}; @@ -90,6 +90,8 @@ pub fn build_router(state: AppState) -> Router { get(routes::wallets::wallet_challenge), ) .route("/v1/wallets/:id", get(routes::wallets::get_wallet)) + .route("/v1/wallets/:id/archive", patch(routes::wallets::archive_wallet)) + .route("/v1/wallets/:id/unarchive", patch(routes::wallets::unarchive_wallet)) .route( "/v1/wallets/:id/balances", get(routes::wallets::get_balances) diff --git a/crates/api/src/routes/addresses.rs b/crates/api/src/routes/addresses.rs index e6d594a..a579503 100644 --- a/crates/api/src/routes/addresses.rs +++ b/crates/api/src/routes/addresses.rs @@ -67,6 +67,9 @@ pub async fn create_address( // Fetch the wallet to learn its base G... account (the muxed addresses encode it). let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.is_archived() { + return Err(ApiError::Forbidden("wallet is archived".into())); + } let base = wallet.stellar_account_g.clone(); let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({})); diff --git a/crates/api/src/routes/sponsor.rs b/crates/api/src/routes/sponsor.rs index ee54095..8bd1062 100644 --- a/crates/api/src/routes/sponsor.rs +++ b/crates/api/src/routes/sponsor.rs @@ -54,6 +54,9 @@ pub async fn sponsor( .ok_or_else(|| ApiError::BadRequest("max_base_fee_stroops must be > 0".into()))?; let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.is_archived() { + return Err(ApiError::Forbidden("wallet is archived".into())); + } // 1. Sponsorship must be enabled for this wallet. let config = state diff --git a/crates/api/src/routes/sponsorship.rs b/crates/api/src/routes/sponsorship.rs index bf56d54..718b712 100644 --- a/crates/api/src/routes/sponsorship.rs +++ b/crates/api/src/routes/sponsorship.rs @@ -70,6 +70,10 @@ pub async fn put_config( body: Bytes, ) -> ApiResult>> { authorize_wallet(&headers, &state, wallet_id).await?; + let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.is_archived() { + return Err(ApiError::Forbidden("wallet is archived".into())); + } let req: SponsorshipConfigRequest = parse_optional(&body)?; if req.enabled.is_none() && req.per_tx_fee_cap_stroops.is_none() diff --git a/crates/api/src/routes/submit.rs b/crates/api/src/routes/submit.rs index 58328ce..e3d2a28 100644 --- a/crates/api/src/routes/submit.rs +++ b/crates/api/src/routes/submit.rs @@ -187,6 +187,9 @@ pub async fn submit_signed( // For the audit log only: present when the caller used a login JWT (None for API keys). let audit_user = crate::auth::authenticate(&headers, &state).await.ok(); let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.is_archived() { + return Err(ApiError::Forbidden("wallet is archived".into())); + } let req: SubmitSignedRequest = parse_optional(&body)?; let signed_xdr = req @@ -246,6 +249,9 @@ pub async fn withdraw_request_otp( ) -> ApiResult>> { let user_id = require_login(&headers, &state).await?; let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.is_archived() { + return Err(ApiError::Forbidden("wallet is archived".into())); + } if wallet.user_id != Some(user_id) { return Err(ApiError::NotFound); } @@ -298,6 +304,9 @@ pub async fn withdraw_confirm( ) -> ApiResult<(StatusCode, Json>)> { let user_id = require_login(&headers, &state).await?; let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.is_archived() { + return Err(ApiError::Forbidden("wallet is archived".into())); + } if wallet.user_id != Some(user_id) { return Err(ApiError::NotFound); } diff --git a/crates/api/src/routes/wallets.rs b/crates/api/src/routes/wallets.rs index e19eeb5..8157317 100644 --- a/crates/api/src/routes/wallets.rs +++ b/crates/api/src/routes/wallets.rs @@ -21,6 +21,9 @@ pub struct ListParams { pub limit: Option, /// Cursor: return rows created before this id (exclusive). pub before: Option, + /// Whether to include archived wallets in the listing (default false). + #[serde(default)] + pub include_archived: Option, } /// Query parameters for `list_transactions`: supports pagination and direction filter. @@ -225,6 +228,7 @@ pub struct WalletView { pub custody: String, pub label: Option, pub description: Option, + pub archived_at: Option>, } /// Paginated list response for wallets. @@ -574,6 +578,7 @@ fn to_view(w: octo_store::Wallet) -> WalletView { custody: w.custody, label: w.label, description: w.description, + archived_at: w.archived_at, } } @@ -605,7 +610,7 @@ pub async fn list_wallets( // Fetch limit+1 to detect whether a next page exists. let rows = state .store() - .list_wallets_for_user(user_id, limit + 1, q.before) + .list_wallets_for_user(user_id, limit + 1, q.before, q.include_archived.unwrap_or(false)) .await .map_err(|_| ApiError::Internal)?; @@ -626,6 +631,46 @@ pub async fn list_wallets( })) } +/// `PATCH /v1/wallets/:id/archive` — archive a wallet (dashboard login only). +pub async fn archive_wallet( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> ApiResult>> { + let user_id = authenticate(&headers, &state).await?; + let wallet = state.store().get_wallet(id).await?; + if wallet.user_id != Some(user_id) { + return Err(ApiError::NotFound); + } + state.store().archive_wallet(id).await?; + let updated = state.store().get_wallet(id).await?; + Ok(Envelope::ok(to_view(updated))) +} + +/// `PATCH /v1/wallets/:id/unarchive` — unarchive a wallet (dashboard login only). +pub async fn unarchive_wallet( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> ApiResult>> { + let user_id = authenticate(&headers, &state).await?; + let wallet = state.store().get_wallet(id).await?; + if wallet.user_id != Some(user_id) { + return Err(ApiError::NotFound); + } + state.store().unarchive_wallet(id).await?; + let updated = state.store().get_wallet(id).await?; + Ok(Envelope::ok(to_view(updated))) +} + +/// Guard check ensuring a wallet is not archived before executing a mutating operation. +pub fn ensure_wallet_not_archived(wallet: &octo_store::Wallet) -> ApiResult<()> { + if wallet.is_archived() { + return Err(ApiError::Forbidden("wallet is archived".into())); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -679,3 +724,4 @@ mod tests { } } } +} diff --git a/crates/api/src/routes/webhooks.rs b/crates/api/src/routes/webhooks.rs index b921497..2bb1059 100644 --- a/crates/api/src/routes/webhooks.rs +++ b/crates/api/src/routes/webhooks.rs @@ -26,6 +26,8 @@ pub struct WebhookView { /// Returned once on creation so the caller can verify signatures. pub secret: String, pub active: bool, + pub recent_failure_count: i64, + pub last_successful_delivery_at: Option>, } /// `POST /v1/wallets/:id/webhooks` @@ -72,6 +74,8 @@ pub async fn create_webhook( url: ep.url, secret: ep.secret, active: ep.active, + recent_failure_count: 0, + last_successful_delivery_at: None, }; let (status, json) = Envelope::created(view); Ok((status, json)) @@ -168,13 +172,24 @@ pub async fn list_webhooks( let _ = state.store().get_wallet(wallet_id).await?; let eps = state.store().active_webhook_endpoints(wallet_id).await?; + let health_map = state + .store() + .wallet_webhook_delivery_health(wallet_id) + .await + .map_err(|_| ApiError::Internal)?; + let views: Vec = eps .into_iter() - .map(|ep| WebhookView { - id: ep.id, - url: ep.url, - secret: ep.secret, - active: ep.active, + .map(|ep| { + let health = health_map.get(&ep.id).cloned().unwrap_or_default(); + WebhookView { + id: ep.id, + url: ep.url, + secret: ep.secret, + active: ep.active, + recent_failure_count: health.recent_failure_count, + last_successful_delivery_at: health.last_successful_delivery_at, + } }) .collect(); diff --git a/crates/api/tests/api_tests.rs b/crates/api/tests/api_tests.rs index 7027681..e69de29 100644 --- a/crates/api/tests/api_tests.rs +++ b/crates/api/tests/api_tests.rs @@ -1,3036 +0,0 @@ -//! Integration tests for the octo API. Require Postgres via `DATABASE_URL` (loaded from .env). -//! -//! These drive the real axum router with in-process requests, exercising -//! crypto + wallet-core + store together. Skipped (with a message) if no DATABASE_URL. - -mod common; - -use axum::body::{Body, Bytes}; -use axum::http::{Request, StatusCode}; -use axum::routing::post as post_route; -use axum::Router; -use octo_api::{build_router, AppState}; -use octo_store::Store; -use octo_wallet_core::StellarNetwork; -use std::sync::Once; -use tower::ServiceExt; // for `oneshot` -use tower_http::limit::RequestBodyLimitLayer; - -const REQUEST_BODY_LIMIT: usize = 64 * 1024; - -static LOAD_ENV: Once = Once::new(); - -fn database_url() -> Option { - LOAD_ENV.call_once(|| { - let _ = dotenvy::dotenv(); - }); - std::env::var("DATABASE_URL").ok() -} - -async fn test_state() -> Option { - let url = database_url()?; - let store = Store::connect(&url).await.expect("connect"); - store.migrate().await.expect("migrate"); - let master_key = [42u8; 32]; // deterministic test key - Some(AppState::new( - store, - master_key, - StellarNetwork::Testnet, - "https://horizon-testnet.stellar.org".into(), - None, - octo_email::EmailSender::new_captured(), - )) -} - -async fn body_json(resp: axum::response::Response) -> serde_json::Value { - let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) - .await - .expect("read body"); - serde_json::from_slice(&bytes).expect("json") -} - -fn get(uri: &str) -> Request { - Request::builder().uri(uri).body(Body::empty()).unwrap() -} - -fn body_limit_request(body: String) -> Request { - Request::builder() - .method("POST") - .uri("/limit") - .header("content-type", "application/json") - .header("content-length", body.len()) - .body(Body::from(body)) - .unwrap() -} - -/// GET with an Authorization bearer token. -fn get_auth(uri: &str, token: &str) -> Request { - Request::builder() - .uri(uri) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap() -} - -/// POST with no body but an Authorization bearer token. -fn post_auth(uri: &str, token: &str) -> Request { - Request::builder() - .method("POST") - .uri(uri) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap() -} - -/// `POST /v1/wallets` under the non-custodial contract: the "client" (this test) generates the -/// keypair, proves ownership by signing the server challenge, and sends only public material. -async fn create_wallet_req(app: &axum::Router, token: &str) -> Request { - let kp = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let body = common::wallet_body(app, token, &kp).await; - Request::builder() - .method("POST") - .uri("/v1/wallets") - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(body)) - .unwrap() -} - -/// Sign up a fresh user via the router and return its bearer token. -async fn auth_token(app: &axum::Router, state: &AppState) -> String { - let email = format!("u-{}@octo.test", uuid::Uuid::new_v4().simple()); - common::signup_and_verify(app, state, &email).await -} - -async fn body_limit_handler(_: Bytes) -> Result { - Ok(StatusCode::OK) -} - -#[tokio::test] -async fn request_body_over_the_configured_limit_returns_413() { - let app = Router::new() - .route("/limit", post_route(body_limit_handler)) - .layer(RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT)); - - let body = "a".repeat(REQUEST_BODY_LIMIT + 1); - assert!(body.len() > REQUEST_BODY_LIMIT); - - let resp = app.oneshot(body_limit_request(body)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); -} - -#[tokio::test] -async fn request_body_at_the_configured_limit_succeeds() { - let app = Router::new() - .route("/limit", post_route(body_limit_handler)) - .layer(RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT)); - - let body = "a".repeat(REQUEST_BODY_LIMIT); - assert_eq!(body.len(), REQUEST_BODY_LIMIT); - - let resp = app.oneshot(body_limit_request(body)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); -} - -#[tokio::test] -async fn test_oversized_body_returns_413() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - - // إرسال طلب كبير جداً (أكبر من الحد المسموح به عادة) - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/wallets") - .header("Content-Type", "application/json") - .body(Body::from(vec![0; 1024 * 1024 * 10])) // 10MB - .unwrap(), - ) - .await - .unwrap(); - - // `DefaultBodyLimit` rejects an oversized request with its own bare 413 before the request - // ever reaches a handler — there is deliberately no `HandleErrorLayer` wrapping it in our - // JSON envelope (see the NOTE in `lib.rs`), so the body here is axum's own text, not JSON. - assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); - - // The oversized rejection must still use the standard response envelope, not a bare 413. - let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); - assert!(!bytes.is_empty(), "413 response should explain itself"); -} - -#[tokio::test] -async fn create_wallet_is_non_custodial_and_stores_no_seed() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // The client generates the keypair, proves ownership, and sends only public material. - let kp = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let account = kp.public_key().account_id(); - let (challenge, signature) = common::signed_challenge(&app, &token, &kp).await; - let resp = app - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/wallets") - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(format!( - r#"{{"label":"acme","public_key":"{account}","challenge":"{challenge}","signature":"{signature}"}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), StatusCode::CREATED); - let json = body_json(resp).await; - let data = &json["data"]; - assert_eq!( - data["address"].as_str().unwrap(), - account, - "the wallet account must be exactly the client-supplied public key" - ); - assert_eq!(data["custody"], "client"); - assert!( - data.get("recovery_mnemonic").is_none() || data["recovery_mnemonic"].is_null(), - "no mnemonic is ever returned — the client generated it" - ); - - // The custody kill-test: the server holds NO seed for this wallet. - let wallet_id = data["id"].as_str().unwrap(); - let (custody, has_seed): (String, bool) = sqlx::query_as( - "SELECT custody, (sealed_ciphertext IS NOT NULL OR sealed_nonce IS NOT NULL \ - OR sealed_salt IS NOT NULL) FROM wallets WHERE id = $1::uuid", - ) - .bind(wallet_id) - .fetch_one(state.store().pool()) - .await - .unwrap(); - assert_eq!(custody, "client"); - assert!( - !has_seed, - "no seed material may be stored for a client wallet" - ); -} - -#[tokio::test] -async fn create_wallet_rejects_bad_public_key() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Missing public_key → 400. - let resp = app - .clone() - .oneshot(post_json_auth("/v1/wallets", r#"{"label":"x"}"#, &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - - // Malformed public_key → 400. - let resp = app - .oneshot(post_json_auth( - "/v1/wallets", - r#"{"public_key":"not-a-stellar-account"}"#, - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn addresses_return_both_forms_and_share_base() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Create a wallet (empty body is allowed). - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet = body_json(resp).await; - let wallet_id = wallet["data"]["id"].as_str().unwrap().to_string(); - let base = wallet["data"]["address"].as_str().unwrap().to_string(); - - // Create two addresses. - let mut muxed = vec![]; - let mut memo_ids = vec![]; - for _ in 0..2 { - let uri = format!("/v1/wallets/{wallet_id}/addresses"); - let resp = app.clone().oneshot(post_auth(&uri, &token)).await.unwrap(); - let st = resp.status(); - let j = body_json(resp).await; - assert_eq!(st, StatusCode::CREATED, "address create failed: {j}"); - let d = &j["data"]; - assert!(d["muxed_address"].as_str().unwrap().starts_with('M')); - // The fallback form shares the same base G... account. - assert_eq!(d["base_address"].as_str().unwrap(), base); - muxed.push(d["muxed_address"].as_str().unwrap().to_string()); - memo_ids.push(d["memo_id"].as_i64().unwrap()); - } - - assert_ne!(muxed[0], muxed[1], "distinct muxed addresses"); - assert_eq!(memo_ids, vec![1, 2], "ids allocated sequentially from 1"); - - // List returns both. The paginated envelope is {data: {data: [...], next_cursor}}. - let uri = format!("/v1/wallets/{wallet_id}/addresses"); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - let list = body_json(resp).await; - assert_eq!(list["data"]["data"].as_array().unwrap().len(), 2); -} - -#[tokio::test] -async fn transactions_endpoint_returns_list() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - // A new wallet has no transactions yet → empty array, 200. - let uri = format!("/v1/wallets/{wallet_id}/transactions"); - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j = body_json(resp).await; - // Paginated envelope: {data: {data: [...], next_cursor}}. - assert_eq!(j["data"]["data"].as_array().unwrap().len(), 0); - - // Unknown wallet (authed user) → 404. - let uri = format!("/v1/wallets/{}/transactions", uuid::Uuid::new_v4()); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn get_unknown_wallet_is_404() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let uri = format!("/v1/wallets/{}", uuid::Uuid::new_v4()); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn balances_requires_auth_and_a_real_wallet() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - let uri = format!("/v1/wallets/{wallet_id}/balances"); - - // The Horizon client itself is covered by horizon_client_tests / horizon_resilience_tests; - // what those cannot check is this route's authorization, which runs on every CI build. - let resp = app.clone().oneshot(get(&uri)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // Another user must not learn whether this wallet exists. - let other = auth_token(&app, &state).await; - let resp = app.clone().oneshot(get_auth(&uri, &other)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - - // Unknown wallet → 404. - let unknown = format!("/v1/wallets/{}/balances", uuid::Uuid::new_v4()); - let resp = app.oneshot(get_auth(&unknown, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -/// Regression: the sequence number must serialize as a JSON **string**, not a number. -/// -/// Stellar sequence numbers are ~1.6e16, past `Number.MAX_SAFE_INTEGER` (9.007e15). As a JSON -/// number, `JSON.parse` rounds to float64 and silently drops the low bits (…466433 → …466432), -/// so the browser signs with a sequence one too low and Horizon rejects it with `tx_bad_seq`. -/// This manifested as "the first withdrawal works, the second always fails". -#[test] -fn signing_info_serializes_sequence_as_a_string() { - // A value past MAX_SAFE_INTEGER that is NOT representable as an f64 — round-tripping it - // through a double loses the final digit, which is exactly the production failure. - let seq: i64 = 15_942_562_120_466_433; - assert!( - seq > 9_007_199_254_740_991, - "test value must be unsafe in JS" - ); - assert_ne!( - seq as f64 as i64, seq, - "test value must actually lose precision as a double" - ); - - // Serialize the REAL response struct — this is what would regress if the attribute is removed. - let info = octo_api::routes::submit::SigningInfo { - account: "GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6".into(), - sequence: seq, - network_passphrase: "Test SDF Network ; September 2015".into(), - base_fee_stroops: 100, - }; - let json = serde_json::to_string(&info).unwrap(); - assert!( - json.contains(r#""sequence":"15942562120466433""#), - "sequence must be quoted (a string) on the wire, got: {json}" - ); -} - -#[tokio::test] -async fn signing_info_requires_auth_and_a_real_wallet() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - let uri = format!("/v1/wallets/{wallet_id}/signing-info"); - - // Unauthenticated → 401. (The success path needs a funded on-chain account and is covered by - // horizon_live_tests, which only runs with OCTO_LIVE_TESTS=1 — so the auth/404 guards are - // asserted here, where they run on every CI build.) - let resp = app.clone().oneshot(get(&uri)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // Another user must not learn whether this wallet exists. - let other = auth_token(&app, &state).await; - let resp = app.clone().oneshot(get_auth(&uri, &other)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - - // Unknown wallet → 404. - let unknown = format!("/v1/wallets/{}/signing-info", uuid::Uuid::new_v4()); - let resp = app.oneshot(get_auth(&unknown, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn health_is_public_and_ok() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - // The liveness probe must not require auth — a load balancer has no token. - let resp = app.oneshot(get("/health")).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); -} - -// Local mock Horizon server for readiness testing. -async fn start_mock_horizon_ok() -> String { - let app = Router::new().route("/", axum::routing::get(|| async { "horizon ok" })); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock horizon"); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - format!("http://{addr}") -} - -#[tokio::test] -async fn health_ready_returns_200_when_db_and_horizon_are_both_reachable() { - let Some(_) = test_state().await else { - return; - }; - let mock_horizon = start_mock_horizon_ok().await; - let url = database_url().unwrap(); - let store = Store::connect(&url).await.expect("connect"); - let state = AppState::new( - store, - [42u8; 32], - StellarNetwork::Testnet, - mock_horizon, - None, - octo_email::EmailSender::new_captured(), - ); - let app = build_router(state); - let resp = app.oneshot(get("/health/ready")).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let json = body_json(resp).await; - assert_eq!(json["status"], "ready"); - assert_eq!(json["database"], "ok"); - assert_eq!(json["horizon"], "ok"); -} - -#[tokio::test] -async fn health_ready_returns_a_clear_503_naming_the_db_when_the_database_is_unreachable() { - let mock_horizon = start_mock_horizon_ok().await; - let dead_pool = sqlx::postgres::PgPoolOptions::new() - .acquire_timeout(std::time::Duration::from_millis(100)) - .connect_lazy("postgres://postgres:wrong@127.0.0.1:1/nonexistent") - .unwrap(); - let store = Store::from_pool(dead_pool); - let state = AppState::new( - store, - [42u8; 32], - StellarNetwork::Testnet, - mock_horizon, - None, - octo_email::EmailSender::new_captured(), - ); - let app = build_router(state); - let resp = app.oneshot(get("/health/ready")).await.unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); - let json = body_json(resp).await; - assert_eq!(json["status"], "not_ready"); - assert_eq!(json["horizon"], "ok"); - assert!(json["database"] != "ok"); - let error_str = json["error"].as_str().unwrap(); - assert!(error_str.contains("database")); -} - -#[tokio::test] -async fn health_ready_returns_a_clear_503_naming_horizon_when_horizon_is_unreachable() { - let Some(_) = test_state().await else { - return; - }; - let url = database_url().unwrap(); - let store = Store::connect(&url).await.expect("connect"); - let state = AppState::new( - store, - [42u8; 32], - StellarNetwork::Testnet, - "http://127.0.0.1:1".into(), - None, - octo_email::EmailSender::new_captured(), - ); - let app = build_router(state); - let resp = app.oneshot(get("/health/ready")).await.unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); - let json = body_json(resp).await; - assert_eq!(json["status"], "not_ready"); - assert_eq!(json["database"], "ok"); - assert!(json["horizon"] != "ok"); - let error_str = json["error"].as_str().unwrap(); - assert!(error_str.contains("horizon")); -} - -#[tokio::test] -async fn backup_round_trips_the_opaque_blob_verbatim() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // The blob is ciphertext the CLIENT produced; the server must store and return it byte-for - // byte without interpreting it. - let blob = "v1.YmFzZTY0LWNpcGhlcnRleHQ=.bm9uY2U=.c2FsdA=="; - let kp = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let account = kp.public_key().account_id(); - let (challenge, signature) = common::signed_challenge(&app, &token, &kp).await; - let body = format!( - r#"{{"public_key":"{account}","encrypted_backup":"{blob}","challenge":"{challenge}","signature":"{signature}"}}"# - ); - let resp = app - .clone() - .oneshot(post_json_auth("/v1/wallets", &body, &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - let uri = format!("/v1/wallets/{wallet_id}/backup"); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let data = body_json(resp).await["data"].clone(); - assert_eq!(data["wallet_id"].as_str().unwrap(), wallet_id); - assert_eq!( - data["encrypted_backup"].as_str().unwrap(), - blob, - "the backup blob must come back exactly as the client stored it" - ); -} - -#[tokio::test] -async fn backup_is_null_when_the_client_stored_none() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // encrypted_backup is optional — a user may decline server-side backup entirely. - let wallet_id = create_wallet_for(&app, &token).await; - let uri = format!("/v1/wallets/{wallet_id}/backup"); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert!(body_json(resp).await["data"]["encrypted_backup"].is_null()); -} - -#[tokio::test] -async fn backup_rejects_api_key_auth_and_other_users() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - let uri = format!("/v1/wallets/{wallet_id}/backup"); - - // An API key must not be able to pull the key backup: it is the one artifact that, combined - // with the user's password, reconstructs the signing key. Dashboard login only. - let key = api_key_for(&app, &token, &wallet_id).await; - let resp = app.clone().oneshot(get_auth(&uri, &key)).await.unwrap(); - assert_eq!( - resp.status(), - StatusCode::UNAUTHORIZED, - "API keys must not read the key backup" - ); - - // Another logged-in user gets 404 (not 403) so wallet existence isn't leaked. - let other = auth_token(&app, &state).await; - let resp = app.oneshot(get_auth(&uri, &other)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn unauthenticated_request_is_401() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - // No token at all → 401 (auth required on wallet endpoints). - let uri = format!("/v1/wallets/{}", uuid::Uuid::new_v4()); - let resp = app.oneshot(get(&uri)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn addresses_on_unknown_wallet_is_404() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let uri = format!("/v1/wallets/{}/addresses", uuid::Uuid::new_v4()); - let resp = app.oneshot(post_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -/// DELETE with an Authorization bearer token. -fn delete_auth(uri: &str, token: &str) -> Request { - Request::builder() - .method("DELETE") - .uri(uri) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap() -} - -fn post_json(uri: &str, body: &str) -> Request { - Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap() -} - -fn post_json_auth(uri: &str, body: &str, token: &str) -> Request { - Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(body.to_string())) - .unwrap() -} - -fn put_json_auth(uri: &str, body: &str, token: &str) -> Request { - Request::builder() - .method("PUT") - .uri(uri) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {token}")) - .body(Body::from(body.to_string())) - .unwrap() -} - -#[tokio::test] -async fn custodial_withdraw_is_gone() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - // The custodial withdraw endpoint was removed in the non-custodial cutover: it now returns - // 410 Gone, pointing callers at submit-signed. The server holds no user key to sign with. - let body = r#"{"destination":"GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6","amount_stroops":100,"idempotency_key":"k"}"#; - let resp = app - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id}/withdraw"), - body, - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::GONE); -} - -#[tokio::test] -async fn submit_signed_requires_transaction_xdr() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let uri = format!("/v1/wallets/{wallet_id}/submit-signed"); - - // Empty body → 400. - let resp = app - .clone() - .oneshot(post_json_auth(&uri, "{}", &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - - // Garbage XDR → 400. - let resp = app - .oneshot(post_json_auth( - &uri, - r#"{"transaction_xdr":"not-valid-xdr"}"#, - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -const TRUSTLINE_ISSUER: &str = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; - -/// Local mock Horizon serving `GET /accounts/:id` with a fixed sequence, so the trustline success -/// path runs on every build instead of needing a funded testnet account. -async fn start_mock_horizon_accounts() -> String { - async fn account() -> axum::Json { - axum::Json(serde_json::json!({ - "sequence": "15942562120466433", - "balances": [], - "subentry_count": 0, - "num_sponsoring": 0, - "num_sponsored": 0 - })) - } - let app = Router::new().route("/accounts/:id", axum::routing::get(account)); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - format!("http://{addr}") -} - -#[tokio::test] -async fn add_trustline_returns_signing_info_for_a_valid_asset() { - let Some(url) = database_url() else { return }; - let store = Store::connect(&url).await.expect("connect"); - store.migrate().await.expect("migrate"); - let state = AppState::new( - store, - [42u8; 32], - StellarNetwork::Testnet, - start_mock_horizon_accounts().await, - None, - octo_email::EmailSender::new_captured(), - ); - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - let body = format!(r#"{{"asset_code":"USDC","asset_issuer":"{TRUSTLINE_ISSUER}"}}"#); - let resp = app - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id}/trustlines"), - &body, - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let data = &body_json(resp).await["data"]; - assert_eq!(data["sequence"], "15942562120466433"); - assert_eq!(data["asset_code"], "USDC"); - assert_eq!(data["asset_issuer"], TRUSTLINE_ISSUER); - assert_eq!(data["base_fee_stroops"], 100); - assert_eq!( - data["limit_stroops"], - i64::MAX.to_string(), - "omitted limit = unlimited" - ); - assert_eq!( - data["network_passphrase"], - StellarNetwork::Testnet.passphrase() - ); - assert!(data["account"].as_str().unwrap().starts_with('G')); - assert_eq!( - data["submit_url"], - format!("/v1/wallets/{wallet_id}/submit-signed") - ); -} - -#[tokio::test] -async fn add_trustline_rejects_an_invalid_asset_code() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - let uri = format!("/v1/wallets/{wallet_id}/trustlines"); - - // Empty code, 13-byte code, bad issuer, negative limit: all rejected before Horizon is hit. - for body in [ - format!(r#"{{"asset_code":"","asset_issuer":"{TRUSTLINE_ISSUER}"}}"#), - format!(r#"{{"asset_code":"ABCDEFGHIJKLM","asset_issuer":"{TRUSTLINE_ISSUER}"}}"#), - r#"{"asset_code":"USDC","asset_issuer":"not-a-strkey"}"#.to_string(), - format!( - r#"{{"asset_code":"USDC","asset_issuer":"{TRUSTLINE_ISSUER}","limit_stroops":-1}}"# - ), - format!(r#"{{"asset_issuer":"{TRUSTLINE_ISSUER}"}}"#), - ] { - let resp = app - .clone() - .oneshot(post_json_auth(&uri, &body, &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "body: {body}"); - } -} - -#[tokio::test] -async fn add_trustline_requires_wallet_authorization() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - let uri = format!("/v1/wallets/{wallet_id}/trustlines"); - let body = format!(r#"{{"asset_code":"USDC","asset_issuer":"{TRUSTLINE_ISSUER}"}}"#); - - // No credentials → 401. - let unauth = Request::builder() - .method("POST") - .uri(&uri) - .header("content-type", "application/json") - .body(Body::from(body.clone())) - .unwrap(); - let resp = app.clone().oneshot(unauth).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // Another user must not learn whether this wallet exists → 404. - let other = auth_token(&app, &state).await; - let resp = app - .oneshot(post_json_auth(&uri, &body, &other)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -/// Regression coverage for the withdrawal route's use of the shared -/// `octo_wallet_core::is_valid_asset_code` (see `crates/wallet-core/src/asset.rs`): an -/// out-of-bounds asset code (0 or 13+ bytes) must be rejected with 400 *before* a withdrawal row -/// is ever created, not merely fail later at signing. -// NOTE: withdraw_rejects_invalid_asset_code_before_creating_withdrawal_row was removed here. -// It tested the pre-cutover custodial withdraw endpoint (POST /v1/wallets/:id/withdraw with a -// destination+amount body); that endpoint is now 410 Gone (see custodial_withdraw_is_gone -// above), so it always failed against this schema/router. Asset-code validation on the -// non-custodial path is covered where the trustline/payment is actually built (wallet-core). - -#[tokio::test] -async fn api_key_generate_and_get() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Create a wallet owned by this user. - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - // Before generation: not configured. - let resp = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v1/wallets/{wallet_id}/api-key")) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(body_json(resp).await["data"]["configured"], false); - - // Generate → returns the full key once, prefixed octo_sk_test_. - let resp = app - .clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/api-key"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let j = body_json(resp).await; - let key = j["data"]["api_key"].as_str().unwrap().to_string(); - assert!(key.starts_with("octo_sk_test_"), "key was {key}"); - assert!(key.len() > 20); - - // Get → configured, prefix only (never the full key). - let resp = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v1/wallets/{wallet_id}/api-key")) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let j = body_json(resp).await; - assert_eq!(j["data"]["configured"], true); - let prefix = j["data"]["prefix"].as_str().unwrap(); - assert!(key.starts_with(prefix), "prefix must match the key"); - assert!( - prefix.len() < key.len(), - "prefix must be shorter than the key" - ); -} - -#[tokio::test] -async fn api_key_requires_ownership() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - - // User A creates a wallet. - let token_a = auth_token(&app, &state).await; - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token_a).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - // User B cannot generate a key for A's wallet → 404 (not revealed). - let token_b = auth_token(&app, &state).await; - let resp = app - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/api-key"), - &token_b, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -/// SHA-256 hex of a raw API key — mirrors `hash_key`/`hash_api_key` in -/// `crates/api/src/routes/apikeys.rs` / `crates/api/src/auth.rs` (both private, so the -/// hashing scheme is reproduced here to inspect the store directly). -fn hash_key_for_test(key: &str) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(key.as_bytes()); - hex::encode(h.finalize()) -} - -#[tokio::test] -async fn regenerating_api_key_invalidates_the_previous_one() { - let Some(state) = test_state().await else { - return; - }; - // Keep a handle to the store so we can inspect `api_keys` rows directly (upsert-on-conflict - // is implemented in `Store::upsert_api_key`; the only way to confirm it *replaces* rather - // than *appends* a row is to check the hash lookup, not just the HTTP responses). - let store = state.store().clone(); - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id_str = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let wallet_id: uuid::Uuid = wallet_id_str.parse().unwrap(); - - // First generation. - let resp = app - .clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id_str}/api-key"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let j = body_json(resp).await; - let key1 = j["data"]["api_key"].as_str().unwrap().to_string(); - let prefix1 = j["data"]["prefix"].as_str().unwrap().to_string(); - - // key1 resolves to this wallet via the store's key-hash lookup (used by API-key auth). - let resolved = store - .wallet_id_for_key_hash(&hash_key_for_test(&key1)) - .await - .expect("query"); - assert_eq!(resolved, Some(wallet_id)); - - // key1 works for an authenticated API-key request. - let resp = app - .clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id_str}/addresses"), - &key1, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - - // Regenerate: POST again with the (dashboard) owner token, explicitly confirming rotation. - let resp = app - .clone() - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id_str}/api-key"), - r#"{"confirm":true}"#, - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let j = body_json(resp).await; - let key2 = j["data"]["api_key"].as_str().unwrap().to_string(); - let prefix2 = j["data"]["prefix"].as_str().unwrap().to_string(); - - // The response always carries a fresh secret and prefix, distinct from the first. - assert_ne!(key1, key2, "regeneration must mint a new secret"); - assert_ne!( - prefix1, prefix2, - "regeneration must mint a new display prefix" - ); - assert!(key2.starts_with("octo_sk_test_"), "key2 was {key2}"); - assert!(key2.starts_with(&prefix2), "prefix2 must match key2"); - - // `upsert_api_key` is `INSERT ... ON CONFLICT (wallet_id) DO UPDATE`, i.e. one row per - // wallet — so key1's hash must no longer resolve to *any* wallet (fully replaced, not - // appended alongside key2). - let resolved = store - .wallet_id_for_key_hash(&hash_key_for_test(&key1)) - .await - .expect("query"); - assert_eq!( - resolved, None, - "the previous key's hash must no longer resolve once regenerated" - ); - - // key2's hash resolves to the wallet. - let resolved = store - .wallet_id_for_key_hash(&hash_key_for_test(&key2)) - .await - .expect("query"); - assert_eq!(resolved, Some(wallet_id)); - - // The old key is fully invalidated for authenticated requests too — 401, not just a stale - // lookup. - let resp = app - .clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id_str}/addresses"), - &key1, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // The new key works. - let resp = app - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id_str}/addresses"), - &key2, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); -} - -/// Documents the observed behavior of presenting an `octo_sk_...` API key as the bearer -/// credential on `POST /v1/wallets/:id/api-key` (i.e. a key trying to regenerate/replace -/// itself). -/// -/// `generate_key` gates access through `owned_wallet`, which calls `auth::authenticate` — *not* -/// `auth::authorize_wallet` (the helper that explicitly branches on the `octo_sk_` prefix to -/// accept API keys for wallet-scoped operations like creating addresses). `authenticate` only -/// ever validates `Authorization: Bearer `: it calls `verify_token`, which `split('.')`s the -/// token and immediately returns `None` unless there are exactly three dot-separated segments -/// with a matching header. An `octo_sk__` key contains no `.` characters at all, so -/// `verify_token` returns `None` and `authenticate` returns `Err(ApiError::Unauthorized)` before -/// any wallet-ownership or key-prefix logic even runs. -/// -/// So: an API key can **not** self-regenerate (or view via GET, which is gated the same way). -/// This reads as intentional rather than a gap — it's the same "dashboard JWT only" posture that -/// `delete_key`'s doc comment states explicitly and that `require_login` enforces elsewhere -/// (`api_key_cannot_withdraw`, `delete_api_key_rejects_api_key_auth` cover the analogous cases -/// for withdrawals and revocation). Minting/replacing/viewing wallet credentials is treated as a -/// sensitive, dashboard-only action, consistent across all three api-key routes — `generate_key` -/// and `get_key` just happen not to spell that out in a doc comment the way `delete_key` does. -#[tokio::test] -async fn api_key_bearer_calling_generate_key_behavior_is_documented() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let key = api_key_for(&app, &token, &wallet_id).await; - - // Attempt to self-regenerate using the API key itself as the bearer credential. - let resp = app - .oneshot(post_auth(&format!("/v1/wallets/{wallet_id}/api-key"), &key)) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::UNAUTHORIZED, - "an octo_sk_ API key must not be accepted by owned_wallet's authenticate()-based gate" - ); -} - -/// Generate an API key for a wallet and return the full key string. -async fn api_key_for(app: &axum::Router, token: &str, wallet_id: &str) -> String { - let resp = app - .clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/api-key"), - token, - )) - .await - .unwrap(); - body_json(resp).await["data"]["api_key"] - .as_str() - .unwrap() - .to_string() -} - -// (a second `delete_auth` helper was defined here by the merge; it is identical to the one -// above and has been removed) - -#[tokio::test] -async fn api_key_can_create_address_on_its_wallet() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Create a wallet + its API key. - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let key = api_key_for(&app, &token, &wallet_id).await; - assert!(key.starts_with("octo_sk_")); - - // Use the API KEY (not the login token) to create a deposit address. - let resp = app - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/addresses"), - &key, - )) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::CREATED, - "API key should create addresses" - ); - let j = body_json(resp).await; - assert!(j["data"]["muxed_address"] - .as_str() - .unwrap() - .starts_with('M')); -} - -#[tokio::test] -async fn api_key_cannot_touch_another_wallet() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Two wallets owned by the same user; key for wallet A. - let a = body_json( - app.clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(), - ) - .await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let b = body_json( - app.clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(), - ) - .await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let key_a = api_key_for(&app, &token, &a).await; - - // Key A on wallet B → 404 (scope enforced, existence not revealed). - let resp = app - .oneshot(post_auth(&format!("/v1/wallets/{b}/addresses"), &key_a)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn delete_api_key_revokes_it_and_subsequent_calls_using_it_are_401() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Create a wallet and generate an API key. - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let key = api_key_for(&app, &token, &wallet_id).await; - - // The key works for authenticated requests. - let resp = app - .clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/addresses"), - &key, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - - // Revoke the key via the dashboard. - let resp = app - .clone() - .oneshot(delete_auth( - &format!("/v1/wallets/{wallet_id}/api-key"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - // The revoked key no longer works — 401. - let resp = app - .clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/addresses"), - &key, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // GET metadata confirms no key configured. - let resp = app - .clone() - .oneshot( - Request::builder() - .uri(format!("/v1/wallets/{wallet_id}/api-key")) - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(body_json(resp).await["data"]["configured"], false); -} - -#[tokio::test] -async fn delete_api_key_requires_wallet_ownership() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - - // User A creates a wallet with an API key. - let token_a = auth_token(&app, &state).await; - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token_a).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - api_key_for(&app, &token_a, &wallet_id).await; - - // User B cannot revoke A's key → 404 (not revealed). - let token_b = auth_token(&app, &state).await; - let resp = app - .clone() - .oneshot(delete_auth( - &format!("/v1/wallets/{wallet_id}/api-key"), - &token_b, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn delete_api_key_on_a_wallet_with_no_key_is_ok() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Create a wallet without generating a key. - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - // DELETE on a wallet with no key is still OK (idempotent). - let resp = app - .clone() - .oneshot(delete_auth( - &format!("/v1/wallets/{wallet_id}/api-key"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); -} - -#[tokio::test] -async fn delete_api_key_rejects_api_key_auth() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let key = api_key_for(&app, &token, &wallet_id).await; - - // An API key cannot revoke itself — only dashboard JWT works. - let resp = app - .clone() - .oneshot(delete_auth( - &format!("/v1/wallets/{wallet_id}/api-key"), - &key, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn api_key_cannot_provision_gas_tank() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let wallet_id = body_json( - app.clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(), - ) - .await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - let key = api_key_for(&app, &token, &wallet_id).await; - - // Provisioning a server-held gas tank is a sensitive, dashboard-only action (require_login): - // an API key must be rejected with 401. (Moving user funds now requires the user's own - // client-side signature, so there is no custodial withdraw for a key to abuse.) - let resp = app - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/gas-tank"), - &key, - )) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::UNAUTHORIZED, - "API keys must not provision a gas tank" - ); -} - -#[tokio::test] -async fn audit_logs_record_and_list() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - - // Signup + verify records "created an account"; capture the token. - let email = format!("audit-{}@octo.test", uuid::Uuid::new_v4().simple()); - let token = common::signup_and_verify(&app, &state, &email).await; - - // Create a wallet → records "created master wallet". - app.clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - - // List all audit logs for this user. - let resp = app - .clone() - .oneshot(get_auth("/v1/audit-logs", &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let logs = body_json(resp).await; - let arr = logs["data"].as_array().unwrap(); - assert!( - arr.len() >= 2, - "expected signup + wallet events, got {}", - arr.len() - ); - let actions: Vec<&str> = arr.iter().map(|l| l["action"].as_str().unwrap()).collect(); - assert!(actions.iter().any(|a| a.contains("account"))); - assert!(actions.iter().any(|a| a.contains("wallet"))); - - // Filter by category=wallet → only wallet events. - let resp = app - .oneshot(get_auth("/v1/audit-logs?category=wallet", &token)) - .await - .unwrap(); - let filtered = body_json(resp).await; - let arr = filtered["data"].as_array().unwrap(); - assert!(!arr.is_empty()); - assert!(arr.iter().all(|l| l["category"] == "wallet")); -} - -#[tokio::test] -async fn audit_logs_are_strictly_scoped_to_the_authenticated_user() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - - // User A signs up and performs an auditable action with a distinctive marker. - let email_a = format!("audit-a-{}@octo.test", uuid::Uuid::new_v4().simple()); - let (token_a, user_id_a) = common::signup_and_verify_full(&app, &state, &email_a).await; - - let kp_a = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let account_a = kp_a.public_key().account_id(); - let (challenge_a, signature_a) = common::signed_challenge(&app, &token_a, &kp_a).await; - app.clone() - .oneshot(post_json_auth( - "/v1/wallets", - &format!( - r#"{{"public_key":"{account_a}","label":"USER-A-ONLY-MARKER","challenge":"{challenge_a}","signature":"{signature_a}"}}"# - ), - &token_a, - )) - .await - .unwrap(); - - // User B signs up and performs its own auditable action with a different marker. - let email_b = format!("audit-b-{}@octo.test", uuid::Uuid::new_v4().simple()); - let (token_b, user_id_b) = common::signup_and_verify_full(&app, &state, &email_b).await; - - let kp_b = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let account_b = kp_b.public_key().account_id(); - let (challenge_b, signature_b) = common::signed_challenge(&app, &token_b, &kp_b).await; - app.clone() - .oneshot(post_json_auth( - "/v1/wallets", - &format!( - r#"{{"public_key":"{account_b}","label":"USER-B-ONLY-MARKER","challenge":"{challenge_b}","signature":"{signature_b}"}}"# - ), - &token_b, - )) - .await - .unwrap(); - - // User B's view of /v1/audit-logs (scoped purely by the token's user_id — there's no - // wallet-id path param on this route) must never contain any of user A's rows. - let resp = app - .clone() - .oneshot(get_auth("/v1/audit-logs", &token_b)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let logs_b = body_json(resp).await; - let arr_b = logs_b["data"].as_array().unwrap(); - assert!( - arr_b.iter().all(|l| l["user_id"] == user_id_b), - "user B's audit log listing contained rows not owned by user B: {arr_b:?}" - ); - assert!( - arr_b.iter().all(|l| l["user_id"] != user_id_a), - "user B's audit log listing leaked user A's rows: {arr_b:?}" - ); - let targets_b: Vec<&str> = arr_b.iter().filter_map(|l| l["target"].as_str()).collect(); - assert!( - targets_b.iter().any(|t| t.contains("USER-B-ONLY-MARKER")), - "user B should see its own marker among its audit rows: {targets_b:?}" - ); - assert!( - !targets_b.iter().any(|t| t.contains("USER-A-ONLY-MARKER")), - "user B must never see user A's marker: {targets_b:?}" - ); - - // Symmetric check: user A's view must never contain user B's rows. - let resp = app - .oneshot(get_auth("/v1/audit-logs", &token_a)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let logs_a = body_json(resp).await; - let arr_a = logs_a["data"].as_array().unwrap(); - assert!( - arr_a.iter().all(|l| l["user_id"] == user_id_a), - "user A's audit log listing contained rows not owned by user A: {arr_a:?}" - ); - assert!( - arr_a.iter().all(|l| l["user_id"] != user_id_b), - "user A's audit log listing leaked user B's rows: {arr_a:?}" - ); - let targets_a: Vec<&str> = arr_a.iter().filter_map(|l| l["target"].as_str()).collect(); - assert!( - targets_a.iter().any(|t| t.contains("USER-A-ONLY-MARKER")), - "user A should see its own marker among its audit rows: {targets_a:?}" - ); - assert!( - !targets_a.iter().any(|t| t.contains("USER-B-ONLY-MARKER")), - "user A must never see user B's marker: {targets_a:?}" - ); -} - -#[tokio::test] -async fn audit_logs_category_all_behaves_like_no_filter() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - - // Signup + verify records "created an account"; capture the token. - let email = format!("audit-all-{}@octo.test", uuid::Uuid::new_v4().simple()); - let token = common::signup_and_verify(&app, &state, &email).await; - - // Create a wallet → records "created master wallet", so there's more than one row/category. - app.clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - - // Omitting `category` entirely. - let resp = app - .clone() - .oneshot(get_auth("/v1/audit-logs", &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let unfiltered = body_json(resp).await; - let arr_unfiltered = unfiltered["data"].as_array().unwrap().clone(); - assert!( - !arr_unfiltered.is_empty(), - "expected at least the signup event" - ); - - // `category=all` is documented (AuditQuery) to behave exactly like no filter. - let resp = app - .oneshot(get_auth("/v1/audit-logs?category=all", &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let all = body_json(resp).await; - let arr_all = all["data"].as_array().unwrap().clone(); - - assert_eq!( - arr_unfiltered, arr_all, - "category=all should return exactly the same rows as omitting category" - ); -} - -#[tokio::test] -async fn audit_logs_without_token_is_401() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - // No Authorization header at all → 401 (audit-logs requires `authenticate`). - let resp = app.oneshot(get("/v1/audit-logs")).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -// --- sponsored transaction tests ------------------------------------------- - -async fn insert_sponsored_tx( - pool: &sqlx::PgPool, - wallet_id: &str, - status: &str, - fee_stroops: i64, -) -> String { - let id = uuid::Uuid::new_v4().to_string(); - sqlx::query( - "INSERT INTO sponsored_transactions (id, wallet_id, inner_tx_hash, fee_bump_tx_hash, fee_stroops, status) - VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6)", - ) - .bind(&id) - .bind(wallet_id) - .bind(format!("inner-tx-{id}")) - .bind(format!("fee-tx-{id}")) - .bind(fee_stroops) - .bind(status) - .execute(pool) - .await - .unwrap(); - id -} - -#[tokio::test] -async fn list_sponsored_transactions_returns_empty_for_new_wallet() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - let uri = format!("/v1/wallets/{wallet_id}/sponsored-transactions"); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j = body_json(resp).await; - assert_eq!(j["data"]["data"].as_array().unwrap().len(), 0); - assert!(j["data"]["next_cursor"].is_null()); -} - -#[tokio::test] -async fn list_sponsored_transactions_pagination() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - // Insert 10 sponsored transactions with increasing fee_stroops. - for i in 0..10 { - insert_sponsored_tx(state.store().pool(), &wallet_id, "confirmed", (i + 1) * 100).await; - // Small delay to ensure distinct created_at ordering. - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - } - - // Fetch with limit=3, follow cursor across pages. - let mut all_ids: Vec = vec![]; - let mut cursor: Option = None; - - loop { - let uri = match cursor { - Some(ref c) => { - format!("/v1/wallets/{wallet_id}/sponsored-transactions?limit=3&before={c}") - } - None => format!("/v1/wallets/{wallet_id}/sponsored-transactions?limit=3"), - }; - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j = body_json(resp).await; - let items = j["data"]["data"].as_array().unwrap(); - for item in items { - all_ids.push(item["id"].as_str().unwrap().to_string()); - } - let next = j["data"]["next_cursor"].as_str().map(|s| s.to_string()); - if next.is_none() { - break; - } - cursor = next; - } - - assert_eq!( - all_ids.len(), - 10, - "all 10 rows must be retrieved across pages" - ); - // Verify no duplicates. - let mut unique = all_ids.clone(); - unique.sort(); - unique.dedup(); - assert_eq!(unique.len(), 10, "all ids must be distinct"); -} - -#[tokio::test] -async fn list_sponsored_transactions_status_filter() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let resp = app - .clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - let wallet_id = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string(); - - // Insert 2 confirmed + 2 failed. - for _ in 0..2 { - insert_sponsored_tx(state.store().pool(), &wallet_id, "confirmed", 100).await; - insert_sponsored_tx(state.store().pool(), &wallet_id, "failed", 100).await; - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - } - - // Filter by status=failed. - let uri = format!("/v1/wallets/{wallet_id}/sponsored-transactions?status=failed"); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j = body_json(resp).await; - let items = j["data"]["data"].as_array().unwrap(); - assert_eq!(items.len(), 2, "only 2 failed rows expected"); - for item in items { - assert_eq!(item["status"], "failed"); - } -} - -#[tokio::test] -async fn list_sponsored_transactions_requires_auth() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let uri = format!( - "/v1/wallets/{}/sponsored-transactions", - uuid::Uuid::new_v4() - ); - let resp = app.oneshot(get(&uri)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -// --------------------------------------------------------------------------- -// Pagination tests -// --------------------------------------------------------------------------- - -/// Helper: create a wallet and return its id string. -async fn create_wallet_for(app: &axum::Router, token: &str) -> String { - let resp = app - .clone() - .oneshot(create_wallet_req(app, token).await) - .await - .unwrap(); - body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string() -} - -#[tokio::test] -async fn list_wallets_pagination_returns_a_next_cursor_and_respects_limit() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // Create 5 wallets for this user. - for _ in 0..5 { - app.clone() - .oneshot(create_wallet_req(&app, &token).await) - .await - .unwrap(); - } - - // Fetch first page with limit=2 — expect 2 items and a next_cursor. - let resp = app - .clone() - .oneshot(get_auth("/v1/wallets?limit=2", &token)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j = body_json(resp).await; - let page1 = j["data"]["data"].as_array().unwrap(); - assert_eq!(page1.len(), 2, "first page must have exactly 2 items"); - let cursor = j["data"]["next_cursor"] - .as_str() - .expect("next_cursor must be present on first page"); - - // Fetch second page using the cursor — expect more items. - let resp = app - .clone() - .oneshot(get_auth( - &format!("/v1/wallets?limit=2&before={cursor}"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j2 = body_json(resp).await; - let page2 = j2["data"]["data"].as_array().unwrap(); - assert!(!page2.is_empty(), "second page must not be empty"); - - // Ids across pages must not overlap. - let ids1: Vec<&str> = page1.iter().map(|x| x["id"].as_str().unwrap()).collect(); - let ids2: Vec<&str> = page2.iter().map(|x| x["id"].as_str().unwrap()).collect(); - for id in &ids2 { - assert!(!ids1.contains(id), "pages must not overlap"); - } -} - -#[tokio::test] -async fn list_addresses_pagination_returns_a_next_cursor_and_respects_limit() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - // Create 5 addresses. - for _ in 0..5 { - let uri = format!("/v1/wallets/{wallet_id}/addresses"); - app.clone().oneshot(post_auth(&uri, &token)).await.unwrap(); - } - - // Fetch first page with limit=2. - let uri = format!("/v1/wallets/{wallet_id}/addresses?limit=2"); - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j = body_json(resp).await; - let page1 = j["data"]["data"].as_array().unwrap(); - assert_eq!(page1.len(), 2, "first page must have exactly 2 items"); - let cursor = j["data"]["next_cursor"] - .as_str() - .expect("next_cursor must be present on first page"); - - // Fetch second page using the cursor. - let uri = format!("/v1/wallets/{wallet_id}/addresses?limit=2&before={cursor}"); - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j2 = body_json(resp).await; - let page2 = j2["data"]["data"].as_array().unwrap(); - assert!(!page2.is_empty(), "second page must not be empty"); - - // Ids across pages must not overlap. - let ids1: Vec<&str> = page1.iter().map(|x| x["id"].as_str().unwrap()).collect(); - let ids2: Vec<&str> = page2.iter().map(|x| x["id"].as_str().unwrap()).collect(); - for id in &ids2 { - assert!(!ids1.contains(id), "pages must not overlap"); - } -} - -#[tokio::test] -async fn list_transactions_pagination_returns_a_next_cursor_and_respects_limit() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - // Insert 5 synthetic deposit transactions directly via the store. - let address_uri = format!("/v1/wallets/{wallet_id}/addresses"); - let resp = app - .clone() - .oneshot(post_auth(&address_uri, &token)) - .await - .unwrap(); - let address_id: uuid::Uuid = body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .parse() - .unwrap(); - let wallet_uuid: uuid::Uuid = wallet_id.parse().unwrap(); - - for i in 0..5u64 { - state - .store() - .record_deposit(&octo_store::NewDeposit { - wallet_id: wallet_uuid, - address_id: Some(address_id), - asset_code: "native".into(), - asset_issuer: None, - amount_stroops: (i + 1) as i64 * 100, - source_account: Some( - "GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6".into(), - ), - destination_account: Some( - "GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6".into(), - ), - stellar_tx_hash: format!("txhash-pag-{wallet_uuid}-{i}"), - operation_index: i as i32, - horizon_op_id: format!("op-pag-{wallet_uuid}-{i}"), - ledger: Some(i as i64), - memo_id: None, - }) - .await - .unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - } - - // Fetch first page with limit=2. - let uri = format!("/v1/wallets/{wallet_id}/transactions?limit=2"); - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j = body_json(resp).await; - let page1 = j["data"]["data"].as_array().unwrap(); - assert_eq!(page1.len(), 2, "first page must have exactly 2 items"); - let cursor = j["data"]["next_cursor"] - .as_str() - .expect("next_cursor must be present on first page"); - - // Fetch second page using the cursor. - let uri = format!("/v1/wallets/{wallet_id}/transactions?limit=2&before={cursor}"); - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let j2 = body_json(resp).await; - let page2 = j2["data"]["data"].as_array().unwrap(); - assert!(!page2.is_empty(), "second page must not be empty"); - - let ids1: Vec<&str> = page1.iter().map(|x| x["id"].as_str().unwrap()).collect(); - let ids2: Vec<&str> = page2.iter().map(|x| x["id"].as_str().unwrap()).collect(); - for id in &ids2 { - assert!(!ids1.contains(id), "pages must not overlap"); - } -} - -#[tokio::test] -async fn pagination_limit_boundaries_are_validated_consistently_with_sponsored_transactions() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - // limit=0 → 400 on all three endpoints. - for uri in [ - "/v1/wallets?limit=0".to_string(), - format!("/v1/wallets/{wallet_id}/addresses?limit=0"), - format!("/v1/wallets/{wallet_id}/transactions?limit=0"), - ] { - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "limit=0 must be 400 for {uri}" - ); - } - - // limit=201 → 400 on all three endpoints. - for uri in [ - "/v1/wallets?limit=201".to_string(), - format!("/v1/wallets/{wallet_id}/addresses?limit=201"), - format!("/v1/wallets/{wallet_id}/transactions?limit=201"), - ] { - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "limit=201 must be 400 for {uri}" - ); - } - - // limit=200 → 200 OK on all three endpoints (boundary is inclusive). - for uri in [ - "/v1/wallets?limit=200".to_string(), - format!("/v1/wallets/{wallet_id}/addresses?limit=200"), - format!("/v1/wallets/{wallet_id}/transactions?limit=200"), - ] { - let resp = app.clone().oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!( - resp.status(), - StatusCode::OK, - "limit=200 must be OK for {uri}" - ); - } -} - -// --------------------------------------------------------------------------- -// Payment links -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn payment_link_public_routes_require_no_auth_and_404_unknown_slugs() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - - // No Authorization header at all — must not be treated as unauthenticated-401, just 404. - let resp = app - .clone() - .oneshot(get("/v1/pay/does-not-exist")) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - - let resp = app - .clone() - .oneshot(post_json( - "/v1/pay/does-not-exist/intent", - r#"{"amount_usdc_stroops":100}"#, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - - let resp = app - .oneshot(get(&format!( - "/v1/pay/does-not-exist/payments/{}", - uuid::Uuid::new_v4() - ))) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn payment_link_management_requires_wallet_ownership() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let owner = auth_token(&app, &state).await; - let other = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &owner).await; - - let uri = format!("/v1/wallets/{wallet_id}/payment-links"); - let resp = app - .clone() - .oneshot(post_json_auth(&uri, r#"{"name":"Support"}"#, &other)) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::NOT_FOUND, - "a non-owner must not learn the wallet exists" - ); - - let resp = app - .clone() - .oneshot(post_json_auth(&uri, r#"{"name":"Support"}"#, &owner)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let created = body_json(resp).await; - let slug = created["data"]["slug"].as_str().unwrap().to_string(); - assert_eq!(created["data"]["active"], true); - assert_eq!(created["data"]["collected_usdc_stroops"], 0); - - // The public page for a freshly created, active, flexible-amount link is reachable with no - // auth and echoes back its deposit address. - let resp = app - .clone() - .oneshot(get(&format!("/v1/pay/{slug}"))) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let public = body_json(resp).await; - assert_eq!(public["data"]["name"], "Support"); - assert!(public["data"]["deposit_address"] - .as_str() - .unwrap() - .starts_with('M')); - - // Deactivating requires ownership too. - let link_id = created["data"]["id"].as_str().unwrap(); - let deactivate_uri = format!("/v1/wallets/{wallet_id}/payment-links/{link_id}"); - let resp = app - .clone() - .oneshot(put_json_auth( - &deactivate_uri, - r#"{"active":false}"#, - &other, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - - let resp = app - .clone() - .oneshot(put_json_auth( - &deactivate_uri, - r#"{"active":false}"#, - &owner, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(body_json(resp).await["data"]["active"], false); - - // An inactive link's public page must 404, not leak its (now-off) details. - let resp = app.oneshot(get(&format!("/v1/pay/{slug}"))).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn payment_link_response_includes_checkout_url_and_redirect_url() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - let uri = format!("/v1/wallets/{wallet_id}/payment-links"); - let resp = app - .clone() - .oneshot(post_json_auth( - &uri, - r#"{"name":"Support","redirect_url":"https://merchant.example/thank-you"}"#, - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let created = body_json(resp).await; - let slug = created["data"]["slug"].as_str().unwrap().to_string(); - let url = created["data"]["url"].as_str().unwrap(); - assert!( - url.ends_with(&format!("/pay/{slug}")), - "url must be a real hosted checkout link ending in /pay/, got {url}" - ); - assert_eq!( - created["data"]["redirect_url"], - "https://merchant.example/thank-you" - ); - - // GET and the public route must echo the same fields. - let link_id = created["data"]["id"].as_str().unwrap(); - let get_uri = format!("/v1/wallets/{wallet_id}/payment-links/{link_id}"); - let resp = app - .clone() - .oneshot(get_auth(&get_uri, &token)) - .await - .unwrap(); - let fetched = body_json(resp).await; - assert_eq!(fetched["data"]["url"], url); - assert_eq!( - fetched["data"]["redirect_url"], - "https://merchant.example/thank-you" - ); - - let resp = app.oneshot(get(&format!("/v1/pay/{slug}"))).await.unwrap(); - let public = body_json(resp).await; - assert_eq!( - public["data"]["redirect_url"], - "https://merchant.example/thank-you" - ); -} - -#[tokio::test] -async fn payment_link_intent_rejects_flexible_amount_without_one() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - let resp = app - .clone() - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id}/payment-links"), - r#"{"name":"Flexible"}"#, - &token, - )) - .await - .unwrap(); - let slug = body_json(resp).await["data"]["slug"] - .as_str() - .unwrap() - .to_string(); - - // No amount supplied for a flexible link → 400, not a panic or a free $0 intent. - let resp = app - .clone() - .oneshot(post_json(&format!("/v1/pay/{slug}/intent"), "{}")) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - - let resp = app - .oneshot(post_json( - &format!("/v1/pay/{slug}/intent"), - r#"{"payer_name":"Ada","payer_email":"ada@example.com","amount_usdc_stroops":5000000}"#, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let intent = body_json(resp).await; - assert_eq!(intent["data"]["amount_usdc_stroops"], 5_000_000); - assert!(intent["data"]["payment_id"].as_str().is_some()); -} - -// --------------------------------------------------------------------------- -// Wallet registration ownership challenge -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn create_wallet_without_challenge_is_rejected() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // A valid public key but no ownership proof — must be rejected, or anyone could register a - // stranger's account and watch its deposit history. - let account = stellar_base::crypto::DalekKeyPair::random() - .unwrap() - .public_key() - .account_id(); - let resp = app - .oneshot(post_json_auth( - "/v1/wallets", - &format!(r#"{{"public_key":"{account}"}}"#), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn create_wallet_rejects_signature_from_a_different_key() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - // The challenge is signed by key B, but the registration claims key A's account. - let kp_a = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let kp_b = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let account_a = kp_a.public_key().account_id(); - let (challenge, signature_by_b) = common::signed_challenge(&app, &token, &kp_b).await; - let resp = app - .oneshot(post_json_auth( - "/v1/wallets", - &format!( - r#"{{"public_key":"{account_a}","challenge":"{challenge}","signature":"{signature_by_b}"}}"# - ), - &token, - )) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "a signature from a different key must not prove ownership of account A" - ); -} - -#[tokio::test] -async fn create_wallet_rejects_another_users_challenge() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let user_a = auth_token(&app, &state).await; - let user_b = auth_token(&app, &state).await; - - // Challenge issued to user A, redeemed by user B: the HMAC user-binding must reject it, - // otherwise a captured (challenge, signature) pair could be replayed cross-account. - let kp = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let account = kp.public_key().account_id(); - let (challenge_for_a, signature) = common::signed_challenge(&app, &user_a, &kp).await; - let resp = app - .oneshot(post_json_auth( - "/v1/wallets", - &format!( - r#"{{"public_key":"{account}","challenge":"{challenge_for_a}","signature":"{signature}"}}"# - ), - &user_b, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -// --------------------------------------------------------------------------- -// Rate limiting -// --------------------------------------------------------------------------- - -/// Signup with an explicit `X-Forwarded-For` so the limiter buckets by a known IP. -fn signup_from_ip(email: &str, ip: &str) -> Request { - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .header("x-forwarded-for", ip) - .body(Body::from(format!( - r#"{{"email":"{email}","password":"supersecret"}}"# - ))) - .unwrap() -} - -#[tokio::test] -async fn signup_is_rate_limited_per_ip() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let ip = format!("203.0.113.{}", rand_octet()); - - // The limit is 10/min/IP; the 11th attempt from the same IP must be refused. - for i in 0..10 { - let email = format!("rl-{}-{i}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot(signup_from_ip(&email, &ip)) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::CREATED, - "signup {i} within the limit should succeed" - ); - } - let email = format!("rl-over-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot(signup_from_ip(&email, &ip)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); - - // A different IP has its own bucket and is unaffected. - let other_ip = format!("198.51.100.{}", rand_octet()); - let email = format!("rl-other-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .oneshot(signup_from_ip(&email, &other_ip)) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); -} - -/// A random last octet so parallel test runs don't share a limiter bucket. -fn rand_octet() -> u8 { - (uuid::Uuid::new_v4().as_bytes()[0] % 200) + 10 -} - -#[tokio::test] -async fn payment_intent_creation_is_rate_limited_per_ip() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - let resp = app - .clone() - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id}/payment-links"), - r#"{"name":"Rate limit test"}"#, - &token, - )) - .await - .unwrap(); - let slug = body_json(resp).await["data"]["slug"] - .as_str() - .unwrap() - .to_string(); - - let ip = format!("192.0.2.{}", rand_octet()); - let intent_req = || { - Request::builder() - .method("POST") - .uri(format!("/v1/pay/{slug}/intent")) - .header("content-type", "application/json") - .header("x-forwarded-for", ip.clone()) - .body(Body::from(r#"{"amount_usdc_stroops":1000000}"#)) - .unwrap() - }; - - // Intent creation is 5/min/IP — each one allocates an address and inserts a row, so it is - // deliberately much tighter than the read endpoints. - for i in 0..5 { - let resp = app.clone().oneshot(intent_req()).await.unwrap(); - assert_eq!( - resp.status(), - StatusCode::CREATED, - "intent {i} should succeed" - ); - } - let resp = app.oneshot(intent_req()).await.unwrap(); - assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); -} - -#[tokio::test] -async fn concurrent_payment_intents_get_distinct_deposit_addresses() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - let resp = app - .clone() - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id}/payment-links"), - r#"{"name":"Concurrent payers"}"#, - &token, - )) - .await - .unwrap(); - let slug = body_json(resp).await["data"]["slug"] - .as_str() - .unwrap() - .to_string(); - - // Two payers start paying the same link. Each must get its OWN deposit address, otherwise - // ingest can only guess which intent a landing deposit belongs to (oldest-pending), and - // payer B's money could confirm payer A's intent. - let mut addresses = Vec::new(); - let mut payment_ids = Vec::new(); - for (i, name) in ["Ada", "Grace"].iter().enumerate() { - let ip = format!("198.18.0.{}", 20 + i); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v1/pay/{slug}/intent")) - .header("content-type", "application/json") - .header("x-forwarded-for", ip) - .body(Body::from(format!( - r#"{{"payer_name":"{name}","amount_usdc_stroops":7000000}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let data = body_json(resp).await; - addresses.push( - data["data"]["deposit_address"] - .as_str() - .unwrap() - .to_string(), - ); - payment_ids.push(data["data"]["payment_id"].as_str().unwrap().to_string()); - } - - assert_ne!( - addresses[0], addresses[1], - "each payment intent must get its own muxed deposit address" - ); - assert_ne!(payment_ids[0], payment_ids[1]); - for addr in &addresses { - assert!( - addr.starts_with('M'), - "expected a muxed address, got {addr}" - ); - } - - // Both start out pending and independent. - for payment_id in &payment_ids { - let resp = app - .clone() - .oneshot(get(&format!("/v1/pay/{slug}/payments/{payment_id}"))) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(body_json(resp).await["data"]["status"], "pending"); - } -} - -// --------------------------------------------------------------------------- -// Payment link payments + image uploads -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn payment_link_payments_list_requires_ownership_and_returns_payers() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let owner = auth_token(&app, &state).await; - let other = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &owner).await; - - let resp = app - .clone() - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id}/payment-links"), - r#"{"name":"Payer list test","amount_usdc_stroops":4000000}"#, - &owner, - )) - .await - .unwrap(); - let created = body_json(resp).await; - let slug = created["data"]["slug"].as_str().unwrap().to_string(); - let link_id = created["data"]["id"].as_str().unwrap().to_string(); - - // A payer starts a payment; their name/email are captured on the intent. - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v1/pay/{slug}/intent")) - .header("content-type", "application/json") - .header("x-forwarded-for", format!("203.0.113.{}", rand_octet())) - .body(Body::from( - r#"{"payer_name":"Ada Lovelace","payer_email":"ada@example.com"}"#, - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - - let uri = format!("/v1/wallets/{wallet_id}/payment-links/{link_id}/payments"); - - // Payer email is personal data — another user must not be able to read it. - let resp = app.clone().oneshot(get_auth(&uri, &other)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - - // Unauthenticated is rejected too (this is not a public pay-page route). - let resp = app.clone().oneshot(get(&uri)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // The owner sees the payer details. - let resp = app.oneshot(get_auth(&uri, &owner)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await; - let rows = body["data"]["data"].as_array().unwrap(); - assert_eq!(rows.len(), 1, "the one intent should be listed: {body}"); - assert_eq!(rows[0]["payer_name"], "Ada Lovelace"); - assert_eq!(rows[0]["payer_email"], "ada@example.com"); - assert_eq!(rows[0]["amount_usdc_stroops"], 4_000_000); - assert_eq!( - rows[0]["status"], "pending", - "an intent with no deposit yet is pending" - ); -} - -#[tokio::test] -async fn upload_signature_requires_auth() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - - // No credential: must be 401 rather than handing out signed upload params. - let resp = app - .clone() - .oneshot(get("/v1/uploads/signature")) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - - // Authenticated: 200 with params when Cloudinary is configured, or a clear 400 when it - // isn't. Either way it must not be a 401/500 — the test env usually has no credentials. - let token = auth_token(&app, &state).await; - let resp = app - .oneshot(get_auth("/v1/uploads/signature", &token)) - .await - .unwrap(); - assert!( - resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST, - "expected signed params or a clear not-configured error, got {}", - resp.status() - ); -} - -#[tokio::test] -async fn submit_payment_validates_against_the_intents_own_address() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = create_wallet_for(&app, &token).await; - - let resp = app - .clone() - .oneshot(post_json_auth( - &format!("/v1/wallets/{wallet_id}/payment-links"), - r#"{"name":"Intent address test","amount_usdc_stroops":2000000}"#, - &token, - )) - .await - .unwrap(); - let created = body_json(resp).await; - let slug = created["data"]["slug"].as_str().unwrap().to_string(); - - // The link's own address, as advertised on the public page. - let resp = app - .clone() - .oneshot(get(&format!("/v1/pay/{slug}"))) - .await - .unwrap(); - let link_address = body_json(resp).await["data"]["deposit_address"] - .as_str() - .unwrap() - .to_string(); - - // An intent gets its OWN address, distinct from the link's. - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/v1/pay/{slug}/intent")) - .header("content-type", "application/json") - .header("x-forwarded-for", format!("198.51.100.{}", rand_octet())) - .body(Body::from(r#"{"payer_name":"Ada"}"#)) - .unwrap(), - ) - .await - .unwrap(); - let intent = body_json(resp).await; - let intent_address = intent["data"]["deposit_address"] - .as_str() - .unwrap() - .to_string(); - let payment_id = intent["data"]["payment_id"].as_str().unwrap().to_string(); - - assert_ne!( - link_address, intent_address, - "each intent must get its own address — this is what the relay validates against" - ); - - // A transaction paying the LINK's address (not this intent's) must be rejected: before the - // fix the relay compared against the link address, so every real Freighter payment — which - // correctly targets the intent address — was refused. - let payer = stellar_base::crypto::DalekKeyPair::random().unwrap(); - let decoded = stellar_strkey::ed25519::MuxedAccount::from_string(&link_address).unwrap(); - let wrong_dest = stellar_base::crypto::MuxedEd25519PublicKey::new( - stellar_base::crypto::PublicKey::from_slice(&decoded.ed25519).unwrap(), - decoded.id, - ); - let usdc = stellar_base::asset::Asset::new_credit( - "USDC", - stellar_base::crypto::PublicKey::from_account_id( - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - ) - .unwrap(), - ) - .unwrap(); - let op = stellar_base::operations::Operation::new_payment() - .with_destination(wrong_dest) - .with_amount(stellar_base::amount::Stroops::new(2_000_000)) - .unwrap() - .with_asset(usdc) - .build() - .unwrap(); - let mut tx = stellar_base::transaction::Transaction::builder( - payer.public_key(), - 1, - stellar_base::transaction::MIN_BASE_FEE, - ) - .add_operation(op) - .into_transaction() - .unwrap(); - tx.sign(payer.as_ref(), &stellar_base::network::Network::new_test()) - .unwrap(); - let signed_xdr = { - use stellar_base::xdr::XDRSerialize; - tx.into_envelope().xdr_base64().unwrap() - }; - - let app_clone = app.clone(); - let resp = app - .oneshot(post_json( - &format!("/v1/pay/{slug}/submit-signed"), - &format!(r#"{{"transaction_xdr":"{signed_xdr}","payment_id":"{payment_id}"}}"#), - )) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "a payment to the link's address rather than this intent's must be rejected" - ); - - // The same transaction aimed at the INTENT's address passes validation. It still fails at - // Horizon (the payer is unfunded), but the response is a 201 envelope with status "failed" - // rather than the 400 that means "we refused to relay this" — proving the destination and - // asset checks accepted it, which is the path a real Freighter payment takes. - let decoded = stellar_strkey::ed25519::MuxedAccount::from_string(&intent_address).unwrap(); - let right_dest = stellar_base::crypto::MuxedEd25519PublicKey::new( - stellar_base::crypto::PublicKey::from_slice(&decoded.ed25519).unwrap(), - decoded.id, - ); - let usdc = stellar_base::asset::Asset::new_credit( - "USDC", - stellar_base::crypto::PublicKey::from_account_id( - "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - ) - .unwrap(), - ) - .unwrap(); - let op = stellar_base::operations::Operation::new_payment() - .with_destination(right_dest) - .with_amount(stellar_base::amount::Stroops::new(2_000_000)) - .unwrap() - .with_asset(usdc) - .build() - .unwrap(); - let mut tx = stellar_base::transaction::Transaction::builder( - payer.public_key(), - 1, - stellar_base::transaction::MIN_BASE_FEE, - ) - .add_operation(op) - .into_transaction() - .unwrap(); - tx.sign(payer.as_ref(), &stellar_base::network::Network::new_test()) - .unwrap(); - let good_xdr = { - use stellar_base::xdr::XDRSerialize; - tx.into_envelope().xdr_base64().unwrap() - }; - - let resp = app_clone - .oneshot(post_json( - &format!("/v1/pay/{slug}/submit-signed"), - &format!(r#"{{"transaction_xdr":"{good_xdr}","payment_id":"{payment_id}"}}"#), - )) - .await - .unwrap(); - assert_eq!( - resp.status(), - StatusCode::CREATED, - "a USDC payment to this intent's own address must pass validation and be relayed" - ); -} - -/// Create a wallet for `token` and return its id. -async fn new_wallet_id(app: &axum::Router, token: &str) -> String { - let resp = app - .clone() - .oneshot(create_wallet_req(app, token).await) - .await - .unwrap(); - body_json(resp).await["data"]["id"] - .as_str() - .unwrap() - .to_string() -} - -#[tokio::test] -async fn get_gas_tank_returns_a_clean_not_provisioned_state_for_a_wallet_with_no_tank() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = new_wallet_id(&app, &token).await; - - let resp = app - .oneshot(get_auth( - &format!("/v1/wallets/{wallet_id}/gas-tank"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let data = body_json(resp).await["data"].clone(); - assert_eq!(data["provisioned"], false); -} - -#[tokio::test] -async fn list_deliveries_response_includes_the_new_diagnostic_fields() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; -#[tokio::test] -async fn get_gas_tank_returns_a_clean_not_provisioned_state_for_a_wallet_with_no_tank() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let wallet_id = new_wallet_id(&app, &token).await; - - let resp = app - .oneshot(get_auth( - &format!("/v1/wallets/{wallet_id}/gas-tank"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let data = body_json(resp).await["data"].clone(); - assert_eq!(data["provisioned"], false); - assert!(data["gas_tank_address"].is_null()); - assert_eq!(data["spent_today_stroops"], 0); -} - -#[tokio::test] -async fn get_gas_tank_returns_the_provisioned_tanks_status_and_spend() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = new_wallet_id(&app, &token).await; - - let created = body_json( - app.clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/gas-tank"), - &token, - )) - .await - .unwrap(), - ) - .await["data"]["gas_tank_address"] - .as_str() - .unwrap() - .to_string(); - - let put = Request::builder() - .method("PUT") - .uri(format!("/v1/wallets/{wallet_id}/sponsorship")) - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json") - .body(Body::from( - r#"{"enabled":true,"daily_budget_stroops":5000}"#, - )) - .unwrap(); - assert_eq!( - app.clone().oneshot(put).await.unwrap().status(), - StatusCode::OK - ); - - let resp = app - .oneshot(get_auth( - &format!("/v1/wallets/{wallet_id}/gas-tank"), - &token, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let data = body_json(resp).await["data"].clone(); - assert_eq!(data["provisioned"], true); - assert_eq!(data["gas_tank_address"], created); - assert_eq!(data["sponsorship_enabled"], true); - assert_eq!(data["daily_budget_stroops"], 5000); - assert_eq!(data["spent_today_stroops"], 0); -} - -#[tokio::test] -async fn get_gas_tank_never_includes_sealed_seed_fields() { - let Some(state) = test_state().await else { - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - let wallet_id = new_wallet_id(&app, &token).await; - app.clone() - .oneshot(post_auth( - &format!("/v1/wallets/{wallet_id}/gas-tank"), - &token, - )) - .await - .unwrap(); - - let resp = app - .oneshot(get_auth( - &format!("/v1/wallets/{wallet_id}/gas-tank"), - &token, - )) - .await - .unwrap(); - let raw = body_json(resp).await["data"].to_string(); - for banned in ["sealed", "ciphertext", "nonce", "salt", "seed", "secret"] { - assert!(!raw.contains(banned), "response leaked `{banned}`: {raw}"); - } -} - -#[tokio::test] -async fn list_deliveries_response_includes_the_new_diagnostic_fields() { - let Some(state) = test_state().await else { - eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); - return; - }; - let app = build_router(state.clone()); - let token = auth_token(&app, &state).await; - - let wallet_id: uuid::Uuid = create_wallet_for(&app, &token).await.parse().unwrap(); - - // Seed a failed delivery directly: this test covers the read path, not dispatch. - let ep = state - .store() - .create_webhook_endpoint(wallet_id, "https://merchant.example/hook", "s") - .await - .unwrap(); - for _ in 0..2 { - state - .store() - .log_webhook_delivery( - ep.id, - "deposit.created", - &serde_json::json!({}), - "failed", - 3, - Some(503), - Some("upstream unavailable"), - ) - .await - .unwrap(); - } - - let uri = format!("/v1/wallets/{wallet_id}/webhooks/{}/deliveries?limit=1", ep.id); - let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let rows = body_json(resp).await["data"].as_array().unwrap().clone(); - assert_eq!(rows.len(), 1, "?limit= must be honoured"); - assert_eq!(rows[0]["response_code"], 503); - assert_eq!(rows[0]["response_body_snippet"], "upstream unavailable"); - assert_eq!(rows[0]["attempts"], 3); -} - -} diff --git a/crates/store/migrations/0025_archive_wallets.sql b/crates/store/migrations/0025_archive_wallets.sql new file mode 100644 index 0000000..86ad834 --- /dev/null +++ b/crates/store/migrations/0025_archive_wallets.sql @@ -0,0 +1,3 @@ +-- Wallet archival: records when a wallet was retired without losing history. +ALTER TABLE wallets ADD COLUMN archived_at TIMESTAMPTZ; +CREATE INDEX idx_wallets_archived_at ON wallets (archived_at); diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs index 20e6712..e69de29 100644 --- a/crates/store/src/error.rs +++ b/crates/store/src/error.rs @@ -1,50 +0,0 @@ -//! Store error type. - -use thiserror::Error; - -/// Errors from the persistence layer. -#[derive(Debug, Error)] -pub enum StoreError { - /// Underlying database error. - #[error("database error")] - Database(#[from] sqlx::Error), - - /// A migration failed to apply. - #[error("migration error")] - Migration(#[from] sqlx::migrate::MigrateError), - - /// A uniqueness constraint was violated (e.g. duplicate on-chain tx, or idempotency key). - /// Callers use this to make inserts idempotent without leaking DB internals. - #[error("conflict: record already exists")] - Conflict, - - /// A requested row was not found. - #[error("not found")] - NotFound, - - /// The daily sponsorship budget would be exceeded by this request. - #[error("daily sponsorship budget exceeded")] - BudgetExceeded, - - /// An OTP was wrong, expired, already used, over the attempt limit, or tx-hash mismatched. - #[error("invalid or expired code")] - InvalidOtp, - - /// A Stellar memo ID cannot be negative. - #[error("memo id must be nonnegative")] - InvalidMemoId, -} - -impl StoreError { - /// Map a raw sqlx error to [`StoreError::Conflict`] when it is a unique-violation, otherwise - /// pass it through. Lets callers treat "already inserted" as a benign no-op. - pub(crate) fn from_sqlx_conflict(err: sqlx::Error) -> Self { - if let sqlx::Error::Database(ref dbe) = err { - // Postgres unique_violation = SQLSTATE 23505. - if dbe.code().as_deref() == Some("23505") { - return StoreError::Conflict; - } - } - StoreError::Database(err) - } -} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 8e58090..e69de29 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,1951 +0,0 @@ -//! Postgres persistence for octo (sqlx). -//! -//! Tables: `wallets`, `addresses`, `transactions`, `withdrawals`, `webhook_endpoints`, -//! `webhook_deliveries`, `ingest_cursor` — see `migrations/0001_init.sql`. -//! -//! Security-relevant guarantees implemented here (see `docs/threat-model.md`): -//! - All queries are parameterized (no string-built SQL) → no SQL injection. -//! - [`Store::allocate_address`] increments the per-wallet muxed-id counter **atomically** inside a -//! transaction, so concurrent address creation can't collide or reuse an id. -//! - [`Store::record_deposit`] is **idempotent** on the immutable `(tx_hash, operation_index)` -//! unique index, so a replayed/reorged Horizon event cannot double-credit. -//! - [`Store::create_withdrawal`] is idempotent on `(wallet_id, idempotency_key)`. -#![forbid(unsafe_code)] - -mod error; -mod models; - -pub use error::StoreError; -pub use models::{ - Address, ApiKey, AuditLog, DenylistedToken, EmailOtp, GasSponsorshipConfig, NewDeposit, - NewPaymentLink, NewSponsoredTx, PaymentLink, PaymentLinkPayment, SponsoredTransaction, - Transaction, User, Wallet, WebhookDelivery, WebhookEndpoint, WhitelistedAddress, Withdrawal, - WithdrawalAllowlistConfig, -}; - -use sqlx::postgres::{PgPool, PgPoolOptions}; -use uuid::Uuid; - -/// Embedded migrations, applied by [`Store::migrate`]. -pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); - -/// A handle to the database (cloneable; wraps a connection pool). -#[derive(Clone)] -pub struct Store { - pool: PgPool, -} - -/// Parameters for creating a server-custody wallet (legacy wallets and gas-tank fee accounts — -/// the only rows that carry a server-held sealed seed). -pub struct NewWallet<'a> { - pub network: &'a str, - pub stellar_account_g: &'a str, - pub sealed_ciphertext: &'a [u8], - pub sealed_nonce: &'a [u8], - pub sealed_salt: &'a [u8], - /// Scheme version tag for the sealed seed. Use `octo_crypto::SCHEME_V1`. - pub sealed_scheme: i16, - pub label: Option<&'a str>, - pub user_id: Option, - pub description: Option<&'a str>, -} - -/// Parameters for creating a non-custodial (client-custody) wallet: the client generated the -/// keypair and sends only the public account plus an opaque password-encrypted backup blob the -/// server cannot decrypt. -pub struct NewClientWallet<'a> { - pub network: &'a str, - pub stellar_account_g: &'a str, - pub encrypted_backup: Option<&'a str>, - pub label: Option<&'a str>, - pub user_id: Option, - pub description: Option<&'a str>, -} - -/// Parameters for creating a withdrawal intent. -pub struct NewWithdrawal<'a> { - pub wallet_id: Uuid, - pub idempotency_key: &'a str, - pub destination_account: &'a str, - pub asset_code: &'a str, - pub asset_issuer: Option<&'a str>, - pub amount_stroops: i64, - pub memo_id: Option, -} - -impl Store { - /// Connect to Postgres at `database_url` and return a pooled handle. - pub async fn connect(database_url: &str) -> Result { - let pool = PgPoolOptions::new() - .max_connections(10) - .connect(database_url) - .await?; - Ok(Self { pool }) - } - - /// Build a store from an existing pool (useful in tests). - pub fn from_pool(pool: PgPool) -> Self { - Self { pool } - } - - /// Apply all pending migrations. - pub async fn migrate(&self) -> Result<(), StoreError> { - MIGRATOR.run(&self.pool).await?; - Ok(()) - } - - /// Borrow the underlying pool. - pub fn pool(&self) -> &PgPool { - &self.pool - } - - // Ping the database to verify pool reachability. - pub async fn ping(&self) -> Result<(), StoreError> { - sqlx::query("SELECT 1").execute(&self.pool).await?; - Ok(()) - } - - // --- users ------------------------------------------------------------ - - /// Create a user. `email` should already be lowercased by the caller. Returns - /// [`StoreError::Conflict`] if the email is already registered. - pub async fn create_user(&self, email: &str, password_hash: &str) -> Result { - sqlx::query_as::<_, User>( - "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *", - ) - .bind(email) - .bind(password_hash) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// Set a user's display username. Returns [`StoreError::Conflict`] if another user already - /// has it (compared case-insensitively, per the `users_username_unique_idx` index). - pub async fn update_username(&self, user_id: Uuid, username: &str) -> Result { - sqlx::query_as::<_, User>( - "UPDATE users SET username = $2, updated_at = now() WHERE id = $1 RETURNING *", - ) - .bind(user_id) - .bind(username) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// Delete a user outright. Only safe pre-verification — used to roll back a signup whose - /// OTP email never went out, so the email isn't stuck as "already registered" forever. - pub async fn delete_unverified_user(&self, user_id: Uuid) -> Result<(), StoreError> { - sqlx::query("DELETE FROM users WHERE id = $1 AND email_verified_at IS NULL") - .bind(user_id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Look up a user by email (caller lowercases). - pub async fn find_user_by_email(&self, email: &str) -> Result, StoreError> { - let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") - .bind(email) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// Fetch a user by id. - pub async fn get_user(&self, id: Uuid) -> Result, StoreError> { - let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// Mark a user's email as verified. - pub async fn mark_email_verified(&self, user_id: Uuid) -> Result<(), StoreError> { - sqlx::query("UPDATE users SET email_verified_at = now() WHERE id = $1") - .bind(user_id) - .execute(&self.pool) - .await?; - Ok(()) - } - - // --- email OTP ---------------------------------------------------------- - - /// Issue a fresh OTP row. Callers hash the code themselves before calling this. - pub async fn create_otp( - &self, - user_id: Uuid, - purpose: &str, - code_hash: &str, - tx_hash_bound: Option<&str>, - ttl: chrono::Duration, - ) -> Result { - let id: Uuid = sqlx::query_scalar( - "INSERT INTO email_otps (user_id, purpose, code_hash, tx_hash_bound, expires_at) - VALUES ($1, $2, $3, $4, now() + $5) RETURNING id", - ) - .bind(user_id) - .bind(purpose) - .bind(code_hash) - .bind(tx_hash_bound) - .bind(ttl) - .fetch_one(&self.pool) - .await?; - Ok(id) - } - - /// Verify an already-hashed code against the most recent unconsumed OTP for - /// `(user_id, purpose)`. On a wrong code, increments `attempts` and returns `InvalidOtp` - /// rather than panicking — callers should surface a generic "invalid or expired code" either - /// way, so guessing can't distinguish "wrong code" from "no such code exists". - pub async fn verify_and_consume_otp( - &self, - user_id: Uuid, - purpose: &str, - code_hash: &str, - tx_hash_bound: Option<&str>, - ) -> Result<(), StoreError> { - const MAX_ATTEMPTS: i16 = 5; - - let otp = sqlx::query_as::<_, EmailOtp>( - "SELECT * FROM email_otps WHERE user_id = $1 AND purpose = $2 - ORDER BY created_at DESC LIMIT 1", - ) - .bind(user_id) - .bind(purpose) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::InvalidOtp)?; - - if otp.consumed_at.is_some() - || otp.attempts >= MAX_ATTEMPTS - || otp.expires_at < chrono::Utc::now() - || otp.tx_hash_bound.as_deref() != tx_hash_bound - { - return Err(StoreError::InvalidOtp); - } - if otp.code_hash != code_hash { - sqlx::query("UPDATE email_otps SET attempts = attempts + 1 WHERE id = $1") - .bind(otp.id) - .execute(&self.pool) - .await?; - return Err(StoreError::InvalidOtp); - } - - sqlx::query("UPDATE email_otps SET consumed_at = now() WHERE id = $1") - .bind(otp.id) - .execute(&self.pool) - .await?; - Ok(()) - } - - // --- audit logs ------------------------------------------------------- - - /// Append an audit-log entry. Best-effort: failures are surfaced to the caller, which logs and - /// continues (auditing must never block the primary operation). - pub async fn record_audit( - &self, - user_id: Uuid, - action: &str, - category: &str, - target: Option<&str>, - ip_address: Option<&str>, - ) -> Result<(), StoreError> { - sqlx::query( - "INSERT INTO audit_logs (user_id, action, category, target, ip_address) - VALUES ($1, $2, $3, $4, $5)", - ) - .bind(user_id) - .bind(action) - .bind(category) - .bind(target) - .bind(ip_address) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// List a user's audit logs (most recent first), optionally filtered by `category` and a - /// case-insensitive `search` over the action/target. Capped at `limit` rows. - pub async fn list_audit_logs( - &self, - user_id: Uuid, - category: Option<&str>, - search: Option<&str>, - limit: i64, - ) -> Result, StoreError> { - // Build with optional filters; `$2`/`$3` are NULL when not provided. - let rows = sqlx::query_as::<_, AuditLog>( - r#" - SELECT * FROM audit_logs - WHERE user_id = $1 - AND ($2::text IS NULL OR category = $2) - AND ($3::text IS NULL OR action ILIKE '%' || $3 || '%' - OR coalesce(target, '') ILIKE '%' || $3 || '%') - ORDER BY created_at DESC - LIMIT $4 - "#, - ) - .bind(user_id) - .bind(category) - .bind(search) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - // --- api keys --------------------------------------------------------- - - /// Create or replace the wallet's API key (regenerate). Stores only the hash + display prefix. - pub async fn upsert_api_key( - &self, - wallet_id: Uuid, - prefix: &str, - key_hash: &str, - ) -> Result { - sqlx::query_as::<_, ApiKey>( - r#" - INSERT INTO api_keys (wallet_id, prefix, key_hash) - VALUES ($1, $2, $3) - ON CONFLICT (wallet_id) - DO UPDATE SET prefix = EXCLUDED.prefix, key_hash = EXCLUDED.key_hash, - created_at = now() - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(prefix) - .bind(key_hash) - .fetch_one(&self.pool) - .await - .map_err(StoreError::Database) - } - - /// Get the wallet's API key metadata (prefix only — never the secret), if one exists. - pub async fn get_api_key(&self, wallet_id: Uuid) -> Result, StoreError> { - let row = sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys WHERE wallet_id = $1") - .bind(wallet_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// Look up the wallet that owns a key by its hash (for API-key authentication later). - pub async fn wallet_id_for_key_hash(&self, key_hash: &str) -> Result, StoreError> { - let row: Option<(Uuid,)> = - sqlx::query_as("SELECT wallet_id FROM api_keys WHERE key_hash = $1") - .bind(key_hash) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| r.0)) - } - - /// Delete (revoke) the API key for a wallet. Returns `Ok(())` even if no key existed. - pub async fn delete_api_key(&self, wallet_id: Uuid) -> Result<(), StoreError> { - sqlx::query("DELETE FROM api_keys WHERE wallet_id = $1") - .bind(wallet_id) - .execute(&self.pool) - .await?; - Ok(()) - } - - // --- wallets ---------------------------------------------------------- - - /// Create a master wallet. Fails with [`StoreError::Conflict`] if the account already exists. - pub async fn create_wallet(&self, new: NewWallet<'_>) -> Result { - sqlx::query_as::<_, Wallet>( - r#" - INSERT INTO wallets - (network, stellar_account_g, sealed_ciphertext, sealed_nonce, sealed_salt, - sealed_scheme, label, user_id, description, custody) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'server') - RETURNING * - "#, - ) - .bind(new.network) - .bind(new.stellar_account_g) - .bind(new.sealed_ciphertext) - .bind(new.sealed_nonce) - .bind(new.sealed_salt) - .bind(new.sealed_scheme) - .bind(new.label) - .bind(new.user_id) - .bind(new.description) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// Attach a gas-tank fee account to a client-custody wallet: stores the tank's sealed seed - /// and public account. The tank only ever holds fee float — never customer funds. - /// - /// `sealed_scheme` must be written alongside the seed: the `wallets_gas_tank_has_seed` CHECK - /// requires it, and key rotation (`bin/migrate-keys`) needs the tag to know how to open it. - pub async fn set_gas_tank( - &self, - wallet_id: Uuid, - gas_tank_account_g: &str, - sealed_ciphertext: &[u8], - sealed_nonce: &[u8], - sealed_salt: &[u8], - sealed_scheme: i16, - ) -> Result { - sqlx::query_as::<_, Wallet>( - r#" - UPDATE wallets - SET gas_tank_account_g = $2, sealed_ciphertext = $3, sealed_nonce = $4, - sealed_salt = $5, sealed_scheme = $6, updated_at = now() - WHERE id = $1 AND custody = 'client' AND gas_tank_account_g IS NULL - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(gas_tank_account_g) - .bind(sealed_ciphertext) - .bind(sealed_nonce) - .bind(sealed_salt) - .bind(sealed_scheme) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::Conflict) // already has a tank, or not a client wallet - } - - /// Create a non-custodial wallet: no seed is stored; the server can never sign for it. - pub async fn create_client_wallet( - &self, - new: NewClientWallet<'_>, - ) -> Result { - sqlx::query_as::<_, Wallet>( - r#" - INSERT INTO wallets - (network, stellar_account_g, label, user_id, description, custody, - encrypted_backup) - VALUES ($1, $2, $3, $4, $5, 'client', $6) - RETURNING * - "#, - ) - .bind(new.network) - .bind(new.stellar_account_g) - .bind(new.label) - .bind(new.user_id) - .bind(new.description) - .bind(new.encrypted_backup) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// List a user's wallets (most recent first), with optional cursor-based pagination. - /// - /// Fetching `limit + 1` rows lets the caller detect whether a next page exists without a - /// separate COUNT query — the same pattern used by `list_sponsored_transactions`. - pub async fn list_wallets_for_user( - &self, - user_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Wallet>( - r#" - SELECT * FROM wallets - WHERE user_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM wallets WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(user_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Paginated version of [`list_wallets_for_user`]: returns at most `limit` rows, newest first. - /// Pass the last page's final wallet id as `before_id` to fetch the next page. - pub async fn list_wallets_for_user_page( - &self, - user_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let query = cursor_pagination_query("wallets", "user_id"); - let rows = sqlx::query_as::<_, Wallet>(&query) - .bind(user_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// List all wallets (used by the ingest supervisor to fan out poll loops). - pub async fn list_wallets(&self) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Wallet>("SELECT * FROM wallets ORDER BY created_at") - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Wallets on `network` that are due for an ingest poll, given activity-based backoff. - /// - /// A dev/production database accumulates wallets that never see another deposit. Polling all - /// of them on the same short cycle spends the concurrency budget on dead accounts and delays - /// the ones that are actually transacting. Idleness is measured by `ingest_cursor.updated_at`, - /// which is only bumped when a record is actually processed: - /// - /// - active (last activity < `active_after_secs`): every tick - /// - idle: at most once per `idle_interval_secs` - /// - dormant (last activity older than `dormant_after_secs`): at most once per - /// `dormant_interval_secs` - /// - /// A wallet with no cursor row has never been polled, so it is always due. - pub async fn wallets_due_for_poll( - &self, - network: &str, - active_after_secs: i64, - idle_interval_secs: i64, - dormant_after_secs: i64, - dormant_interval_secs: i64, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Wallet>( - r#" - SELECT w.* FROM wallets w - LEFT JOIN ingest_cursor c ON c.wallet_id = w.id - WHERE w.network = $1 - -- Never polled, or never saw activity => always due. - AND ( - c.last_polled_at IS NULL - OR c.updated_at IS NULL - OR c.last_polled_at < now() - make_interval(secs => - CASE - -- Active: no extra wait, poll every tick. - WHEN c.updated_at > now() - make_interval(secs => $2) THEN 0 - -- Dormant: longest wait between polls. - WHEN c.updated_at <= now() - make_interval(secs => $4) THEN $5 - -- Idle: in between. - ELSE $3 - END) - ) - ORDER BY w.created_at - "#, - ) - .bind(network) - .bind(active_after_secs as f64) - .bind(idle_interval_secs as f64) - .bind(dormant_after_secs as f64) - .bind(dormant_interval_secs as f64) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Record that a wallet was polled (whether or not anything new arrived). - /// - /// Distinct from [`Store::set_cursor`], which only advances on real activity — the backoff - /// tiers need both "when did we last see money" and "when did we last look". - pub async fn mark_polled(&self, wallet_id: Uuid) -> Result<(), StoreError> { - // `updated_at` is deliberately backdated to the epoch on INSERT: it means "last time this - // wallet saw activity", and merely looking at a wallet is not activity. Letting it take - // its `DEFAULT now()` would mark every never-used wallet as freshly active and the - // backoff tiers would never engage. `set_cursor` is the only writer that advances it. - sqlx::query( - r#" - INSERT INTO ingest_cursor (wallet_id, last_polled_at, updated_at) - VALUES ($1, now(), 'epoch') - ON CONFLICT (wallet_id) DO UPDATE SET last_polled_at = now() - "#, - ) - .bind(wallet_id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Fetch a wallet by id. - pub async fn get_wallet(&self, id: Uuid) -> Result { - sqlx::query_as::<_, Wallet>("SELECT * FROM wallets WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::NotFound) - } - - /// Atomically swap the sealed seed material for a single wallet after a reseal/key-rotation. - /// - /// The caller (typically `bin/migrate-keys`) opens the old seed with the old master key, - /// re-seals it with the new master key via `octo_crypto::reseal`, and then calls this method - /// to persist the result. The `expected_scheme` guard ensures idempotency: if the row was - /// already migrated (e.g. by a concurrent runner) the update is silently skipped rather than - /// overwriting a newer record. - /// - /// Returns `true` if the row was updated, `false` if it was already on the target scheme. - pub async fn reseal_wallet( - &self, - wallet_id: Uuid, - new_ciphertext: &[u8], - new_nonce: &[u8], - new_salt: &[u8], - new_scheme: i16, - expected_old_scheme: i16, - ) -> Result { - // Only update the row if it still carries the old scheme — this is the idempotency guard. - // A concurrent runner that already migrated this wallet will have set sealed_scheme to - // `new_scheme`, so the WHERE clause won't match and no double-reseal can occur. - let result = sqlx::query( - r#" - UPDATE wallets - SET sealed_ciphertext = $2, - sealed_nonce = $3, - sealed_salt = $4, - sealed_scheme = $5, - updated_at = now() - WHERE id = $1 - AND sealed_scheme = $6 - "#, - ) - .bind(wallet_id) - .bind(new_ciphertext) - .bind(new_nonce) - .bind(new_salt) - .bind(new_scheme) - .bind(expected_old_scheme) - .execute(&self.pool) - .await?; - - Ok(result.rows_affected() > 0) - } - - /// Fetch a page of wallets whose `sealed_scheme` does not equal `target_scheme`, for the - /// migration backfill job. Returns at most `batch_size` rows ordered by `id` (stable for - /// resumable cursored iteration). Pass the last returned wallet's `id` as `after_id` on - /// subsequent calls to page through the full table without re-scanning already-migrated rows. - pub async fn list_wallets_needing_reseal( - &self, - target_scheme: i16, - batch_size: i64, - after_id: Option, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Wallet>( - r#" - SELECT * FROM wallets - WHERE sealed_scheme <> $1 - AND ($2::uuid IS NULL OR id > $2) - ORDER BY id - LIMIT $3 - "#, - ) - .bind(target_scheme) - .bind(after_id) - .bind(batch_size) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - // --- addresses -------------------------------------------------------- - - /// Atomically allocate the next muxed id for `wallet_id` and insert the address row. - /// - /// The counter bump and the insert happen in one transaction with a row lock, so two - /// concurrent callers always get distinct, gap-free-enough ids and never collide. - pub async fn allocate_address( - &self, - wallet_id: Uuid, - muxed_address_for: impl FnOnce(i64) -> Result, - customer_ref: Option<&str>, - metadata: serde_json::Value, - ) -> Result { - let mut tx = self.pool.begin().await?; - - // Lock the wallet row and read+bump the counter. - let next_id: i64 = - sqlx::query_scalar("SELECT next_muxed_id FROM wallets WHERE id = $1 FOR UPDATE") - .bind(wallet_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(StoreError::NotFound)?; - - sqlx::query("UPDATE wallets SET next_muxed_id = next_muxed_id + 1, updated_at = now() WHERE id = $1") - .bind(wallet_id) - .execute(&mut *tx) - .await?; - - // Derive the muxed address for this id via the caller-provided closure (wallet-core). - let muxed_address = muxed_address_for(next_id).map_err(|_| StoreError::NotFound)?; - - let address = sqlx::query_as::<_, Address>( - r#" - INSERT INTO addresses (wallet_id, muxed_id, muxed_address, customer_ref, metadata) - VALUES ($1, $2, $3, $4, $5) - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(next_id) - .bind(&muxed_address) - .bind(customer_ref) - .bind(metadata) - .fetch_one(&mut *tx) - .await - .map_err(StoreError::from_sqlx_conflict)?; - - tx.commit().await?; - Ok(address) - } - - /// List addresses for a wallet (most recent first), with optional cursor-based pagination. - pub async fn list_addresses( - &self, - wallet_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Address>( - r#" - SELECT * FROM addresses - WHERE wallet_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM addresses WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Paginated version of [`list_addresses`]: returns at most `limit` rows, newest first. - /// Pass the last page's final address id as `before_id` to fetch the next page. - pub async fn list_addresses_page( - &self, - wallet_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let query = cursor_pagination_query("addresses", "wallet_id"); - let rows = sqlx::query_as::<_, Address>(&query) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Fetch an address by id. - pub async fn get_address(&self, id: Uuid) -> Result, StoreError> { - let row = sqlx::query_as::<_, Address>("SELECT * FROM addresses WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// Find the address for a given `(wallet_id, muxed_id)`, if any. - pub async fn address_by_muxed_id( - &self, - wallet_id: Uuid, - muxed_id: i64, - ) -> Result, StoreError> { - let row = sqlx::query_as::<_, Address>( - "SELECT * FROM addresses WHERE wallet_id = $1 AND muxed_id = $2", - ) - .bind(wallet_id) - .bind(muxed_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - // --- transactions (deposits) ------------------------------------------ - - /// Idempotently record a confirmed deposit. - /// - /// Returns `Ok(Some(tx))` on first insert and `Ok(None)` if this exact on-chain operation was - /// already recorded (the `(tx_hash, operation_index)` unique index fired) — so replays and - /// reorged re-deliveries never double-credit. - pub async fn record_deposit(&self, d: &NewDeposit) -> Result, StoreError> { - let result = sqlx::query_as::<_, Transaction>( - r#" - INSERT INTO transactions - (wallet_id, address_id, direction, asset_code, asset_issuer, amount_stroops, - source_account, destination_account, stellar_tx_hash, operation_index, - horizon_op_id, ledger, memo_id, status) - VALUES ($1, $2, 'deposit', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'confirmed') - RETURNING * - "#, - ) - .bind(d.wallet_id) - .bind(d.address_id) - .bind(&d.asset_code) - .bind(&d.asset_issuer) - .bind(d.amount_stroops) - .bind(&d.source_account) - .bind(&d.destination_account) - .bind(&d.stellar_tx_hash) - .bind(d.operation_index) - .bind(&d.horizon_op_id) - .bind(d.ledger) - .bind(d.memo_id) - .fetch_one(&self.pool) - .await; - - match result { - Ok(tx) => Ok(Some(tx)), - Err(e) => match StoreError::from_sqlx_conflict(e) { - StoreError::Conflict => Ok(None), // already recorded — benign - other => Err(other), - }, - } - } - - /// List transactions for a wallet (most recent first), with optional cursor-based pagination. - pub async fn list_transactions( - &self, - wallet_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Transaction>( - r#" - SELECT * FROM transactions - WHERE wallet_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM transactions WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Paginated version of [`list_transactions`]: returns at most `limit` rows, newest first. - /// Pass the last page's final transaction id as `before_id` to fetch the next page. - pub async fn list_transactions_page( - &self, - wallet_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let query = cursor_pagination_query("transactions", "wallet_id"); - let rows = sqlx::query_as::<_, Transaction>(&query) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Fetch a single transaction by id. - pub async fn get_transaction(&self, id: Uuid) -> Result, StoreError> { - let row = sqlx::query_as::<_, Transaction>("SELECT * FROM transactions WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - // --- withdrawals ------------------------------------------------------ - - /// Cheap existence check on `(wallet_id, idempotency_key)`, used to short-circuit a retried - /// request with a 409 **before** running any pre-flight Horizon checks — a key that has - /// already been consumed doesn't need its request re-validated against the chain. - pub async fn withdrawal_exists( - &self, - wallet_id: Uuid, - idempotency_key: &str, - ) -> Result { - let found: Option = sqlx::query_scalar( - "SELECT id FROM withdrawals WHERE wallet_id = $1 AND idempotency_key = $2", - ) - .bind(wallet_id) - .bind(idempotency_key) - .fetch_optional(&self.pool) - .await?; - Ok(found.is_some()) - } - - /// Create a withdrawal intent. Idempotent on `(wallet_id, idempotency_key)`: a retried request - /// with the same key returns [`StoreError::Conflict`] instead of creating a second payout. - /// Record a confirmed/failed outbound transfer in the `transactions` history (the table the - /// dashboard lists). Withdrawals previously lived only in `withdrawals`, which is why they - /// never showed up in "recent transactions". - #[allow(clippy::too_many_arguments)] - pub async fn record_withdrawal_transaction( - &self, - wallet_id: Uuid, - asset_code: &str, - asset_issuer: Option<&str>, - amount_stroops: i64, - source_account: &str, - destination_account: &str, - stellar_tx_hash: Option<&str>, - status: &str, - ) -> Result { - let row = sqlx::query_as::<_, Transaction>( - r#" - INSERT INTO transactions - (wallet_id, direction, asset_code, asset_issuer, amount_stroops, - source_account, destination_account, stellar_tx_hash, status) - VALUES ($1, 'withdrawal', $2, $3, $4, $5, $6, $7, $8) - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(asset_code) - .bind(asset_issuer) - .bind(amount_stroops) - .bind(source_account) - .bind(destination_account) - .bind(stellar_tx_hash) - .bind(status) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn create_withdrawal( - &self, - new: NewWithdrawal<'_>, - ) -> Result { - sqlx::query_as::<_, Withdrawal>( - r#" - INSERT INTO withdrawals - (wallet_id, idempotency_key, destination_account, asset_code, asset_issuer, - amount_stroops, memo_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING * - "#, - ) - .bind(new.wallet_id) - .bind(new.idempotency_key) - .bind(new.destination_account) - .bind(new.asset_code) - .bind(new.asset_issuer) - .bind(new.amount_stroops) - .bind(new.memo_id) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// Update a withdrawal's status (and optional tx hash) after submission. - pub async fn update_withdrawal_status( - &self, - id: Uuid, - status: &str, - stellar_tx_hash: Option<&str>, - ) -> Result<(), StoreError> { - sqlx::query( - "UPDATE withdrawals SET status = $2, stellar_tx_hash = $3, updated_at = now() WHERE id = $1", - ) - .bind(id) - .bind(status) - .bind(stellar_tx_hash) - .execute(&self.pool) - .await?; - Ok(()) - } - - // --- sponsored transactions ------------------------------------------- - - /// List sponsored transactions for a wallet (most recent first), with - /// optional status filter and cursor-based pagination. - pub async fn list_sponsored_transactions( - &self, - wallet_id: Uuid, - limit: i64, - status_filter: Option<&str>, - before_id: Option, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, SponsoredTransaction>( - r#" - SELECT * FROM sponsored_transactions - WHERE wallet_id = $1 - AND ($2::text IS NULL OR status = $2) - AND ($3::uuid IS NULL OR (created_at, id) < (SELECT created_at, id FROM sponsored_transactions WHERE id = $3)) - ORDER BY created_at DESC, id DESC - LIMIT $4 - "#, - ) - .bind(wallet_id) - .bind(status_filter) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - // --- gas sponsorship config ------------------------------------------- - - /// Fetch a wallet's sponsorship config, or `None` if none has been saved. - pub async fn get_gas_sponsorship_config( - &self, - wallet_id: Uuid, - ) -> Result, StoreError> { - let row = sqlx::query_as::<_, GasSponsorshipConfig>( - "SELECT * FROM gas_sponsorship_configs WHERE wallet_id = $1", - ) - .bind(wallet_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// Create or replace a wallet's sponsorship config. - pub async fn upsert_gas_sponsorship_config( - &self, - wallet_id: Uuid, - enabled: bool, - per_tx_fee_cap_stroops: Option, - daily_budget_stroops: Option, - ) -> Result { - sqlx::query_as::<_, GasSponsorshipConfig>( - r#" - INSERT INTO gas_sponsorship_configs - (wallet_id, enabled, per_tx_fee_cap_stroops, daily_budget_stroops) - VALUES ($1, $2, $3, $4) - ON CONFLICT (wallet_id) DO UPDATE SET - enabled = EXCLUDED.enabled, - per_tx_fee_cap_stroops = EXCLUDED.per_tx_fee_cap_stroops, - daily_budget_stroops = EXCLUDED.daily_budget_stroops, - updated_at = now() - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(enabled) - .bind(per_tx_fee_cap_stroops) - .bind(daily_budget_stroops) - .fetch_one(&self.pool) - .await - .map_err(StoreError::Database) - } - - /// Sum of sponsored fees reserved (pending + confirmed) for a wallet so far today (UTC). - /// Used to enforce the rolling daily budget and to report `spent_today`. - pub async fn sum_sponsored_fees_reserved_today( - &self, - wallet_id: Uuid, - ) -> Result { - let total: Option = sqlx::query_scalar( - r#" - SELECT COALESCE(SUM(fee_stroops), 0)::bigint - FROM sponsored_transactions - WHERE wallet_id = $1 - AND status IN ('pending', 'confirmed') - AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') - "#, - ) - .bind(wallet_id) - .fetch_one(&self.pool) - .await?; - Ok(total.unwrap_or(0)) - } - - // --- withdrawal allowlist ---------------------------------------------- - - /// Fetch a wallet's withdrawal-allowlist config, if one has ever been set. `None` means the - /// wallet has never touched this feature — treat that the same as `enabled = false`. - pub async fn get_withdrawal_allowlist_config( - &self, - wallet_id: Uuid, - ) -> Result, StoreError> { - let row = sqlx::query_as::<_, WithdrawalAllowlistConfig>( - "SELECT * FROM withdrawal_allowlist_configs WHERE wallet_id = $1", - ) - .bind(wallet_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// Create or replace a wallet's withdrawal-allowlist toggle. - pub async fn upsert_withdrawal_allowlist_config( - &self, - wallet_id: Uuid, - enabled: bool, - ) -> Result { - sqlx::query_as::<_, WithdrawalAllowlistConfig>( - r#" - INSERT INTO withdrawal_allowlist_configs (wallet_id, enabled) - VALUES ($1, $2) - ON CONFLICT (wallet_id) DO UPDATE SET - enabled = EXCLUDED.enabled, - updated_at = now() - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(enabled) - .fetch_one(&self.pool) - .await - .map_err(StoreError::Database) - } - - /// Add an address to a wallet's withdrawal allowlist. `Conflict` if already present. - pub async fn add_whitelisted_address( - &self, - wallet_id: Uuid, - address: &str, - label: Option<&str>, - ) -> Result { - sqlx::query_as::<_, WhitelistedAddress>( - r#" - INSERT INTO whitelisted_addresses (wallet_id, address, label) - VALUES ($1, $2, $3) - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(address) - .bind(label) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// List a wallet's whitelisted addresses, newest first. - pub async fn list_whitelisted_addresses( - &self, - wallet_id: Uuid, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, WhitelistedAddress>( - "SELECT * FROM whitelisted_addresses WHERE wallet_id = $1 ORDER BY created_at DESC", - ) - .bind(wallet_id) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Remove a whitelisted address. `NotFound` if it doesn't belong to `wallet_id`. - pub async fn remove_whitelisted_address( - &self, - wallet_id: Uuid, - entry_id: Uuid, - ) -> Result<(), StoreError> { - let result = - sqlx::query("DELETE FROM whitelisted_addresses WHERE id = $1 AND wallet_id = $2") - .bind(entry_id) - .bind(wallet_id) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(StoreError::NotFound); - } - Ok(()) - } - - /// `true` if `address` (already normalized to its base `G...` form by the caller) is on - /// `wallet_id`'s allowlist. Pure existence check — callers first check whether the allowlist - /// is even `enabled` via [`Store::get_withdrawal_allowlist_config`]. - pub async fn is_address_whitelisted( - &self, - wallet_id: Uuid, - address: &str, - ) -> Result { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM whitelisted_addresses WHERE wallet_id = $1 AND address = $2)", - ) - .bind(wallet_id) - .bind(address) - .fetch_one(&self.pool) - .await?; - Ok(exists) - } - - // --- per-address received totals --------------------------------------- - - /// Lifetime total (in stroops) of confirmed deposits credited to one generated address. - /// This is historical bookkeeping, not a live on-chain balance — deposits to any address - /// land in the wallet's single master account (that's the point of muxed addresses; there is - /// nothing to sweep), so this number will not match a per-address Horizon balance query. - pub async fn sum_deposits_for_address(&self, address_id: Uuid) -> Result { - let total: Option = sqlx::query_scalar( - r#" - SELECT COALESCE(SUM(amount_stroops), 0)::bigint - FROM transactions - WHERE address_id = $1 AND direction = 'deposit' AND status = 'confirmed' - "#, - ) - .bind(address_id) - .fetch_one(&self.pool) - .await?; - Ok(total.unwrap_or(0)) - } - - /// Batched version of [`Store::sum_deposits_for_address`] for an address list page: returns - /// `(address_id, total_stroops)` pairs in one round trip instead of N. - pub async fn sum_deposits_for_addresses( - &self, - address_ids: &[Uuid], - ) -> Result, StoreError> { - if address_ids.is_empty() { - return Ok(Vec::new()); - } - let rows: Vec<(Uuid, i64)> = sqlx::query_as( - r#" - SELECT address_id, COALESCE(SUM(amount_stroops), 0)::bigint AS total - FROM transactions - WHERE address_id = ANY($1) AND direction = 'deposit' AND status = 'confirmed' - GROUP BY address_id - "#, - ) - .bind(address_ids) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - // --- payment links ------------------------------------------------------- - - /// Create a payment link backed by an already-allocated deposit address. - pub async fn create_payment_link( - &self, - link: NewPaymentLink<'_>, - ) -> Result { - let row = sqlx::query_as::<_, PaymentLink>( - r#" - INSERT INTO payment_links - (wallet_id, address_id, slug, name, description, image_url, redirect_url, amount_usdc_stroops) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING * - "#, - ) - .bind(link.wallet_id) - .bind(link.address_id) - .bind(link.slug) - .bind(link.name) - .bind(link.description) - .bind(link.image_url) - .bind(link.redirect_url) - .bind(link.amount_usdc_stroops) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict)?; - Ok(row) - } - - /// Fetch a payment link owned by `wallet_id` (scoped so one merchant can't read another's). - pub async fn get_payment_link( - &self, - wallet_id: Uuid, - id: Uuid, - ) -> Result { - sqlx::query_as::<_, PaymentLink>( - "SELECT * FROM payment_links WHERE id = $1 AND wallet_id = $2", - ) - .bind(id) - .bind(wallet_id) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::NotFound) - } - - /// Public lookup by slug — the UNIQUE constraint supplies the index for this equality lookup. - /// No wallet scoping; this is the pay-page entry point. - pub async fn get_payment_link_by_slug(&self, slug: &str) -> Result { - sqlx::query_as::<_, PaymentLink>("SELECT * FROM payment_links WHERE slug = $1") - .bind(slug) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::NotFound) - } - - /// Unscoped lookup by id — for internal (non-owner-facing) callers that already know which - /// row they want, e.g. the expiry sweep resolving a payment's link to build its webhook. - pub async fn get_payment_link_by_id( - &self, - id: Uuid, - ) -> Result, StoreError> { - let row = sqlx::query_as::<_, PaymentLink>("SELECT * FROM payment_links WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// The payment link whose dedicated deposit address is `address_id`, if any. - pub async fn get_payment_link_by_address( - &self, - address_id: Uuid, - ) -> Result, StoreError> { - let row = - sqlx::query_as::<_, PaymentLink>("SELECT * FROM payment_links WHERE address_id = $1") - .bind(address_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn list_payment_links( - &self, - wallet_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let query = cursor_pagination_query("payment_links", "wallet_id"); - let rows = sqlx::query_as::<_, PaymentLink>(&query) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn set_payment_link_active( - &self, - wallet_id: Uuid, - id: Uuid, - active: bool, - ) -> Result { - sqlx::query_as::<_, PaymentLink>( - r#" - UPDATE payment_links SET active = $1, updated_at = now() - WHERE id = $2 AND wallet_id = $3 - RETURNING * - "#, - ) - .bind(active) - .bind(id) - .bind(wallet_id) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::NotFound) - } - - /// Record a payer's intent to pay (the "Continue" step, before any on-chain payment lands). - pub async fn record_payment_link_intent( - &self, - payment_link_id: Uuid, - payer_name: Option<&str>, - payer_email: Option<&str>, - amount_usdc_stroops: i64, - address_id: Option, - ) -> Result { - let row = sqlx::query_as::<_, PaymentLinkPayment>( - r#" - INSERT INTO payment_link_payments - (payment_link_id, payer_name, payer_email, amount_usdc_stroops, address_id) - VALUES ($1, $2, $3, $4, $5) - RETURNING * - "#, - ) - .bind(payment_link_id) - .bind(payer_name) - .bind(payer_email) - .bind(amount_usdc_stroops) - .bind(address_id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - /// The pending intent owning `address_id`, if any — ingest's exact deposit match. - pub async fn pending_payment_by_address( - &self, - address_id: Uuid, - ) -> Result, StoreError> { - let row = sqlx::query_as::<_, PaymentLinkPayment>( - r#" - SELECT * FROM payment_link_payments - WHERE address_id = $1 AND status = 'pending' - ORDER BY created_at ASC - LIMIT 1 - "#, - ) - .bind(address_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn get_payment_link_payment( - &self, - payment_link_id: Uuid, - id: Uuid, - ) -> Result { - sqlx::query_as::<_, PaymentLinkPayment>( - "SELECT * FROM payment_link_payments WHERE id = $1 AND payment_link_id = $2", - ) - .bind(id) - .bind(payment_link_id) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::NotFound) - } - - /// The oldest still-pending payment on a link — ingest matches deposits against this one. - pub async fn oldest_pending_payment_link_payment( - &self, - payment_link_id: Uuid, - ) -> Result, StoreError> { - let row = sqlx::query_as::<_, PaymentLinkPayment>( - r#" - SELECT * FROM payment_link_payments - WHERE payment_link_id = $1 AND status = 'pending' - ORDER BY created_at ASC - LIMIT 1 - "#, - ) - .bind(payment_link_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn confirm_payment_link_payment( - &self, - id: Uuid, - transaction_id: Uuid, - ) -> Result<(), StoreError> { - sqlx::query( - r#" - UPDATE payment_link_payments - SET status = 'confirmed', transaction_id = $1 - WHERE id = $2 - "#, - ) - .bind(transaction_id) - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Record a deposit that landed on this payment's address but for the wrong amount. - /// `status` must be `"underpaid"` or `"overpaid"` — the transaction is still linked (so the - /// merchant/payer can see what actually arrived) but the payment is deliberately NOT marked - /// `confirmed`. - pub async fn mark_payment_link_payment_mismatched( - &self, - id: Uuid, - transaction_id: Uuid, - status: &str, - ) -> Result<(), StoreError> { - sqlx::query( - r#" - UPDATE payment_link_payments - SET status = $1, transaction_id = $2 - WHERE id = $3 - "#, - ) - .bind(status) - .bind(transaction_id) - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Mark payments still `pending` past a 1-hour deadline as `expired`, returning the rows that - /// were flipped so the caller can fire one webhook per expiry without a second query. - pub async fn expire_stale_payment_link_payments( - &self, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, PaymentLinkPayment>( - r#" - UPDATE payment_link_payments - SET status = 'expired' - WHERE status = 'pending' AND created_at < now() - interval '1 hour' - RETURNING * - "#, - ) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Payments recorded against a link (newest first), with cursor pagination. - /// - /// Includes pending intents, not just confirmed ones — a merchant wants to see that someone - /// started paying, and pending rows are how an abandoned checkout shows up. - pub async fn list_payment_link_payments( - &self, - payment_link_id: Uuid, - limit: i64, - before_id: Option, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, PaymentLinkPayment>( - r#" - SELECT * FROM payment_link_payments - WHERE payment_link_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM payment_link_payments WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(payment_link_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Lifetime total (in USDC stroops) confirmed on a payment link. - pub async fn sum_payment_link_collected( - &self, - payment_link_id: Uuid, - ) -> Result { - let total: Option = sqlx::query_scalar( - r#" - SELECT COALESCE(SUM(amount_usdc_stroops), 0)::bigint - FROM payment_link_payments - WHERE payment_link_id = $1 AND status = 'confirmed' - "#, - ) - .bind(payment_link_id) - .fetch_one(&self.pool) - .await?; - Ok(total.unwrap_or(0)) - } - - /// Batched version of [`Store::sum_payment_link_collected`] for a link list page. - pub async fn sum_payment_link_collected_batch( - &self, - payment_link_ids: &[Uuid], - ) -> Result, StoreError> { - if payment_link_ids.is_empty() { - return Ok(Vec::new()); - } - let rows: Vec<(Uuid, i64)> = sqlx::query_as( - r#" - SELECT payment_link_id, COALESCE(SUM(amount_usdc_stroops), 0)::bigint AS total - FROM payment_link_payments - WHERE payment_link_id = ANY($1) AND status = 'confirmed' - GROUP BY payment_link_id - "#, - ) - .bind(payment_link_ids) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Atomically reserve budget and record a sponsored transaction. - /// - /// Inserts a `pending` row **only if** doing so keeps today's reserved fees within - /// `daily_budget_stroops` (a `NULL` budget means unlimited). The check and insert happen in one - /// statement (a conditional CTE), so concurrent sponsorships can't oversubscribe the budget. - /// Returns `StoreError::BudgetExceeded` if the budget would be exceeded, or - /// `StoreError::Conflict` if this `inner_tx_hash` was already sponsored (double-submit). - pub async fn try_reserve_sponsored_transaction( - &self, - wallet_id: Uuid, - inner_tx_hash: &str, - fee_stroops: i64, - daily_budget_stroops: Option, - ) -> Result { - // The read-then-insert below must be serialized per wallet. A bare conditional CTE is NOT - // enough: under READ COMMITTED every concurrent transaction computes `spent` from a - // snapshot taken before the others' inserts are visible, so N requests can each see the - // same total and all pass the budget guard (observed: 11 reservations against a 10-slot - // budget under 20 concurrent requests). - // - // A transaction-scoped advisory lock keyed on the wallet id makes the check-and-insert - // mutually exclusive for that wallet, while leaving other wallets fully parallel. The - // lock is released automatically when the transaction commits or rolls back. - let mut tx = self.pool.begin().await?; - - // Fold the wallet UUID into a stable i64 lock key. - let lock_key = { - let b = wallet_id.as_bytes(); - i64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) - ^ i64::from_be_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]) - }; - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; - - let result = sqlx::query_as::<_, SponsoredTransaction>( - r#" - WITH spent AS ( - SELECT COALESCE(SUM(fee_stroops), 0)::bigint AS total - FROM sponsored_transactions - WHERE wallet_id = $1 - AND status IN ('pending', 'confirmed') - AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') - ) - INSERT INTO sponsored_transactions (wallet_id, inner_tx_hash, fee_stroops, status) - SELECT $1, $2, $3, 'pending' - FROM spent - WHERE $4::bigint IS NULL OR spent.total + $3 <= $4 - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(inner_tx_hash) - .bind(fee_stroops) - .bind(daily_budget_stroops) - .fetch_optional(&mut *tx) - .await; - - // Commit before returning so the reservation (and the lock release) are durable. - if result.is_ok() { - tx.commit().await?; - } - - match result { - // A row means the insert (and budget check) succeeded. - Ok(Some(row)) => Ok(row), - // No row means the WHERE budget guard rejected the insert. - Ok(None) => Err(StoreError::BudgetExceeded), - // Unique violation on inner_tx_hash => already sponsored. - Err(e) => Err(StoreError::from_sqlx_conflict(e)), - } - } - - /// Update a sponsored transaction's outcome after submission. - pub async fn finalize_sponsored_transaction( - &self, - id: Uuid, - status: &str, - fee_bump_tx_hash: Option<&str>, - error: Option<&str>, - ) -> Result<(), StoreError> { - self.update_sponsored_tx_status(id, status, fee_bump_tx_hash, error) - .await - } - - /// Insert a sponsored transaction as `pending` (no budget check — see - /// [`Store::try_reserve_sponsored_transaction`] for the atomic budget-aware insert). - /// Fails with [`StoreError::Conflict`] if this `inner_tx_hash` was already recorded. - pub async fn record_sponsored_tx( - &self, - new: NewSponsoredTx<'_>, - ) -> Result { - sqlx::query_as::<_, SponsoredTransaction>( - r#" - INSERT INTO sponsored_transactions (wallet_id, inner_tx_hash, fee_stroops, status) - VALUES ($1, $2, $3, 'pending') - RETURNING * - "#, - ) - .bind(new.wallet_id) - .bind(new.inner_tx_hash) - .bind(new.fee_stroops) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// Update a sponsored transaction's status, fee-bump hash, and error. - pub async fn update_sponsored_tx_status( - &self, - id: Uuid, - status: &str, - fee_bump_tx_hash: Option<&str>, - error: Option<&str>, - ) -> Result<(), StoreError> { - sqlx::query( - "UPDATE sponsored_transactions SET status = $2, fee_bump_tx_hash = $3, error = $4 WHERE id = $1", - ) - .bind(id) - .bind(status) - .bind(fee_bump_tx_hash) - .bind(error) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Sum of **confirmed** sponsored fees for a wallet so far today (UTC) — i.e. actually spent. - /// (Pending rows are excluded; for budget *reservation* use - /// [`Store::sum_sponsored_fees_reserved_today`].) - pub async fn sum_sponsored_fees_today(&self, wallet_id: Uuid) -> Result { - let total: Option = sqlx::query_scalar( - r#" - SELECT COALESCE(SUM(fee_stroops), 0)::bigint - FROM sponsored_transactions - WHERE wallet_id = $1 - AND status = 'confirmed' - AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') - "#, - ) - .bind(wallet_id) - .fetch_one(&self.pool) - .await?; - Ok(total.unwrap_or(0)) - } - - // --- token deny-list ------------------------------------------------- - - /// Add a token to the deny-list so it cannot be replayed after logout. - /// - /// `token_hash` must be the **SHA-256 hex** of the raw JWT (never the token itself). - /// `expires_at` should mirror the token's own `exp` claim so that rows can be pruned once - /// they are past their natural expiry and cannot match any valid token anyway. - /// - /// Inserting the same hash twice is harmless (ON CONFLICT DO NOTHING). - pub async fn denylist_token( - &self, - token_hash: &str, - user_id: Uuid, - expires_at: chrono::DateTime, - ) -> Result<(), StoreError> { - sqlx::query( - r#" - INSERT INTO token_denylist (token_hash, user_id, expires_at) - VALUES ($1, $2, $3) - ON CONFLICT (token_hash) DO NOTHING - "#, - ) - .bind(token_hash) - .bind(user_id) - .bind(expires_at) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Returns `true` if the token hash is present in the deny-list **and** has not yet expired. - /// - /// Expired rows are logically irrelevant (the token itself would fail `verify_token`'s expiry - /// check), but this query skips them so a slow pruning job doesn't affect correctness. - pub async fn is_token_denylisted(&self, token_hash: &str) -> Result { - let found: Option = sqlx::query_scalar( - "SELECT true FROM token_denylist WHERE token_hash = $1 AND expires_at > now() LIMIT 1", - ) - .bind(token_hash) - .fetch_optional(&self.pool) - .await?; - Ok(found.is_some()) - } - - // --- ingest cursor ---------------------------------------------------- - - /// Read the saved Horizon paging token for a wallet, if any. - pub async fn get_cursor(&self, wallet_id: Uuid) -> Result, StoreError> { - let token: Option = - sqlx::query_scalar("SELECT paging_token FROM ingest_cursor WHERE wallet_id = $1") - .bind(wallet_id) - .fetch_optional(&self.pool) - .await? - .flatten(); - Ok(token) - } - - /// Upsert the Horizon paging token for a wallet (durable resume point). - pub async fn set_cursor(&self, wallet_id: Uuid, paging_token: &str) -> Result<(), StoreError> { - sqlx::query( - r#" - INSERT INTO ingest_cursor (wallet_id, paging_token, updated_at) - VALUES ($1, $2, now()) - ON CONFLICT (wallet_id) - DO UPDATE SET paging_token = EXCLUDED.paging_token, updated_at = now() - "#, - ) - .bind(wallet_id) - .bind(paging_token) - .execute(&self.pool) - .await?; - Ok(()) - } - - // --- webhooks --------------------------------------------------------- - - /// Register a webhook endpoint for a wallet. - pub async fn create_webhook_endpoint( - &self, - wallet_id: Uuid, - url: &str, - secret: &str, - ) -> Result { - sqlx::query_as::<_, WebhookEndpoint>( - r#" - INSERT INTO webhook_endpoints (wallet_id, url, secret) - VALUES ($1, $2, $3) - RETURNING * - "#, - ) - .bind(wallet_id) - .bind(url) - .bind(secret) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from_sqlx_conflict) - } - - /// List the active webhook endpoints for a wallet. - pub async fn active_webhook_endpoints( - &self, - wallet_id: Uuid, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, WebhookEndpoint>( - "SELECT * FROM webhook_endpoints WHERE wallet_id = $1 AND active = true", - ) - .bind(wallet_id) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Deactivate a webhook endpoint by setting its active status to false. - pub async fn deactivate_webhook_endpoint(&self, id: Uuid) -> Result<(), StoreError> { - sqlx::query("UPDATE webhook_endpoints SET active = false WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Fetch a single webhook endpoint by id. `NotFound` if it does not exist. - /// - /// Callers must still check `wallet_id` before returning data, so that an endpoint belonging - /// to another wallet is reported as 404 rather than 403 (no existence leak). - pub async fn get_webhook_endpoint(&self, id: Uuid) -> Result { - sqlx::query_as::<_, WebhookEndpoint>("SELECT * FROM webhook_endpoints WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await? - .ok_or(StoreError::NotFound) - } - - /// Check whether a webhook endpoint is still active using its indexed id. - pub async fn is_webhook_endpoint_active(&self, id: Uuid) -> Result { - sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM webhook_endpoints WHERE id = $1 AND active = true)", - ) - .bind(id) - .fetch_one(&self.pool) - .await - .map_err(StoreError::from) - } - - /// An endpoint's delivery history, newest first, capped at `limit` rows. - pub async fn list_webhook_deliveries( - &self, - endpoint_id: Uuid, - limit: i64, - ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, WebhookDelivery>( - r#" - SELECT * FROM webhook_deliveries - WHERE endpoint_id = $1 - ORDER BY created_at DESC, id DESC - LIMIT $2 - "#, - ) - .bind(endpoint_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - /// Record a webhook delivery attempt (audit log). Returns the delivery id. - pub async fn log_webhook_delivery( - &self, - endpoint_id: Uuid, - event_type: &str, - payload: &serde_json::Value, - status: &str, - attempts: i32, - response_code: Option, - ) -> Result { - let id: Uuid = sqlx::query_scalar( - r#" - INSERT INTO webhook_deliveries - (endpoint_id, event_type, payload, status, attempts, response_code) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id - "#, - ) - .bind(endpoint_id) - .bind(event_type) - .bind(payload) - .bind(status) - .bind(attempts) - .bind(response_code) - .fetch_one(&self.pool) - .await?; - Ok(id) - } - - // --- token deny-list -------------------------------------------------- - - /// Revoke a JWT by inserting it into the deny-list. - /// - /// `expires_at` should match the token's `exp` claim (converted from Unix seconds). Duplicate - /// revocations (same token) are silently ignored via `ON CONFLICT DO NOTHING`. - pub async fn revoke_token( - &self, - token: &str, - expires_at: chrono::DateTime, - ) -> Result<(), StoreError> { - sqlx::query( - r#" - INSERT INTO token_denylist (token, expires_at) - VALUES ($1, $2) - ON CONFLICT (token) DO NOTHING - "#, - ) - .bind(token) - .bind(expires_at) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Return `true` if the token has been revoked (is in the deny-list). - pub async fn is_token_revoked(&self, token: &str) -> Result { - let exists: bool = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM token_denylist WHERE token = $1)") - .bind(token) - .fetch_one(&self.pool) - .await?; - Ok(exists) - } - - /// Delete expired deny-list entries (those whose `expires_at` is in the past). - /// - /// Intended to be called periodically (e.g. once per hour in a background task) to prevent - /// unbounded table growth. Safe to skip — expired tokens are rejected by `verify_token()` - /// regardless of the deny-list. - pub async fn purge_expired_tokens(&self) -> Result { - let result = sqlx::query("DELETE FROM token_denylist WHERE expires_at < now()") - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } -} - -// Builds keyset cursor pagination query using (created_at, id) tuple comparison for deterministic descending order. -pub fn cursor_pagination_query(table: &str, filter_column: &str) -> String { - format!( - r#" - SELECT * FROM {table} - WHERE {filter_column} = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM {table} WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "# - ) -} diff --git a/crates/store/src/models.rs b/crates/store/src/models.rs index 744d840..1195a1b 100644 --- a/crates/store/src/models.rs +++ b/crates/store/src/models.rs @@ -37,6 +37,7 @@ pub struct Wallet { pub gas_tank_account_g: Option, pub created_at: DateTime, pub updated_at: DateTime, + pub archived_at: Option>, } impl Wallet { @@ -44,6 +45,11 @@ impl Wallet { pub fn is_client_custody(&self) -> bool { self.custody == "client" } + + /// True when the wallet has been archived. + pub fn is_archived(&self) -> bool { + self.archived_at.is_some() + } } /// A per-customer deposit address (off-chain row). @@ -146,6 +152,13 @@ pub struct WebhookDelivery { pub updated_at: DateTime, } +/// Recent delivery health rollup for a webhook endpoint. +#[derive(Debug, Clone, Default, Serialize, Deserialize, sqlx::FromRow)] +pub struct WebhookDeliveryHealth { + pub recent_failure_count: i64, + pub last_successful_delivery_at: Option>, +} + /// An audit-log entry (append-only record of account activity). #[derive(Debug, Clone, FromRow, Serialize)] pub struct AuditLog { diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index 5d275f5..e69de29 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -1,1213 +0,0 @@ -//! Integration tests for octo-store. Require a running Postgres. -//! -//! Run with: `docker compose up -d db` then `cargo test -p octo-store`. -//! -//! `DATABASE_URL` is read from the workspace `.env` automatically (via dotenvy), so the plain -//! `cargo test -p octo-store` works without exporting anything. If no URL can be found, the tests -//! print a clear SKIPPED message and pass (so a DB-less `cargo test` of the whole workspace is -//! green). If a URL is found but the DB is unreachable, the test fails loudly with the reason. - -use octo_store::{ - NewDeposit, NewPaymentLink, NewSponsoredTx, NewWallet, NewWithdrawal, Store, StoreError, -}; -use std::sync::Once; -use uuid::Uuid; - -static LOAD_ENV: Once = Once::new(); - -/// Resolve `DATABASE_URL`, loading the workspace `.env` first. Returns `None` only if no URL is -/// configured anywhere (in which case tests skip with a message). -fn database_url() -> Option { - LOAD_ENV.call_once(|| { - // Search upward from the crate dir for a .env (workspace root holds it). - let _ = dotenvy::dotenv(); - }); - std::env::var("DATABASE_URL").ok() -} - -async fn store() -> Option { - let Some(url) = database_url() else { - eprintln!( - "SKIPPED: DATABASE_URL is not set (no .env found). \ - Run `docker compose up -d db` and ensure .env exists to run store tests." - ); - return None; - }; - let store = Store::connect(&url) - .await - .unwrap_or_else(|e| panic!("could not connect to {url}: {e}")); - store.migrate().await.expect("migrate"); - Some(store) -} - -/// Create a throwaway wallet with a unique account id (so tests don't collide). -async fn fresh_wallet(store: &Store) -> Uuid { - let acct = format!("G{}", Uuid::new_v4().simple()); // unique, not a real strkey (fine for store tests) - let w = store - .create_wallet(NewWallet { - network: "testnet", - stellar_account_g: &acct, - sealed_ciphertext: b"ciphertext", - sealed_nonce: b"nonce12bytes", - sealed_salt: b"saltsaltsaltsalt", - sealed_scheme: 1, // octo_crypto::SCHEME_V1 - label: Some("test"), - user_id: None, - description: None, - }) - .await - .expect("create wallet"); - w.id -} - -#[tokio::test] -async fn create_and_get_wallet() { - let Some(store) = store().await else { return }; - let id = fresh_wallet(&store).await; - let w = store.get_wallet(id).await.expect("get"); - assert_eq!(w.network, "testnet"); - assert_eq!(w.next_muxed_id, 1); -} - -#[tokio::test] -async fn allocate_address_increments_atomically() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - - // muxed_address is globally unique in the schema (real ones encode the base account), so make - // the test value unique per wallet too. - let wid = wallet_id.simple(); - let a = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-{id}")), - Some("user-a"), - serde_json::json!({}), - ) - .await - .expect("alloc a"); - let b = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-{id}")), - Some("user-b"), - serde_json::json!({}), - ) - .await - .expect("alloc b"); - - assert_eq!(a.muxed_id, 1); - assert_eq!(b.muxed_id, 2); - assert_ne!(a.muxed_address, b.muxed_address); - - let list = store - .list_addresses(wallet_id, 100, None) - .await - .expect("list"); - assert_eq!(list.len(), 2); -} - -#[tokio::test] -async fn record_deposit_is_idempotent() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - let tx_hash = Uuid::new_v4().to_string(); - - let dep = NewDeposit { - wallet_id, - address_id: None, - asset_code: "native".into(), - asset_issuer: None, - amount_stroops: 10_000_000, - source_account: Some("Gsender".into()), - destination_account: Some("Gmaster".into()), - stellar_tx_hash: tx_hash.clone(), - operation_index: 0, - horizon_op_id: format!("{tx_hash}-0"), - ledger: Some(123), - memo_id: None, - }; - - // First insert credits. - let first = store.record_deposit(&dep).await.expect("first"); - assert!(first.is_some(), "first deposit must be recorded"); - - // Replaying the SAME horizon_op_id must NOT double-credit. - let second = store.record_deposit(&dep).await.expect("second"); - assert!( - second.is_none(), - "duplicate deposit must be a no-op (anti double-credit)" - ); - - let txs = store - .list_transactions(wallet_id, 100, None) - .await - .expect("list"); - assert_eq!(txs.len(), 1, "exactly one ledger entry for one on-chain op"); -} - -#[tokio::test] -async fn different_op_index_same_tx_is_distinct() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - let tx_hash = Uuid::new_v4().to_string(); - - let base = NewDeposit { - wallet_id, - address_id: None, - asset_code: "native".into(), - asset_issuer: None, - amount_stroops: 5, - source_account: None, - destination_account: None, - stellar_tx_hash: tx_hash.clone(), - operation_index: 0, - horizon_op_id: format!("{tx_hash}-0"), - ledger: None, - memo_id: None, - }; - let op1 = NewDeposit { - operation_index: 1, - horizon_op_id: format!("{tx_hash}-1"), - ..base.clone() - }; - - assert!(store.record_deposit(&base).await.expect("op0").is_some()); - assert!(store.record_deposit(&op1).await.expect("op1").is_some()); - assert_eq!( - store - .list_transactions(wallet_id, 100, None) - .await - .unwrap() - .len(), - 2 - ); -} - -#[tokio::test] -async fn sum_deposits_for_address_totals_only_that_addresss_confirmed_deposits() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - let wid = wallet_id.simple(); - - let addr_a = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-a-{id}")), - Some("a"), - serde_json::json!({}), - ) - .await - .expect("alloc a"); - let addr_b = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-b-{id}")), - Some("b"), - serde_json::json!({}), - ) - .await - .expect("alloc b"); - - // Two deposits to A, one to B — A's total must be the sum of only its own two, not B's. - for (i, amount) in [(0, 10_000_000i64), (1, 2_500_000)] { - let tx_hash = Uuid::new_v4().to_string(); - store - .record_deposit(&NewDeposit { - wallet_id, - address_id: Some(addr_a.id), - asset_code: "native".into(), - asset_issuer: None, - amount_stroops: amount, - source_account: Some("Gsender".into()), - destination_account: Some("Gmaster".into()), - stellar_tx_hash: tx_hash.clone(), - operation_index: i, - horizon_op_id: format!("{tx_hash}-{i}"), - ledger: Some(1), - memo_id: None, - }) - .await - .expect("record deposit to a"); - } - let tx_hash_b = Uuid::new_v4().to_string(); - store - .record_deposit(&NewDeposit { - wallet_id, - address_id: Some(addr_b.id), - asset_code: "native".into(), - asset_issuer: None, - amount_stroops: 999_000_000, - source_account: Some("Gsender".into()), - destination_account: Some("Gmaster".into()), - stellar_tx_hash: tx_hash_b.clone(), - operation_index: 0, - horizon_op_id: format!("{tx_hash_b}-0"), - ledger: Some(1), - memo_id: None, - }) - .await - .expect("record deposit to b"); - - assert_eq!( - store - .sum_deposits_for_address(addr_a.id) - .await - .expect("sum a"), - 12_500_000, - "A's total must be the sum of its own two deposits, unaffected by B's" - ); - assert_eq!( - store - .sum_deposits_for_address(addr_b.id) - .await - .expect("sum b"), - 999_000_000 - ); - - // A brand-new address with no deposits sums to 0, not an error. - let addr_c = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-c-{id}")), - Some("c"), - serde_json::json!({}), - ) - .await - .expect("alloc c"); - assert_eq!( - store - .sum_deposits_for_address(addr_c.id) - .await - .expect("sum c"), - 0 - ); - - // The batched form must agree with the per-address form, and only return entries that - // actually have deposits (address C has none, so it's absent rather than a zero row). - let batched = store - .sum_deposits_for_addresses(&[addr_a.id, addr_b.id, addr_c.id]) - .await - .expect("batched sum"); - let totals: std::collections::HashMap = batched.into_iter().collect(); - assert_eq!(totals.get(&addr_a.id), Some(&12_500_000)); - assert_eq!(totals.get(&addr_b.id), Some(&999_000_000)); - assert_eq!( - totals.get(&addr_c.id), - None, - "an address with zero deposits has no row in the batched result (GROUP BY yields nothing)" - ); - - // Empty id list must short-circuit to an empty result, not error or scan the whole table. - assert_eq!( - store - .sum_deposits_for_addresses(&[]) - .await - .expect("empty batch"), - Vec::new() - ); -} - -#[tokio::test] -async fn payment_link_lifecycle_intent_confirm_and_sum() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - let wid = wallet_id.simple(); - - let addr = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-{id}")), - None, - serde_json::json!({}), - ) - .await - .expect("alloc address"); - - let slug = format!("link-{wid}"); - let link = store - .create_payment_link(NewPaymentLink { - wallet_id, - address_id: addr.id, - slug: &slug, - name: "Support octo", - description: Some("donations"), - image_url: None, - redirect_url: None, - amount_usdc_stroops: None, - }) - .await - .expect("create link"); - assert_eq!(link.slug, slug); - assert!(link.active); - - // Public lookup by slug must work with no wallet_id in hand. - let by_slug = store - .get_payment_link_by_slug(&slug) - .await - .expect("by slug"); - assert_eq!(by_slug.id, link.id); - - // A fresh link has nothing collected yet. - assert_eq!( - store - .sum_payment_link_collected(link.id) - .await - .expect("sum"), - 0 - ); - - let intent = store - .record_payment_link_intent( - link.id, - Some("Ada"), - Some("ada@example.com"), - 10_000_000, - Some(addr.id), - ) - .await - .expect("record intent"); - assert_eq!(intent.status, "pending"); - - let oldest = store - .oldest_pending_payment_link_payment(link.id) - .await - .expect("oldest pending") - .expect("one pending row"); - assert_eq!(oldest.id, intent.id); - - // Exact-address lookup is how ingest matches a deposit to one specific intent. - let by_address = store - .pending_payment_by_address(addr.id) - .await - .expect("by address") - .expect("pending intent on this address"); - assert_eq!(by_address.id, intent.id); - assert_eq!(by_address.address_id, Some(addr.id)); - - let tx_hash = Uuid::new_v4().to_string(); - let dep = store - .record_deposit(&NewDeposit { - wallet_id, - address_id: Some(addr.id), - asset_code: "USDC".into(), - asset_issuer: Some("GISSUER".into()), - amount_stroops: 10_000_000, - source_account: Some("Gpayer".into()), - destination_account: Some("Gmaster".into()), - stellar_tx_hash: tx_hash.clone(), - operation_index: 0, - horizon_op_id: format!("{tx_hash}-0"), - ledger: Some(1), - memo_id: None, - }) - .await - .expect("record deposit") - .expect("first insert"); - - store - .confirm_payment_link_payment(intent.id, dep.id) - .await - .expect("confirm payment"); - - let confirmed = store - .get_payment_link_payment(link.id, intent.id) - .await - .expect("get payment"); - assert_eq!(confirmed.status, "confirmed"); - assert_eq!(confirmed.transaction_id, Some(dep.id)); - - // Once confirmed, it's no longer the oldest pending (there is none left). - assert!(store - .oldest_pending_payment_link_payment(link.id) - .await - .expect("oldest pending after confirm") - .is_none()); - - assert_eq!( - store - .sum_payment_link_collected(link.id) - .await - .expect("sum after confirm"), - 10_000_000 - ); - - let batch = store - .sum_payment_link_collected_batch(&[link.id]) - .await - .expect("batch sum"); - assert_eq!(batch, vec![(link.id, 10_000_000)]); - - // Deactivating is scoped to the owning wallet. - let deactivated = store - .set_payment_link_active(wallet_id, link.id, false) - .await - .expect("deactivate"); - assert!(!deactivated.active); -} - -#[tokio::test] -async fn payment_link_mismatched_deposit_records_the_transaction_but_does_not_confirm() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - let wid = wallet_id.simple(); - - let addr = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-{id}")), - None, - serde_json::json!({}), - ) - .await - .expect("alloc address"); - - let link = store - .create_payment_link(NewPaymentLink { - wallet_id, - address_id: addr.id, - slug: &format!("link-mismatch-{wid}"), - name: "Underpaid test", - description: None, - image_url: None, - redirect_url: None, - amount_usdc_stroops: Some(10_000_000), - }) - .await - .expect("create link"); - - let intent = store - .record_payment_link_intent(link.id, None, None, 10_000_000, Some(addr.id)) - .await - .expect("record intent"); - - let tx_hash = Uuid::new_v4().to_string(); - let dep = store - .record_deposit(&NewDeposit { - wallet_id, - address_id: Some(addr.id), - asset_code: "USDC".into(), - asset_issuer: Some("GISSUER".into()), - amount_stroops: 5_000_000, // half of what was expected - source_account: Some("Gpayer".into()), - destination_account: Some("Gmaster".into()), - stellar_tx_hash: tx_hash.clone(), - operation_index: 0, - horizon_op_id: format!("{tx_hash}-0"), - ledger: Some(1), - memo_id: None, - }) - .await - .expect("record deposit") - .expect("first insert"); - - store - .mark_payment_link_payment_mismatched(intent.id, dep.id, "underpaid") - .await - .expect("mark mismatched"); - - let mismatched = store - .get_payment_link_payment(link.id, intent.id) - .await - .expect("get payment"); - assert_eq!(mismatched.status, "underpaid"); - assert_eq!( - mismatched.transaction_id, - Some(dep.id), - "the short deposit must still be linked, so the merchant can see what actually arrived" - ); - - // A mismatched payment is not "pending" any more, so it must not still be matchable — ingest - // must not later confuse a second, correct deposit with this already-resolved intent. - assert!(store - .pending_payment_by_address(addr.id) - .await - .expect("by address") - .is_none()); -} - -#[tokio::test] -async fn expire_stale_payment_link_payments_only_sweeps_old_pending_rows() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - let wid = wallet_id.simple(); - - let addr = store - .allocate_address( - wallet_id, - |id| Ok(format!("M{wid}-{id}")), - None, - serde_json::json!({}), - ) - .await - .expect("alloc address"); - - let link = store - .create_payment_link(NewPaymentLink { - wallet_id, - address_id: addr.id, - slug: &format!("link-expiry-{wid}"), - name: "Expiry test", - description: None, - image_url: None, - redirect_url: None, - amount_usdc_stroops: Some(10_000_000), - }) - .await - .expect("create link"); - - let stale = store - .record_payment_link_intent(link.id, None, None, 10_000_000, Some(addr.id)) - .await - .expect("record stale intent"); - // Backdate it past the 1-hour deadline directly — this test can't wait an hour. - sqlx::query( - "UPDATE payment_link_payments SET created_at = now() - interval '2 hours' WHERE id = $1", - ) - .bind(stale.id) - .execute(store.pool()) - .await - .expect("backdate"); - - let fresh = store - .record_payment_link_intent(link.id, None, None, 10_000_000, Some(addr.id)) - .await - .expect("record fresh intent"); - - let expired = store - .expire_stale_payment_link_payments() - .await - .expect("sweep"); - let expired_ids: Vec = expired.iter().map(|p| p.id).collect(); - assert!( - expired_ids.contains(&stale.id), - "the >1hr-old pending row must be swept" - ); - assert!( - !expired_ids.contains(&fresh.id), - "a freshly-created pending row must not be swept" - ); - - let stale_after = store - .get_payment_link_payment(link.id, stale.id) - .await - .expect("get stale"); - assert_eq!(stale_after.status, "expired"); - - let fresh_after = store - .get_payment_link_payment(link.id, fresh.id) - .await - .expect("get fresh"); - assert_eq!(fresh_after.status, "pending"); - - // Running the sweep again must be a no-op for already-expired rows (idempotent). - let expired_again = store - .expire_stale_payment_link_payments() - .await - .expect("sweep again"); - assert!(!expired_again.iter().any(|p| p.id == stale.id)); -} - -#[tokio::test] -async fn withdrawal_idempotency_key_blocks_double_spend() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - - let mk = |key: &'static str| NewWithdrawal { - wallet_id, - idempotency_key: key, - destination_account: "Gdest", - asset_code: "native", - asset_issuer: None, - amount_stroops: 1_000, - memo_id: None, - }; - - let first = store.create_withdrawal(mk("key-1")).await; - assert!(first.is_ok(), "first withdrawal accepted"); - - // Same idempotency key => conflict, not a second payout. - let second = store.create_withdrawal(mk("key-1")).await; - assert!( - matches!(second, Err(StoreError::Conflict)), - "retry must conflict" - ); - - // A different key is a different withdrawal. - let third = store.create_withdrawal(mk("key-2")).await; - assert!(third.is_ok()); -} - -/// Insert a minimal gas_sponsorship_configs row (no limits) for `wallet_id`. -async fn insert_sponsorship_config(store: &Store, wallet_id: Uuid) { - sqlx::query("INSERT INTO gas_sponsorship_configs (wallet_id, enabled) VALUES ($1, true)") - .bind(wallet_id) - .execute(store.pool()) - .await - .expect("insert gas_sponsorship_configs"); -} - -#[tokio::test] -async fn record_and_update_sponsored_tx() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - insert_sponsorship_config(&store, wallet_id).await; - - let hash = format!("inner-{}", Uuid::new_v4().simple()); - let row = store - .record_sponsored_tx(NewSponsoredTx { - wallet_id, - inner_tx_hash: &hash, - fee_stroops: 500, - }) - .await - .expect("record"); - - assert_eq!(row.wallet_id, wallet_id); - assert_eq!(row.inner_tx_hash, hash); - assert_eq!(row.fee_stroops, 500); - assert_eq!(row.status, "pending"); - assert!(row.fee_bump_tx_hash.is_none()); - - // Update to confirmed. - let bump_hash = format!("bump-{}", Uuid::new_v4().simple()); - store - .update_sponsored_tx_status(row.id, "confirmed", Some(&bump_hash), None) - .await - .expect("update"); - - // Verify via pool (the store has no get_sponsored_tx yet; query directly). - let updated: (String, Option) = - sqlx::query_as("SELECT status, fee_bump_tx_hash FROM sponsored_transactions WHERE id = $1") - .bind(row.id) - .fetch_one(store.pool()) - .await - .expect("fetch updated"); - - assert_eq!(updated.0, "confirmed"); - assert_eq!(updated.1.as_deref(), Some(bump_hash.as_str())); -} - -#[tokio::test] -async fn sum_fees_today_counts_only_confirmed() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - insert_sponsorship_config(&store, wallet_id).await; - - // No rows → 0. - let initial = store - .sum_sponsored_fees_today(wallet_id) - .await - .expect("sum"); - assert_eq!(initial, 0); - - // Insert a pending tx (fee 200): should not count. - let pending = store - .record_sponsored_tx(NewSponsoredTx { - wallet_id, - inner_tx_hash: &format!("pending-{}", Uuid::new_v4().simple()), - fee_stroops: 200, - }) - .await - .expect("pending record"); - // Still 0 — pending doesn't count. - assert_eq!(store.sum_sponsored_fees_today(wallet_id).await.unwrap(), 0); - - // Confirm the tx → now it counts. - store - .update_sponsored_tx_status(pending.id, "confirmed", None, None) - .await - .expect("update to confirmed"); - assert_eq!( - store.sum_sponsored_fees_today(wallet_id).await.unwrap(), - 200 - ); - - // A second confirmed tx adds to the total. - let second = store - .record_sponsored_tx(NewSponsoredTx { - wallet_id, - inner_tx_hash: &format!("second-{}", Uuid::new_v4().simple()), - fee_stroops: 300, - }) - .await - .expect("second record"); - store - .update_sponsored_tx_status(second.id, "confirmed", None, None) - .await - .unwrap(); - assert_eq!( - store.sum_sponsored_fees_today(wallet_id).await.unwrap(), - 500 - ); -} - -#[tokio::test] -async fn sum_fees_today_can_use_wallet_status_created_at_index() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - - let mut tx = store.pool().begin().await.expect("begin transaction"); - sqlx::query("SET LOCAL enable_seqscan = off") - .execute(&mut *tx) - .await - .expect("disable sequential scans for index eligibility check"); - let plan: Vec = sqlx::query_scalar( - r#"EXPLAIN (COSTS OFF) - SELECT COALESCE(SUM(fee_stroops), 0)::bigint - FROM sponsored_transactions - WHERE wallet_id = $1 - AND status = 'confirmed' - AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC')"#, - ) - .bind(wallet_id) - .fetch_all(&mut *tx) - .await - .expect("explain sum_sponsored_fees_today"); - let plan = plan.join("\n"); - - assert!( - plan.contains("idx_sponsored_wallet_status_"), - "expected the wallet/status/created_at index, got:\n{plan}" - ); - assert!( - !plan.contains("Seq Scan"), - "sum query must not require a full table scan:\n{plan}" - ); -} - -#[tokio::test] -async fn duplicate_inner_tx_hash_is_conflict() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - insert_sponsorship_config(&store, wallet_id).await; - - let hash = format!("dup-{}", Uuid::new_v4().simple()); - - let first = store - .record_sponsored_tx(NewSponsoredTx { - wallet_id, - inner_tx_hash: &hash, - fee_stroops: 100, - }) - .await; - assert!(first.is_ok(), "first record must succeed"); - - // Same inner_tx_hash → UNIQUE violation → Conflict. - let second = store - .record_sponsored_tx(NewSponsoredTx { - wallet_id, - inner_tx_hash: &hash, - fee_stroops: 100, - }) - .await; - assert!( - matches!(second, Err(StoreError::Conflict)), - "duplicate inner_tx_hash must conflict, got: {second:?}" - ); -} - -#[tokio::test] -async fn cursor_roundtrip() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - - assert_eq!(store.get_cursor(wallet_id).await.unwrap(), None); - store.set_cursor(wallet_id, "token-1").await.unwrap(); - assert_eq!( - store.get_cursor(wallet_id).await.unwrap().as_deref(), - Some("token-1") - ); - // Upsert overwrites. - store.set_cursor(wallet_id, "token-2").await.unwrap(); - assert_eq!( - store.get_cursor(wallet_id).await.unwrap().as_deref(), - Some("token-2") - ); -} - -#[tokio::test] -async fn migrate_is_idempotent_when_run_twice() { - let Some(store) = store().await else { return }; - // `store()` already ran migrate() once during setup; running it again against the same - // already-migrated database mirrors a server restart (bin/server/src/main.rs calls - // store.migrate().await on every boot) and must be a safe no-op, not an error. - store - .migrate() - .await - .expect("second migrate() call must succeed with no error"); -} - -#[tokio::test] -async fn migrate_applies_exactly_the_expected_version_set() { - let Some(store) = store().await else { return }; - - let mut versions: Vec = sqlx::query_scalar( - "SELECT version FROM _sqlx_migrations WHERE success = true ORDER BY version", - ) - .fetch_all(store.pool()) - .await - .expect("query _sqlx_migrations"); - versions.sort_unstable(); - - // One version per file under crates/store/migrations/, 0001_init.sql .. 0020. - // Guards against silent version collisions — sqlx keys migrations by version, so a repeated - // number means only one of the colliding pair actually ran. - assert_eq!( - versions, - vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], - "expected exactly the twenty known migrations to be recorded as applied" - ); -} - -#[tokio::test] -async fn upsert_gas_sponsorship_config_works() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - let cfg = store - .upsert_gas_sponsorship_config(wallet_id, true, Some(500_000), Some(10_000_000)) - .await - .expect("upsert"); - assert!(cfg.enabled); - let spent = store - .sum_sponsored_fees_reserved_today(wallet_id) - .await - .expect("sum"); - assert_eq!(spent, 0); -} - -/// Create a throwaway user with a unique email (so tests don't collide). -async fn fresh_user(store: &Store) -> Uuid { - let email = format!("test-{}@example.invalid", Uuid::new_v4().simple()); - store - .create_user(&email, "not-a-real-hash") - .await - .expect("create user") - .id -} - -// --- indexing-overhaul correctness regressions (hard/store/indexing-overhaul-with-load-test) --- -// -// These assert result *correctness* (ordering, filtering) for the query shapes the new indices in -// migrations/0008_sponsored_and_audit_indexing.sql target. An index change must never change which -// rows come back or in what order — if either of these starts failing, the index migration altered -// query semantics, not just performance, and that's a bug in the migration. - -#[tokio::test] -async fn list_sponsored_transactions_orders_filters_and_paginates_correctly() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - insert_sponsorship_config(&store, wallet_id).await; - - // Three rows, two different statuses, with `created_at` pinned to strictly increasing values - // (rather than relying on wall-clock ordering, which is too coarse to guarantee distinct - // timestamps for back-to-back inserts and would make the ORDER BY assertions flaky). - let mut ids = Vec::new(); - for (i, (label, status)) in [("a", "pending"), ("b", "confirmed"), ("c", "confirmed")] - .into_iter() - .enumerate() - { - let row = store - .record_sponsored_tx(NewSponsoredTx { - wallet_id, - inner_tx_hash: &format!("order-{label}-{}", Uuid::new_v4().simple()), - fee_stroops: 100, - }) - .await - .expect("record"); - if status == "confirmed" { - store - .update_sponsored_tx_status(row.id, "confirmed", None, None) - .await - .expect("confirm"); - } - sqlx::query("UPDATE sponsored_transactions SET created_at = now() - make_interval(secs => $2) WHERE id = $1") - .bind(row.id) - .bind((10 - i) as f64) - .execute(store.pool()) - .await - .expect("pin created_at"); - ids.push(row.id); - } - - // Unfiltered: most-recent-first (created_at DESC, id DESC — insertion order reversed). - let all = store - .list_sponsored_transactions(wallet_id, 10, None, None) - .await - .expect("list all"); - let all_ids: Vec = all.iter().map(|r| r.id).collect(); - assert_eq!(all_ids, vec![ids[2], ids[1], ids[0]]); - - // Status filter: only the two confirmed rows, same relative order. - let confirmed = store - .list_sponsored_transactions(wallet_id, 10, Some("confirmed"), None) - .await - .expect("list confirmed"); - let confirmed_ids: Vec = confirmed.iter().map(|r| r.id).collect(); - assert_eq!(confirmed_ids, vec![ids[2], ids[1]]); - - // Cursor pagination: page of 1 starting after the newest row returns the next one down. - let page = store - .list_sponsored_transactions(wallet_id, 1, None, Some(ids[2])) - .await - .expect("list after cursor"); - assert_eq!(page.len(), 1); - assert_eq!(page[0].id, ids[1]); -} - -#[tokio::test] -async fn list_audit_logs_filters_by_category_and_search_correctly() { - let Some(store) = store().await else { return }; - let user_id = fresh_user(&store).await; - - store - .record_audit( - user_id, - "signed in", - "authentication", - None, - Some("203.0.113.1"), - ) - .await - .expect("record 1"); - store - .record_audit( - user_id, - "created wallet octo master wallet", - "wallet", - Some("octo master wallet"), - None, - ) - .await - .expect("record 2"); - store - .record_audit(user_id, "rotated api key", "credentials", None, None) - .await - .expect("record 3"); - - // Pin `created_at` to strictly increasing values in insertion order (see the sponsored-tx test - // above for why wall-clock ordering alone isn't reliable enough for the ORDER BY assertions). - for (offset_secs, action) in [ - (10.0, "signed in"), - (9.0, "created wallet octo master wallet"), - (8.0, "rotated api key"), - ] { - sqlx::query( - "UPDATE audit_logs SET created_at = now() - make_interval(secs => $2) \ - WHERE user_id = $1 AND action = $3", - ) - .bind(user_id) - .bind(offset_secs) - .bind(action) - .execute(store.pool()) - .await - .expect("pin created_at"); - } - - // Category filter: only the "wallet" row. - let by_category = store - .list_audit_logs(user_id, Some("wallet"), None, 10) - .await - .expect("list by category"); - assert_eq!(by_category.len(), 1); - assert_eq!(by_category[0].category, "wallet"); - - // Search filter (the ILIKE / trigram-index case): matches action OR target, case-insensitive. - let by_search = store - .list_audit_logs(user_id, None, Some("MASTER"), 10) - .await - .expect("list by search"); - assert_eq!(by_search.len(), 1); - assert_eq!(by_search[0].action, "created wallet octo master wallet"); - - // No match. - let no_match = store - .list_audit_logs(user_id, None, Some("nonexistent-term"), 10) - .await - .expect("list no match"); - assert!(no_match.is_empty()); - - // Unfiltered: all three, most-recent-first. - let all = store - .list_audit_logs(user_id, None, None, 10) - .await - .expect("list all"); - assert_eq!(all.len(), 3); - assert_eq!(all[0].action, "rotated api key"); -} - -#[tokio::test] -async fn wallets_due_for_poll_applies_activity_backoff() { - let Some(store) = store().await else { return }; - - // `network` is CHECK-constrained to mainnet/testnet, so this test can't invent its own. It - // uses mainnet (a handful of inert rows) and filters results down to the ids it created. - let network = "mainnet"; - let mut ids = Vec::new(); - for label in ["never-polled", "active", "idle", "dormant"] { - let acct = format!("G{}", Uuid::new_v4().simple()); - let w = store - .create_wallet(NewWallet { - network, - stellar_account_g: &acct, - sealed_ciphertext: b"ct", - sealed_nonce: b"nonce", - sealed_salt: b"salt", - sealed_scheme: 1, - label: Some(label), - user_id: None, - description: None, - }) - .await - .expect("create wallet"); - ids.push(w.id); - } - let (never, active, idle, dormant) = (ids[0], ids[1], ids[2], ids[3]); - - // Tiers for this test: active < 60s, idle polled at most every 100s, dormant (> 300s since - // activity) polled at most every 100_000s. - let mine = ids.clone(); - let due = |store: &Store| { - let store = store.clone(); - let mine = mine.clone(); - async move { - store - .wallets_due_for_poll(network, 60, 100, 300, 100_000) - .await - .expect("due query") - .into_iter() - .map(|w| w.id) - // Other mainnet rows may exist in a shared dev DB; only assert on our own. - .filter(|id| mine.contains(id)) - .collect::>() - } - }; - - // Nothing has a cursor row yet: every wallet is due. - let ids_due = due(&store).await; - assert_eq!( - ids_due.len(), - 4, - "wallets with no cursor row are always due" - ); - - // Give each wallet a cursor row with a distinct activity/poll profile. All were *just* - // polled, so only the active one should come back as due again immediately. - for (id, activity_secs) in [(active, 10i64), (idle, 200), (dormant, 100_000)] { - sqlx::query( - "INSERT INTO ingest_cursor (wallet_id, paging_token, updated_at, last_polled_at) - VALUES ($1, 'tok', now() - make_interval(secs => $2), now())", - ) - .bind(id) - .bind(activity_secs as f64) - .execute(store.pool()) - .await - .expect("seed cursor"); - } - - let ids_due = due(&store).await; - assert!( - ids_due.contains(&active), - "an actively-transacting wallet must be polled every tick" - ); - assert!( - !ids_due.contains(&idle), - "an idle wallet polled just now must wait for its interval" - ); - assert!( - !ids_due.contains(&dormant), - "a dormant wallet polled just now must wait for its (longer) interval" - ); - assert!( - ids_due.contains(&never), - "a wallet that has never been polled is still due" - ); - - // Move the idle wallet's last poll past its 100s interval — it becomes due, while the - // dormant one (100_000s interval) is still not. - sqlx::query("UPDATE ingest_cursor SET last_polled_at = now() - make_interval(secs => 150) WHERE wallet_id = $1") - .bind(idle) - .execute(store.pool()) - .await - .expect("age idle poll"); - sqlx::query("UPDATE ingest_cursor SET last_polled_at = now() - make_interval(secs => 150) WHERE wallet_id = $1") - .bind(dormant) - .execute(store.pool()) - .await - .expect("age dormant poll"); - - let ids_due = due(&store).await; - assert!( - ids_due.contains(&idle), - "idle wallet is due once its interval elapses" - ); - assert!( - !ids_due.contains(&dormant), - "dormant wallet needs much longer than the idle interval before it is due" - ); -} - -#[tokio::test] -async fn mark_polled_creates_and_updates_the_cursor_row() { - let Some(store) = store().await else { return }; - let wallet_id = fresh_wallet(&store).await; - - // No cursor row yet — mark_polled must create one rather than silently no-op. - store.mark_polled(wallet_id).await.expect("first mark"); - let first: Option> = - sqlx::query_scalar("SELECT last_polled_at FROM ingest_cursor WHERE wallet_id = $1") - .bind(wallet_id) - .fetch_one(store.pool()) - .await - .expect("read cursor"); - let first = first.expect("last_polled_at set"); - - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - store.mark_polled(wallet_id).await.expect("second mark"); - let second: Option> = - sqlx::query_scalar("SELECT last_polled_at FROM ingest_cursor WHERE wallet_id = $1") - .bind(wallet_id) - .fetch_one(store.pool()) - .await - .expect("read cursor again"); - assert!( - second.expect("still set") > first, - "repeat polls advance the timestamp" - ); - - // Marking a poll must NOT look like activity. If it did, every never-used wallet would count - // as freshly active and the backoff tiers would never engage at all. - let activity: chrono::DateTime = - sqlx::query_scalar("SELECT updated_at FROM ingest_cursor WHERE wallet_id = $1") - .bind(wallet_id) - .fetch_one(store.pool()) - .await - .expect("read updated_at"); - assert!( - activity < chrono::Utc::now() - chrono::Duration::days(365), - "mark_polled must not advance updated_at (last-activity); got {activity}" - ); - - // Marking a poll must not invent a paging token — that only advances on real activity. - let token: Option = - sqlx::query_scalar("SELECT paging_token FROM ingest_cursor WHERE wallet_id = $1") - .bind(wallet_id) - .fetch_one(store.pool()) - .await - .expect("read token"); - assert!( - token.is_none(), - "mark_polled must not fabricate a cursor position" - ); -} - -#[test] -fn shared_cursor_pagination_helper_encodes_invariant() { - let query = octo_store::cursor_pagination_query("wallets", "user_id"); - assert!(query.contains("SELECT * FROM wallets")); - assert!(query.contains("WHERE user_id = $1")); - assert!(query.contains("($2::uuid IS NULL OR (created_at, id) < (")); - assert!(query.contains("SELECT created_at, id FROM wallets WHERE id = $2")); - assert!(query.contains("ORDER BY created_at DESC, id DESC")); - assert!(query.contains("LIMIT $3")); -} diff --git a/justfile b/justfile index 9a1ea05..71801ad 100644 --- a/justfile +++ b/justfile @@ -61,3 +61,25 @@ db-reset: # Run the server. run: cargo run -p octo-server + +# Run the Bruno API-tests integration suite non-interactively against a local server. +test-integration: + #!/usr/bin/env bash + set -euo pipefail + cargo build -p octo-server + cargo run -p octo-server & + SERVER_PID=$! + trap 'kill $SERVER_PID 2>/dev/null || true' EXIT + echo "Waiting for octo-server to be ready..." + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + echo "octo-server is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "octo-server failed to start" + exit 1 + fi + sleep 1 + done + npx -y @usebruno/cli run api-tests --env Local