First of the operator-configurable registration gates (PoW-priced restore and
pay-to-register follow). A NEW wallet must present a valid invite code when
REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it,
and the active gates are advertised via /capabilities so the wallet shapes
onboarding.
- migration + db: invite_codes table storing only sha256(code) (128-bit codes,
no pepper needed). Redemption is an atomic single-statement claim
(UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent
submission (L1 test fires 8 concurrent claims; exactly one wins).
- core::invite: generate (base58, 128-bit) + sha256 hash.
- enforce_invite in extension_register, mirroring enforce_pow (returning bypass;
ONE literal error for not-found/used/expired — never an oracle).
- RegistrationConfig.require_invite + RegistrationCapability in /capabilities.
- smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes).
- L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
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.
6 confirmed findings (review wqrd2gka3), 3 root-cause fixes:
- HIGH: execute_erasure had no status recheck and the sweeper's SKIP LOCKED row
lock was released before execution (separate connection), so (a) a request
CANCELLED after the sweep's SELECT was still executed, and (b) a fleet could
double-execute one erasure (duplicate hash-chained audit row, double count).
Fixed by making execution atomically CLAIM the row as its first statement —
`UPDATE ... SET status='completed' WHERE id=$1 AND status='confirmed'
RETURNING user_id` — and skipping (returns false) when 0 rows match. Only the
first executor of a still-confirmed request proceeds; a cancelled/already-done
row is a no-op; on any later failure the whole tx (claim included) rolls back.
- account-existence oracle: the absent-user request path returned a FRESH random
erasure_id + advancing scheduled_for each call, while a real account returned a
STABLE get-or-create id — a re-call distinguisher. The request response is now
constant-shape: a STABLE id (real, or a deterministic integrity-secret-keyed
synthetic for the absent path), a fixed "pending" status, and NO scheduled_for
(it's meaningful only at confirm, where grace starts, and is returned there).
- config: ERASURE_GRACE_PERIOD_HOURS is bounded (<= 10 years) in validate(), so
the u64->i64 cast can't wrap negative (defeating grace) and Duration::hours
can't panic.
Dismissed after verification: ERASURE_ENABLED without FEATURE_NOSTR_IDENTITY
(validate already requires PUBLIC_API_URL; the nostr_identity coupling is a
nicety). Tests extended: cancelled-request-not-executed (HIGH regression),
double-execute no-op. 140 lib + 140 integration green, no drift, scans clean.
The last operator subsystem: a user who no longer trusts the backend can reach in
and delete their data. Action-bound and two-phase.
- migration 20260626000003: erasure_requests (user_id ON DELETE SET NULL so the
completed tombstone survives the user delete; one-live-request-per-user partial
unique index; due index).
- Proof model (§5.2): every action is a fresh NIP-98 signed action (purpose
erasure_request/confirm/cancel/export) bound to a single-use challenge nonce +
canonical descriptor + (confirm/cancel) the explicit erasure_id. A session
token is NEVER sufficient. Erasure targets only the users.id the proof's key
resolves to (via linked nostr_pubkey); leaked identifiers are never selectors.
Nonce consumed AFTER signature checks (forged sig can't burn a victim's nonce).
- Public routes (proof-gated): POST /account/erasure/challenge, /account/erasure
(request; ABSENT-user path returns identical shape — no account-existence
oracle), /account/erasure/:id/confirm (revokes live sessions), DELETE
/account/erasure/:id (cancel during grace), POST /account/export (see-before-
delete; omits view keys; audited).
- Execution (§5.7): a background sweeper claims confirmed-past-grace rows
(FOR UPDATE SKIP LOCKED) and runs ONE fail-closed tx: purge login_events
(the origin/asset/time quasi-identifier; default) or anonymize; scrub audit
PII; DELETE users (schema cascades wallets/sessions/keys/owned slatepacks,
SET NULL on counterparty/audit/login links); tombstone; hash-chained
account_erasure_completed audit.
- config: ERASURE_GRACE_PERIOD_HOURS (72h default); validate() requires the
integrity secret + PUBLIC_API_URL when erasure is enabled.
- 1 L1 test: export -> request -> confirm -> execute -> account gone, plus the
absent-user constant-shape path.
Scope (v1, documented): live primary. No at-rest view key exists to crypto-shred
(stateless-forward), so backup residue is bounded by retention-window docs, not
crypto-shred (non-v1 per decisions ledger). Deferred: LWS de-registration queue +
read-back probe (operators run their own LWS), audit cross-reference scrub on
OTHER users' rows, restore_attempt salted-rotated fingerprint, retention sweepers,
NIP-17 DM warning, in-flight-value blockers (no swaps/tips in the clean core),
hard per-day export rate cap, /admin/erasure/stuck observability. Routes are off
the public OpenAPI for now (stable contract; annotate later).
9 confirmed findings (review wbdf3c0sd), all fixed:
S4 (capabilities):
- REGRESSION: /capabilities reported grin_relay:false for a working relay (S4
wrongly coupled it to Grin chain serviceability). The relay is a non-custodial
mailbox — wallet broadcasts locally — so it is decoupled again. Removed the now-
dead grin_relay downgrade note.
S3 (bootstrap latch):
- A DELETE of the latch row escaped Locked without Tampered detection on a
CLI-bootstrapped-but-no-users instance (adoption keyed off `users` only). Boot
now adopts on has_any_users() OR has_any_admin_keys(), so a deleted latch
re-latches Locked instead of silently dropping to uninitialized.
- bootstrap_admin's single-bootstrap guard was a check-then-insert race (two
concurrent `setup` could seed two admins). It now takes the admin-keys mutation
advisory lock (shared with add/revoke/rotate) at the top of its tx.
S2 (admin posture + landing):
- Hard bind guard matched loopback by string prefix ("localhost".starts_with) —
"localhost.evil.com" slipped past. Now resolves via to_socket_addrs and requires
EVERY resolved addr to be loopback.
- host_allowed's IPv6 loopback check was an unanchored prefix ("[::1]" matched
"[::1].evil"); now anchored to exactly "[::1]" / "[::1]:port". +regression tests.
- Anti-clickjacking headers (X-Frame-Options/CSP) are now applied on the 403
Host-reject path too (computed on both branches, headers added once).
- GET /api/v1/server-info now returns a BARE 404 when landing is off (the
{error,code} envelope itself hinted the route exists).
- Public landing `chains` now uses the SAME serviceability predicate as
/capabilities (no over-advertising an unserviceable chain).
- Removed the dead/misleading PUBLIC_EXPOSE_FEATURES toggle (never read; the
feature tuple is intentionally never emitted anonymously).
Dismissed after verification: latch chains-by-flag (dup), title length bound
(operator-owned + escaped), prices:true with empty PRICES_ASSETS (documented).
140 lib + 140 integration green, no openapi drift, scans clean.
Admin posture (§2.1/§2.2):
- Hard fail-closed bind guard in main: a non-loopback ADMIN_BIND refuses startup
unless ADMIN_ALLOW_PUBLIC_BIND=true (was a warning).
- admin_plane_guard middleware on the admin router: Host allowlist
(localhost/127.0.0.1/[::1]/configured TOR_ADMIN_ONION; a missing Host is allowed
since rebinding must supply one and the bind is the primary control) to defeat
DNS-rebinding against the loopback socket, plus X-Frame-Options: DENY +
CSP frame-ancestors 'none' (anti-clickjacking). Still no CORS on the admin plane.
Public landing (§2.3), default-OFF, one fingerprint surface:
- GET /api/v1/server-info — built from a hand-written ServerInfo allowlist (never
by serializing config/state). Default projection is minimal { software.name,
status }; version (coarse major.minor), chains, price_feed, and uptime (bool
`up` only) are each individually opt-in. The enabled-feature tuple is NEVER
emitted anonymously (connected clients use /capabilities).
- GET / — minimal HTML (operator title HTML-escaped); bare 404 when off (does not
hint an admin plane exists). server-info also 404s when off.
- config: ADMIN_ALLOW_PUBLIC_BIND, TOR_ADMIN_ONION; PUBLIC_LANDING_TITLE +
per-field PUBLIC_EXPOSE_VERSION/CHAINS/PRICE_FEED toggles.
Tests: host_allowlist + html_escape unit tests; landing off->on minimal-projection
(asserts only software+status, no version/chains/features); admin-plane Host-403 +
anti-clickjacking headers. Landing/admin stay off the public OpenAPI.
Deferred (documented): §2.5 NIP-05 consent-gating (changes existing
/.well-known/nostr.json behavior — needs the standing v0.2.4-compat verification
first), §2.3 k-anon public stats, response-header/TLS-fingerprint stripping (proxy
/ops-doc concern).
"Sign in with Smirk" for operators. Auth is a NIP-98 signed action over a
single-use nonce (AUTHN) + the MAC'd allowlist (AUTHZ), composed in admin_guard
so a protected route physically cannot run without both. All rejections collapse
to one opaque 401 (no enumeration oracle).
- src/api/admin.rs: admin_guard (verify access token -> live session by jti ->
live MAC-verified, activated allowlist entry) + AdminContext; handlers:
- challenge: mint a 120s admin_login nonce + return the {url, instance_id} the
wallet must bind.
- verify: verify_signed_action (purpose/nonce/descriptor/instance) THEN consume
the nonce (forged sig can't burn a victim's nonce; replay loses the race) ->
authorize (pending key allowed for first login, which activates it) -> mint +
persist session AND the login audit row in ONE tx (fail-closed) -> bump
last_used.
- refresh: re-run FULL authorization before minting (closes the "8h refresh is
the real blast radius" gap) + constant-time refresh-hash bind + rotate jti so
the old access token dies.
- logout: revoke the session; audit. me: guarded whoami.
- admin_router (lib): served on a SEPARATE loopback socket in main (confidentiality
by socket, not middleware); no CORS layer (cross-origin browser calls blocked);
warns if bound non-loopback (hard bind-guard lands with admin posture). Routes
are intentionally absent from the public OpenAPI.
- config: ADMIN_PUBLIC_URL (the signed-action u-tag base; validated when enabled).
AppState.admin_sessions (Some only when enabled). AdminSessionManager.mint_access
(refresh) + db helpers (create_admin_session_audited, touch_admin_key_last_used).
- test harness: admin_router() + request_on(router); 1 L1 test exercising the full
flow end-to-end incl. user-token rejection, replay rejection, jti rotation, and
post-logout revocation.
NOTE (for the upcoming S1 review): admin-login timing-oracle hardening (fixed-cost
allowlist probe + jittered 401 floor, spec §1.3) is deferred — the loopback/onion
exposure + single-use nonce + signature gate are the v1 boundary. Per-pubkey
backoff (§0.5) also pending.
From a multi-lens adversarial review of the capabilities + prices work:
- PRICES_ASSETS="" now means NONE, not all (real bug): from_env used env_opt,
which collapses unset and present-but-empty into None, so the documented
"empty = none" silently served all five feeds. Read via env::var so unset =>
all, present (even empty) => the listed subset or none.
- PRICES_CURRENCY is now validated against a curated SUPPORTED_PRICE_CURRENCIES
allowlist. Previously a typo (e.g. "usdd") booted, advertised prices:true, and
served an empty feed forever (the provider returns empty quotes for an unknown
currency) — a fail-open path inconsistent with the adjacent provider/asset
checks.
- PRICES_PROVIDER is lowercased at read, matching the asset/currency
normalization (mixed-case no longer trips the allowlist).
- validate() now requires an Electrum URL for an enabled BTC/LTC chain (hard
error in prod, warn in dev), symmetric with the xmr/wow/grin secret checks.
Keeps /capabilities honest: a chain it can't serve won't boot advertising
itself as available.
Tests: config regressions (currency, utxo-electrum); a PriceClient feed-subset
selection unit test; the /prices integration test now populates the snapshot and
asserts the served quotes + RFC-3339 timestamp (the prior PRICES_ASSETS env-var
was inert under the harness); capabilities asserts network presence/null across
all five chains.
Dismissed after verification: coingecko json() was already switched to a
streaming read_capped; fleet refresh-jitter (no API key, single-node,
self-healing on 429); prices capability staying a bare bool (/prices carries the
currency + per-asset map).
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.
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.
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.