Commit Graph
6 Commits
Author SHA1 Message Date
jwinterm 586f681287 feat: Grin slatepack relay (non-custodial async mailbox, feature-flagged)
Final wallet-handler slice. An async store-and-forward mailbox for interactive
Grin transfers between two registered Smirk users, over the grin_slatepacks db.
Reworked clean from the legacy relay — NOT a port.

Endpoints (under /api/v1/wallet/grin/relay, JWT-gated):
- POST create   {recipient_user_id, slate_id, slatepack, amount_nanogrin}
- GET  pending  -> the caller's inbox
- POST get      {slate_id} -> poll a relay's state (sender/recipient)
- POST respond  {slate_id, response_slatepack} (recipient)
- POST finalize {slate_id, tx_hash} (sender; RECORDS the txid only)
- POST cancel   {slate_id} (sender or recipient)

Non-custodial: stores only opaque (encrypted) slatepacks + lifecycle status;
never holds keys, finalizes, or broadcasts. The wallet broadcasts locally (via
/wallet/grin/broadcast) and reports the txid here — diverging from the legacy
server-side post_tx (see docs/private/OWN_BACKEND_CONVERGENCE.md).

Controls + hardening (incl. fixes from the adversarial review workflow):
- Per-action authz: caller must be the slate's sender/recipient; a non-party
  gets 404 (no existence oracle).
- slate_id is UNIQUE (+ validated as a UUID) and mutations key on the
  authorized row's PRIMARY KEY, not slate_id — so a duplicate slate_id can't
  brick a relay (fetch_optional-on-2-rows 500) or let a slate_id-keyed UPDATE
  touch another row. create maps the unique violation to 409.
- State transitions are ATOMIC: the status + expiry preconditions live in the
  UPDATE WHERE clause (not a Rust read-check-write), so respond-twice and
  cancel-races-finalize cannot occur; a non-qualifying transition -> 409.
- TTL enforced at the action boundary (respond/finalize require expires_at >
  now) + a periodic expire_old_slatepacks sweep in main.rs (relay-enabled only).
- Recipient must be a registered user; slatepack sizes capped (64 KiB).
- Behind FEATURE_GRIN_RELAY (default on).

6 paths + schemas + grin_relay tag in openapi (drift-clean). 4 L1 integration
tests against live Postgres: full lifecycle, unregistered-recipient 400, auth
gate, non-party/non-recipient 404s.
2026-06-23 17:39:13 -04:00
jwinterm e62f1bec8e feat: Grin wallet handlers (view-only, non-custodial)
Third wallet-handler slice. Authenticated, view-only proxies over the
GrinClient — the backend holds no Grin spend key; the wallet exports its
rewind_hash (a public-derived view credential) per scan, the backend forwards
it to grin-wallet's scan_rewind_hash and stores no secret. Spending happens in
the wallet; the backend only broadcasts the finalized tx.

Endpoints (under /api/v1/wallet/grin):
- POST scan {rewind_hash, start_height?} -> {outputs, total_balance,
  last_pmmr_index}  (incremental resume index)
- GET  height -> {height}
- POST broadcast {tx} -> {ok}  (node push_transaction)

