mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
@@ -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
|
||||
|
||||
+14
-7
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user