From fa9c7503ed9faa6dbb244e53edc662e2dbef36aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 23:53:16 +0000 Subject: [PATCH] fix(cashu): pending-invoice card lingering + thumbs-up mint recommendation never landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 --- .../amethyst/model/LocalCache.kt | 29 ++++++++++++++++++ .../model/nip60Cashu/CashuWalletState.kt | 30 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f6146a42b3..f4dd274eff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -234,6 +234,9 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -3133,6 +3136,32 @@ object LocalCache : ILocalCache, ICacheProvider { consumeRegularEvent(event, relay, wasVerified) } + // ============================================================ + // NIP-87 Cashu mint discovery + recommendations + // ============================================================ + // All three are kind 3xxxx (parameterized-replaceable per the + // spec) but neither CashuMintEvent / FedimintEvent / + // MintRecommendationEvent extends AddressableEvent in Quartz + // today, so consumeBaseReplaceable's `check(event is + // AddressableEvent)` would crash. Route through + // consumeRegularEvent — downstream consumers + // (CashuMintDirectoryState, CashuWalletState) already dedupe + // by (pubKey, dTag) and keep the newest. Without these + // entries the dispatch falls into the "Event Not Supported" + // else branch and silently drops the event, so our own + // kind:38000 thumbs-up never lands in the cache. + is CashuMintEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is FedimintEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is MintRecommendationEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is ChannelCreateEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 70693f21b9..0bbfacf00b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -278,7 +279,34 @@ class CashuWalletState( jobs += scope.launch(Dispatchers.Default) { cache.live.newEventBundles.collect { notes -> - val ours = notes.mapNotNull { it.event }.filter(::isRelevantEvent) + val all = notes.mapNotNull { it.event } + + // Process our own NIP-09 deletions inline rather than + // relying on LocalCache's `deletedEventBundles`. That + // path only fires when `consume(DeletionEvent)` finds + // the target Note still resident in `notes` — a + // LargeSoftCache backed by WeakReferences, which can + // be cleared on any GC cycle. When the weak ref is + // gone (common between publish and the round-trip on + // a busy device), the cache's deleteNote/removedNote + // chain silently no-ops and our quoteEvents / + // tokenEvents / etc. retain the logically-deleted + // entries indefinitely — which manifested as the + // pending-invoice card not disappearing after the + // user tapped Discard. The kind:5 event itself does + // reach us via newEventBundles regardless of soft- + // cache state, so we extract its target ids and + // remove from our maps directly. + val ourDeleteIds = + all + .asSequence() + .filterIsInstance() + .filter { it.pubKey == pubKey } + .flatMap { it.deleteEventIds().asSequence() } + .toSet() + if (ourDeleteIds.isNotEmpty()) removeEvents(ourDeleteIds) + + val ours = all.filter(::isRelevantEvent) if (ours.isNotEmpty()) { applyEvents(ours) recomputePending()