refactor(commons): extract CashuWalletReader projection for amy reuse

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
Claude
2026-06-21 22:20:25 +00:00
parent fc7db088b2
commit d111c2589d
2 changed files with 195 additions and 36 deletions
@@ -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<HexKey>()
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<Event> {
@@ -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<String>,
val tokenEntries: List<TokenEntry>,
val history: List<CashuSpendingHistoryEvent>,
val pendingQuotes: List<CashuMintQuoteEvent>,
val nutzapEvents: List<NutzapEvent>,
val recommendations: List<MintRecommendationEvent>,
) {
/** 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<String, Long>
get() =
tokenEntries
.groupBy { it.content.mint }
.mapValues { (_, byMint) -> byMint.sumOf { it.content.totalAmount() } }
}
suspend fun project(events: Iterable<Event>): WalletSnapshot {
var walletEvent: CashuWalletEvent? = null
var nutzapInfoEvent: NutzapInfoEvent? = null
val tokenEvents = LinkedHashMap<HexKey, CashuTokenEvent>()
val historyEvents = LinkedHashMap<HexKey, CashuSpendingHistoryEvent>()
val quoteEvents = LinkedHashMap<HexKey, CashuMintQuoteEvent>()
val nutzapEvents = LinkedHashMap<HexKey, NutzapEvent>()
val recommendationEvents = LinkedHashMap<String, MintRecommendationEvent>()
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<CashuTokenEvent>): Map<HexKey, TokenContent> {
val out = HashMap<HexKey, TokenContent>()
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<CashuTokenEvent>,
contents: Map<HexKey, TokenContent>,
): List<TokenEntry> {
val all = events.toList()
val deletedIds = mutableSetOf<HexKey>()
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<CashuMintQuoteEvent>,
history: Iterable<CashuSpendingHistoryEvent>,
now: Long = TimeUtils.now(),
): List<CashuMintQuoteEvent> {
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 }
}
}
}