Three small UI fixes on the Cashu wallet screen:
- Add a Discard button to the Receive dialog. When the user requested an
invoice they no longer want (typo'd amount, wrong mint, changed their
mind), tap Discard to NIP-09-delete the kind:7374 mint quote so the
pending-quote banner stops re-surfacing it.
- Stop the action-row labels from clipping. The previous
OutlinedButton-per-tile layout, with 4 tiles in a row plus default
24dp horizontal padding and a 20dp icon, clipped "Send Token" on
standard 360dp phones. Replaced with a custom Surface+Column tile that
gives us control over padding and lets the label wrap to 2 lines.
- Render history rows like the LN + on-chain transaction lists:
counterparty avatar on the left, name + timestamp in the middle,
signed amount on the right. For inbound nutzap redemptions we resolve
the sender's pubkey from the redeemed-marker `e` tag (the kind:9321
is in LocalCache thanks to the cashu filter assembler). For
everything else there's no Nostr counterparty, so we fall back to a
directional arrow icon matching the LN wallet's empty-counterparty
pattern.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Bug: after saving an NWC connection (or any conclusion of the form
screen), nav.popBack() landed on the wallet-type chooser instead of
the Wallet screen. The chooser would then sit there as a useless dead
end requiring another back press to escape.
Same bug existed on the Cashu add path from the chooser, though the
edit-from-CashuWalletScreen path was unaffected because it bypassed
the chooser entirely.
Fix: at the chooser, replace the chooser entry with the form via
popUpTo(target, Route.WalletAdd::class) instead of pushing the form
on top via nav.nav(target). Now:
* From + on Wallet → Choose → NWC → Save → back to Wallet (1 pop)
* From + on Wallet → Choose → Cashu → Save → back to Wallet (1 pop)
* From CashuWalletScreen → Edit → Save → back to CashuWallet (unchanged)
* Back button from inside the form now also goes directly to
Wallet, skipping the chooser that's no longer in the stack —
a minor improvement since the chooser had nothing useful to
return to after a type was picked.
Bug: every entry to the wallet screen re-opened the Receive dialog if
any pending kind:7374 mint quote existed. So a user who got an invoice
and then navigated away — for any reason, even without dismissing —
would be greeted by the same invoice dialog the next time they opened
the wallet. Worse: even after they'd paid and the mint had issued
proofs, the brief window before the kind:7374 NIP-09 delete propagated
would cause the dialog to pop again.
Fix: drop the LaunchedEffect(pendingQuotes) auto-resume; replace with
a non-modal banner card just under the BalanceCard that shows
"N pending invoices · Tap to resume". The user opts in by tapping it.
The underlying CashuWalletState.pendingQuotes flow + the
viewModel.resumeMintQuote() VM method are unchanged — only the
trigger surface moves from "auto" to "user-initiated".
playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
It was redundant context — the screen title already says "Wallet" and
the cards underneath are clearly the user's. Removing the section
header reclaims vertical space on the main list. Kept the Spacer
above the first card so the cards don't bump against the OnchainSection
divider.
Leaving the wallet_your_wallets string resource in place so Crowdin
translations stay valid; removing it would force a fan-out across
every locale file for no functional gain.
UserMetadataForKeyKinds — the per-user kind list pulled by
UserWatcherSubAssembler every time the app renders a user (profile
pictures, names, status, identities, NIP-65, DM-relays, etc.) — now
also includes NIP-61 NutzapInfoEvent (kind:10019).
Net effect: when you scroll into a note authored by user X, the same
subscription that fetches X's kind:0 also pulls X's kind:10019 if
present. The Nutzap chip in the zap picker (which peeks
LocalCache via CashuWalletState.peekNutzapTarget) can then resolve
without an extra round-trip — so the chip appears immediately for any
user we've at least seen the profile of.
Deliberately NOT co-loading kind:17375 here. That's the user's
private wallet, NIP-44-encrypted to them; we couldn't decrypt it and
have no reason to fetch other people's wallet events. Only the public
kind:10019 announcement is useful cross-user. The owning user's own
17375 is fetched by AccountInfoAndListsFromKeyKinds2 (the always-on
account-load filter).
Cost: one extra kind in an already-batched per-relay author filter.
No additional round-trips, no extra subscriptions.
playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Two fixes that together make sure NIP-60 / NIP-61 events actually reach
the cache and the always-on account loader picks them up.
1) LocalCache dispatch
Before: justConsumeInnerInner's `when (event)` block had no branches
for any of CashuWalletEvent / CashuTokenEvent /
CashuSpendingHistoryEvent / CashuMintQuoteEvent / NutzapEvent /
NutzapInfoEvent. Events of those kinds were dropped on the floor by
the cache — only our private re-broadcast through
account.sendLiterallyEverywhere() (which calls
cache.justConsumeMyOwnEvent directly, bypassing the dispatcher) made
the wallet visible. Events arriving cleanly from relays were
silently lost.
Now: each kind dispatches through the same consumeBaseReplaceable
(kinds 17375, 10019) / consumeRegularEvent (7374, 7375, 7376, 9321)
paths used by every other Nostr kind in the app. Token events,
history, mint quotes, and inbound nutzaps now land in LocalCache
from any source (relay, restore-from-prefs, manual paste, …).
To make CashuWalletEvent dispatchable through consumeBaseReplaceable
(which requires AddressableEvent), promote it from `Event` to
`BaseReplaceableEvent`. The static helper `createAddress(pubKey)`
stays for callers that don't have an instance; FIXED_D_TAG kept for
backwards source compatibility.
2) Account-load filter
AccountInfoAndListsFromKeyKinds2 (the always-on per-account
subscription that loads kind:0 / NIP-65 / mute list / etc. on
signin) now also pulls kind:17375 and kind:10019. This means even
users who never open the wallet screen have their wallet event and
nutzap-info indexed against their home-relay set — so the wallet is
ready to render the moment they do open it, and inbound nutzaps can
target a known kind:10019 without a separate fetch.
Note: this doesn't replace CashuWalletFilterAssembler — that one
runs against outbox relays and also fetches the non-replaceable
kinds (7374, 7375, 7376, 9321). Both are needed; the relay client
dedupes overlapping filters on the wire.
playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still
pass (BdhkeTest × 7, AmountSplit × 7, P2PK × 6, MintException × 4).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Mirrors the existing local-backup pattern used for kind:0, kind:3,
NIP-65 relay list, mute list, etc.: every time the user's Cashu wallet
event or nutzap info event lands in LocalCache, the latest copy is
serialized into encrypted account prefs. On next launch the saved copy
is pushed into LocalCache before any relay round-trip, so the wallet
screen renders the user's existing wallet immediately — even if relays
are slow or unreachable. The AccountSessionManager also re-broadcasts
both events on signin, exactly like it does for kind:0 / kind:3.
Why this matters:
* Wallet event (kind:17375) holds the user's mint list AND the P2PK
private key used to receive nutzaps. If we couldn't fetch it from
relays on cold start and the user tapped "Create wallet", we'd
overwrite the remote one — destroying the P2PK key and orphaning
any inbound nutzaps. The discovering-state UI commit added a
timeout safety net; this commit makes the wallet actually load
instantly from local backup, removing the race entirely for
returning users.
* Nutzap info (kind:10019) tells other users which mints we accept
and which P2PK pubkey to lock proofs to. Losing it would mean
senders' new nutzaps wouldn't reach us.
Plumbing:
* AccountSettings gains backupCashuWallet + backupNutzapInfo +
updateCashuWallet/updateNutzapInfo setters (dedup by event id,
saveAccountSettings() on change — same shape as updateNIP65RelayList).
* LocalPreferences: LATEST_CASHU_WALLET + LATEST_NUTZAP_INFO PrefKeys
constants, putOrRemove in the writer, async parseEventOrNull in
the reader, constructor wiring on AccountSettings rebuild.
* AccountSessionManager: rebroadcast both events on signin alongside
the existing kind:0/3/NIP-65/etc. broadcast.
* CashuWalletState: takes AccountSettings, on start() pushes both
backups into LocalCache via justConsumeMyOwnEvent, and applyEvents
persists any new wallet/nutzap-info into settings via update*().
* isRelevantEvent + applyEvents + removeEvents now also handle
NutzapInfoEvent (we subscribe to kind:10019 but previously didn't
index it as a first-class state surface — now exposed as
cashuWalletState.nutzapInfoEvent).
playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still
pass (BdhkeTest × 7, AmountSplit × 7, P2PK × 6, MintException × 4).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
NIP-60 wallets are portable — kind:17375 + 7375 + 7376 + 10019 are
stored on relays, so a wallet created in another client (cashu.me,
Boardwalk, etc.) should appear in Amethyst when the user signs in with
the same Nostr key. The plumbing already handles this: our
CashuWalletFilterAssembler subscribes to kinds=[17375, 7375, 7376,
7374, 10019] authored by us, and CashuWalletState.applyEvents() indexes
incoming events regardless of which client published them.
The UX bug: the wallet screen had two states (wallet event present /
absent). On first launch, before relays delivered the existing wallet
event, we rendered the "No Cashu wallet — Create" state. Tapping Create
there published a fresh kind:17375 which (being replaceable) clobbered
the remote wallet — destroying the P2PK key and orphaning any inbound
nutzaps locked to it.
Fix:
* CashuWalletState gets a `discovering: StateFlow<Boolean>` set to
true at start() until either a wallet event arrives (cleared from
applyEvents) or DISCOVERY_TIMEOUT_MS (8 s) elapses, whichever first.
* CashuWalletScreen renders a "Looking for your wallet…" pane with a
spinner + explainer while discovering is true. Empty-create CTA only
fires after timeout for genuinely wallet-less users.
playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Stack trace from runtime:
java.lang.NullPointerException
at CashuWalletViewModel.getState(CashuWalletViewModel.kt:139)
at CashuWalletViewModel.getWalletEvent(CashuWalletViewModel.kt:142)
at WalletScreen.kt:94
Cause: `CashuWalletViewModel.state` dereferences `account!!`, which is
populated by init(). I had init() inside `LaunchedEffect(Unit)` in the
three call sites — that effect only fires *after* the first composition
returns, so the very first read of `viewModel.walletEvent` (line 94 of
WalletScreen) hit a null account and threw.
The existing `WalletViewModel` (NWC) handles this by calling init()
directly in the composable body — init() is idempotent (just assigns
two fields), so recomposing is fine. Match that pattern in
WalletScreen, CashuWalletScreen, and AddCashuWalletScreen.
Both flavors compile clean; 24/24 NIP-60 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds end-to-end NIP-87 support so users can pick mints from the
network instead of having to know URLs upfront, and can publicly
endorse mints they use.
Discovery (commons + amethyst)
* commons/.../CashuMintDirectoryFilterAssembler — subscribes to
kind:38172 cashu mint announcements and kind:38000 cashu-scoped
recommendations (#k=["38172"]) on a configurable relay set.
Fedimint announcements (38173) are intentionally excluded — this
feeds the Cashu mint picker only.
* RelaySubscriptionsCoordinator.cashuMintDirectory — singleton
assembler reachable as Amethyst.instance.sources.cashuMintDirectory.
Indexing state (amethyst/model)
* CashuMintDirectoryState — account-scoped index of announcements +
recommendations. Reactive: backfills from LocalCache.notes on
first observer and listens to LocalCache.live.newEventBundles for
incremental updates. The relay subscription only runs while at
least one picker is on screen (ref-counted open()/close()).
* Ranking: follows-recommendations DESC, then total recommendations
DESC, then URL ASC. Dedup'd by (recommender, mint URL) so a
single recommender can't inflate counts by re-posting.
* CashuMintDirectoryEntry — display model with URL, latest
announcement, total and follows-recommendation counts.
Publishing recommendations (CashuWalletOps)
* recommendMint(mintUrl, dTag?, review) — publishes kind:38000 with
both the `a`-tag (pointing at the mint's announcement by
kind:pubkey:dTag) and a `u`-tag with the raw URL so older clients
indexing by URL still pick it up.
UI integration
* MintPickerSheet — ModalBottomSheet with search field + scrollable
list. Each row shows the mint name (parsed from the announcement
content) or URL, with badge chips for "from people you follow"
and total recommendation counts. The "Add" button writes the URL
back to the caller's mints list; already-added URLs show "Added"
instead.
* AddCashuWalletScreen gets a "Browse" button next to the Mints
section header that opens the picker. Selected mints are still
Verify-able via the existing ping; users can still paste manually
if they want.
* CashuWalletScreen's mint list gets a thumb-up icon button per
mint that fires viewModel.recommendMint(url) — best-effort,
silent failure (logged via Log.w("CashuWallet")).
Wiring
* cashuMintDirectoryFilterAssembler factory plumbs through Account →
AccountCacheState → AppModules. The mock test AccountViewModel
constructions in AccountViewModel.kt are updated to pass a fresh
assembler.
playDebug + fdroidDebug compile clean. 24/24 jvm tests still passing.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Bug: tapping the Edit pencil on CashuWalletScreen routed to
AddCashuWalletScreen, which always started with an empty mints list and
auto-generated a fresh P2PK key on save. Net effect: editing a wallet
silently wiped the mint list and invalidated any inbound nutzaps locked
to the previous key.
Changes:
* AddCashuWalletScreen now detects edit mode (walletEvent != null) and
pre-fills the mints list from CashuWalletState.mints on entry.
Subsequent state updates (e.g. mints arriving from relays mid-edit)
merge in via LaunchedEffect(existingMints).
* P2PK key handling is now an explicit 3-way radio (KeepCurrent /
AutoGenerate / Manual) with KeepCurrent as the edit-mode default.
AutoGenerate in edit mode shows a destructive-action warning. Create
mode hides KeepCurrent and defaults to AutoGenerate.
* CashuWalletState.exportP2pkPrivkeyHex() — suspending accessor used by
the VM when KeepCurrent is selected. Necessary because remote / NIP-46
signers need a round-trip to decrypt the wallet's NIP-44 content.
* CashuWalletViewModel.saveWallet(mints, keyMode, manualPrivkey?) —
replaces the old (autoGenPrivkey, manualPrivkey) shape with the
explicit P2pkKeyMode enum so the screen and VM agree on intent
instead of inferring it from a boolean.
* Title shows "Edit Cashu wallet" + button reads "Save changes" when
editing an existing wallet.
* Vertical scroll added so radio + manual key field don't push the
Save button off-screen on small devices.
Both playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds an end-to-end "Nutzap" path to the existing zap chooser popup.
Protocol layer (quartz)
* CashuMintOperations.swapToLocked: mints P2PK-locked outputs for a
recipient pubkey alongside the unlocked change. Uses the new
lockedOutputFor() helper which encodes NUT-11 P2PK secret strings
before blinding.
* NutzapInfoEvent.createAddress() mirrors CashuWalletEvent's helper so
LocalCache.getOrCreateAddressableNote can look up a recipient's
kind:10019 by pubkey alone.
Wallet ops (amethyst)
* CashuWalletOps.sendNutzap: spends [available] proofs at [mintUrl] to
produce locked outputs worth [amountSats], publishes a kind:9321 with
those proofs + the zappedEvent + recipient p-tag, rolls leftover
change into a new kind:7375 (with `del` referencing the sources),
NIP-09-deletes the source token events, and logs kind:7376 (direction
OUT, destroyed/created references).
* CashuWalletState.peekNutzapTarget(recipient): pure read against the
cached kind:10019 + our mint set. Returns a NutzapTarget (mint URL +
recipient P2PK pubkey) if (a) we have a Cashu wallet, (b) recipient
published kind:10019 with a P2PK pubkey, and (c) we share at least
one mint with them. Returns null otherwise so the UI can hide the
nutzap chip.
* CashuWalletState.sendNutzap: orchestrates target lookup + ops call.
UI integration
* ReactionsRow.ZapAmountChoicePopup gains a `nutzapEnabled: Boolean`
parameter. When true, the popup renders a NutzapAmountChip per zap
amount (tertiary-color, wallet icon) inline with the existing LN +
on-chain chips. Tap fires AccountViewModel.sendNutzap which
forwards into CashuWalletState.sendNutzap. Errors surface via the
same toast path as LN-zap errors.
* ReusableZapButton computes nutzapEnabled from the recipient's
cached kind:10019; chip is hidden when no nutzap target resolves.
Sender currently has to have the recipient's kind:10019 already in
LocalCache for the chip to appear (typical when viewing a note whose
author the user has interacted with). Background prefetch of kind:10019
for unfamiliar authors is a follow-up.
24/24 NIP-60 jvm tests still passing. Both playDebug and fdroidDebug
compile clean.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Replaces the `var publishDelegate` set-after-construction pattern with an
explicit `CashuWalletState.start(publish: suspend (Event) -> Unit)`.
Account now calls `cashuWalletState.start { event -> sendLiterallyEverywhere(event) }`
from its own init { } block, AFTER all field initializers complete.
Why this matters: the previous code launched the backfill + cache-live
collectors from inside the state's own init { } block. Those collectors
could (and would, for returning users) fire an auto-redeem during
Account's field-initializer phase — at which point `publishDelegate` was
still the no-op default AND `followPlusAllMineWithIndex` (which
sendLiterallyEverywhere depends on) wasn't initialized yet. The publish
would silently swallow or NPE. Gating all of start()'s work behind a
@Volatile started flag eliminates the window.
Also: `MintExceptionTest` (+4 tests) pins down the runtime-exception
contract of `MintHttpException` and the new `MintProtocolException` —
the latter is what callers branch on when distinguishing "mint refused"
from "HTTP failed". Kept simple so any future refactor that breaks the
hierarchy fails loudly here instead of silently in describeMintError.
24/24 NIP-60 jvm tests passing.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Two small follow-ups to the audit refactor:
* recomputeUnspent: replace the broken `getOrPut { ... return@forEach }`
pattern (which short-circuited the outer loop on a single decryption
failure, skipping remaining tokens) with an explicit containsKey
guard. Decryption failures are now individually skipped without
affecting other tokens in the same pass.
* CashuWalletScreen: when the wallet opens and pendingQuotes (live
flow from CashuWalletState) is non-empty, automatically resume the
most recent kind:7374 by re-polling the mint and reopening the
receive dialog. Without this, a user who backgrounded the app
mid-mint would see no indication their pending invoice exists.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Addresses the critical findings from the post-implementation audit:
A1. State holder lives on Account, not the ViewModel
New CashuWalletState owns the wallet event, decrypted token contents,
history, mint-quote, and inbound-nutzap indexes. It's constructed on
Account and runs for the lifetime of the login session — so nutzaps
arriving while the user is on Home/DMs/etc. get auto-redeemed without
requiring the wallet screen to be open. ViewModel becomes a thin
presenter that forwards flows + holds per-flow UI state (mint quote
in progress, melt confirmation pending).
A2. Reactive observation via LocalCache.live.newEventBundles
The state object backfills once from cache.notes at construction time,
then receives incremental updates from the live new/deleted event
bundles for any NIP-60/61 event authored by us (or addressed to us
via #p for nutzaps). NIP-44 decryption results for kind:7375 events
are cached by event-id, so the per-refresh re-decrypt is gone (D2).
A3. Mutex-guarded auto-redeem (no more duplicate /v1/swap races)
redeemPendingNutzapsSerialized uses tryLock so a sweep already in
flight short-circuits any new triggers; subsequent cache updates
catch up via the next bundle.
A4. Mint-quote recovery on launch
pendingQuotes flow surfaces unfulfilled kind:7374 events whose
expiration hasn't passed and whose id isn't yet referenced with a
"destroyed" marker in any kind:7376. ViewModel.resumeMintQuote()
re-polls the mint for the original quote and rebuilds the flow.
B1. NutzapInfoEvent now carries the wallet's outbox relays so senders
publish nutzaps where our assembler is actually listening.
B2. Subscription tracks the outboxRelaysFlow — when the relay list
changes, the assembler subscription is rebuilt with the new set.
B5. New MintProtocolException distinguishes "HTTP fine, protocol said
no" (e.g. melt state != PAID) from "HTTP error". Both surface
through describeMintError() (now top-level — C4).
B7. redeemNutzap now pre-checks the P2PK secret's pubkey matches our
wallet pubkey before signing — saves a wasted mint round-trip when
the lock targets someone else.
B8. Melt is a two-phase flow: startMelt() returns a Quoted state with
amount + fee_reserve so the UI confirms before paying; confirmMelt()
actually spends. No more silent fee acceptance.
C1. MintHttpClient + CashuMintOperations cached per mint URL via a
ConcurrentHashMap.
C3. AddCashuWalletScreen has a "Verify" button that pings /v1/info
before adding, with inline success / failure feedback.
C7. Inline JsonObject FQN in P2PK.kt replaced with proper import.
C8. Dead .also { _ -> secretJson } removed from redeemNutzap.
D1. runCatching {}.getOrNull() callsites in the state holder now log
via Log.w("CashuWallet") so silent failures surface in logcat.
D5. CashuWalletQueryState made @Immutable + data class for Compose
stability hygiene.
Touched files: Account.kt (state field + constructor params),
AccountCacheState.kt + AppModules.kt (wire the assembler factory +
okHttpClientForMoney through), CashuWalletOps.kt (decouples from
Account, takes signer + publish callback), CashuWalletState.kt (new),
CashuWalletViewModel.kt (presenter rewrite), CashuWalletScreen.kt
(two-phase melt UI), AddCashuWalletScreen.kt (Verify button),
strings.xml (new keys).
All 20 NIP-60 jvm tests still pass; playDebug + fdroidDebug compile
clean.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Builds out the Cashu wallet beyond the scaffold: a complete mint
protocol layer, the four user-facing wallet operations (mint, melt,
send-as-token, redeem), and auto-redemption of inbound NIP-61
nutzaps. Wires the relay subscription so the wallet state syncs
across devices.
quartz/ — mint protocol layer (commonMain + jvmAndroid)
* nip60Cashu/mintApi/MintApiDtos.kt — Kotlinx Serialization DTOs
for NUT-00..06 (info, keys, mint/quote/bolt11, mint/bolt11,
swap, melt/quote/bolt11, melt/bolt11, checkstate). ProofDto
carries the optional NUT-11 witness.
* nip60Cashu/mintApi/MintHttpClient.kt — OkHttp + kotlinx-json
client bound to a single mint URL; surfaces MintHttpException
with the mint's detail string preserved for the UI.
* nip60Cashu/mintApi/CashuMintOperations.kt — combines BDHKE +
HTTP + amount splitting. Exposes requestMintQuote / mintProofs
/ swap / requestMeltQuote / meltProofs / redeemNutzap. Power-
of-2 amount split per NUT-00.
* nip60Cashu/mintApi/AmountSplit.kt — extracted into commonMain
for testability.
* nip60Cashu/p2pk/P2PK.kt — NUT-11 locked-secret format and
BIP-340 Schnorr witness signing.
* CashuProof gains an optional witness field.
amethyst/ — wallet ops + UI
* model/nip60Cashu/CashuWalletOps.kt — Nostr publishing layer
over CashuMintOperations:
- publishWalletEvents (kind 17375 + kind 10019 together)
- startMintFromLightning / checkMintQuote /
completeMintFromLightning (kind 7374 lifecycle + 7375 +
7376 + NIP-09 deletion of the quote)
- meltToLightning (pre-swap if needed, melt, change rollover,
delete sources, history)
- sendAsToken (swap to exact split, V4Encoder for cashuB,
rollover, history)
- redeemToken (inbound cashuA/B via swap)
- redeemNutzap (NIP-61 P2PK unlock + swap, history with
unencrypted "redeemed" marker per spec)
* service/cashu/v4/V4Encoder.kt — inverse of the existing
V4Parser; encodes proofs to cashuB strings for send.
* ui/screen/loggedIn/wallet/CashuWalletScreen.kt — adds four
action buttons (Receive / Send LN / Send Token / Redeem) with
AlertDialog-based flows that poll the mint quote, paste/copy
from clipboard, and surface mint errors.
* ui/screen/loggedIn/wallet/CashuWalletViewModel.kt — new mint
/ melt / send-token / redeem state machines, subscribes via
CashuWalletFilterAssembler on init (auto-syncs the wallet
across devices), observes the wallet note's flow for reactive
refresh, and auto-redeems any inbound kind 9321 nutzap that
isn't already marked redeemed in our kind 7376 history.
relay subscription
* commons/.../CashuWalletFilterAssembler.kt refactored into the
standard ComposeSubscriptionManager + SingleSubEoseManager
pair (matches the NWC pattern). Now driven by subscribe(query)
/ unsubscribe(query) calls from the ViewModel.
* RelaySubscriptionsCoordinator.cashuWallet exposes a singleton
assembler reachable as Amethyst.instance.sources.cashuWallet.
Tests (jvmTest)
* BdhkeTest — 7/7
* AmountSplitTest — 7/7 (NUT-00 vectors + sum invariants)
* P2PKTest — 6/6 (secret round-trip, witness verifies under
BIP-340, compressed + x-only acceptance)
Total: 20 new NIP-60 jvm tests, all passing. Both playDebug and
fdroidDebug compile clean.
Deferred (clearly bounded follow-ups):
* Sending nutzaps (kind 9321) from the zap picker UI — requires
integrating with the existing LN zap chooser surface. The
underlying P2PK locking primitives are in place.
* Recovering an interrupted kind 7374 mint quote on next launch
— current flow keeps polling while the dialog stays open.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds the user-visible scaffolding for a Cashu wallet alongside the
existing NWC wallets. View-only for now — minting, send/receive, and
NIP-61 nutzaps land in a follow-up commit on this branch.
UI
* AddWalletScreen is now a wallet-type chooser. The existing NWC
flow moves verbatim to AddNwcWalletScreen; AddCashuWalletScreen
is new: takes one or more mint URLs, auto-generates a separate
P2PK key for nutzap receiving (or accepts a pasted hex key), and
publishes a kind:17375 wallet event via the account's signer
using CashuWalletEvent.build(mints, privkey).
* CashuWalletScreen renders the wallet's mint list, total balance
in sats (summed across all unspent kind:7375 token events the
signer can decrypt, with rollover applied via the `del` field),
and a chronological history view sourced from kind:7376.
* WalletScreen surfaces the Cashu wallet as a card under "Your
Wallets" when one exists, so the Wallets entry point shows both
wallet kinds side by side.
Relay subscription
* CashuWalletFilterAssembler (commons) subscribes one filter per
relay covering kinds 17375/7375/7376/7374/10019 by author and
one targeting inbound kind:9321 via #p. Not yet wired into
Account.kt — the view path works because we feed our own writes
through cache.justConsumeMyOwnEvent. Cross-device sync requires
the assembler subscription wiring, which comes next.
Plumbing
* Routes.WalletAddNwc / WalletAddCashu / CashuWallet added and
registered in AppNavigation.
* CashuWalletEvent.createAddress(pubKey) mirrors MetadataEvent for
looking up the replaceable wallet event from LocalCache.
Compiles clean on playDebug + fdroidDebug; BDHKE jvm tests still
pass (7/7).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Implements blind Diffie-Hellman key exchange per NUT-00 — the
cryptographic core that lets a Cashu mint sign blinded messages
without seeing the underlying secret. Used by the upcoming NIP-60
wallet flows (mint, swap, melt) to issue and verify ecash proofs.
- hash_to_curve (NUT-00 try-and-increment, with Cashu domain separator)
- blind: B_ = Y + r·G
- unblind: C = C_ - r·K
- sign/verify: mint-side helpers used by tests and DLEQ-less
client-side proof validation.
All operations sit on top of the existing pure-Kotlin secp256k1
implementation in quartz/utils/secp256k1/, so they run on every KMP
target without JNI. Includes the official NUT-00 hash_to_curve test
vectors and a BDHKE round-trip with both the trivial (a=1, r=1) and
a random key.
7/7 jvm tests pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
When a note's only zap split recipient is the post author, the split is
redundant — the author already receives the zap. Skip rendering the row
in those cases by gating on a new `hasZapSplitSetupBesidesAuthor` helper.
ExoPlayer entered the ERROR state silently when a codec was missing or the
container/format wasn't supported, leaving a blank video area with no
recourse. Track the player error in MediaControllerState, render an overlay
with the error code, and offer an "Open in browser" button so the user can
fall back to the system browser for codecs the device can't decode.
style(nests): import TimeUtils in CreateNestViewModel instead of inline FQN
HIGH-1: import java.io.RandomAccessFile in MetadataStripper instead of
inline fully-qualified name
HIGH-2: catch AvifMetadataNotVerifiableException in the 6 ViewModels
that call MetadataStripper.strip directly (profile picture, emoji pack
list+display, bookmark group, nest, channel)
MEDIUM-1: tighten AvifAnimatedDecoderFactory.createAnimatedImageDecoder
annotation from @RequiresApi(P) to @RequiresApi(S); the outer guard is
already SDK_INT < S.
MEDIUM-2: replace the curried lambda DI seam in MetadataStripper with
a named fun interface (AvifExifReader).
MEDIUM-3: rename isGifUrl -> isAnimatedMediaUrl (MyAsyncImage) and
BaseMediaContent.isGif() -> isAnimatedMedia() (ZoomableContentView)
since both predicates now cover AVIF as well as GIF.
- AvifAnimatedDecoderFactory.isAvif now iterates a single brand list
with .any { rangeEquals(8, it) } instead of three || branches.
- MetadataStripper.inspectAvifMetadata dropped the outer defensive
try/catch; the inner catch already converts parse failures to
AvifMetadataNotVerifiableException and the rest of the function
cannot realistically throw.
- PreviewMetadataCalculator extracts the shared ImageDecoder allocator
+ exception path from decodeAvifBytes and decodeAvifFromUri into a
single private decodeAvif(source) helper.
- RobohashFallbackAsyncImage merges its identical Loading and Error
when branches into one via Kotlin's multi-value branch syntax.
- MediaCompressorTest drops a no-op MockKAnnotations.init(this) call
and the now-unused import; no @MockK fields exist.
fix(ui): default avatar contentScale to Crop, not Fit
fix(images): skip thumbnail cache for animated AVIF profile pictures
fix(ui): animate profile pictures regardless of URL extension
15 bite-sized tasks across 6 phases (A foundation, B upload pipeline, C animation
lifecycle audit, D test fixtures + instrumented tests, E manual on-device
verification, F ship). Each task has exact file paths, full test code, full
patch code, exact commands, expected output, and per-task commits.
Companion to amethyst/plans/2026-05-26-avif-support.md spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(amethyst): document strip-toggle-off AVIF EXIF leak as known limitation
docs(amethyst): document Desktop AVIF gaps from spot-check
docs(amethyst): record animated AVIF playback caveats from on-device testing
docs(amethyst): note API < 31 gallery-picker greys out AVIF (OS limit)
docs(amethyst): tighten API < 31 known-limitation with on-device findings
docs(amethyst): plan and design for AVIF instrumented tests
Tapping a chip silently failed if no installed app handled the
type-specific URI scheme (bitcoin:, ethereum:, monero:, etc.).
Surface that case through the existing toastManager so users know
to install a compatible wallet.
https://claude.ai/code/session_01R7kRziq14Hc22dPwAnZRAr
Closes the UX gap where a user who creates a pack via the in-app UI has no
path to add it to their NIP-51 kind-10030 selection without leaving the
pack-management screens.
- Wire DragAndDropTarget on avatar circle and banner area
- Image-only filter (jpg/png/gif/webp/avif)
- Visual drag-over feedback (primary border highlight)
- Fix avatar: only show placeholder icon when no image set
(previously overlay was visible behind the loaded avatar)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both umbrelOS (via getumbrel/umbrel-apps#4962) and StartOS / Start9
(via Start9-Community/namecoin-core-startos) ship a self-hosted
Namecoin Core that this backend can target. Generalize the
help/strings so umbrel users discover the feature too.
No logic changes.
Replace the awkward small icon button with a full 100dp tappable circle.
Shows surfaceVariant background when empty, semi-transparent overlay with
centered upload icon when image is present. Spinner replaces icon during
upload.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace plain text payment-target rows with a FlowRow of pill-shaped
clickable chips that carry a type-aware icon, brand color, uppercase
label, and a truncated address. Tap opens a type-specific URI scheme
(bitcoin:, lightning:, ethereum:, monero:, liquidnetwork:, dash:,
payto:// fallback) so wallets can actually pick up the intent; long-press
still copies the authority to the clipboard.
If a user's NIP-65 outbox advertises only relays that don't hold their
kind 0, profile fetching used to give up after EOSE on those relays.
filterUserMetadataForKey now widens to the account's indexer relays
once every outbox relay has either EOSE'd or is in cannotConnectRelays
and metadata is still missing. UserWatcherSubAssembler invalidates
filters on EOSE so the fallback re-evaluates without waiting for an
unrelated trigger.