# smirk-backend-core — example environment. # # Copy to `.env` and fill in for your deployment. NEVER commit a real `.env`. # Generate secrets with: openssl rand -hex 32 # ── Server ──────────────────────────────────────────────────────────────── SERVER_HOST=0.0.0.0 SERVER_PORT=8080 # Public absolute API base URL — set this to YOUR real domain. It is the canonical # value the NIP-98 `u` tag is verified against (never the request Host header). In # production, boot fails closed if this is left as a placeholder (contains `example` # or `your-`) while Nostr identity (on by default), admin, or erasure is enabled — # so every real deployment must replace it. PUBLIC_API_URL=https://backend.example.org/api/v1 ENVIRONMENT=development # Deployment topology: single (default) permits in-process challenge/nonce state; # fleet means a load-balanced set of instances and requires shared/stateless # challenge state. Must be single|fleet or boot fails. DEPLOYMENT_MODE=single # Comma-separated CIDRs or bare IPs whose X-Forwarded-For is trusted for the # client IP (rate-limiting + audit). Empty (default) = always use the real TCP # peer. Set this to your reverse proxy's address(es) when it terminates TLS. TRUSTED_PROXIES= # Comma-separated browser origins allowed by CORS (e.g. the web wallet). Empty # (default) allows any origin — safe here because auth is a Bearer token, not a # cookie, so no ambient credentials ride a cross-origin request. Credentials are # never allowed. CORS_ALLOWED_ORIGINS= # ── Database ────────────────────────────────────────────────────────────── DATABASE_URL=postgres://user:password@127.0.0.1:5432/smirk # ── Auth (JWT) ──────────────────────────────────────────────────────────── JWT_SECRET= JWT_EXPIRY_HOURS=24 # ── At-rest identity secrets (REQUIRED; generate each: openssl rand -hex 32) ─ # HMAC peppers keep stored identity values (pubkey_hash, seed_fingerprint) # non-reproducible from a candidate seed; the salt unlinks client IPs in abuse # tables. Boot fails closed if any is unset or too short. SEED_FINGERPRINT_PEPPER= REFRESH_TOKEN_PEPPER= IP_SALT= # ── Proof-of-work signup gate (ALTCHA v2) ───────────────────────────────── # FEATURE_POW is the master switch; POW_REQUIRED only applies when it is on. Boot # fails closed if POW_REQUIRED=true while FEATURE_POW=false (a silently-open gate). FEATURE_POW=false ALTCHA_HMAC_KEY= POW_REQUIRED=false ALTCHA_COST=100000 # Testing aid: comma-separated lowercase pubkey hashes that ALWAYS require PoW, # even when POW_REQUIRED=false (opt-in, for exercising the gate). Requires # FEATURE_POW=true or boot fails closed. Leave empty in normal deployments. TEST_POW_REQUIRED_FOR_PUBKEYS= # ── Registration gates (composable; each advertised via /capabilities) ──── # Require an operator-minted invite code to create a NEW wallet. Mint codes with # `smirk-admin mint-invite --count `. Returning wallets bypass it, and # self-hosting bypasses all gates (run your own backend). PoW is the ALTCHA gate # above. Compose freely (e.g. invite + PoW + pay-to-register). REGISTRATION_REQUIRE_INVITE=false # Pay-to-register: require a SETTLED payment invoice to create a new wallet. The # invoice is minted at POST /auth/payment-invoice and paid — directly to YOUR own # wallet — via an external, non-custodial processor. The `btcpay` adapter speaks # the BTCPay-compatible invoice API, which also covers this project's xmrcheckout # /wowcheckout apps and the BTCPay Monero plugin, so you pick the coin by pointing # at the processor that accepts it. The backend only READS invoice status (pull # model) — it never holds funds or keys. Self-hosting bypasses this (leave off). REGISTRATION_REQUIRE_PAYMENT=false PAYMENT_PROVIDER=btcpay # Processor base URL, store/merchant id, and an API key that can create + read # invoices (held redacted; never logged). Required when the gate is on. PAYMENT_PROVIDER_URL=https://pay.example.org PAYMENT_STORE_ID= PAYMENT_API_KEY= # Registration price + currency ('XMR', or a fiat code the processor converts). PAYMENT_AMOUNT=0.01 PAYMENT_CURRENCY=XMR # Confirmations-to-finalize (mapped to the processor's speed policy), and how long # an unpaid invoice stays payable before it expires. PAYMENT_CONFIRMATIONS=1 PAYMENT_EXPIRES_MINUTES=60 # How the enabled invite/payment gates COMBINE (PoW is orthogonal and always # applies on top). 'all' (default): a new wallet must satisfy EVERY enabled gate # (invite AND payment when both are on). 'any': the enabled gates are # ALTERNATIVES and the wallet satisfies exactly ONE (e.g. "an invite code OR pay # to register"); the client shows a "pick a method" chooser. With a single gate # enabled the two modes are identical. REGISTRATION_GATE_MODE=all # ── Chain enablement (each chain is independently feature-flagged) ───────── # All default to TRUE. FAIL-CLOSED RULE: in production, an ENABLED chain with no # source configured makes the server REFUSE TO BOOT — a chain that cannot be # served must not advertise itself as available via /capabilities. So set the # ones you do not run to false, or configure the source for each you keep on: # BTC / LTC — an electrum/Fulcrum URL (BTC_ELECTRUM_URL / LTC_ELECTRUM_URL) or # a fallback list. # XMR / WOW — the *_LWS_ADMIN_KEY (plus its LWS + daemon URLs). # GRIN — GRIN_OWNER_API_SECRET (plus the GRIN_* wallet/node wiring). # In development these are warnings rather than a hard boot error. FEATURE_BTC=true FEATURE_LTC=true FEATURE_XMR=true FEATURE_WOW=true FEATURE_GRIN=true # ── Address-privacy features (opt-in) ───────────────────────────────────── # Per-payment receive addresses. Off by default; enable once your node stack # meets the requirement noted below. # # FEATURE_XMR_SUBADDR_PROVISIONING: provisions account-0 subaddress indices on # the LWS at register/import so per-payment XMR/WOW receive addresses are # scanned. REQUIRES your monero-lws / wownero-lws to run with # `--max-subaddresses >= 200` (it defaults to 0, which disables subaddress # support entirely). Confirm with: # curl -s http:///get_version | grep -o '"max_subaddresses":[0-9]*' # The backend probes this at provision time and refuses with a clear error # rather than half-working, so a wrong setting is loud, not silent. FEATURE_XMR_SUBADDR_PROVISIONING=false # # FEATURE_UTXO_MULTI_ADDRESS: mounts the batched BTC/LTC endpoints # (/wallet/utxo/{balance,utxos,history}_multi) used by HD gap-limit wallets, # capped at 32 addresses per request. When off, the routes are not mounted and # the single-address endpoints are unchanged. FEATURE_UTXO_MULTI_ADDRESS=false # ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ───── BTC_NETWORK=mainnet BTC_ELECTRUM_URL= # # ── PRIVACY: read this before setting fallbacks ─────────────────────────────── # These are THIRD-PARTY servers. When your own Electrum/Fulcrum is unreachable, # the backend fails over to them, and the queries it sends carry YOUR USERS' # ADDRESSES. The failover is silent and correct, so the only symptom is that # balances keep working: a dead Fulcrum can go unnoticed for days while every # lookup goes to someone else's server. (That is not hypothetical; it happened # to us for 11 days.) # # Two honest choices, both defensible: # * LEAVE THESE SET for availability. Balances survive your node dying. Watch # for the "third-party Electrum fallback(s) configured" line at startup. # * LEAVE THEM EMPTY to fail closed. Balances break when your node breaks, and # every query stays on infrastructure you control. This is the right default # for a privacy-first or air-gapped deployment. # # More entries = more resilience and a wider spread of who sees a given query. # They are tried in order after the primary. BTC_ELECTRUM_FALLBACKS=ssl://electrum.blockstream.info:50002,ssl://electrum.emzy.de:50002,ssl://electrum.bitaroo.net:50002,ssl://fulcrum.sethforprivacy.com:50002,ssl://electrum.diynodes.com:50002,ssl://bitcoin.lu.ke:50002,ssl://electrum.jochen-hoenicke.de:50006,ssl://btc.electroncash.dk:60002 # Require CA-verified TLS (webpki + hostname) for ssl:// electrum servers. Applies # to BOTH BTC and LTC. Default false: public electrum servers are self-signed by # convention and their data is treated as hostile regardless, so accepting any # cert keeps the public fallbacks usable. Set true if you run your own CA-cert'd # Fulcrum and want to harden. ELECTRUM_STRICT_TLS=false # ── Litecoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ──── LTC_NETWORK=mainnet LTC_ELECTRUM_URL= # Same privacy tradeoff as BTC_ELECTRUM_FALLBACKS above; read that note. LTC_ELECTRUM_FALLBACKS=ssl://electrum-ltc.bysh.me:50002,ssl://electrum.ltc.xurious.com:50002,ssl://backup.electrum-ltc.org:443,ssl://electrum1.cipig.net:20063,ssl://electrum2.cipig.net:20063,ssl://electrum3.cipig.net:20063,ssl://ltc.rentonisk.com:50002 # ── Monero (daemon + light wallet server) ───────────────────────────────── XMR_LWS_URL=http://127.0.0.1:8443 XMR_LWS_ADMIN_URL=http://127.0.0.1:9443 XMR_LWS_ADMIN_KEY= XMR_DAEMON_URL=http://127.0.0.1:18081 # ── Wownero (daemon + light wallet server) ──────────────────────────────── WOW_LWS_URL=http://127.0.0.1:18443 WOW_LWS_ADMIN_URL=http://127.0.0.1:19443 WOW_LWS_ADMIN_KEY= WOW_DAEMON_URL=http://127.0.0.1:34568 # ── Grin (wallet owner/foreign API + node API) ──────────────────────────── GRIN_OWNER_API_URL=http://127.0.0.1:3420/v3/owner GRIN_OWNER_API_SECRET= GRIN_WALLET_PASSWORD= GRIN_FOREIGN_API_URL=http://127.0.0.1:3415/v2/foreign GRIN_NODE_API_URL=http://127.0.0.1:3413/v2/owner GRIN_NODE_API_USER=grin GRIN_NODE_API_PASS= GRIN_NODE_FOREIGN_API_URL=http://127.0.0.1:3413/v2/foreign GRIN_NODE_FOREIGN_API_SECRET= # ── Grin light-wallet-server (grin-lws) — optional add-on to the Grin chain ─ # When GRIN_LWS_URL is set (and FEATURE_GRIN is on) the Grin scan proxies to # grin-lws first, trusting it only when it is provably synced to the tip, and # otherwise falling back to the authoritative grin-wallet scan. Leave GRIN_LWS_URL # empty to use grin-wallet only. GRIN_LWS_ADMIN_URL / GRIN_LWS_ADMIN_KEY reach # grin-lws's bearer-gated admin API (optional). GRIN_LWS_URL= GRIN_LWS_ADMIN_URL= GRIN_LWS_ADMIN_KEY= # ── Wallet restore (import) policy ──────────────────────────────────────── # How far back a wallet may ask THIS instance to scan on import. The backfill # cost lands on your LWS/node, so it is your call; it is advertised via # /capabilities so the wallet adapts its import UX. Self-sovereignty is # preserved — a user needing a deeper restore runs their own instance or picks # an operator with a looser policy. # create-only — new wallets only (scan starts at the tip). DEFAULT — and it # betrays nothing: self-sovereignty is run-your-own-backend. # bounded — restore up to WALLET_MAX_RESTORE_DEPTH_DAYS behind the tip # unlimited — any restore height (the natural choice for self-hosting) WALLET_RESTORE_POLICY=create-only WALLET_MAX_RESTORE_DEPTH_DAYS=365 # PoW-priced restore: charge the importer compute proportional to the scan depth # they impose on you. Free below WALLET_RESTORE_POW_FREE_DAYS, then +1 hashcash # difficulty bit per WALLET_RESTORE_POW_DAYS_PER_BIT days of depth (0 = pricing # OFF), capped at WALLET_RESTORE_POW_MAX_BITS. Applies within bounded/unlimited. WALLET_RESTORE_POW_FREE_DAYS=90 WALLET_RESTORE_POW_DAYS_PER_BIT=0 WALLET_RESTORE_POW_MAX_BITS=24 # ── Feature flags (each feature ships in the open core, toggled per deployment) ── # The wallet client reads GET /api/v1/capabilities and greys out disabled features. FEATURE_PRICES=true PRICES_PROVIDER=coingecko PRICES_FETCH_INTERVAL_SECS=300 # Which assets the price feed quotes. UNSET = all supported (btc,ltc,xmr,wow,grin); # a subset like "btc,xmr" narrows it; PRESENT-BUT-EMPTY (PRICES_ASSETS= with no # value) means none. Leave commented to serve all. #PRICES_ASSETS= # Currency the feed quotes in. One of: usd, eur, gbp, jpy, cny, aud, cad, chf, btc. # A typo fails closed at boot (rather than serving an empty feed). PRICES_CURRENCY=usd # Nostr-native identity (NIP-98 login/link, NIP-05 directory). ON by default; when # on, PUBLIC_API_URL is required (and must be https in production). FEATURE_NOSTR_IDENTITY=true # Grin slatepack relay: a non-custodial encrypted store-and-forward mailbox for # interactive Grin transfers. ON by default; can be disabled independently of Grin # chain access. FEATURE_GRIN_RELAY=true # Tips are parked for the conditional-transfer redesign; off until that lands. FEATURE_TIPS=false # Base URL for public-tip share links (a tip's share URL is {base}/{tip_id}). # REQUIRED when FEATURE_TIPS=true (boot fails closed without it); never hardcode a # host, so federated/self-hosted instances build their own share links. TIP_SHARE_BASE_URL= # ── Nostr relay (optional messaging plane; see docs/operations/RELAY.md) ──── # Off by default. When on, the operator runs a relay (the packaged # nostr-rs-relay under deploy/relay, or an external one) that this backend # advertises (/capabilities + NIP-05) and write-restricts via a gRPC admission # service. It is the user's DM INBOX, never the only relay — clients also use the # public interop relays. Self-hosting concern. RELAY_ENABLED=false # bundled (run deploy/relay's nostr-rs-relay) | external (point at your own). RELAY_MODE=bundled # Public ws(s):// URL clients connect to (wss required in production). RELAY_URL= # Write policy the admission service enforces: # inbox-outbox — registered npubs publish their own events; anyone may # deliver a gift-wrapped DM (kind 1059) to a registered user. # author-allowlist — only registered npubs may publish anything. # open — accept everything (relay resource caps still apply). # premium-post — registered users post wallet events (DMs) free; general # notes require an active PREMIUM_* subscription. RELAY_WRITE_POLICY=inbox-outbox # NIP-13 proof-of-work bits required on cross-ecosystem (non-registered-author) # inbound events; 0 disables the PoW gate. RELAY_INBOUND_POW_BITS=0 # Write-exempt npubs (comma-separated; npub1… or 64-char hex): may publish ANY # kind regardless of the write policy or premium status. Use this for an # announcements / feed-owner account that seeds a premium-post feed without # holding a subscription. Empty = no exemptions. RELAY_WRITE_ALLOWLIST_NPUBS= # Loopback address the gRPC event-admission service binds (the relay calls it). # It answers "is this npub registered?" (a registration oracle) so it MUST stay # loopback; the bundled compose reaches it via the host network. RELAY_ADMISSION_BIND=127.0.0.1:8090 # Allow a NON-loopback admission bind (advanced; e.g. a relay container without # host networking). Off by default; you must firewall it if you enable it. RELAY_ADMISSION_ALLOW_PUBLIC=false RELAY_MAX_EVENT_BYTES=65536 RELAY_RETENTION_DAYS=30 # ── Premium relay tier (optional; recurring, tiered relay-posting access) ── # Wallet use stays free; premium unlocks GENERAL Nostr posting to the relay # (wallet-functional events — DMs, tips, swap coordination — stay free). Requires # RELAY_ENABLED=true + RELAY_WRITE_POLICY=premium-post + the PAYMENT_* processor # block above. Off by default. See docs/design/premium-relay.md. PREMIUM_ENABLED=false # Price currency: a fiat code the processor converts, or a specific coin. PREMIUM_CURRENCY=USD # Comma-separated plans, each `id:days:amount` (a built-in tiered discount). PREMIUM_PLANS=quarter:90:5,halfyear:182:9,year:365:15 # ── Public curated feed (optional; powers a read-only web feed like feed.) ── # Reads FROM the relay, so it needs RELAY_ENABLED. All public display config — # what the feed shows. Off by default; advertised via /capabilities.feed when on. FEED_ENABLED=false FEED_SHOW_OWNER=true FEED_SHOW_PREMIUM=true # Operator npub (hex or npub1…) for "owner" posts + display; empty if none. FEED_OWNER_NPUB= # Comma-separated featured npubs, and extra relays to also pull those authors from. FEED_ALLOWLIST_NPUBS= FEED_EXTRA_RELAYS= # ── Admin surface (see docs/operations/CONSOLE.md; default posture is loopback/Tor) ─ # Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here, # never a seed or private key. The allowlist is mutated CLI/loopback-only in v1. # When enabled, the embedded Operator Console SPA is served at /admin on this plane. ADMIN_ENABLED=false ADMIN_BIND=127.0.0.1:8081 # Allow ADMIN_BIND to be a NON-loopback address. Default false: boot REFUSES a # public admin bind (confidentiality is by socket). Only set true if you front the # plane with Tor client-auth or a trusted proxy and firewall it. ADMIN_ALLOW_PUBLIC_BIND=false # Tor onion host you reach the admin plane at (e.g. .onion). REQUIRED for the # Tor-onion posture: it is added to the admin Host allowlist, so without it a # request whose Host header is the onion is rejected 403 (anti DNS-rebinding). # Never logged or surfaced publicly. Leave empty for the loopback/SSH-tunnel path. TOR_ADMIN_ONION= # Public absolute URL of THIS node's admin surface. The per-node id that binds # signed admin actions (so a challenge signed for one node can't be replayed to # another across a fleet) is DERIVED from this value — give each node in a fleet a # distinct one. Must be an absolute URL when ADMIN_ENABLED=true. ADMIN_PUBLIC_URL=https://admin.example.org ADMIN_JWT_SECRET= # Integrity MAC secret protecting admin/setup trust anchors against DB tampering. # Losing this locks out admin — back it up (see docs/operations). ADMIN_KEY_INTEGRITY_SECRET= ADMIN_MAX_KEYS=8 ADMIN_PENDING_KEY_TTL_DAYS=7 # RESERVED / inert: ADMIN_PUBKEYS is parsed but NOT consumed anywhere in this # version — it does not gate admin access. Authorization is the MAC-protected key # allowlist in the database (managed via `smirk-admin` and the console Keys tab), # never this variable. Leave it unset; setting it has no effect. ADMIN_PUBKEYS= # How the Operator Console applies a RESTART-REQUIRED config change (see CONSOLE.md): # manual (default) — persist + flag; you apply it with a graceful restart. # auto — the instance self-restarts after saving. REQUIRES a # supervisor that brings it back (systemd Restart=always); # otherwise it goes down and stays down. RESTART_APPLY_MODE=manual # ── Public landing page (OFF by default; full + per-field tunable when on) ── PUBLIC_LANDING_ENABLED=false # Optional custom landing title (leave unset for the default). #PUBLIC_LANDING_TITLE= PUBLIC_EXPOSE_VERSION=false PUBLIC_EXPOSE_CHAINS=false PUBLIC_EXPOSE_PRICE_FEED=false PUBLIC_EXPOSE_UPTIME=false PUBLIC_STATS_ENABLED=false PUBLIC_STATS_CACHE_HOURS=24 # ── Data retention / erasure ────────────────────────────────────────────── # When ERASURE_ENABLED=true, self-service erasure needs ADMIN_KEY_INTEGRITY_SECRET # (its audit trail is the integrity-MAC'd hash chain) and PUBLIC_API_URL (its # proofs bind that URL); boot fails closed otherwise. ERASURE_ENABLED=false ERASURE_PURGE_LOGIN_EVENTS=true ERASURE_EXPORT_PER_DAY=3 # Grace window (hours) between a confirmed erasure and its execution. Max 87600 # (10 years). ERASURE_GRACE_PERIOD_HOURS=72 # Log retention (auto-deletion); 0 = keep indefinitely. RETENTION_LOGIN_EVENTS_DAYS=90 RETENTION_AUDIT_DAYS=365