Premium could invoice through exactly one processor, so an operator
running BTCPay for BTC, LTC and Grin could not also take Monero or
Wownero: BTCPay has no Wownero support, and this operator runs Monero
through its own checkout rather than the BTCPay plugin.
xmrcheckout and wowcheckout implement the same two Greenfield invoice
routes, accept the same `token` auth and report the same statuses, so
the existing adapter serves them unchanged. What was missing was more
than one of them. Each is now an optional rail with its own URL, store
and key (PAYMENT_XMRCHECKOUT_* and PAYMENT_WOWCHECKOUT_*), all three
values or none, and a half-configured rail refuses to start and names
itself rather than advertising a coin that cannot be paid.
The invoice row already recorded which provider minted it; activation
now polls that one, never whichever is primary today. An invoice whose
rail was switched off is refused and left unconsumed, so turning the
rail back on lets the user redeem what they paid. A client that names
a rail the instance does not offer is refused rather than handed an
invoice in a different coin; a client that names none gets the
primary, which is how every client before this behaved.
Registration is untouched and keeps the primary. /capabilities lists
the rails and their assets, primary first, from config alone, so it
never depends on a processor being reachable. PAYMENT_ASSETS labels
the primary. Checkout rails also get an exact confirmationsRequired,
which BTCPay Server is not sent since it only has speedPolicy buckets.
`tracing_subscriber::fmt::init()` reads RUST_LOG when it is called, and it
was called one line before `dotenvy::dotenv()`. The systemd unit sets no
EnvironmentFile on purpose, so RUST_LOG exists only in .env: the filter was
built with no directive and fell back to its default, ERROR.
Production has logged nothing but errors since the 2026-09-20 build. On
smirk-backend, `configuration loaded` has appeared zero times since, and
the 01:53 restart today produced no application output at all. It hides
only in production, since a developer usually has RUST_LOG exported
already.
This also defeats the legacy sign-in migration's designed verification.
`LegacyDirectory::connect` deliberately connects at boot rather than per
request so an operator reads "enabled" or "staying disabled" once, in the
log. Both are `info!`, and the failure branch is a `warn!`, so all three
were being dropped exactly when we need them.
The guard asserts the ordering property and locates the two calls by
crate rather than by line, and carries a test proving it fails on the
inverted order.
Also unbreak the test targets, which have not compiled on main: `AppState`
gained `legacy` and `NewSocialTip` gained `recipient_user_id` without
their fixtures following. Both take the closed value, off and public.
Electrum's get_history is txid, height and fee, and we passed exactly that
through. The wallet read total_received and total_sent off those rows, got
undefined for both, defaulted them to zero, and rendered every Bitcoin and
Litecoin row as a confident "sent 0". Silent, so it read as an empty wallet
rather than as a bug.
The amounts come from the verbose transaction, and the exactness helpers
this needed already existed on VerboseTransaction and simply had no caller:
total_received_at is unambiguous from the outputs, and total_sent_from
answers only when the server resolved EVERY input's prevout, so a server
that resolves none leaves the sent side unknown rather than reporting a
partial figure that looks exact.
Best effort throughout. A transaction that cannot be fetched keeps null, not
zero, because the client distinguishes "not determined" from "nothing moved"
and one slow upstream must not fail the whole history request. Resolution is
bounded to the newest rows: each one costs a transaction fetch, so an
unbounded history would turn a single request into hundreds. Rows past the
bound arrive without amounts and say so.
The multi-address path resolves each distinct txid once and computes against
the whole address set, so a transfer between two of the user's own addresses
nets out instead of reading as a payment to a stranger.
openapi.json is regenerated, not edited.
A targeted tip is addressed to a Smirk handle rather than to whoever holds a
share URL. The v3 port shipped public-only and the schema said so: there was
no recipient column anywhere, so create_social_tip rejected is_public=false
outright and the two inbox endpoints returned an empty list unconditionally.
This is the missing half. The migration adds recipient_user_id plus two
CHECKs that keep each kind of tip well formed: a targeted tip must name its
recipient, and must carry the ciphertext, because with no URL fragment that
ciphertext is the only way to claim it. Without the second one a draft could
be created that looks healthy and is permanently unclaimable. Both are
satisfied by every existing row, which is public, so nothing is backfilled
or rewritten.
is_public is derived from the recipient in the INSERT rather than passed
alongside it. Two independent sources for "who may claim this" is how a tip
ends up addressed to one person and claimable by anyone.
Claiming was already guarded by is_public = TRUE, so no targeted tip could
be claimed at all; that becomes "public, or the caller is the recipient".
The claimable query repeats exactly the conditions the claim call enforces,
so the inbox cannot show a Claim button the backend then refuses.
It is off by default and separately from FEATURE_TIPS, because it is a
distinct surface: an inbox, and a claim authorised by identity rather than
by holding a secret. It also needs the migration to have run, so an instance
must opt in rather than acquire it by upgrading. The capability is
advertised only when tipping is also on, since a targeted tip is still a
tip, and recipient resolution checks the flag before anything else: a
backend that cannot serve this must refuse, never quietly fall back to
creating a public tip from a targeted request, which would hand a tip meant
for one person to anyone with the URL.
The config fixture moves to Config::test_default so a test of what a flag
does can live next to the flag.
A targeted XMR or WOW tip has to be sealed to a key that is not the spend
key. That scalar is the spend authority and an ed25519 signing oracle the
dapp bridge already exposes, and as a raw reduced scalar it does not pair
with age's SHA512(seed)[0..32] derivation anyway.
Grin got its second row for free, because a slatepack address is bech32 and
a signing key is hex, so the value alone says which row it belongs in. An
encryption subkey has no such tell: it is 32 opaque bytes that look exactly
like the identity key, so it needs an explicit key_type or the upsert on
(user_id, asset, key_type) overwrites the key sign-in verifies against.
So RegisterKeyRequest gains an optional key_type. Omitted, every shipped
wallet keeps the value-based routing it relies on today. Supplied, it must
name one of primary, enc or slatepack; anything else is a 400 rather than a
row nothing will ever read. One invariant outranks the request: a bech32
Grin address is still refused from the primary row, which is the outage
ddbba30 fixed.
The lookup response gains tip_keys alongside public_keys, applying the
per-asset target server-side. For BTC, LTC and Grin the two maps agree. For
XMR and WOW tip_keys carries the enc row or nothing at all, so a wallet too
old to have published one has no tip target, which is the right answer
rather than a fallback to the spend key.
Sign-in resolves the proven key against this instance first, then the BTC
pubkey_hash fallback. When both miss and the legacy directory is configured,
it now asks whether the legacy instance knows that key, and adopts the
identity if so. With the directory off, which is the default, the lookup
resolves nothing and the refusal is exactly what it was.
The import creates a NEW row rather than attaching to an existing one, and
that is the point. Fourteen of the sixteen legacy handles are also held here
by rows we cannot prove belong to the same person, so adopting by name could
hand this person's keys to someone else's account. The keys just proven are
the identity; a handle is carried over only when it is free here, and a key
another account already holds is skipped so one key can never resolve to two
users.
pubkey_hash is not carried either: legacy computes a plain sha256 where this
instance stores an HMAC, so the value would be meaningless here, and a
collision would be with a row we cannot identify.
Migrating on sign-in rather than in a batch means every import is gated on a
signature the user just produced, and the population drains on its own as
people arrive. Nothing is copied for a user who never returns.
Not yet exercised against a real legacy database: that needs the read-only
credential in place, so this stays closed until it is.
The two deployments never shared a database. The legacy instance holds 134
identities whose keys v3 has never seen, so pointing the website at v3 would
refuse all of them: nothing in smirk_v3_db can match a key that exists only
in smirk_db.
Neither obvious join key survives contact with the data. users.pubkey_hash
is a plain sha256 on legacy and an HMAC under identity_pepper on v3, so the
columns are not comparable at all. Usernames are actively dangerous: 14 of
the 16 handles exist in both databases while only 6 users share a raw key,
so merging by name would hand one person's keys to someone else's account.
What is reliable is the public key a signature just proved control of. This
looks up that key and nothing else, so it can only answer about a key the
caller could already sign for.
The capability is closed by default. With no LEGACY_DATABASE_URL nothing
connects, every lookup answers "not found", and sign-in behaves exactly as
it does now. A URL that points nowhere degrades to disabled rather than
failing the boot, because a migration convenience must never be able to stop
the service from serving. Nothing calls it yet.
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.
website_verify resolved every asset through get_user_by_pubkey_hash, but
users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC
could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the
failure was indistinguishable from an unregistered user, so the page told people
to register with an extension they already had.
That did not surface while smirk.cash pointed at the legacy backend, which
resolves by asset key against its own database. It becomes the whole bug the
moment the site is repointed here, so it has to land with that change, not after.
get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend,
84 arrived through migrate-legacy, which imported identity without keys, and
would otherwise stop resolving entirely.
The rejection message is unchanged and still says nothing about whether an
account exists.
/wallet/grin/height called grin_client directly, skipping the grin_tip helper
declared immediately above it, which prefers grin-lws and falls back to
grin-wallet. It was the only Grin endpoint that could not be served by grin-lws
alone.
With GRIN_OWNER_API_URL empty, as it is in production, this failed while scan and
broadcast worked, so Grin read as half broken rather than as one path being
unwired. Scan already prefers grin-lws and broadcast goes to the node's foreign
API, so nothing else depended on the wallet.
The register, login and link handlers rejected any proof signed more than 30
seconds from server time, measured against a clock the client owns and we do
not. Ordinary desktop drift exceeds that, so a user at a new location failed
every attempt with 'Invalid Nostr proof': a cryptographic-sounding message that
names nothing the user can act on.
The window is now 120 seconds and symmetric, so a fast clock is bounded like a
slow one, and the message points at the device clock. Erasure moves with it:
left at 30 it would have let a device register and log in while silently unable
to delete or export its own data, which is harder to diagnose than a uniform
failure.
120 seconds is a mitigation, not a cure. Nobody has measured the reported
machine's offset, and whole-hour errors from a wrong timezone or an RTC kept in
local time are common. The durable fix is to return the server's time with the
challenge so an honest client stamps from it; the client already accepts an
injected timestamp.
sum_mempool_received computed received minus sent, which reads as a net figure
but double-counts the spend: every mempool row's spent_outputs already go to the
wallet, which subtracts them after verifying each key image.
On a send with change, sent exceeds received because the whole input is consumed
and part comes back, so the subtraction underflowed and saturated to zero. The
change output then appeared nowhere until it confirmed. Reported 2026-09-08:
sending 0.3 from a 7 XMR output left 6.7 XMR invisible for a block, which to the
person holding the wallet looks like money that has gone missing.
Pure receives were unaffected, their sent being zero, which is why the mempool
work looked complete when it was tested against incoming payments.
The existing test encoded the defect as intended behaviour, annotating a
change-shaped row as 'net 0 via saturating_sub'. Corrected, plus a regression
case for the reported amounts and a property test that a spend can never shrink
the pending total.
register_account called add_account unconditionally when provisioning was off,
which is production's default. monero-lws answers a duplicate with
account_exists as an HTTP 500, the backend maps that to a node error, and the
user is told NODE_UNAVAILABLE. A routine re-registration was therefore
indistinguishable from the node being down.
Reported 2026-09-08: a tip failed with an upstream error while both LWS
instances were active and had logged no warnings in 24 hours. Nothing was wrong
with the node or the account.
The provisioning path already checked existence first and documented why. That
check now guards both paths, so registration is idempotent and the two branches
cannot drift apart again.
The tiers were compile-time constants with a comment noting that operator-tunable
config could follow. This is that, with the constants as defaults, so an
operator who sets nothing sees exactly the previous behaviour.
The burst is what needed a knob, not the rate. One wallet session spends FIVE
strict tokens: /auth/check-restore, /auth/pow-challenge, the two nostr register
calls, and /auth/me. A 20-token bucket therefore holds four onboardings, so four
people unlocking behind one NAT (an office, a household, a VPN exit) exhaust it
and the fifth is throttled with nothing on screen to explain it. Measured
2026-08-24 against production while diagnosing an unrelated flake, where
/auth/check-restore answered 429 RATE_LIMITED.
Raising the burst leaves the sustained rate untouched, and the sustained rate is
the actual abuse control: a flood still converges on period_ms. That is why this
is a burst knob rather than an IP allowlist. An allowlist would remove abuse
protection entirely for the listed address while doing nothing for the shared-NAT
users who are the ones actually hitting this.
Zero is rejected at startup for both period and burst. It reads like
'unlimited' and means the opposite: no token is ever issued and every request is
refused.
NOT DEPLOYED. Production changes go through such-fleet with a --check/--diff
pass; this is the reviewable change, not the rollout.
New PRICES_PROVIDER=neroswap arm fetches every rate in one call from the
canonical oracle at prices.neroswap.com (Kraken majors; WOW from Nonlogs
plus volume-weighted CexSwap), so every Such Software product quotes the
same WOW number. GRIN, which the oracle doesn't publish, falls back to
the direct nonlogs leg converted via the oracle's BTC/USD. coingecko and
kraken arms unchanged and still selectable.
CI gates on cargo fmt --all -- --check and it was already failing on main before
this branch: rustfmt 1.9.0 reformats three call chains that an older rustfmt
left alone. Same housekeeping as 586d29e.
utoipa derives the /auth/extension operation description and the
rotation_signature / seed_fingerprint property docs from the doc comments, which
changed when the rotation proof became instance- and target-bound. CI has an
openapi-drift job that diffs a fresh gen_openapi against the committed spec, so
this keeps that gate green.
Fixes every confirmed backend finding from the v0.3.0 pre-ship review.
Identity (the two that corrupt accounts):
- nostr_register creates a row with pubkey_hash NULL, so that wallet's later
/auth/extension call matched nothing, fell through the rotation path (which no
client ever satisfies, since nothing sends rotation_signature) and minted a
SECOND account. A fingerprint match on a NULL-pubkey_hash row now adopts the
submitted pubkey_hash onto that row, but only when the row's on-file BTC key
equals the submitted one, so a bare fingerprint (which check_restore discloses,
and is therefore not a secret) still cannot claim an account.
- The fingerprint-ownership check no longer short-circuits on returning_by_pubkey.
It was skipped for returning users, so a fingerprint owned by another row got
backfilled, violated the UNIQUE index, and 500'd that account on every
subsequent login.
- The rotation proof is verified over a distinct, instance- and target-bound
message. It shared the login message, so a captured login signature was a
replayable rotation proof against another instance.
Abuse control:
- The rate limiters key on the real client IP, honouring X-Forwarded-For only
from a trusted proxy. Behind the TLS terminator every request looked like
127.0.0.1, so the whole internet shared one bucket and ~3 req/s 429'd every
wallet unlock globally.
- A PoW solution is single-use instead of replayable for the challenge TTL.
- Delegated /verify is opt-in and rate-limited; it was an unauthenticated
amplifier into Electrum, LWS and a full-genesis grin scan.
- A grin scan with no start_height gets the same depth policy and PoW price as
one that passes a height, instead of an unbounded free scan.
Retention and logging:
- The retention sweep is implemented AND scheduled. RETENTION_* was advertised
and inert, which made the privacy policy's retention promise untrue. Zero
means keep forever, and each statement is batched so a backlog drains over
ticks rather than holding row locks.
- login_events.ip_hash is no longer written, and legacy values are scrubbed:
nothing ever read it, so it was retention without a purpose.
- Tracing spans log user_id only, not the raw npub and username.
- secret-scan.sh matches the coverage its comment claims.
Also fixes the test suite, which did not compile on this commit: NewSession
gained an id in c64cd9b and two call sites were never updated. mint_session now
binds one session_id for both the token and the row, since a different id there
would silently recreate the bug c64cd9b fixed.
Deploying the previous version showed nip05_domain = api.smirk.cash, but handles
are name@smirk.cash: that is what v0.2 used, what the wallet derived by stripping
'api.' client-side, and what the kind-0 profiles already on relays carry. Since
the wallet now PREFERS this field, shipping it would have silently re-rendered
every handle as @api.smirk.cash and disagreed with the published profiles.
Caught by checking the deployed output rather than assuming the change was inert.
Default is now PUBLIC_API_URL's host with a leading 'api.' removed, preserving
the established behaviour, and NIP05_DOMAIN overrides it for operators whose
well-known lives elsewhere. Still explicit where it matters; the default just
stops being a regression.
public-free and paid-relay both set SERVER_PORT=8081, which is the ADMIN_BIND
default (config.rs). An operator following either example with the admin plane
enabled gets 'Address already in use' at startup and no obvious cause. Moves
ADMIN_BIND rather than SERVER_PORT so the nginx config in the example still
matches.
The sid revocation check added in e2589d4 rejected EVERY authenticated request
with 401. issue_session generated session_id = Uuid::new_v4(), minted the access
token with it as sid, then called create_session WITHOUT passing it, so Postgres
assigned the row a different primary key. is_session_live(sid) therefore never
matched and the liveness check failed closed on every call.
Both halves were individually correct, which is why unit tests did not catch it:
303 backend tests passed throughout. The e2e suite caught it, as a wallet that
authenticated successfully and then showed 0 for every asset.
NewSession now carries the id explicitly and create_session binds it, at all
three call sites (extension/website login and the refresh rotation, which shares
the helper).
Verified: fiat-balance e2e goes from all-zero to $108.21.
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 wallet cannot derive the handle domain; guessing by stripping 'api.' only
works for a smirk.cash-shaped deployment. The host of PUBLIC_API_URL is the
answer, so state it.
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.
A grin kernel excess is also a 66-hex Pedersen point, so validate_hex(66) cannot distinguish it from an output commitment; a kernel excess never matches a rewind scan and yields a silent not_found. A send slate also carries the sender's change output. tx_ref MUST be the recipient's received (non-change) output commitment — document the footgun so the payer/frontend sets it correctly.
verify_access_token was a pure signature + expiry check, so an access token could
not be revoked. Sign-out and logout revoked the DB session row, and an erasure
request revoked all of them, but the bearer token kept working for the rest of its
24h life on every authenticated route. The refresh token has always carried a sid
for exactly this; the access token, which actually guards the API, did not.
Adds sid to AccessTokenClaims and checks it against the sessions table on each
authenticated request (one indexed primary-key lookup).
sid is Option so tokens minted before this deploy still verify rather than logging
every user out mid-session; they simply remain unrevocable until they expire,
which is the pre-existing behaviour.
Wire the family="grin" arm of POST /api/v1/verify, replacing the stub.
Grin has no address and no txid, so the request identifies a payment by its output COMMITMENT (tx_ref) and the recipient's rewind_hash (view_key). scan_rewind_hash proves recipient ownership AND the true received amount in one scan (only the recipient's rewind_hash recognizes the commitment), then gates on grin's 10-confirmation depth via get_height.
No get_kernel: MW aggregation/cut-through leaves no on-chain join binding a kernel excess to a specific output, so a kernel-only check can't soundly gate amount or recipient. Sender is unenforceable (MW reveals none -> sender:null) and dest_address is ignored (no addresses); a missing view_key returns not_found, never a false verify, mirroring the cryptonote arm.
Adds grin_for resolver + grin_amount_outcome helper + unit tests. 8/8 verify tests pass, clippy and rustfmt clean.
default_relays_are_wss still referenced DEFAULT_RELAYS, removed when the constant was renamed INTEROP_RELAY_HINTS, which broke the `cargo test`/--tests build. Repoint it (interop_relay_hints_are_wss); behaviour unchanged.
relay_hints appended damus/nos.lol unconditionally, so every self-hosted instance
published a NIP-05 document pointing its own users at third-party relays, and a
correspondent would deliver there instead of to the operator's relay. An operator
running a private deployment had no way to stop it.
Now opt-in: NIP05_INTEROP_HINTS (default off) for the built-in interop pair, plus
NIP05_EXTRA_RELAY_HINTS for the operator's own list.
GET /premium/status gains write_allowlisted + can_post_general.
The wallet was re-deriving posting rights from write_policy + premium, which
cannot see RELAY_WRITE_ALLOWLIST_NPUBS. An allowlisted operator was shown
"posting needs a premium subscription" with the composer hidden, while the
relay's own admission check would have accepted the event. Compute the answer
server-side from the same RelayProvider the admission service consults so the
two cannot drift.
Add POST /api/v1/verify, the server side of moon.vote's delegated
settlement check: confirm a specific tx_ref paid at least the expected
base-unit amount to dest_address, confirmed to this backend's per-asset
depth.
- UTXO (btc/ltc): mirror ElectrumClient semantics; consult get_history
first to distinguish not_found from a node error, then attribute the
exact amount via VerboseTransaction::total_received_at.
- CryptoNote (xmr/wow): require a private view_key, scan via
get_address_txs, match tx_ref, gate on mempool/height and the daemon
confirmation depth (XMR 10, WOW 4).
- grin: deferred (reports not_found with a warn).
Business outcomes (incl. failures) are always 200 with a fixed reason
vocabulary; only infrastructure failures return non-2xx AppError so
moon.vote retries. Unauthenticated by design (nginx allow-list gates it);
no utoipa/openapi wiring (server-to-server, not in the wallet client).
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.
The published privacy policy states that IP addresses are retained only as a
salted one-way hash for rate limiting. That was true of the login-event path
but not of sessions.ip_address or audit_logs.ip_address, both of which stored
a raw client IP tied to user_id on every login.
Drop both columns and all writer/model code, so the schema enforces the policy
rather than relying on convention. The hashed rate-limit path (login_events,
restore_attempts) is unchanged and remains the only IP retention.
Also drops the `wallets` table, which was created in the initial schema and
never read or written: a latent user_id -> address / view_key store.
The operator forensic trail (admin_audit_logs, admin_sessions) deliberately
keeps its raw IP. Privileged-action forensics is a different threat model and
is not covered by the user-facing claim.
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.
- tools/secret-scan.sh now also flags nsec1 Nostr secret keys and
*_PEPPER/*_SALT/*INTEGRITY_SECRET hex assignments, the secret classes
this backend actually handles (previously only PEM/seed shapes).
- .gitignore: *.hex + admin-key* so an operator running inside the
checkout cannot commit the raw admin key (create-admin-wallet --out).
- PRIVACY.md pointing to the canonical smirk.cash/privacy, framed for
self-hosting operators as independent data controllers.
- 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.
- Keys tab crashed on render: api.keys() expected an array but the
backend returns {keys:[...]}; unwrap it (KeysTab maps over the array).
- Config save now sends only CHANGED fields, not the whole section, so
the backend stops spuriously flagging restarts and pinning env-sourced
values into the DB overlay.
- Config edit state survives a reload (saving one section no longer
discards unsaved edits in others); numeric inputs ignore empty/NaN
instead of silently coercing to 0.
- Mint and Revoke now surface errors instead of swallowing them.
Rebuilt admin-ui/dist.
A runtime-safe config PUT rebuilt the effective config from config_base +
the FULL persisted overlay and stored it, which live-applied any
restart-required section persisted by an earlier PUT (features/relay/
premium) while the boot-built clients still ran the old value — and
cleared restart_pending in the process. The exact desync RuntimeClass
exists to prevent.
Now the hot-swap applies ONLY the (all-runtime-safe) patch onto the
currently-running config, and restart_pending is computed honestly via a
shared helper (GET and PUT agree). Also reclassify registration.
require_payment as restart-required: it gates the boot-built payment
client (None unless require_payment||premium at boot), so hot-enabling it
would 500 every registration until restart. Regression tests for both.
The repo pins channel=stable (no rustfmt version), so CI now pulls
rustfmt 1.9.0, which reformats code last touched by an older rustfmt.
main itself failed `cargo fmt --check` under the current toolchain;
this is a formatting-only sweep across 24 files to make the fmt gate
green. No behavior change; tests + clippy -D warnings still pass.
Extends the operator settings overlay with five sections, all non-secret
by construction (api_key, ALTCHA hmac_key, peppers, and processor wiring
stay in env):
- registration: require_invite, gate_mode, require_payment + payment
amount/currency/confirmations/expires (runtime-safe: read per-request)
- pow: enabled, required, cost (runtime-safe)
- features: prices + provider/interval/currency, tips, nostr_identity,
grin_relay, and per-chain enablement (restart-required: boot workers)
- relay: enabled, mode, advertised_url, write_policy, inbound_pow_bits,
max_event_bytes, retention (restart-required; bind + allowlist excluded)
- premium: enabled, currency (restart-required; priced plans stay in env)
Every PUT re-runs Config::validate, so enabling a gate with incomplete
wiring (e.g. pow without a key, pay-to-register without a processor) is
rejected at save, never booted disarmed. Tests: identity round-trip
(proves read-field == write-field for every entry), a hard assert that
no secret can appear in the editable overlay, gate_mode typo rejection,
and pow-without-key fail-closed.
- admin.rs: make the config/invite JSON response structs pub (they are
returned from pub handlers; matches the already-pub AdminKeyView etc.)
- invite_codes/operator_settings: drop redundant reborrow (&mut *tx -> &mut tx)
- electrum: minimize a boolean (De Morgan, behavior-identical)
- relay: move the test module to end of file (items_after_test_module)
Pure lint hygiene surfaced by the current stable toolchain; no behavior
change. cargo clippy --all-targets -- -D warnings is now clean.
New console-drift job rebuilds admin-ui from the lockfile (npm ci) and
fails if the committed admin-ui/dist drifts. rust-embed bakes dist into
the binary, so a stale dist ships an old console; this mirrors the
openapi-drift gate. Pinned to Node 22 (the version that built the
committed bundle) so the byte-diff is deterministic.
The SPA issued GET /admin/auth/challenge but the route is POST (nonce
issuance is a write). Left as-is the login flow 405'd at step one. Fixed
the client verb and rebuilt dist.
Route-level test (tower oneshot, no DB) confirms GET /admin returns the
console HTML with text/html, and GET /admin/assets/<hash>.js returns the
built bundle with a JS content-type. Pairs with the embed test so both
'not built' and 'not wired' regressions fail CI.
Preact + Vite standalone admin-ui bundled into the server binary via
rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin
challenge); tabs cover instance status, admin keys, invite codes, and
the effective-config editor (live vs restart-required per field).
admin-ui/dist is committed because rust-embed reads it at compile time;
node_modules is gitignored (npm ci reproduces it). A lib test asserts
index.html and the /admin/ asset base are embedded so an unbuilt
console fails the build instead of an operator.
The console is served on the admin plane only (loopback bind), behind
the existing NIP-98 admin_guard + MAC allowlist.
- console section: restart_apply_mode (manual | auto), an operator setting (runtime-safe,
DB-overridable via /admin/config), env RESTART_APPLY_MODE default manual.
- Graceful shutdown: main serve now awaits SIGTERM/Ctrl-C OR an in-app shutdown signal
(AppState.shutdown Notify) via with_graceful_shutdown, draining in-flight requests.
- PUT /admin/config in `auto` mode self-restarts shortly after responding to apply a
restart-required change (systemd Restart=always brings it back with the new config);
`manual` just flags restart_pending. The console warns before an auto save.
Stage 3 complete: operator config is readable + editable through the real validated,
audited, precedence-based layer; runtime-safe edits apply live, infra edits restart.
cargo all-targets clean, lib tests pass.
The operator-editable config surface, on the effective-config layer:
- GET /admin/config: per-field effective value + the DB overlay (source=db) + per-section
version + runtime_class map + restart_pending (persisted-but-unapplied change).
- PUT /admin/config: a sparse patch is field-merged onto the persisted overlay, validated
with the SAME Config::validate boot uses (400 on invalid, nothing persists), then each
changed section is persisted audited + version-guarded (409 on a stale expected_version).
Runtime-safe edits HOT-SWAP the effective config atomically (arc-swap, live next request);
any restart-required field is persisted + flagged restart_pending (operator restarts to
apply). Records the editing admin key as updated_by.
- config_overlay: editable_overlay() (effective values as an overlay) + EDITABLE_FIELDS.
- operator_settings: optimistic-concurrency (expected_version) + settings_versions().
Secrets remain unreachable (overlays carry no secret fields). cargo all-targets clean,
lib 256/0. Next (3b): restart_apply_mode (manual|auto self-restart) + graceful shutdown.