Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ curl -s -X POST localhost:8080/v1/wallets/<WALLET_ID>/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

Expand Down
149 changes: 149 additions & 0 deletions crates/store/tests/store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = (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"
);
}
72 changes: 71 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<WALLET_ID>/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=<PAYER_G_ADDRESS>" | 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":"<BASE64_SIGNED_XDR>","payment_id":"<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/<PAYMENT_ID> | 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

Expand Down
67 changes: 67 additions & 0 deletions docs/audit-log.md
Original file line number Diff line number Diff line change
@@ -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=<value>`.

| 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 (<status>)` | `routes/submit.rs` `submit_signed` | `POST /v1/wallets/{id}/submit-signed`, only when called with a dashboard JWT |
| `wallet` | `confirmed a withdrawal (<status>)` | `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 (<status>)` | `routes/sponsor.rs` `sponsor` | `POST /v1/wallets/{id}/sponsor`, only when called with a dashboard JWT |
| `sponsorship` | `updated sponsorship config (enabled: <bool>)` | `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`).
Loading