Commit Graph
70 Commits
Author SHA1 Message Date
jwinterm c2c07db34a chore: cargo fmt with rustfmt 1.9.0 (repo-wide)
CI gates on cargo fmt --all -- --check and it was already failing on main before
this branch: rustfmt 1.9.0 reformats three call chains that an older rustfmt
left alone. Same housekeeping as 586d29e.
2026-08-13 10:37:13 -04:00
jw 81a3e4cf58 fix: pre-ship audit remediation for auth identity, abuse control and retention
Fixes every confirmed backend finding from the v0.3.0 pre-ship review.

Identity (the two that corrupt accounts):
- nostr_register creates a row with pubkey_hash NULL, so that wallet's later
  /auth/extension call matched nothing, fell through the rotation path (which no
  client ever satisfies, since nothing sends rotation_signature) and minted a
  SECOND account. A fingerprint match on a NULL-pubkey_hash row now adopts the
  submitted pubkey_hash onto that row, but only when the row's on-file BTC key
  equals the submitted one, so a bare fingerprint (which check_restore discloses,
  and is therefore not a secret) still cannot claim an account.
- The fingerprint-ownership check no longer short-circuits on returning_by_pubkey.
  It was skipped for returning users, so a fingerprint owned by another row got
  backfilled, violated the UNIQUE index, and 500'd that account on every
  subsequent login.
- The rotation proof is verified over a distinct, instance- and target-bound
  message. It shared the login message, so a captured login signature was a
  replayable rotation proof against another instance.

Abuse control:
- The rate limiters key on the real client IP, honouring X-Forwarded-For only
  from a trusted proxy. Behind the TLS terminator every request looked like
  127.0.0.1, so the whole internet shared one bucket and ~3 req/s 429'd every
  wallet unlock globally.
- A PoW solution is single-use instead of replayable for the challenge TTL.
- Delegated /verify is opt-in and rate-limited; it was an unauthenticated
  amplifier into Electrum, LWS and a full-genesis grin scan.
- A grin scan with no start_height gets the same depth policy and PoW price as
  one that passes a height, instead of an unbounded free scan.

Retention and logging:
- The retention sweep is implemented AND scheduled. RETENTION_* was advertised
  and inert, which made the privacy policy's retention promise untrue. Zero
  means keep forever, and each statement is batched so a backlog drains over
  ticks rather than holding row locks.
- login_events.ip_hash is no longer written, and legacy values are scrubbed:
  nothing ever read it, so it was retention without a purpose.
- Tracing spans log user_id only, not the raw npub and username.
- secret-scan.sh matches the coverage its comment claims.

Also fixes the test suite, which did not compile on this commit: NewSession
gained an id in c64cd9b and two call sites were never updated. mint_session now
binds one session_id for both the token and the row, since a different id there
would silently recreate the bug c64cd9b fixed.
2026-08-13 10:31:02 -04:00
jwinterm 23659dfd5a capabilities: nip05_domain must keep the handle domain users already have
Deploying the previous version showed nip05_domain = api.smirk.cash, but handles
are name@smirk.cash: that is what v0.2 used, what the wallet derived by stripping
'api.' client-side, and what the kind-0 profiles already on relays carry. Since
the wallet now PREFERS this field, shipping it would have silently re-rendered
every handle as @api.smirk.cash and disagreed with the published profiles.

Caught by checking the deployed output rather than assuming the change was inert.

Default is now PUBLIC_API_URL's host with a leading 'api.' removed, preserving
the established behaviour, and NIP05_DOMAIN overrides it for operators whose
well-known lives elsewhere. Still explicit where it matters; the default just
stops being a regression.
2026-08-04 06:06:00 -04:00
jwinterm c64cd9be8e fix(auth): the session row must carry the id the token claims
The sid revocation check added in e2589d4 rejected EVERY authenticated request
with 401. issue_session generated session_id = Uuid::new_v4(), minted the access
token with it as sid, then called create_session WITHOUT passing it, so Postgres
assigned the row a different primary key. is_session_live(sid) therefore never
matched and the liveness check failed closed on every call.

Both halves were individually correct, which is why unit tests did not catch it:
303 backend tests passed throughout. The e2e suite caught it, as a wallet that
authenticated successfully and then showed 0 for every asset.

NewSession now carries the id explicitly and create_session binds it, at all
three call sites (extension/website login and the refresh rotation, which shares
the helper).

Verified: fiat-balance e2e goes from all-zero to $108.21.
2026-08-02 21:32:58 -04:00
jwinterm 465ffe5cbd capabilities: advertise nip05_domain
The wallet cannot derive the handle domain; guessing by stripping 'api.' only
works for a smirk.cash-shaped deployment. The host of PUBLIC_API_URL is the
answer, so state it.
2026-08-02 13:25:15 -04:00
jwinterm 87a7317df3 docs(verify): document the grin tx_ref output-commitment contract
A grin kernel excess is also a 66-hex Pedersen point, so validate_hex(66) cannot distinguish it from an output commitment; a kernel excess never matches a rewind scan and yields a silent not_found. A send slate also carries the sender's change output. tx_ref MUST be the recipient's received (non-change) output commitment — document the footgun so the payer/frontend sets it correctly.
2026-08-01 08:14:28 -04:00
jwinterm e2589d4835 auth: make access tokens revocable
verify_access_token was a pure signature + expiry check, so an access token could
not be revoked. Sign-out and logout revoked the DB session row, and an erasure
request revoked all of them, but the bearer token kept working for the rest of its
24h life on every authenticated route. The refresh token has always carried a sid
for exactly this; the access token, which actually guards the API, did not.

