`@Volatile` in commonMain resolves to `kotlin.jvm.Volatile` by default,
which doesn't exist on iOS targets. Two new NIP-60 Cashu files use
`@Volatile` without an explicit import, breaking
`:quartz:compileKotlinIosSimulatorArm64`:
Bdhke.kt:577:6 Unresolved reference 'Volatile'.
MintApiSerializerWarmup.kt:74:6 Unresolved reference 'Volatile'.
Add `import kotlin.concurrent.Volatile` to both files. Same pattern as
6f1292bfc (Note.kt) — semantics unchanged on JVM/Android, now also
resolves on iOS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bdhke's allocation-free hot path used to expose `BdhkeScratchpad` as
an explicit parameter on every public function (`blind(secret, r, scratch)`,
`unblind(..., scratch)`, `verifyDleq(..., scratch)`, …). Every caller
in CashuMintOperations had to remember to allocate a scratchpad per
loop and thread it through.
Switch to a thread-local pool. New expect/actual:
internal expect fun bdhkeScratchpad(): BdhkeScratchpad
- jvmAndroid: ThreadLocal.withInitial { BdhkeScratchpad() }
- apple / linux: fresh allocation per call (no Cashu in prod)
Each public Bdhke function pulls the scratchpad internally with one
`val scratch = bdhkeScratchpad()` at the top. Every thread that ever
touches Bdhke gets one scratchpad allocated lazily on first use and
reuses it across every subsequent call on that thread — same JIT-bug
mitigation, much cleaner API.
Removes:
- 0-arg and N+1-arg overloads on blind / unblind / verifyDleq /
verifyDleqCarol / hashToCurveCompressed
- `scratch` parameter on private addRTimesA / unblindOne /
unblindAll
- All `val scratch = BdhkeScratchpad()` boilerplate in
CashuMintOperations.restore / meltToLightning / verifyTokenDleq /
checkStates / secretOutputsFor
Also strips the diagnostic Log.i("CashuTrace") / Log.i("BdhkeTrace")
lines added during the JIT-bug investigation — the at-most-once
warmup + ThreadLocal pooling should resolve the crash, and the
traces were polluting logcat at info level.
Nested calls (verifyDleqCarol → blind + addRTimesA + verifyDleq)
all grab the same thread-local scratchpad; the holder field sets
are disjoint by design so nested use is safe.
BdhkeTest still 17/17 green.
The previous warmup change introduced a startup crash: each account's
CashuWalletState.start() spawned a coroutine on Dispatchers.Default
that ran Bdhke.warmup() (32 blind+unblind cycles) AND
MintApiSerializerWarmup.warmup() (decode 32-element synthetic
RestoreResponseDto). With two accounts, that's 128 BDHKE calls + 64
deserializations all in flight at once — far worse JIT pressure than
the original problem, since the warmup explicitly tries to make
methods hot.
The trace showed it clearly — multiple unblind/blind logs from
different threads interleaving mid-call ("unblind: parseAffinePointInto k"
appearing without a preceding "unblind: parseAffinePointInto cTick"
from the same logical call). ART's optimizer then crashed on the
flood.
Add a @Volatile flag to both warmups. First caller does the work;
every subsequent caller returns immediately. Plain volatile (not
atomic CAS or synchronized) because:
- The race window is tiny (microseconds between read and write)
- Two extra 32-cycle warmups in the worst case isn't a correctness
or performance issue
- Stays commonMain-portable without expect/actual or atomicfu
The trace from the last crash showed the restore unblind loop running
378 iterations per batch — minibits returns one signature per
(amount, bTick) we probed (63 denominations per counter × 6 unique
counters). Most iterations hit the recoveredCounters.add(...) continue
path. That's 60× more loop pressure than needed AND it's exactly the
kind of wide hot loop the ART JIT keeps trying to compile.
Changes:
1. Dedup BEFORE the unblind loop. Walk response.signatures once,
keep one (counter -> first signature) entry per unique counter
into a LinkedHashMap, then iterate that. Restore's per-batch
inner loop drops from 378 to ~6 iterations and stops being a
JIT compile target.
2. Pre-warm hot paths during CashuWalletState.start() on a background
coroutine, so the synchronous JIT compile pause happens at app
init (low pressure, no user waiting) instead of mid-restore where
we observed a 13ms gap on the first Bdhke.blind of a new batch
followed by a crash:
- Bdhke.warmup() runs 32 blind+unblind cycles with synthetic
keypair so the secp256k1 / hashToCurve / addPoints / toCompressed
hot path tier-1 compiles once.
- MintApiSerializerWarmup.warmup() decodes a synthetic 32-element
RestoreResponseDto (with nested DleqProofDtos) plus a SwapResponseDto.
kotlinx.serialization's generated decoder for BlindSignatureDto
becomes JIT-compiled during init — that decoder is otherwise
the heaviest allocation density we hit (~1.5k data-class
instances + ~5k Strings per real restore response, ART optimizer
bait on Android 15+).
If the JIT crash recurs, the next move is hand-rolling the JSON
parser for RestoreResponseDto / SwapResponseDto to bypass
kotlinx.serialization on the hot path entirely.
The previous round of scratchpadding still left two Fe4 allocations
per call inside toCompressed — the final point→bytes step at the
tail of every blind / unblind / addRTimesA. Across an NUT-09 restore
sweep that's ~250+ Fe4 allocations the ART JIT optimizer still gets
to chew on. After ART inlines toCompressed back into the outer
crypto bodies, those allocations end up in the same hot method
bodies the scratchpad was supposed to clear out.
Adds a [toCompressedScratch] variant that reuses two new holders on
[BdhkeScratchpad], and routes every hot-path caller through it
(unblind, blind, addRTimesA, hashToCurveCompressed). The plain
[toCompressed] stays for the cold paths (sign, signFull, others
only used by tests).
Also adds verbose BdhkeTrace / CashuTrace markers in:
- Bdhke.blind: secKeyVerify → hashToCurveInto → mulG → addPoints → toCompressedScratch
- Bdhke.unblind: parseAffinePoint cTick → parseAffinePoint k → computeNegRk → addPoints → toCompressedScratch
- restore inner loop: per-counter (CashuDeterministic → Bdhke.blind → build dtos), HTTP, per-signature unblind begin/end
So when the next JIT crash happens (if it does), the last log line
points at the exact sub-step ART was compiling. Logs are at INFO
level; cheap enough to leave on while diagnosing, easy to strip
afterward.
Final pass on the Bdhke JIT-crash mitigation. Three more Bdhke
functions on the production hot path still allocated per-call holders
and could trigger the Android 15+ ART JIT escape-analysis crash:
verifyDleq ~17 short-lived Fe4 / MutablePoint per call
addRTimesA ~6 short-lived holders per call
hashToCurveCompressed ~3 per iter (plus ~3 inside hashToCurve)
verifyDleqCarol orchestrates blind + addRTimesA + verifyDleq, so the
Carol path (used by every inbound nutzap / cashu-token redeem) was
allocating ~40 short-lived holders per proof. Same shape that crashes
the JIT in swap output handling.
Adds scratchpadded overloads:
verifyDleq(..., scratch)
verifyDleqCarol(..., scratch)
hashToCurveCompressed(x, scratch)
addRTimesA's scratchpad variant is the only signature (it's private)
Plus three helper -Into variants for the negate / toUncompressedOrNull /
compressedToUncompressed steps verifyDleq inlines.
Wired:
verifyTokenDleq → one scratch per call, reused across every per-proof
Carol verification
checkStates → one scratch per call, reused across the per-proof
hashToCurveCompressed pre-image computation
Tests: 17/17 BdhkeTest still pass.
After this commit, every Bdhke function on the production hot path
runs allocation-free. Functions only used by tests / mint emulation
(sign, signFull, verify) are intentionally not refactored — they're
never invoked at runtime in the app. The remaining 2-allocation
helpers (toCompressed, toCompressedOrNull) are well below the JIT
crash threshold.
Latest crash log showed no CashuTrace markers between the mint HTTP
response and the SIGSEGV — the tracing was only around swapToLocked
and unblindAll. The crash is in the NUT-09 restore loop, which calls
Bdhke.blind hundreds of times per batch (one per counter slot) and
each blind call still allocated ~5 short-lived Fe4 / MutablePoint
holders inside hashToCurve + the blind step itself.
Same JIT-bug shape as unblind had. Same fix: pre-allocate the holders
inside BdhkeScratchpad, pass it to blind / hashToCurveInto so the
methods write through caller-owned holders instead of allocating
fresh ones. Adds 6 fields to the scratchpad (kept distinct from the
unblind ones so future nesting can't silently overwrite live state).
Wired:
- CashuMintOperations.restore: one batchScratch per HTTP batch,
reused across all per-counter Bdhke.blind calls in that batch.
- secretOutputsFor: one scratch per call, reused across the
mapIndexed loop.
- Pre-existing scratchpads in unblindAll, melt-change loop, NUT-09
restore unblind loop unchanged.
Added a `restore: batch counter=N size=M denoms=K` trace line so
the next failure (if any) points at restore unambiguously.
Tests: 17/17 in BdhkeTest still pass (spec vector + round-trips).
The earlier "split into smaller methods" mitigation didn't hold —
ART's JIT inliner re-merged them at compile time and the same
SIGSEGV at 0x48 in "Jit thread pool" still hit on Android 15+. The
crash was visible in NUT-09 restore (hundreds of unblinds in a
loop), in zap-send, and in send-token.
Root cause is ART 15+'s escape-analysis scalarization pass choking
on the allocation density inside Bdhke.unblind — about 10 short-
lived Fe4 / MutablePoint holders per call. Splitting doesn't help
because once the JIT inlines, the holders are back in one body.
This commit eliminates the allocations entirely. Bdhke.unblind
gains an overload that takes a [BdhkeScratchpad] holding pre-
allocated holders; the internal helpers (parseAffinePointInto,
computeNegRkInto) write through them instead of constructing new
ones. The JIT sees objects coming from outside the method, marks
them as escaping, and the buggy scalarization pass never runs.
unblindAll, the melt-change loop, and the NUT-09 restore loop each
allocate ONE scratchpad and reuse it across every iteration —
1 allocation per loop instead of ~10 per output. Big perf win for
restore on top of dodging the crash.
The scratchpad-less unblind(blindSignature, r, mintPubKey) overload
still exists for tests and one-off callers; it just delegates to
the scratchpad variant with a fresh allocation.
If this still crashes, the trace logging now points at the
swap-level frames (swapToLocked / fetchKeyset / POST swap / swap
response / unblindAll begin / end). If it survives, the JIT had
nothing left in the hot path to choke on and the underlying ART
bug is no longer reachable from our code.
CashuTrace showed the Android 15+ JIT compiler crash (SIGSEGV 0x48
in "Jit thread pool") firing while ART was compiling Bdhke.unblind:
unblindOne[0/2] begin amount=2
unblindOne: Bdhke.unblind begin
>>> Fatal signal 11 (SIGSEGV) <<< in Jit thread pool
unblindOne: Bdhke.unblind end ← app thread keeps running
unblindOne[0/2] end
unblindOne[1/2] begin amount=8
unblindOne: Bdhke.unblind begin
unblindOne: Bdhke.unblind end
unblindOne[1/2] end
[…300ms of other work, then process death tombstone…]
The unblind itself runs fine to completion (twice). The crash is the
JIT compiler thread choking on Bdhke.unblind's bytecode — specifically
the escape-analysis pass on the 10 short-lived Fe4 / MutablePoint
allocations packed into one ~30-line method body. ART decides the
process is unstable a few hundred ms later and tears it down.
Split the body into three: an orchestrator + parseAffinePoint() +
computeNegRk(). Same semantics, three smaller bytecode bodies for
the JIT to compile, each well under the threshold that triggers the
bad optimizer path.
If this still crashes, the trace will tell us which of the three
methods the JIT was compiling and we move to the next mitigation
(reusable per-thread buffer pools to drop allocations further).
CashuPreview parses tokens locally via CachedCashuParser — no network,
no link unfurl, just CBOR decode of the cashuB string and a small
card. The canPreview = false gate is there to suppress network
previews (images, link unfurls, lightning invoice lookups); cashu
tokens have no such cost.
Suppressing it was the reason a cashuB pasted into a DM rendered as
a wall of base64 even though the same token in the Redeem button
worked fine. Flip the no-preview branch to render the same CashuPreview.
Also add CashuTrace logging around the entire zap path (sendNutzap,
swapToLocked, unblindAll, unblindOne, secretOutputsFor, Bdhke.unblind,
signer.sign + publish for nutzap/keep/delete/history events). The
JIT crash on Android 15+ keeps surfacing in different code paths
even after the DLEQ-on-our-outputs skip; the trace lets us see the
last frame before the SIGSEGV so we can target the right hot spot.
"Mint error HTTP 400: outputs already signed" started hitting every
send-token / send-LN after the wallet had crashed once. Root cause:
AccountSettings.reserveCashuCounters wrote the counter advance to
the same MutableStateFlow that drives the global settings save —
debounced by 1000 ms before the disk write fires.
The race window is exactly the time between "we ask the mint to
sign" and "the mint replies": ~200 ms. Any crash in that window
(ART JIT crash on Android 15+, signer dialog dismiss, OOM) loses
the counter advance even though the mint has already persisted its
side. Next reservation pulls the same slot, derives the same
deterministic blinded message, mint rejects with 10002.
Move the counter to a dedicated CashuPreferences SharedPreferences
file per account, written with `commit = true` so every reserve()
is durable BEFORE returning. AccountSettings.reserveCashuCounters
now delegates; a one-time migration on first read seeds the new
store from the legacy cashuKeysetCounters map so users on the old
build don't reset to zero. Plain (non-encrypted) prefs because
counters aren't secret — they don't carry value and aren't the seed.
Per Vitor's suggestion: the file is sized for the broader "Cashu
state that needs its own store" idea; today it only holds counters,
but the structure is in place for the kind:17375 / kind:10019 backups
to move out of the debounced settings path too if we later want.
Android 15 ART crashes (SIGSEGV at 0x48 in "Jit thread pool") when
the post-swap unblind loop runs Bdhke.verifyDleq once per signed
denomination in tight sequence. The pure-Kotlin elliptic curve
math allocates many short-lived MutablePoint / Fe4 holder objects;
ART 15+'s JIT compiler hits a bug compiling that pattern under load
and takes the whole process down right after a swap (or mint, or
swap-to-locked) returns. User reported it on auto-redeem first; now
hits send-token too, exactly 3 ms after the swap response — the
unblind loop barely started before the JIT crashed.
unblindOne / unblindAll only ever processes outputs WE asked the
mint to sign. A malicious mint can just refuse the request or
silently substitute a key — both are caught at next-spend when the
mint rejects our proofs, so the pre-emptive DLEQ check buys "fail
fast" vs. "fail at next op", not actual security. CDK and nutshell
wallets skip this check for the same reason.
Drop the DLEQ block from unblindOne. Third-party proof verification
(incoming cashu tokens, nutzap redeems) goes through the separate
verifyTokenDleq / verifyDleqCarol path and is unchanged — that's
where the untrust boundary actually is.
Reworks the add-cashu-wallet mint-discovery flow per UI feedback.
Was: two surfaces — the mint URL text field + a separate "Browse"
button that opened a bottom sheet (MintPickerSheet) listing
recommended mints. Each row showed two chips ("X from people you
follow" + "Y recommendations") which read as a count but didn't say
*who*.
Now: one surface. The Browse button + the bottom sheet are gone.
The autocomplete under the URL field always shows the directory:
"Popular mints" header when the field is empty (the old Browse
content, ranked by follows-then-total), "Matching mints" header
when typed. Both states render the same MintDirectoryRow.
MintDirectoryRow replaces the two chips with a single line —
"Recommended by [avatar gallery of up to 6 follows] +N more" —
where +N rolls in remaining followers and every non-follow
recommender. Falls back to "Recommended by N others" when no
follow has signed off, or "No recommendations yet" otherwise.
Rows are visually discrete via OutlinedCard + HorizontalDivider,
addressing the "need a more visible border between two items"
note.
Data plumbing:
- CashuMintDirectoryEntry gains followsRecommenderPubkeys (capped
at MAX_FOLLOWS_RECOMMENDER_AVATARS=6 so the gallery doesn't
blow up on broadly-recommended mints — the total is still in
followsRecommendationCount).
- CashuMintDirectoryState.search(query, limit) — substring filter
ranked by the same comparator as entries; empty query returns
the top mints, which is exactly what the inline-popular state
needs.
MintPickerSheet.kt deleted; the only caller was the now-removed
Browse button. cashu_browse_mints + cashu_mint_picker_* strings
removed alongside it. New strings use <plurals> for the count-
bearing forms per the project's plural-handling rule.
Future Step Bob's request mentioned: the autocomplete in
CashuWalletSettingsScreen could be migrated to the same row in a
follow-up — same shape, different host.
Mint failure "Signature amount 8 != output amount 4" hit on the
resume-pending-invoice flow because some mints (observed on
minibits) match their internal restore table by the blind point
alone — when the wallet probes (B_=X, amount=4) and (B_=X, amount=8),
those mints return ONE entry with echo.amount=4 (the first matching
output we sent) and sig.amount=8 (the actually-signed denomination).
The strict amount-equality check in unblindOne then trips.
The cryptographic source of truth is the signature, not the echo:
the mint can only sign one denomination per blind point because the
keys are per-denomination. Switch the restore loop to look up
counter materials by bTick alone, then reconstruct the BlindOutput
with the signature's amount before unblinding.
Side benefits: the per-counter map shrinks from N_denoms entries
to 1 entry, and a HashSet<Long> dedups counters in case a tolerant
mint echoes the same blind point under multiple amounts.
The "Issuing proofs" dialog hung at 80% for 30 seconds and the mint
returned "List should have at most 1000 items after validation, not
6300" on the resume-pending-invoice flow. Two compounding causes:
1. CashuMintOperations.restore packs `batchSize * denominations.size`
blinded outputs into each /v1/restore body. The default
batchSize=100 against a typical keyset with the full power-of-2
denomination set (~63 amounts) hits 6300 outputs per request —
nutshell / CDK / minibits all enforce a 1000-item Pydantic cap
and reject the body.
Auto-clamp the effective batch size so total outputs stay under
MAX_RESTORE_REQUEST_ITEMS (500, leaving headroom for the
response doubling — the mint echoes outputs alongside signatures).
Honours the caller's batchSize when it already fits.
2. recoverPreviouslyIssuedProofs (the "outputs already signed"
fallback) was scanning every keyset denomination at every counter
in the rewind window. But the prior /v1/mint/bolt11 reserved only
`splitAmountIntoDenominations(amountSats).size` counters and the
mint signed exactly one output per slot — so the relevant denoms
are a handful, not 63.
Pass `amounts = splitAmountIntoDenominations(amountSats)` plus a
single-batch sweep (`batchSize = rewind`, `emptyBatchesToStop = 1`)
so the recovery makes one HTTP call against ~3-6 denominations
instead of 32 batches of 63.
Some NWC relays (notably relay-nwc.rizful.com) replay cached kind-23195
events every time a REQ filter mutates. Because we previously left each
reqId in the filter for a full 60s after sending its request, every new
NWC call added a fresh reqId to a filter that still listed several
stale ones, and the relay would re-deliver every matching cached
response. That arrived here as a flurry of NO_MATCH events, wasted work
on each new send, and made the transactions screen feel stuck.
Cleanup is now driven by the response itself:
- NwcSignerState cancels the 60s safety-net job on response and asks
the assembler to drop the filter through unsubscribeSoon, which is
debounced 1.5s so a burst of responses produces one relay-side
filter update instead of one per response.
- subscribeAndFlush drains any pending unsubscribes synchronously
before adding the new query, so the relay sees a direct {old}->{new}
transition instead of the {old}->{old,new}->{new} that triggered
Rizful's replay path.
- The 60s timeout remains as a safety net for wallets that never reply.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in the receive-LN path that together produced
"Mint error HTTP 400: outputs already signed" after paying the
invoice, with the old balance + pending banner stuck on screen.
1. Race in the 3s polling loop. checkAndCompleteMint did its
paid-check before flipping _mintState to Completing, so two
polls 3s apart both saw AwaitingPayment and both called
completeMintFromLightning. Poll 1 spent the mint quote and
poll 2 hit code 10002. Same hazard on a double-tapped
"resume pending quote" banner.
Fix: atomic compareAndSet to Completing before any await,
in both checkAndCompleteMint and resumeMintQuote. A
concurrent caller sees the flipped state and bails. If the
paid check returns "not yet", we roll the state back to the
previous AwaitingPayment so the poll resumes.
2. No recovery when the mint already issued. If the prior
attempt's /v1/mint/bolt11 succeeded at the mint but we
failed to publish the kind:7375 locally (signer dialog
dismissed, app killed, etc.), the mint considers the
quote consumed and rejects every retry with "outputs
already signed" — the wallet sat stuck forever.
Fix: catch that exact failure mode in
completeMintFromLightning and fall back to NUT-09 — re-
derive the deterministic blinded outputs from the wallet
seed (scanning a 32-counter rewind window, then forward
via the standard gap-limit heuristic), ask the mint
/v1/restore which it has signed, checkstate the results
to filter spent ones, unblind into proofs, and publish
them as kind:7375 + kind:7376 + kind:5 of the quote
exactly like a fresh mint. End state matches the
original happy path.
Threading: CashuWalletOps gains three optional callbacks
(seedForRestore, peekCashuCounter, reserveCashuCounters)
alongside the existing seedWarmer and DeterministicSecretFactory
wiring; CashuWalletState supplies them from ensureSeed() and
AccountSettings.{peekCashuCounter,reserveCashuCounters}.
The framing: Amy gains every cashu action the Amethyst UI exposes, each
one reusing the exact same quartz + commons code the Android wallet
runs in production. The deliverable is a shell harness under
cli/tests/cashu/ that walks two Amy accounts through the full wallet
lifecycle against a production mint, so regressions in the cashu code
path fail on the JVM in CI without an emulator.
Plan covers:
- Three extractions before any verb lands: cashu token parsers to
quartz, CashuWalletOps to commons, CashuWalletReader projection
helpers to commons.
- One storage addition: ~/.amy/<account>/cashu.json for NUT-13 keyset
counters (deterministic secrets need durable counter state across
invocations).
- Command surface under amy cashu …: wallet, mint, balance, receive,
send, mint-rec, maintenance — mirrors every action the Android
wallet exposes.
- Stable --json shape per verb.
- 9-PR sequencing: extractions first, then verbs grouped by user
intent, then the 10-scenario interop harness.
- Acceptance criteria: harness passes against mint.minibits.cash and
the on-relay events are byte-equivalent to what Amethyst would
produce.
Two bugs in the pending-quote resume path:
1. resumeMintQuote was hard-coding amountSats=0 because kind:7374 doesn't
carry the amount and the mint quote status DTO doesn't echo it. When
the user paid the invoice in an external wallet and then tapped the
pending banner, the 3s polling loop would fire checkAndCompleteMint
with amountSats=0 — completeMintFromLightning couldn't mint zero
proofs and the mint would either reject or, more commonly, the next
/v1/mint/quote/bolt11/{quote} call would already say "quote not found"
because issuance had been GC'd.
Fix: decode the amount from the bolt11 invoice in status.request via
LnInvoiceUtil.getAmountInSats. If the mint reports paid AND we have a
valid amount, complete the mint inline; otherwise drop to
AwaitingPayment with the recovered amount so the polling loop has
something real to drive.
2. checkAndCompleteMint and resumeMintQuote treated "quote not found"
like any other error — the local kind:7374 stayed alive and the
pending banner kept pointing at dead state. NIP-09 delete the quote
event when the mint says it's gone (HTTP 404, or 400 with detail
containing "quote … not found / does not exist / unknown"), and
reset to Idle.
isQuoteGoneError matches by substring across common mint phrasings —
the cashu spec doesn't pin the error message and minibits/nutshell/cdk
all word it slightly differently.
The DragIndicator sat between the wallet name column and the balance
column with verticalAlignment=CenterVertically, so it visually centered
between the balance amount and the "sats" subtitle — landing in dead
space between two lines.
Move it to the start of the row, matching the relay-reorder convention
(BasicRelaySetupInfoClickableRow). Same icon, same modifier, same
RelayDragState wiring; just promoted from middle to leading slot.
Mirrors Android's NamecoinSharedPreferences pinned-cert API on Desktop so
user-accepted TLS pins survive process restart. Same JSON-list shape, same
distinct-append semantics, same wipe-on-reset behaviour.
What's new
- DesktopNamecoinPreferences gains addPinnedCert / loadPinnedCerts /
clearPinnedCerts (sync rather than suspend, since java.util.prefs is
synchronous). reset() now clears pinned certs too, matching Android.
- DesktopNamecoinNameService accepts a pinnedCertsProvider and pushes the
loaded list into ElectrumXClient.setDynamicCerts at init, mirroring
Android's AppModules.kt wiring. Exposes the underlying client so the
Settings UI can call testServer() and re-apply pins live.
- Desktop NamecoinSettingsSection grows an optional Test Connection + TOFU
pin sub-section: runs ElectrumXClient.testServer per active server,
collects PEM + SHA-256 fingerprint from successful TLS handshakes, and
prompts the user to pin each new cert via AlertDialog. UI hidden when
no service is wired (so existing call sites stay valid).
- Main.kt wires both halves together and updates the freshly-pinned cert
list into the live client without waiting for restart.
Persistence is plain java.util.prefs (same backing store as the rest of
DesktopNamecoinPreferences) — explicitly NOT EncryptedSharedPreferences.
Pinned cert PEMs are public material; no secrets stored.
Tests
- DesktopNamecoinPreferencesTest: +6 cases covering empty default,
persistence + reload, dedup, blank input ignored, reset wipes, and
independence from settings copies.
Verification
- ./gradlew :desktopApp:compileKotlin — BUILD SUCCESSFUL
- ./gradlew :desktopApp:test — BUILD SUCCESSFUL (16 tests, 0 failures)
- ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL
- ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck :desktopApp:spotlessCheck — BUILD SUCCESSFUL
Stack note
Stacked behind #3072 (merged 2026-05-27). Next: PR-C for the full
Namecoin Core RPC backend + composite fallback persistence + UI.
Two issues with the send flow on the new-music-track screen:
1. Cover + audio uploaded sequentially. As cover finished, its picker
reverted to the placeholder while audio was still going, making the
user think the operation was done.
2. The coroutine ran on the screen's viewModelScope, so leaving the
screen cancelled it.
Rework the upload pipeline:
* Snapshot the form into an immutable SendSnapshot before launching, so
user edits to the form during the in-flight upload don't poison what
gets published, and the coroutine sees stable inputs even after the
per-screen VM is cleared.
* Run cover + audio uploads in parallel via async/awaitAll. Either
side failing throws an UploadException that the outer catch routes
to the global toast manager.
* Launch through accountViewModel.launchSigner so the work runs on the
AccountViewModel.viewModelScope — survives screen leave. Errors and
success both surface as global toasts.
* Keep coverMedia/audioMedia/pickedAudioName populated through the
ENTIRE upload+publish, not just per-phase. Only clear them on
success, so each picker keeps showing the user the file it's still
uploading.
* Single isSending flag replaces the separate isUploading/isPublishing
booleans; drives an inline progress banner + disables the Send
button + dims the pickers to read-only while in flight.
* One-shot completionEvents SharedFlow that the screen subscribes to.
If the user is still on the screen when the operation finishes, we
popBack; if they've already left, no one is listening and we don't
pop someone else's stack frame.
Also tighten the playback Row height in MusicTrack from 100dp to 80dp.
With a 75dp play button the previous 100dp container left a ~12.5dp
empty band top + bottom that the user flagged as too tall.
The route hint set by newKey() (Route.ImportFollowsSelectUser) was stored
on AccountState.LoggedIn and never cleared. LoggedInPage re-assigned
accountViewModel.firstRoute = route on every recomposition, so any
Activity recreation (rotation, dark-mode toggle, returning from
background) made the screen pop up again even after the user dismissed
it via popBack.
Consume the route once via remember(state) in AccountScreen, and gate the
firstRoute assignment behind LaunchedEffect(Unit) so it only fires once
per composition lifetime.
AddCashuWalletScreen, AddNwcWalletScreen, and CashuWalletSettingsScreen
host their OutlinedTextFields directly in the Scaffold body — mint URL,
wallet name, NWC URI, mint-recommendation add row — so the keyboard
covers the field when the user taps to type.
Apply the codebase's canonical insets recipe (matching AllMediaServersScreen):
.padding(padding)
.consumeWindowInsets(padding)
.imePadding()
The other new wallet screens are untouched on purpose: AddWalletScreen
has no text inputs; WalletScreen + CashuWalletScreen route every input
through AlertDialog (which handles its own IME); MintPickerSheet rides
ModalBottomSheet which moves with the keyboard.
Five changes:
1. Drop the audio-URL text field from the composer. Upload tile at the
top is the single source of truth; the ViewModel still threads the
existing url through MusicTrackEvent.edit so edit mode keeps it.
2. Auto-fill title / artist / album / duration from MediaMetadataRetriever
when an audio file is picked. Only fills empty fields — anything the
user already typed wins. Duration is the exception: derived numbers
overwrite, since they're not opinions.
3. Rename the SavingTopBar 'Save' button to 'Send' for this screen
(introduces SendingTopBar + R.string.send so other screens can reuse).
4. Fix silent data corruption in AddToMusicPlaylistViewModel.init:
the previous `if account already set, return` guard kept the FIRST
track address the VM ever saw. Subsequent invocations with a
different trackAddress (process recreation, navigation reusing the
back-stack entry) routed toggle() at the stale track, adding the
wrong track to playlists. Now refreshes trackAddress on every call
and restarts the scan job when it changes.
5. Inset modifier order on NewMusicTrackScreen tightened in the prior
commit, kept here for continuity.
Three fixes in one:
1. MediaSaverToDisk crash when saving a music URL. The fall-through
branch routed every non-image, non-PDF MIME to
MediaStore.Video.EXTERNAL_CONTENT_URI; an audio/mpeg blob inserted
into the Video collection fails with IllegalArgumentException. Add a
dedicated audio/* branch that writes to MediaStore.Audio with
DIRECTORY_MUSIC.
2. Drop the redundant cover-URL text field from the new-music-track
composer. The upload tile at the top is the source of truth for the
cover — having a second field at the bottom asked the user to set
the same thing twice. ViewModel still threads coverUrl through to
MusicTrackEvent.edit so editing an existing track keeps its image.
3. Tighten the new-music-track Scaffold's inset modifier order:
padding(pad) -> consumeWindowInsets(pad) -> imePadding() ->
horizontal padding -> verticalScroll. Without consumeWindowInsets,
imePadding double-counts the nav-bar inset on Android 11+ and the
bottom field gets shoved too far up when the keyboard opens.
Two NamecoinSettings classes had drifted:
- commons (used by Desktop): only enabled + customServers
- amethyst service.namecoin (used by Android): full schema with backend,
namecoinCoreRpc, fallbackToCustomElectrumx, fallbackToDefaultElectrumx
This left Desktop unable to persist any of the Namecoin Core RPC or
fallback-policy state introduced in the Android settings UI. Promote
the rich Android version into commons as the single source of truth
and delete the Android duplicate.
- Move the rich schema (backend, namecoinCoreRpc, fallback toggles,
hasUsableCoreRpc, toFallbackPolicy) into the commons NamecoinSettings.
- Delete amethyst/service/namecoin/NamecoinSettings.kt and its test.
- Repoint the two Android imports (NamecoinSharedPreferences,
NamecoinSettingsSection) at the commons class. No behaviour change on
Android.
- Fold the Android-only backend/RPC/fallback test cases into the commons
NamecoinSettingsTest so the shared schema stays covered.
Desktop persistence (DesktopNamecoinPreferences) still only reads/writes
enabled + customServers; the extra commons fields fall back to defaults
on the existing Desktop store. Wiring those new fields into Desktop is
the next change.
The inline Namecoin resolution row in the global search bar (and the
on-chain zap recipient field) is gated by looksLikeNamecoinIdentifier,
which previously only matched the '.bit' shapes. Direct namespace
references like 'id/mstrofnone' or 'd/mstrofnone' — both accepted by
NamecoinNameResolver.isNamecoinIdentifier — fell through the gate and
the resolver was never called, so no on-chain feedback appeared in the
search bar.
Bring the UI gate in line with the resolver:
- accept 'd/<name>' (domain namespace, direct reference)
- accept 'id/<name>' (identity namespace)
- lower the length floor so single-character labels (valid, if
expensive, on chain) still trigger; '.bit' inputs keep their
5-char floor
Doc comment for the row's behaviour and the NamecoinResolutionRow
KDoc are updated to reflect the broader accepted set. New unit tests
cover both new prefixes (with case-insensitivity and leading '@'),
short single-label names, bare-prefix rejection, and explicit
non-routing for other Namecoin namespaces ('a/', 'u/').
The auto-redeem loop on a fresh-start cache was hitting mint /v1/keys
once per inbound nutzap, every time triggerAutoRedeem fired (so once
per arriving event batch). For nutzaps locked to a stale wallet P2PK
key (sender saw an old kind:10019) this was pure waste — the P2PK
check happens after the DLEQ network call, so we paid for keysets
just to throw.
Two changes:
1. Move the P2PK lock check ahead of NUT-12 DLEQ inside redeemNutzap.
No-network rejection of wrong-key nutzaps now lands in microseconds.
2. Track deterministic failures (IllegalArgumentException — wrong P2PK,
no mint tag, no proofs, not P2PK-locked) in a session-local
sessionUnredeemableNutzaps set, parallel to sessionRedeemedNutzaps.
redeemPendingNutzapsSerialized skips both sets on every sweep, so
one auto-redeem cycle per nutzap is enough to learn it's a lost
cause; subsequent triggerAutoRedeem firings cost nothing.
Also flattens the try/catch from runCatching+onSuccess+onFailure to
make the bytecode straightforward for the JIT (the user's tombstone
showed a SIGSEGV in Jit thread pool right after three failed redeem
attempts; whether that's the cause or just a coincident ART bug, the
flatter form is friendlier for the verifier).
The previous cover used SubcomposeAsyncImage with a single content lambda
that stacked the loaded image and the chip as BoxScope siblings. Under
Coil's subcompose-layout pass the chip's wrap-content Box got measured
to the cover's full size, painting a translucent black square over the
whole artwork.
Drop SubcomposeAsyncImage. Use rememberAsyncImagePainter + a plain
Image + painter-state observation inside a normal Box. The image fills
the box (Modifier.matchParentSize) and the chip sits as a normal
BoxScope child with Modifier.align(BottomStart).padding(12.dp) —
predictable wrap-content sizing.
The Apps screen was missing the top-nav follow-list spinner that other
single-feed screens (Articles, Longs, Pictures) use, and nothing was
loading because the relay subscription only queried the user's own
outbox and `SoftwareApplicationEvent` (kind 32267) was never consumed
by `LocalCache.justConsumeInnerInner` — it fell through to the
"Event Not Supported" else branch and got dropped. `ReleaseArtifactSetEvent`
(kind 30063) and `SoftwareAssetEvent` (kind 3063) had the same gap.
- Register `SoftwareApplicationEvent`, `SoftwareAssetEvent`, and
`ReleaseArtifactSetEvent` in the `LocalCache` consume dispatch.
- Add `defaultSoftwareAppsFollowList` setting (Account/Settings/Prefs)
and `liveSoftwareAppsFollowLists{,PerRelay}` flows.
- Reshape `SoftwareAppsSubAssembler` to extend
`PerUserAndFollowListEoseManager<_, TopFilter>` and assemble per-list
relay filters via `makeSoftwareAppsFilter`, mirroring Pictures/Longs.
- Add a `SoftwareAppsTopBar` with the standard `FeedFilterSpinner` and
a `WatchAccountForSoftwareAppsScreen` to invalidate the DAL when the
selected list changes.
- DAL now applies `FilterByListParams` so the rendered feed honors the
active top-nav list.
The prior startup scrub was racing with kind:7375 events arriving from
relays after start() returned — by the time those events landed in
_tokenEntries the sweep had already finished against an empty list, so
ghost proofs persisted and the user still hit "proofs already spent" on
self-zap / self-send.
Move the scrub inline: sendNutzap / sendAsToken / meltToLightning all
call scrubLocallyStaleProofs(mintUrl) right before selecting proofs,
narrowed to the mint about to be spent. The scrub now also drops stale
entries from internal indexes via removeEvents() instead of waiting for
the bundled newEventBundles round-trip — without that, the very next
read of _tokenEntries (microseconds later, in the same coroutine) would
still see the ghosts.
Drops the mixed-state skip-and-log branch in favour of treating any
entry with ≥1 SPENT proof as stale — keeping mixed entries around just
lets them resurface as HTTP 400 on the next swap. The unspent portion
of a mixed entry is recoverable via NUT-09 restore.
Wraps sendAsToken + meltToLightning on CashuWalletState (parallel to
the existing sendNutzap wrapper) so the viewmodel calls go through the
same heal path. Drops redundant validation from the viewmodel.
Addresses review feedback on PR #3068: keep the entire NamecoinCoreRpcClient
bootstrap (config push + pinned-cert load) inside the single applicationIOScope
launch instead of doing setConfig synchronously in the by-lazy block. Matches
the ElectrumXClient init shape above.
Music tracks (kind 36787) belong in playlists (kind 34139), not in the
generic bookmark list — but the dropdown menu was showing both, which
made the bookmark rows feel like the default place to save tracks.
Mirror the EmojiPack pattern instead: one curation flow per kind. For a
MusicTrackEvent the Timestamp & Bookmarks section now shows only
'Add to playlist' (which opens the toggle sheet that already handles
add/remove across every owned playlist). Everything else still gets the
standard Manage / Private bookmark / Public bookmark trio.
The new-track composer was URL-only — users had to upload audio and
artwork elsewhere and paste links. Match the New Badge composer flow
instead: pick the files up front, Save uploads them through the user's
default Blossom/NIP-96 server and publishes the kind 36787 event with
the resulting URLs.
Cover image picker is the first item on the screen (square upload tile,
same style as Badge). Audio file picker sits below it. URL text fields
stay below the pickers so power users who source audio from Wavlake /
Stemstr / their own server can still paste links instead of uploading.
ViewModel changes:
- coverMedia / audioMedia: MultiOrchestrator slots fed by the pickers
- saveAndPublish() runs cover upload then audio upload then publish;
either failure short-circuits and surfaces a toast
- isValid() accepts either a picked audio file or a non-blank URL
- All Compose state writes from IO hop through Main.immediate
Audio picker uses OpenDocument(audio/*) — we don't reuse the shared
FileSelect because it also accepts application/pdf.
Up until now both screens shared one combined REQ that asked for both
kinds (36787 + 34139). That meant a single 'makeMusicTracksFilter' had
to cover both feeds, and the since cursor had to be the min of both
feeds' lastNoteCreatedAt to avoid over-fetching.
Split the pipeline so each screen owns its own kind:
- filterMusicEventsByX helpers are parameterized by kinds: List<Int>
- MUSIC_TRACK_KINDS = [36787], MUSIC_PLAYLIST_KINDS = [34139]
- makeMusicTracksFilter(...) and makeMusicPlaylistsFilter(...) are
thin wrappers picking the right kind list
- MusicTracksSubAssembler / MusicPlaylistsSubAssembler each use their
own helper and their own feed's cursor (no min-of-both)
Also:
- Cover the rest of the top-bar selectors that were previously falling
through to emptyList: Hashtag, Geohash (Location), AllCommunities,
SingleCommunity. Music feeds now respect all of them.
- Drop TimeUtils.oneWeekAgo() floor from filterMusicEventsGlobal —
music kinds are sparse on most relays, the floor silently hid older
content the user hadn't seen yet.
Tracks referenced by a playlist are still loaded on demand by each
PlaylistTrackRow's own observeNoteEvent, so the playlists REQ no longer
needs to bundle kind 36787.
Two changes that together address "Mint error (HTTP 400): proofs already
spent" on self-send / self-zap:
1. Replace the auto keyset-migration on wallet load with a non-destructive
NUT-07 sweep. The migration performed a swap-then-publish that wasn't
atomic — a failure mid-sequence (cancelled signer prompt, transient
network) left the mint with the source proofs marked spent while our
local kind:7375 still held them. The next user-initiated send would
then pick those ghost proofs and the mint would reject the swap.
The new sweep does a read-only /v1/checkstate per mint and NIP-09
deletes any kind:7375 whose proofs are all reported SPENT. Mixed-state
entries (rare; partial sub-swap landed) are logged and skipped — they
self-resolve when the user spends the entry. migrateStaleKeysets()
stays around for explicit on-demand wiring.
2. Track redeemed nutzap ids in an in-memory set inside the redeem mutex.
Without this, a second triggerAutoRedeem firing in the ~1s window
between publishing kind:7376 and the bundled newEventBundles emission
could re-pick the same nutzap and the mint would 400 with "proofs
already spent" on the P2PK swap.