Falling back to public Electrum is a PRIVACY decision, not just an availability
one: the queries carry user addresses. It is easy to miss because the failover
is silent and correct, so the only symptom is that balances keep working. Our own
LTC Fulcrum was dead for 11 days with every lookup going to third parties, and
nothing surfaced it.
- startup: log how many third-party fallbacks are configured and how to fail
closed instead, so the posture is visible in the operator's own logs
- .env.example: state the tradeoff at the setting, with both choices spelled out
rather than a default presented as obviously correct
- OPERATOR_SETUP: same note where an operator configures chains
Also expands the shipped fallback lists (BTC 3 -> 8, LTC 4 -> 7), which is both
more resilience and a wider spread of which server sees any given query.
The note said to check the LWS setting without saying how. Adds the one-liner and
records that the backend probes it at provision time and refuses loudly, so a
wrong setting is not a silent half-working state.
Backend support for two address-privacy features. Both are behind env flags
that default to off, so an unconfigured instance behaves exactly as before:
the routes are not mounted and no provisioning is attempted.
FEATURE_XMR_SUBADDR_PROVISIONING
Adds POST /wallet/lws/provision_subaddrs and provisions account-0 subaddress
indices on the LWS at register/import.
Three money-safety properties worth calling out:
* The provisioned ceiling is read from the LWS response, never echoed back
from the request. It is the contiguous run starting at minor 0, not
max(hi): given ranges [[0,99],[200,299]], reporting 299 would let a wallet
hand out indices 100..199 that the LWS never scans, so funds sent there
would be invisible. No usable run is a hard error, not a guess.
* import_account now always runs the backward rescan even when provisioning
fails. Previously a provisioning error aborted first, leaving the account
registered at the chain tip while every retry returned 200 OK, so a
restoring wallet would show a zero balance permanently.
* A capacity probe reads max_subaddresses from the LWS and refuses up front
with a message naming the required --max-subaddresses value, rather than
failing per-account later. The option defaults to 0 upstream.
Spent outputs now carry their subaddress index. monero-lws emits this as
`sender` (the index the spent output was received at), not `recipient`;
without it a client recomputes a primary key image for a subaddress spend,
treats the mismatch as a decoy, and never subtracts the amount.
FEATURE_UTXO_MULTI_ADDRESS
Adds balance_multi / utxos_multi / history_multi for HD gap-limit wallets.
Capped at 32 addresses, summed with checked_add, per-UTXO address tag
preserved so the client never has to re-guess which key owns an output.
Bounded concurrency with a per-batch deadline returning 504, so a slow
Electrum server cannot stack n * IO_TIMEOUT.
Both flags are advertised in /capabilities so a client can negotiate instead
of calling routes that 404.
- New docs/operations/CONSOLE.md: reaching /admin, NIP-98 login, the
defaults->env->DB precedence model, per-section runtime-safe vs
restart-required table, expected_versions concurrency, restart_apply_mode
(with the systemd Restart=always requirement for auto), clearing a DB
override. Linked from README + OPERATOR_SETUP.
- All six example recipes gained an explicit chain-enablement block
(FEATURE_XMR/WOW on, BTC/LTC/GRIN off) so they no longer hard-fail
Config::validate in production (default-on chains with empty sources).
- Documented all 16 previously-undocumented env vars in .env.example
(RESTART_APPLY_MODE, TOR_ADMIN_ONION, DEPLOYMENT_MODE, TIP_SHARE_BASE_URL,
...); RELAY.md gained the premium-post policy + RELAY_WRITE_ALLOWLIST_NPUBS;
ADMIN_PUBKEYS noted as reserved/inert.
Add a GrinLwsClient (a faithful mirror of the XMR LwsClient) + GrinLwsConfig
(enabled when GRIN_LWS_URL is set). /wallet/grin/scan now defaults to grin-lws
when configured and returns derivation paths (key_id/n_child) + spendability,
letting the wallet skip its client-side output-identify search.
Money-safety: the restore-depth/PoW gate runs once and is terminal (never
masked by fallback); grin-lws is trusted only when provably synced
(scanned_height + margin >= tip), otherwise the scan falls back to the
authoritative grin-wallet scan_rewind_hash; a grin-lws transport error also
falls back; grin-wallet errors propagate as 503. No path ever synthesizes an
empty/zero balance.
Off unless GRIN_LWS_URL is configured (prod grin behavior unchanged until
pointed at a grin-lws instance). Capabilities advertise `grin_lws`.
openapi.json regenerated.
RELAY_WRITE_ALLOWLIST_NPUBS (npub1… or hex, comma-separated): listed authors may
publish ANY kind regardless of write policy or premium status. The use case is a
feed-owner / announcements account that seeds a premium-post feed without holding
a subscription — the admission engine previously had no owner/allowlist bypass
(FEED_OWNER_NPUB is read-side only).
- policy.rs: decide() gains author_allowlisted, checked first (permit-any).
- mod.rs: RelayProvider::is_write_allowlisted; entries decoded npub→hex once at
boot (bech32), malformed entries logged + skipped. npub-decode KAT.
- nauthz.rs: resolves the flag (in-memory set, no DB) and short-circuits the
registration/premium/recipient lookups when exempt.
- .env.example: document the var + the premium-post policy.
Relay tests 21/21.
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.
A read-only web feed (feed.<domain>) is a backend-operator knob: FEED_ENABLED
plus curation switches (show owner / show premium members / a featured npub
allowlist / extra relays) surface in /capabilities.feed so the feed page adapts
per instance. Reads from the relay, so validate() fail-closes FEED_ENABLED
without RELAY_ENABLED. Off by default; all public display config, no secrets.
Add ready-to-run .env + README recipes (personal, public-free, friends,
paid-access, paid-relay, community) and the premium-relay design overview,
with audit fixes to the config template and spec:
- Drop the inert INSTANCE_ID knob (config never read it) and document the real
per-node id, which is derived from ADMIN_PUBLIC_URL.
- Warn that a placeholder PUBLIC_API_URL is rejected at boot in production
(Nostr identity is on by default); soften the recipe "boots today" status.
- Remove dead template vars: BTC/LTC Core-RPC settings and the non-existent
PUBLIC_EXPOSE_FEATURES (replaced with the real per-field landing switches).
- Mark ALTCHA_HMAC_KEY conditional in PoW-off recipes; blank placeholder
payment creds so a recipe doesn't ship a key that fails validation.
- Fix premium-relay spec drift: the migration creates no premium_until index,
disabled endpoints return 400/200 (not 404), and neutralize operator voice.
Adversarial review of the relay admission surface:
- FAIL-SAFE on admission death: nostr-rs-relay fails OPEN if its gRPC admission
server is unreachable, so a dead admission = an unrestricted relay. The backend
now EXITS if the admission service stops (supervisor restarts a clean pair);
documented in RELAY.md. Pin the relay image to a digest (compose note).
- Registration-oracle protection: validate() now refuses a non-loopback
RELAY_ADMISSION_BIND unless RELAY_ADMISSION_ALLOW_PUBLIC=true (+ test).
- No N+1 on crafted events: recipient p-tags are capped (20, deduped) and resolved
in ONE batched query (db.any_registered_npub) instead of one-per-tag.
- Defence-in-depth size cap: admission denies events over max_event_bytes before
any DB work (the relay enforces its own limit too).
- Docs: RELAY.md Security section (fail-open, loopback oracle, default PoW=0 spam
footgun, client sender-verification); config.toml relay port 8088 (not 8080);
Docker Hub image; .env.example RELAY_ADMISSION_ALLOW_PUBLIC.
Note: the client-side sender-impersonation blocker is fixed separately in the
monorepo (verifying gift-wrap unwrap).
Completes the operator side of the relay integration:
- deploy/relay/: reference docker-compose + config.toml for a locked-down
nostr-rs-relay wired to the backend's gRPC admission (host networking so the
loopback admission socket stays private). Clearly marked a template to pin/
verify against the operator's nostr-rs-relay version.
- docs/operations/RELAY.md: what it is (inbox relay, not the only relay), the
admission architecture, the three write policies + NIP-13 PoW, bundled vs
external, verification, and the seam's modularity. Linked from OPERATOR_SETUP.
- .env.example: the RELAY_* block (all off/placeholder).
Backend relay integration (config → seam → policy → gRPC admission →
capabilities → NIP-05 → packaging/docs) is now complete. Client DM stack next.
- .env.example: add the required at-rest identity secrets (SEED_FINGERPRINT_PEPPER,
REFRESH_TOKEN_PEPPER, IP_SALT). They were missing, so `cp .env.example .env` on a
fresh setup boot-failed closed on the first required-secret check.
- README: correct the first-admin bootstrap to `smirk-admin setup --pubkey`.
create-admin-wallet registers a pending key but does not latch, so on a fresh
instance it leaves the bootstrap uninitialized.
- docs/operations/OPERATOR_SETUP.md: full deployment guide — database, secrets,
per-chain backends, run + verify, admin bootstrap, registration policy, and
smoke-testing an instance (SMOKE_API_STYLE=namespaced).
Surfaced by a clean-room operator setup against a fresh instance.
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.
Default restore policy is now create-only — which betrays no self-sovereignty,
since that lives at the ECOSYSTEM level (run your own backend). On top of the
create-only|bounded|unlimited depth gate, restore depth can be PRICED in hashcash
PoW so an importer pays compute proportional to the chain-scan cost they impose
on the operator (deeper restore => more work).
- core::restore_pow: a self-contained hashcash bound to (asset, address,
start_height) — a solved nonce is valid only for that exact restore, so no
challenge state, no replay, no depth-downgrade. Difficulty = leading zero bits.
- RestoreConfig: pow_free_days / pow_days_per_bit (0 = off) / pow_max_bits curve;
required_restore_pow_bits + enforce_restore_pow (literal errors, no oracle).
- Enforced at xmr/wow register + grin scan via a new restore_pow_nonce; the curve
is advertised via /capabilities so the wallet computes + solves it.
- Default WALLET_RESTORE_POLICY flipped bounded -> create-only.
- Unit tests (curve scaling/cap, off-when-zero, valid/invalid nonce, hashcash
roundtrip + param-binding); .env.example; OpenAPI drift clean.
First of the operator-configurable registration gates (PoW-priced restore and
pay-to-register follow). A NEW wallet must present a valid invite code when
REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it,
and the active gates are advertised via /capabilities so the wallet shapes
onboarding.
- migration + db: invite_codes table storing only sha256(code) (128-bit codes,
no pepper needed). Redemption is an atomic single-statement claim
(UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent
submission (L1 test fires 8 concurrent claims; exactly one wins).
- core::invite: generate (base58, 128-bit) + sha256 hash.
- enforce_invite in extension_register, mirroring enforce_pow (returning bypass;
ONE literal error for not-found/used/expired — never an oracle).
- RegistrationConfig.require_invite + RegistrationCapability in /capabilities.
- smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes).
- L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
How far back a wallet may ask THIS instance to scan on import is an operator
decision — the backfill cost lands on its LWS/node. Add WALLET_RESTORE_POLICY
(create-only | bounded | unlimited, default bounded) + WALLET_MAX_RESTORE_DEPTH_DAYS,
advertise it via /capabilities so the wallet adapts its import UX, and enforce
it at the scan path (xmr/wow register, grin scan): a too-deep start_height is a
ValidationError naming the bound, never the caller's value. Create needs no
check (no tip lookup). Self-sovereignty stays intact at the ecosystem level —
a deeper restore means self-hosting or a looser operator. Fail-closed config
validation; a unit test per policy; OpenAPI drift clean.
- minimal Axum server exposing GET /health
- OpenAPI contract generated from handlers (gen_openapi -> openapi.json),
drift-gated in CI so the spec can never silently diverge from the code
- CI: secret-scan + gitleaks, openapi drift gate, fmt/clippy/build/test/doc-test, cargo-deny
- Cargo.lock tracked for reproducible builds; publish=false; pinned toolchain
- README, SECURITY.md, deny.toml, .env.example