Adds sid to AccessTokenClaims and checks it against the sessions table on each
authenticated request (one indexed primary-key lookup).

sid is Option so tokens minted before this deploy still verify rather than logging
every user out mid-session; they simply remain unrevocable until they expire,
which is the pre-existing behaviour.
2026-08-01 08:00:38 -04:00
jwinterm f26b432999 feat(verify): implement grin (Mimblewimble) settlement verification
Wire the family="grin" arm of POST /api/v1/verify, replacing the stub.

Grin has no address and no txid, so the request identifies a payment by its output COMMITMENT (tx_ref) and the recipient's rewind_hash (view_key). scan_rewind_hash proves recipient ownership AND the true received amount in one scan (only the recipient's rewind_hash recognizes the commitment), then gates on grin's 10-confirmation depth via get_height.

No get_kernel: MW aggregation/cut-through leaves no on-chain join binding a kernel excess to a specific output, so a kernel-only check can't soundly gate amount or recipient. Sender is unenforceable (MW reveals none -> sender:null) and dest_address is ignored (no addresses); a missing view_key returns not_found, never a false verify, mirroring the cryptonote arm.

Adds grin_for resolver + grin_amount_outcome helper + unit tests. 8/8 verify tests pass, clippy and rustfmt clean.
2026-08-01 07:36:34 -04:00
jwinterm 11cf5947ce fix(nip05): repoint stale test to renamed INTEROP_RELAY_HINTS
default_relays_are_wss still referenced DEFAULT_RELAYS, removed when the constant was renamed INTEROP_RELAY_HINTS, which broke the `cargo test`/--tests build. Repoint it (interop_relay_hints_are_wss); behaviour unchanged.
2026-08-01 07:36:34 -04:00
jwinterm 0b410c2b00 nip05: advertise only the relays the operator chose
relay_hints appended damus/nos.lol unconditionally, so every self-hosted instance
published a NIP-05 document pointing its own users at third-party relays, and a
correspondent would deliver there instead of to the operator's relay. An operator
running a private deployment had no way to stop it.

Now opt-in: NIP05_INTEROP_HINTS (default off) for the built-in interop pair, plus
NIP05_EXTRA_RELAY_HINTS for the operator's own list.
2026-08-01 05:39:24 -04:00
jwinterm ae118edf86 premium: publish the relay posting decision, not its inputs
GET /premium/status gains write_allowlisted + can_post_general.

The wallet was re-deriving posting rights from write_policy + premium, which
cannot see RELAY_WRITE_ALLOWLIST_NPUBS. An allowlisted operator was shown
"posting needs a premium subscription" with the composer hidden, while the
relay's own admission check would have accepted the event. Compute the answer
server-side from the same RelayProvider the admission service consults so the
two cannot drift.
2026-07-31 17:17:58 -04:00
jwinterm 0f0ec2af49 feat(verify): delegated settlement verification endpoint
Add POST /api/v1/verify, the server side of moon.vote's delegated
settlement check: confirm a specific tx_ref paid at least the expected
base-unit amount to dest_address, confirmed to this backend's per-asset
depth.

- UTXO (btc/ltc): mirror ElectrumClient semantics; consult get_history
  first to distinguish not_found from a node error, then attribute the
  exact amount via VerboseTransaction::total_received_at.
- CryptoNote (xmr/wow): require a private view_key, scan via
  get_address_txs, match tx_ref, gate on mempool/height and the daemon
  confirmation depth (XMR 10, WOW 4).
- grin: deferred (reports not_found with a warn).

Business outcomes (incl. failures) are always 200 with a fixed reason
vocabulary; only infrastructure failures return non-2xx AppError so
moon.vote retries. Unauthenticated by design (nginx allow-list gates it);
no utoipa/openapi wiring (server-to-server, not in the wallet client).
2026-07-29 07:39:08 -04:00
jwinterm fb48e04ea6 feat(wallet): subaddress provisioning and batched UTXO endpoints (off by default)
Backend support for two address-privacy features. Both are behind env flags
that default to off, so an unconfigured instance behaves exactly as before:
the routes are not mounted and no provisioning is attempted.

FEATURE_XMR_SUBADDR_PROVISIONING
  Adds POST /wallet/lws/provision_subaddrs and provisions account-0 subaddress
  indices on the LWS at register/import.

  Three money-safety properties worth calling out:

  * The provisioned ceiling is read from the LWS response, never echoed back
    from the request. It is the contiguous run starting at minor 0, not
    max(hi): given ranges [[0,99],[200,299]], reporting 299 would let a wallet
    hand out indices 100..199 that the LWS never scans, so funds sent there
    would be invisible. No usable run is a hard error, not a guess.
  * import_account now always runs the backward rescan even when provisioning
    fails. Previously a provisioning error aborted first, leaving the account
    registered at the chain tip while every retry returned 200 OK, so a
    restoring wallet would show a zero balance permanently.
  * A capacity probe reads max_subaddresses from the LWS and refuses up front
    with a message naming the required --max-subaddresses value, rather than
    failing per-account later. The option defaults to 0 upstream.

  Spent outputs now carry their subaddress index. monero-lws emits this as
  `sender` (the index the spent output was received at), not `recipient`;
  without it a client recomputes a primary key image for a subaddress spend,
  treats the mismatch as a decoy, and never subtracts the amount.

