mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+5
-36
@@ -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> {
|
||||
|
||||
+190
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user