From c73e7924dd62d20c9b87c80ad32d1feb00f3cfc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 19:19:33 +0000 Subject: [PATCH] feat(cashu): add mint-balance queries and mint-to-mint rebalance orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the Reload Mint screen (NEEDS_RELOAD funding path): - peekMintBalances(): per-mint spendable balance from in-memory proofs (synchronous; feeds the balances list and source-feasibility checks). - recipientSharedMints(): the recipient's accepted mints we also hold — the candidate reload destinations. - rebalance(source, target, sats): mints a fresh quote at the target and pays its invoice by melting at the source, moving cashu between mints with no new sats. Verifies the source covers amount + fee before spending, abandons the unpaid quote otherwise, and leaves a resumable kind:7374 if the target hasn't confirmed yet — money is never lost on failure. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../model/nip60Cashu/CashuWalletState.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) 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 index 567cb11595..6cc59d458e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -812,6 +813,29 @@ class CashuWalletState( ) } + /** + * Spendable cashu balance per mint URL, summed from in-memory proofs. + * Synchronous and local — feeds the Reload Mint screen's balances list and + * source-feasibility checks. May overstate by NUT-07 stale proofs; the + * actual move scrubs before melting. + */ + fun peekMintBalances(): Map = + _tokenEntries.value + .groupBy { it.content.mint } + .mapValues { (_, entries) -> entries.sumOf { it.content.totalAmount() } } + + /** + * Mints the recipient accepts (their kind:10019) that we also hold a wallet + * at — the candidate destinations for a reload. Empty when the recipient + * has no kind:10019 or shares no mint with us. + */ + fun recipientSharedMints(recipientPubKey: HexKey): List { + val ourMints = _mints.value.toSet() + if (ourMints.isEmpty()) return emptyList() + val info = cache.getOrCreateUser(recipientPubKey).nutzapInfo() ?: return emptyList() + return info.mints().map { it.mintUrl }.filter { it in ourMints } + } + /** * NUT-09 wallet restore — recover proofs minted at [mintUrl] whose * kind:7375 token events have been lost. Scans deterministic @@ -1135,6 +1159,85 @@ class CashuWalletState( return ops.meltToLightning(mintUrl, quote, available) } + /** + * Move [sats] of cashu from [sourceMintUrl] into [targetMintUrl] by minting + * a fresh quote at the target and paying its invoice with a melt at the + * source — no new sats enter the wallet. Used by the Reload Mint screen to + * top up a recipient's mint from another mint the user already holds. + * + * The melt settles the Lightning payment synchronously, so the target quote + * should read PAID within moments; we poll briefly to absorb mint-side lag + * before issuing the proofs. + * + * Money is never lost on failure: a failed melt returns change to the + * source, and a paid-but-not-yet-issued target leaves a resumable kind:7374 + * the pending-quote banner can finish later. Throws with a user-facing + * message on any failure; callers surface it via [describeMintError]. + */ + suspend fun rebalance( + sourceMintUrl: String, + targetMintUrl: String, + sats: Long, + onProgress: ((Float) -> Unit)? = null, + ): RebalanceCompleted { + check(started) { "CashuWalletState.start() not called" } + require(sats > 0) { "Amount must be positive" } + require(sourceMintUrl != targetMintUrl) { "Source and target mints must differ" } + + // 1. Mint quote at the destination — publishes a resumable kind:7374. + onProgress?.invoke(0.1f) + val mintFlow = ops.startMintFromLightning(targetMintUrl, sats) + + // 2. Quote the melt at the source and make sure it covers amount + fee + // BEFORE spending. Abandon the unpaid mint quote otherwise so it + // doesn't linger in the pending banner. + onProgress?.invoke(0.25f) + val meltQuote = ops.requestMeltQuote(sourceMintUrl, mintFlow.invoice) + scrubLocallyStaleProofs(sourceMintUrl) + val sourceBalance = + _tokenEntries.value + .filter { it.content.mint == sourceMintUrl } + .sumOf { it.content.totalAmount() } + val required = meltQuote.amount + meltQuote.feeReserve + if (sourceBalance < required) { + runCatching { ops.cancelMintQuote(mintFlow.quoteEvent) } + throw IllegalStateException("$sourceMintUrl has $sourceBalance sat — needs $required to move $sats") + } + + // 3. Pay the destination invoice by melting at the source. + onProgress?.invoke(0.5f) + meltToLightning(sourceMintUrl, meltQuote) + + // 4. The melt paid the invoice; confirm + issue proofs at the target. + onProgress?.invoke(0.75f) + val pollAttempts = 8 + val pollDelayMs = 1_000L + var paid = false + var attempt = 0 + while (!paid && attempt < pollAttempts) { + val status = ops.checkMintQuote(targetMintUrl, mintFlow.mintQuote.quote) + paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED" + if (!paid) { + delay(pollDelayMs * (attempt + 1)) + attempt++ + } + } + if (!paid) { + // Funds already left the source; the destination invoice is paid or + // will be. Leave the kind:7374 so the pending banner can finish it. + throw IllegalStateException("Paid $targetMintUrl but it hasn't confirmed yet — finish from the pending quote banner") + } + ops.completeMintFromLightning(targetMintUrl, mintFlow.quoteEvent, sats) + onProgress?.invoke(1.0f) + + return RebalanceCompleted( + sourceMintUrl = sourceMintUrl, + targetMintUrl = targetMintUrl, + movedSats = sats, + feeSats = meltQuote.feeReserve, + ) + } + // ============================================================ // Publish bridge // ============================================================ @@ -1179,3 +1282,11 @@ data class NutzapFunding( val bestSingleMintSats: Long, val totalWalletSats: Long, ) + +/** Result of a [CashuWalletState.rebalance] mint-to-mint move. */ +data class RebalanceCompleted( + val sourceMintUrl: String, + val targetMintUrl: String, + val movedSats: Long, + val feeSats: Long, +)