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. *