FEATURE_UTXO_MULTI_ADDRESS
  Adds balance_multi / utxos_multi / history_multi for HD gap-limit wallets.
  Capped at 32 addresses, summed with checked_add, per-UTXO address tag
  preserved so the client never has to re-guess which key owns an output.
  Bounded concurrency with a per-batch deadline returning 504, so a slow
  Electrum server cannot stack n * IO_TIMEOUT.

Both flags are advertised in /capabilities so a client can negotiate instead
of calling routes that 404.
2026-07-28 16:01:25 -04:00
jwinterm cf85dce89f privacy: drop raw-IP PII columns and the dead wallets table
The published privacy policy states that IP addresses are retained only as a
salted one-way hash for rate limiting. That was true of the login-event path
but not of sessions.ip_address or audit_logs.ip_address, both of which stored
a raw client IP tied to user_id on every login.

Drop both columns and all writer/model code, so the schema enforces the policy
rather than relying on convention. The hashed rate-limit path (login_events,
restore_attempts) is unchanged and remains the only IP retention.

Also drops the `wallets` table, which was created in the initial schema and
never read or written: a latent user_id -> address / view_key store.

The operator forensic trail (admin_audit_logs, admin_sessions) deliberately
keeps its raw IP. Privileged-action forensics is a different threat model and
is not covered by the user-facing claim.
2026-07-28 16:01:07 -04:00
jwinterm b2d727be08 fix(lws): serialize XMR/WOW atomic amounts as strings (u64 > 2^53 safe)
XMR/WOW atomic amounts (piconero/wownoshi) can exceed 2^53, which a
JavaScript number cannot hold exactly, so a large balance or output was
silently rounded when the wallet JSON.parsed it. Serialize every atomic
amount (balance total_received/locked/pending, spent + unspent output
amounts, history totals) as a decimal string; heights, indices, counts,
and fee params stay numbers. utoipa value_type=String keeps the OpenAPI
honest. Tests assert the string wire contract for a 2^53+1 value.
2026-07-19 18:12:14 -04:00
jwinterm ea2230164e fix(config): runtime-safe PUT no longer drags pending restart-required changes live
A runtime-safe config PUT rebuilt the effective config from config_base +
the FULL persisted overlay and stored it, which live-applied any
restart-required section persisted by an earlier PUT (features/relay/
premium) while the boot-built clients still ran the old value — and
cleared restart_pending in the process. The exact desync RuntimeClass
exists to prevent.

Now the hot-swap applies ONLY the (all-runtime-safe) patch onto the
currently-running config, and restart_pending is computed honestly via a
shared helper (GET and PUT agree). Also reclassify registration.
require_payment as restart-required: it gates the boot-built payment
client (None unless require_payment||premium at boot), so hot-enabling it
would 500 every registration until restart. Regression tests for both.
2026-07-19 05:30:16 -04:00
jwinterm 586d29efe7 chore: cargo fmt with current stable rustfmt (repo-wide)
The repo pins channel=stable (no rustfmt version), so CI now pulls
rustfmt 1.9.0, which reformats code last touched by an older rustfmt.
main itself failed `cargo fmt --check` under the current toolchain;
this is a formatting-only sweep across 24 files to make the fmt gate
green. No behavior change; tests + clippy -D warnings still pass.
2026-07-19 03:44:47 -04:00
jwinterm 542e70b8e0 chore: satisfy clippy 1.95 (-D warnings) across the branch
- admin.rs: make the config/invite JSON response structs pub (they are
  returned from pub handlers; matches the already-pub AdminKeyView etc.)
- invite_codes/operator_settings: drop redundant reborrow (&mut *tx -> &mut tx)
- electrum: minimize a boolean (De Morgan, behavior-identical)
- relay: move the test module to end of file (items_after_test_module)

Pure lint hygiene surfaced by the current stable toolchain; no behavior
change. cargo clippy --all-targets -- -D warnings is now clean.
2026-07-19 03:34:29 -04:00
jwinterm d33a777556 test(admin): assert /admin serves the console over HTTP
Route-level test (tower oneshot, no DB) confirms GET /admin returns the
console HTML with text/html, and GET /admin/assets/<hash>.js returns the
built bundle with a JS content-type. Pairs with the embed test so both
'not built' and 'not wired' regressions fail CI.
2026-07-18 16:15:17 -04:00
jwinterm 35447868d4 feat(admin): serve Operator Console SPA at /admin (rust-embed)
Preact + Vite standalone admin-ui bundled into the server binary via
rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin
challenge); tabs cover instance status, admin keys, invite codes, and
the effective-config editor (live vs restart-required per field).

admin-ui/dist is committed because rust-embed reads it at compile time;
node_modules is gitignored (npm ci reproduces it). A lib test asserts
index.html and the /admin/ asset base are embedded so an unbuilt
console fails the build instead of an operator.

The console is served on the admin plane only (loopback bind), behind
the existing NIP-98 admin_guard + MAC allowlist.
2026-07-18 16:03:03 -04:00
jwinterm 8b408a063a feat(admin): invite mint/list endpoints (POST+GET /admin/invites)
- invite_codes.rs: InviteCodeRow + list_invite_codes(limit,offset) + mint_invites_audited
  (generate + insert N + admin_audit append in one tx). Raw codes returned once (only the
  hash is stored, unrecoverable).
- admin.rs: admin_invites_mint (clamp 1..=1000, audited) + admin_invites_list (non-secret
  view: hash prefix + label + created_at + unused/used/expired status + unused_count).
- /admin/invites route (loopback plane, admin_guard). Promotes the CLI mint-invite to the
  console. cargo all-targets clean.
