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 inc64cd9band 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 bugc64cd9bfixed.
smirk-backend-core
Open, self-hostable backend for the Smirk non-custodial multi-chain wallet. Rust + Axum + PostgreSQL.
It gives a wallet chain access (balances, history, UTXOs, spend inputs, fee estimation, broadcast), Nostr-native identity, a fiat price feed, an async Grin slatepack relay, and a public social-tips subsystem, all without ever holding a spend key or seed. Run your own; the wallet is backend-agnostic.
Status: v0.3.0 — feature-complete and security-reviewed, but young. The schema/API may still evolve. Run your own chain backends where you can, review the code, and treat early deployments accordingly.
Non-custodial by construction
The server stores public addresses, public keys, and (for Monero/Wownero) incoming view keys only — never a spend key, never a seed. View credentials are forwarded per request to the chain backends that scan with them and are not persisted here. The wallet signs and broadcasts locally; the backend reads chains and relays bytes.
Chains
| Chain | Source | Notes |
|---|---|---|
| Bitcoin, Litecoin | Electrum / Fulcrum | reads, fee estimation, broadcast |
| Monero, Wownero | light-wallet-server (LWS) | stateless view-key forwarding |
| Grin | grin-lws (default) / grin-wallet (view-only) + node | rewind_hash scan, broadcast, slatepack relay; scans proxy to grin-lws when configured, falling back to the authoritative grin-wallet scan |
Each chain is independently feature-flagged; a chain whose source isn't configured
is reported enabled: false by /capabilities rather than failing at call time.
API
The HTTP contract is generated from the handlers and committed to
openapi.json — the single source of truth for the API and the
wallet's generated client. A CI gate fails the build on any drift.
Public surface (/api/v1):
- Identity & auth — NIP-98 (Nostr) sign-in + link, wallet-signature auth,
audience-separated JWT sessions, NIP-05 directory (
/.well-known/nostr.json). - Chain access — per-chain balance / history / UTXOs / spend inputs / fee / broadcast.
- Grin relay — non-custodial store-and-forward mailbox for interactive Grin transfers (feature-flagged).
- Social tips (
/tips/social/*, feature-flagged): the public send-a-tip surface across BTC/LTC/XMR/WOW and Grin. Roughly ten endpoints cover the lifecycle: create (two-phase draft), attach-funding, public metadata for a share-URL holder, sent, received, claimable, cancel, claim, confirm-sweep, and clawback. ATipStatusstate machine (draft → pending_confirmation → pending → claiming → claimed, pluscancelled/clawed_back/funding_mismatch) governs the row. The backend never holds the spend key: for BTC/LTC/XMR/WOW the tip key rides the share URL; Grin tips use a voucher/commitment model verified against the node'sget_outputs/get_kernel. Three background workers keep it honest, and every one leaves a row untouched on any upstream error (never a status flip): a funding verifier (confirmation-count + on-chain amount check before a tip becomes claimable), a sweep reconciler (settlesclaiming → claimedonly after the claim sweep confirms on-chain to the per-asset depth, with reorg revert), and lifecycle/draft GC janitors for abandoned rows. - Capabilities (
/capabilities) — what this instance enables, so the wallet adapts per-instance. - Prices (
/prices) — cached fiat feed, per-feed operator control. - Self-service erasure (
/account/erasure,/account/export) — action-bound, two-phase delete-my-data + export. - Health (
/health) and an optional, default-off public landing (/).
Operator surface
Operator/admin functions live on a separate loopback listener (front it with Tor or an SSH tunnel) — confidentiality by socket, never merged into the public router or OpenAPI.
- Operator Console: an embedded single-page app served at
/adminon the loopback admin plane. Sign in with your admin key, then review status, manage admin keys and invite codes, and edit the runtime config overlay (env plus a validated DB layer). Guide: docs/operations/CONSOLE.md. - Sign-in-with-Smirk admin auth — a NIP-98 signed action over a single-use nonce (AUTHN) + a MAC-protected key allowlist (AUTHZ), composed so a route can't run without both.
- First-run bootstrap — an explicit, MAC-protected latch (no trust-on-first- use); a live deployment adopts as already-bootstrapped; tampering fails closed.
smirk-adminCLI — break-glass key management,create-admin-wallet(zeroized),doctor. Talks to Postgres directly, bypassing the network plane.- Tamper-evident audit — privileged actions are written to a hash-chained audit trail, fail-closed (the state change rolls back if the audit write fails).
Running it
Requires Rust (stable) and PostgreSQL.
cp .env.example .env # then edit: DATABASE_URL + the required secrets
createdb smirk_backend_core # or point DATABASE_URL at an existing database
cargo run --release # migrations run automatically on startup
The server fails closed: it refuses to start on a missing/weak secret or an
inconsistent feature configuration rather than booting with a control silently
defeated. Generate secrets with openssl rand -hex 32. See
.env.example for the full, documented configuration surface.
First admin (headless bootstrap) — run one of these; both latch, so the other is refused afterward by design:
smirk-admin setup --pubkey <x-only-hex> # seed YOUR pubkey (active immediately)
smirk-admin create-admin-wallet --out key.hex # generate a key (pending) + latch, no setup needed
<x-only-hex> is the admin's Sign-in-with-Smirk (Nostr) public key; the admin
then authenticates with the matching key over NIP-98. Full deployment guide:
docs/operations/OPERATOR_SETUP.md.
Security posture
- Fail-closed configuration — the only place that reads the environment; validates secrets (length + placeholder checks) and feature consistency at boot.
- Identity at rest is peppered —
pubkey_hash/seed_fingerprintare stored as HMACs, so the database is not a seed-existence oracle or a cross-instance linker. - Strict JWTs — HS256 pinned, zero leeway, audience-separated; admin tokens are cryptographically distinct from user tokens.
- Bounded, hardened upstream I/O — TLS with hostname verification, streaming size caps, and per-request timeouts on every external call.
- Adversarially reviewed — every security-critical subsystem was built clean (never ported) and put through a multi-agent adversarial review before landing.
Report vulnerabilities privately — see SECURITY.md. Please do not open public issues for security reports.
Testing
cargo test # unit + doc tests (no database needed)
TEST_DATABASE_URL=postgres://… cargo test --tests # + L1 integration against Postgres
Integration tests self-skip when TEST_DATABASE_URL is unset. They use only
deterministic, non-sensitive test secrets and ephemeral identities — never a
funded or otherwise sensitive wallet seed.
License
MIT © Such Software LLC — run it, embed it, modify it. Built for interop.