Commit Graph
14267 Commits
Author SHA1 Message Date
Vitor PamplonaandClaude Opus 4.7 233a5b25b6 fix(nwc): trim subscription filter on response to avoid relay replays
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>
2026-05-27 17:18:25 -04:00
Claude 0b1fa9e775 fix(cashu): receive flow — gate concurrent mint, NUT-09 recover stuck quotes
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}.
2026-05-27 21:13:45 +00:00
Claude 02e611c986 docs(cli): plan — Cashu (NIP-60/61/87) in amy as an Amethyst test harness
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.
2026-05-27 21:04:32 +00:00
Claude d28458553a Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe 2026-05-27 20:02:35 +00:00
Vitor PamplonaandGitHub f898e4be57 Merge pull request #3067 from vitorpamplona/claude/confident-allen-AOGU6
Add music tracks and playlists support with NIP-51 events
2026-05-27 15:59:41 -04:00
Claude bf02a235e0 Merge remote-tracking branch 'origin/main' into claude/confident-allen-AOGU6
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-05-27 19:55:07 +00:00
Claude 6dc873a5d2 fix(cashu): resume mint quote — auto-complete on PAID, drop dead quotes
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.
2026-05-27 19:50:45 +00:00
Vitor PamplonaandGitHub 08520f553f Merge pull request #3076 from mstrofnone/feat/desktop-namecoin-pinned-certs
feat(desktop): persist TOFU-pinned Namecoin ElectrumX certs
2026-05-27 15:45:19 -04:00
Claude fa484c00eb ui(wallet): lead the wallet row with the drag handle
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.
2026-05-27 19:44:37 +00:00
m b5b70fe693 feat(desktop): persist TOFU-pinned Namecoin ElectrumX certs
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.
2026-05-28 05:41:22 +10:00
David KasparandGitHub 7c4220ba3d Merge pull request #3073 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-27 21:41:18 +02:00
Claude 8a55bba1f2 feat(music): parallel uploads + survive-screen-leave + visible progress
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.
2026-05-27 19:39:50 +00:00
Vitor PamplonaandGitHub 80991c6c8b Merge pull request #3074 from vitorpamplona/claude/gallant-darwin-VfniN
Fix route hint re-firing on activity recreation
2026-05-27 15:33:28 -04:00
Vitor PamplonaandGitHub e937b460e1 Merge pull request #3072 from mstrofnone/feat/commons-namecoin-settings-extension
refactor(namecoin): consolidate NamecoinSettings into commons
2026-05-27 15:31:19 -04:00
Claude 187cb407c9 fix(new-user): stop Import Follow List from reappearing after dismiss
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.
2026-05-27 19:26:28 +00:00
Claude 8a3bdb0d78 ui(wallet): imePadding on screens whose body holds the text fields
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.
2026-05-27 19:21:40 +00:00
Claude 3082095a4b feat(music): auto-fill metadata from picked audio + drop audio URL field
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.
2026-05-27 19:18:41 +00:00
Claude 9c9cf8844d fix(music): save audio to MediaStore.Audio (not Video) and trim composer
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.
2026-05-27 19:08:54 +00:00
Crowdin Bot 2c9d9c3129 New Crowdin translations by GitHub Action 2026-05-27 19:08:46 +00:00
Vitor PamplonaandGitHub 58f1ee9606 Merge pull request #3069 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-27 15:07:14 -04:00
Vitor PamplonaandGitHub 63b91012fb Merge pull request #3071 from vitorpamplona/claude/trusting-mccarthy-SA1aI
Add Software Apps feed with follow list filtering
2026-05-27 15:07:05 -04:00
m 67edb32fa7 refactor(namecoin): consolidate NamecoinSettings into commons
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.
2026-05-28 05:06:44 +10:00
Vitor PamplonaandGitHub 7692bee0a5 Merge pull request #3070 from mstrofnone/fix/namecoin-search-id-d-prefixes
fix(search): route d/ and id/ Namecoin namespaces through the resolution row
2026-05-27 15:06:10 -04:00
m 216176eeb7 fix(search): route d/ and id/ Namecoin namespaces through the resolution row
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/').
2026-05-28 04:46:55 +10:00
Claude 81b87634a3 fix(cashu): don't hammer mint on un-redeemable inbound nutzaps
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).
2026-05-27 18:44:49 +00:00
Claude ea227f1968 fix(music): playlist cover chip is wrap-content again, not full-cover
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.
2026-05-27 18:33:22 +00:00
Claude 506ed94bfd fix(software-apps): add top-nav filter and route NIP-82 events through LocalCache
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.
2026-05-27 18:25:39 +00:00
Crowdin Bot 2b6798e78a New Crowdin translations by GitHub Action 2026-05-27 18:25:22 +00:00
Claude 510514644d fix(cashu): heal stale proofs inline on every user-initiated spend
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.
2026-05-27 18:23:39 +00:00
Vitor PamplonaandGitHub d5cca1e3e4 Merge pull request #3068 from mstrofnone/feat/namecoin-core-rpc-tofu
feat(namecoin): TOFU pin for Namecoin Core RPC TLS path
2026-05-27 14:23:25 -04:00
mstrofnone a1071b5189 fix(namecoin): move setConfig into the bootstrap launch
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.
2026-05-28 04:18:15 +10:00
Claude 4177390591 feat(music): swap bookmark rows for playlist row in track dropdown menu
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.
2026-05-27 18:18:11 +00:00
Claude 82d2290e43 feat(music): cover image and audio file pickers on new-track composer
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.
2026-05-27 18:11:44 +00:00
Claude 71d50504fd feat(music): split tracks and playlists into separate filter pipelines
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.
2026-05-27 17:52:28 +00:00
Claude 4db226d5f2 fix(cashu): heal stale proofs + harden auto-redeem against double-spend
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.
2026-05-27 17:45:29 +00:00
m cfb3f1b9fa feat(namecoin): TOFU pin for Namecoin Core RPC TLS path
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.
2026-05-28 03:32:28 +10:00
Claude e876a162a4 fix(music): review feedback round
- 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.
2026-05-27 17:27:44 +00:00
Claude 5a4cb5cc16 fix(music): apply avatar+chip pattern to loading and error covers too
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
2026-05-27 17:21:00 +00:00
Claude 93a27abf23 fix(music): playlist cover chip no longer collides with author avatar
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.
2026-05-27 16:28:10 +00:00
Claude 1190abb30d refactor(user): eager-init pinned addressable notes
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.
2026-05-27 16:19:37 +00:00
Claude 052592b91b fix(cashu): NUT-12 hash input — uncompressed bytes, hex-encoded, UTF-8
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
2026-05-27 15:43:38 +00:00
Claude a403f44d8b fix(music): observe playlist and track notes so cards update on relay arrival
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.
2026-05-27 15:36:30 +00:00
Vitor PamplonaandGitHub 1cd57bd497 Merge pull request #3066 from vitorpamplona/claude/sweet-maxwell-UMahG
fix(playback): hide video controls while the error overlay is shown
2026-05-27 11:27:18 -04:00
Claude 5e25968059 feat(cashu): NUT-12 Carol verification + mint-info caching + keyset migration
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
2026-05-27 15:17:45 +00:00
Claude 805e010c57 fix(cashu): audit polish — DLEQ scalar check, batch counters, seed mutex, log throwables
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
2026-05-27 15:17:44 +00:00
Claude 29de889a1a feat(cashu): NUT-09 restore — recover proofs from seed end-to-end
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
2026-05-27 15:17:44 +00:00
Claude f6b3c40b3f feat(cashu): wire NUT-20 signed mint quote into start/complete flow
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
2026-05-27 15:17:44 +00:00
Claude d7e447428c feat(cashu): wire NUT-13 deterministic secrets into the live mint flow
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
2026-05-27 15:17:44 +00:00
Claude d971ff4da7 feat(cashu): NUT-17 WebSocket subscription protocol (JSON-RPC layer)
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
2026-05-27 15:17:44 +00:00
Claude 4747c0f039 feat(cashu): NUT-20 signed mint quote — quote-theft prevention
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
2026-05-27 15:17:44 +00:00