2026-07-18 15:39:51 -04:00
jwinterm a80fa46daf feat(config): configurable restart_apply_mode + graceful shutdown (Stage 3b)
- console section: restart_apply_mode (manual | auto), an operator setting (runtime-safe,
  DB-overridable via /admin/config), env RESTART_APPLY_MODE default manual.
- Graceful shutdown: main serve now awaits SIGTERM/Ctrl-C OR an in-app shutdown signal
  (AppState.shutdown Notify) via with_graceful_shutdown, draining in-flight requests.
- PUT /admin/config in `auto` mode self-restarts shortly after responding to apply a
  restart-required change (systemd Restart=always brings it back with the new config);
  `manual` just flags restart_pending. The console warns before an auto save.

Stage 3 complete: operator config is readable + editable through the real validated,
audited, precedence-based layer; runtime-safe edits apply live, infra edits restart.
cargo all-targets clean, lib tests pass.
2026-07-18 14:08:33 -04:00
jwinterm 22d362b62f feat(config): GET/PUT /admin/config endpoints on the settings layer (Stage 3a)
The operator-editable config surface, on the effective-config layer:
- GET /admin/config: per-field effective value + the DB overlay (source=db) + per-section
  version + runtime_class map + restart_pending (persisted-but-unapplied change).
- PUT /admin/config: a sparse patch is field-merged onto the persisted overlay, validated
  with the SAME Config::validate boot uses (400 on invalid, nothing persists), then each
  changed section is persisted audited + version-guarded (409 on a stale expected_version).
  Runtime-safe edits HOT-SWAP the effective config atomically (arc-swap, live next request);
  any restart-required field is persisted + flagged restart_pending (operator restarts to
  apply). Records the editing admin key as updated_by.
- config_overlay: editable_overlay() (effective values as an overlay) + EDITABLE_FIELDS.
- operator_settings: optimistic-concurrency (expected_version) + settings_versions().

Secrets remain unreachable (overlays carry no secret fields). cargo all-targets clean,
lib 256/0. Next (3b): restart_apply_mode (manual|auto self-restart) + graceful shutdown.
2026-07-18 14:00:43 -04:00
jwinterm 4c83e53839 feat(config): effective config via ArcSwap + DB overlay at boot (Stage 2)
Wire the settings overlay into the live config path:
- AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) +
  config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every
  config read goes through it (state.cfg().field), so a runtime-safe operator edit is
  seen on the next request. One Config type, no parallel path.
- Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build
  every client/worker/router from the EFFECTIVE config. A persisted overlay that no
  longer validates is ignored (boot env-only + loud log), never bricks. Env-only when
  ADMIN_KEY_INTEGRITY_SECRET is unset.
- Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a
  reference outlives the statement; integrity_secret returns owned String.
- Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep).

Behavior-preserving: with no operator_settings rows, effective == env config.
cargo check --all-targets clean; lib tests 256/0.
2026-07-18 06:00:16 -04:00
jwinterm 533f945290 feat(tips): serve /tips/social/received + /claimable as empty (public-only)
Public-only instances have no targeted-recipient inbox (public tips are claimed
via share URL, never delivered to a user), so the client's inbox poll of
/received + /claimable 404'd. Serve them 200-empty instead so the poll succeeds
quietly. Matches the client contract ({tips:[]}).
2026-07-10 22:35:44 -04:00
jwinterm 1d6d67faa5 feat(tips): grin public tips (voucher model via grin node get_outputs + get_kernel)
Grin as a new asset arm in the tips machinery (no grin-lws change, no custodial
grin_transactions/grin_outputs port):
- GrinClient::get_outputs([commit]) (funding: present+height=confirmed) +
  get_kernel(excess) (sweep spend-height); reached via state.chains.grin.
- create-gating (supported_tip_asset grin, validate 66-hex commitment,
  confirmations_for_asset grin=10), grin_commitment column (migration).
- confirmation worker grin arm: get_outputs present -> confs; amount AUTO-VERIFY
  with warn (voucher hides amount; not a theft vector; matches legacy).
- sweep reconciler grin arm: get_outputs-absent + get_kernel height at depth 10 ->
  settle; absence-without-kernel -> no settle (recoverable, never missettle);
  reorg revert only on commitment reappearing; probe error never settles/reverts.
- 5 grin tests + pure-helper unit tests; 251 lib + 24 tips green.

Amount-verification note: the voucher's bulletproof rewind nonce is random +
untransmitted, so it can't be rewound today; a clean non-custodial fast-follow is
to have the client send the per-voucher rewind nonce and verify via rangeproof
rewind (nonce reveals amount only, not spend authority).
2026-07-10 18:40:56 -04:00
jwinterm 5d8d5e193f feat(tips): stage 5 — GC workers + fail-closed flag + capabilities + openapi
- GC: cancel_old_drafts (draft >7d -> cancelled), cancel_stuck_pending_confirmation
  (>7d), log_stuck_claiming (read-only, >15min). Draft GC hourly + lifecycle GC
  5min, spawned behind FEATURE_TIPS. Funded-but-unattached safety net is the
  7-day window + clawback incl 'cancelled'.
- config validate(): tips enabled fails closed unless a supported chain
  (btc/ltc/xmr/wow) is on AND tip_share_base is set.
