fix(topup): checkpoint the instant funds leave the wallet, not after the whole flow

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
This commit is contained in:
Claude
2026-05-30 02:07:43 +00:00
parent 2c02795e45
commit 380122bcb5
2 changed files with 20 additions and 7 deletions
@@ -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
@@ -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)
}