From 5cd756cea222a12bd2b23f9fa104cd699f8bd3b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 21:54:42 +0000 Subject: [PATCH] refactor(cashu): lift wallet state to Account, react to live cache updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 + .../vitorpamplona/amethyst/model/Account.kt | 18 + .../model/accountsCache/AccountCacheState.kt | 4 + .../model/nip60Cashu/CashuWalletOps.kt | 193 +++++--- .../model/nip60Cashu/CashuWalletState.kt | 433 ++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 10 + .../loggedIn/wallet/AddCashuWalletScreen.kt | 46 +- .../loggedIn/wallet/CashuWalletScreen.kt | 28 +- .../loggedIn/wallet/CashuWalletViewModel.kt | 358 ++++++--------- amethyst/src/main/res/values/strings.xml | 7 + .../assemblers/CashuWalletFilterAssembler.kt | 5 +- .../quartz/nip60Cashu/p2pk/P2PK.kt | 3 +- .../nip60Cashu/mintApi/CashuMintOperations.kt | 22 +- .../nip60Cashu/mintApi/MintHttpClient.kt | 10 + 14 files changed, 832 insertions(+), 307 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 35f74af408..a72f1dd25e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 17b4b79346..376eb247b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -291,6 +291,8 @@ class Account( override val signer: NostrSigner, val geolocationFlow: () -> StateFlow, 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 3233e9377a..0bd6684814 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -53,6 +53,8 @@ import java.io.File class AccountCacheState( val geolocationFlow: () -> StateFlow, 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index ba8fb9482f..3779d1c301 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -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() - 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, p2pkPrivkeyHex: String?, + nutzapRelays: List = 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, ): 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, 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 new file mode 100644 index 0000000000..3569b71936 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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>, + 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() + private val historyEvents = ConcurrentHashMap() + private val quoteEvents = ConcurrentHashMap() + private val nutzapEvents = ConcurrentHashMap() + + /** NIP-44 decryption cache for token contents, keyed by event id. */ + private val tokenContents = ConcurrentHashMap() + private val redeemMutex = Mutex() + + // ============================================================ + // Public flows + // ============================================================ + private val _walletEvent = MutableStateFlow(null) + val walletEvent: StateFlow = _walletEvent.asStateFlow() + + private val _mints = MutableStateFlow>(emptyList()) + val mints: StateFlow> = _mints.asStateFlow() + + private val _tokenEntries = MutableStateFlow>(emptyList()) + val tokenEntries: StateFlow> = _tokenEntries.asStateFlow() + + val balanceSats: StateFlow = + _tokenEntries + .map { entries -> entries.sumOf { it.content.totalAmount() } } + .flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, 0L) + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + + /** Unfulfilled, unexpired kind:7374 events — surfaced for resume UI. */ + private val _pendingQuotes = MutableStateFlow>(emptyList()) + val pendingQuotes: StateFlow> = _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() + 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) { + 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) { + 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) { + 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() + 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 { + val collected = mutableListOf() + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index af93b2af10..5ec44ad8c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2177,6 +2177,11 @@ fun mockAccountViewModel(): AccountViewModel { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(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.Loading) }, nwcFilterAssembler = { nwcFilters }, + cashuWalletFilterAssembler = { + com.vitorpamplona.amethyst.commons.relayClient.assemblers + .CashuWalletFilterAssembler(client) + }, + okHttpClientForMoney = { okhttp3.OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = EmptyNostrClient(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt index b1659ceb8d..bea7afe37c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt index 09d4c7cdb7..1cb204a811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt @@ -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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 407a9b9c3f..740092dd2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -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(null) - val walletEvent = _walletEvent.asStateFlow() - - private val _mints = MutableStateFlow>(emptyList()) - val mints = _mints.asStateFlow() - - private val _balanceSats = MutableStateFlow(0L) - val balanceSats = _balanceSats.asStateFlow() - - private val _tokenEntries = MutableStateFlow>(emptyList()) - val tokenEntries = _tokenEntries.asStateFlow() - - private val _history = MutableStateFlow>(emptyList()) - val history = _history.asStateFlow() + val walletEvent get() = state.walletEvent + val mints get() = state.mints + val balanceSats get() = state.balanceSats + val tokenEntries: StateFlow> get() = state.tokenEntries + val history get() = state.history + val pendingQuotes get() = state.pendingQuotes private val _createState = MutableStateFlow(CashuWalletCreateState.Idle) val createState = _createState.asStateFlow() @@ -179,151 +161,42 @@ class CashuWalletViewModel : ViewModel() { private val _redeemState = MutableStateFlow(CashuRedeemFlowState.Idle) val redeemState = _redeemState.asStateFlow() + private val _mintPingState = MutableStateFlow(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() - val historyList = mutableListOf() - 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() - val decoded = mutableMapOf() - 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, - ) { - 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, 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() +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8fddd430c4..e52c1f7a70 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1898,6 +1898,13 @@ Copy invoice Request invoice Pay invoice + Get quote + Asking mint for a quote… + Pay %1$s sat + up to %2$s sat in fees? + Verify + ✓ Mint is reachable + ✓ %1$s + Could not reach mint: %1$s Create token Redeem Done diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index ff43b9b4e3..6aed549eed 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -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, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt index 4faeb06c63..2d6393c896 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt @@ -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) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index c19bf5d56e..1a8d331313 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -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* diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt index cc230653f7..228222c9d0 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt @@ -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. *