From 8e9c9dcbbc3cffa5db0ada5e7c486518ee9a1071 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:44:29 +0200 Subject: [PATCH] feat(composables): route fund dialogs through the durable seam --- .../receive/ReceiveLightningDialog.vue | 2 + src/composables/useMoveFundsPage.ts | 72 +++++++++++------ src/composables/usePayInvoiceDialog.ts | 80 ++++++++++++++----- src/composables/useReceiveLightningDialog.ts | 57 ++++++++----- src/composables/useSendTokenDialog.ts | 15 +++- 5 files changed, 162 insertions(+), 64 deletions(-) diff --git a/src/components/receive/ReceiveLightningDialog.vue b/src/components/receive/ReceiveLightningDialog.vue index 4851300..5f7208c 100644 --- a/src/components/receive/ReceiveLightningDialog.vue +++ b/src/components/receive/ReceiveLightningDialog.vue @@ -113,6 +113,7 @@ {{ claimError }} { if (!sats) return; step.value = 'working'; stage.value = 'Asking the target mint for an invoice…'; + let fundOperation: ReturnType | undefined; try { - const ownerFence = wallet.captureOwnerFence(); + fundOperation = wallet.beginFundOperation(); + const ownerFence = fundOperation.ownerFence; + const ownerId = wallet.pubkey ?? undefined; const commitContext = { ownerFence, warn: warnCommitted }; + let stagedOutput: Bearer | undefined; + let carvedOutput: Bearer | undefined; // 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, + wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value), satsToMsat(sats), targetInput.value, { 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) => { 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…'; // a carve that mutated nothing fires no hook - the note is already // tracked; look it up const carved = + carvedOutput ?? carveState.committed ?? wallet.bearers.find((bearer) => bearer.url === transfer.carve.note.url); if (!carved) throw new Error('The carved note was not tracked.'); @@ -162,13 +190,14 @@ export const useMoveFundsPage = () => { } const feeSats = msatToSats(transfer.quote.targetMintFeeMsat); if (transfer.outcome === 'settled') { - await wallet.markSpent(carved.id, ownerFence); const claimed = transfer.mintedAtTarget; if (claimed) { - const notes: NewBearer[] = claimed.possibleCopy - ? [claimed.note, claimed.possibleCopy] - : [claimed.note]; - await addCommittedBearers(wallet, notes, commitContext); + try { + await wallet.finalizeStagedMintOutput(stagedOutput.id, claimed.note, ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + warnCommitted(error.message); + } } await activity.log( 'transfer', @@ -178,11 +207,11 @@ 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 wallet.restoreStagedMintSource( + stagedOutput.id, + transfer.rotatedNote, + ownerFence, + ); } await activity.log( 'transfer', @@ -191,22 +220,13 @@ export const useMoveFundsPage = () => { ); } else if (transfer.outcome === 'unknown-still-pending') { 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( 'transfer', `A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`, (error) => warnCommitted(error.message), ); } else if (transfer.outcome === 'settled-claim-failed') { - await wallet.markSpent(carved.id, ownerFence); - if (transfer.claimMaterial?.note) { - await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext); - } + await wallet.finalizeSpentStagedMintOutput(stagedOutput.id, ownerFence); await activity.log( 'transfer', `${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), ); } - const claimNote = transfer.claimMaterial?.note ?? null; + const claimNote = transfer.claimMaterial?.note ?? stagedOutput; result.value = { outcome: transfer.outcome, requestedSats: sats, @@ -237,6 +257,8 @@ export const useMoveFundsPage = () => { : message; toast('negative', inlineError.value); step.value = 'form'; + } finally { + fundOperation?.complete(); } }; return { diff --git a/src/composables/usePayInvoiceDialog.ts b/src/composables/usePayInvoiceDialog.ts index 7f770e8..d0f1aa9 100644 --- a/src/composables/usePayInvoiceDialog.ts +++ b/src/composables/usePayInvoiceDialog.ts @@ -1,10 +1,16 @@ import { computed, ref, watch } from 'vue'; 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 { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops'; import type { PayOutcome } from '@/lnurlcash/ops'; +import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints'; import type { Bearer } from '@/lnurlcash/types'; import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { useWalletStore } from '@/stores/wallet'; @@ -143,31 +149,72 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit step.value = 'working'; stage.value = 'Preparing the exact amount and sending the payment…'; let ownerFence: WalletOwnerFence | undefined; + let fundOperation: ReturnType | undefined; try { - ownerFence = wallet.captureOwnerFence(); - const commitContext = { ownerFence, warn: warnCommitted }; + fundOperation = wallet.beginFundOperation(); + 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 // 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 } = {}; + let returnStage: Bearer | undefined; 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, onCarve } - : { assertOwner: ownerFence, onCarve }, - ); + const allocateOutputSecrets = (server: string, count: number) => + wallet.allocateOutputSecrets(server, count, fence); + const mintSignatureKeys = (server: string) => + getTrustedMintVerificationKeys(server, ownerId); + // the pay-return recovery output: staged durably (allocated secret, + // zero value, pendingMint) and linked to the carved source BEFORE the + // classification rotate can burn it + const onReturnReady = async ( + carve: Parameters[1], + recoverySecret: string, + ): Promise => { + 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…'; // 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); + if (returnStage) { + 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)); if (paid.outcome === 'settled') { @@ -178,13 +225,6 @@ 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.`, @@ -227,6 +267,8 @@ export const usePayInvoiceDialog = (props: PayInvoiceProps, emit: PayInvoiceEmit toast('negative', inlineError.value); } step.value = 'input'; + } finally { + fundOperation?.complete(); } }; const closeResult = (): void => { diff --git a/src/composables/useReceiveLightningDialog.ts b/src/composables/useReceiveLightningDialog.ts index 619efce..eeed7e0 100644 --- a/src/composables/useReceiveLightningDialog.ts +++ b/src/composables/useReceiveLightningDialog.ts @@ -2,9 +2,9 @@ import { computed, ref, watch } from 'vue'; import { Notify } from 'quasar'; 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 { NewBearer } from '@/lnurlcash/types'; +import type { Bearer } from '@/lnurlcash/types'; import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units'; import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints'; import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet'; @@ -35,8 +35,10 @@ export const useReceiveLightningDialog = ( const preparing = ref(false); const formError = ref(''); const prepared = ref(null); + const stagedOutput = ref(null); const waiting = ref(false); const claimError = ref(''); + const claimRetryable = ref(true); const receivedSats = ref(0); const receivedServer = ref(''); const rotationWarning = ref(''); @@ -85,23 +87,21 @@ export const useReceiveLightningDialog = ( const onClaimed = async ( claimed: ClaimedNote, from: PreparedMint, + staged: Bearer, ownerFence: WalletOwnerFence, ): Promise => { const server = from.server; const wasTrusted = mints.isTrusted(server); - const notes: NewBearer[] = claimed.possibleCopy - ? [claimed.note, claimed.possibleCopy] - : [claimed.note]; let trustWarning = ''; try { - await wallet.addBearers(notes, ownerFence); + await wallet.finalizeStagedMintOutput(staged.id, claimed.note, ownerFence); } catch (error) { if (!(error instanceof TrustedMintPostCommitError)) throw error; trustWarning = error.message; } receivedSats.value = displaySats(claimed.note.amount); receivedServer.value = server; - rotationWarning.value = claimed.rotationError ?? ''; + rotationWarning.value = ''; await activity.log( 'mint', `Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`, @@ -130,23 +130,35 @@ export const useReceiveLightningDialog = ( } }; const beginClaim = (): void => { - if (!prepared.value || claimRun) return; + if (!prepared.value || !stagedOutput.value || claimRun) return; waiting.value = true; claimError.value = ''; + claimRetryable.value = true; const current = prepared.value; + const staged = stagedOutput.value; claimRun = (async () => { + const fundOperation = wallet.beginFundOperation(); try { - const ownerFence = wallet.captureOwnerFence(); + const ownerFence = fundOperation.ownerFence; await onClaimed( await claimMintedNote(current, {}, { assertOwner: ownerFence }), current, + staged, ownerFence, ); } catch (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.`; Notify.create({ type: 'negative', message: errorMessage(error) }); } finally { + fundOperation.complete(); waiting.value = false; } })(); @@ -156,20 +168,26 @@ export const useReceiveLightningDialog = ( if (!sats || preparing.value) return; preparing.value = true; formError.value = ''; + let fundOperation: ReturnType | undefined; try { const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value; - const next = await prepareMint(input, satsToMsat(sats)); - // named mints claim through the wallet's own secret and may - // legitimately serve no verify URL; unnamed mints need one (the - // preimage it reveals IS the claim) - if (next.mode === 'unnamed' && !next.verifyUrl) { - formError.value = - 'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.'; - return; - } + fundOperation = wallet.beginFundOperation(); + const ownerFence = fundOperation.ownerFence; + let persisted: Bearer | undefined; + const next = await prepareMint(input, satsToMsat(sats), { + assertOwner: ownerFence, + allocateOutputSecrets: (server, count) => + wallet.allocateOutputSecrets(server, count, ownerFence), + 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; claimRun = null; claimError.value = ''; + claimRetryable.value = true; step.value = 'invoice'; beginClaim(); } catch (error) { @@ -177,6 +195,7 @@ export const useReceiveLightningDialog = ( formError.value = errorMessage(error); Notify.create({ type: 'negative', message: formError.value }); } finally { + fundOperation?.complete(); preparing.value = false; } }; @@ -211,6 +230,7 @@ export const useReceiveLightningDialog = ( preparing.value = false; formError.value = ''; prepared.value = null; + stagedOutput.value = null; waiting.value = false; rotationWarning.value = ''; }, @@ -219,6 +239,7 @@ export const useReceiveLightningDialog = ( CUSTOM_MINT, amountSats, claimError, + claimRetryable, copyInvoice, createInvoice, customMint, diff --git a/src/composables/useSendTokenDialog.ts b/src/composables/useSendTokenDialog.ts index 684bcb5..a28c7ff 100644 --- a/src/composables/useSendTokenDialog.ts +++ b/src/composables/useSendTokenDialog.ts @@ -5,6 +5,7 @@ import { toBech32Lnurl } from 'lnurlcash-kit'; import { writeClipboard } from '@/capabilities/clipboard'; import { canShareText, shareText } from '@/capabilities/share'; import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops'; +import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints'; import type { Bearer } from '@/lnurlcash/types'; import { msatToSats, satsToMsat } from '@/lnurlcash/units'; import { useWalletStore } from '@/stores/wallet'; @@ -80,15 +81,21 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = preparing.value = true; errorMessage.value = null; let ownerFence: WalletOwnerFence | undefined; + let fundOperation: ReturnType | undefined; try { - ownerFence = wallet.captureOwnerFence(); + fundOperation = wallet.beginFundOperation(); + ownerFence = fundOperation.ownerFence; const fence = ownerFence; + const ownerId = wallet.pubkey ?? undefined; // 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: fence, + allocateOutputSecrets: (server, count) => + wallet.allocateOutputSecrets(server, count, fence), + mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId), onCarve: async (landed) => { carveState.committed = await commitCarve(wallet, landed, { ownerFence: fence, @@ -141,6 +148,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = : message; toast('negative', errorMessage.value); } finally { + fundOperation?.complete(); preparing.value = false; } }; @@ -165,8 +173,10 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = const note = prepared.value; if (!note) return; removing.value = true; + let fundOperation: ReturnType | undefined; try { - await wallet.markSpent(note.id, wallet.captureOwnerFence()); + fundOperation = wallet.beginFundOperation(); + await wallet.markSpent(note.id, fundOperation.ownerFence); await activity.log( 'spent', `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`, @@ -178,6 +188,7 @@ export const useSendTokenDialog = (props: SendTokenProps, emit: SendTokenEmit) = } catch (error) { toast('negative', error instanceof Error ? error.message : 'Something went wrong.'); } finally { + fundOperation?.complete(); removing.value = false; } };