Premium could invoice through exactly one processor, so an operator
running BTCPay for BTC, LTC and Grin could not also take Monero or
Wownero: BTCPay has no Wownero support, and this operator runs Monero
through its own checkout rather than the BTCPay plugin.
xmrcheckout and wowcheckout implement the same two Greenfield invoice
routes, accept the same `token` auth and report the same statuses, so
the existing adapter serves them unchanged. What was missing was more
than one of them. Each is now an optional rail with its own URL, store
and key (PAYMENT_XMRCHECKOUT_* and PAYMENT_WOWCHECKOUT_*), all three
values or none, and a half-configured rail refuses to start and names
itself rather than advertising a coin that cannot be paid.
The invoice row already recorded which provider minted it; activation
now polls that one, never whichever is primary today. An invoice whose
rail was switched off is refused and left unconsumed, so turning the
rail back on lets the user redeem what they paid. A client that names
a rail the instance does not offer is refused rather than handed an
invoice in a different coin; a client that names none gets the
primary, which is how every client before this behaved.
Registration is untouched and keeps the primary. /capabilities lists
the rails and their assets, primary first, from config alone, so it
never depends on a processor being reachable. PAYMENT_ASSETS labels
the primary. Checkout rails also get an exact confirmationsRequired,
which BTCPay Server is not sent since it only has speedPolicy buckets.
`tracing_subscriber::fmt::init()` reads RUST_LOG when it is called, and it
was called one line before `dotenvy::dotenv()`. The systemd unit sets no
EnvironmentFile on purpose, so RUST_LOG exists only in .env: the filter was
built with no directive and fell back to its default, ERROR.
Production has logged nothing but errors since the 2026-09-20 build. On
smirk-backend, `configuration loaded` has appeared zero times since, and
the 01:53 restart today produced no application output at all. It hides
only in production, since a developer usually has RUST_LOG exported
already.
This also defeats the legacy sign-in migration's designed verification.
`LegacyDirectory::connect` deliberately connects at boot rather than per
request so an operator reads "enabled" or "staying disabled" once, in the
log. Both are `info!`, and the failure branch is a `warn!`, so all three
were being dropped exactly when we need them.
The guard asserts the ordering property and locates the two calls by
crate rather than by line, and carries a test proving it fails on the
inverted order.
Also unbreak the test targets, which have not compiled on main: `AppState`
gained `legacy` and `NewSocialTip` gained `recipient_user_id` without
their fixtures following. Both take the closed value, off and public.
website_verify resolved every asset through get_user_by_pubkey_hash, but
users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC
could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the
failure was indistinguishable from an unregistered user, so the page told people
to register with an extension they already had.
That did not surface while smirk.cash pointed at the legacy backend, which
resolves by asset key against its own database. It becomes the whole bug the
moment the site is repointed here, so it has to land with that change, not after.
get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend,
84 arrived through migrate-legacy, which imported identity without keys, and
would otherwise stop resolving entirely.
The rejection message is unchanged and still says nothing about whether an
account exists.
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.
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.
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.
- 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.
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.
The integration harness dropped response headers (returned only status + JSON body),
which is exactly why CORS/security-header behavior couldn't be tested. Add:
- request_full(method, uri, extra_headers, body) -> (status, HeaderMap, body):
sends arbitrary request headers (e.g. an Origin) and returns response headers.
- try_app_with(|cfg| ...): tweak the validated Config before building the router
(e.g. restrict cors_allowed_origins). try_app now delegates to it.
New tests/cors.rs proves the well-known fix end-to-end: with the API CORS restricted
to one origin, /.well-known/nostr.json still serves ACAO: * to a foreign origin
(federation works) while /api/v1/* does NOT — the two policies are genuinely
distinct. Verified against Postgres (passes); existing auth_flow/capabilities unaffected.
First slice of the optional Nostr relay (messaging plane), behind a swappable
seam mirroring the PaymentProvider pattern so nostr-rs-relay can be replaced by
another relay (strfry/external) or another messaging protocol later.
- config: MessagingConfig { relay: RelayConfig } — RELAY_ENABLED (off by default),
mode (bundled|external), advertised ws(s):// URL, write_policy
(inbox-outbox default | author-allowlist | open), inbound_pow_bits (NIP-13),
admission_bind, resource caps. validate() fail-closes (URL required + ws(s)://
+ wss in prod, known policy, PoW <= 40, admission bind for non-open). +7 tests.
- infra/relay: RelayProvider trait + from_config (feature-gated, fail-closed) +
the default nostr-rs-relay/external adapter; AppState.relay wired
(lib/main/tests).
- infra/relay/policy: PURE admission engine (WritePolicy, decide, NIP-13
leading_zero_bits): open permits all; author-allowlist gates on a registered
npub; inbox-outbox permits a registered author's outbox OR a kind-1059 gift-wrap
addressed to a registered user (inbox), the external-inbound branch optionally
behind a PoW gate. Registration lookups are resolved by the caller and passed as
booleans so the decision is pure + fully unit-tested (10 tests).
The gRPC event-admission service that calls this engine, capabilities/NIP-05
advertising, packaging, and the client DM stack land in the following slices.
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.
"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.
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.
Add tiered per-IP rate limiting to the router, completing the assembly:
- STRICT tier (~2 req/s, burst 20) on the unauthenticated, abuse-prone
surface: /api/v1/auth/* and /api/v1/website/*. This closes the
website-challenge growth vector (the prior rate-limit LOW): an unauthenticated
flood of challenge requests is now throttled per IP.
- NORMAL tier (~10 req/s, burst 60) on the authenticated wallet/identity API.
- PeerIpKeyExtractor keys on the peer IP; `main` now serves with
into_make_service_with_connect_info::<SocketAddr> so the IP is available, and
the test harness injects a loopback ConnectInfo (each test builds a fresh
router with fresh limiter state).
Over-limit returns 429. The limiter sits inside the global layers (CORS/timeout/
body/compression/trace) and in front of the handlers.
L1 test proves it throttles: a 40-request burst to a strict-tier route returns
429 (the governor runs before the handler, so even unauthenticated requests
count). All 33 integration tests + 114 lib tests pass through the fully-layered
router; openapi unchanged.
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).
- 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.