mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
refactor(cashu): lift wallet state to Account, react to live cache updates
Addresses the critical findings from the post-implementation audit:
A1. State holder lives on Account, not the ViewModel
New CashuWalletState owns the wallet event, decrypted token contents,
history, mint-quote, and inbound-nutzap indexes. It's constructed on
Account and runs for the lifetime of the login session — so nutzaps
arriving while the user is on Home/DMs/etc. get auto-redeemed without
requiring the wallet screen to be open. ViewModel becomes a thin
presenter that forwards flows + holds per-flow UI state (mint quote
in progress, melt confirmation pending).
A2. Reactive observation via LocalCache.live.newEventBundles
The state object backfills once from cache.notes at construction time,
then receives incremental updates from the live new/deleted event
bundles for any NIP-60/61 event authored by us (or addressed to us
via #p for nutzaps). NIP-44 decryption results for kind:7375 events
are cached by event-id, so the per-refresh re-decrypt is gone (D2).
A3. Mutex-guarded auto-redeem (no more duplicate /v1/swap races)
redeemPendingNutzapsSerialized uses tryLock so a sweep already in
flight short-circuits any new triggers; subsequent cache updates
catch up via the next bundle.
A4. Mint-quote recovery on launch
pendingQuotes flow surfaces unfulfilled kind:7374 events whose
expiration hasn't passed and whose id isn't yet referenced with a
"destroyed" marker in any kind:7376. ViewModel.resumeMintQuote()
re-polls the mint for the original quote and rebuilds the flow.
B1. NutzapInfoEvent now carries the wallet's outbox relays so senders
publish nutzaps where our assembler is actually listening.
B2. Subscription tracks the outboxRelaysFlow — when the relay list
changes, the assembler subscription is rebuilt with the new set.
B5. New MintProtocolException distinguishes "HTTP fine, protocol said
no" (e.g. melt state != PAID) from "HTTP error". Both surface
through describeMintError() (now top-level — C4).
B7. redeemNutzap now pre-checks the P2PK secret's pubkey matches our
wallet pubkey before signing — saves a wasted mint round-trip when
the lock targets someone else.
B8. Melt is a two-phase flow: startMelt() returns a Quoted state with
amount + fee_reserve so the UI confirms before paying; confirmMelt()
actually spends. No more silent fee acceptance.
C1. MintHttpClient + CashuMintOperations cached per mint URL via a
ConcurrentHashMap.
C3. AddCashuWalletScreen has a "Verify" button that pings /v1/info
before adding, with inline success / failure feedback.
C7. Inline JsonObject FQN in P2PK.kt replaced with proper import.
C8. Dead .also { _ -> secretJson } removed from redeemNutzap.
D1. runCatching {}.getOrNull() callsites in the state holder now log
via Log.w("CashuWallet") so silent failures surface in logcat.
D5. CashuWalletQueryState made @Immutable + data class for Compose
stability hygiene.
Touched files: Account.kt (state field + constructor params),
AccountCacheState.kt + AppModules.kt (wire the assembler factory +
okHttpClientForMoney through), CashuWalletOps.kt (decouples from
Account, takes signer + publish callback), CashuWalletState.kt (new),
CashuWalletViewModel.kt (presenter rewrite), CashuWalletScreen.kt
(two-phase melt UI), AddCashuWalletScreen.kt (Verify button),
strings.xml (new keys).
All 20 NIP-60 jvm tests still pass; playDebug + fdroidDebug compile
clean.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
@@ -510,6 +510,8 @@ class AppModules(
|
||||
AccountCacheState(
|
||||
geolocationFlow = { locationManager.geohashStateFlow },
|
||||
nwcFilterAssembler = { sources.nwc },
|
||||
cashuWalletFilterAssembler = { sources.cashuWallet },
|
||||
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
|
||||
contentResolverFn = { appContext.contentResolver },
|
||||
otsResolverBuilder = { otsResolverBuilder.build() },
|
||||
cache = cache,
|
||||
|
||||
@@ -291,6 +291,8 @@ class Account(
|
||||
override val signer: NostrSigner,
|
||||
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
|
||||
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
|
||||
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
|
||||
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
|
||||
val otsResolverBuilder: () -> OtsResolver,
|
||||
val cache: LocalCache,
|
||||
val client: INostrClient,
|
||||
@@ -405,6 +407,17 @@ class Account(
|
||||
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
|
||||
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
|
||||
|
||||
val cashuWalletState =
|
||||
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
|
||||
pubKey = signer.pubKey,
|
||||
signer = signer,
|
||||
cache = cache,
|
||||
scope = scope,
|
||||
assembler = cashuWalletFilterAssembler(),
|
||||
outboxRelaysFlow = outboxRelays.flow,
|
||||
okHttpClient = okHttpClientForMoney,
|
||||
)
|
||||
|
||||
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
|
||||
|
||||
// Follows Relays
|
||||
@@ -3388,6 +3401,11 @@ class Account(
|
||||
init {
|
||||
Log.d("AccountRegisterObservers", "Init")
|
||||
|
||||
// Bridge CashuWalletOps's publish callback to our `sendLiterallyEverywhere`
|
||||
// so the state object can push events to relays + cache without holding
|
||||
// a direct reference back to Account.
|
||||
cashuWalletState.publishDelegate = { event -> sendLiterallyEverywhere(event) }
|
||||
|
||||
// Restore Marmot MLS group state on startup
|
||||
if (marmotManager != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
|
||||
+4
@@ -53,6 +53,8 @@ import java.io.File
|
||||
class AccountCacheState(
|
||||
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
|
||||
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
|
||||
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
|
||||
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
|
||||
val contentResolverFn: () -> ContentResolver,
|
||||
val otsResolverBuilder: () -> OtsResolver,
|
||||
val cache: LocalCache,
|
||||
@@ -191,6 +193,8 @@ class AccountCacheState(
|
||||
signer = signerWithClientTag,
|
||||
geolocationFlow = geolocationFlow,
|
||||
nwcFilterAssembler = nwcFilterAssembler,
|
||||
cashuWalletFilterAssembler = cashuWalletFilterAssembler,
|
||||
okHttpClientForMoney = okHttpClientForMoney,
|
||||
otsResolverBuilder = otsResolverBuilder,
|
||||
cache = cache,
|
||||
client = client,
|
||||
|
||||
+125
-68
@@ -20,20 +20,24 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip60Cashu
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.cashu.v4.V4Encoder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.SpendingDirection
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.TokenReference
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintProtocolException
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintQuoteBolt11ResponseDto
|
||||
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
@@ -47,24 +51,33 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Wallet-level operations that combine the [CashuMintOperations] HTTP layer
|
||||
* with Nostr event publishing (kind 7375 / 7376 / 7374 / 17375 / 10019 /
|
||||
* deletion-5).
|
||||
*
|
||||
* The operations layer is intentionally stateless: each call receives the
|
||||
* current decrypted state from [CashuWalletViewModel] (or rebuilds it from
|
||||
* [LocalCache] when needed). All side effects flow through [Account] which
|
||||
* handles signing + publishing + local-cache consumption in one shot.
|
||||
* Stateless w.r.t. wallet contents — each call receives the current state
|
||||
* from `CashuWalletState`. Signing and broadcast are abstracted behind the
|
||||
* [signer] + [publish] callbacks so the ops layer can be unit-tested without
|
||||
* a full [com.vitorpamplona.amethyst.model.Account] graph.
|
||||
*
|
||||
* Per-mint [CashuMintOperations] instances are cached so repeated calls
|
||||
* against the same mint reuse the same `MintHttpClient` and avoid re-
|
||||
* instantiating the JSON serializer per request.
|
||||
*/
|
||||
class CashuWalletOps(
|
||||
private val account: Account,
|
||||
private val signer: NostrSigner,
|
||||
private val publish: suspend (Event) -> Unit,
|
||||
private val okHttpClient: (String) -> OkHttpClient,
|
||||
) {
|
||||
private fun client(mintUrl: String) = MintHttpClient(mintUrl, okHttpClient)
|
||||
private val opsCache = ConcurrentHashMap<String, CashuMintOperations>()
|
||||
|
||||
private fun ops(mintUrl: String) = CashuMintOperations(client(mintUrl))
|
||||
private fun ops(mintUrl: String): CashuMintOperations =
|
||||
opsCache.getOrPut(mintUrl.trimEnd('/')) {
|
||||
CashuMintOperations(MintHttpClient(mintUrl, okHttpClient))
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish kind:17375 + kind:10019 in one go.
|
||||
@@ -80,24 +93,28 @@ class CashuWalletOps(
|
||||
suspend fun publishWalletEvents(
|
||||
mints: List<String>,
|
||||
p2pkPrivkeyHex: String?,
|
||||
nutzapRelays: List<NormalizedRelayUrl> = emptyList(),
|
||||
): CreatedWallet {
|
||||
require(mints.isNotEmpty()) { "Wallet must have at least one mint" }
|
||||
|
||||
val priv = (p2pkPrivkeyHex?.takeIf { it.isNotBlank() } ?: Bdhke.randomScalar().toHexKey())
|
||||
val pubKeyHex = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(priv.hexToByteArray())).toHexKey()
|
||||
|
||||
val walletTemplate = CashuWalletEvent.build(mints, priv, account.signer)
|
||||
val walletEvent = account.signer.sign(walletTemplate)
|
||||
account.sendLiterallyEverywhere(walletEvent)
|
||||
val walletTemplate = CashuWalletEvent.build(mints, priv, signer)
|
||||
val walletEvent = signer.sign(walletTemplate)
|
||||
publish(walletEvent)
|
||||
|
||||
// Populate `relay` tags so senders know where to publish nutzaps —
|
||||
// without these, they fall back to NIP-65 outbox and may miss our
|
||||
// subscription scope on relays we don't read from.
|
||||
val nutzapInfoTemplate =
|
||||
NutzapInfoEvent.build(
|
||||
mints = mints.map { NutzapMintTag(it, listOf("sat")) },
|
||||
relays = emptyList(),
|
||||
relays = nutzapRelays,
|
||||
p2pkPubkey = pubKeyHex,
|
||||
)
|
||||
val nutzapInfoEvent = account.signer.sign(nutzapInfoTemplate)
|
||||
account.sendLiterallyEverywhere(nutzapInfoEvent)
|
||||
val nutzapInfoEvent = signer.sign(nutzapInfoTemplate)
|
||||
publish(nutzapInfoEvent)
|
||||
|
||||
return CreatedWallet(
|
||||
walletEvent = walletEvent,
|
||||
@@ -120,10 +137,10 @@ class CashuWalletOps(
|
||||
CashuMintQuoteEvent.build(
|
||||
quoteId = response.quote,
|
||||
mintUrl = mintUrl,
|
||||
signer = account.signer,
|
||||
signer = signer,
|
||||
)
|
||||
val quoteEvent = account.signer.sign(quoteTemplate)
|
||||
account.sendLiterallyEverywhere(quoteEvent)
|
||||
val quoteEvent = signer.sign(quoteTemplate)
|
||||
publish(quoteEvent)
|
||||
return MintQuoteStarted(
|
||||
quoteEvent = quoteEvent,
|
||||
mintQuote = response,
|
||||
@@ -146,13 +163,13 @@ class CashuWalletOps(
|
||||
quoteEvent: CashuMintQuoteEvent,
|
||||
amountSats: Long,
|
||||
): MintCompleted {
|
||||
val quoteId = quoteEvent.quoteId(account.signer)
|
||||
val quoteId = quoteEvent.quoteId(signer)
|
||||
val minted = ops(mintUrl).mintProofs(quoteId, amountSats)
|
||||
|
||||
val tokenContent = minted.toTokenContent(mintUrl)
|
||||
val tokenTemplate = CashuTokenEvent.build(tokenContent, account.signer)
|
||||
val tokenEvent = account.signer.sign(tokenTemplate)
|
||||
account.sendLiterallyEverywhere(tokenEvent)
|
||||
val tokenTemplate = CashuTokenEvent.build(tokenContent, signer)
|
||||
val tokenEvent = signer.sign(tokenTemplate)
|
||||
publish(tokenEvent)
|
||||
|
||||
val historyTemplate =
|
||||
CashuSpendingHistoryEvent.build(
|
||||
@@ -166,15 +183,15 @@ class CashuWalletOps(
|
||||
marker = TokenReference.MARKER_CREATED,
|
||||
),
|
||||
),
|
||||
signer = account.signer,
|
||||
signer = signer,
|
||||
)
|
||||
val historyEvent = account.signer.sign(historyTemplate)
|
||||
account.sendLiterallyEverywhere(historyEvent)
|
||||
val historyEvent = signer.sign(historyTemplate)
|
||||
publish(historyEvent)
|
||||
|
||||
// NIP-09 delete the now-fulfilled quote event.
|
||||
val delTemplate = DeletionEvent.build(listOf(quoteEvent))
|
||||
val delEvent = account.signer.sign(delTemplate)
|
||||
account.sendLiterallyEverywhere(delEvent)
|
||||
val delEvent = signer.sign(delTemplate)
|
||||
publish(delEvent)
|
||||
|
||||
return MintCompleted(
|
||||
tokenEvent = tokenEvent,
|
||||
@@ -184,20 +201,30 @@ class CashuWalletOps(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a bolt11 invoice via the chosen mint, spending [available] token
|
||||
* events. Picks the smallest subset of token events whose total covers
|
||||
* `amount + fee_reserve` (greedy by total amount descending). Any leftover
|
||||
* change comes back as proofs in a fresh kind:7375.
|
||||
* Phase 1 of melt — ask the mint how much an invoice will cost and what
|
||||
* its fee_reserve is. Pure read; no state mutation. UI shows the quote to
|
||||
* the user; they confirm; the wallet calls [meltToLightning] with the
|
||||
* same quote to actually pay.
|
||||
*/
|
||||
suspend fun requestMeltQuote(
|
||||
mintUrl: String,
|
||||
invoice: String,
|
||||
): MeltQuoteBolt11ResponseDto = ops(mintUrl).requestMeltQuote(invoice)
|
||||
|
||||
/**
|
||||
* Phase 2 of melt — pay the invoice using the agreed-upon [quote].
|
||||
* Spends [available] token events: picks the smallest subset whose total
|
||||
* covers `amount + fee_reserve` (greedy by total amount descending). Any
|
||||
* leftover change comes back as proofs in a fresh kind:7375.
|
||||
*/
|
||||
suspend fun meltToLightning(
|
||||
mintUrl: String,
|
||||
invoice: String,
|
||||
quote: MeltQuoteBolt11ResponseDto,
|
||||
available: List<TokenEntry>,
|
||||
): MeltCompleted {
|
||||
if (available.isEmpty()) throw IllegalStateException("No proofs available to spend")
|
||||
|
||||
val ops = ops(mintUrl)
|
||||
val quote = ops.requestMeltQuote(invoice)
|
||||
val required = quote.amount + quote.feeReserve
|
||||
|
||||
val (selected, _) = selectProofsCovering(available, required)
|
||||
@@ -214,9 +241,9 @@ class CashuWalletOps(
|
||||
val keepEvent =
|
||||
if (swap.keep.isNotEmpty()) {
|
||||
val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id })
|
||||
val tokenTemplate = CashuTokenEvent.build(content, account.signer)
|
||||
val signed = account.signer.sign(tokenTemplate)
|
||||
account.sendLiterallyEverywhere(signed)
|
||||
val tokenTemplate = CashuTokenEvent.build(content, signer)
|
||||
val signed = signer.sign(tokenTemplate)
|
||||
publish(signed)
|
||||
signed
|
||||
} else {
|
||||
null
|
||||
@@ -239,9 +266,9 @@ class CashuWalletOps(
|
||||
selected.map { it.event.id }
|
||||
}
|
||||
val content = TokenContent(mint = mintUrl, proofs = meltResult.changeProofs, del = delIds)
|
||||
val tokenTemplate = CashuTokenEvent.build(content, account.signer)
|
||||
val signed = account.signer.sign(tokenTemplate)
|
||||
account.sendLiterallyEverywhere(signed)
|
||||
val tokenTemplate = CashuTokenEvent.build(content, signer)
|
||||
val signed = signer.sign(tokenTemplate)
|
||||
publish(signed)
|
||||
signed
|
||||
} else {
|
||||
null
|
||||
@@ -253,7 +280,7 @@ class CashuWalletOps(
|
||||
run {
|
||||
val toDelete = selected.map { it.event } + listOfNotNull(prePaidChangeEvent.takeIf { finalChangeEvent != null })
|
||||
val delTemplate = DeletionEvent.build(toDelete)
|
||||
account.signer.sign(delTemplate).also { account.sendLiterallyEverywhere(it) }
|
||||
signer.sign(delTemplate).also { publish(it) }
|
||||
}
|
||||
|
||||
val historyTemplate =
|
||||
@@ -267,10 +294,10 @@ class CashuWalletOps(
|
||||
}
|
||||
finalChangeEvent?.let { add(TokenReference(it.id, null, TokenReference.MARKER_CREATED)) }
|
||||
},
|
||||
signer = account.signer,
|
||||
signer = signer,
|
||||
)
|
||||
val historyEvent = account.signer.sign(historyTemplate)
|
||||
account.sendLiterallyEverywhere(historyEvent)
|
||||
val historyEvent = signer.sign(historyTemplate)
|
||||
publish(historyEvent)
|
||||
|
||||
return MeltCompleted(
|
||||
preimage = meltResult.preimage,
|
||||
@@ -305,9 +332,9 @@ class CashuWalletOps(
|
||||
val newKeepEvent =
|
||||
if (swap.keep.isNotEmpty()) {
|
||||
val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id })
|
||||
val template = CashuTokenEvent.build(content, account.signer)
|
||||
val signed = account.signer.sign(template)
|
||||
account.sendLiterallyEverywhere(signed)
|
||||
val template = CashuTokenEvent.build(content, signer)
|
||||
val signed = signer.sign(template)
|
||||
publish(signed)
|
||||
signed
|
||||
} else {
|
||||
null
|
||||
@@ -316,7 +343,7 @@ class CashuWalletOps(
|
||||
val deleteEvent =
|
||||
run {
|
||||
val template = DeletionEvent.build(selected.map { it.event })
|
||||
account.signer.sign(template).also { account.sendLiterallyEverywhere(it) }
|
||||
signer.sign(template).also { publish(it) }
|
||||
}
|
||||
|
||||
val historyTemplate =
|
||||
@@ -328,10 +355,10 @@ class CashuWalletOps(
|
||||
selected.forEach { add(TokenReference(it.event.id, null, TokenReference.MARKER_DESTROYED)) }
|
||||
newKeepEvent?.let { add(TokenReference(it.id, null, TokenReference.MARKER_CREATED)) }
|
||||
},
|
||||
signer = account.signer,
|
||||
signer = signer,
|
||||
)
|
||||
val historyEvent = account.signer.sign(historyTemplate)
|
||||
account.sendLiterallyEverywhere(historyEvent)
|
||||
val historyEvent = signer.sign(historyTemplate)
|
||||
publish(historyEvent)
|
||||
|
||||
return SendTokenCompleted(
|
||||
cashuToken = tokenString,
|
||||
@@ -361,9 +388,9 @@ class CashuWalletOps(
|
||||
|
||||
// All output goes to "keep" since targetSplit was null.
|
||||
val content = TokenContent(mint = mintUrl, proofs = swap.keep)
|
||||
val tokenTemplate = CashuTokenEvent.build(content, account.signer)
|
||||
val tokenEvent = account.signer.sign(tokenTemplate)
|
||||
account.sendLiterallyEverywhere(tokenEvent)
|
||||
val tokenTemplate = CashuTokenEvent.build(content, signer)
|
||||
val tokenEvent = signer.sign(tokenTemplate)
|
||||
publish(tokenEvent)
|
||||
|
||||
val historyTemplate =
|
||||
CashuSpendingHistoryEvent.build(
|
||||
@@ -374,10 +401,10 @@ class CashuWalletOps(
|
||||
add(TokenReference(tokenEvent.id, null, TokenReference.MARKER_CREATED))
|
||||
nutzapEventId?.let { add(TokenReference(it, null, TokenReference.MARKER_REDEEMED)) }
|
||||
},
|
||||
signer = account.signer,
|
||||
signer = signer,
|
||||
)
|
||||
val historyEvent = account.signer.sign(historyTemplate)
|
||||
account.sendLiterallyEverywhere(historyEvent)
|
||||
val historyEvent = signer.sign(historyTemplate)
|
||||
publish(historyEvent)
|
||||
|
||||
return RedeemCompleted(
|
||||
amount = total,
|
||||
@@ -407,6 +434,7 @@ class CashuWalletOps(
|
||||
suspend fun redeemNutzap(
|
||||
nutzap: NutzapEvent,
|
||||
walletPrivkeyHex: String,
|
||||
walletP2pkPubkeyHex: String,
|
||||
): RedeemCompleted {
|
||||
val mintUrl =
|
||||
nutzap.mintUrl()
|
||||
@@ -425,13 +453,29 @@ class CashuWalletOps(
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the lock points at us before spending a mint round-trip. The
|
||||
// pubkey in the P2PK secret can be 64-char x-only or 66-char
|
||||
// compressed; normalize to x-only for the comparison since BIP-340
|
||||
// verification (which the mint uses) is parity-agnostic.
|
||||
val ourXOnly = walletP2pkPubkeyHex.lastHex64()
|
||||
parsedProofs.forEach { proof ->
|
||||
val parsed =
|
||||
P2PK.parseSecret(proof.secret)
|
||||
?: throw IllegalArgumentException("Nutzap proof is not P2PK-locked")
|
||||
if (parsed.pubKeyHex.lastHex64() != ourXOnly) {
|
||||
throw IllegalArgumentException(
|
||||
"Nutzap proof is locked to a different pubkey (${parsed.pubKeyHex.take(16)}…)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val swap = ops(mintUrl).redeemNutzap(parsedProofs, walletPrivkeyHex)
|
||||
val total = swap.keep.sumOf { it.amount }
|
||||
|
||||
val tokenContent = TokenContent(mint = mintUrl, proofs = swap.keep)
|
||||
val tokenTemplate = CashuTokenEvent.build(tokenContent, account.signer)
|
||||
val tokenEvent = account.signer.sign(tokenTemplate)
|
||||
account.sendLiterallyEverywhere(tokenEvent)
|
||||
val tokenTemplate = CashuTokenEvent.build(tokenContent, signer)
|
||||
val tokenEvent = signer.sign(tokenTemplate)
|
||||
publish(tokenEvent)
|
||||
|
||||
val historyTemplate =
|
||||
CashuSpendingHistoryEvent.build(
|
||||
@@ -442,10 +486,10 @@ class CashuWalletOps(
|
||||
TokenReference(tokenEvent.id, null, TokenReference.MARKER_CREATED),
|
||||
TokenReference(nutzap.id, null, TokenReference.MARKER_REDEEMED),
|
||||
),
|
||||
signer = account.signer,
|
||||
signer = signer,
|
||||
)
|
||||
val historyEvent = account.signer.sign(historyTemplate)
|
||||
account.sendLiterallyEverywhere(historyEvent)
|
||||
val historyEvent = signer.sign(historyTemplate)
|
||||
publish(historyEvent)
|
||||
|
||||
return RedeemCompleted(
|
||||
amount = total,
|
||||
@@ -475,14 +519,27 @@ class CashuWalletOps(
|
||||
return picked to running
|
||||
}
|
||||
|
||||
/** Catches mint HTTP errors and surfaces their detail message. */
|
||||
fun describe(e: Throwable): String =
|
||||
when (e) {
|
||||
is MintHttpException -> "Mint error (HTTP ${e.httpStatus}): ${e.detail ?: e.message}"
|
||||
else -> e.message ?: e::class.simpleName ?: "Unknown error"
|
||||
}
|
||||
/**
|
||||
* Ping `/v1/info` on [mintUrl] and return the mint's display name on
|
||||
* success. Used by the Add-Mint UI to give immediate feedback when a URL
|
||||
* is typo'd, points at a non-Cashu host, or is otherwise unreachable.
|
||||
*
|
||||
* Throws on failure so the UI can surface the underlying reason.
|
||||
*/
|
||||
suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, okHttpClient).info().name
|
||||
}
|
||||
|
||||
/** Drop the leading parity byte if present so two pubkeys can be compared. */
|
||||
private fun String.lastHex64(): String = if (length == 66) substring(2) else this
|
||||
|
||||
/** Catches mint HTTP / protocol errors and surfaces their detail message. */
|
||||
fun describeMintError(e: Throwable): String =
|
||||
when (e) {
|
||||
is MintHttpException -> "Mint error (HTTP ${e.httpStatus}): ${e.detail ?: e.message}"
|
||||
is MintProtocolException -> "Mint refused: ${e.message}"
|
||||
else -> e.message ?: e::class.simpleName ?: "Unknown error"
|
||||
}
|
||||
|
||||
/** A decrypted, unspent token event ready to be spent. */
|
||||
data class TokenEntry(
|
||||
val event: CashuTokenEvent,
|
||||
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip60Cashu
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Account-scoped state holder for the NIP-60 Cashu wallet + NIP-61 nutzaps.
|
||||
*
|
||||
* Lives on [com.vitorpamplona.amethyst.model.Account], so it stays alive for
|
||||
* the lifetime of the login session — not just while the wallet screen is
|
||||
* visible. This matters because:
|
||||
*
|
||||
* - Inbound nutzaps (kind 9321) need to be auto-redeemed when they arrive,
|
||||
* regardless of which screen the user is on.
|
||||
* - The wallet event + token events need to land in [LocalCache] on first
|
||||
* launch (or on fresh device sign-in) without requiring the user to open
|
||||
* the wallet screen first.
|
||||
*
|
||||
* Mirrors the shape of [com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState]
|
||||
* — a single state object on Account, ViewModels are thin presenters.
|
||||
*
|
||||
* Reactivity: subscribes to [LocalCache.live.newEventBundles] (and the
|
||||
* delete bundle) and re-indexes any incoming NIP-60/NIP-61 event authored by
|
||||
* or addressed to this account. The full [LocalCache.notes] map is scanned
|
||||
* ONCE during init to backfill; after that all updates are incremental.
|
||||
*
|
||||
* Auto-redeem is serialized through a [Mutex] so concurrent cache updates
|
||||
* don't fire duplicate /v1/swap calls against the mint.
|
||||
*/
|
||||
class CashuWalletState(
|
||||
private val pubKey: HexKey,
|
||||
private val signer: NostrSigner,
|
||||
private val cache: LocalCache,
|
||||
private val scope: CoroutineScope,
|
||||
private val assembler: CashuWalletFilterAssembler,
|
||||
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
) {
|
||||
val ops: CashuWalletOps =
|
||||
CashuWalletOps(
|
||||
signer = signer,
|
||||
publish = ::publishEvent,
|
||||
okHttpClient = okHttpClient,
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Raw indexes — keyed by event id, mutated only on the cache thread.
|
||||
// ============================================================
|
||||
private var walletEventInternal: CashuWalletEvent? = null
|
||||
private val tokenEvents = ConcurrentHashMap<HexKey, CashuTokenEvent>()
|
||||
private val historyEvents = ConcurrentHashMap<HexKey, CashuSpendingHistoryEvent>()
|
||||
private val quoteEvents = ConcurrentHashMap<HexKey, CashuMintQuoteEvent>()
|
||||
private val nutzapEvents = ConcurrentHashMap<HexKey, NutzapEvent>()
|
||||
|
||||
/** NIP-44 decryption cache for token contents, keyed by event id. */
|
||||
private val tokenContents = ConcurrentHashMap<HexKey, TokenContent>()
|
||||
private val redeemMutex = Mutex()
|
||||
|
||||
// ============================================================
|
||||
// Public flows
|
||||
// ============================================================
|
||||
private val _walletEvent = MutableStateFlow<CashuWalletEvent?>(null)
|
||||
val walletEvent: StateFlow<CashuWalletEvent?> = _walletEvent.asStateFlow()
|
||||
|
||||
private val _mints = MutableStateFlow<List<String>>(emptyList())
|
||||
val mints: StateFlow<List<String>> = _mints.asStateFlow()
|
||||
|
||||
private val _tokenEntries = MutableStateFlow<List<TokenEntry>>(emptyList())
|
||||
val tokenEntries: StateFlow<List<TokenEntry>> = _tokenEntries.asStateFlow()
|
||||
|
||||
val balanceSats: StateFlow<Long> =
|
||||
_tokenEntries
|
||||
.map { entries -> entries.sumOf { it.content.totalAmount() } }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, 0L)
|
||||
|
||||
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
|
||||
val history: StateFlow<List<CashuSpendingHistoryEvent>> = _history.asStateFlow()
|
||||
|
||||
/** Unfulfilled, unexpired kind:7374 events — surfaced for resume UI. */
|
||||
private val _pendingQuotes = MutableStateFlow<List<CashuMintQuoteEvent>>(emptyList())
|
||||
val pendingQuotes: StateFlow<List<CashuMintQuoteEvent>> = _pendingQuotes.asStateFlow()
|
||||
|
||||
fun hasWallet(): Boolean = _walletEvent.value != null
|
||||
|
||||
/** Read the wallet's P2PK pubkey (33-byte compressed hex). */
|
||||
suspend fun p2pkPubkeyHex(): String? {
|
||||
val priv = walletPrivkeyHex() ?: return null
|
||||
return Secp256k1
|
||||
.pubKeyCompress(Secp256k1.pubkeyCreate(priv.hexToByteArray()))
|
||||
.toHexKey()
|
||||
}
|
||||
|
||||
private suspend fun walletPrivkeyHex(): String? =
|
||||
_walletEvent.value?.let { evt ->
|
||||
runCatching { evt.privkey(signer) }.getOrNull()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Lifecycle
|
||||
// ============================================================
|
||||
private val jobs = mutableListOf<Job>()
|
||||
private var currentSubscription: CashuWalletQueryState? = null
|
||||
|
||||
init {
|
||||
// Backfill from cache once.
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val initial = scanCacheForOwnEvents()
|
||||
applyEvents(initial)
|
||||
recomputePending()
|
||||
triggerAutoRedeem()
|
||||
}
|
||||
|
||||
// Keep the relay subscription in sync with the outbox set.
|
||||
jobs +=
|
||||
scope.launch(Dispatchers.IO) {
|
||||
outboxRelaysFlow.collect { relays ->
|
||||
syncSubscription(relays)
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive incremental update: any new event arrival that matches our
|
||||
// pubkey + the NIP-60/61 kinds we care about gets indexed.
|
||||
jobs +=
|
||||
scope.launch(Dispatchers.Default) {
|
||||
cache.live.newEventBundles.collect { notes ->
|
||||
val ours = notes.mapNotNull { it.event }.filter(::isRelevantEvent)
|
||||
if (ours.isNotEmpty()) {
|
||||
applyEvents(ours)
|
||||
recomputePending()
|
||||
triggerAutoRedeem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jobs +=
|
||||
scope.launch(Dispatchers.Default) {
|
||||
cache.live.deletedEventBundles.collect { notes ->
|
||||
val ids = notes.mapNotNull { it.event?.id }.toSet()
|
||||
if (ids.isNotEmpty()) removeEvents(ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
jobs.forEach { it.cancel() }
|
||||
jobs.clear()
|
||||
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
currentSubscription = null
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Subscription management
|
||||
// ============================================================
|
||||
private fun syncSubscription(relays: Set<NormalizedRelayUrl>) {
|
||||
val previous = currentSubscription
|
||||
if (relays.isEmpty()) {
|
||||
previous?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
currentSubscription = null
|
||||
return
|
||||
}
|
||||
if (previous != null && previous.relays == relays) return // unchanged
|
||||
|
||||
previous?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
val next = CashuWalletQueryState(pubKey, relays)
|
||||
currentSubscription = next
|
||||
assembler.subscribe(next)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Indexing
|
||||
// ============================================================
|
||||
private fun isRelevantEvent(event: Event): Boolean =
|
||||
when (event) {
|
||||
is CashuWalletEvent, is CashuTokenEvent, is CashuSpendingHistoryEvent,
|
||||
is CashuMintQuoteEvent,
|
||||
-> event.pubKey == pubKey
|
||||
// Inbound nutzaps: addressed to us, possibly authored by someone
|
||||
// else. Match by the recipient `#p` tag.
|
||||
is NutzapEvent -> event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == pubKey }
|
||||
else -> false
|
||||
}
|
||||
|
||||
private suspend fun applyEvents(events: List<Event>) {
|
||||
var dirtyWallet = false
|
||||
var dirtyTokens = false
|
||||
var dirtyHistory = false
|
||||
var dirtyQuotes = false
|
||||
var dirtyNutzaps = false
|
||||
|
||||
for (event in events) {
|
||||
when (event) {
|
||||
is CashuWalletEvent -> {
|
||||
// Replaceable: keep the latest by created_at.
|
||||
val current = walletEventInternal
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
walletEventInternal = event
|
||||
dirtyWallet = true
|
||||
}
|
||||
}
|
||||
is CashuTokenEvent -> {
|
||||
if (tokenEvents.put(event.id, event) == null) dirtyTokens = true
|
||||
}
|
||||
is CashuSpendingHistoryEvent -> {
|
||||
if (historyEvents.put(event.id, event) == null) dirtyHistory = true
|
||||
}
|
||||
is CashuMintQuoteEvent -> {
|
||||
if (quoteEvents.put(event.id, event) == null) dirtyQuotes = true
|
||||
}
|
||||
is NutzapEvent -> {
|
||||
if (nutzapEvents.put(event.id, event) == null) dirtyNutzaps = true
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
if (dirtyWallet) {
|
||||
_walletEvent.value = walletEventInternal
|
||||
walletEventInternal?.let { evt ->
|
||||
_mints.value =
|
||||
runCatching { evt.mints(signer) }
|
||||
.onFailure { Log.w("CashuWallet") { "Failed to decrypt wallet mints: ${it.message}" } }
|
||||
.getOrNull() ?: emptyList()
|
||||
} ?: run { _mints.value = emptyList() }
|
||||
}
|
||||
if (dirtyTokens) recomputeUnspent()
|
||||
if (dirtyHistory) {
|
||||
_history.value = historyEvents.values.sortedByDescending { it.createdAt }
|
||||
}
|
||||
if (dirtyQuotes || dirtyHistory) {
|
||||
// History gains might mark quotes as fulfilled (via the "destroyed"
|
||||
// kind:7374 reference); recompute the pending list.
|
||||
recomputePending()
|
||||
}
|
||||
if (dirtyNutzaps) {
|
||||
triggerAutoRedeem()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun removeEvents(ids: Set<HexKey>) {
|
||||
var dirtyTokens = false
|
||||
var dirtyHistory = false
|
||||
var dirtyQuotes = false
|
||||
var dirtyNutzaps = false
|
||||
var dirtyWallet = false
|
||||
|
||||
ids.forEach { id ->
|
||||
if (tokenEvents.remove(id) != null) {
|
||||
dirtyTokens = true
|
||||
tokenContents.remove(id)
|
||||
}
|
||||
if (historyEvents.remove(id) != null) dirtyHistory = true
|
||||
if (quoteEvents.remove(id) != null) dirtyQuotes = true
|
||||
if (nutzapEvents.remove(id) != null) dirtyNutzaps = true
|
||||
if (walletEventInternal?.id == id) {
|
||||
walletEventInternal = null
|
||||
dirtyWallet = true
|
||||
}
|
||||
}
|
||||
|
||||
if (dirtyWallet) {
|
||||
_walletEvent.value = null
|
||||
_mints.value = emptyList()
|
||||
}
|
||||
if (dirtyTokens) recomputeUnspent()
|
||||
if (dirtyHistory) _history.value = historyEvents.values.sortedByDescending { it.createdAt }
|
||||
if (dirtyQuotes || dirtyHistory) recomputePending()
|
||||
// dirtyNutzaps would trigger UI surfacing for inbound nutzaps; auto-
|
||||
// redeem already fires from the live-event observer, so no extra
|
||||
// signal is needed here.
|
||||
if (dirtyNutzaps) Unit
|
||||
}
|
||||
|
||||
private suspend fun recomputeUnspent() {
|
||||
val all = tokenEvents.values.toList()
|
||||
// Decrypt anything we haven't seen before; reuse cached TokenContent
|
||||
// for events we've already decrypted.
|
||||
all.forEach { evt ->
|
||||
tokenContents.getOrPut(evt.id) {
|
||||
runCatching { evt.tokenContent(signer) }
|
||||
.onFailure { Log.w("CashuWallet") { "Failed to decrypt token ${evt.id.take(8)}: ${it.message}" } }
|
||||
.getOrNull() ?: return@getOrPut return@forEach
|
||||
}
|
||||
}
|
||||
|
||||
// Apply `del` rollover.
|
||||
val deletedIds = mutableSetOf<HexKey>()
|
||||
all.forEach { evt -> tokenContents[evt.id]?.del?.let(deletedIds::addAll) }
|
||||
|
||||
val unspent =
|
||||
all
|
||||
.filter { it.id !in deletedIds && tokenContents.containsKey(it.id) }
|
||||
.mapNotNull { evt -> tokenContents[evt.id]?.let { TokenEntry(evt, it) } }
|
||||
.sortedByDescending { it.event.createdAt }
|
||||
|
||||
_tokenEntries.value = unspent
|
||||
}
|
||||
|
||||
private fun recomputePending() {
|
||||
val now = TimeUtils.now()
|
||||
// A quote is "pending" if (1) not expired, and (2) no kind:7376 history
|
||||
// event references its id with a "destroyed" marker — completion of the
|
||||
// mint flow deletes the kind:7374, and history records a `destroyed`
|
||||
// reference to the now-fulfilled quote.
|
||||
val destroyedQuoteIds =
|
||||
historyEvents.values
|
||||
.asSequence()
|
||||
.flatMap { it.tags.asSequence() }
|
||||
.filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" }
|
||||
.map { it[1] }
|
||||
.toSet()
|
||||
|
||||
_pendingQuotes.value =
|
||||
quoteEvents.values
|
||||
.filter { it.id !in destroyedQuoteIds }
|
||||
.filter { evt ->
|
||||
val exp =
|
||||
evt.tags
|
||||
.firstOrNull { it.size >= 2 && it[0] == "expiration" }
|
||||
?.get(1)
|
||||
?.toLongOrNull()
|
||||
exp == null || exp > now
|
||||
}.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
private fun scanCacheForOwnEvents(): List<Event> {
|
||||
val collected = mutableListOf<Event>()
|
||||
cache.notes.forEach { _, note ->
|
||||
val e = note.event ?: return@forEach
|
||||
if (isRelevantEvent(e)) collected += e
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Auto-redeem of inbound NIP-61 nutzaps
|
||||
// ============================================================
|
||||
private fun triggerAutoRedeem() {
|
||||
scope.launch(Dispatchers.IO) { redeemPendingNutzapsSerialized() }
|
||||
}
|
||||
|
||||
private suspend fun redeemPendingNutzapsSerialized() {
|
||||
if (!redeemMutex.tryLock()) return // a sweep is already in flight
|
||||
try {
|
||||
val privkey = walletPrivkeyHex() ?: return
|
||||
val pubkey = p2pkPubkeyHex() ?: return
|
||||
val alreadyRedeemed =
|
||||
historyEvents.values
|
||||
.flatMap { it.redeemedReferences() }
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
|
||||
val candidates = nutzapEvents.values.filter { it.id !in alreadyRedeemed }
|
||||
if (candidates.isEmpty()) return
|
||||
|
||||
for (ev in candidates) {
|
||||
runCatching { ops.redeemNutzap(ev, privkey, pubkey) }
|
||||
.onFailure { e ->
|
||||
Log.w("CashuWallet") {
|
||||
"Auto-redeem of nutzap ${ev.id.take(8)} failed: ${describeMintError(e)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
redeemMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Publish bridge
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Bridge for [CashuWalletOps.publish]. Concrete `Account` plugs in its
|
||||
* `sendLiterallyEverywhere` via the constructor-time wiring. We keep this
|
||||
* delegate field separate to avoid an Account ↔ State direct dependency.
|
||||
*/
|
||||
var publishDelegate: suspend (Event) -> Unit = { /* set by Account */ }
|
||||
|
||||
private suspend fun publishEvent(event: Event) {
|
||||
publishDelegate(event)
|
||||
}
|
||||
}
|
||||
+10
@@ -2177,6 +2177,11 @@ fun mockAccountViewModel(): AccountViewModel {
|
||||
signer = NostrSignerInternal(keyPair),
|
||||
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
|
||||
nwcFilterAssembler = { nwcFilters },
|
||||
cashuWalletFilterAssembler = {
|
||||
com.vitorpamplona.amethyst.commons.relayClient.assemblers
|
||||
.CashuWalletFilterAssembler(client)
|
||||
},
|
||||
okHttpClientForMoney = { okhttp3.OkHttpClient() },
|
||||
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
|
||||
cache = LocalCache,
|
||||
client = client,
|
||||
@@ -2228,6 +2233,11 @@ fun mockVitorAccountViewModel(): AccountViewModel {
|
||||
signer = NostrSignerInternal(keyPair),
|
||||
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
|
||||
nwcFilterAssembler = { nwcFilters },
|
||||
cashuWalletFilterAssembler = {
|
||||
com.vitorpamplona.amethyst.commons.relayClient.assemblers
|
||||
.CashuWalletFilterAssembler(client)
|
||||
},
|
||||
okHttpClientForMoney = { okhttp3.OkHttpClient() },
|
||||
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
|
||||
cache = LocalCache,
|
||||
client = EmptyNostrClient(),
|
||||
|
||||
+45
-1
@@ -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(
|
||||
|
||||
+26
-2
@@ -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 -> {
|
||||
|
||||
+137
-221
@@ -21,32 +21,21 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.MintQuoteStarted
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.TokenEntry
|
||||
import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError
|
||||
import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser
|
||||
import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class CashuWalletCreateState {
|
||||
data object Idle : CashuWalletCreateState()
|
||||
@@ -85,6 +74,15 @@ sealed class CashuMintFlowState {
|
||||
sealed class CashuMeltFlowState {
|
||||
data object Idle : CashuMeltFlowState()
|
||||
|
||||
data object Quoting : CashuMeltFlowState()
|
||||
|
||||
/** Mint returned a quote — show amount + fee, await user confirmation. */
|
||||
data class Quoted(
|
||||
val mintUrl: String,
|
||||
val invoice: String,
|
||||
val quote: MeltQuoteBolt11ResponseDto,
|
||||
) : CashuMeltFlowState()
|
||||
|
||||
data object Paying : CashuMeltFlowState()
|
||||
|
||||
data class Completed(
|
||||
@@ -128,41 +126,25 @@ sealed class CashuRedeemFlowState {
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for the NIP-60 Cashu wallet screens.
|
||||
* Presenter ViewModel for the NIP-60 Cashu wallet screens.
|
||||
*
|
||||
* Exposes:
|
||||
* - wallet bootstrap (create/edit kind:17375 + kind:10019)
|
||||
* - balance / mint list / history / unspent token entries derived from LocalCache
|
||||
* - mint-from-LN flow (start quote → poll → complete)
|
||||
* - melt-to-LN flow (pay bolt11)
|
||||
* - send-as-token flow (produce cashuB)
|
||||
* - redeem cashuB token flow
|
||||
* All persistent state lives on [Account.cashuWalletState]; this VM only
|
||||
* holds the transient per-flow UI state (mint quote in progress, melt
|
||||
* confirmation pending, redeem result). State flows are forwarded directly
|
||||
* from [CashuWalletState] so they survive screen lifecycle.
|
||||
*/
|
||||
class CashuWalletViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var accountViewModel: AccountViewModel? = null
|
||||
private val ops by lazy {
|
||||
val acc = account ?: error("init() not called")
|
||||
CashuWalletOps(acc, Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForMoney)
|
||||
}
|
||||
private val assembler get() = Amethyst.instance.sources.cashuWallet
|
||||
private var subscription: CashuWalletQueryState? = null
|
||||
private var observerJob: Job? = null
|
||||
private val state: CashuWalletState get() = account!!.cashuWalletState
|
||||
private val ops: CashuWalletOps get() = state.ops
|
||||
|
||||
private val _walletEvent = MutableStateFlow<CashuWalletEvent?>(null)
|
||||
val walletEvent = _walletEvent.asStateFlow()
|
||||
|
||||
private val _mints = MutableStateFlow<List<String>>(emptyList())
|
||||
val mints = _mints.asStateFlow()
|
||||
|
||||
private val _balanceSats = MutableStateFlow(0L)
|
||||
val balanceSats = _balanceSats.asStateFlow()
|
||||
|
||||
private val _tokenEntries = MutableStateFlow<List<TokenEntry>>(emptyList())
|
||||
val tokenEntries = _tokenEntries.asStateFlow()
|
||||
|
||||
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
|
||||
val history = _history.asStateFlow()
|
||||
val walletEvent get() = state.walletEvent
|
||||
val mints get() = state.mints
|
||||
val balanceSats get() = state.balanceSats
|
||||
val tokenEntries: StateFlow<List<TokenEntry>> get() = state.tokenEntries
|
||||
val history get() = state.history
|
||||
val pendingQuotes get() = state.pendingQuotes
|
||||
|
||||
private val _createState = MutableStateFlow<CashuWalletCreateState>(CashuWalletCreateState.Idle)
|
||||
val createState = _createState.asStateFlow()
|
||||
@@ -179,151 +161,42 @@ class CashuWalletViewModel : ViewModel() {
|
||||
private val _redeemState = MutableStateFlow<CashuRedeemFlowState>(CashuRedeemFlowState.Idle)
|
||||
val redeemState = _redeemState.asStateFlow()
|
||||
|
||||
private val _mintPingState = MutableStateFlow<MintPingState>(MintPingState.Idle)
|
||||
val mintPingState = _mintPingState.asStateFlow()
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
|
||||
// Subscribe to relay events so the wallet/proofs/history/nutzaps
|
||||
// arrive from outbox relays on first launch and stay in sync.
|
||||
val pubkey = accountViewModel.account.signer.pubKey
|
||||
val relays = accountViewModel.account.outboxRelays.flow.value
|
||||
if (relays.isNotEmpty()) {
|
||||
val query = CashuWalletQueryState(pubkey, relays)
|
||||
subscription = query
|
||||
assembler.subscribe(query)
|
||||
}
|
||||
|
||||
// Re-derive state from the cache whenever the wallet note changes.
|
||||
// Token / history events that arrive via the same subscription are
|
||||
// captured on the next refresh tick.
|
||||
val walletNote = LocalCache.getOrCreateAddressableNote(CashuWalletEvent.createAddress(pubkey))
|
||||
observerJob?.cancel()
|
||||
observerJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
walletNote
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.collect { refresh() }
|
||||
}
|
||||
|
||||
refresh()
|
||||
// No subscription / observer / refresh here — CashuWalletState owns
|
||||
// that lifecycle and is alive for the whole login session.
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
observerJob?.cancel()
|
||||
observerJob = null
|
||||
subscription?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
subscription = null
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the local cache for our wallet event and its associated token /
|
||||
* history events, decrypting whatever the current signer can decrypt and
|
||||
* re-emitting state flows.
|
||||
*
|
||||
* Today this is fire-and-forget. Reactive observation of the underlying
|
||||
* notes is a follow-up — for the first slice, the screen calls refresh()
|
||||
* on entry and after a write.
|
||||
*/
|
||||
fun refresh() {
|
||||
val acc = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val pubKey = acc.signer.pubKey
|
||||
val cache = LocalCache
|
||||
|
||||
val walletNote = cache.getOrCreateAddressableNote(CashuWalletEvent.createAddress(pubKey))
|
||||
val walletEvt = walletNote.event as? CashuWalletEvent
|
||||
_walletEvent.value = walletEvt
|
||||
|
||||
if (walletEvt != null) {
|
||||
runCatching { walletEvt.mints(acc.signer) }
|
||||
.onSuccess { _mints.value = it }
|
||||
} else {
|
||||
_mints.value = emptyList()
|
||||
/** Verify a mint URL is reachable + speaks Cashu v1. */
|
||||
fun pingMint(url: String) {
|
||||
val vm = accountViewModel ?: return
|
||||
if (url.isBlank()) return
|
||||
_mintPingState.value = MintPingState.Pinging
|
||||
vm.launchSigner {
|
||||
try {
|
||||
val name = ops.pingMint(url.trim().trimEnd('/'))
|
||||
_mintPingState.value = MintPingState.Ok(name)
|
||||
} catch (e: Exception) {
|
||||
_mintPingState.value = MintPingState.Failed(describeMintError(e))
|
||||
}
|
||||
|
||||
val tokenList = mutableListOf<CashuTokenEvent>()
|
||||
val historyList = mutableListOf<CashuSpendingHistoryEvent>()
|
||||
cache.notes.forEach { _, note ->
|
||||
val e = note.event ?: return@forEach
|
||||
if (e.pubKey != pubKey) return@forEach
|
||||
when (e) {
|
||||
is CashuTokenEvent -> tokenList.add(e)
|
||||
is CashuSpendingHistoryEvent -> historyList.add(e)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rollover: drop any token whose ID appears in a newer token's `del`.
|
||||
val deletedIds = mutableSetOf<String>()
|
||||
val decoded = mutableMapOf<String, TokenContent>()
|
||||
tokenList.forEach { tok ->
|
||||
runCatching { tok.tokenContent(acc.signer) }
|
||||
.getOrNull()
|
||||
?.let { content ->
|
||||
decoded[tok.id] = content
|
||||
deletedIds.addAll(content.del)
|
||||
}
|
||||
}
|
||||
val unspent = tokenList.filter { it.id !in deletedIds && decoded[it.id] != null }
|
||||
val entries = unspent.mapNotNull { e -> decoded[e.id]?.let { TokenEntry(e, it) } }
|
||||
_tokenEntries.value = entries.sortedByDescending { it.event.createdAt }
|
||||
|
||||
_balanceSats.value = entries.sumOf { it.content.totalAmount() }
|
||||
_history.value = historyList.sortedByDescending { it.createdAt }
|
||||
|
||||
// Best-effort auto-redeem of any inbound NIP-61 nutzaps that we
|
||||
// haven't already redeemed. Idempotent — guarded by the redeemed
|
||||
// marker in our kind:7376 history.
|
||||
redeemPendingNutzaps(walletEvt, historyList)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun redeemPendingNutzaps(
|
||||
walletEvt: CashuWalletEvent?,
|
||||
history: List<CashuSpendingHistoryEvent>,
|
||||
) {
|
||||
val acc = account ?: return
|
||||
if (walletEvt == null) return
|
||||
val privkey = runCatching { walletEvt.privkey(acc.signer) }.getOrNull() ?: return
|
||||
|
||||
val alreadyRedeemed =
|
||||
history
|
||||
.flatMap { it.redeemedReferences() }
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
|
||||
val candidates =
|
||||
buildList {
|
||||
LocalCache.notes.forEach { _, note ->
|
||||
val e = note.event ?: return@forEach
|
||||
if (e is NutzapEvent && e.id !in alreadyRedeemed) add(e)
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty()) return
|
||||
|
||||
candidates.forEach { ev ->
|
||||
runCatching { ops.redeemNutzap(ev, privkey) }
|
||||
.onFailure {
|
||||
// Swallow; we'll retry on the next refresh. The mint may
|
||||
// have rejected for double-spend (someone else redeemed
|
||||
// first) or other transient reasons.
|
||||
}
|
||||
}
|
||||
fun resetMintPing() {
|
||||
_mintPingState.value = MintPingState.Idle
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and publishes a kind:17375 + kind:10019 pair. The wallet event is
|
||||
* replaceable so calling this again with different mints (or a different
|
||||
* privkey) overwrites the previous wallet.
|
||||
*/
|
||||
fun saveWallet(
|
||||
mints: List<String>,
|
||||
autoGenPrivkey: Boolean,
|
||||
manualPrivkey: String? = null,
|
||||
) {
|
||||
val vm = accountViewModel ?: return
|
||||
val acc = account ?: return
|
||||
|
||||
if (mints.isEmpty()) {
|
||||
_createState.value = CashuWalletCreateState.Error("Add at least one mint")
|
||||
@@ -335,17 +208,20 @@ class CashuWalletViewModel : ViewModel() {
|
||||
try {
|
||||
val privkey =
|
||||
when {
|
||||
autoGenPrivkey -> Bdhke.randomScalar().toHexKey()
|
||||
autoGenPrivkey -> null // ops generates one
|
||||
!manualPrivkey.isNullOrBlank() -> manualPrivkey.trim()
|
||||
else -> null
|
||||
}
|
||||
|
||||
ops.publishWalletEvents(mints, privkey)
|
||||
ops.publishWalletEvents(
|
||||
mints = mints,
|
||||
p2pkPrivkeyHex = privkey,
|
||||
nutzapRelays =
|
||||
acc.outboxRelays.flow.value
|
||||
.toList(),
|
||||
)
|
||||
_createState.value = CashuWalletCreateState.Success
|
||||
refresh()
|
||||
} catch (e: Exception) {
|
||||
_createState.value =
|
||||
CashuWalletCreateState.Error(ops.describe(e))
|
||||
_createState.value = CashuWalletCreateState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,9 +250,8 @@ class CashuWalletViewModel : ViewModel() {
|
||||
try {
|
||||
val flow = ops.startMintFromLightning(mintUrl, amountSats)
|
||||
_mintState.value = CashuMintFlowState.AwaitingPayment(flow, mintUrl, amountSats)
|
||||
refresh()
|
||||
} catch (e: Exception) {
|
||||
_mintState.value = CashuMintFlowState.Error(ops.describe(e))
|
||||
_mintState.value = CashuMintFlowState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,19 +262,42 @@ class CashuWalletViewModel : ViewModel() {
|
||||
*/
|
||||
fun checkAndCompleteMint() {
|
||||
val vm = accountViewModel ?: return
|
||||
val state = _mintState.value as? CashuMintFlowState.AwaitingPayment ?: return
|
||||
val current = _mintState.value as? CashuMintFlowState.AwaitingPayment ?: return
|
||||
vm.launchSigner {
|
||||
try {
|
||||
val status = ops.checkMintQuote(state.mintUrl, state.flow.mintQuote.quote)
|
||||
val status = ops.checkMintQuote(current.mintUrl, current.flow.mintQuote.quote)
|
||||
val paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED"
|
||||
if (!paid) return@launchSigner
|
||||
|
||||
_mintState.value = CashuMintFlowState.Completing
|
||||
ops.completeMintFromLightning(state.mintUrl, state.flow.quoteEvent, state.amountSats)
|
||||
_mintState.value = CashuMintFlowState.Completed(state.amountSats)
|
||||
refresh()
|
||||
ops.completeMintFromLightning(current.mintUrl, current.flow.quoteEvent, current.amountSats)
|
||||
_mintState.value = CashuMintFlowState.Completed(current.amountSats)
|
||||
} catch (e: Exception) {
|
||||
_mintState.value = CashuMintFlowState.Error(ops.describe(e))
|
||||
_mintState.value = CashuMintFlowState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume polling an unfulfilled kind:7374 quote left over from a previous session. */
|
||||
fun resumeMintQuote(quoteEvent: com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent) {
|
||||
val vm = accountViewModel ?: return
|
||||
val mintUrl =
|
||||
quoteEvent.mint() ?: run {
|
||||
_mintState.value = CashuMintFlowState.Error("Quote has no mint tag")
|
||||
return
|
||||
}
|
||||
vm.launchSigner {
|
||||
try {
|
||||
val quoteId = quoteEvent.quoteId(account!!.signer)
|
||||
val status = ops.checkMintQuote(mintUrl, quoteId)
|
||||
_mintState.value =
|
||||
CashuMintFlowState.AwaitingPayment(
|
||||
flow = MintQuoteStarted(quoteEvent = quoteEvent, mintQuote = status, invoice = status.request),
|
||||
mintUrl = mintUrl,
|
||||
amountSats = 0L, // unknown — caller may have to re-enter, mint will validate
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_mintState.value = CashuMintFlowState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,7 +308,11 @@ class CashuWalletViewModel : ViewModel() {
|
||||
|
||||
// -------- Melt to LN --------
|
||||
|
||||
fun meltToLightning(
|
||||
/**
|
||||
* Phase 1 of melt — request a quote so the user can see amount + fees
|
||||
* before committing. Use [confirmMelt] to actually pay.
|
||||
*/
|
||||
fun startMelt(
|
||||
mintUrl: String,
|
||||
invoice: String,
|
||||
) {
|
||||
@@ -423,20 +325,44 @@ class CashuWalletViewModel : ViewModel() {
|
||||
_meltState.value = CashuMeltFlowState.Error("Pick a mint")
|
||||
return
|
||||
}
|
||||
val available = _tokenEntries.value.filter { it.content.mint == mintUrl }
|
||||
val available = tokenEntries.value.filter { it.content.mint == mintUrl }
|
||||
if (available.isEmpty()) {
|
||||
_meltState.value = CashuMeltFlowState.Error("No proofs available at $mintUrl")
|
||||
return
|
||||
}
|
||||
|
||||
_meltState.value = CashuMeltFlowState.Quoting
|
||||
vm.launchSigner {
|
||||
try {
|
||||
val quote = ops.requestMeltQuote(mintUrl, invoice.trim())
|
||||
val balance = available.sumOf { it.content.totalAmount() }
|
||||
if (balance < quote.amount + quote.feeReserve) {
|
||||
_meltState.value =
|
||||
CashuMeltFlowState.Error(
|
||||
"Need ${quote.amount + quote.feeReserve} sat (incl. fees) but only have $balance",
|
||||
)
|
||||
return@launchSigner
|
||||
}
|
||||
_meltState.value = CashuMeltFlowState.Quoted(mintUrl, invoice.trim(), quote)
|
||||
} catch (e: Exception) {
|
||||
_meltState.value = CashuMeltFlowState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Phase 2 of melt — user confirmed the fee, pay the invoice now. */
|
||||
fun confirmMelt() {
|
||||
val vm = accountViewModel ?: return
|
||||
val quoted = _meltState.value as? CashuMeltFlowState.Quoted ?: return
|
||||
val available = tokenEntries.value.filter { it.content.mint == quoted.mintUrl }
|
||||
|
||||
_meltState.value = CashuMeltFlowState.Paying
|
||||
vm.launchSigner {
|
||||
try {
|
||||
val result = ops.meltToLightning(mintUrl, invoice.trim(), available)
|
||||
val result = ops.meltToLightning(quoted.mintUrl, quoted.quote, available)
|
||||
_meltState.value = CashuMeltFlowState.Completed(result.paidAmount, result.fees, result.preimage)
|
||||
refresh()
|
||||
} catch (e: Exception) {
|
||||
_meltState.value = CashuMeltFlowState.Error(ops.describe(e))
|
||||
_meltState.value = CashuMeltFlowState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,7 +387,7 @@ class CashuWalletViewModel : ViewModel() {
|
||||
_sendTokenState.value = CashuSendTokenFlowState.Error("Pick a mint")
|
||||
return
|
||||
}
|
||||
val available = _tokenEntries.value.filter { it.content.mint == mintUrl }
|
||||
val available = tokenEntries.value.filter { it.content.mint == mintUrl }
|
||||
val balanceAtMint = available.sumOf { it.content.totalAmount() }
|
||||
if (balanceAtMint < amountSats) {
|
||||
_sendTokenState.value =
|
||||
@@ -474,9 +400,8 @@ class CashuWalletViewModel : ViewModel() {
|
||||
try {
|
||||
val result = ops.sendAsToken(mintUrl, amountSats, available, memo)
|
||||
_sendTokenState.value = CashuSendTokenFlowState.Ready(result.cashuToken, result.amount)
|
||||
refresh()
|
||||
} catch (e: Exception) {
|
||||
_sendTokenState.value = CashuSendTokenFlowState.Error(ops.describe(e))
|
||||
_sendTokenState.value = CashuSendTokenFlowState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,29 +430,18 @@ class CashuWalletViewModel : ViewModel() {
|
||||
_redeemState.value = CashuRedeemFlowState.Error("Token has no proofs")
|
||||
return
|
||||
}
|
||||
tok.mint to
|
||||
tok.proofs.map {
|
||||
CashuProof(
|
||||
id = it.id,
|
||||
amount = it.amount.toLong(),
|
||||
secret = it.secret,
|
||||
c = it.C,
|
||||
)
|
||||
}
|
||||
tok.mint to tok.proofs.map { CashuProof(it.id, it.amount.toLong(), it.secret, it.C) }
|
||||
}
|
||||
|
||||
is GenericLoadable.Error -> {
|
||||
_redeemState.value = CashuRedeemFlowState.Error(parsed.errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
_redeemState.value = CashuRedeemFlowState.Error("Could not parse token")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trimmed.startsWith("cashuA") -> {
|
||||
when (val parsed = V3Parser.parseCashuA(trimmed)) {
|
||||
is GenericLoadable.Loaded -> {
|
||||
@@ -536,29 +450,18 @@ class CashuWalletViewModel : ViewModel() {
|
||||
_redeemState.value = CashuRedeemFlowState.Error("Token has no proofs")
|
||||
return
|
||||
}
|
||||
tok.mint to
|
||||
tok.proofs.map {
|
||||
CashuProof(
|
||||
id = it.id,
|
||||
amount = it.amount.toLong(),
|
||||
secret = it.secret,
|
||||
c = it.C,
|
||||
)
|
||||
}
|
||||
tok.mint to tok.proofs.map { CashuProof(it.id, it.amount.toLong(), it.secret, it.C) }
|
||||
}
|
||||
|
||||
is GenericLoadable.Error -> {
|
||||
_redeemState.value = CashuRedeemFlowState.Error(parsed.errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
_redeemState.value = CashuRedeemFlowState.Error("Could not parse token")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_redeemState.value =
|
||||
CashuRedeemFlowState.Error("Not a Cashu token (must start with cashuA or cashuB)")
|
||||
@@ -566,7 +469,7 @@ class CashuWalletViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
if (mintUrl !in _mints.value) {
|
||||
if (mintUrl !in mints.value) {
|
||||
_redeemState.value =
|
||||
CashuRedeemFlowState.Error("Token mint ($mintUrl) is not in your wallet. Add it first.")
|
||||
return
|
||||
@@ -577,9 +480,8 @@ class CashuWalletViewModel : ViewModel() {
|
||||
try {
|
||||
val result = ops.redeemToken(trimmed, proofs, mintUrl)
|
||||
_redeemState.value = CashuRedeemFlowState.Completed(result.amount)
|
||||
refresh()
|
||||
} catch (e: Exception) {
|
||||
_redeemState.value = CashuRedeemFlowState.Error(ops.describe(e))
|
||||
_redeemState.value = CashuRedeemFlowState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,3 +490,17 @@ class CashuWalletViewModel : ViewModel() {
|
||||
_redeemState.value = CashuRedeemFlowState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
sealed class MintPingState {
|
||||
data object Idle : MintPingState()
|
||||
|
||||
data object Pinging : MintPingState()
|
||||
|
||||
data class Ok(
|
||||
val name: String?,
|
||||
) : MintPingState()
|
||||
|
||||
data class Failed(
|
||||
val message: String,
|
||||
) : MintPingState()
|
||||
}
|
||||
|
||||
@@ -1898,6 +1898,13 @@
|
||||
<string name="cashu_copy_invoice">Copy invoice</string>
|
||||
<string name="cashu_request_invoice">Request invoice</string>
|
||||
<string name="cashu_pay_invoice">Pay invoice</string>
|
||||
<string name="cashu_get_quote">Get quote</string>
|
||||
<string name="cashu_getting_quote">Asking mint for a quote…</string>
|
||||
<string name="cashu_quote_confirm">Pay %1$s sat + up to %2$s sat in fees?</string>
|
||||
<string name="cashu_verify">Verify</string>
|
||||
<string name="cashu_mint_reachable">✓ Mint is reachable</string>
|
||||
<string name="cashu_mint_reachable_named">✓ %1$s</string>
|
||||
<string name="cashu_mint_unreachable">Could not reach mint: %1$s</string>
|
||||
<string name="cashu_create_token">Create token</string>
|
||||
<string name="cashu_redeem_button">Redeem</string>
|
||||
<string name="cashu_done">Done</string>
|
||||
|
||||
+3
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
@@ -43,8 +44,8 @@ import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
* NIP-60 events and as the `#p` tag value for inbound nutzaps. `relays` is
|
||||
* the union of relays to subscribe on (NIP-65 outbox + DM relays at minimum).
|
||||
*/
|
||||
@Stable
|
||||
class CashuWalletQueryState(
|
||||
@Immutable
|
||||
data class CashuWalletQueryState(
|
||||
val pubkey: HexKey,
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
@@ -97,7 +98,7 @@ object P2PK {
|
||||
if (arr.size < 2) return null
|
||||
val kind = (arr[0] as? JsonPrimitive)?.content
|
||||
if (kind != "P2PK") return null
|
||||
val body = arr[1] as? kotlinx.serialization.json.JsonObject ?: return null
|
||||
val body = arr[1] as? JsonObject ?: return null
|
||||
val data = (body["data"] as? JsonPrimitive)?.content ?: return null
|
||||
val nonce = (body["nonce"] as? JsonPrimitive)?.content
|
||||
ParsedP2pk(pubKeyHex = data, nonceHex = nonce)
|
||||
|
||||
+10
-12
@@ -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*
|
||||
|
||||
+10
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user