diff --git a/README.md b/README.md index 44eb3ee..9f34caf 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ curl -s -X POST localhost:8080/v1/wallets//submit-signed \ ``` See [docs/non-custodial-flow.md](docs/non-custodial-flow.md) for the full -build → sign → relay sequence. +build → sign → relay sequence. Accepting payments via a shareable link is walked through in +[docs/api.md](docs/api.md#payment-link-checkout-flow); account activity categories are in +[docs/audit-log.md](docs/audit-log.md). ## Security architecture diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index 000e5a0..159fef1 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -1200,3 +1200,152 @@ async fn mark_polled_creates_and_updates_the_cursor_row() { "mark_polled must not fabricate a cursor position" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_set_gas_tank_calls_result_in_exactly_one_success() { + let Some(store) = store().await else { return }; + let acct = format!("G{}", Uuid::new_v4().simple()); + let wallet = store + .create_client_wallet(octo_store::NewClientWallet { + network: "testnet", + stellar_account_g: &acct, + encrypted_backup: None, + label: Some("gas-tank-race"), + user_id: None, + description: None, + }) + .await + .expect("create client wallet"); + + // All callers wait on the barrier so their UPDATEs genuinely overlap. + const N: usize = 8; + let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(N)); + let tanks: Vec = (0..N) + .map(|_| format!("G{}", Uuid::new_v4().simple())) + .collect(); + let handles: Vec<_> = tanks + .iter() + .cloned() + .map(|tank| { + let (store, barrier, wallet_id) = (store.clone(), barrier.clone(), wallet.id); + tokio::spawn(async move { + barrier.wait().await; + let res = store + .set_gas_tank(wallet_id, &tank, b"ct", b"nonce", b"salt", 1) + .await; + (tank, res) + }) + }) + .collect(); + + let mut winners = Vec::new(); + for h in handles { + let (tank, res) = h.await.expect("task"); + match res { + Ok(_) => winners.push(tank), + Err(e) => assert!( + matches!(e, StoreError::Conflict), + "loser must be Conflict: {e:?}" + ), + } + } + assert_eq!(winners.len(), 1, "exactly one provisioning call may win"); + + // The stored tank must be the winner's, not a blend of racing writes. + let stored = store.get_wallet(wallet.id).await.expect("get"); + assert_eq!( + stored.gas_tank_account_g.as_deref(), + Some(winners[0].as_str()) + ); +} + +/// Seed a cursor row: last activity `activity_ago` seconds back, last poll `polled_ago` back. +async fn seed_cursor(store: &Store, id: Uuid, activity_ago: i64, polled_ago: i64) { + sqlx::query( + "INSERT INTO ingest_cursor (wallet_id, paging_token, updated_at, last_polled_at) + VALUES ($1, 'tok', now() - make_interval(secs => $2), now() - make_interval(secs => $3))", + ) + .bind(id) + .bind(activity_ago as f64) + .bind(polled_ago as f64) + .execute(store.pool()) + .await + .expect("seed cursor"); +} + +// Tiers: active < 60s since activity, idle wait 100s, dormant >= 300s since activity, wait 100_000s. +async fn is_due(store: &Store, id: Uuid) -> bool { + store + .wallets_due_for_poll("testnet", 60, 100, 300, 100_000) + .await + .expect("due query") + .iter() + .any(|w| w.id == id) +} + +#[tokio::test] +async fn wallets_due_for_poll_includes_a_wallet_with_no_cursor_row_at_all() { + let Some(store) = store().await else { return }; + let id = fresh_wallet(&store).await; + assert!( + is_due(&store, id).await, + "never-polled wallet is always due" + ); +} + +#[tokio::test] +async fn wallets_due_for_poll_boundary_at_exactly_active_after_secs() { + let Some(store) = store().await else { return }; + let (inside, outside) = (fresh_wallet(&store).await, fresh_wallet(&store).await); + + // Both polled 1s ago; only the tier decides the wait (active: 0s, idle: 100s). + seed_cursor(&store, inside, 58, 1).await; // just inside active_after_secs + seed_cursor(&store, outside, 62, 1).await; // just outside => idle tier + + assert!( + is_due(&store, inside).await, + "just-active wallet polls every tick" + ); + assert!( + !is_due(&store, outside).await, + "just-idle wallet must wait its interval" + ); +} + +#[tokio::test] +async fn wallets_due_for_poll_boundary_at_exactly_dormant_after_secs() { + let Some(store) = store().await else { return }; + let (inside, outside) = (fresh_wallet(&store).await, fresh_wallet(&store).await); + + // Both polled 150s ago: past the idle wait (100s), far short of the dormant wait. + seed_cursor(&store, inside, 298, 150).await; // just before dormant_after_secs => idle + seed_cursor(&store, outside, 302, 150).await; // just past it => dormant + + assert!( + is_due(&store, inside).await, + "just-idle wallet is due after its interval" + ); + assert!( + !is_due(&store, outside).await, + "just-dormant wallet must wait the long interval" + ); +} + +#[tokio::test] +async fn wallets_due_for_poll_excludes_an_idle_wallet_polled_within_its_interval() { + let Some(store) = store().await else { return }; + let (recent, stale) = (fresh_wallet(&store).await, fresh_wallet(&store).await); + + // Idle tier (200s since activity), 100s interval: 90s ago is too soon, 110s is due. + seed_cursor(&store, recent, 200, 90).await; + seed_cursor(&store, stale, 200, 110).await; + + assert!( + !is_due(&store, recent).await, + "polled within its interval => excluded" + ); + assert!( + is_due(&store, stale).await, + "polled past its interval => due" + ); +} diff --git a/docs/api.md b/docs/api.md index 05a836a..68ab4d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -118,10 +118,80 @@ so it cannot escalate or revoke itself. - `GET /v1/wallets/{id}/api-key` — metadata (prefix, created_at) — never the key itself. - `DELETE /v1/wallets/{id}/api-key` — revoke. +## Payment link checkout flow + +A payment link is a shareable, USDC-only checkout page. The merchant creates it once (authenticated); +every payer step after that is **public — no credential** — and keyed by the link's `slug`. Public +routes are rate-limited per client IP (per minute): read 60, **intent 5**, signing-info 60, +submit 20, status 60. Over the limit → `429`. + +Merchant, once (dashboard JWT or wallet API key; `$TOKEN` as in the README): + +```bash +curl -s -X POST localhost:8080/v1/wallets//payment-links \ + -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"name":"Order #1042","amount_usdc_stroops":50000000}' | jq # omit the amount for a flexible link +# -> data.slug (e.g. "3f9c1a7d2e"), data.url (hosted checkout page) +``` + +Payer, from there (`$SLUG` is `data.slug`; a fixed-amount link ignores any amount the payer sends, +a flexible link requires `amount_usdc_stroops > 0`): + +```bash +# 1. Fetch the link: what is being paid and where. 404 if the slug is unknown or the link is inactive. +curl -s localhost:8080/v1/pay/$SLUG | jq +# -> { name, description, image_url, redirect_url, amount_usdc_stroops, deposit_address, asset_code: "USDC" } + +# 2. Create a payment intent. Each intent gets its OWN muxed deposit address, so a deposit maps to +# exactly one payment. payer_name / payer_email are optional. +curl -s -X POST localhost:8080/v1/pay/$SLUG/intent \ + -H 'content-type: application/json' -d '{"payer_name":"Ada","payer_email":"ada@example.com"}' | jq +# -> 201 { payment_id, deposit_address, amount_usdc_stroops } (keep payment_id) + +# 3. Get what you need to build the transaction. `account` is the PAYER's own G... account, so the +# returned sequence is the payer's. Omit it and the merchant wallet's account is used. +# A payer account that does not exist on the network yet (unfunded) returns 404. +curl -s "localhost:8080/v1/pay/$SLUG/signing-info?account=" | jq +# -> { account, sequence, network_passphrase, base_fee_stroops } + +# 4. Build and SIGN LOCALLY (e.g. in Freighter): exactly one USDC Payment to the intent's +# deposit_address, nothing else. Then relay it, passing the payment_id from step 2. +curl -s -X POST localhost:8080/v1/pay/$SLUG/submit-signed \ + -H 'content-type: application/json' \ + -d '{"transaction_xdr":"","payment_id":""}' | jq +# -> 201 { status: "confirmed" | "failed", stellar_tx_hash, detail } + +# 5. Poll until the deposit is matched (the pay page polls about every 3s). +curl -s localhost:8080/v1/pay/$SLUG/payments/ | jq +# -> { status, transaction_id, expected_usdc_stroops, received_usdc_stroops } +``` + +Things an integrator should know: + +- **`submit-signed` returns `201` even when `status` is `"failed"`** — check `status` and `detail` + (a Horizon result code such as `op_underfunded`), not just the HTTP code. `400` means the + transaction was rejected before relay: not a v1 envelope, unsigned, or not exactly one USDC + `Payment` to this intent's `deposit_address`. +- **The relay is deliberately narrow**: it never signs and cannot spend anything except that one + payment. Without `payment_id` it falls back to the link's own address (legacy clients). +- **Status** is one of `pending`, `confirmed`, `expired`, `underpaid`, `overpaid`. `received_usdc_stroops` + is `null` until a deposit is matched; `expected_usdc_stroops` is always present so a client can + show "you sent X, expected Y". A `confirmed` status comes from the ingest worker seeing the + deposit, not from the submit response. +- **Payer PII is write-only.** `payer_name` and `payer_email` are stored for the merchant but no + public route returns them, and none of the public responses above carries merchant-internal + fields (wallet id, link id, collected totals). If a public response ever gains or loses a field, + update the shapes shown here. +- Amounts are integer stroops: `50000000` = 5 USDC. + +Merchant-side management (list/get/deactivate links and list a link's payments) lives under +`/v1/wallets/{id}/payment-links` — see [openapi.yaml](openapi.yaml). + ## Audit logs - `GET /v1/audit-logs` — your account's activity, filterable by `category` and a free-text - `search`. Valid categories: `authentication`, `wallet`, `address`, `credentials`, `configuration`, `sponsorship`. + `search`. Valid categories: `authentication`, `wallet`, `address`, `credentials`, `configuration`, `sponsorship`. The category set and every event that emits one is catalogued in + [audit-log.md](audit-log.md). ## Conventions diff --git a/docs/audit-log.md b/docs/audit-log.md new file mode 100644 index 0000000..949071d --- /dev/null +++ b/docs/audit-log.md @@ -0,0 +1,67 @@ +# Audit log + +`GET /v1/audit-logs` returns the signed-in user's account activity (see [api.md](api.md)). Rows are +written by `crate::audit::record` in `crates/api/src/audit.rs`. Recording is **best-effort**: a +failure is logged and never fails the request that triggered it. + +## Categories + +The category set lives in `crate::audit::category`. The wire value is what a client filters on +with `GET /v1/audit-logs?category=`. + +| Constant | Wire value | Meaning | +|---|---|---| +| `AUTH` | `authentication` | Account session lifecycle: sign-up, sign-in, token refresh, sign-out | +| `WALLET` | `wallet` | Wallet lifecycle and anything that relays a transaction from a wallet | +| `ADDRESS` | `address` | Customer deposit addresses | +| `CREDENTIALS` | `credentials` | Per-wallet API key issuance and revocation | +| `SPONSORSHIP` | `sponsorship` | Gas-tank fee sponsorship: sponsored transactions and config changes | +| `WEBHOOK` | `configuration` | Reserved for webhook configuration changes — **no call site emits it yet** | +| `WITHDRAWAL` | `wallet` | Alias of `WALLET` (same wire value) — **no call site uses it**; withdrawals emit `WALLET` | + +Two constants share the wire value `wallet`, so filtering by `wallet` returns both. + +## Call sites + +Every `crate::audit::record` call in `crates/api/src` (13 in total): + +| Category | Action text | Emitted from | Trigger | +|---|---|---|---| +| `authentication` | `created an account` | `auth.rs` `signup` | `POST /v1/auth/signup` | +| `authentication` | `signed in` | `auth.rs` `login` | `POST /v1/auth/login` (successful) | +| `authentication` | `refreshed session token` | `auth.rs` `refresh` | `POST /v1/auth/refresh` | +| `authentication` | `logged out` | `auth.rs` `logout` | `POST /v1/auth/logout` | +| `wallet` | `created master wallet` | `routes/wallets.rs` `create_wallet` | `POST /v1/wallets` | +| `wallet` | `provisioned a gas tank` | `routes/wallets.rs` `create_gas_tank` | `POST /v1/wallets/{id}/gas-tank` | +| `wallet` | `submitted a signed transaction ()` | `routes/submit.rs` `submit_signed` | `POST /v1/wallets/{id}/submit-signed`, only when called with a dashboard JWT | +| `wallet` | `confirmed a withdrawal ()` | `routes/submit.rs` `withdraw_confirm` | `POST /v1/wallets/{id}/withdraw/confirm` | +| `address` | `generated a deposit address` | `routes/addresses.rs` `create_address` | `POST /v1/wallets/{id}/addresses`, when the wallet has an owner | +| `credentials` | `generated an API key` | `routes/apikeys.rs` `generate_key` | `POST /v1/wallets/{id}/api-key` | +| `credentials` | `revoked API key` | `routes/apikeys.rs` `delete_key` | `DELETE /v1/wallets/{id}/api-key` | +| `sponsorship` | `sponsored a transaction ()` | `routes/sponsor.rs` `sponsor` | `POST /v1/wallets/{id}/sponsor`, only when called with a dashboard JWT | +| `sponsorship` | `updated sponsorship config (enabled: )` | `routes/sponsorship.rs` `put_config` | `PUT /v1/wallets/{id}/sponsorship` | + +To re-verify this table is exhaustive: `grep -rn "audit::record" crates/api/src`. + +## Keeping the lists in sync + +Three places name the categories and must agree: the constants in `audit.rs`, this table, and the +`category` filter on `GET /v1/audit-logs` (`routes/audit.rs`). Any change to the constants must +update the other two in the same PR. The filter currently forwards any non-empty string to the +store unvalidated (an unknown category simply returns no rows); when it is changed to reject +unknown values, it should validate against `category::*` so the accepted set and the emitted set +cannot drift. + +## Adding an event or a category + +1. **Reuse an existing category** when the event is another action on the same kind of thing (a new + wallet operation is `wallet`, a new sign-in method is `authentication`). Categories are filter + chips in the dashboard, so keep the set small. +2. **Add a category** only when a user would plausibly want to filter for the new events on their + own and none of the existing meanings fit. Add the constant in `audit.rs`, a row to both tables + above, and update the filter validation. +3. Call `crate::audit::record(&state, user_id, action, category::X, target, &headers).await` after + the operation has succeeded. Use a past-tense action, put the resource (label, hash, account) + in `target`, and never put secrets or key material in either. +4. Record only when there is a user to attribute to. API-key callers have no user, so those paths + skip the record (see `submit_signed` and `sponsor`).