fix: commit send, pay and move carves at the checkpoint (#6)

The three composables commit the carve inside onCarve via the existing
commitCarve helper instead of after the flow, falling back to a URL
lookup for no-mutation carves (which fire no hook). On
failed-funds-returned with a rotation, the re-secured note is added
before the burned carved note is marked spent.
This commit is contained in:
2026-08-31 14:48:03 +02:00
parent 587dd11ba8
commit 9e37fee841
3 changed files with 64 additions and 8 deletions
+24 -3
View File
@@ -7,7 +7,7 @@ import type { MintFee } from 'lnurlcash-kit';
import { transferBetweenMints } from '@/lnurlcash/ops'; import { transferBetweenMints } from '@/lnurlcash/ops';
import type { TransferOutcome } from '@/lnurlcash/ops'; import type { TransferOutcome } from '@/lnurlcash/ops';
import { maxNetForBalance, quoteMintFee } from '@/lnurlcash/fees'; 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 { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints'; import { useMintsStore } from '@/stores/mints';
@@ -135,14 +135,28 @@ export const useMoveFundsPage = () => {
try { try {
const ownerFence = wallet.captureOwnerFence(); const ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted }; 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( const transfer = await transferBetweenMints(
wallet.bearers, wallet.bearers,
satsToMsat(sats), satsToMsat(sats),
targetInput.value, targetInput.value,
{ assertOwner: ownerFence }, {
assertOwner: ownerFence,
onCarve: async (carve) => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
},
},
); );
stage.value = 'Confirming the result…'; 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) { if (transfer.rescuedNote) {
await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext); await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext);
} }
@@ -163,6 +177,13 @@ export const useMoveFundsPage = () => {
); );
toast('positive', `Moved ${sats.toLocaleString()} sats.`); toast('positive', `Moved ${sats.toLocaleString()} sats.`);
} else if (transfer.outcome === 'failed-funds-returned') { } 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( await activity.log(
'transfer', 'transfer',
`A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`, `A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`,
+22 -3
View File
@@ -5,6 +5,7 @@ import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnur
import { readClipboard } from '@/capabilities/clipboard'; import { readClipboard } from '@/capabilities/clipboard';
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops'; import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
import type { PayOutcome } from '@/lnurlcash/ops'; import type { PayOutcome } from '@/lnurlcash/ops';
import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { msatToSats, satsToMsat } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
import { useActivityStore } from '@/stores/activity'; import { useActivityStore } from '@/stores/activity';
@@ -145,15 +146,26 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
try { try {
ownerFence = wallet.captureOwnerFence(); ownerFence = wallet.captureOwnerFence();
const commitContext = { ownerFence, warn: warnCommitted }; 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<typeof commitCarve>[1]): Promise<void> => {
carveState.committed = await commitCarve(wallet, carve, commitContext);
};
const paid = await payWithBearers( const paid = await payWithBearers(
wallet.bearers, wallet.bearers,
payment.input, payment.input,
payment.kind === 'address' payment.kind === 'address'
? { amountMsat: payment.amountMsat, assertOwner: ownerFence } ? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve }
: { assertOwner: ownerFence }, : { assertOwner: ownerFence, onCarve },
); );
stage.value = 'Confirming the result…'; 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) { if (paid.rescuedNote) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext); await addCommittedBearers(wallet, [paid.rescuedNote], commitContext);
} }
@@ -166,6 +178,13 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('positive', `Paid ${sats} sats.`); toast('positive', `Paid ${sats} sats.`);
emit('sent'); emit('sent');
} else if (paid.outcome === 'failed-funds-returned') { } 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( await activity.log(
'transfer', 'transfer',
`A ${sats} sat payment failed - funds are back in your wallet.`, `A ${sats} sat payment failed - funds are back in your wallet.`,
+18 -2
View File
@@ -82,10 +82,26 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
let ownerFence: WalletOwnerFence | undefined; let ownerFence: WalletOwnerFence | undefined;
try { try {
ownerFence = wallet.captureOwnerFence(); 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), { 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) { if (carve.change) {
await activity.log( await activity.log(
'split', 'split',