Layer global middleware onto build_router (applies to every route, shared by
main + the test harness):
- CORS: configurable allowed origins via CORS_ALLOWED_ORIGINS; empty -> allow
any origin, which is safe here because auth is a Bearer token (no cookies, no
ambient credentials). Credentials are never allowed.
- Body limit: 1 MiB global backstop (handlers already validate tighter per
field); /wallet/lws/submit_tx overrides to ~2 MiB+ for raw Monero tx hex.
- Timeout: 60s request backstop (above the chain clients' ~30s timeouts, so
their errors surface first; bounds any slow handler). Uses the http-aware
tower_http TimeoutLayer (-> 408), not tower::timeout (fallible, rejected by
axum's Infallible Router).
- gzip compression + request tracing (outermost).
Adds cors_allowed_origins config + the tower-http "timeout" feature. Per-IP
rate limiting is deliberately separate (needs ConnectInfo) — next commit.
All gates green; integration tests pass through the layered router (CORS/timeout/
compression/body-limit are oneshot-safe). openapi unchanged.
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.
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.
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.
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).
Phase 0 of grin support, reworked clean from the chain-audit playbook. The
open backend is NON-CUSTODIAL and holds no Grin spend key, so this is a
VIEW-ONLY client — NOT a port of the reference's spend-capable Owner-API
driver. Rationale + the grin-lws plan: docs/private/GRIN_LWS_DESIGN.md.
Model: Grin has had view/spend separation since 2021 (grin_keychain::ViewKey;
rewind_hash = blake2b of the PUBLIC root key). grin-wallet's Owner API
scan_rewind_hash(rewind_hash, start_height) does the chain scan and returns a
ViewWallet{output_result, total_balance, last_pmmr_index} (incremental). This
client drives that; the wallet builds + signs txs locally and hands the backend
only the finalized tx to broadcast.
Surface (view-only): open_wallet (operator scan-engine token, cached),
scan_rewind_hash -> ViewWallet, node get_status/get_height, broadcast
(node Foreign push_transaction). DROPPED: all spend methods (init_send_tx,
finalize, balance, cancel) and the node rangeproof-scan paths (scan_rewind_hash
does the scanning).
Hardened Owner-API v3 secure transport (ECDH secp256k1 + AES-256-GCM):
- session init is TOCTOU-free (re-checked under the write lock);
- AES-GCM nonce counter advances with checked_add and is never reused
(overflow -> error, not wrap);
- the ECDH shared key, wallet password, and response bodies are NEVER logged
or interpolated into errors (errors carry a static label + numeric code);
- tokio (non-poisoning) RwLock for the token + session;
- typed Ok/Err envelopes deserialized directly (no Value clone of ViewWallet);
- bounded streaming response read (16 MiB) + request/connect timeouts;
- secrets wrapped in core::secret::Secret.
8 L2 tests: AES-GCM encrypt/decrypt round-trip, nonce monotonicity + byte
layout, nonce-exhaustion-errors-not-panics, wrong-key auth rejection, bad
nonce/ciphertext rejection, Ok/Err/rpc-error envelope mapping (message not
surfaced), construction without I/O, confirmation math (tip/zero/future).
Reworked clean from the chain-audit playbook into one generic client over
CryptoNoteNetwork (Monero + Wownero share the LWS API). The LWS may be
remote/community-run, so every response is treated as hostile.
Security (closes the audit CRITICAL/HIGH/MEDIUM findings):
- The two `&str` byte-slice panics are GONE: responses are parsed via a
generic post_json -> read_capped -> serde_json::from_slice path. No
byte-indexing of an untrusted body anywhere; the JSON-error-column
slice is deleted.
- Streaming size cap (8 MiB) enforced as bytes arrive (content-length is
attacker-asserted) + shared reqwest client with request/connect
timeouts. No unbounded read, no slowloris.
- Admin key held in a redacting `Secret` (Debug -> "***"); secret-bearing
request structs drop `Debug`; #[instrument] skips view_key.
- Non-success -> generic NodeError tagged with a static endpoint label +
status code only; the upstream body is never interpolated into a log or
error. Transport (connect) errors are logged privately, redacted publicly.
- Amounts are u64 end-to-end via the fail-closed string_or_* deserializers
(garbage/overflow -> Err, never panic/silent-zero); mempool sum and
confirmation math use saturating ops; future-block confirmation -> 0.
- Ring/decoy count clamped (MAX_DECOY_COUNT) + request/parsed fan-out
capped (MAX_PARSED_VEC).
0-conf architecture: default is REST-poll (compute pending on-demand from
get_address_txs, which includes mempool on current monero-lws). HTTP
webhooks are NOT brought over (redundant + fragile per docs/lws-update.md);
ZMQ-push stays an optional, feature-flagged unit for single/own backends
wanting instant confirms. Kept the account-lifecycle calls
(modify_account_status/rescan/account_scan_height) — those are the
deep-sleep machinery REST-poll uses to wake/sleep accounts for scale.
Dropped the /login pending flow + all dead/tip surface.
Adds a shared redacting `Secret` (core::secret, grin reuses) and the
reqwest `stream` feature for bounded reads. types.rs kept (the
string_or_* fail-closed helpers) with secret request structs de-Debug'd
and error-not-panic tests. 15 L2 tests. Adversarially reviewed (7/7
audit findings closed; future-block confirmation hardened).
Electrum/Fulcrum already covers everything the backend needs for Bitcoin
and Litecoin — balance, UTXOs, history, tip, verbose tx, fee estimation,
and broadcast (blockchain.transaction.broadcast). A self-hosted Fulcrum
relays broadcasts to its own backing node, so a separate Bitcoin Core
RPC path buys nothing in the self-host model while adding the
bitcoincore-rpc + bitcoin + secp256k1 + jsonrpc dependency tree and a
duplicate broadcast surface.
- Remove the `bitcoincore-rpc` dependency (and its transitive tree).
- Slim `UtxoConfig` to pure Electrum config: drop use_local_node /
rpc_url / rpc_user / rpc_pass; keep network + electrum_primary +
electrum_fallbacks.
A future LTC-MWEB or direct-node provider is a distinct capability
(MWEB uses a scan-key model, not canonical scriptPubKeys) and would slot
in at the provider seam rather than extending this client — so nothing is
foreclosed by keeping the surface minimal now.
BTC/LTC Electrum/Fulcrum client, reworked clean from the chain-audit
playbook (not ported). Every byte from a server is treated as hostile.
Security:
- TLS verified against the webpki root store WITH hostname checking; the
accept-any-certificate verifier is gone (closes the MITM hole). The
connector is built once and reused.
- Addresses decoded via the `bech32` crate (full checksum + bech32m +
witness version) and base58check (checksum); the hand-rolled bech32
underflow panic is removed. Coin/network enforced (reject ltc-on-btc,
testnet-on-mainnet) — no byte-indexing of untrusted input.
- Amounts pass a fallible checked f64->sat converter (rejects
non-finite/negative/>2^53) with checked_add summation; no silent
saturation to 0 / u64::MAX. estimatefee requires finite, clamps.
- Bounded response read + per-exchange timeout (no OOM, no slowloris).
- JSON-RPC response id correlated with the request.
- Untrusted upstream text is control-stripped + length-capped before it
reaches a (privately-logged) NodeError.
Single `call` primitive centralizes failover + id + envelope mapping;
public methods (balance/utxos/history/tip/verbose-tx/broadcast/fee) are
thin wrappers. Tip-attribution helpers dropped; total_sent_from now
declines (None) without resolved prevouts instead of mislabeling a
receive-with-change as a send.
20 L2 tests: address->script KATs (P2WPKH/P2WSH/taproot/legacy), network
rejection, panic-safety on short/garbage input, checked-sat bounds,
envelope id correlation, bounded/oversized read, duplex round-trip.
- 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.
- api/website.rs: sign-in-with-wallet (dapp connect). challenge bound to origin
+ short TTL; verify is single-use (remove-before-verify, race-safe), expiry-
checked, signature via Result<(),AppError> verifiers (no oracle), resolves an
EXISTING user by the proven key's pubkey_hash (never mints identity), mints a
Platform::Web session.
- api/users.rs: username set/get + by-username lookup (JWT-gated, 409 on taken,
constant-shape lookup); per-asset key registration bound to the JWT user
(no spoofable user_id) + public key-directory reads.
- api/nip05.rs: /.well-known/nostr.json directory (username -> nostr_pubkey).
- Wired into api/mod.rs; website+users nested at /api/v1, nip05 at root; all
paths/schemas registered (openapi.json now 17 endpoints).
- main.rs: periodic GC of expired web challenges (bounds the in-memory store).
Adversarially reviewed (register_key authz + website identity-binding both
hold; no critical/high). 69 tests green. Deferred LOW: per-IP rate-limit on the
website routes lands with the router-assembly unit.
api/middleware.rs: Bearer access-token extraction; client_ip() trusts XFF only
behind config.trusted_proxies (fail-closed against spoofing).
api/auth.rs: extension_register (PoW-gated; derivation-rotation requires a
signature from the on-file key, no fingerprint hijack), check_restore (per-IP +
per-fingerprint rate limits, enumeration/timing-resistant), pow_challenge,
refresh_token (re-checks active session + revoke-then-issue rotation, race-safe),
logout, get_me, nostr_login (login-grade NIP-98, never creates a user, bound to
config public_api_url), nostr_link (JWT + state-change verify_signed_action,
fail-closed nonce stub) + DTOs + routes().
Wired into api/mod.rs + nested at /api/v1 in main.rs; all 8 paths registered in
ApiDoc so openapi.json covers the auth surface (was vacuous).
Drafted+hardened via draft->adversarial-review->revise workflow; independently
re-reviewed (no critical/high). 58 tests, all gates green.
Known LOW (tracked): concurrent first-registration of the same new pubkey_hash
can 500 instead of idempotent re-read; client-retryable.
- core/crypto/pepper.rs: domain-separated HMAC peppering of identity values at
rest (kills the seed-existence oracle: stored pubkey_hash/seed_fingerprint are
non-reproducible from a candidate seed without the server pepper)
- infra/db/mod.rs: Database wrapper holding the pool + peppers (no Debug derive);
peppering applied at the data boundary so it cannot drift between call sites
- infra/db/users.rs: Nostr-native query set (create/get/update, by pubkey_hash
/seed_fingerprint [peppered], by username/nostr_pubkey, get-or-create with
NULL-only backfill, username + nostr_pubkey UNIQUE -> 409). Explicit columns
(no SELECT *). Dropped all telegram/discord methods.
core/crypto/nip98.rs:
- verify_nip98 (login-grade): binds url+method+freshness
- verify_signed_action (state-change): adds single-use nonce + purpose +
canonical request-descriptor hash + optional target-id/instance-id, each an
exactly-one tag, constant-time compared, all checked on the signature-
authenticated event (id recomputed + schnorr-verified before any tag trusted)
- hardening from adversarial review: length-guard pubkey/id/sig before k256
construction (no panic on short input), saturating freshness window (no
overflow wrap); regressions for all three
core/crypto/signatures.rs: BIP-137 ECDSA + Ed25519 verification returning
Result<(),AppError> (Ok only on valid; malformed->400, mismatch->401) — closes
the Ok(false) fail-open.
Cargo.toml: overflow-checks=true in release (defense-in-depth for auth paths).
models/db.rs: User keyed by pubkey_hash/nostr_pubkey/username (pubkey_hash and
seed_fingerprint peppered at rest); Wallet, Session (platform=extension|web|nostr),
UserKey, GrinSlatepack, AuditLog. Dropped all telegram/discord/social-identity
columns and every tip/social/swap struct (swaps land as their own feature unit).
migrations/20260622000001_initial_schema: consolidated clean schema; enum types;
UNIQUE on identity columns; FK ON DELETE (CASCADE/SET NULL) for self-service
erasure; indexes on every lookup column. PostgreSQL 13+.
- minimal Axum server exposing GET /health
- OpenAPI contract generated from handlers (gen_openapi -> openapi.json),
drift-gated in CI so the spec can never silently diverge from the code
- CI: secret-scan + gitleaks, openapi drift gate, fmt/clippy/build/test/doc-test, cargo-deny
- Cargo.lock tracked for reproducible builds; publish=false; pinned toolchain
- README, SECURITY.md, deny.toml, .env.example