From 67d6135ee7e3ad588f3ea81a2fde0366bb873c08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:45:21 +0000 Subject: [PATCH] fix(topup): never re-move funds on retry; wait for proofs before zapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: a top-up that moved funds but then failed at the nutzap (the freshly minted proofs hadn't landed in local state yet → "No proofs available") left a Failed state; tapping "Try again" re-ran the WHOLE pipeline and moved the funds a second time — two transfers for one zap. - Add a `toppedUp` checkpoint set the moment funds land at the target (after rebalance / completeMintFromLightning). confirm()/retry now skips the move entirely once topped up and only (re)sends the zap — funds can never move twice. - awaitTargetFunded(): briefly poll the target balance after topping up so the follow-up nutzap sees the new proofs and succeeds on the first try instead of needing a manual retry. Best-effort with a timeout; the checkpoint guarantees no double-move even if it falls through. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn --- .../loggedIn/wallet/ReloadMintViewModel.kt | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) 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 13838fcf77..7de2acdc35 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 @@ -128,6 +128,11 @@ class ReloadMintViewModel : ViewModel() { * scope, not this VM's). */ private var job: Job? = null + /** Set once the target mint has actually been topped up, so a retry after a + * later failure (e.g. the send) re-sends only — it must never move funds + * again. Without this, "Try again" re-ran the whole pipeline and double-spent. */ + private var toppedUp = false + private var recipient: String? = null private val _uiState = MutableStateFlow(ReloadUiState()) @@ -278,8 +283,9 @@ class ReloadMintViewModel : ViewModel() { vm.launchSigner { try { when { - // The target already covers the send — no top-up, just zap. - moveSats <= 0L -> sendNutzapAndFinish(note, s.sendSats) + // Already topped up (or the target already covers the send) — + // never move funds again on retry, just (re)send the zap. + toppedUp || moveSats <= 0L -> sendNutzapAndFinish(note, s.sendSats) source is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, moveSats, note, s.sendSats) source is ReloadSource.LightningWallet -> reloadFromLightningThenZap(s.selectedTarget, moveSats, walletUriFor(source.walletId), note, s.sendSats) @@ -306,6 +312,9 @@ class ReloadMintViewModel : ViewModel() { 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 + awaitTargetFunded(targetMint, sendSats) sendNutzapAndFinish(note, sendSats) } @@ -355,9 +364,31 @@ class ReloadMintViewModel : ViewModel() { } 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) } + /** + * Freshly moved/minted proofs reach [CashuWalletState] asynchronously (the + * kind:7375 token event round-trips before [peekMintBalances] reflects it). + * Give the target a moment to show the balance so the nutzap that follows + * doesn't fail with "No proofs available" on the first try. Best-effort: if it + * doesn't land in time the send still proceeds (and fails recoverably without + * re-moving funds, thanks to the [toppedUp] checkpoint). + */ + private suspend fun awaitTargetFunded( + targetMint: String, + sats: Long, + ) { + val st = state ?: return + repeat(12) { + if ((st.peekMintBalances()[targetMint] ?: 0L) >= sats) return + delay(500) + } + } + /** * Send the nutzap and only report [ReloadStatus.Done] once it actually succeeds. * Suspends on the real send (throwing on failure) instead of the fire-and-forget