- capabilities: advertise tips=true only when actually serveable.
- regenerated openapi.json (8 /tips/social/* endpoints).

Public-tips port COMPLETE (stages 1-5). Full suite green.
2026-07-10 15:51:20 -04:00
jwinterm b7e3510553 feat(tips): stage 4 — claim / clawback / confirm-sweep + sweep reconciler (money-out)
Escrow settlement, audited guards ported verbatim:
- mark_tip_claiming: status IN('pending','claiming') AND confs>=required AND
  funding_amount_verified AND sweep_confirmed_at IS NULL (double-claim guard).
- confirm_tip_sweep: RECORD-not-settle, first-recorder-wins (COALESCE +
  sweep_txid IS NULL OR =$3) — RBF-steal safe; reconciler owns claiming->claimed.
- confirm_sweep_onchain: SOLE writer of 'claimed', one-way status='claiming' AND
  sweep_confirmed_at IS NULL lock; settles only after on-chain sweep confirms to
  per-asset depth (btc/ltc 1, wow 4, xmr 10).
- clawback: 5-status set incl 'cancelled', blocked once sweep_confirmed_at set.
- revert_sweep_on_reorg: only from 'claimed', re-opens to 'claiming' + clears txid.
- sweep_reconciler: address-history-driven (not recorded txid), pick_winner
  (earliest height, lexi-smallest tie), pg advisory locks on a pinned conn for
  single-instance safety; reorg pass reverts only on affirmative vanish, never on
  probe error. DMs stubbed, exactly-once stamps kept.
- POST claim/confirm-sweep/clawback endpoints + openapi.
- 16 integration + 12 reconciler unit tests; build clean; SocialTipRow dead_code
  allow removed (all fields now read).
2026-07-10 15:34:14 -04:00
jwinterm b63cac19e2 feat(tips): stage 3 — attach-funding + funding-verification worker (money-in)
- attach_funding_to_tip: atomic guarded UPDATE (COALESCE + CASE), idempotent on
  same txid, 400 on a different txid; non-drafts land pending_confirmation so the
  verifier (not the caller) owns the ->pending transition.
- worker (src/tips/confirmation.rs): XMR/WOW confirmation counting via LWS;
  amount verification for BTC/LTC (Electrum full history, confirmed-only) +
  XMR/WOW (classify_funding_amount, separate-then-subtract net). Verified->pending,
  Short->funding_mismatch; Unfunded/Drained/LwsError never mutate status (outage-
  safe). Spawned behind FEATURE_TIPS on a 60s loop w/ catch_unwind guard.
- POST /tips/social/:id/attach-funding endpoint + openapi.
- 5 DB tests + 6 classify unit tests; build clean.
2026-07-10 14:53:46 -04:00
jwinterm f3d6be95b8 feat(tips): stage 2 — create / get-public / get-sent / cancel routes
Public-tip create/read/cancel surface matching the client contract:
- POST /tips/social (draft or funded), GET /tips/social/sent,
  GET /tips/social/:id/public (unauth; 404s non-public), POST .../:id/cancel.
- DB: insert (draft/pending_confirmation), get_sent, guarded cancel_draft
  (owner + draft-only).
- Validates is_public (targeted rejected), amount>0, claim_key_hash 64-hex,
  encrypted_key <=4096 hex, tip_address; share_url from TIP_SHARE_BASE_URL.
- ensure_tips_enabled gate; openapi paths+schemas+tag registered.
No funds move. 5 route/DB tests pass.
2026-07-10 08:50:28 -04:00
jwinterm 4d1d9006bc feat(grin): proxy scans through grin-lws (default) with grin-wallet fallback
Add a GrinLwsClient (a faithful mirror of the XMR LwsClient) + GrinLwsConfig
(enabled when GRIN_LWS_URL is set). /wallet/grin/scan now defaults to grin-lws
when configured and returns derivation paths (key_id/n_child) + spendability,
letting the wallet skip its client-side output-identify search.

Money-safety: the restore-depth/PoW gate runs once and is terminal (never
masked by fallback); grin-lws is trusted only when provably synced
(scanned_height + margin >= tip), otherwise the scan falls back to the
authoritative grin-wallet scan_rewind_hash; a grin-lws transport error also
falls back; grin-wallet errors propagate as 503. No path ever synthesizes an
empty/zero balance.

Off unless GRIN_LWS_URL is configured (prod grin behavior unchanged until
pointed at a grin-lws instance). Capabilities advertise `grin_lws`.
openapi.json regenerated.
2026-07-09 16:26:23 -04:00
jwinterm a7c4bbd238 feat(grin): address→npub bridge + mark relay same-instance-only
Enhance the grin address→user lookup to return the owner's linked npub (not just a
bool), so a bare grin slatepack address can resolve to its owner's npub and route
over the federated Nostr gift-wrap rail instead of only manual handoff. Doc-comment
the backend slatepack relay as a same-instance-only convenience — the federated
default transport is Nostr gift-wrap; the relay needs a recipient user_id and both
parties on this instance, so it is not the primary path in a public-backend world.
2026-07-09 04:47:13 -04:00
jwinterm b32b8a9059 security(auth): make gated registration fully atomic (F3/F4 fast-follow)
The prior fixes closed the common cases with pre-checks; this closes the
residual races by consuming the single-use gate token(s) AND creating the
user AND persisting keys in ONE transaction, so a failure never burns a
token without an account.

- New Database::create_user_consuming_gates: claims the invite + consumes
  the payment invoice + inserts the user + upserts keys inside a tx;
  rolls the whole thing back on a username collision (mapped to 409) or a
  raced/already-spent token.
- New plan_gate_consume: resolves which tokens to spend WITHOUT consuming
  (the payment settled-check stays non-consuming), so `all`-mode verifies
  the payment is settled before anything is spent, then both tokens are
  consumed atomically at creation.
- extension_register + nostr_register route NEW users through the atomic
  path (returning users keep the ordinary backfill path); the npub F2
  fail-closed check runs before the tx. Removed the now-dead
  enforce_method_gates / enforce_invite / enforce_payment.
- tests/registration_atomicity.rs proves the rollback: a username
  collision keeps the invite; an already-spent payment keeps the invite.

Validated against Postgres: full suite 286 passed / 0 failed.
2026-07-06 05:58:04 -04:00
jwinterm c802f416cd security(auth): harden registration/identity against verified review findings
Adversarial review of the auth/registration surface (findings tracked
privately). Backend half:

- F2: npub-native register no longer binds an npub onto an existing
  wallet row on a seed_fingerprint match alone (unauthenticated account
  takeover). get_or_create_user_by_nostr_pubkey fails closed (409);
  linking an npub goes through the authenticated /auth/nostr/link flow.
  Integration test rewritten to assert the refuse-and-409 contract.
- F3: `all`-mode gate verifies payment is settled (non-consuming) BEFORE
  consuming the single-use invite, so a still-confirming payment can't
  permanently burn the invite. verify_payment_settled split out.
- F4: username availability is pre-checked before any gate token is
  consumed; collisions return 409, not 500 — a taken username no longer
  burns a paid invoice / invite.
- F6: nostr_register now persists the submitted chain keys, so
  receive-by-username and restore self-verification work for npub wallets.
- F10: the pay-to-register not-yet-settled response carries a stable
  PAYMENT_PENDING code so polling clients don't parse a free-text string.

Validated against Postgres: full suite 283 passed / 0 failed.
2026-07-06 04:23:11 -04:00
jwinterm 1232a80cc8 feat(capabilities): advertise features.nostr_native_auth (npub register available)
The wallet gates its bootstrap on this: when true it registers via NIP-98
(no BTC signature); a legacy backend that omits the flag gets the BTC path.
Shares the NIP-98 infra requirement (PUBLIC_API_URL) with nostr_identity.
2026-07-05 08:04:32 -04:00
jwinterm c49872b62b feat(auth): npub-native registration (POST /auth/nostr/register) - Nostr-native identity
Make npub the identity anchor: a wallet can register from its seed-derived Nostr
key alone, no BTC signature. Breaks the link chicken-and-egg (nostr_login is
create-never; nostr_link needs a prior BTC-authed JWT). Additive - /auth/extension
(BTC) and the link flow keep working for legacy backends during the transition.

- get_or_create_user_by_nostr_pubkey (db twin of the BTC path): keys/inserts on
  nostr_pubkey (raw, never peppered), pubkey_hash NULL. THE load-bearing dedup:
  looks up by seed_fingerprint and MERGES the npub onto an existing (BTC-anchored
  or link-less) row rather than splitting one wallet into two identities; a
  different npub already on that row fails closed (409). backfill_optionals shared
  helper.
- POST /auth/nostr/register + GET /auth/nostr/register-challenge: verify a NIP-98
  signed-action over a subject-less single-use nonce (replay-safe), then re-run
  the abuse gates keyed on the npub (a create path must not bypass PoW/invite/
  payment); pay-to-register invoices still bind to the BTC key carried in keys[].
  Empty-body descriptor contract (mirrors /auth/nostr/link); binding the key list
  into the proof is a documented hardening follow-up.
- Housekeeping: get_user_count counts npub-only users; record_login_event labels
  the nostr path.

3 integration KATs (create-by-npub + idempotent, replay rejected, seed_fingerprint
MERGE dedup) pass against a real DB; 211 lib tests pass; openapi regenerated.
2026-07-05 08:02:47 -04:00
jwinterm 6f0fca532d feat(registration): add gate mode (all|any) for alternative registration methods
The invite + payment gates were strictly conjunctive: a new wallet had to satisfy
EVERY enabled gate. Add a REGISTRATION_GATE_MODE knob so an operator can offer the
enabled gates as ALTERNATIVES instead (e.g. "an invite code OR pay to register").
PoW stays orthogonal and always applies on top, regardless of mode.

- config: GateMode { All (default) | Any } on RegistrationConfig, from
  REGISTRATION_GATE_MODE (default "all", back-compatible).
- capabilities: advertise registration_mode ("all"|"any") so the wallet can route
  onboarding (multiple gates + "any" => "pick a method"; "all" => satisfy each).
- enforcement: enforce_method_gates composes invite+payment per mode. Extracted a
  pure plan_gates() decision so the fund-critical dispatch is unit-tested. In
  "any" with both gates on, the client presents exactly ONE credential and only
  that gate runs (and only it consumes its single-use token); presenting BOTH is
  rejected so a one-of choice can never burn two tokens (an invite AND a paid
  invoice). Single-gate configs behave identically in both modes.

6 KATs on plan_gates (open / conjunction / single-gate / dispatch / reject-both /
require-one). openapi.json regenerated. 211 lib tests pass.
2026-07-04 22:34:58 -04:00
jwinterm ef6d280412 feat(feed): operator-configurable public feed advertised via /capabilities
A read-only web feed (feed.<domain>) is a backend-operator knob: FEED_ENABLED
plus curation switches (show owner / show premium members / a featured npub
allowlist / extra relays) surface in /capabilities.feed so the feed page adapts
per instance. Reads from the relay, so validate() fail-closes FEED_ENABLED
without RELAY_ENABLED. Off by default; all public display config, no secrets.
2026-07-03 16:58:22 -04:00
jwinterm dec9994e06 fix(config): fail closed on POW_REQUIRED without FEATURE_POW; harden premium key check
- Reject boot when POW_REQUIRED (or the per-pubkey PoW list) is set while
  FEATURE_POW is off: the gate would silently never apply and /capabilities
  would report pow_required:false — a signup endpoint the operator believes
  is protected but isn't.
- Validate the premium PAYMENT_API_KEY through the same require_secret path
  the pay-to-register flow uses, so a placeholder key cannot reach production.
- Advertise the premium-post write policy in MessagingCapability and
  regenerate openapi.json to match.
- Give the premium integration fixtures unique per-run ids so the suite is
  re-runnable against a persistent local test database.
2026-07-02 19:47:38 -04:00
jwinterm eb98e4d625 feat(premium): recurring tiered relay-posting subscription (premium-post)
A paid premium tier gating GENERAL Nostr posting to the operator's relay, while
wallet-functional events (encrypted DMs, tips, swap coordination) stay free for
registered users. Registration itself stays free (PoW-gated). Off by default.

- Admission: a new `premium-post` write policy gates on kind x membership —
  premium author -> any kind; registered + WALLET_KINDS (1059 gift-wrap, 10050
  DM-relay-list) -> free; registered + general -> premium required; external ->
  gift-wrap DM inbox only. nauthz resolves is_premium_npub (only under premium-post),
  fail-closed.
- Payment: reuses the PaymentProvider seam (PULL model, non-custodial — only reads
  invoice status). POST /premium/invoice {plan} mints a tier invoice bound to the
  JWT user_id; POST /premium/activate verifies Settled + consumes single-use AND
  extends premium_until in ONE transaction (a mid-flight failure keeps the paid
  invoice redeemable); GET /premium/status reads it. Per-user pending-invoice cap.
- Tiered plans (PREMIUM_PLANS id:days:amount) = a built-in discount feature;
  advertised via /capabilities (premium_relay + premium{currency,plans,relay_url}).
- Config fail-closes: PREMIUM_ENABLED needs relay on + premium-post + processor
  creds + >=1 positive-decimal plan + unique ids + days<=3660, and the 0-conf /
  expiry processor floors apply on the premium path too (its common case is
  require_payment=false).
- Tests: 15 policy unit (incl. 5 premium-post) + config fail-closed matrix + plan
  parser + L1 integration (activate single-use/stacking/cross-user, is_premium by
  expiry, feature gate). OpenAPI + migration updated.

Adversarially reviewed (correctness / security-payment / operational-config); the
0-conf-on-premium-path double-spend footgun, non-transactional activate, and
unbounded plan days were caught and fixed before commit.
2026-07-02 17:56:44 -04:00
jwinterm b4935b15e8 feat(admin): migrate-legacy — import v0.2.x user identity into v0.3
`smirk-admin migrate-legacy --source-url <pg> [--commit] [--limit N]` copies
user identity from a legacy v0.2.x backend database into this v0.3 DB, so users
keep their handle (and NIP-05 name@domain) when they move to a v0.3 client.

- Identity-only, joined on the derivation-independent seed_fingerprint; also
  carries pubkey_hash (v3 wallets reclaim their row with no rotation step),
  username, nostr_pubkey, wallet_birthday and chain restore heights. Socials/
  tips/keys/wallets are intentionally not carried (no v0.3 home, or re-registered
  by the client on first unlock).
- pubkey_hash + seed_fingerprint are peppered at rest, so the importer builds its
  target with the real SEED_FINGERPRINT_PEPPER (run with the v0.3 .env).
- Idempotent (skips users already present by either peppered identity column),
  dry-run unless --commit, best-effort per row: a transient error or a concurrent
  native onboarding is reconciled, never aborting the pass. Reserved/taken/
  malformed usernames and already-linked npubs are dropped; the user still lands.
- Exports validate_username so the importer applies the identical format +
  reserved-name rules the HTTP handler does.
- docs/operations/LEGACY_MIGRATION.md + scripts/test-legacy-migration.sh, a
  self-contained E2E that seeds alice/bob/carol plus edge cases and asserts
  import, peppering, collision handling, and idempotency.

Reviewed adversarially (correctness / security / operational) before commit.
2026-07-02 13:49:45 -04:00
jwinterm f1872c781c feat(relay): advertise the relay via /capabilities + NIP-05 relay hints
So clients can discover + connect to the relay (and self-adapt when it's off):

- /capabilities: features.nostr_relay + a messaging{ relay_url, write_policy,
  inbound_pow_bits, supported_nips } object, with the same config-presence
  downgrade as chains/nostr_identity (advertised only when enabled AND a URL is
  configured — never advertise a relay clients can't reach). OpenAPI regenerated.
- nip05.rs: /.well-known/nostr.json relay hints are now config-driven — this
  instance's own relay (the user's DM inbox) first when enabled, then the public
  interop relays (damus/nos.lol) so cross-wallet delivery still works. Replaces
  the hardcoded-only default.
2026-07-02 07:00:02 -04:00
jwinterm 1181b5938a fix(auth): canonicalize verified Nostr pubkey + harden the link/login grade
Adversarial review (Identity Phase 1 flow) findings:

- Canonicalize the pubkey returned by verify_nip98 / verify_signed_action to
  lowercase hex. BIP-340 hex-decode is case-insensitive, so an UPPER-case-hex
  pubkey is a fully self-consistent, verifying event — but stored/looked-up
  verbatim it would let ONE key bind to two accounts via case variance (bypassing
  the UNIQUE index + 409) and yield non-canonical NIP-05 output. Now every
  downstream store/compare/lookup is on the canonical form. +KAT.
- nostr_link records a login event on success: a session-authed link (including a
  rotation that REPLACES an existing npub) is a security-relevant binding change
  and must not be silent.
- Tighten NIP98_LOGIN_MAX_AGE_SECS 60 -> 30s: a captured login token is a bearer
  credential within the window; match it to the state-change grade.
2026-07-02 05:43:20 -04:00
jwinterm 8d4f78a8a3 feat(auth): wire the Nostr link nonce — link-challenge endpoint + atomic consume
The link flow was stubbed: consume_link_nonce always returned false and no nonce
issuer existed, so nostr_login worked only for already-linked npubs and nothing
could link one. Complete it:

- GET /auth/nostr/link-challenge (authenticated) issues a single-use nonce via
  the existing challenge store, bound to the caller (subject) + the nostr_link
  purpose, 5-minute TTL.
- nostr_link now consumes it atomically (DELETE … RETURNING, so a replay loses
  the race), cross-checks the nonce was issued to THIS user, then verifies the
  signed-action proof (unchanged) and stores the npub.
- Route registered + OpenAPI regenerated (no drift).

tests/nostr_auth.rs: pins the empty-body descriptor hash as a cross-impl KAT (the
wallet binds the identical value), and covers a full challenge→link round-trip
plus replay and wrong-user-nonce rejection.
2026-07-02 04:36:40 -04:00
jwinterm 54ae9fadac feat(registration): pay-to-register via pluggable payment provider (gate #3)
Optional REGISTRATION_REQUIRE_PAYMENT gate: a new wallet must settle an
invoice on an external, non-custodial payment processor before it can
register. Pull model — the grant reads the processor's authenticated API as
the source of truth, so there is no inbound webhook to secure and a settled
invoice cannot be forged over HTTP. Returning wallets and self-hosting bypass
it, exactly like the invite and PoW gates.

- infra/payment: a pluggable PaymentProvider seam (neutral Invoice /
  InvoiceStatus / InvoiceRequest) with a BTCPay-compatible first adapter that
  also serves this project's xmrcheckout/wowcheckout apps + the BTCPay Monero
  plugin, so "operator picks any coin" costs one adapter. A future ETH/native
  adapter (BTCPay serves neither) implements the same trait. Only `Settled`
  grants (unknown status fails safe); the API key rides in a redacting Secret
  and hostile responses are size-capped.
- payment_invoices: invoice<->pubkey_hash binding + atomic single-use consume
  (UPDATE ... WHERE consumed_at IS NULL AND pubkey_hash=$ RETURNING), so one
  invoice grants at most one registration and never a different identity.
- POST /auth/payment-invoice mints + binds; enforce_payment (bind -> processor
  `Settled` -> atomic consume) runs beside enforce_invite; payment_invoice_id
  on the register request.
- Fail-closed config: https-in-prod, positive-decimal price, >= 1 confirmation
  (bars a 0-conf settled-then-double-spent free registration), bounded expiry.
  /capabilities advertises payment_required + price; OpenAPI + .env.example.

Tests: adapter status/speedPolicy mapping + config validation (unit), and L1
binding + atomic single-use (concurrent-consume-one-wins, mis-bind rejected).
Reviewed adversarially across payment-integrity, leak/oracle, and config/DoS
lenses; the confirmed findings (0-conf, expiry bound, Settled-means-paid-in-full
adapter contract, rotation-branch bypass guard) are addressed here.
2026-06-30 21:30:12 -04:00
jwinterm 56238dcac8 feat(restore): default create-only + PoW-priced restore depth (gate #2)
Default restore policy is now create-only — which betrays no self-sovereignty,
since that lives at the ECOSYSTEM level (run your own backend). On top of the
create-only|bounded|unlimited depth gate, restore depth can be PRICED in hashcash
PoW so an importer pays compute proportional to the chain-scan cost they impose
on the operator (deeper restore => more work).

- core::restore_pow: a self-contained hashcash bound to (asset, address,
  start_height) — a solved nonce is valid only for that exact restore, so no
  challenge state, no replay, no depth-downgrade. Difficulty = leading zero bits.
- RestoreConfig: pow_free_days / pow_days_per_bit (0 = off) / pow_max_bits curve;
  required_restore_pow_bits + enforce_restore_pow (literal errors, no oracle).
- Enforced at xmr/wow register + grin scan via a new restore_pow_nonce; the curve
  is advertised via /capabilities so the wallet computes + solves it.
- Default WALLET_RESTORE_POLICY flipped bounded -> create-only.
- Unit tests (curve scaling/cap, off-when-zero, valid/invalid nonce, hashcash
  roundtrip + param-binding); .env.example; OpenAPI drift clean.
2026-06-30 18:35:20 -04:00
jwinterm 610819bb5d feat(registration): composable gate framework + invite codes (gate #1)
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).
2026-06-30 17:14:53 -04:00
jwinterm 99e1727204 feat(lws): balance as a verification passthrough (spent_outputs + mempool pending)
POST /wallet/lws/balance returned a server-summarized {unlocked, locked} where
unlocked = total_received - locked OVER-COUNTED: a non-custodial backend cannot
verify spends without the spend key, so it never subtracted them. Reshape to the
wallet's verification-passthrough contract — aggregate get_address_info +
get_address_txs (parallel) and return total_received, locked_balance,
pending_balance (0-conf summed directly from mempool rows: webhook-free, cleaner
than the private backend's DB table), and spent_outputs[] for client-side
key-image verification. The wallet nets its own spends (privacy-correct).
spent_outputs fixes the over-count immediately; pending_balance activates once
the parity LWS mempool feature is deployed. Adversarially reviewed (3 lenses,
0 confirmed findings).
2026-06-29 16:13:46 -04:00
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