refactor(cashu): lift wallet state to Account, react to live cache updates

Addresses the critical findings from the post-implementation audit:

A1. State holder lives on Account, not the ViewModel
  New CashuWalletState owns the wallet event, decrypted token contents,
  history, mint-quote, and inbound-nutzap indexes. It's constructed on
  Account and runs for the lifetime of the login session — so nutzaps
  arriving while the user is on Home/DMs/etc. get auto-redeemed without
  requiring the wallet screen to be open. ViewModel becomes a thin
  presenter that forwards flows + holds per-flow UI state (mint quote
  in progress, melt confirmation pending).

A2. Reactive observation via LocalCache.live.newEventBundles
  The state object backfills once from cache.notes at construction time,
  then receives incremental updates from the live new/deleted event
  bundles for any NIP-60/61 event authored by us (or addressed to us
  via #p for nutzaps). NIP-44 decryption results for kind:7375 events
  are cached by event-id, so the per-refresh re-decrypt is gone (D2).

A3. Mutex-guarded auto-redeem (no more duplicate /v1/swap races)
  redeemPendingNutzapsSerialized uses tryLock so a sweep already in
  flight short-circuits any new triggers; subsequent cache updates
  catch up via the next bundle.

A4. Mint-quote recovery on launch
  pendingQuotes flow surfaces unfulfilled kind:7374 events whose
  expiration hasn't passed and whose id isn't yet referenced with a
  "destroyed" marker in any kind:7376. ViewModel.resumeMintQuote()
  re-polls the mint for the original quote and rebuilds the flow.

B1. NutzapInfoEvent now carries the wallet's outbox relays so senders
  publish nutzaps where our assembler is actually listening.

B2. Subscription tracks the outboxRelaysFlow — when the relay list
  changes, the assembler subscription is rebuilt with the new set.

B5. New MintProtocolException distinguishes "HTTP fine, protocol said
  no" (e.g. melt state != PAID) from "HTTP error". Both surface
  through describeMintError() (now top-level — C4).

B7. redeemNutzap now pre-checks the P2PK secret's pubkey matches our
  wallet pubkey before signing — saves a wasted mint round-trip when
  the lock targets someone else.

B8. Melt is a two-phase flow: startMelt() returns a Quoted state with
  amount + fee_reserve so the UI confirms before paying; confirmMelt()
  actually spends. No more silent fee acceptance.

C1. MintHttpClient + CashuMintOperations cached per mint URL via a
  ConcurrentHashMap.

C3. AddCashuWalletScreen has a "Verify" button that pings /v1/info
  before adding, with inline success / failure feedback.

C7. Inline JsonObject FQN in P2PK.kt replaced with proper import.

C8. Dead .also { _ -> secretJson } removed from redeemNutzap.

D1. runCatching {}.getOrNull() callsites in the state holder now log
  via Log.w("CashuWallet") so silent failures surface in logcat.

D5. CashuWalletQueryState made @Immutable + data class for Compose
  stability hygiene.

Touched files: Account.kt (state field + constructor params),
AccountCacheState.kt + AppModules.kt (wire the assembler factory +
okHttpClientForMoney through), CashuWalletOps.kt (decouples from
Account, takes signer + publish callback), CashuWalletState.kt (new),
CashuWalletViewModel.kt (presenter rewrite), CashuWalletScreen.kt
(two-phase melt UI), AddCashuWalletScreen.kt (Verify button),
strings.xml (new keys).

All 20 NIP-60 jvm tests still pass; playDebug + fdroidDebug compile
clean.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:40 +00:00
parent 16401e536a
commit 5cd756cea2
14 changed files with 832 additions and 307 deletions
@@ -510,6 +510,8 @@ class AppModules(
AccountCacheState(
geolocationFlow = { locationManager.geohashStateFlow },
nwcFilterAssembler = { sources.nwc },
cashuWalletFilterAssembler = { sources.cashuWallet },
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
contentResolverFn = { appContext.contentResolver },
otsResolverBuilder = { otsResolverBuilder.build() },
cache = cache,
@@ -291,6 +291,8 @@ class Account(
override val signer: NostrSigner,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
val client: INostrClient,
@@ -405,6 +407,17 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
signer = signer,
cache = cache,
scope = scope,
assembler = cashuWalletFilterAssembler(),
outboxRelaysFlow = outboxRelays.flow,
okHttpClient = okHttpClientForMoney,
)
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
// Follows Relays
@@ -3388,6 +3401,11 @@ class Account(
init {
Log.d("AccountRegisterObservers", "Init")
// Bridge CashuWalletOps's publish callback to our `sendLiterallyEverywhere`
// so the state object can push events to relays + cache without holding
// a direct reference back to Account.
cashuWalletState.publishDelegate = { event -> sendLiterallyEverywhere(event) }
// Restore Marmot MLS group state on startup
if (marmotManager != null) {
scope.launch(Dispatchers.IO) {
@@ -53,6 +53,8 @@ import java.io.File
class AccountCacheState(
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val contentResolverFn: () -> ContentResolver,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
@@ -191,6 +193,8 @@ class AccountCacheState(
signer = signerWithClientTag,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
cashuWalletFilterAssembler = cashuWalletFilterAssembler,
okHttpClientForMoney = okHttpClientForMoney,
otsResolverBuilder = otsResolverBuilder,
cache = cache,
client = client,
@@ -20,20 +20,24 @@
*/
package com.vitorpamplona.amethyst.model.nip60Cashu
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.cashu.v4.V4Encoder
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
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.bdhke.Bdhke
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.history.SpendingDirection
import com.vitorpamplona.quartz.nip60Cashu.history.TokenReference
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintProtocolException
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -47,24 +51,33 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
/**
* Wallet-level operations that combine the [CashuMintOperations] HTTP layer
* with Nostr event publishing (kind 7375 / 7376 / 7374 / 17375 / 10019 /
* deletion-5).
*
* The operations layer is intentionally stateless: each call receives the
* current decrypted state from [CashuWalletViewModel] (or rebuilds it from
* [LocalCache] when needed). All side effects flow through [Account] which
* handles signing + publishing + local-cache consumption in one shot.
* Stateless w.r.t. wallet contents — each call receives the current state
* from `CashuWalletState`. Signing and broadcast are abstracted behind the
* [signer] + [publish] callbacks so the ops layer can be unit-tested without
* a full [com.vitorpamplona.amethyst.model.Account] graph.
*
* Per-mint [CashuMintOperations] instances are cached so repeated calls
* against the same mint reuse the same `MintHttpClient` and avoid re-
* instantiating the JSON serializer per request.
*/
class CashuWalletOps(
private val account: Account,
private val signer: NostrSigner,
private val publish: suspend (Event) -> Unit,
private val okHttpClient: (String) -> OkHttpClient,
) {
private fun client(mintUrl: String) = MintHttpClient(mintUrl, okHttpClient)
private val opsCache = ConcurrentHashMap<String, CashuMintOperations>()
private fun ops(mintUrl: String) = CashuMintOperations(client(mintUrl))
private fun ops(mintUrl: String): CashuMintOperations =
opsCache.getOrPut(mintUrl.trimEnd('/')) {
CashuMintOperations(MintHttpClient(mintUrl, okHttpClient))
}
/**
* Publish kind:17375 + kind:10019 in one go.
@@ -80,24 +93,28 @@ class CashuWalletOps(
suspend fun publishWalletEvents(
mints: List<String>,
p2pkPrivkeyHex: String?,
nutzapRelays: List<NormalizedRelayUrl> = emptyList(),
): CreatedWallet {
require(mints.isNotEmpty()) { "Wallet must have at least one mint" }
val priv = (p2pkPrivkeyHex?.takeIf { it.isNotBlank() } ?: Bdhke.randomScalar().toHexKey())
val pubKeyHex = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(priv.hexToByteArray())).toHexKey()
val walletTemplate = CashuWalletEvent.build(mints, priv, account.signer)
val walletEvent = account.signer.sign(walletTemplate)
account.sendLiterallyEverywhere(walletEvent)
val walletTemplate = CashuWalletEvent.build(mints, priv, signer)
val walletEvent = signer.sign(walletTemplate)
publish(walletEvent)
// Populate `relay` tags so senders know where to publish nutzaps —
// without these, they fall back to NIP-65 outbox and may miss our
// subscription scope on relays we don't read from.
val nutzapInfoTemplate =
NutzapInfoEvent.build(
mints = mints.map { NutzapMintTag(it, listOf("sat")) },
relays = emptyList(),
relays = nutzapRelays,
p2pkPubkey = pubKeyHex,
)
val nutzapInfoEvent = account.signer.sign(nutzapInfoTemplate)
account.sendLiterallyEverywhere(nutzapInfoEvent)
val nutzapInfoEvent = signer.sign(nutzapInfoTemplate)
publish(nutzapInfoEvent)
return CreatedWallet(
walletEvent = walletEvent,
@@ -120,10 +137,10 @@ class CashuWalletOps(
CashuMintQuoteEvent.build(
quoteId = response.quote,
mintUrl = mintUrl,
signer = account.signer,
signer = signer,
)
val quoteEvent = account.signer.sign(quoteTemplate)
account.sendLiterallyEverywhere(quoteEvent)
val quoteEvent = signer.sign(quoteTemplate)
publish(quoteEvent)
return MintQuoteStarted(
quoteEvent = quoteEvent,
mintQuote = response,
@@ -146,13 +163,13 @@ class CashuWalletOps(
quoteEvent: CashuMintQuoteEvent,
amountSats: Long,
): MintCompleted {
val quoteId = quoteEvent.quoteId(account.signer)
val quoteId = quoteEvent.quoteId(signer)
val minted = ops(mintUrl).mintProofs(quoteId, amountSats)
val tokenContent = minted.toTokenContent(mintUrl)
val tokenTemplate = CashuTokenEvent.build(tokenContent, account.signer)
val tokenEvent = account.signer.sign(tokenTemplate)
account.sendLiterallyEverywhere(tokenEvent)
val tokenTemplate = CashuTokenEvent.build(tokenContent, signer)
val tokenEvent = signer.sign(tokenTemplate)
publish(tokenEvent)
val historyTemplate =
CashuSpendingHistoryEvent.build(
@@ -166,15 +183,15 @@ class CashuWalletOps(
marker = TokenReference.MARKER_CREATED,
),
),
signer = account.signer,
signer = signer,
)
val historyEvent = account.signer.sign(historyTemplate)
account.sendLiterallyEverywhere(historyEvent)
val historyEvent = signer.sign(historyTemplate)
publish(historyEvent)
// NIP-09 delete the now-fulfilled quote event.
val delTemplate = DeletionEvent.build(listOf(quoteEvent))
val delEvent = account.signer.sign(delTemplate)
account.sendLiterallyEverywhere(delEvent)
val delEvent = signer.sign(delTemplate)
publish(delEvent)
return MintCompleted(
tokenEvent = tokenEvent,
@@ -184,20 +201,30 @@ class CashuWalletOps(
}
/**
* Pay a bolt11 invoice via the chosen mint, spending [available] token
* events. Picks the smallest subset of token events whose total covers
* `amount + fee_reserve` (greedy by total amount descending). Any leftover
* change comes back as proofs in a fresh kind:7375.
* Phase 1 of melt — ask the mint how much an invoice will cost and what
* its fee_reserve is. Pure read; no state mutation. UI shows the quote to
* the user; they confirm; the wallet calls [meltToLightning] with the
* same quote to actually pay.
*/
suspend fun requestMeltQuote(
mintUrl: String,
invoice: String,
): MeltQuoteBolt11ResponseDto = ops(mintUrl).requestMeltQuote(invoice)
/**
* Phase 2 of melt — pay the invoice using the agreed-upon [quote].
* Spends [available] token events: picks the smallest subset whose total
* covers `amount + fee_reserve` (greedy by total amount descending). Any
* leftover change comes back as proofs in a fresh kind:7375.
*/
suspend fun meltToLightning(
mintUrl: String,
invoice: String,
quote: MeltQuoteBolt11ResponseDto,
available: List<TokenEntry>,
): MeltCompleted {
if (available.isEmpty()) throw IllegalStateException("No proofs available to spend")
val ops = ops(mintUrl)
val quote = ops.requestMeltQuote(invoice)
val required = quote.amount + quote.feeReserve
val (selected, _) = selectProofsCovering(available, required)
@@ -214,9 +241,9 @@ class CashuWalletOps(
val keepEvent =
if (swap.keep.isNotEmpty()) {
val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id })
val tokenTemplate = CashuTokenEvent.build(content, account.signer)
val signed = account.signer.sign(tokenTemplate)
account.sendLiterallyEverywhere(signed)
val tokenTemplate = CashuTokenEvent.build(content, signer)
val signed = signer.sign(tokenTemplate)
publish(signed)
signed
} else {
null
@@ -239,9 +266,9 @@ class CashuWalletOps(
selected.map { it.event.id }
}
val content = TokenContent(mint = mintUrl, proofs = meltResult.changeProofs, del = delIds)
val tokenTemplate = CashuTokenEvent.build(content, account.signer)
val signed = account.signer.sign(tokenTemplate)
account.sendLiterallyEverywhere(signed)
val tokenTemplate = CashuTokenEvent.build(content, signer)
val signed = signer.sign(tokenTemplate)
publish(signed)
signed
} else {
null
@@ -253,7 +280,7 @@ class CashuWalletOps(
run {
val toDelete = selected.map { it.event } + listOfNotNull(prePaidChangeEvent.takeIf { finalChangeEvent != null })
val delTemplate = DeletionEvent.build(toDelete)
account.signer.sign(delTemplate).also { account.sendLiterallyEverywhere(it) }
signer.sign(delTemplate).also { publish(it) }
}
val historyTemplate =
@@ -267,10 +294,10 @@ class CashuWalletOps(
}
finalChangeEvent?.let { add(TokenReference(it.id, null, TokenReference.MARKER_CREATED)) }
},
signer = account.signer,
signer = signer,
)
val historyEvent = account.signer.sign(historyTemplate)
account.sendLiterallyEverywhere(historyEvent)
val historyEvent = signer.sign(historyTemplate)
publish(historyEvent)
return MeltCompleted(
preimage = meltResult.preimage,
@@ -305,9 +332,9 @@ class CashuWalletOps(
val newKeepEvent =
if (swap.keep.isNotEmpty()) {
val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id })
val template = CashuTokenEvent.build(content, account.signer)
val signed = account.signer.sign(template)
account.sendLiterallyEverywhere(signed)
val template = CashuTokenEvent.build(content, signer)
val signed = signer.sign(template)
publish(signed)
signed
} else {
null
@@ -316,7 +343,7 @@ class CashuWalletOps(
val deleteEvent =
run {
val template = DeletionEvent.build(selected.map { it.event })
account.signer.sign(template).also { account.sendLiterallyEverywhere(it) }
signer.sign(template).also { publish(it) }
}
val historyTemplate =
@@ -328,10 +355,10 @@ class CashuWalletOps(
selected.forEach { add(TokenReference(it.event.id, null, TokenReference.MARKER_DESTROYED)) }
newKeepEvent?.let { add(TokenReference(it.id, null, TokenReference.MARKER_CREATED)) }
},
signer = account.signer,
signer = signer,
)
val historyEvent = account.signer.sign(historyTemplate)
account.sendLiterallyEverywhere(historyEvent)
val historyEvent = signer.sign(historyTemplate)
publish(historyEvent)
return SendTokenCompleted(
cashuToken = tokenString,
@@ -361,9 +388,9 @@ class CashuWalletOps(
// All output goes to "keep" since targetSplit was null.
val content = TokenContent(mint = mintUrl, proofs = swap.keep)
val tokenTemplate = CashuTokenEvent.build(content, account.signer)
val tokenEvent = account.signer.sign(tokenTemplate)
account.sendLiterallyEverywhere(tokenEvent)
val tokenTemplate = CashuTokenEvent.build(content, signer)
val tokenEvent = signer.sign(tokenTemplate)
publish(tokenEvent)
val historyTemplate =
CashuSpendingHistoryEvent.build(
@@ -374,10 +401,10 @@ class CashuWalletOps(
add(TokenReference(tokenEvent.id, null, TokenReference.MARKER_CREATED))
nutzapEventId?.let { add(TokenReference(it, null, TokenReference.MARKER_REDEEMED)) }
},
signer = account.signer,
signer = signer,
)
val historyEvent = account.signer.sign(historyTemplate)
account.sendLiterallyEverywhere(historyEvent)
val historyEvent = signer.sign(historyTemplate)
publish(historyEvent)
return RedeemCompleted(
amount = total,
@@ -407,6 +434,7 @@ class CashuWalletOps(
suspend fun redeemNutzap(
nutzap: NutzapEvent,
walletPrivkeyHex: String,
walletP2pkPubkeyHex: String,
): RedeemCompleted {
val mintUrl =
nutzap.mintUrl()
@@ -425,13 +453,29 @@ class CashuWalletOps(
)
}
// Verify the lock points at us before spending a mint round-trip. The
// pubkey in the P2PK secret can be 64-char x-only or 66-char
// compressed; normalize to x-only for the comparison since BIP-340
// verification (which the mint uses) is parity-agnostic.
val ourXOnly = walletP2pkPubkeyHex.lastHex64()
parsedProofs.forEach { proof ->
val parsed =
P2PK.parseSecret(proof.secret)
?: throw IllegalArgumentException("Nutzap proof is not P2PK-locked")
if (parsed.pubKeyHex.lastHex64() != ourXOnly) {
throw IllegalArgumentException(
"Nutzap proof is locked to a different pubkey (${parsed.pubKeyHex.take(16)}…)",
)
}
}
val swap = ops(mintUrl).redeemNutzap(parsedProofs, walletPrivkeyHex)
val total = swap.keep.sumOf { it.amount }
val tokenContent = TokenContent(mint = mintUrl, proofs = swap.keep)
val tokenTemplate = CashuTokenEvent.build(tokenContent, account.signer)
val tokenEvent = account.signer.sign(tokenTemplate)
account.sendLiterallyEverywhere(tokenEvent)
val tokenTemplate = CashuTokenEvent.build(tokenContent, signer)
val tokenEvent = signer.sign(tokenTemplate)
publish(tokenEvent)
val historyTemplate =
CashuSpendingHistoryEvent.build(
@@ -442,10 +486,10 @@ class CashuWalletOps(
TokenReference(tokenEvent.id, null, TokenReference.MARKER_CREATED),
TokenReference(nutzap.id, null, TokenReference.MARKER_REDEEMED),
),
signer = account.signer,
signer = signer,
)
val historyEvent = account.signer.sign(historyTemplate)
account.sendLiterallyEverywhere(historyEvent)
val historyEvent = signer.sign(historyTemplate)
publish(historyEvent)
return RedeemCompleted(
amount = total,
@@ -475,14 +519,27 @@ class CashuWalletOps(
return picked to running
}
/** Catches mint HTTP errors and surfaces their detail message. */
fun describe(e: Throwable): String =
when (e) {
is MintHttpException -> "Mint error (HTTP ${e.httpStatus}): ${e.detail ?: e.message}"
else -> e.message ?: e::class.simpleName ?: "Unknown error"
}
/**
* Ping `/v1/info` on [mintUrl] and return the mint's display name on
* success. Used by the Add-Mint UI to give immediate feedback when a URL
* is typo'd, points at a non-Cashu host, or is otherwise unreachable.
*
* Throws on failure so the UI can surface the underlying reason.
*/
suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, okHttpClient).info().name
}
/** Drop the leading parity byte if present so two pubkeys can be compared. */
private fun String.lastHex64(): String = if (length == 66) substring(2) else this
/** Catches mint HTTP / protocol errors and surfaces their detail message. */
fun describeMintError(e: Throwable): String =
when (e) {
is MintHttpException -> "Mint error (HTTP ${e.httpStatus}): ${e.detail ?: e.message}"
is MintProtocolException -> "Mint refused: ${e.message}"
else -> e.message ?: e::class.simpleName ?: "Unknown error"
}
/** A decrypted, unspent token event ready to be spent. */
data class TokenEntry(
val event: CashuTokenEvent,
@@ -0,0 +1,433 @@
/*
* 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.model.nip60Cashu
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
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.nutzap.NutzapEvent
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
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
/**
* Account-scoped state holder for the NIP-60 Cashu wallet + NIP-61 nutzaps.
*
* Lives on [com.vitorpamplona.amethyst.model.Account], so it stays alive for
* the lifetime of the login session — not just while the wallet screen is
* visible. This matters because:
*
* - Inbound nutzaps (kind 9321) need to be auto-redeemed when they arrive,
* regardless of which screen the user is on.
* - The wallet event + token events need to land in [LocalCache] on first
* launch (or on fresh device sign-in) without requiring the user to open
* the wallet screen first.
*
* Mirrors the shape of [com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState]
* — a single state object on Account, ViewModels are thin presenters.
*
* Reactivity: subscribes to [LocalCache.live.newEventBundles] (and the
* delete bundle) and re-indexes any incoming NIP-60/NIP-61 event authored by
* or addressed to this account. The full [LocalCache.notes] map is scanned
* ONCE during init to backfill; after that all updates are incremental.
*
* Auto-redeem is serialized through a [Mutex] so concurrent cache updates
* don't fire duplicate /v1/swap calls against the mint.
*/
class CashuWalletState(
private val pubKey: HexKey,
private val signer: NostrSigner,
private val cache: LocalCache,
private val scope: CoroutineScope,
private val assembler: CashuWalletFilterAssembler,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
okHttpClient: (String) -> OkHttpClient,
) {
val ops: CashuWalletOps =
CashuWalletOps(
signer = signer,
publish = ::publishEvent,
okHttpClient = okHttpClient,
)
// ============================================================
// Raw indexes — keyed by event id, mutated only on the cache thread.
// ============================================================
private var walletEventInternal: CashuWalletEvent? = null
private val tokenEvents = ConcurrentHashMap<HexKey, CashuTokenEvent>()
private val historyEvents = ConcurrentHashMap<HexKey, CashuSpendingHistoryEvent>()
private val quoteEvents = ConcurrentHashMap<HexKey, CashuMintQuoteEvent>()
private val nutzapEvents = ConcurrentHashMap<HexKey, NutzapEvent>()
/** NIP-44 decryption cache for token contents, keyed by event id. */
private val tokenContents = ConcurrentHashMap<HexKey, TokenContent>()
private val redeemMutex = Mutex()
// ============================================================
// Public flows
// ============================================================
private val _walletEvent = MutableStateFlow<CashuWalletEvent?>(null)
val walletEvent: StateFlow<CashuWalletEvent?> = _walletEvent.asStateFlow()
private val _mints = MutableStateFlow<List<String>>(emptyList())
val mints: StateFlow<List<String>> = _mints.asStateFlow()
private val _tokenEntries = MutableStateFlow<List<TokenEntry>>(emptyList())
val tokenEntries: StateFlow<List<TokenEntry>> = _tokenEntries.asStateFlow()
val balanceSats: StateFlow<Long> =
_tokenEntries
.map { entries -> entries.sumOf { it.content.totalAmount() } }
.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, 0L)
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
val history: StateFlow<List<CashuSpendingHistoryEvent>> = _history.asStateFlow()
/** Unfulfilled, unexpired kind:7374 events — surfaced for resume UI. */
private val _pendingQuotes = MutableStateFlow<List<CashuMintQuoteEvent>>(emptyList())
val pendingQuotes: StateFlow<List<CashuMintQuoteEvent>> = _pendingQuotes.asStateFlow()
fun hasWallet(): Boolean = _walletEvent.value != null
/** Read the wallet's P2PK pubkey (33-byte compressed hex). */
suspend fun p2pkPubkeyHex(): String? {
val priv = walletPrivkeyHex() ?: return null
return Secp256k1
.pubKeyCompress(Secp256k1.pubkeyCreate(priv.hexToByteArray()))
.toHexKey()
}
private suspend fun walletPrivkeyHex(): String? =
_walletEvent.value?.let { evt ->
runCatching { evt.privkey(signer) }.getOrNull()
}
// ============================================================
// Lifecycle
// ============================================================
private val jobs = mutableListOf<Job>()
private var currentSubscription: CashuWalletQueryState? = null
init {
// Backfill from cache once.
scope.launch(Dispatchers.Default) {
val initial = scanCacheForOwnEvents()
applyEvents(initial)
recomputePending()
triggerAutoRedeem()
}
// Keep the relay subscription in sync with the outbox set.
jobs +=
scope.launch(Dispatchers.IO) {
outboxRelaysFlow.collect { relays ->
syncSubscription(relays)
}
}
// Reactive incremental update: any new event arrival that matches our
// pubkey + the NIP-60/61 kinds we care about gets indexed.
jobs +=
scope.launch(Dispatchers.Default) {
cache.live.newEventBundles.collect { notes ->
val ours = notes.mapNotNull { it.event }.filter(::isRelevantEvent)
if (ours.isNotEmpty()) {
applyEvents(ours)
recomputePending()
triggerAutoRedeem()
}
}
}
jobs +=
scope.launch(Dispatchers.Default) {
cache.live.deletedEventBundles.collect { notes ->
val ids = notes.mapNotNull { it.event?.id }.toSet()
if (ids.isNotEmpty()) removeEvents(ids)
}
}
}
fun destroy() {
jobs.forEach { it.cancel() }
jobs.clear()
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
}
// ============================================================
// Subscription management
// ============================================================
private fun syncSubscription(relays: Set<NormalizedRelayUrl>) {
val previous = currentSubscription
if (relays.isEmpty()) {
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
return
}
if (previous != null && previous.relays == relays) return // unchanged
previous?.let { runCatching { assembler.unsubscribe(it) } }
val next = CashuWalletQueryState(pubKey, relays)
currentSubscription = next
assembler.subscribe(next)
}
// ============================================================
// Indexing
// ============================================================
private fun isRelevantEvent(event: Event): Boolean =
when (event) {
is CashuWalletEvent, is CashuTokenEvent, is CashuSpendingHistoryEvent,
is CashuMintQuoteEvent,
-> event.pubKey == pubKey
// Inbound nutzaps: addressed to us, possibly authored by someone
// else. Match by the recipient `#p` tag.
is NutzapEvent -> event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == pubKey }
else -> false
}
private suspend fun applyEvents(events: List<Event>) {
var dirtyWallet = false
var dirtyTokens = false
var dirtyHistory = false
var dirtyQuotes = false
var dirtyNutzaps = false
for (event in events) {
when (event) {
is CashuWalletEvent -> {
// Replaceable: keep the latest by created_at.
val current = walletEventInternal
if (current == null || event.createdAt > current.createdAt) {
walletEventInternal = event
dirtyWallet = true
}
}
is CashuTokenEvent -> {
if (tokenEvents.put(event.id, event) == null) dirtyTokens = true
}
is CashuSpendingHistoryEvent -> {
if (historyEvents.put(event.id, event) == null) dirtyHistory = true
}
is CashuMintQuoteEvent -> {
if (quoteEvents.put(event.id, event) == null) dirtyQuotes = true
}
is NutzapEvent -> {
if (nutzapEvents.put(event.id, event) == null) dirtyNutzaps = true
}
else -> Unit
}
}
if (dirtyWallet) {
_walletEvent.value = walletEventInternal
walletEventInternal?.let { evt ->
_mints.value =
runCatching { evt.mints(signer) }
.onFailure { Log.w("CashuWallet") { "Failed to decrypt wallet mints: ${it.message}" } }
.getOrNull() ?: emptyList()
} ?: run { _mints.value = emptyList() }
}
if (dirtyTokens) recomputeUnspent()
if (dirtyHistory) {
_history.value = historyEvents.values.sortedByDescending { it.createdAt }
}
if (dirtyQuotes || dirtyHistory) {
// History gains might mark quotes as fulfilled (via the "destroyed"
// kind:7374 reference); recompute the pending list.
recomputePending()
}
if (dirtyNutzaps) {
triggerAutoRedeem()
}
}
private suspend fun removeEvents(ids: Set<HexKey>) {
var dirtyTokens = false
var dirtyHistory = false
var dirtyQuotes = false
var dirtyNutzaps = false
var dirtyWallet = false
ids.forEach { id ->
if (tokenEvents.remove(id) != null) {
dirtyTokens = true
tokenContents.remove(id)
}
if (historyEvents.remove(id) != null) dirtyHistory = true
if (quoteEvents.remove(id) != null) dirtyQuotes = true
if (nutzapEvents.remove(id) != null) dirtyNutzaps = true
if (walletEventInternal?.id == id) {
walletEventInternal = null
dirtyWallet = true
}
}
if (dirtyWallet) {
_walletEvent.value = null
_mints.value = emptyList()
}
if (dirtyTokens) recomputeUnspent()
if (dirtyHistory) _history.value = historyEvents.values.sortedByDescending { it.createdAt }
if (dirtyQuotes || dirtyHistory) recomputePending()
// dirtyNutzaps would trigger UI surfacing for inbound nutzaps; auto-
// redeem already fires from the live-event observer, so no extra
// signal is needed here.
if (dirtyNutzaps) Unit
}
private suspend fun recomputeUnspent() {
val all = tokenEvents.values.toList()
// Decrypt anything we haven't seen before; reuse cached TokenContent
// for events we've already decrypted.
all.forEach { evt ->
tokenContents.getOrPut(evt.id) {
runCatching { evt.tokenContent(signer) }
.onFailure { Log.w("CashuWallet") { "Failed to decrypt token ${evt.id.take(8)}: ${it.message}" } }
.getOrNull() ?: return@getOrPut return@forEach
}
}
// 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
}
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 }
}
private fun scanCacheForOwnEvents(): List<Event> {
val collected = mutableListOf<Event>()
cache.notes.forEach { _, note ->
val e = note.event ?: return@forEach
if (isRelevantEvent(e)) collected += e
}
return collected
}
// ============================================================
// Auto-redeem of inbound NIP-61 nutzaps
// ============================================================
private fun triggerAutoRedeem() {
scope.launch(Dispatchers.IO) { redeemPendingNutzapsSerialized() }
}
private suspend fun redeemPendingNutzapsSerialized() {
if (!redeemMutex.tryLock()) return // a sweep is already in flight
try {
val privkey = walletPrivkeyHex() ?: return
val pubkey = p2pkPubkeyHex() ?: return
val alreadyRedeemed =
historyEvents.values
.flatMap { it.redeemedReferences() }
.map { it.eventId }
.toSet()
val candidates = nutzapEvents.values.filter { it.id !in alreadyRedeemed }
if (candidates.isEmpty()) return
for (ev in candidates) {
runCatching { ops.redeemNutzap(ev, privkey, pubkey) }
.onFailure { e ->
Log.w("CashuWallet") {
"Auto-redeem of nutzap ${ev.id.take(8)} failed: ${describeMintError(e)}"
}
}
}
} finally {
redeemMutex.unlock()
}
}
// ============================================================
// Publish bridge
// ============================================================
/**
* Bridge for [CashuWalletOps.publish]. Concrete `Account` plugs in its
* `sendLiterallyEverywhere` via the constructor-time wiring. We keep this
* delegate field separate to avoid an Account ↔ State direct dependency.
*/
var publishDelegate: suspend (Event) -> Unit = { /* set by Account */ }
private suspend fun publishEvent(event: Event) {
publishDelegate(event)
}
}
@@ -2177,6 +2177,11 @@ fun mockAccountViewModel(): AccountViewModel {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { nwcFilters },
cashuWalletFilterAssembler = {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuWalletFilterAssembler(client)
},
okHttpClientForMoney = { okhttp3.OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = client,
@@ -2228,6 +2233,11 @@ fun mockVitorAccountViewModel(): AccountViewModel {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { nwcFilters },
cashuWalletFilterAssembler = {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuWalletFilterAssembler(client)
},
okHttpClientForMoney = { okhttp3.OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = EmptyNostrClient(),
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -143,25 +144,43 @@ fun AddCashuWalletScreen(
Spacer(modifier = Modifier.height(8.dp))
val pingState by viewModel.mintPingState.collectAsState()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = mintInput,
onValueChange = { mintInput = it },
onValueChange = {
mintInput = it
viewModel.resetMintPing()
},
label = { Text(stringRes(R.string.cashu_mint_url)) },
placeholder = { Text("https://mint.example.com") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(
onClick = {
viewModel.pingMint(mintInput.trim().trimEnd('/'))
},
enabled = mintInput.isNotBlank() && pingState !is MintPingState.Pinging,
) {
if (pingState is MintPingState.Pinging) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringRes(R.string.cashu_verify))
}
}
Spacer(modifier = Modifier.width(4.dp))
OutlinedButton(
onClick = {
val trimmed = mintInput.trim().trimEnd('/')
if (trimmed.isNotEmpty() && trimmed !in mints) {
mints.add(trimmed)
mintInput = ""
viewModel.resetMintPing()
}
},
enabled = mintInput.isNotBlank(),
@@ -170,6 +189,31 @@ fun AddCashuWalletScreen(
}
}
when (val ps = pingState) {
is MintPingState.Ok -> {
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
if (ps.name.isNullOrBlank()) {
stringRes(R.string.cashu_mint_reachable)
} else {
stringRes(R.string.cashu_mint_reachable_named, ps.name)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
is MintPingState.Failed -> {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.cashu_mint_unreachable, ps.message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
else -> Unit
}
Spacer(modifier = Modifier.height(24.dp))
Text(
@@ -620,6 +620,24 @@ private fun SendLnDialog(
text = {
Column {
when (val s = state) {
is CashuMeltFlowState.Quoting -> {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text(stringRes(R.string.cashu_getting_quote))
}
}
is CashuMeltFlowState.Quoted -> {
Text(
stringRes(
R.string.cashu_quote_confirm,
s.quote.amount.toString(),
s.quote.feeReserve.toString(),
),
)
}
is CashuMeltFlowState.Paying -> {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
@@ -666,9 +684,15 @@ private fun SendLnDialog(
when (state) {
is CashuMeltFlowState.Idle, is CashuMeltFlowState.Error -> {
TextButton(
onClick = { viewModel.meltToLightning(pickedMint, invoice) },
onClick = { viewModel.startMelt(pickedMint, invoice) },
enabled = invoice.isNotBlank() && pickedMint.isNotBlank(),
) { Text(stringRes(R.string.cashu_pay_invoice)) }
) { Text(stringRes(R.string.cashu_get_quote)) }
}
is CashuMeltFlowState.Quoted -> {
TextButton(onClick = { viewModel.confirmMelt() }) {
Text(stringRes(R.string.cashu_pay_invoice))
}
}
is CashuMeltFlowState.Completed -> {
@@ -21,32 +21,21 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
import com.vitorpamplona.amethyst.model.nip60Cashu.MintQuoteStarted
import com.vitorpamplona.amethyst.model.nip60Cashu.TokenEntry
import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError
import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser
import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
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.nutzap.NutzapEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
sealed class CashuWalletCreateState {
data object Idle : CashuWalletCreateState()
@@ -85,6 +74,15 @@ sealed class CashuMintFlowState {
sealed class CashuMeltFlowState {
data object Idle : CashuMeltFlowState()
data object Quoting : CashuMeltFlowState()
/** Mint returned a quote — show amount + fee, await user confirmation. */
data class Quoted(
val mintUrl: String,
val invoice: String,
val quote: MeltQuoteBolt11ResponseDto,
) : CashuMeltFlowState()
data object Paying : CashuMeltFlowState()
data class Completed(
@@ -128,41 +126,25 @@ sealed class CashuRedeemFlowState {
}
/**
* ViewModel for the NIP-60 Cashu wallet screens.
* Presenter ViewModel for the NIP-60 Cashu wallet screens.
*
* Exposes:
* - wallet bootstrap (create/edit kind:17375 + kind:10019)
* - balance / mint list / history / unspent token entries derived from LocalCache
* - mint-from-LN flow (start quote → poll → complete)
* - melt-to-LN flow (pay bolt11)
* - send-as-token flow (produce cashuB)
* - redeem cashuB token flow
* All persistent state lives on [Account.cashuWalletState]; this VM only
* holds the transient per-flow UI state (mint quote in progress, melt
* confirmation pending, redeem result). State flows are forwarded directly
* from [CashuWalletState] so they survive screen lifecycle.
*/
class CashuWalletViewModel : ViewModel() {
private var account: Account? = null
private var accountViewModel: AccountViewModel? = null
private val ops by lazy {
val acc = account ?: error("init() not called")
CashuWalletOps(acc, Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForMoney)
}
private val assembler get() = Amethyst.instance.sources.cashuWallet
private var subscription: CashuWalletQueryState? = null
private var observerJob: Job? = null
private val state: CashuWalletState get() = account!!.cashuWalletState
private val ops: CashuWalletOps get() = state.ops
private val _walletEvent = MutableStateFlow<CashuWalletEvent?>(null)
val walletEvent = _walletEvent.asStateFlow()
private val _mints = MutableStateFlow<List<String>>(emptyList())
val mints = _mints.asStateFlow()
private val _balanceSats = MutableStateFlow(0L)
val balanceSats = _balanceSats.asStateFlow()
private val _tokenEntries = MutableStateFlow<List<TokenEntry>>(emptyList())
val tokenEntries = _tokenEntries.asStateFlow()
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
val history = _history.asStateFlow()
val walletEvent get() = state.walletEvent
val mints get() = state.mints
val balanceSats get() = state.balanceSats
val tokenEntries: StateFlow<List<TokenEntry>> get() = state.tokenEntries
val history get() = state.history
val pendingQuotes get() = state.pendingQuotes
private val _createState = MutableStateFlow<CashuWalletCreateState>(CashuWalletCreateState.Idle)
val createState = _createState.asStateFlow()
@@ -179,151 +161,42 @@ class CashuWalletViewModel : ViewModel() {
private val _redeemState = MutableStateFlow<CashuRedeemFlowState>(CashuRedeemFlowState.Idle)
val redeemState = _redeemState.asStateFlow()
private val _mintPingState = MutableStateFlow<MintPingState>(MintPingState.Idle)
val mintPingState = _mintPingState.asStateFlow()
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
// Subscribe to relay events so the wallet/proofs/history/nutzaps
// arrive from outbox relays on first launch and stay in sync.
val pubkey = accountViewModel.account.signer.pubKey
val relays = accountViewModel.account.outboxRelays.flow.value
if (relays.isNotEmpty()) {
val query = CashuWalletQueryState(pubkey, relays)
subscription = query
assembler.subscribe(query)
}
// Re-derive state from the cache whenever the wallet note changes.
// Token / history events that arrive via the same subscription are
// captured on the next refresh tick.
val walletNote = LocalCache.getOrCreateAddressableNote(CashuWalletEvent.createAddress(pubkey))
observerJob?.cancel()
observerJob =
viewModelScope.launch(Dispatchers.IO) {
walletNote
.flow()
.metadata.stateFlow
.collect { refresh() }
}
refresh()
// No subscription / observer / refresh here — CashuWalletState owns
// that lifecycle and is alive for the whole login session.
}
override fun onCleared() {
observerJob?.cancel()
observerJob = null
subscription?.let { runCatching { assembler.unsubscribe(it) } }
subscription = null
super.onCleared()
}
/**
* Scans the local cache for our wallet event and its associated token /
* history events, decrypting whatever the current signer can decrypt and
* re-emitting state flows.
*
* Today this is fire-and-forget. Reactive observation of the underlying
* notes is a follow-up — for the first slice, the screen calls refresh()
* on entry and after a write.
*/
fun refresh() {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val pubKey = acc.signer.pubKey
val cache = LocalCache
val walletNote = cache.getOrCreateAddressableNote(CashuWalletEvent.createAddress(pubKey))
val walletEvt = walletNote.event as? CashuWalletEvent
_walletEvent.value = walletEvt
if (walletEvt != null) {
runCatching { walletEvt.mints(acc.signer) }
.onSuccess { _mints.value = it }
} else {
_mints.value = emptyList()
/** Verify a mint URL is reachable + speaks Cashu v1. */
fun pingMint(url: String) {
val vm = accountViewModel ?: return
if (url.isBlank()) return
_mintPingState.value = MintPingState.Pinging
vm.launchSigner {
try {
val name = ops.pingMint(url.trim().trimEnd('/'))
_mintPingState.value = MintPingState.Ok(name)
} catch (e: Exception) {
_mintPingState.value = MintPingState.Failed(describeMintError(e))
}
val tokenList = mutableListOf<CashuTokenEvent>()
val historyList = mutableListOf<CashuSpendingHistoryEvent>()
cache.notes.forEach { _, note ->
val e = note.event ?: return@forEach
if (e.pubKey != pubKey) return@forEach
when (e) {
is CashuTokenEvent -> tokenList.add(e)
is CashuSpendingHistoryEvent -> historyList.add(e)
else -> Unit
}
}
// Apply rollover: drop any token whose ID appears in a newer token's `del`.
val deletedIds = mutableSetOf<String>()
val decoded = mutableMapOf<String, TokenContent>()
tokenList.forEach { tok ->
runCatching { tok.tokenContent(acc.signer) }
.getOrNull()
?.let { content ->
decoded[tok.id] = content
deletedIds.addAll(content.del)
}
}
val unspent = tokenList.filter { it.id !in deletedIds && decoded[it.id] != null }
val entries = unspent.mapNotNull { e -> decoded[e.id]?.let { TokenEntry(e, it) } }
_tokenEntries.value = entries.sortedByDescending { it.event.createdAt }
_balanceSats.value = entries.sumOf { it.content.totalAmount() }
_history.value = historyList.sortedByDescending { it.createdAt }
// Best-effort auto-redeem of any inbound NIP-61 nutzaps that we
// haven't already redeemed. Idempotent — guarded by the redeemed
// marker in our kind:7376 history.
redeemPendingNutzaps(walletEvt, historyList)
}
}
private suspend fun redeemPendingNutzaps(
walletEvt: CashuWalletEvent?,
history: List<CashuSpendingHistoryEvent>,
) {
val acc = account ?: return
if (walletEvt == null) return
val privkey = runCatching { walletEvt.privkey(acc.signer) }.getOrNull() ?: return
val alreadyRedeemed =
history
.flatMap { it.redeemedReferences() }
.map { it.eventId }
.toSet()
val candidates =
buildList {
LocalCache.notes.forEach { _, note ->
val e = note.event ?: return@forEach
if (e is NutzapEvent && e.id !in alreadyRedeemed) add(e)
}
}
if (candidates.isEmpty()) return
candidates.forEach { ev ->
runCatching { ops.redeemNutzap(ev, privkey) }
.onFailure {
// Swallow; we'll retry on the next refresh. The mint may
// have rejected for double-spend (someone else redeemed
// first) or other transient reasons.
}
}
fun resetMintPing() {
_mintPingState.value = MintPingState.Idle
}
/**
* Builds and publishes a kind:17375 + kind:10019 pair. The wallet event is
* replaceable so calling this again with different mints (or a different
* privkey) overwrites the previous wallet.
*/
fun saveWallet(
mints: List<String>,
autoGenPrivkey: Boolean,
manualPrivkey: String? = null,
) {
val vm = accountViewModel ?: return
val acc = account ?: return
if (mints.isEmpty()) {
_createState.value = CashuWalletCreateState.Error("Add at least one mint")
@@ -335,17 +208,20 @@ class CashuWalletViewModel : ViewModel() {
try {
val privkey =
when {
autoGenPrivkey -> Bdhke.randomScalar().toHexKey()
autoGenPrivkey -> null // ops generates one
!manualPrivkey.isNullOrBlank() -> manualPrivkey.trim()
else -> null
}
ops.publishWalletEvents(mints, privkey)
ops.publishWalletEvents(
mints = mints,
p2pkPrivkeyHex = privkey,
nutzapRelays =
acc.outboxRelays.flow.value
.toList(),
)
_createState.value = CashuWalletCreateState.Success
refresh()
} catch (e: Exception) {
_createState.value =
CashuWalletCreateState.Error(ops.describe(e))
_createState.value = CashuWalletCreateState.Error(describeMintError(e))
}
}
}
@@ -374,9 +250,8 @@ class CashuWalletViewModel : ViewModel() {
try {
val flow = ops.startMintFromLightning(mintUrl, amountSats)
_mintState.value = CashuMintFlowState.AwaitingPayment(flow, mintUrl, amountSats)
refresh()
} catch (e: Exception) {
_mintState.value = CashuMintFlowState.Error(ops.describe(e))
_mintState.value = CashuMintFlowState.Error(describeMintError(e))
}
}
}
@@ -387,19 +262,42 @@ class CashuWalletViewModel : ViewModel() {
*/
fun checkAndCompleteMint() {
val vm = accountViewModel ?: return
val state = _mintState.value as? CashuMintFlowState.AwaitingPayment ?: return
val current = _mintState.value as? CashuMintFlowState.AwaitingPayment ?: return
vm.launchSigner {
try {
val status = ops.checkMintQuote(state.mintUrl, state.flow.mintQuote.quote)
val status = ops.checkMintQuote(current.mintUrl, current.flow.mintQuote.quote)
val paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED"
if (!paid) return@launchSigner
_mintState.value = CashuMintFlowState.Completing
ops.completeMintFromLightning(state.mintUrl, state.flow.quoteEvent, state.amountSats)
_mintState.value = CashuMintFlowState.Completed(state.amountSats)
refresh()
ops.completeMintFromLightning(current.mintUrl, current.flow.quoteEvent, current.amountSats)
_mintState.value = CashuMintFlowState.Completed(current.amountSats)
} catch (e: Exception) {
_mintState.value = CashuMintFlowState.Error(ops.describe(e))
_mintState.value = CashuMintFlowState.Error(describeMintError(e))
}
}
}
/** Resume polling an unfulfilled kind:7374 quote left over from a previous session. */
fun resumeMintQuote(quoteEvent: com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent) {
val vm = accountViewModel ?: return
val mintUrl =
quoteEvent.mint() ?: run {
_mintState.value = CashuMintFlowState.Error("Quote has no mint tag")
return
}
vm.launchSigner {
try {
val quoteId = quoteEvent.quoteId(account!!.signer)
val status = ops.checkMintQuote(mintUrl, quoteId)
_mintState.value =
CashuMintFlowState.AwaitingPayment(
flow = MintQuoteStarted(quoteEvent = quoteEvent, mintQuote = status, invoice = status.request),
mintUrl = mintUrl,
amountSats = 0L, // unknown — caller may have to re-enter, mint will validate
)
} catch (e: Exception) {
_mintState.value = CashuMintFlowState.Error(describeMintError(e))
}
}
}
@@ -410,7 +308,11 @@ class CashuWalletViewModel : ViewModel() {
// -------- Melt to LN --------
fun meltToLightning(
/**
* Phase 1 of melt — request a quote so the user can see amount + fees
* before committing. Use [confirmMelt] to actually pay.
*/
fun startMelt(
mintUrl: String,
invoice: String,
) {
@@ -423,20 +325,44 @@ class CashuWalletViewModel : ViewModel() {
_meltState.value = CashuMeltFlowState.Error("Pick a mint")
return
}
val available = _tokenEntries.value.filter { it.content.mint == mintUrl }
val available = tokenEntries.value.filter { it.content.mint == mintUrl }
if (available.isEmpty()) {
_meltState.value = CashuMeltFlowState.Error("No proofs available at $mintUrl")
return
}
_meltState.value = CashuMeltFlowState.Quoting
vm.launchSigner {
try {
val quote = ops.requestMeltQuote(mintUrl, invoice.trim())
val balance = available.sumOf { it.content.totalAmount() }
if (balance < quote.amount + quote.feeReserve) {
_meltState.value =
CashuMeltFlowState.Error(
"Need ${quote.amount + quote.feeReserve} sat (incl. fees) but only have $balance",
)
return@launchSigner
}
_meltState.value = CashuMeltFlowState.Quoted(mintUrl, invoice.trim(), quote)
} catch (e: Exception) {
_meltState.value = CashuMeltFlowState.Error(describeMintError(e))
}
}
}
/** Phase 2 of melt — user confirmed the fee, pay the invoice now. */
fun confirmMelt() {
val vm = accountViewModel ?: return
val quoted = _meltState.value as? CashuMeltFlowState.Quoted ?: return
val available = tokenEntries.value.filter { it.content.mint == quoted.mintUrl }
_meltState.value = CashuMeltFlowState.Paying
vm.launchSigner {
try {
val result = ops.meltToLightning(mintUrl, invoice.trim(), available)
val result = ops.meltToLightning(quoted.mintUrl, quoted.quote, available)
_meltState.value = CashuMeltFlowState.Completed(result.paidAmount, result.fees, result.preimage)
refresh()
} catch (e: Exception) {
_meltState.value = CashuMeltFlowState.Error(ops.describe(e))
_meltState.value = CashuMeltFlowState.Error(describeMintError(e))
}
}
}
@@ -461,7 +387,7 @@ class CashuWalletViewModel : ViewModel() {
_sendTokenState.value = CashuSendTokenFlowState.Error("Pick a mint")
return
}
val available = _tokenEntries.value.filter { it.content.mint == mintUrl }
val available = tokenEntries.value.filter { it.content.mint == mintUrl }
val balanceAtMint = available.sumOf { it.content.totalAmount() }
if (balanceAtMint < amountSats) {
_sendTokenState.value =
@@ -474,9 +400,8 @@ class CashuWalletViewModel : ViewModel() {
try {
val result = ops.sendAsToken(mintUrl, amountSats, available, memo)
_sendTokenState.value = CashuSendTokenFlowState.Ready(result.cashuToken, result.amount)
refresh()
} catch (e: Exception) {
_sendTokenState.value = CashuSendTokenFlowState.Error(ops.describe(e))
_sendTokenState.value = CashuSendTokenFlowState.Error(describeMintError(e))
}
}
}
@@ -505,29 +430,18 @@ class CashuWalletViewModel : ViewModel() {
_redeemState.value = CashuRedeemFlowState.Error("Token has no proofs")
return
}
tok.mint to
tok.proofs.map {
CashuProof(
id = it.id,
amount = it.amount.toLong(),
secret = it.secret,
c = it.C,
)
}
tok.mint to tok.proofs.map { CashuProof(it.id, it.amount.toLong(), it.secret, it.C) }
}
is GenericLoadable.Error -> {
_redeemState.value = CashuRedeemFlowState.Error(parsed.errorMessage)
return
}
else -> {
_redeemState.value = CashuRedeemFlowState.Error("Could not parse token")
return
}
}
}
trimmed.startsWith("cashuA") -> {
when (val parsed = V3Parser.parseCashuA(trimmed)) {
is GenericLoadable.Loaded -> {
@@ -536,29 +450,18 @@ class CashuWalletViewModel : ViewModel() {
_redeemState.value = CashuRedeemFlowState.Error("Token has no proofs")
return
}
tok.mint to
tok.proofs.map {
CashuProof(
id = it.id,
amount = it.amount.toLong(),
secret = it.secret,
c = it.C,
)
}
tok.mint to tok.proofs.map { CashuProof(it.id, it.amount.toLong(), it.secret, it.C) }
}
is GenericLoadable.Error -> {
_redeemState.value = CashuRedeemFlowState.Error(parsed.errorMessage)
return
}
else -> {
_redeemState.value = CashuRedeemFlowState.Error("Could not parse token")
return
}
}
}
else -> {
_redeemState.value =
CashuRedeemFlowState.Error("Not a Cashu token (must start with cashuA or cashuB)")
@@ -566,7 +469,7 @@ class CashuWalletViewModel : ViewModel() {
}
}
if (mintUrl !in _mints.value) {
if (mintUrl !in mints.value) {
_redeemState.value =
CashuRedeemFlowState.Error("Token mint ($mintUrl) is not in your wallet. Add it first.")
return
@@ -577,9 +480,8 @@ class CashuWalletViewModel : ViewModel() {
try {
val result = ops.redeemToken(trimmed, proofs, mintUrl)
_redeemState.value = CashuRedeemFlowState.Completed(result.amount)
refresh()
} catch (e: Exception) {
_redeemState.value = CashuRedeemFlowState.Error(ops.describe(e))
_redeemState.value = CashuRedeemFlowState.Error(describeMintError(e))
}
}
}
@@ -588,3 +490,17 @@ class CashuWalletViewModel : ViewModel() {
_redeemState.value = CashuRedeemFlowState.Idle
}
}
sealed class MintPingState {
data object Idle : MintPingState()
data object Pinging : MintPingState()
data class Ok(
val name: String?,
) : MintPingState()
data class Failed(
val message: String,
) : MintPingState()
}
+7
View File
@@ -1898,6 +1898,13 @@
<string name="cashu_copy_invoice">Copy invoice</string>
<string name="cashu_request_invoice">Request invoice</string>
<string name="cashu_pay_invoice">Pay invoice</string>
<string name="cashu_get_quote">Get quote</string>
<string name="cashu_getting_quote">Asking mint for a quote…</string>
<string name="cashu_quote_confirm">Pay %1$s sat + up to %2$s sat in fees?</string>
<string name="cashu_verify">Verify</string>
<string name="cashu_mint_reachable">✓ Mint is reachable</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mint_unreachable">Could not reach mint: %1$s</string>
<string name="cashu_create_token">Create token</string>
<string name="cashu_redeem_button">Redeem</string>
<string name="cashu_done">Done</string>
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
@@ -43,8 +44,8 @@ import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
* NIP-60 events and as the `#p` tag value for inbound nutzaps. `relays` is
* the union of relays to subscribe on (NIP-65 outbox + DM relays at minimum).
*/
@Stable
class CashuWalletQueryState(
@Immutable
data class CashuWalletQueryState(
val pubkey: HexKey,
val relays: Set<NormalizedRelayUrl>,
)
@@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
@@ -97,7 +98,7 @@ object P2PK {
if (arr.size < 2) return null
val kind = (arr[0] as? JsonPrimitive)?.content
if (kind != "P2PK") return null
val body = arr[1] as? kotlinx.serialization.json.JsonObject ?: return null
val body = arr[1] as? JsonObject ?: return null
val data = (body["data"] as? JsonPrimitive)?.content ?: return null
val nonce = (body["nonce"] as? JsonPrimitive)?.content
ParsedP2pk(pubKeyHex = data, nonceHex = nonce)
@@ -118,9 +118,13 @@ class CashuMintOperations(
),
)
// Match signatures back to outputs by index — mints preserve order.
// The mint MUST return one signature per output, in the same order.
// We cannot match by content alone (signatures only echo `amount` +
// `id`, not the blinded message `B_`), so the protocol relies on
// positional correspondence. nutshell + cdk + cashu-ts all preserve
// this — kept here as an invariant, not a heuristic.
if (response.signatures.size != allOutputs.size) {
throw IllegalStateException(
throw MintProtocolException(
"Mint returned ${response.signatures.size} signatures for ${allOutputs.size} outputs",
)
}
@@ -146,11 +150,10 @@ class CashuMintOperations(
if (lockedProofs.isEmpty()) throw IllegalArgumentException("No proofs to redeem")
val unlocked =
lockedProofs.map { proof ->
val secretJson =
P2PK.parseSecret(proof.secret)
?: throw IllegalArgumentException("Proof secret is not a NUT-11 P2PK secret")
P2PK.parseSecret(proof.secret)
?: throw IllegalArgumentException("Proof secret is not a NUT-11 P2PK secret")
val witness = P2PK.signWitness(proof.secret, walletPrivkeyHex)
proof.copy(witness = witness).also { _ -> secretJson }
proof.copy(witness = witness)
}
return swap(unlocked, targetSplit = null)
}
@@ -198,12 +201,7 @@ class CashuMintOperations(
val paid = response.paid == true || response.state == "PAID"
if (!paid) {
throw MintHttpException(
httpStatus = 200,
detail = "Melt not completed (state=${response.state})",
code = null,
message = "Melt failed: state=${response.state}",
)
throw MintProtocolException("Melt not completed (state=${response.state})")
}
// Unblind any change the mint returned. The mint can return *fewer*
@@ -42,6 +42,16 @@ class MintHttpException(
message: String,
) : RuntimeException(message)
/**
* Thrown when the mint responded with HTTP 2xx but the protocol-level
* outcome was a failure (e.g. melt completed without reaching the PAID
* state). Distinct from [MintHttpException] so callers can tell
* "network/HTTP problem" apart from "mint said no to the request".
*/
class MintProtocolException(
message: String,
) : RuntimeException(message)
/**
* OkHttp-backed Cashu v1 mint client implementing NUT-00..06 endpoints.
*