- JWT-gated. rewind_hash is a view credential: GrinScanRequest omits Debug and
  is skipped from every span (never logged), and is validated as 64 hex before
  any grin call (applying the wallet-handler review's secret-DTO lesson).
- Feature-gated: grin disabled -> 400.
- Shared validate_hex64 in wallet/mod.rs (view_key + rewind_hash); 3 paths +
  schemas + grin tag registered; spec regenerated, drift-clean.

4 L1 integration tests (auth gate on scan/height/broadcast; malformed
rewind_hash 400).

Also: dropped Debug from the xmr_wow view_key-bearing request DTOs
(ViewRequest/RegisterRequest) per the review — defense-in-depth against a
cleartext view_key dump.
2026-06-23 16:02:44 -04:00
jwinterm 10b508fa8e feat: XMR/WOW wallet handlers (LWS proxy, stateless-forward)
Second wallet-handler slice. Authenticated, non-custodial, stateless-forward
proxies over the LwsClient: the wallet sends its private view key per request,
the backend forwards it (never stores it; the LWS holds it for scanning), and
the wallet constructs + signs spends locally.

Endpoints (POST, under /api/v1/wallet/lws, asset = xmr|wow in body):
- register     -> add/import account (view key) for LWS scanning (+start_height)
- balance      -> total_received / unlocked / locked + scan state
- history      -> txs incl. mempool pending + candidate spent_outputs
- unspent_outs -> outputs + fee params for client-side spend construction
- random_outs  -> ring decoys (count clamped + fan-out capped in the client)
- submit_tx    -> broadcast a finalized signed tx
- height       -> daemon chain height
- confirmations-> tx confirmation count

- JWT-gated; the view key is skipped from every tracing span (never logged) and
  never stored. Feature-gated dispatch; unknown/disabled asset -> 400.
- Boundary validation: view_key (64 hex), address (bounded), txid (64 hex),
  tx_hex (even hex, <=2 MiB) — all before any LWS call.
- Shared validators in wallet/mod.rs (validate_hex/view_key/cn_address).
- Colliding DTO names disambiguated (Lws-prefixed) so the openapi schema
  registry stays correct; 8 paths + schemas + xmr_wow tag registered; spec
  regenerated, drift-clean.

6 L1 integration tests (auth gate on balance/register; unknown-asset,
bad-view-key, malformed-tx-hex, malformed-txid 400s) + validator unit tests.
2026-06-23 14:32:38 -04:00
jwinterm 1ec4639652 feat: BTC/LTC wallet handlers (electrum proxy, authenticated)
First wallet-handler slice. Thin, non-custodial, JWT-gated proxies over the
ElectrumClient — the wallet derives addresses and signs locally; these
endpoints only relay reads and a finalized broadcast.

Endpoints (POST, under /api/v1/wallet/utxo, asset = btc|ltc in body):
- balance  -> {confirmed, unconfirmed} (separate integers; client sums)
- utxos    -> [{txid, vout, value, height}]
- history  -> [{txid, height, fee}]
- tip      -> {height}
- fee      -> {sat_per_vb}
- broadcast-> {txid}

- JWT-gated for abuse control; the client-supplied address is NOT persisted
  against the user (no address<->identity graph stored here).
- Feature-gated dispatch: unknown/disabled asset -> 400; the Electrum client
  validates the address + network before any network call.
- broadcast validates tx_hex (non-empty, even-length, hex, <=200 KB) before
  reaching the node.
- Registered in build_router + openapi.json (6 paths, btc_ltc tag); spec
  regenerated, drift-clean.

5 L1 integration tests (auth gate on balance/broadcast/fee; unknown-asset and
malformed-tx-hex 400s — all deterministic, no network) + tx_hex unit test.
2026-06-23 11:57:43 -04:00
jwinterm b7e3199200 feat: wire chain clients into AppState (feature-gated)
Add ChainClients (infra/chains.rs): per-chain data-source handles built once at
startup from config and shared via AppState. Each client is present only when
its chain feature flag is on, and a malformed chain endpoint now fails startup
(fail-closed) rather than the first request. Cloneable (grin's session state is
shared via Arc) so it lives in the cloneable AppState.

Foundation for the wallet-handlers phase — handlers will read these clients
from State. No endpoints yet (openapi unchanged).
2026-06-23 10:39:25 -04:00
jwinterm 611e02e4de test: L1 integration harness (Postgres-backed, fundless)
- shared build_router() (lib) so main + tests exercise identical wiring
- tests/common: spins the real router + AppState against TEST_DATABASE_URL;
  self-skips when unset (cargo test stays green without a DB). Deterministic,
  non-secret test JWT/peppers; ephemeral generated identities only — no funded
  or sensitive wallet seeds (those stay in a separate gitignored L3 harness).
- tests/db_queries: validates migrations + db layer against real Postgres
  (peppered identity lookups, UNIQUE->409, session create/lookup/revoke,
  key upsert/list/delete, restore-attempt counts).
- tests/auth_flow: HTTP flows through the real router (health, auth gate,
  refresh rotation + old-token revocation, username set+lookup, key register+
  fetch, NIP-05 directory).
- CI: new integration job with an ephemeral postgres:16 service.
- 82 tests total (69 unit + 13 integration), all green against live PG.

Complements the monorepo's client-side suite (it tests the wire format the
client sends; the client tests the wallet side); mirrors its secret-handling
(env-driven, gitignored). Mirrors the L0-L5 tiering from docs/engineering.
2026-06-22 18:54:24 -04:00