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
This commit is contained in:
Claude
2026-05-27 15:17:42 +00:00
parent 12f8b025fb
commit fa9c7503ed
2 changed files with 58 additions and 1 deletions
@@ -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)
}
@@ -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<DeletionEvent>()
.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()