diff --git a/src/composables/useMoveFundsPage.ts b/src/composables/useMoveFundsPage.ts index 91365de..17eac8c 100644 --- a/src/composables/useMoveFundsPage.ts +++ b/src/composables/useMoveFundsPage.ts @@ -7,7 +7,7 @@ import type { MintFee } from 'lnurlcash-kit'; import { transferBetweenMints } from '@/lnurlcash/ops'; import type { TransferOutcome } from '@/lnurlcash/ops'; import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees'; -import type { NewBearer } from '@/lnurlcash/types'; +import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units'; import { useWalletStore } from '@/stores/wallet'; import { useMintsStore } from '@/stores/mints'; @@ -135,14 +135,28 @@ export const useMoveFundsPage = () => { try { const ownerFence = wallet.captureOwnerFence(); const commitContext = { ownerFence, warn: warnCommitted }; + // the carve commits the moment it lands server-side (onCarve), not + // after the melt + settlement wait - an abort mid-wait can then + // never leave burned inputs looking spendable or strand the outputs + const carveState: { committed?: Bearer } = {}; const transfer = await transferBetweenMints( wallet.bearers, satsToMsat(sats), targetInput.value, - { assertOwner: ownerFence }, + { + assertOwner: ownerFence, + onCarve: async (carve) => { + carveState.committed = await commitCarve(wallet, carve, commitContext); + }, + }, ); stage.value = 'Confirming the result…'; - const carved = await commitCarve(wallet, transfer.carve, commitContext); + // a carve that mutated nothing fires no hook - the note is already + // tracked; look it up + const carved = + carveState.committed ?? + wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url); + if (!carved) throw new Error('The carved note was not tracked.'); if (transfer.rescuedNote) { await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext); } @@ -163,6 +177,13 @@ export const useMoveFundsPage = () => { ); toast('positive', `Moved ${sats.toLocaleString()} sats.`); } else if (transfer.outcome === 'failed-funds-returned') { + if (transfer.rotatedNote) { + // the source-probe rotate re-secured the returned funds at a + // fresh secret, burning the carved note the checkpoint already + // committed - add the new note BEFORE marking the old one spent + await addCommittedBearers(wallet, [transfer.rotatedNote], commitContext); + await wallet.markSpent(carved.id, ownerFence); + } await activity.log( 'transfer', `A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`, diff --git a/src/composables/usePayInvoiceDialog.ts b/src/composables/usePayInvoiceDialog.ts index 6e1fbb4..7f770e8 100644 --- a/src/composables/usePayInvoiceDialog.ts +++ b/src/composables/usePayInvoiceDialog.ts @@ -5,6 +5,7 @@ import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnur import { readClipboard } from '@/capabilities/clipboard'; import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops'; import type { PayOutcome } from '@/lnurlcash/ops'; +import type { Bearer } from '@/lnurlcash/types'; import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { useWalletStore } from '@/stores/wallet'; import { useActivityStore } from '@/stores/activity'; @@ -145,15 +146,26 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit try { ownerFence = wallet.captureOwnerFence(); const commitContext = { ownerFence, warn: warnCommitted }; + // the carve commits the moment it lands server-side (onCarve), not + // after the settlement wait - an abort mid-wait can then never leave + // burned inputs looking spendable or strand the fresh outputs + const carveState: { committed?: Bearer } = {}; + const onCarve = async (carve: Parameters[1]): Promise => { + carveState.committed = await commitCarve(wallet, carve, commitContext); + }; const paid = await payWithBearers( wallet.bearers, payment.input, payment.kind === 'address' - ? { amountMsat: payment.amountMsat, assertOwner: ownerFence } - : { assertOwner: ownerFence }, + ? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve } + : { assertOwner: ownerFence, onCarve }, ); stage.value = 'Confirming the result…'; - const committed = await commitCarve(wallet, paid.carve, commitContext); + // a carve that mutated nothing (one note already held the exact + // amount) fires no hook - the note is already tracked; look it up + const committed = + carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url); + if (!committed) throw new Error('The carved note was not tracked.'); if (paid.rescuedNote) { await addCommittedBearers(wallet, [paid.rescuedNote], commitContext); } @@ -166,6 +178,13 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit toast('positive', `Paid ${sats} sats.`); emit('sent'); } else if (paid.outcome === 'failed-funds-returned') { + if (paid.rotatedNote) { + // the classification rotate re-secured the returned funds at a + // fresh secret, burning the carved note the checkpoint already + // committed - add the new note BEFORE marking the old one spent + await addCommittedBearers(wallet, [paid.rotatedNote], commitContext); + await wallet.markSpent(committed.id, ownerFence); + } await activity.log( 'transfer', `A ${sats} sat payment failed - funds are back in your wallet.`, diff --git a/src/composables/useSendTokenDialog.ts b/src/composables/useSendTokenDialog.ts index 15b8b4b..684bcb5 100644 --- a/src/composables/useSendTokenDialog.ts +++ b/src/composables/useSendTokenDialog.ts @@ -82,10 +82,26 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = let ownerFence: WalletOwnerFence | undefined; try { ownerFence = wallet.captureOwnerFence(); + const fence = ownerFence; + // the carve commits the moment it lands server-side (onCarve), so + // even a crash right after can never strand the fresh outputs or + // leave burned inputs looking spendable + const carveState: { committed?: Bearer } = {}; const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), { - assertOwner: ownerFence, + assertOwner: fence, + onCarve: async (landed) => { + carveState.committed = await commitCarve(wallet, landed, { + ownerFence: fence, + warn: warnCommitted, + }); + }, }); - const note = await commitCarve(wallet, carve, { ownerFence, warn: warnCommitted }); + // a carve that mutated nothing (one note already held the exact + // amount) fires no hook - the note is already tracked; look it up + const committed = + carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url); + if (!committed) throw new Error('The carved note was not tracked.'); + const note = committed; if (carve.change) { await activity.log( 'split',