From 380122bcb5b70d620568221e626befca6bf62c5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 02:07:43 +0000 Subject: [PATCH] fix(topup): checkpoint the instant funds leave the wallet, not after the whole flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found the toppedUp checkpoint was still too coarse: it was set only AFTER rebalance() / the LN mint fully returned. But funds leave the wallet mid-flow — the source melt (rebalance) and the invoice payment (Lightning) both happen before the poll/completeMintFromLightning steps that can throw. A failure there left toppedUp=false, so "Try again" re-ran the whole move and spent a second time. - CashuWalletState.rebalance gains an onFundsMoved callback fired immediately after the melt succeeds; the VM sets toppedUp there. - The Lightning path sets toppedUp the moment the invoice is confirmed paid, before issuing ecash. Either way, once money has moved a retry can only re-send / resume — it can never move funds again. (The paid-but-unissued quote remains recoverable via the pending quote banner.) https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../model/nip60Cashu/CashuWalletState.kt | 6 ++++++ .../loggedIn/wallet/ReloadMintViewModel.kt | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) 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 9218037079..902ca796d8 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 @@ -1179,6 +1179,7 @@ class CashuWalletState( targetMintUrl: String, sats: Long, onProgress: ((Float) -> Unit)? = null, + onFundsMoved: () -> Unit = {}, ): RebalanceCompleted { check(started) { "CashuWalletState.start() not called" } require(sats > 0) { "Amount must be positive" } @@ -1207,6 +1208,11 @@ class CashuWalletState( // 3. Pay the destination invoice by melting at the source. onProgress?.invoke(0.5f) meltToLightning(sourceMintUrl, meltQuote) + // Funds have now LEFT the source. Signal the caller immediately so a + // failure in the steps below (slow confirmation, completeMint error) + // never causes a retry to melt a second time — the destination quote is + // paid and resumable via the pending-quote banner instead. + onFundsMoved() // 4. The melt paid the invoice; confirm + issue proofs at the target. // The melt settled synchronously, but a healthy mint can still lag a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 7de2acdc35..477adc4af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -309,11 +309,16 @@ class ReloadMintViewModel : ViewModel() { ) { val st = state ?: return setStatus(ReloadStatus.Working("Moving funds", 0.1f)) - st.rebalance(sourceMint, targetMint, moveSats) { p -> - setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) - } - // Funds have moved — checkpoint so a later failure never re-moves them. - toppedUp = true + st.rebalance( + sourceMintUrl = sourceMint, + targetMintUrl = targetMint, + sats = moveSats, + onProgress = { p -> setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f))) }, + // Checkpoint the instant funds leave the source — not after the whole + // rebalance returns. A failure between the melt and the issue/send steps + // must never let a retry melt again. + onFundsMoved = { toppedUp = true }, + ) awaitTargetFunded(targetMint, sendSats) sendNutzapAndFinish(note, sendSats) } @@ -362,10 +367,12 @@ class ReloadMintViewModel : ViewModel() { setStatus(ReloadStatus.Failed("Invoice not paid yet — you can finish it later from the pending quote banner")) return } + // The invoice is paid — money has left the wallet. Checkpoint now, before + // issuing, so a failure in completeMintFromLightning never lets a retry + // mint+pay a second time (the paid quote is resumable from the banner). + toppedUp = true setStatus(ReloadStatus.Working("Issuing ecash", 0.85f)) ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats) - // Ecash minted at the target — checkpoint so a later failure never re-mints. - toppedUp = true awaitTargetFunded(targetMint, sendSats) sendNutzapAndFinish(note, sendSats) }