Commit Graph
13 Commits
Author SHA1 Message Date
jwinterm 224ef36ad3 feat(restore): operator-configurable wallet restore policy
How far back a wallet may ask THIS instance to scan on import is an operator
decision — the backfill cost lands on its LWS/node. Add WALLET_RESTORE_POLICY
(create-only | bounded | unlimited, default bounded) + WALLET_MAX_RESTORE_DEPTH_DAYS,
advertise it via /capabilities so the wallet adapts its import UX, and enforce
it at the scan path (xmr/wow register, grin scan): a too-deep start_height is a
ValidationError naming the bound, never the caller's value. Create needs no
check (no tip lookup). Self-sovereignty stays intact at the ecosystem level —
a deeper restore means self-hosting or a looser operator. Fail-closed config
validation; a unit test per policy; OpenAPI drift clean.
2026-06-28 22:15:42 -04:00
jwinterm ff89ca6584 feat(wallet): unified GET /wallet/heights (tip per chain)
The wallet's ChainProvider seam fetches all chain tips in one call
(getBlockchainHeights) and selects per asset; the backend exposed per-chain
height endpoints but no aggregate, so the client's call 404'd. Add a
best-effort /wallet/heights returning {btc,ltc,xmr,wow,grin} concurrently — a
disabled or unreachable chain is null, never failing the others. JWT-gated,
OpenAPI-annotated (drift clean).
2026-06-28 22:01:14 -04:00
jwinterm 56c8dd463a feat(openapi): document bearer auth on the 28 token-gated routes
The spec carried no security scheme, so generated clients/docs could not tell
which endpoints require a session token — e.g. /wallet/utxo/balance read as
public while its handler enforces auth via extract_user_id_from_token. Add a
`bearer_auth` HTTP/JWT scheme through a Modify addon and annotate every route
that resolves a session; auth-bootstrap, public username/key lookups, and
system routes stay open. Regenerated openapi.json (drift clean).
2026-06-28 21:42:32 -04:00
jwinterm 2d0077b2b9 feat(capabilities): honest secret-presence downgrade + admin features view [operator §4]
Make /capabilities fail-closed and add the admin features surface.

- Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is
  unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner
  secret) now reports enabled:false — so /capabilities never advertises a chain
  that would 404, and a missing-secret downgrade is indistinguishable from a
  deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity
  requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper.
- contract_version stamp on the public document (clients soft-notice a higher
  value, ignore unknown additive keys).
- GET /admin/features (admin_guard): the resolved effective state PLUS admin-only
  downgrade reasons (the public surface hides the reason, per §4.3).
- Golden test: the public capabilities JSON, built from a fully-secret-seeded
  config, contains no secret value (asserts no "integration-test-" substring).

openapi regenerated (contract_version). Admin features view stays off the public
OpenAPI.

Deferred (documented): runtime mutation — PUT /admin/features + a DB feature-
override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags
are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a
deferred LB decision) is a later addition.
2026-06-27 15:35:56 -04:00
jwinterm d8b6058d40 feat: fiat price feed with per-feed operator control (GET /api/v1/prices)
Cached crypto→fiat price feed, refreshed by a background task and served from an
in-memory snapshot — no upstream call on the request path. Public and
feature-flagged: when FEATURE_PRICES is off, /prices is 404 and no upstream is
ever contacted.

Per-feed control, as a public server needs: PRICES_ASSETS narrows which feeds an
instance quotes (unset = all supported; an explicit list serves a subset; empty
serves none), validated fail-closed against SUPPORTED_PRICE_ASSETS. Provider
(PRICES_PROVIDER, coingecko) and currency (PRICES_CURRENCY, usd) are configurable;
the refresh interval is clamped to a provider-friendly minimum.

- infra/prices: PriceClient (feed whitelist → provider coin ids), bounded by the
  HTTP client timeout; non-finite/negative/missing quotes are skipped, not faked.
  A fetch failure keeps the last good snapshot. Drift guard: a test asserts the
  provider mapping covers exactly SUPPORTED_PRICE_ASSETS.
- main: background refresh task only when enabled and at least one feed is set.
- AppState.prices snapshot; handler 404s when disabled.
- openapi (prices tag); unit tests + L1 test over both feature-flag states.
2026-06-25 16:41:40 -04:00
jwinterm 1e2bdf3f16 feat: GET /api/v1/capabilities (client feature discovery)
Public, unauthenticated description of what this deployment offers, so the
wallet can adapt to the instance it's pointed at — grey out disabled chains and
features, and pick the right network. This is the client-facing half of the
open-core/feature-flag design: every capability is a config switch, surfaced
here. No secrets — only on/off flags and public network names.

Response: { version, chains: { btc/ltc/xmr/wow/grin: { enabled, network } },
features: { grin_relay, prices, nostr_identity, tips } }. UTXO chains carry
their configured network; CryptoNote/Grin report null. On the normal rate-limit
tier. Registered in openapi (system tag); 1 L1 test (public, well-shaped).
2026-06-25 16:27:13 -04:00
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 fb3c547d93 feat: website auth, username + key registration, NIP-05 directory
- 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.
2026-06-22 18:13:47 -04:00
jwinterm be23042c0c feat: core auth handlers (register, restore, pow, refresh, logout, me, nostr)
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.
2026-06-22 17:47:20 -04:00
jwinterm 143f00701b chore: initial foundation — server skeleton, autodocs, CI
- 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
2026-06-22 06:18:57 -04:00