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.
When the user picks the Namecoin Core RPC backend and points at a
self-hosted node behind a self-signed cert (StartOS / Start9, umbrel,
LAN reverse proxy, …) the previous flow only worked if the cert's CA
was already in the device trust store. There was no in-app way to
inspect or pin the certificate, so users had to install the StartOS
root CA at the OS level — or settle for an unencrypted onion path.
This change brings the Namecoin Core RPC path up to parity with the
existing ElectrumX path:
- NamecoinCoreRpcClient.probe() now opens a short-lived, no-auth TLS
socket alongside the JSON-RPC call to capture the server's leaf
certificate (PEM + SHA-256 fingerprint). Capture is best-effort
and only runs for https:// URLs. Credentials are never sent over
the inspection socket.
- RpcProbeResult exposes serverCertPem, certFingerprint, and
tlsHandshakeFailed so the Settings UI can react. New fields are
nullable / default false so existing callers compile unchanged.
- NamecoinCoreRpcClient maintains its own dynamic-cert keystore and
a lazy pinned SSLSocketFactory (same shape as ElectrumXClient's,
minus the hardcoded list — Core RPC has no public defaults). When
cfg.usePinnedTrustStore is true and the URL is https, callRpc()
routes through the pinned factory with a permissive hostname
verifier (LAN/onion certs commonly carry IP-only SANs).
- NamecoinSettingsSection's Namecoin Core RPC card now shows a
'Trust Server Certificate?' AlertDialog after Test RPC when the
probe captured a cert and the user hasn't pinned yet, reusing the
existing namecoin_pin_cert_* strings. Accept persists the PEM
AND flips usePinnedTrustStore=true on the config. The result
card also displays the captured fingerprint and a '(pinned)'
marker so the user can see the current trust state at a glance.
- The pinned PEM list is stored in the existing
KEY_PINNED_CERTS DataStore entry, so a single TOFU confirmation
covers both backends. AppModules' namecoinCoreRpcClient init now
bootstraps the pinned list on app start, matching ElectrumX.
- Tests cover the new probe fields' defaults and the addPinnedCert
/ setDynamicCerts surface.
Local verification: builds clean (assembleFdroidDebug), :quartz:jvmTest
NamecoinCoreRpcClientTest all green, :amethyst:testFdroidDebugUnitTest
namecoin suites all green.
- isPublic() now always returns !isPrivate() so a playlist tagged with
both public=true and private=true is consistently reported as private,
matching the 'isPrivate wins' contract documented on isPrivate().
- AddToMusicPlaylistViewModel + NewMusicPlaylistFab: hop to
Dispatchers.Main.immediate for every Compose State write. The wrapping
coroutines (rescan loop, launchSigner) run on Dispatchers.IO; Snapshot
tolerates off-main writes but the codebase convention is main-only.
- MusicTracksSubAssembler + MusicPlaylistsSubAssembler: the single REQ
asks both kinds 36787+34139, so the since cursor must be the min of
both feeds' lastNoteCreatedAt to avoid over-fetching the lagging kind.
Both assemblers now also listen to the other feed's cursor flow.
- Extract formatTrackDuration into MusicFormatting.kt; MusicTrack and
MusicPlaylist share it.
- syntheticWaveformFor: replace inline FQN
com.vitorpamplona.amethyst.service.playback.composable.WaveformData
with an import.
- NewMusicPlaylistFab: drop the second .trim() — the dialog's confirm
button already trims before invoking onCreate.
The previous fix only ported the no-cover branch. Loading and error
fallbacks were still rendering banner-only with the chip floating alone,
hiding the author's avatar entirely whenever a cover URL was set but
hadn't loaded.
Drop the MyAsyncImage wrapper here and drive SubcomposeAsyncImage
directly so every painter state picks its own overlay:
- Success: real cover + floating chip alone (no avatar to collide with)
- Loading: blurred banner + avatar+chip side-by-side
- Error / no image: banner + avatar+chip side-by-side
When a playlist has no `image` tag, the cover falls back to the
author's profile banner with their avatar baked into the bottom-left by
DefaultImageHeader. The track-count chip was also pinned to BottomStart,
so it landed on top of the avatar.
Render the banner alone (DefaultImageBanner) and place the avatar and
the chip side-by-side in a single bottom row instead. Extract the chip
into a small TrackCountChip composable so both branches share the same
visual.
The three pinned per-user replaceable notes (NIP-65 / DM relays /
nutzap info) were lazy fields. Lazy delegation here adds a synchronized
read on every access for no gain — User is constructed via
LocalCache.getOrCreate, and the pinned notes are read on essentially
every interaction with the user. Resolving them at construction also
lets us drop the stored UserContext reference.
Real minibits / nutshell / CDK-backed mints were rejecting every mint
with "DLEQ verification failed for amount X — mint signature does not
match its published keyset key". The hash input format was wrong on
two axes:
1. Used 33-byte COMPRESSED points; spec uses 65-byte UNCOMPRESSED
(`04 || X || Y`). CDK's `hash_e` in crates/cashu/src/dhke.rs is
the authoritative reference — it calls `.to_uncompressed_bytes()`
then `hex::encode`.
2. Hashed RAW BYTES; spec hashes the UTF-8 of the hex-encoded form.
So the SHA-256 input is 520 ASCII chars (4 points × 130 hex chars),
not 132 raw bytes.
The earlier round-trip test for `signFull → verifyDleq` hid this
because BOTH halves agreed on the wrong format. The fix re-roots
against the verbatim NUT-12 spec vector from
cashubtc/nuts/tests/12-tests.md, which catches any future drift
without needing a live mint.
Carol path (NUT-12 §3) also fixed in the same commit. The previous
`verifyDleqCarol` passed the UNBLINDED `C` to `verifyDleq` as if it
were `C'` — DLEQ math is over the blinded form, so it always
returned false. Per spec, Carol reconstructs both:
B' = hashToCurve(secret) + r·G (what Alice sent)
C' = C + r·A (what the mint returned)
`Bdhke.addRTimesA(C, r, A)` is the inverse of [unblind]'s
`C' - r·A` step. Renamed the `blindSignature` parameter to
`unblindedC` to make the contract clear at call sites.
What still works after the fix:
- Round-trip self-consistency (signFull ↔ verifyDleq) — same algo on
both sides, still verifies.
- All existing tampered-input rejection cases.
- The new dleqProofTestVector pins against the spec's published
proof values; reproducible offline.
- The Carol verification call site in CashuMintOperations.verifyTokenDleq
was updated to the new parameter name.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
The playlist header and each PlaylistTrackRow were reading note.event
synchronously, so when a fresh playlist (or a track referenced by the
playlist) arrived from a relay after first composition, the row stayed
on its 'Unknown track' / stale-snapshot placeholder until the parent
recomposed for some other reason.
Switch both to observeNoteEvent<T>, which subscribes to the note's
metadata flow AND drives the EventFinderFilterAssembler — so a relay
delivering the event both updates the local cache and triggers
recomposition on the same call.
Three follow-ups from the CDK comparison audit:
(#1) NUT-12 §3 Carol verification on incoming proofs.
Previously the wallet only verified DLEQ on proofs the mint signed
directly (Alice-side: mint → our wallet). Proofs arriving from other
wallets — incoming nutzaps, imported cashuB tokens — were trusted at
face value and the validity check was deferred until spend time. A
malicious sender could hand us junk proofs and we'd only find out
when the swap failed, by which point the "payment" was already
considered done from the sender's side.
What's here:
- `CashuProof.dleq: DleqProofDto?` — proofs now retain the
`(e, s, r)` tuple after Alice-side unblinding. `r` is the wallet's
blinding factor (only the minter knows it), included in the
outbound proof so the next recipient can reconstruct `B'` and
verify against the mint's keyset key offline.
- `Bdhke.verifyDleqCarol(secret, r, e, s, C, A)` — Carol-side check.
Reconstructs `B' = hashToCurve(secret) + r·G` from the proof's
secret and the carried blinding factor, then delegates to the
existing `verifyDleq`. No round-trip to the mint required.
- `CashuMintOperations.verifyTokenDleq(proofs)` — batch helper that
fetches the mint's full keyset list, looks up each proof's amount
key, and verifies the DLEQ when the proof carries one. Best-effort:
proofs without dleq (legacy sender, mint stripped) skip cleanly so
we still accept their owner's intent; only an actual mismatch
fails.
- Wired into `CashuWalletOps.redeemNutzap` — every inbound nutzap's
proofs are now Carol-verified BEFORE we hand them to the mint for
swap. Mismatch throws `MintProtocolException`, refusing to redeem
rather than racing the bad proofs into our wallet event.
- `NutzapProofJson` extended with an optional `dleq` field so kind:9321
nutzaps round-trip the tuple between wallets that support it. Older
senders that omit it remain compatible (no Carol check, fall back to
spend-time validation).
(#2) `/v1/info` caching with 30-minute TTL.
Every Verify-mint tap, every future NUT-17 feature-detection lookup,
and every UI surface that shows mint metadata used to fire a fresh
GET. Mint info changes on the order of weeks; 30 minutes is a
compromise between staleness and not blocking a fresh user-visible
upgrade for an hour. Force-refresh path (force=true) for callers that
need certainty after a user-initiated retry. Volatile pair so the
read on the hot path doesn't need a lock — a torn read just causes
one extra HTTP call, never a wrong answer.
(#4) Proactive keyset migration on wallet load.
When the mint rotates its active keyset, proofs minted under the old
one remain spendable — until the mint actually retires that keyset's
private key, after which our held proofs become un-spendable. Without
proactive migration, the first warning is a melt that mysteriously
fails. We now sweep on every wallet start: group held tokens by mint,
fetch the current active id, identify entries with any stale-keyset
proof, and consolidate them via a single swap → fresh kind:7375 →
NIP-09 the source events. Best-effort per mint: a network failure on
one mint skips that mint and the sweep continues for the others.
Three new top-level surfaces:
- `CashuMintOperations.verifyTokenDleq(proofs)` — Carol verification
- `CashuWalletOps.fetchActiveKeysetId(mintUrl)`
- `CashuWalletOps.migrateToActiveKeyset(mintUrl, entries, activeId)`
- `CashuWalletState.migrateStaleKeysets()` — driver, called in start()
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Four small fixes from the post-NUT audit; none user-visible on the
happy path, but #1 is a real correctness issue that would have
rejected vanishingly-rare-but-valid mint signatures.
1. Bdhke.verifyDleq: stop rejecting valid `e >= N`.
NUT-12 treats the challenge `e` as bytes, not as a scalar mod n —
there's no requirement that `e < n`. The previous `ScalarN.isValid(e)`
check would have thrown MintProtocolException with probability ~2^-128
per proof (when sha256 happens to output >= curve order). The
downstream point multiplication `ECPoint.mul(out, A, eScalar)` handles
`e >= n` correctly via GLV reduction internally, so the rejection
was the only thing standing between us and a valid proof. Now we
only reject `e == 0` (which would degenerate verification — `sG -
0·A == sG`, independent of the mint's keyset key — so any junk
signature would pass). Tests still green; vector-style proofs hit
`e < n` 99.99...% of the time, so this isn't observable in the
suite.
2. CashuWalletViewModel Log.w: pass the throwable.
Four call sites (recommendMint / deleteRecommendation /
restoreFromMint / cancelMintQuote) interpolated the exception
message into the lambda but dropped the stack trace. Switched to
the (tag, message, throwable) overload so support reports keep
the trace. Same antipattern that find-non-lambda-logs flags.
3. SecretFactory: batch counter reservation.
The factory was called once per amount denomination inside
secretOutputFor. A 100-sat mint that splits into 7 powers of 2
meant 7 @Synchronized critical sections on
AccountSettings.reserveCashuCounters + 7 saveable.update calls.
Disk was already debounced (1s), but the lock-acquisition pattern
was wasteful and serialised any concurrent mints longer than needed.
New contract: `nextSecrets(keysetId, count): List<DerivedSecret>`.
The deterministic impl reserves the whole counter range in ONE
atomic reservation; the random impl just allocates N random pairs.
CashuMintOperations gained a `secretOutputsFor(amounts, keyset)`
helper that does one batch call; all four call sites (swap, swap
keep-change, swapToLocked keep-change, meltProofs change-outputs)
migrated. `nextSecret(keysetId)` stays as a default-method
single-output convenience.
4. CashuWalletState.ensureSeed: serialise via Mutex.
The previous @Volatile-only double-check could race two coroutines
into both calling `walletPrivkeyHex()` (signer round-trip). For local
signers that's a μs of wasted HMAC; for NIP-46 bunker signers it's
a network decrypt the user pays for twice. Wrapping with
`Mutex.withLock` after the unlocked fast-path gives proper
single-flight semantics without slowing the warm-cache path.
5. NUT-09 restore loop: micro-perf cleanup (rides along).
Pre-sized HashMap + ArrayList to `batchSize × denominations.size`
(was resizing ~10x per 1000-output batch). Hoisted
`bTick.toHexKey()` once per counter — the old code called it twice
per output (once for the map key, once for output.toDto()). Inner
loop now builds the BlindedMessageDto directly with the hoisted
hex instead of going through toDto's accessor.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit closes the loop on the NUT-13/09 combo started in earlier
commits. With NUT-13 wired, every secret + blinding factor is now
seed-derivable; this commit adds the driver loop that asks the mint
"which of these did you sign?", filters out spent ones, and republishes
the survivors as kind:7375 events. The wallet now genuinely survives
losing all its kind:7375 token events — losing your nostr-relay-stored
proofs is no longer permanent funds loss.
What's here:
- CashuMintOperations.restore(seed, keysetId, startCounter, batchSize,
emptyBatchesToStop, amounts?): scans counters in batches, calls
/v1/restore for each batch, unblinds returned signatures, repeats
until N consecutive empty batches signal "no more proofs". Gap-limit
heuristic matches BIP-32 wallet recovery — bounds the scan to
"actually-used + buffer" rather than a fixed upper bound.
- CashuMintOperations.checkStates(proofs): NUT-07 batched proof-state
query, returning a Map<secret, ProofState>. Required because
/v1/restore returns even SPENT proofs (the mint signed them at the
time); without filtering we'd treat already-spent secrets as
recoverable balance.
- CashuMintOperations.activeKeyset() — public surface for the restore
driver. Was previously private.
- New ProofState enum (UNSPENT / SPENT / PENDING / UNKNOWN) with
fromWire() fallback for forwards compatibility with mints that
introduce new state strings.
- CashuWalletOps.restoreFromMint(mintUrl, seed, startCounter): drives
the scan, filters by /v1/checkstate, publishes a single kind:7375
for surviving proofs + a kind:7376 IN history row. Returns
RestoreOutcome with the totals + the highest-counter-seen so the
caller can bump persisted counter state.
- CashuWalletState.restoreFromMint(mintUrl): materialises the seed
via ensureSeed(), delegates to ops, then advances
cashuKeysetCounters past the highest recovered slot so subsequent
mints don't reuse a counter we've just confirmed in use. Returns
null when no seed is available yet (caller retries after wallet
load).
- CashuWalletViewModel.restoreFromAllMints() iterates every mint in
the wallet's mint list, aggregating recovered totals. Best-effort
per mint — one mint failing doesn't abort the others. State
exposed via restoreState: StateFlow<RestoreFlowState>.
- CashuWalletSettingsScreen "Recover from seed" row, sandwiched
between "Edit wallet" and "My mint recommendations". Subtitle
shows the running status, the result totals, or the error. Tap
to kick the scan; idempotent.
Subtle bit: NUT-09 returns proofs regardless of state. Without the
/v1/checkstate filter we'd republish spent proofs and the wallet
would think its balance had recovered, only for the next swap/melt
to fail. The two endpoints together produce a clean "recoverable
balance" answer.
Restore is idempotent — re-running after a successful pass finds
the same proofs (the mint still signed them) but checkStates
classifies them SPENT because the previous round of redemption
consumed them. The result is "Recovered 0 sats", not a duplicate.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Mints that support NUT-20 require the wallet to prove ownership of the
quote when redeeming it for proofs. Without this, anyone who observes
the quote id can race to /v1/mint/bolt11 and steal the freshly-minted
proofs. The earlier commit landed the protocol primitive
(MintQuoteSignature); this one threads it through the live flow.
What's here:
- CashuMintQuoteEvent gets a JSON content shape — `{"quote_id":
"...", "p2pk_priv": "..."}` instead of the bare quote-id string.
Backwards compatible: build() falls back to the plain-string shape
when no signing key is supplied, and decrypt() tries JSON first then
treats parse failure as a legacy plain string. Existing wallets keep
reading their old kind:7374 events; new ones carry the NUT-20 key.
- New CashuMintQuoteEvent.decrypt() returns both the quote id and the
optional signing privkey in one go. quoteId() / signingPrivkey()
are thin accessors over decrypt() so call sites that don't care
about the key stay unchanged.
- CashuMintOperations.requestMintQuote(amountSats, signingPubkey?)
forwards the pubkey to the mint. Always-on is safe: mints that
don't support NUT-20 ignore the extra field per spec.
- CashuMintOperations.mintProofs(quote, amountSats, signingPrivkey?)
signs `sha256(quote || B_0 || B_1 || …)` with the privkey and
attaches the signature when present. Null signingPrivkey skips
NUT-20 entirely (legacy events that don't carry a key).
- CashuWalletOps.startMintFromLightning generates a fresh per-quote
keypair, sends the pubkey to the mint, and stashes the privkey
inside the encrypted kind:7374. Ephemeral keypair per quote keeps
the privacy property — no observable correlation between quotes.
- CashuWalletOps.completeMintFromLightning reads back both fields
from the kind:7374 in one decrypt() call and forwards them to
mintProofs. Resume-from-relaunch works because the key lives with
the quote event (which is restored on app start), not in memory.
Compatibility:
- New quote events carry the NUT-20 key; old ones don't and skip the
signature. Either way the on-wire shape of /v1/mint/bolt11 is
identical to NUT-04 when signature is null.
- Pre-NUT-20 mints ignore the unknown pubkey field — JSON tolerance
per the spec.
- Persistence: the privkey is co-located with the quote in the same
NIP-44-encrypted blob, so backup/restore semantics are unchanged.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
The earlier commit landed the math primitives + spec-vector tests. This
commit threads them into the actual blind-message construction so every
mint / swap / melt the wallet performs now uses NUT-13-derived secrets
instead of pure randomness. Side effect: kind:7375 loss is no longer
permanent funds loss — the wallet can re-derive past secrets from the
seed and recover via NUT-09 /v1/restore (driver loop still to come).
What's here:
- `SecretFactory` strategy in quartz mintApi/. Two impls:
RandomSecretFactory — pure random; the new default for tests and
legacy callers that don't carry a seed.
DeterministicSecretFactory — NUT-13 derivation via a (seedProvider,
reserveCounter) pair. Seed is lazy (the wallet decrypts kind:17375
asynchronously); if the cache is empty, it falls back to random,
preserving pre-NUT-13 behaviour during the warm-up window.
- `CashuMintOperations` constructor now takes a SecretFactory
(default: random). `secretOutputFor` delegates to it. The on-wire
shape is identical either way — the mint can't tell which scheme
we're using.
- `CashuWalletOps` constructor takes a SecretFactory + a suspend
seedWarmer callback. Every blinding op (mintProofs / meltToLightning
/ sendNutzap / redeemNutzap) calls seedWarmer() before constructing
outputs so the seed cache is warm by the time the synchronous
SecretFactory queries it.
- `CashuDeterministic.deriveWalletSeed(p2pkPrivkey)` — derives the
64-byte NUT-13 master seed from the wallet's existing P2PK key via
`HMAC-SHA512("Cashu-Wallet-Seed-v1", priv)`. No new field on
kind:17375 needed; existing wallets become recoverable on first use.
Distinct key-derivation domain so leaking a Cashu secret doesn't
expose the P2PK key.
- `CashuWalletState.cachedSeed` (@Volatile) + `ensureSeed()` (suspend)
— derives once on first call (paying the signer round-trip for the
P2PK key), caches for the wallet's lifetime. Pure function of the
P2PK key, so the cache never invalidates.
- `AccountSettings.cashuKeysetCounters` (`MutableMap<keysetId, Long>`)
+ `reserveCashuCounters(keysetId, count)` — atomic, persistent
read-modify-write that hands out a strictly-monotonic counter range.
Two contracts the spec demands: never reuse a (seed, keysetId,
counter) tuple, and persist the increment BEFORE the secret is used.
Both satisfied — the saveAccountSettings call happens inside the
@Synchronized block before reserveCashuCounters returns.
- `peekCashuCounter(keysetId)` — read-only inspector for the upcoming
NUT-09 restore driver loop (needs to know how high to scan).
Approach (a) — derive seed from the existing P2PK key — was chosen
over (b) — add a new BIP-39 mnemonic field to kind:17375 — because:
- Zero migration: every existing wallet becomes recoverable on next
op, no save-and-republish required.
- The P2PK key is already the recovery-critical secret in kind:17375.
Anyone with the kind:17375 can derive the seed and reconstruct
everything; same threat model as today.
- Cross-wallet recovery via mnemonic is a separate UX feature we can
layer later by also storing/importing a mnemonic when the user
wants that contract.
Backwards compatibility: SecretFactory defaults to RandomSecretFactory
on CashuMintOperations and CashuWalletOps, so existing direct
constructions (mint-operations tests, ad-hoc helper code) keep their
previous behaviour. Production goes through CashuWalletState, which
injects the deterministic factory.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
NUT-17 lets a wallet subscribe to mint events instead of polling. The
big win is the receive flow: instead of `LaunchedEffect { while (true)
{ delay(3000); viewModel.checkAndCompleteMint() } }` hammering the
mint every three seconds, a single subscribe at quote-open time and a
push-notification at PAID time. Smaller wins for melt status and
proof-state tracking.
This commit lands the protocol-message layer + tests; the actual
WebSocket client wrapper and integration into the receive flow follow
in a separate commit. Splitting these because the framing is normative
(any mismatch is a wire-format bug we'd rather catch in unit tests
than against a real mint).
What's here:
- WsRequest / WsResponse / WsNotification — JSON-RPC 2.0 framing as
data classes. Wallet sends WsRequest; mint replies WsResponse for
acknowledgement and pushes WsNotification for state updates.
- WsRequestParams unifies the two shapes (subscribe needs kind +
filters + subId; unsubscribe needs only subId). kotlinx default
null-omission keeps unsubscribe payloads clean.
- WsNotificationParams.payload is left as `JsonElement` rather than
pre-deserialised to a discriminated union: the caller already knows
the kind (it owns the subId it created), so it decodes directly to
the typed DTO without a wasted intermediate parse.
- NutSeventeenKinds constants match the on-wire strings verbatim
(`bolt11_mint_quote`, `bolt11_melt_quote`, `bolt12_mint_quote`,
`bolt12_melt_quote`, `proof_state`). Renaming any of these would
break interop with every mint — a test pins the values.
- ProofStateNotificationDto for the proof-state push payload (NUT-07
shape: Y, state, optional witness).
Subtle bit: kotlinx.serialization omits fields whose value equals
the default. We need `jsonrpc: "2.0"` to ALWAYS appear on the wire —
mints reject anything else — so the field is annotated
`@EncodeDefault` (ExperimentalSerializationApi). Without this, the
first test of the round-trip showed `jsonrpc` missing in the encoded
payload, which a strict mint would reject before parsing further.
Tests: 10 cases covering subscribe / unsubscribe round-trip, the
unsubscribe shape omitting kind+filters cleanly, mint-quote and
proof-state notification decode, response with result vs error,
the wire-string constants, forwards-compat with unknown payload
fields, and a JsonObject-builder spoof for downstream tests that
want to simulate mint notifications without a real WS connection.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Some mints (and increasingly the upcoming ones) require the wallet to
prove ownership of the mint quote when redeeming it for proofs. Without
this, anyone who observes the quote id can race the wallet to /v1/mint/
bolt11 and steal the freshly minted proofs — the quote id is the only
credential. NUT-20 binds the quote to a wallet pubkey at quote creation
and requires a matching BIP-340 Schnorr signature at mint time.
What's here:
- MintQuoteBolt11RequestDto.pubkey — optional 33-byte compressed
secp256k1 hex. When set, the mint records it and refuses to issue
proofs without a matching signature.
- MintBolt11RequestDto.signature — optional 64-byte BIP-340 Schnorr
hex over `sha256(quote_id || B_0 || B_1 || …)` where each B_ is the
UTF-8 encoded hex string of an output's blinded message.
- MintQuoteSignature object — three-arg sign() taking quote id +
output hex list + privkey, plus a DTO convenience overload. Hashes
the concatenation with SHA-256 first per spec (BIP-340 is over a
32-byte digest, not arbitrary payload bytes).
Older mints ignore both fields and operate per NUT-04, so leaving
them null is the no-op default for wallets that don't care.
Tests: 8 cases covering the payload-construction contract (quote ||
outputs in order, no separator), signature length (always 64 bytes),
round-trip verification against the derived x-only pubkey, sensitivity
to either input changing, wrong-length key rejection, and the DTO
convenience overload agreeing with the plain string form.
Not yet wired into CashuMintOperations / CashuWalletOps — like NUT-13
and NUT-09 from the previous commit, this lands the protocol primitive
so the integration step (per-quote keypair generation + persistence,
attaching pubkey to startMintFromLightning and signature to
completeMintFromLightning) is mechanical.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP