Commit Graph
14197 Commits
Author SHA1 Message Date
Claude aa307bfb63 feat(cashu): NUT-02 input-fee math on swap / swap-to-locked / melt
Newer mints charge a per-input fee on swap and melt. Without reserving
it from the output total, the mint rejects every swap/melt with
"amount mismatch" the moment it has any fee configured. We were
reading input_fee_ppk into the KeysetSummaryDto but never threading
it through the actual ops math — fee-charging mints simply didn't
work for us.

Per NUT-02 the fee is `ceil(numInputs * input_fee_ppk / 1000)`. The
ceiling is load-bearing: floor undercharges by one sat in the common
case (numInputs * ppk not exactly divisible by 1000), which is also
exactly what mints reject. New `computeInputFee` helper does the
ceiling-division in pure Long math — `(n * ppk + 999) / 1000` — with
defensive zeroing for null / zero / negative ppk.

Applied in three paths:

- swap(): output total = inputs - fee. The change bucket shrinks by
  fee; the send bucket (when split) stays whole.
- swapToLocked() (nutzap send): change shrinks by fee, recipient
  still gets exactly targetSplit sats locked.
- meltProofs(): required inputs grow by fee (separate from
  quote.feeReserve, which bounds LN routing fees, not the mint's
  processing fee). Change-output upper bound shrinks accordingly.

Also exposes input_fee_ppk on the full KeysetDto (was only on the
summary) so the fee-aware paths can read it from the same /v1/keys
call we already make.

Tests: 10 cases on the ceiling-division helper covering null/zero
ppk, exact-divide boundaries (999 / 1000 / 1001 inputs at 1 ppk),
typical and large fees, and defensive negative-ppk handling.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:43 +00:00
Claude 6caaae460b ui(cashu): swap generic Wallet glyph for the bundled CustomHashTagIcons.Cashu logo
Four spots that represent Cashu-specific things were using
MaterialSymbols.AccountBalanceWallet — a generic bank/wallet icon that
read no different from the on-chain or Lightning iconography on the
zap popup. Replace with the multi-tone Cashu logo that already ships
in commons/hashtags/Cashu.kt and is the canonical brand mark used by
CashuRedeem.kt and the hashtag chips.

Spots swapped:
- ReactionsRow.NutzapAmountChip — the purple chip in the zap popup
  (most important — gives Cashu a distinct visual identity next to
  the orange Lightning bolt and on-chain ₿ chips)
- CashuWalletSettingsScreen.RecommendationSuggestionList — the
  autocomplete dropdown beneath "Recommend a mint"
- AddCashuWalletScreen.MintSuggestionList — autocomplete under the
  mint URL input on the create/edit wallet form
- CashuWalletScreen.MintRow — the leading icon on each wallet-mint
  row in the wallet header

Used `tint = Color.Unspecified` to preserve the icon's native tan +
amber palette (the icon ships with three solid colors on its paths);
flattening to a single colorScheme tint would drop the brand cue.
Brought in via `import androidx.compose.material3.Icon as Material3Icon`
because the file-level `Icon` is the project's custom MaterialSymbol-
only overload (in commons.icons.symbols) — couldn't reuse it for an
ImageVector.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:43 +00:00
Claude a105eb91d7 ui(zap): reorder amount-choice chips — Cashu, Lightning, on-chain
Previously the popup rendered Lightning chips first, then on-chain, then
Cashu. With recipient-capability gating in place, lead with the rail
that's both fastest and free-of-fees when available — Cashu (nutzap) —
falling back to Lightning, then on-chain. Mirrors the priority order
RailCapabilityResolver uses for the underlying capability fallback,
so the visual top-down sweep matches "best rail for this recipient that
the sender has configured".

Mechanical reorder inside ZapAmountChoicePopupContent's FlowRow — no
behaviour change beyond layout. Gear settings button stays at the end.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:43 +00:00
Claude b0bb2885ea ui(cashu): @Preview composables for the new wallet bits
Adds Android-Studio-visible previews for the recently-authored
composables on the Cashu wallet stack, so designers / reviewers can
eyeball them without building and launching the app. All previews use
the existing ThemeComparisonColumn helper to render dark + light side
by side, matching the convention used by SensitivityWarning and the
other existing preview files.

CashuWalletSettingsScreen:
- SettingsRow (with + without subtitle)
- EmptyRecommendationsHint
- AddRecommendationRow (empty + typing state)
- RecommendationSuggestionList
- RecommendationRow (plain + with review text)

AddCashuWalletScreen:
- MintSuggestionList (multi + single)

For RecommendationRow a tiny `fakeRecommendation` helper builds a
synthetic kind:38000 with stable tag layout (d / k / u) so the preview
exercises the same `mintUrls()` + `dTag()` paths the real renderer uses
without touching the signer.

Skipped: top-level screen composables (CashuWalletSettingsScreen,
CashuWalletScreen, AddCashuWalletScreen) — those take AccountViewModel /
CashuWalletViewModel / INav, which can't be cheaply faked. Anyone who
needs to see them previewed should iterate on the smaller sub-
composables included here.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:43 +00:00
Claude 8fa636bbe8 refactor(model): lazy-pinned addressable notes on User via UserContext
Pins each per-user replaceable note to the User's lifetime so weak-ref
eviction from LocalCache.addressables can't lose them — same fix the
NIP-65 / DM relay list notes already had, generalised so adding new
pinned kinds is a one-liner.

Background: LocalCache.addressables is a LargeSoftCache<Address,
AddressableNote> backed by WeakReference. Without a strong reference
somewhere, an addressable note shell (and any event loaded into it) can
be cleared on any GC cycle even though it was successfully delivered.
The User constructor already held nip65RelayListNote / dmRelayListNote
fields exactly to defeat this for kinds 10002 and 10050. kind:10019
(NutzapInfoEvent) had no such pin, so the zap picker's "does this user
accept nutzaps?" check would silently return null for an evicted note —
the chip never showed even when the recipient had actually published.

This refactor:
1. Adds `UserContext` — a one-method `fun interface` exposing
   `addressableNote(addr): Note`. User holds it for life; LocalCache
   implements it via a single instance bound to ::getOrCreateAddressableNoteInternal.
2. Converts the three per-user pinned notes (nip65 / dm / nutzapInfo)
   to `by lazy` fields backed by the context. Each is resolved the
   first time it's read and then held by the User's strong reference
   until the User itself is collected. `by lazy`'s default SYNCHRONIZED
   mode handles concurrent reads from the zap picker + wallet state.
3. Adds typed accessors on User: nutzapInfo(), acceptsNutzaps(),
   nutzapMints(), nutzapP2pkPubkey() — mirrors the existing
   authorRelayList() / dmInboxRelayList() shape.
4. CashuWalletState.peekNutzapTarget now reads via
   `cache.getOrCreateUser(recipientPubKey).nutzapInfo()` instead of
   touching the cache's addressable map directly.

Tradeoffs vs the eager-constructor approach:
- No upfront allocation for kinds the screen never reads.
- Adding a new pinned kind (mute list, blocked relays, bookmark list)
  is one `by lazy { context.addressableNote(...) }` line in User —
  no constructor-signature churn across call sites.
- User now depends on a narrow `UserContext` interface; test fakes are
  a one-liner: `User(hex) { addr -> Note(addr.toValue()) }`.

Migration:
- Single User constructor call site (LocalCache.getOrCreateUser) updated.
- Two existing test fakes (NoteOnchainZapTest, SearchResultSorterTest)
  switched to the SAM-lambda form.
- No external behaviour change — the public `nip65RelayListNote` /
  `dmRelayListNote` fields keep the same names and types, so the few
  consumers (RelayFeedViewModel, ChatNewMessageViewModel) need no edits.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:43 +00:00
Claude 0cb07761ce feat(cashu): add-recommendation input + suggest only on typed text
Two changes in the Cashu Wallet Settings flow:

