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.
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, and an async Grin slatepack relay — 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-wallet (view-only) + node | rewind_hash scan, broadcast, slatepack relay |
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).
- 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.
- 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.