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.
Wire the settings overlay into the live config path:
- AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) +
config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every
config read goes through it (state.cfg().field), so a runtime-safe operator edit is
seen on the next request. One Config type, no parallel path.
- Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build
every client/worker/router from the EFFECTIVE config. A persisted overlay that no
longer validates is ignored (boot env-only + loud log), never bricks. Env-only when
ADMIN_KEY_INTEGRITY_SECRET is unset.
- Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a
reference outlives the statement; integrity_secret returns owned String.
- Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep).
Behavior-preserving: with no operator_settings rows, effective == env config.
cargo check --all-targets clean; lib tests 256/0.
Stage 1 of the operator-editable config layer (additive; no behavior change yet):
- migration operator_settings: one MAC(ADMIN_KEY_INTEGRITY_SECRET) JSONB row per
section, FK to admin_keys, audited on write. Missing section = env-only.
- OperatorSettingRow model.
- SettingsOverlay + section overlays (landing/retention/restore) with secrets
EXCLUDED BY CONSTRUCTION; Config::apply_overlay merges onto the env-derived Config
then re-runs the SAME Config::validate boot uses (a bad edit yields the identical
error, nothing persists, the instance cannot be bricked).
- runtime_class() single source of truth (RuntimeSafe hot-swap vs RestartRequired).
- operator_settings storage: canonical-JSON MAC (stable across JSONB key reorder),
load_settings_overlay (fail-closed per section -> env fallback), put_setting_audited
(upsert + admin_audit append in one tx).
- sqlx json feature.
cargo check clean; 4 new unit tests pass. Next stages: ArcSwap AppState + ~59
read-site migration, GET/PUT /admin/config (+ restart_apply_mode), remaining sections.
The BTC/LTC balance path dials a configured primary (our Fulcrum) then randomized
public fallbacks. When the primary was unreachable, every request paid the full
10s CONNECT_TIMEOUT before falling back, so a down/overwhelmed primary meant a
~10s user-facing stall on every balance read.
- CONNECT_TIMEOUT 10s -> 4s (primary is a warm mesh node; fallbacks answer in ~1s)
- Add a per-primary circuit breaker: after 2 consecutive primary failures, skip
the primary for a 30s cooldown and go straight to fallbacks, so a down primary
costs a stall at most once per cooldown instead of on every request. Success
clears it immediately. Only skips the primary when a fallback exists.
Protects the small such-nodes-one Fulcrum box: if it is overwhelmed or drops, the
backend fails over to public electrum in ~1s instead of stalling users. 22/22
electrum unit tests pass (incl. a new breaker state-machine test).
- config: grin is a supported tip chain (voucher model verified against the node),
so FEATURE_TIPS with only grin enabled no longer fails closed at boot.
- social_tips: revert_sweep_on_reorg now preserves sweep_txid instead of nulling
it. Address-scan chains (btc/ltc/xmr/wow) overwrite it via confirm_sweep_onchain
on the next cycle, so preserving the orphaned value is harmless; grin needs the
kernel excess to re-date a re-mined sweep via get_kernel, so nulling it stranded
reorged grin tips in 'claiming'. Test updated to assert preservation.
- lws import_account: retry the birthday backfill rescan (3 attempts, backoff) so
a transient failure no longer strands a new account at the chain tip. The
already-registered Some(_) no-op is unchanged, so this cannot reintroduce the
balance-reset bug.
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:[]}).
Grin as a new asset arm in the tips machinery (no grin-lws change, no custodial
grin_transactions/grin_outputs port):
- GrinClient::get_outputs([commit]) (funding: present+height=confirmed) +
get_kernel(excess) (sweep spend-height); reached via state.chains.grin.
- create-gating (supported_tip_asset grin, validate 66-hex commitment,
confirmations_for_asset grin=10), grin_commitment column (migration).
- confirmation worker grin arm: get_outputs present -> confs; amount AUTO-VERIFY
with warn (voucher hides amount; not a theft vector; matches legacy).
- sweep reconciler grin arm: get_outputs-absent + get_kernel height at depth 10 ->
settle; absence-without-kernel -> no settle (recoverable, never missettle);
reorg revert only on commitment reappearing; probe error never settles/reverts.
- 5 grin tests + pure-helper unit tests; 251 lib + 24 tips green.
Amount-verification note: the voucher's bulletproof rewind nonce is random +
untransmitted, so it can't be rewound today; a clean non-custodial fast-follow is
to have the client send the per-voucher rewind nonce and verify via rangeproof
rewind (nonce reveals amount only, not spend authority).
Escrow settlement, audited guards ported verbatim:
- mark_tip_claiming: status IN('pending','claiming') AND confs>=required AND
funding_amount_verified AND sweep_confirmed_at IS NULL (double-claim guard).
- confirm_tip_sweep: RECORD-not-settle, first-recorder-wins (COALESCE +
sweep_txid IS NULL OR =$3) — RBF-steal safe; reconciler owns claiming->claimed.
- confirm_sweep_onchain: SOLE writer of 'claimed', one-way status='claiming' AND
sweep_confirmed_at IS NULL lock; settles only after on-chain sweep confirms to
per-asset depth (btc/ltc 1, wow 4, xmr 10).
- clawback: 5-status set incl 'cancelled', blocked once sweep_confirmed_at set.
- revert_sweep_on_reorg: only from 'claimed', re-opens to 'claiming' + clears txid.
- sweep_reconciler: address-history-driven (not recorded txid), pick_winner
(earliest height, lexi-smallest tie), pg advisory locks on a pinned conn for
single-instance safety; reorg pass reverts only on affirmative vanish, never on
probe error. DMs stubbed, exactly-once stamps kept.
- POST claim/confirm-sweep/clawback endpoints + openapi.
- 16 integration + 12 reconciler unit tests; build clean; SocialTipRow dead_code
allow removed (all fields now read).
- attach_funding_to_tip: atomic guarded UPDATE (COALESCE + CASE), idempotent on
same txid, 400 on a different txid; non-drafts land pending_confirmation so the
verifier (not the caller) owns the ->pending transition.
- worker (src/tips/confirmation.rs): XMR/WOW confirmation counting via LWS;
amount verification for BTC/LTC (Electrum full history, confirmed-only) +
XMR/WOW (classify_funding_amount, separate-then-subtract net). Verified->pending,
Short->funding_mismatch; Unfunded/Drained/LwsError never mutate status (outage-
safe). Spawned behind FEATURE_TIPS on a 60s loop w/ catch_unwind guard.
- POST /tips/social/:id/attach-funding endpoint + openapi.
- 5 DB tests + 6 classify unit tests; build clean.