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.