XMR/WOW atomic amounts (piconero/wownoshi) can exceed 2^53, which a
JavaScript number cannot hold exactly, so a large balance or output was
silently rounded when the wallet JSON.parsed it. Serialize every atomic
amount (balance total_received/locked/pending, spent + unspent output
amounts, history totals) as a decimal string; heights, indices, counts,
and fee params stay numbers. utoipa value_type=String keeps the OpenAPI
honest. Tests assert the string wire contract for a 2^53+1 value.
Public-only instances have no targeted-recipient inbox (public tips are claimed
via share URL, never delivered to a user), so the client's inbox poll of
/received + /claimable 404'd. Serve them 200-empty instead so the poll succeeds
quietly. Matches the client contract ({tips:[]}).
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.
The wallet gates its bootstrap on this: when true it registers via NIP-98
(no BTC signature); a legacy backend that omits the flag gets the BTC path.
Shares the NIP-98 infra requirement (PUBLIC_API_URL) with nostr_identity.
Make npub the identity anchor: a wallet can register from its seed-derived Nostr
key alone, no BTC signature. Breaks the link chicken-and-egg (nostr_login is
create-never; nostr_link needs a prior BTC-authed JWT). Additive - /auth/extension
(BTC) and the link flow keep working for legacy backends during the transition.
- get_or_create_user_by_nostr_pubkey (db twin of the BTC path): keys/inserts on
nostr_pubkey (raw, never peppered), pubkey_hash NULL. THE load-bearing dedup:
looks up by seed_fingerprint and MERGES the npub onto an existing (BTC-anchored
or link-less) row rather than splitting one wallet into two identities; a
different npub already on that row fails closed (409). backfill_optionals shared
helper.
- POST /auth/nostr/register + GET /auth/nostr/register-challenge: verify a NIP-98
signed-action over a subject-less single-use nonce (replay-safe), then re-run
the abuse gates keyed on the npub (a create path must not bypass PoW/invite/
payment); pay-to-register invoices still bind to the BTC key carried in keys[].
Empty-body descriptor contract (mirrors /auth/nostr/link); binding the key list
into the proof is a documented hardening follow-up.
- Housekeeping: get_user_count counts npub-only users; record_login_event labels
the nostr path.
3 integration KATs (create-by-npub + idempotent, replay rejected, seed_fingerprint
MERGE dedup) pass against a real DB; 211 lib tests pass; openapi regenerated.
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.
- Reject boot when POW_REQUIRED (or the per-pubkey PoW list) is set while
FEATURE_POW is off: the gate would silently never apply and /capabilities
would report pow_required:false — a signup endpoint the operator believes
is protected but isn't.
- Validate the premium PAYMENT_API_KEY through the same require_secret path
the pay-to-register flow uses, so a placeholder key cannot reach production.
- Advertise the premium-post write policy in MessagingCapability and
regenerate openapi.json to match.
- Give the premium integration fixtures unique per-run ids so the suite is
re-runnable against a persistent local test database.
A paid premium tier gating GENERAL Nostr posting to the operator's relay, while
wallet-functional events (encrypted DMs, tips, swap coordination) stay free for
registered users. Registration itself stays free (PoW-gated). Off by default.
- Admission: a new `premium-post` write policy gates on kind x membership —
premium author -> any kind; registered + WALLET_KINDS (1059 gift-wrap, 10050
DM-relay-list) -> free; registered + general -> premium required; external ->
gift-wrap DM inbox only. nauthz resolves is_premium_npub (only under premium-post),
fail-closed.
- Payment: reuses the PaymentProvider seam (PULL model, non-custodial — only reads
invoice status). POST /premium/invoice {plan} mints a tier invoice bound to the
JWT user_id; POST /premium/activate verifies Settled + consumes single-use AND
extends premium_until in ONE transaction (a mid-flight failure keeps the paid
invoice redeemable); GET /premium/status reads it. Per-user pending-invoice cap.
- Tiered plans (PREMIUM_PLANS id:days:amount) = a built-in discount feature;
advertised via /capabilities (premium_relay + premium{currency,plans,relay_url}).
- Config fail-closes: PREMIUM_ENABLED needs relay on + premium-post + processor
creds + >=1 positive-decimal plan + unique ids + days<=3660, and the 0-conf /
expiry processor floors apply on the premium path too (its common case is
require_payment=false).
- Tests: 15 policy unit (incl. 5 premium-post) + config fail-closed matrix + plan
parser + L1 integration (activate single-use/stacking/cross-user, is_premium by
expiry, feature gate). OpenAPI + migration updated.
Adversarially reviewed (correctness / security-payment / operational-config); the
0-conf-on-premium-path double-spend footgun, non-transactional activate, and
unbounded plan days were caught and fixed before commit.
So clients can discover + connect to the relay (and self-adapt when it's off):
- /capabilities: features.nostr_relay + a messaging{ relay_url, write_policy,
inbound_pow_bits, supported_nips } object, with the same config-presence
downgrade as chains/nostr_identity (advertised only when enabled AND a URL is
configured — never advertise a relay clients can't reach). OpenAPI regenerated.
- nip05.rs: /.well-known/nostr.json relay hints are now config-driven — this
instance's own relay (the user's DM inbox) first when enabled, then the public
interop relays (damus/nos.lol) so cross-wallet delivery still works. Replaces
the hardcoded-only default.
The link flow was stubbed: consume_link_nonce always returned false and no nonce
issuer existed, so nostr_login worked only for already-linked npubs and nothing
could link one. Complete it:
- GET /auth/nostr/link-challenge (authenticated) issues a single-use nonce via
the existing challenge store, bound to the caller (subject) + the nostr_link
purpose, 5-minute TTL.
- nostr_link now consumes it atomically (DELETE … RETURNING, so a replay loses
the race), cross-checks the nonce was issued to THIS user, then verifies the
signed-action proof (unchanged) and stores the npub.
- Route registered + OpenAPI regenerated (no drift).
tests/nostr_auth.rs: pins the empty-body descriptor hash as a cross-impl KAT (the
wallet binds the identical value), and covers a full challenge→link round-trip
plus replay and wrong-user-nonce rejection.
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).
POST /wallet/lws/balance returned a server-summarized {unlocked, locked} where
unlocked = total_received - locked OVER-COUNTED: a non-custodial backend cannot
verify spends without the spend key, so it never subtracted them. Reshape to the
wallet's verification-passthrough contract — aggregate get_address_info +
get_address_txs (parallel) and return total_received, locked_balance,
pending_balance (0-conf summed directly from mempool rows: webhook-free, cleaner
than the private backend's DB table), and spent_outputs[] for client-side
key-image verification. The wallet nets its own spends (privacy-correct).
spent_outputs fixes the over-count immediately; pending_balance activates once
the parity LWS mempool feature is deployed. Adversarially reviewed (3 lenses,
0 confirmed findings).
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.
The wallet's ChainProvider seam fetches all chain tips in one call
(getBlockchainHeights) and selects per asset; the backend exposed per-chain
height endpoints but no aggregate, so the client's call 404'd. Add a
best-effort /wallet/heights returning {btc,ltc,xmr,wow,grin} concurrently — a
disabled or unreachable chain is null, never failing the others. JWT-gated,
OpenAPI-annotated (drift clean).
The spec carried no security scheme, so generated clients/docs could not tell
which endpoints require a session token — e.g. /wallet/utxo/balance read as
public while its handler enforces auth via extract_user_id_from_token. Add a
`bearer_auth` HTTP/JWT scheme through a Modify addon and annotate every route
that resolves a session; auth-bootstrap, public username/key lookups, and
system routes stay open. Regenerated openapi.json (drift clean).
Make /capabilities fail-closed and add the admin features surface.
- Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is
unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner
secret) now reports enabled:false — so /capabilities never advertises a chain
that would 404, and a missing-secret downgrade is indistinguishable from a
deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity
requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper.
- contract_version stamp on the public document (clients soft-notice a higher
value, ignore unknown additive keys).
- GET /admin/features (admin_guard): the resolved effective state PLUS admin-only
downgrade reasons (the public surface hides the reason, per §4.3).
- Golden test: the public capabilities JSON, built from a fully-secret-seeded
config, contains no secret value (asserts no "integration-test-" substring).
openapi regenerated (contract_version). Admin features view stays off the public
OpenAPI.
Deferred (documented): runtime mutation — PUT /admin/features + a DB feature-
override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags
are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a
deferred LB decision) is a later addition.
Cached crypto→fiat price feed, refreshed by a background task and served from an
in-memory snapshot — no upstream call on the request path. Public and
feature-flagged: when FEATURE_PRICES is off, /prices is 404 and no upstream is
ever contacted.
Per-feed control, as a public server needs: PRICES_ASSETS narrows which feeds an
instance quotes (unset = all supported; an explicit list serves a subset; empty
serves none), validated fail-closed against SUPPORTED_PRICE_ASSETS. Provider
(PRICES_PROVIDER, coingecko) and currency (PRICES_CURRENCY, usd) are configurable;
the refresh interval is clamped to a provider-friendly minimum.
- infra/prices: PriceClient (feed whitelist → provider coin ids), bounded by the
HTTP client timeout; non-finite/negative/missing quotes are skipped, not faked.
A fetch failure keeps the last good snapshot. Drift guard: a test asserts the
provider mapping covers exactly SUPPORTED_PRICE_ASSETS.
- main: background refresh task only when enabled and at least one feed is set.
- AppState.prices snapshot; handler 404s when disabled.
- openapi (prices tag); unit tests + L1 test over both feature-flag states.
Public, unauthenticated description of what this deployment offers, so the
wallet can adapt to the instance it's pointed at — grey out disabled chains and
features, and pick the right network. This is the client-facing half of the
open-core/feature-flag design: every capability is a config switch, surfaced
here. No secrets — only on/off flags and public network names.
Response: { version, chains: { btc/ltc/xmr/wow/grin: { enabled, network } },
features: { grin_relay, prices, nostr_identity, tips } }. UTXO chains carry
their configured network; CryptoNote/Grin report null. On the normal rate-limit
tier. Registered in openapi (system tag); 1 L1 test (public, well-shaped).
Final wallet-handler slice. An async store-and-forward mailbox for interactive
Grin transfers between two registered Smirk users, over the grin_slatepacks db.
Reworked clean from the legacy relay — NOT a port.
Endpoints (under /api/v1/wallet/grin/relay, JWT-gated):
- POST create {recipient_user_id, slate_id, slatepack, amount_nanogrin}
- GET pending -> the caller's inbox
- POST get {slate_id} -> poll a relay's state (sender/recipient)
- POST respond {slate_id, response_slatepack} (recipient)
- POST finalize {slate_id, tx_hash} (sender; RECORDS the txid only)
- POST cancel {slate_id} (sender or recipient)
Non-custodial: stores only opaque (encrypted) slatepacks + lifecycle status;
never holds keys, finalizes, or broadcasts. The wallet broadcasts locally (via
/wallet/grin/broadcast) and reports the txid here — diverging from the legacy
server-side post_tx (see docs/private/OWN_BACKEND_CONVERGENCE.md).
Controls + hardening (incl. fixes from the adversarial review workflow):
- Per-action authz: caller must be the slate's sender/recipient; a non-party
gets 404 (no existence oracle).
- slate_id is UNIQUE (+ validated as a UUID) and mutations key on the
authorized row's PRIMARY KEY, not slate_id — so a duplicate slate_id can't
brick a relay (fetch_optional-on-2-rows 500) or let a slate_id-keyed UPDATE
touch another row. create maps the unique violation to 409.
- State transitions are ATOMIC: the status + expiry preconditions live in the
UPDATE WHERE clause (not a Rust read-check-write), so respond-twice and
cancel-races-finalize cannot occur; a non-qualifying transition -> 409.
- TTL enforced at the action boundary (respond/finalize require expires_at >
now) + a periodic expire_old_slatepacks sweep in main.rs (relay-enabled only).
- Recipient must be a registered user; slatepack sizes capped (64 KiB).
- Behind FEATURE_GRIN_RELAY (default on).
6 paths + schemas + grin_relay tag in openapi (drift-clean). 4 L1 integration
tests against live Postgres: full lifecycle, unregistered-recipient 400, auth
gate, non-party/non-recipient 404s.
Third wallet-handler slice. Authenticated, view-only proxies over the
GrinClient — the backend holds no Grin spend key; the wallet exports its
rewind_hash (a public-derived view credential) per scan, the backend forwards
it to grin-wallet's scan_rewind_hash and stores no secret. Spending happens in
the wallet; the backend only broadcasts the finalized tx.
Endpoints (under /api/v1/wallet/grin):
- POST scan {rewind_hash, start_height?} -> {outputs, total_balance,
last_pmmr_index} (incremental resume index)
- GET height -> {height}
- POST broadcast {tx} -> {ok} (node push_transaction)
- JWT-gated. rewind_hash is a view credential: GrinScanRequest omits Debug and
is skipped from every span (never logged), and is validated as 64 hex before
any grin call (applying the wallet-handler review's secret-DTO lesson).
- Feature-gated: grin disabled -> 400.
- Shared validate_hex64 in wallet/mod.rs (view_key + rewind_hash); 3 paths +
schemas + grin tag registered; spec regenerated, drift-clean.
4 L1 integration tests (auth gate on scan/height/broadcast; malformed
rewind_hash 400).
Also: dropped Debug from the xmr_wow view_key-bearing request DTOs
(ViewRequest/RegisterRequest) per the review — defense-in-depth against a
cleartext view_key dump.
First wallet-handler slice. Thin, non-custodial, JWT-gated proxies over the
ElectrumClient — the wallet derives addresses and signs locally; these
endpoints only relay reads and a finalized broadcast.
Endpoints (POST, under /api/v1/wallet/utxo, asset = btc|ltc in body):
- balance -> {confirmed, unconfirmed} (separate integers; client sums)
- utxos -> [{txid, vout, value, height}]
- history -> [{txid, height, fee}]
- tip -> {height}
- fee -> {sat_per_vb}
- broadcast-> {txid}
- JWT-gated for abuse control; the client-supplied address is NOT persisted
against the user (no address<->identity graph stored here).
- Feature-gated dispatch: unknown/disabled asset -> 400; the Electrum client
validates the address + network before any network call.
- broadcast validates tx_hex (non-empty, even-length, hex, <=200 KB) before
reaching the node.
- Registered in build_router + openapi.json (6 paths, btc_ltc tag); spec
regenerated, drift-clean.
5 L1 integration tests (auth gate on balance/broadcast/fee; unknown-asset and
malformed-tx-hex 400s — all deterministic, no network) + tx_hex unit test.
- api/website.rs: sign-in-with-wallet (dapp connect). challenge bound to origin
+ short TTL; verify is single-use (remove-before-verify, race-safe), expiry-
checked, signature via Result<(),AppError> verifiers (no oracle), resolves an
EXISTING user by the proven key's pubkey_hash (never mints identity), mints a
Platform::Web session.
- api/users.rs: username set/get + by-username lookup (JWT-gated, 409 on taken,
constant-shape lookup); per-asset key registration bound to the JWT user
(no spoofable user_id) + public key-directory reads.
- api/nip05.rs: /.well-known/nostr.json directory (username -> nostr_pubkey).
- Wired into api/mod.rs; website+users nested at /api/v1, nip05 at root; all
paths/schemas registered (openapi.json now 17 endpoints).
- main.rs: periodic GC of expired web challenges (bounds the in-memory store).
Adversarially reviewed (register_key authz + website identity-binding both
hold; no critical/high). 69 tests green. Deferred LOW: per-IP rate-limit on the
website routes lands with the router-assembly unit.
api/middleware.rs: Bearer access-token extraction; client_ip() trusts XFF only
behind config.trusted_proxies (fail-closed against spoofing).
api/auth.rs: extension_register (PoW-gated; derivation-rotation requires a
signature from the on-file key, no fingerprint hijack), check_restore (per-IP +
per-fingerprint rate limits, enumeration/timing-resistant), pow_challenge,
refresh_token (re-checks active session + revoke-then-issue rotation, race-safe),
logout, get_me, nostr_login (login-grade NIP-98, never creates a user, bound to
config public_api_url), nostr_link (JWT + state-change verify_signed_action,
fail-closed nonce stub) + DTOs + routes().
Wired into api/mod.rs + nested at /api/v1 in main.rs; all 8 paths registered in
ApiDoc so openapi.json covers the auth surface (was vacuous).
Drafted+hardened via draft->adversarial-review->revise workflow; independently
re-reviewed (no critical/high). 58 tests, all gates green.
Known LOW (tracked): concurrent first-registration of the same new pubkey_hash
can 500 instead of idempotent re-read; client-retryable.
- 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