feat(composables): route fund dialogs through the durable seam

This commit is contained in:
2026-09-05 11:44:29 +02:00
parent 04e62110b6
commit 8e9c9dcbbc
5 changed files with 162 additions and 64 deletions
@@ -113,6 +113,7 @@
{{ claimError }} {{ claimError }}
</q-banner> </q-banner>
<q-btn <q-btn
v-if="claimRetryable"
unelevated unelevated
color="primary" color="primary"
text-color="dark" text-color="dark"
@@ -215,6 +216,7 @@ const {
CUSTOM_MINT, CUSTOM_MINT,
amountSats, amountSats,
claimError, claimError,
claimRetryable,
copyInvoice, copyInvoice,
createInvoice, createInvoice,
customMint, customMint,
+47 -25
View File
@@ -1,3 +1,4 @@
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
@@ -7,9 +8,10 @@ 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 { Bearer, NewBearer } from '@/lnurlcash/types'; import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } 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 { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
import { useMintsStore } from '@/stores/mints'; import { useMintsStore } from '@/stores/mints';
import { useActivityStore } from '@/stores/activity'; import { useActivityStore } from '@/stores/activity';
import { addCommittedBearers, commitCarve } from './walletCarveCommit'; import { addCommittedBearers, commitCarve } from './walletCarveCommit';
@@ -132,28 +134,54 @@ export const useMoveFundsPage = () => {
if (!sats) return; if (!sats) return;
step.value = 'working'; step.value = 'working';
stage.value = 'Asking the target mint for an invoice…'; stage.value = 'Asking the target mint for an invoice…';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
const ownerFence = wallet.captureOwnerFence(); fundOperation = wallet.beginFundOperation();
const ownerFence = fundOperation.ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const commitContext = { ownerFence, warn: warnCommitted }; const commitContext = { ownerFence, warn: warnCommitted };
let stagedOutput: Bearer | undefined;
let carvedOutput: Bearer | undefined;
// the carve commits the moment it lands server-side (onCarve), not // the carve commits the moment it lands server-side (onCarve), not
// after the melt + settlement wait - an abort mid-wait can then // after the melt + settlement wait - an abort mid-wait can then
// never leave burned inputs looking spendable or strand the outputs // never leave burned inputs looking spendable or strand the outputs
const carveState: { committed?: Bearer } = {}; const carveState: { committed?: Bearer } = {};
const transfer = await transferBetweenMints( const transfer = await transferBetweenMints(
wallet.bearers, wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
satsToMsat(sats), satsToMsat(sats),
targetInput.value, targetInput.value,
{ {
assertOwner: ownerFence, assertOwner: ownerFence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, ownerFence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
persistOutput: async (note) => {
[stagedOutput] = await addCommittedBearers(wallet, [note], commitContext);
},
onCarve: async (carve) => { onCarve: async (carve) => {
carveState.committed = await commitCarve(wallet, carve, commitContext); carveState.committed = await commitCarve(wallet, carve, commitContext);
}, },
onMeltReady: async (carve, sourceRecoverySecret) => {
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
carvedOutput =
carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!carvedOutput) throw new Error('The carved note was not tracked.');
await wallet.reserveStagedMintSource(
stagedOutput.id,
carvedOutput.id,
sourceRecoverySecret,
ownerFence,
);
},
}, },
); );
if (!stagedOutput) throw new Error('The pending target note was not persisted.');
stage.value = 'Confirming the result…'; stage.value = 'Confirming the result…';
// a carve that mutated nothing fires no hook - the note is already // a carve that mutated nothing fires no hook - the note is already
// tracked; look it up // tracked; look it up
const carved = const carved =
carvedOutput ??
carveState.committed ?? carveState.committed ??
wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url); wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url);
if (!carved) throw new Error('The carved note was not tracked.'); if (!carved) throw new Error('The carved note was not tracked.');
@@ -162,13 +190,14 @@ export const useMoveFundsPage = () => {
} }
const feeSats = msatToSats(transfer.quote.targetMintFeeMsat); const feeSats = msatToSats(transfer.quote.targetMintFeeMsat);
if (transfer.outcome === 'settled') { if (transfer.outcome === 'settled') {
await wallet.markSpent(carved.id, ownerFence);
const claimed = transfer.mintedAtTarget; const claimed = transfer.mintedAtTarget;
if (claimed) { if (claimed) {
const notes: NewBearer[] = claimed.possibleCopy try {
? [claimed.note, claimed.possibleCopy] await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence);
: [claimed.note]; } catch (error) {
await addCommittedBearers(wallet, notes, commitContext); if (!(error instanceof TrustedMintPostCommitError)) throw error;
warnCommitted(error.message);
}
} }
await activity.log( await activity.log(
'transfer', 'transfer',
@@ -178,11 +207,11 @@ 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) { if (transfer.rotatedNote) {
// the source-probe rotate re-secured the returned funds at a await wallet.restoreStagedMintSource(
// fresh secret, burning the carved note the checkpoint already stagedOutput.id,
// committed - add the new note BEFORE marking the old one spent transfer.rotatedNote,
await addCommittedBearers(wallet, [transfer.rotatedNote], commitContext); ownerFence,
await wallet.markSpent(carved.id, ownerFence); );
} }
await activity.log( await activity.log(
'transfer', 'transfer',
@@ -191,22 +220,13 @@ export const useMoveFundsPage = () => {
); );
} else if (transfer.outcome === 'unknown-still-pending') { } else if (transfer.outcome === 'unknown-still-pending') {
await wallet.markSpent(carved.id, ownerFence); await wallet.markSpent(carved.id, ownerFence);
// a named target's claim material carries the note at the wallet's
// own secret - claimable the moment the melt settles, so it is
// tracked unverified rather than lost with the result object
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`, `A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`,
(error) => warnCommitted(error.message), (error) => warnCommitted(error.message),
); );
} else if (transfer.outcome === 'settled-claim-failed') { } else if (transfer.outcome === 'settled-claim-failed') {
await wallet.markSpent(carved.id, ownerFence); await wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence);
if (transfer.claimMaterial?.note) {
await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext);
}
await activity.log( await activity.log(
'transfer', 'transfer',
`${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`, `${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`,
@@ -220,7 +240,7 @@ export const useMoveFundsPage = () => {
(error) => warnCommitted(error.message), (error) => warnCommitted(error.message),
); );
} }
const claimNote = transfer.claimMaterial?.note ?? null; const claimNote = transfer.claimMaterial?.note ?? stagedOutput;
result.value = { result.value = {
outcome: transfer.outcome, outcome: transfer.outcome,
requestedSats: sats, requestedSats: sats,
@@ -237,6 +257,8 @@ export const useMoveFundsPage = () => {
: message; : message;
toast('negative', inlineError.value); toast('negative', inlineError.value);
step.value = 'form'; step.value = 'form';
} finally {
fundOperation?.complete();
} }
}; };
return { return {
+60 -18
View File
@@ -1,10 +1,16 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit'; import {
decodeBolt11AmountMsat,
isBolt11Invoice,
resolveLnurlInput,
withNewK1,
} from 'lnurlcash-kit';
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 { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types'; 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';
@@ -143,31 +149,72 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
step.value = 'working'; step.value = 'working';
stage.value = 'Preparing the exact amount and sending the payment…'; stage.value = 'Preparing the exact amount and sending the payment…';
let ownerFence: WalletOwnerFence | undefined; let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
ownerFence = wallet.captureOwnerFence(); fundOperation = wallet.beginFundOperation();
const commitContext = { ownerFence, warn: warnCommitted }; ownerFence = fundOperation.ownerFence;
const fence = ownerFence;
const ownerId = wallet.pubkey ?? undefined;
const commitContext = { ownerFence: fence, warn: warnCommitted };
// the carve commits the moment it lands server-side (onCarve), not // the carve commits the moment it lands server-side (onCarve), not
// after the settlement wait - an abort mid-wait can then never leave // after the settlement wait - an abort mid-wait can then never leave
// burned inputs looking spendable or strand the fresh outputs // burned inputs looking spendable or strand the fresh outputs
const carveState: { committed?: Bearer } = {}; const carveState: { committed?: Bearer } = {};
let returnStage: Bearer | undefined;
const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => { const onCarve = async (carve: Parameters<typeof commitCarve>[1]): Promise<void> => {
carveState.committed = await commitCarve(wallet, carve, commitContext); carveState.committed = await commitCarve(wallet, carve, commitContext);
}; };
const paid = await payWithBearers( const allocateOutputSecrets = (server: string, count: number) =>
wallet.bearers, wallet.allocateOutputSecrets(server, count, fence);
payment.input, const mintSignatureKeys = (server: string) =>
payment.kind === 'address' getTrustedMintVerificationKeys(server, ownerId);
? { amountMsat: payment.amountMsat, assertOwner: ownerFence, onCarve } // the pay-return recovery output: staged durably (allocated secret,
: { assertOwner: ownerFence, onCarve }, // zero value, pendingMint) and linked to the carved source BEFORE the
// classification rotate can burn it
const onReturnReady = async (
carve: Parameters<typeof commitCarve>[1],
recoverySecret: string,
): Promise<void> => {
const source =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === carve.note.url);
if (!source) throw new Error('The carved note was not tracked.');
[returnStage] = await addCommittedBearers(
wallet,
[
{
url: withNewK1(carve.note.url, recoverySecret, carve.note.amount),
callback: '',
amount: 0,
verified: false,
pendingMint: { retireAfter: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60 },
},
],
commitContext,
); );
if (!returnStage) throw new Error('The payment recovery output was not persisted.');
await wallet.reserveStagedMintSource(returnStage.id, source.id, recoverySecret, fence);
};
const paid = await payWithBearers(wallet.bearers, payment.input, {
...(payment.kind === 'address' ? { amountMsat: payment.amountMsat } : {}),
assertOwner: fence,
onCarve,
allocateOutputSecrets,
mintSignatureKeys,
onReturnReady,
});
stage.value = 'Confirming the result…'; stage.value = 'Confirming the result…';
// a carve that mutated nothing (one note already held the exact // a carve that mutated nothing (one note already held the exact
// amount) fires no hook - the note is already tracked; look it up // amount) fires no hook - the note is already tracked; look it up
const committed = const committed =
carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url); carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === paid.carve.note.url);
if (!committed) throw new Error('The carved note was not tracked.'); if (!committed) throw new Error('The carved note was not tracked.');
if (paid.rescuedNote) { if (returnStage) {
await addCommittedBearers(wallet, [paid.rescuedNote], commitContext); const returned = paid.rotatedNote ?? paid.rescuedNote;
if (returned) {
await wallet.finalizeStagedMintOutput(returnStage.id, returned, fence);
} else if (paid.outcome !== 'unknown-still-pending') {
await wallet.finalizeSpentStagedMintOutput(returnStage.id, fence);
}
} }
const sats = formatSats(msatToSats(paid.amountMsat)); const sats = formatSats(msatToSats(paid.amountMsat));
if (paid.outcome === 'settled') { if (paid.outcome === 'settled') {
@@ -178,13 +225,6 @@ 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.`,
@@ -227,6 +267,8 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit
toast('negative', inlineError.value); toast('negative', inlineError.value);
} }
step.value = 'input'; step.value = 'input';
} finally {
fundOperation?.complete();
} }
}; };
const closeResult = (): void => { const closeResult = (): void => {
+39 -18
View File
@@ -2,9 +2,9 @@ import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar'; import { Notify } from 'quasar';
import { writeClipboard } from '@/capabilities/clipboard'; import { writeClipboard } from '@/capabilities/clipboard';
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops'; import { MintedNoteSpentError, prepareMint, claimMintedNote } from '@/lnurlcash/ops';
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops'; import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
import type { NewBearer } from '@/lnurlcash/types'; import type { Bearer } from '@/lnurlcash/types';
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units'; import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints'; import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
@@ -35,8 +35,10 @@ export const useReceiveLightningDialog = (
const preparing = ref(false); const preparing = ref(false);
const formError = ref(''); const formError = ref('');
const prepared = ref<PreparedMint | null>(null); const prepared = ref<PreparedMint | null>(null);
const stagedOutput = ref<Bearer | null>(null);
const waiting = ref(false); const waiting = ref(false);
const claimError = ref(''); const claimError = ref('');
const claimRetryable = ref(true);
const receivedSats = ref(0); const receivedSats = ref(0);
const receivedServer = ref(''); const receivedServer = ref('');
const rotationWarning = ref(''); const rotationWarning = ref('');
@@ -85,23 +87,21 @@ export const useReceiveLightningDialog = (
const onClaimed = async ( const onClaimed = async (
claimed: ClaimedNote, claimed: ClaimedNote,
from: PreparedMint, from: PreparedMint,
staged: Bearer,
ownerFence: WalletOwnerFence, ownerFence: WalletOwnerFence,
): Promise<void> => { ): Promise<void> => {
const server = from.server; const server = from.server;
const wasTrusted = mints.isTrusted(server); const wasTrusted = mints.isTrusted(server);
const notes: NewBearer[] = claimed.possibleCopy
? [claimed.note, claimed.possibleCopy]
: [claimed.note];
let trustWarning = ''; let trustWarning = '';
try { try {
await wallet.addBearers(notes, ownerFence); await wallet.finalizeStagedMintOutput(staged.id, claimed.note, ownerFence);
} catch (error) { } catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error; if (!(error instanceof TrustedMintPostCommitError)) throw error;
trustWarning = error.message; trustWarning = error.message;
} }
receivedSats.value = displaySats(claimed.note.amount); receivedSats.value = displaySats(claimed.note.amount);
receivedServer.value = server; receivedServer.value = server;
rotationWarning.value = claimed.rotationError ?? ''; rotationWarning.value = '';
await activity.log( await activity.log(
'mint', 'mint',
`Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`, `Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`,
@@ -130,23 +130,35 @@ export const useReceiveLightningDialog = (
} }
}; };
const beginClaim = (): void => { const beginClaim = (): void => {
if (!prepared.value || claimRun) return; if (!prepared.value || !stagedOutput.value || claimRun) return;
waiting.value = true; waiting.value = true;
claimError.value = ''; claimError.value = '';
claimRetryable.value = true;
const current = prepared.value; const current = prepared.value;
const staged = stagedOutput.value;
claimRun = (async () => { claimRun = (async () => {
const fundOperation = wallet.beginFundOperation();
try { try {
const ownerFence = wallet.captureOwnerFence(); const ownerFence = fundOperation.ownerFence;
await onClaimed( await onClaimed(
await claimMintedNote(current, {}, { assertOwner: ownerFence }), await claimMintedNote(current, {}, { assertOwner: ownerFence }),
current, current,
staged,
ownerFence, ownerFence,
); );
} catch (error) { } catch (error) {
if (!(error instanceof Error)) throw error; if (!(error instanceof Error)) throw error;
if (error instanceof MintedNoteSpentError) {
await wallet.finalizeSpentStagedMintOutput(staged.id, fundOperation.ownerFence);
claimRetryable.value = false;
claimError.value = errorMessage(error);
Notify.create({ type: 'negative', message: claimError.value });
return;
}
claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`; claimError.value = `${errorMessage(error)} The invoice stays valid — you can try again.`;
Notify.create({ type: 'negative', message: errorMessage(error) }); Notify.create({ type: 'negative', message: errorMessage(error) });
} finally { } finally {
fundOperation.complete();
waiting.value = false; waiting.value = false;
} }
})(); })();
@@ -156,20 +168,26 @@ export const useReceiveLightningDialog = (
if (!sats || preparing.value) return; if (!sats || preparing.value) return;
preparing.value = true; preparing.value = true;
formError.value = ''; formError.value = '';
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value; const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
const next = await prepareMint(input, satsToMsat(sats)); fundOperation = wallet.beginFundOperation();
// named mints claim through the wallet's own secret and may const ownerFence = fundOperation.ownerFence;
// legitimately serve no verify URL; unnamed mints need one (the let persisted: Bearer | undefined;
// preimage it reveals IS the claim) const next = await prepareMint(input, satsToMsat(sats), {
if (next.mode === 'unnamed' && !next.verifyUrl) { assertOwner: ownerFence,
formError.value = allocateOutputSecrets: (server, count) =>
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.'; wallet.allocateOutputSecrets(server, count, ownerFence),
return; persistOutput: async (note) => {
} [persisted] = await wallet.addBearers([note], ownerFence);
},
});
if (!persisted) throw new Error('The pending mint output was not persisted.');
stagedOutput.value = persisted;
prepared.value = next; prepared.value = next;
claimRun = null; claimRun = null;
claimError.value = ''; claimError.value = '';
claimRetryable.value = true;
step.value = 'invoice'; step.value = 'invoice';
beginClaim(); beginClaim();
} catch (error) { } catch (error) {
@@ -177,6 +195,7 @@ export const useReceiveLightningDialog = (
formError.value = errorMessage(error); formError.value = errorMessage(error);
Notify.create({ type: 'negative', message: formError.value }); Notify.create({ type: 'negative', message: formError.value });
} finally { } finally {
fundOperation?.complete();
preparing.value = false; preparing.value = false;
} }
}; };
@@ -211,6 +230,7 @@ export const useReceiveLightningDialog = (
preparing.value = false; preparing.value = false;
formError.value = ''; formError.value = '';
prepared.value = null; prepared.value = null;
stagedOutput.value = null;
waiting.value = false; waiting.value = false;
rotationWarning.value = ''; rotationWarning.value = '';
}, },
@@ -219,6 +239,7 @@ export const useReceiveLightningDialog = (
CUSTOM_MINT, CUSTOM_MINT,
amountSats, amountSats,
claimError, claimError,
claimRetryable,
copyInvoice, copyInvoice,
createInvoice, createInvoice,
customMint, customMint,
+13 -2
View File
@@ -5,6 +5,7 @@ import { toBech32Lnurl } from 'lnurlcash-kit';
import { writeClipboard } from '@/capabilities/clipboard'; import { writeClipboard } from '@/capabilities/clipboard';
import { canShareText, shareText } from '@/capabilities/share'; import { canShareText, shareText } from '@/capabilities/share';
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops'; import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
import type { Bearer } from '@/lnurlcash/types'; 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';
@@ -80,15 +81,21 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
preparing.value = true; preparing.value = true;
errorMessage.value = null; errorMessage.value = null;
let ownerFence: WalletOwnerFence | undefined; let ownerFence: WalletOwnerFence | undefined;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
ownerFence = wallet.captureOwnerFence(); fundOperation = wallet.beginFundOperation();
ownerFence = fundOperation.ownerFence;
const fence = ownerFence; const fence = ownerFence;
const ownerId = wallet.pubkey ?? undefined;
// the carve commits the moment it lands server-side (onCarve), so // the carve commits the moment it lands server-side (onCarve), so
// even a crash right after can never strand the fresh outputs or // even a crash right after can never strand the fresh outputs or
// leave burned inputs looking spendable // leave burned inputs looking spendable
const carveState: { committed?: Bearer } = {}; const carveState: { committed?: Bearer } = {};
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), { const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats), {
assertOwner: fence, assertOwner: fence,
allocateOutputSecrets: (server, count) =>
wallet.allocateOutputSecrets(server, count, fence),
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
onCarve: async (landed) => { onCarve: async (landed) => {
carveState.committed = await commitCarve(wallet, landed, { carveState.committed = await commitCarve(wallet, landed, {
ownerFence: fence, ownerFence: fence,
@@ -141,6 +148,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
: message; : message;
toast('negative', errorMessage.value); toast('negative', errorMessage.value);
} finally { } finally {
fundOperation?.complete();
preparing.value = false; preparing.value = false;
} }
}; };
@@ -165,8 +173,10 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
const note = prepared.value; const note = prepared.value;
if (!note) return; if (!note) return;
removing.value = true; removing.value = true;
let fundOperation: ReturnType<typeof wallet.beginFundOperation> | undefined;
try { try {
await wallet.markSpent(note.id, wallet.captureOwnerFence()); fundOperation = wallet.beginFundOperation();
await wallet.markSpent(note.id, fundOperation.ownerFence);
await activity.log( await activity.log(
'spent', 'spent',
`Handed over a ${formatSats(msatToSats(note.amount))} sat note.`, `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`,
@@ -178,6 +188,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) =
} catch (error) { } catch (error) {
toast('negative', error instanceof Error ? error.message : 'Something went wrong.'); toast('negative', error instanceof Error ? error.message : 'Something went wrong.');
} finally { } finally {
fundOperation?.complete();
removing.value = false; removing.value = false;
} }
}; };