From d111c2589d3bb175ad6cee2ecbb5b2d6a7ce466c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 22:20:25 +0000 Subject: [PATCH] refactor(commons): extract CashuWalletReader projection for amy reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure, stateless CashuWalletReader in commons that projects a stream of NIP-60/61/87 events into a WalletSnapshot (wallet/nutzap-info events, decrypted mints, unspent token entries, history, pending quotes, nutzaps, recommendations, plus balance + per-mint balances). Android's CashuWalletState keeps its incremental dirty-tracking and StateFlow plumbing but now delegates the two tricky computations — del-rollover over decrypted tokens (computeUnspent) and the destroyed/expired pending-quote filter (computePending) — to the shared reader instead of carrying its own copies. amy will call project() once per command over its event store. Extraction C of cli/plans/2026-05-28-cashu-cli.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- .../model/nip60Cashu/CashuWalletState.kt | 41 +--- .../commons/cashu/CashuWalletReader.kt | 190 ++++++++++++++++++ 2 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt 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 6279e1b4ab..fdb2580d04 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.nip60Cashu +import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps import com.vitorpamplona.amethyst.commons.cashu.ops.MeltCompleted import com.vitorpamplona.amethyst.commons.cashu.ops.NutzapSent @@ -52,7 +53,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -719,44 +719,13 @@ class CashuWalletState( } } - // Apply `del` rollover. - val deletedIds = mutableSetOf() - all.forEach { evt -> tokenContents[evt.id]?.del?.let(deletedIds::addAll) } - - val unspent = - all - .filter { it.id !in deletedIds && tokenContents.containsKey(it.id) } - .mapNotNull { evt -> tokenContents[evt.id]?.let { TokenEntry(evt, it) } } - .sortedByDescending { it.event.createdAt } - - _tokenEntries.value = unspent + // Shared del-rollover + sort with the headless reader. + _tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents) } private fun recomputePending() { - val now = TimeUtils.now() - // A quote is "pending" if (1) not expired, and (2) no kind:7376 history - // event references its id with a "destroyed" marker — completion of the - // mint flow deletes the kind:7374, and history records a `destroyed` - // reference to the now-fulfilled quote. - val destroyedQuoteIds = - historyEvents.values - .asSequence() - .flatMap { it.tags.asSequence() } - .filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" } - .map { it[1] } - .toSet() - - _pendingQuotes.value = - quoteEvents.values - .filter { it.id !in destroyedQuoteIds } - .filter { evt -> - val exp = - evt.tags - .firstOrNull { it.size >= 2 && it[0] == "expiration" } - ?.get(1) - ?.toLongOrNull() - exp == null || exp > now - }.sortedByDescending { it.createdAt } + // Shared destroyed/expired filter with the headless reader. + _pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values) } private fun scanCacheForOwnEvents(): List { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt new file mode 100644 index 0000000000..1ea5966ad5 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cashu + +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Pure, stateless projection of a stream of NIP-60 / NIP-61 / NIP-87 events + * into a [WalletSnapshot]. This is the read half of the wallet, extracted out + * of the Android-only `CashuWalletState` so the headless CLI (`amy`) and the + * reactive Android holder run the **same** decrypt + del-rollover + + * pending-quote logic. + * + * - Android's `CashuWalletState` keeps its incremental dirty-tracking and + * StateFlow side effects but delegates the two tricky computations + * ([computeUnspent] and [computePending]) here. + * - `amy` calls [project] once per command over `store.allOfKinds(...)`. + * + * Decryption uses the supplied [signer]; failures are logged and the offending + * event is skipped, exactly as the Android holder does. + */ +class CashuWalletReader( + private val signer: NostrSigner, +) { + data class WalletSnapshot( + val walletEvent: CashuWalletEvent?, + val nutzapInfoEvent: NutzapInfoEvent?, + val mints: List, + val tokenEntries: List, + val history: List, + val pendingQuotes: List, + val nutzapEvents: List, + val recommendations: List, + ) { + /** Total spendable balance in the base unit (sats). */ + val balanceSats: Long get() = tokenEntries.sumOf { it.content.totalAmount() } + + /** Spendable balance grouped by mint URL. */ + val balancesByMint: Map + get() = + tokenEntries + .groupBy { it.content.mint } + .mapValues { (_, byMint) -> byMint.sumOf { it.content.totalAmount() } } + } + + suspend fun project(events: Iterable): WalletSnapshot { + var walletEvent: CashuWalletEvent? = null + var nutzapInfoEvent: NutzapInfoEvent? = null + val tokenEvents = LinkedHashMap() + val historyEvents = LinkedHashMap() + val quoteEvents = LinkedHashMap() + val nutzapEvents = LinkedHashMap() + val recommendationEvents = LinkedHashMap() + + for (event in events) { + when (event) { + is CashuWalletEvent -> + if (walletEvent == null || event.createdAt > walletEvent.createdAt) walletEvent = event + is NutzapInfoEvent -> + if (nutzapInfoEvent == null || event.createdAt > nutzapInfoEvent.createdAt) nutzapInfoEvent = event + is CashuTokenEvent -> tokenEvents.putIfAbsent(event.id, event) + is CashuSpendingHistoryEvent -> historyEvents.putIfAbsent(event.id, event) + is CashuMintQuoteEvent -> quoteEvents.putIfAbsent(event.id, event) + is NutzapEvent -> nutzapEvents.putIfAbsent(event.id, event) + is MintRecommendationEvent -> { + val key = event.dTag() ?: event.id + val current = recommendationEvents[key] + if (current == null || event.createdAt > current.createdAt) recommendationEvents[key] = event + } + else -> Unit + } + } + + val mints = + walletEvent?.let { evt -> + runCatching { evt.mints(signer) } + .onFailure { Log.w("CashuWalletReader") { "Failed to decrypt wallet mints: ${it.message}" } } + .getOrNull() + } ?: emptyList() + + val contents = decryptTokens(tokenEvents.values) + val tokenEntries = computeUnspent(tokenEvents.values, contents) + val pendingQuotes = computePending(quoteEvents.values, historyEvents.values) + + return WalletSnapshot( + walletEvent = walletEvent, + nutzapInfoEvent = nutzapInfoEvent, + mints = mints, + tokenEntries = tokenEntries, + history = historyEvents.values.sortedByDescending { it.createdAt }, + pendingQuotes = pendingQuotes, + nutzapEvents = nutzapEvents.values.sortedByDescending { it.createdAt }, + recommendations = recommendationEvents.values.sortedByDescending { it.createdAt }, + ) + } + + /** Decrypt every token event's content, skipping (and logging) failures. */ + suspend fun decryptTokens(events: Iterable): Map { + val out = HashMap() + events.forEach { evt -> + val content = + runCatching { evt.tokenContent(signer) } + .onFailure { Log.w("CashuWalletReader") { "Failed to decrypt token ${evt.id.take(8)}: ${it.message}" } } + .getOrNull() + if (content != null) out[evt.id] = content + } + return out + } + + companion object { + /** + * Project decrypted token events into the unspent set: drop anything a + * later token's `del` rollover marked destroyed or that failed to + * decrypt, then sort newest-first. + */ + fun computeUnspent( + events: Iterable, + contents: Map, + ): List { + val all = events.toList() + val deletedIds = mutableSetOf() + all.forEach { evt -> contents[evt.id]?.del?.let(deletedIds::addAll) } + return all + .filter { it.id !in deletedIds && contents.containsKey(it.id) } + .mapNotNull { evt -> contents[evt.id]?.let { TokenEntry(evt, it) } } + .sortedByDescending { it.event.createdAt } + } + + /** + * A quote is pending when it is neither expired nor referenced as + * "destroyed" by a kind:7376 history event (completion of a mint flow + * deletes the kind:7374 and records a destroyed reference to it). + */ + fun computePending( + quotes: Iterable, + history: Iterable, + now: Long = TimeUtils.now(), + ): List { + val destroyedQuoteIds = + history + .asSequence() + .flatMap { it.tags.asSequence() } + .filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" } + .map { it[1] } + .toSet() + + return quotes + .filter { it.id !in destroyedQuoteIds } + .filter { evt -> + val exp = + evt.tags + .firstOrNull { it.size >= 2 && it[0] == "expiration" } + ?.get(1) + ?.toLongOrNull() + exp == null || exp > now + }.sortedByDescending { it.createdAt } + } + } +}