fix(topup): never re-move funds on retry; wait for proofs before zapping

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
This commit is contained in:
Claude
2026-05-30 00:45:21 +00:00
parent 77c183186c
commit 67d6135ee7
@@ -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