1. My Mint Recommendations now has its own input row.

   A new OutlinedTextField under the section header lets the user paste
   a mint URL and tap "+" to publish a kind:38000 recommendation
   without having to leave Settings, find the mint elsewhere, and
   thumbs-up it. As the user types, the same cache-backed directory
   autocomplete that AddCashuWallet uses surfaces matching mints from
   the kind:10019 / kind:38000 / kind:38172 the cache already holds —
   tap a suggestion to one-shot recommend (publish + clear the field),
   useful for chaining several adds.

   Suggestions are filtered to drop URLs the user has already
   recommended (de-duped by the lowercased / trailing-slash-stripped
   mint URL across the user's own kind:38000s) so the same row never
   appears in both the autocomplete and the list directly below.

2. Autocomplete reacts only to typed text, not to an empty field.

   The first iteration showed the whole directory the moment the
   field gained focus — i.e. on the Edit Cashu Wallet mint-URL popup
   the user saw mint URLs without typing anything, which felt like the
   form was pre-populating itself. Both call sites
   (AddCashuWalletScreen + CashuWalletSettingsScreen) now early-return
   an empty suggestion list when the trimmed input is blank, so the
   dropdown is purely a reaction to what the user types.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:43 +00:00
Claude 94121c71c9 feat(cashu): mint URL directory + autocomplete
Adds a cache-backed Cashu mint directory sibling to LocalCache.relayHints
that aggregates mint URLs from every relevant event the cache sees, and
wires it into the AddCashuWallet mint-URL text field as inline
autocomplete so users don't have to remember mint URLs.

What feeds the directory:
- NutzapInfoEvent (kind:10019) — every nostr user with a Cashu wallet
  publishes their accepted mints there. A typical inbox of cached
  profiles seeds a useful starter directory automatically.
- MintRecommendationEvent (kind:38000) — explicit public vouches.
- CashuMintEvent (kind:38172) — formal mint announcements from the
  NIP-87 directory subscription.

How it's populated:
- LocalCache.updateMintIndex(event) is called from
  justConsumeAndUpdateIndexes alongside updateHintIndexes, so every new
  event with a mint URL adds to the index. wasNew gating prevents
  re-emissions from inflating popularity counters.
- LocalCache.ensureMintDirectoryBackfilled() does a one-shot scan of the
  existing notes + addressables maps. The autocomplete UI kicks this in
  a LaunchedEffect on screen open so suggestions are useful before the
  next relay round-trip.

Where it surfaces today:
- AddCashuWalletScreen — under the mint-URL OutlinedTextField, a
  MintSuggestionList card shows up to 6 cache-derived suggestions ranked
  by popularity desc + URL asc. Tapping a row fills the field (does not
  auto-add — users typically want to Verify first). Filters out URLs the
  user already added and exact matches of what they typed.

The MintPicker dropdown inside the Receive / Send dialogs is unchanged
— those only need to choose between mints the user already has in their
wallet, so no directory autocomplete applies there.

Tests: 8 unit tests cover normalisation (case-insensitive, trailing-slash
stripping, http(s) gating), popularity ranking, substring filtering,
limit enforcement, and malformed-URL handling.

URL normalisation: trimmed, lower-cased, trailing `/` stripped, scheme
must be http(s). Same URL with different casing or trailing slash
collapses to one entry so popularity counts correctly.

Implementation notes:
- MintDirectoryIndex lives in commons/jvmAndroid (uses ConcurrentHashMap;
  iOS doesn't ship Cashu wallet yet).
- Thread-safe; safe to read from any dispatcher.
- No persistence — purely in-memory, accumulates over the session.
- Entries are never removed: stale entries don't hurt (user always
  verifies before adding), and tracking which event added which URL
  would add bookkeeping without UX benefit.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:42 +00:00
Claude fa9c7503ed fix(cashu): pending-invoice card lingering + thumbs-up mint recommendation never landing
Two bugs sharing the same root cause — LocalCache silently dropping or
losing events that downstream wallet state depended on.

1. Discard Invoice didn't remove the pending banner.

   `CashuWalletState` listens on `cache.live.deletedEventBundles` to
   prune `quoteEvents` when a NIP-09 delete of a kind:7374 is processed.
   That stream is only emitted from `LocalCache.deleteNote`, which is
   only reached when `consume(DeletionEvent)` finds the target Note
   still resident in `notes` — a `LargeSoftCache<HexKey, Note>` backed
   by `WeakReference`s (commons/.../LargeSoftCache.kt). Weak references
   can be cleared on any GC cycle, so on a moderately busy device the
   quote Note is often gone between publish and the deletion round-trip;
   the cache's deleteNote path then no-ops, `_deletedEventBundles`
   never fires, and our `quoteEvents` map keeps the deleted entry until
   process death — manifesting as a pending-invoice banner that the
   user can't dismiss.

   Fix: process our own kind:5 deletions inline in the
   `newEventBundles` collector (which DOES see every kind:5 we publish,
   independent of soft-cache state) by extracting `deleteEventIds()`
   and calling the existing `removeEvents()`. The wallet flows now stay
   in sync regardless of weak-ref collection.

2. Thumbs-up on a mint never appeared in My Mint Recommendations.

   `LocalCache.justConsumeAndUpdateIndexes` dispatches by event type and
   falls into a `else -> Log.w("Event Not Supported")` branch for
   anything missing a `when` arm — silently dropping the event. None of
   the three NIP-87 events (`CashuMintEvent`, `FedimintEvent`,
   `MintRecommendationEvent`) had a dispatch entry, so when the wallet
   published a kind:38000 the cache rejected it, `newEventBundles`
   never emitted, and `CashuWalletState.applyEvents` never indexed it.
   Same broken path for mint announcements arriving from
   `CashuMintDirectoryFilterAssembler`'s relay subscription.

   Fix: add three dispatch entries routing all NIP-87 events through
   `consumeRegularEvent`. They're parameterized-replaceable per spec
   but none extend `AddressableEvent` in Quartz today, so
   `consumeBaseReplaceable`'s `check(event is AddressableEvent)` would
   throw — `consumeRegularEvent` works because the downstream consumers
   (`CashuMintDirectoryState`, `CashuWalletState.applyEvents`) already
   dedupe by `(pubKey, dTag)` and keep the newest.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:42 +00:00
Claude 12f8b025fb feat(zap): gate ZapAmountChoicePopup chips on recipient capability
Until now the popup showed Lightning chips whenever the sender had LN
amounts configured, regardless of whether the recipient had any way to
receive sats over LN. Nutzap chips were already recipient-gated; this
extends the same pattern to Lightning and (defensively) on-chain so a
single tap can't silently land in a no-op.

Adds a small `RailCapabilityResolver` that, given a note, returns three
flags evaluated against the author + every `zap` split tag on the event:

- hasCashu      — any pubkey recipient has a kind:10019 with P2PK and
                  shares a mint with our wallet (delegates to the existing
                  CashuWalletState.peekNutzapTarget).
- hasLightning  — any pubkey recipient has lud16/lud06 in kind:0, OR the
                  note has at least one direct ZapSplitSetupLnAddress.
- hasOnchain    — at least one pubkey recipient exists (NIP-BC derives the
                  Taproot address from the pubkey, so any nostr pubkey is
                  payable; an event with only lnAddress-only splits has
                  nothing to tweak).

A flag is `true` when at least one recipient on the note can be paid
through that rail — matching the existing best-effort behaviour of the
real send paths (ZapPaymentHandler skips pubkeys with no lnAddress;
OnchainZapSendDialog separately warns about skipped lnAddress splits).

The popup gates the existing `zapAmountChoices` / `onchainZapAmountChoices`
lists on the corresponding flags by passing an empty list when the rail
is unsupported — same pattern already used for nutzap chips, so
ZapAmountChoicePopupContent and the chip composables stay unchanged.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:42 +00:00
Claude 9d0224ba5b feat(cashu): Wallet Settings screen with retractable mint recommendations
The top-bar pencil on the Cashu Wallet screen becomes a gear that opens a
new Settings hub instead of jumping straight to the edit form. The hub
hosts:

- "Edit wallet details" → routes to the existing AddCashuWallet form in
  edit mode (mints + nutzap key).
- "My mint recommendations" → live list of NIP-87 kind:38000 events this
  account has published, each with a NIP-09 retract button. Retraction
  fires DeletionEvent with both `e` and (when a d-tag is present) `a`
  tags so compliant relays drop all versions of the parameterized-
  replaceable recommendation.

The wallet's existing CashuWalletFilterAssembler now pulls
MintRecommendationEvent.KIND alongside the other NIP-60 / NIP-61 kinds,
so the list populates without an extra subscription. CashuWalletState
indexes own recommendations into a new `ownRecommendations` StateFlow
(keyed by d-tag, falling back to event id for malformed events) and
keeps it in sync via the existing live cache + delete observers.

Future settings (auto-recommend toggle, nutzap relay overrides,
export/backup) belong here — consolidating wallet-shaped knobs in one
place avoids re-cluttering the main wallet screen.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:42 +00:00
Claude e4b0d80e5c ui(cashu): wallet polish — discard invoice, tile alignment, history rows
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
2026-05-27 15:17:42 +00:00
Claude 9cfca5272e fix(wallet): replace the chooser in the back stack when user picks a type
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.
2026-05-27 15:17:42 +00:00
Claude 817227806b fix(cashu): replace auto-popup with a tappable pending-quote banner
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.
2026-05-27 15:17:42 +00:00
Claude 8ac2a6cac3 ui(wallet): drop the "Your Wallets" header from the wallet list
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.
2026-05-27 15:17:41 +00:00
Claude b9e85d15e0 feat(cashu): pre-cache kind:10019 alongside kind:0 for every viewed user
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
2026-05-27 15:17:41 +00:00
Claude 311c9d13d9 feat(cashu): account-load filter + LocalCache dispatch for NIP-60/61 kinds
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
2026-05-27 15:17:41 +00:00
Claude 3a35eaac73 feat(cashu): back up kind:17375 + kind:10019 to account preferences
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
2026-05-27 15:17:41 +00:00
Claude bb86df246a fix(cashu): show "discovering" state instead of empty-create CTA on first launch
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
2026-05-27 15:17:41 +00:00
Claude 811aa26d57 fix(cashu): init the wallet VM synchronously in composition body
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
2026-05-27 15:17:41 +00:00
Claude ba46f31359 feat(cashu): NIP-87 mint discovery + recommendations
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
2026-05-27 15:17:41 +00:00
Claude c6757a2fae fix(cashu): make AddCashuWallet screen also work as Edit, preserve P2PK key
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
2026-05-27 15:17:40 +00:00
Claude bfd00ccc34 feat(cashu): send NIP-61 nutzaps from the zap picker
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
2026-05-27 15:17:40 +00:00
Claude bbd43e34e9 fix(cashu): close publish-bridge race + lock in exception types via tests
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
2026-05-27 15:17:40 +00:00
Claude bae6e8dcb5 fix(cashu): correct skip-on-decrypt-fail + auto-resume orphan mint quotes
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
2026-05-27 15:17:40 +00:00
Claude 5cd756cea2 refactor(cashu): lift wallet state to Account, react to live cache updates
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
2026-05-27 15:17:40 +00:00
Claude 16401e536a feat(cashu): full NIP-60 wallet + NIP-61 nutzap receive
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
2026-05-27 15:17:40 +00:00
Claude a64cc274cc feat(amethyst): scaffold NIP-60 Cashu wallet UI + state
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
2026-05-27 15:17:39 +00:00
Claude 023e2df542 feat(quartz): add BDHKE primitives for NIP-60 Cashu wallets
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
2026-05-27 15:17:39 +00:00
Vitor PamplonaandGitHub bbc7f9740e Merge pull request #3065 from vitorpamplona/claude/gifted-ritchie-5XjZf
Exclude author from zap split display logic
2026-05-27 11:09:29 -04:00
Vitor PamplonaandGitHub 7d6bbac200 Merge pull request #3064 from vitorpamplona/claude/sweet-maxwell-UMahG
Add playback error overlay with browser fallback for video codec failures
2026-05-27 11:04:09 -04:00
Claude e876e2b09b feat(zap-splits): hide single-author zap split row in NoteCompose
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.
2026-05-27 14:54:48 +00:00
Claude 89607376cc feat(playback): surface unsupported codec errors with browser fallback
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.
2026-05-27 14:51:09 +00:00
Vitor PamplonaandGitHub d381cf9109 Merge pull request #3063 from davotoula/feat/avif-support
Comprehensive AVIF support (#837)
2026-05-27 10:32:03 -04:00
davotoula ef25f8c0e6 test(amethyst): instrumented coverage for AVIF upload + decode
Adds 4 instrumented test files + 3 tiny pre-committed AVIF fixtures to
catch regressions in the upload pipeline.
2026-05-27 16:11:20 +02:00
davotoula f8b24c645a fix(chat): hide DM quality slider for AVIF and correct error framing 2026-05-27 16:11:20 +02:00
davotoula 7d580452e4 fix(uploads): surface specific AVIF metadata error instead of 'Upload cancelled' 2026-05-27 16:11:20 +02:00
davotoula 50a81c35cf Code review:
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.
2026-05-27 16:11:20 +02:00
davotoula 03c42f585e AVIF display + thumbnail-cache fixes from manual testing
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
2026-05-27 16:11:20 +02:00
davotoula 57724cee8c Comprehensive AVIF support (#837)
feat(ui): hide compression slider for non-compressible files (AVIF, GIF, SVG)
feat(images): custom Coil decoder for animated AVIF
feat(ui): include AVIF in animation-aware MIME predicates
fix(uploads): AVIF extension fallback in BlossomUploader
fix(uploads): AVIF extension fallback for NIP-96 multipart filename
feat(uploads): decode AVIF previews with ImageDecoder for blurhash/thumbhash
feat(uploads): fail-closed AVIF metadata inspection in MetadataStripper
fix(uploads): preserve AVIF bytes through MediaCompressor
feat(uploads): add MediaMimeTypes helper for AVIF detection
2026-05-27 16:11:20 +02:00
davotoulaandClaude Opus 4.7 adc0d36407 docs(amethyst): TDD-style implementation plan for AVIF support (issue #837)
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>
2026-05-27 16:11:20 +02:00
davotoula a99927bf92 Docs: plan for comprehensive AVIF support (issue #837)
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
2026-05-27 16:11:20 +02:00
Vitor PamplonaandGitHub 1ef8b7405a Merge pull request #3060 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-27 09:56:19 -04:00
Crowdin Bot 72afadbb38 New Crowdin translations by GitHub Action 2026-05-27 13:20:50 +00:00
Vitor PamplonaandGitHub b12e160457 Merge pull request #3056 from mstrofnone/feat/namecoin-core-rpc-backend
feat(namecoin): add Namecoin Core RPC backend with optional ElectrumX fallback
2026-05-27 09:17:30 -04:00
Vitor PamplonaandGitHub 7af34762bb Merge pull request #3062 from vitorpamplona/claude/payment-targets-ui-7pgrY
feat(profile): toast when no app handles a payment target scheme
2026-05-27 09:15:29 -04:00
Claude 023a3c6624 feat(profile): toast when no app handles a payment target scheme
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
2026-05-27 13:10:36 +00:00
Vitor PamplonaandGitHub 9856458c54 Merge pull request #3061 from vitorpamplona/claude/payment-targets-ui-7pgrY
feat(profile): modern chip layout for payment targets
2026-05-27 09:09:09 -04:00
Vitor PamplonaandGitHub 7e44df0d1f Merge pull request #3059 from davotoula/feat/emoji-pack-add-to-list-menu
Add "Add/remove to/from emoji list" row to pack-card menu
2026-05-27 06:34:10 -04:00
Vitor PamplonaandGitHub da5f01011c Merge pull request #3058 from nrobi144/feat/desktop-profile-editing
feat(desktop): full profile editing — 13 fields, image upload, NIP-05 verification, drag-and-drop
2026-05-27 06:33:26 -04:00
davotoula 205b629c9d Code review:
- tighten EmojiListToggleRow null-handling and label branching
2026-05-27 09:58:26 +02:00