such-gitea ddbba30765 fix(grin): keep the slatepack address from overwriting the signing key
A v0.3.0 wallet registers two different Grin keys in sequence. Bootstrap
stores the Smirk-derived public key, which is what /auth/website/verify
checks a Grin signature against, and a deferred effect then registers the
canonical grin-wallet slatepack address. These are separate derivations,
not two encodings of one key, and both wrote (user_id, 'grin', 'primary').
The second silently replaced the first, so signing in to the website with
Grin could not match any account.

Route the value to its own row instead. A slatepack address is bech32 under
the grin/tgrin HRP and a hex public key cannot start with those letters,
since none of them are hex digits, so the two are separable by construction
rather than by guess. That matters because it fixes wallets that are already
shipped: they keep posting what they post today and land in the right row.

lookup_username picked with an unqualified find on the asset, which returns
an arbitrary row once an asset has two. It now selects by key_type, and for
Grin prefers the slatepack address, falling back to a primary row only while
that row still holds a pre-split address. A sender is never handed a public
key where an address belongs.

The migration is additive: existing bech32 values are copied into the new
row and the primary row is left for the next unlock to rewrite. Nothing is
deleted, so a user who never unlocks again keeps a working address.
2026-09-14 23:03:13 -04:00

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. A TipStatus state machine (draft → pending_confirmation → pending → claiming → claimed, plus cancelled / 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's get_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 (settles claiming → claimed only 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 /admin on 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-admin CLI — 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_fingerprint are 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.

S
Description
Mirror of github.com/Such-Software/smirk-backend-core
Readme MIT
2.1 MiB
Languages
Rust 97.6%
TypeScript 1.4%
Shell 0.6%
PLpgSQL 0.3%