diff --git a/src/composables/useMoveFundsPage.ts b/src/composables/useMoveFundsPage.ts new file mode 100644 index 0000000..c8f0e47 --- /dev/null +++ b/src/composables/useMoveFundsPage.ts @@ -0,0 +1,235 @@ +import { computed, ref, watch } from 'vue'; +import { useRouter } from 'vue-router'; +import { useQuasar } from 'quasar'; +import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit'; +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 { floorMsatToSat, msatToSats, satsToMsat, MSAT_PER_SAT } from '@/lnurlcash/units'; +import { useWalletStore } from '@/stores/wallet'; +import { useMintsStore } from '@/stores/mints'; +import { useActivityStore } from '@/stores/activity'; +import { addCommittedBearers, commitCarve } from './walletCarveCommit'; + +type Option = Readonly<{ label: string; value: string }>; +type TransferResult = Readonly<{ + outcome: TransferOutcome; + requestedSats: number; + feeSats: number; + sourceServer: string; + targetServer: string; + claimNoteValueSats?: number; +}>; + +export const useMoveFundsPage = () => { + const router = useRouter(); + const $q = useQuasar(); + const wallet = useWalletStore(); + const mints = useMintsStore(); + const activity = useActivityStore(); + const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => { + if (typeof $q.notify === 'function') { + $q.notify({ type, message, position: 'top', timeout: 3000 }); + } + }; + const warnCommitted = (message: string): void => toast('warning', message); + watch( + () => wallet.state, + (state) => { + if (state !== 'unlocked') void router.replace('/'); + }, + { immediate: true }, + ); + const CUSTOM_TARGET = '__custom__'; + const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT; + const spendableByServerMsat = computed(() => { + const byServer = new Map(); + for (const bearer of wallet.bearers) { + if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url)) + continue; + const server = serverOf(bearer.url); + byServer.set(server, (byServer.get(server) ?? 0) + bearer.amount); + } + return byServer; + }); + const sourceOptions = computed(() => + [...spendableByServerMsat.value.entries()].map(([server, msat]) => ({ + label: `${server} - ${displaySats(msat).toLocaleString()} sats available`, + value: server, + })), + ); + const step = ref<'form' | 'confirm' | 'working' | 'result'>('form'); + const sourceServer = ref(''); + const targetChoice = ref(''); + const customTarget = ref(''); + const amountSats = ref(null); + const inlineError = ref(''); + const stage = ref(''); + const result = ref(null); + const targetOptions = computed(() => { + const options: Option[] = []; + for (const mint of mints.mints) { + if (mint.server === sourceServer.value) continue; + const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`; + options.push({ + label: mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address, + value: address, + }); + } + options.push({ label: 'Another mint…', value: CUSTOM_TARGET }); + return options; + }); + const targetInput = computed(() => + targetChoice.value === CUSTOM_TARGET ? customTarget.value.trim() : targetChoice.value, + ); + const formFilled = computed( + () => + sourceServer.value !== '' && + targetInput.value !== '' && + Number.isInteger(amountSats.value) && + (amountSats.value ?? 0) >= 1, + ); + const targetFee = ref(null); + let quoteTimer: ReturnType | null = null; + watch(targetInput, (input) => { + targetFee.value = null; + if (quoteTimer) clearTimeout(quoteTimer); + if (input === '') return; + quoteTimer = setTimeout(() => { + void quoteMintFee(input).then((fee) => { + if (targetInput.value === input) targetFee.value = fee; + }); + }, 400); + }); + const targetFeeText = computed(() => + targetFee.value + ? `This mint charges a receive fee (${describeMintFee(targetFee.value)}) - Max already accounts for it.` + : '', + ); + const setMax = (): void => { + const msat = spendableByServerMsat.value.get(sourceServer.value) ?? 0; + amountSats.value = displaySats(maxNetForBalance(msat, targetFee.value)); + }; + const proceed = (): void => { + inlineError.value = ''; + const sats = amountSats.value; + if (!sats || !Number.isInteger(sats) || sats < 1) { + inlineError.value = 'Enter how many sats to move.'; + return; + } + const sourceMsat = spendableByServerMsat.value.get(sourceServer.value) ?? 0; + if (satsToMsat(sats) > sourceMsat) { + inlineError.value = `That's more than the ${displaySats(sourceMsat).toLocaleString()} sats spendable at ${sourceServer.value}.`; + return; + } + step.value = 'confirm'; + }; + const move = async (): Promise => { + const sats = amountSats.value; + if (!sats) return; + step.value = 'working'; + stage.value = 'Asking the target mint for an invoice…'; + try { + const ownerFence = wallet.captureOwnerFence(); + const commitContext = { ownerFence, warn: warnCommitted }; + const transfer = await transferBetweenMints( + wallet.bearers, + satsToMsat(sats), + targetInput.value, + { assertOwner: ownerFence }, + ); + stage.value = 'Confirming the result…'; + const carved = await commitCarve(wallet, transfer.carve, commitContext); + if (transfer.rescuedNote) { + await addCommittedBearers(wallet, [transfer.rescuedNote], commitContext); + } + 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); + } + await activity.log( + 'transfer', + `Moved ${sats.toLocaleString()} sats from ${transfer.sourceServer} to ${transfer.targetServer}.`, + (error) => warnCommitted(error.message), + ); + toast('positive', `Moved ${sats.toLocaleString()} sats.`); + } else if (transfer.outcome === 'failed-funds-returned') { + await activity.log( + 'transfer', + `A ${sats.toLocaleString()} sat move to ${transfer.targetServer} failed - funds are back in your wallet.`, + (error) => warnCommitted(error.message), + ); + } else if (transfer.outcome === 'unknown-still-pending') { + await wallet.markSpent(carved.id, ownerFence); + 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 activity.log( + 'transfer', + `${sats.toLocaleString()} sats arrived at ${transfer.targetServer} but claiming the note failed - it is saved unverified.`, + (error) => warnCommitted(error.message), + ); + } else { + await wallet.markSpent(carved.id, ownerFence); + await activity.log( + 'spent', + `A ${sats.toLocaleString()} sat note was already spent at ${transfer.sourceServer}.`, + (error) => warnCommitted(error.message), + ); + } + const claimNote = transfer.claimMaterial?.note ?? null; + result.value = { + outcome: transfer.outcome, + requestedSats: sats, + feeSats, + sourceServer: transfer.sourceServer, + targetServer: transfer.targetServer, + ...(claimNote ? { claimNoteValueSats: displaySats(claimNote.amount) } : {}), + }; + step.value = 'result'; + } catch (error) { + const message = error instanceof Error ? error.message : 'Something went wrong.'; + inlineError.value = message.startsWith('No mint holds enough') + ? 'Not enough spendable balance at the source mint to cover that move.' + : message; + toast('negative', inlineError.value); + step.value = 'form'; + } + }; + return { + CUSTOM_TARGET, + amountSats, + customTarget, + formFilled, + inlineError, + move, + proceed, + result, + router, + setMax, + sourceOptions, + sourceServer, + stage, + step, + targetChoice, + targetFeeText, + targetInput, + targetOptions, + }; +}; diff --git a/src/composables/walletCarveCommit.test.ts b/src/composables/walletCarveCommit.test.ts new file mode 100644 index 0000000..eef9a71 --- /dev/null +++ b/src/composables/walletCarveCommit.test.ts @@ -0,0 +1,130 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import type { CarveResult } from '@/lnurlcash/ops'; +import { loadBearers } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { commitCarve } from './walletCarveCommit'; +import { useWalletStore } from '../stores/wallet'; + +const note = (secret: string): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, +}); + +const failOnBearerWrite = (occurrence: number): void => { + const setItem = localStorage.setItem.bind(localStorage); + let writes = 0; + vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => { + if (key === 'sattle_bearers') { + writes += 1; + if (writes === occurrence) throw new Error('bearer storage unavailable'); + } + setItem(key, value); + }); +}; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('commitCarve', () => { + it('commits the carve additions and spent marks in one bearer write', async () => { + // Given a wallet holding the carve's input note + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [input] = await wallet.addBearers([note('aa')], ownerFence); + if (!input) throw new Error('Expected the input bearer.'); + const carve: CarveResult = { + note: note('bb'), + change: note('cc'), + consumed: [input], + }; + const writes = vi.spyOn(localStorage, 'setItem'); + + // When the carve is committed + const committed = await commitCarve(wallet, carve, { + ownerFence, + warn: () => undefined, + }); + + // Then the whole rotation landed as ONE durable write + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(committed.url).toBe(carve.note.url); + expect(wallet.bearers).toHaveLength(3); + expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(3); + expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + }); + + it('survives a failure that would have hit the old split commit second write', async () => { + // Given a wallet holding the carve's input note, with the second + // sattle_bearers write poisoned (the old add-then-markSpent split wrote + // twice; the single-write commit never reaches a second write) + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [input] = await wallet.addBearers([note('aa')], ownerFence); + if (!input) throw new Error('Expected the input bearer.'); + failOnBearerWrite(2); + const carve: CarveResult = { + note: note('bb'), + change: note('cc'), + consumed: [input], + }; + + // When the carve is committed + const committed = await commitCarve(wallet, carve, { + ownerFence, + warn: () => undefined, + }); + + // Then the rotation committed completely: additions tracked, input spent + expect(committed.url).toBe(carve.note.url); + expect(wallet.bearers).toHaveLength(3); + expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(3); + expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true); + }); + + it('leaves no partial carve behind when the commit write itself fails', async () => { + // Given bearer storage that fails the very next write + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [input] = await wallet.addBearers([note('aa')], ownerFence); + if (!input) throw new Error('Expected the input bearer.'); + failOnBearerWrite(1); + + // When the carve commit fails + await expect( + commitCarve( + wallet, + { note: note('bb'), change: note('cc'), consumed: [input] }, + { ownerFence, warn: () => undefined }, + ), + ).rejects.toThrow('bearer storage unavailable'); + + // Then nothing moved: not in storage, not in the reactive list + expect(wallet.bearers).toHaveLength(1); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.spent).toBeUndefined(); + }); +}); diff --git a/src/composables/walletCarveCommit.ts b/src/composables/walletCarveCommit.ts new file mode 100644 index 0000000..ed9b032 --- /dev/null +++ b/src/composables/walletCarveCommit.ts @@ -0,0 +1,67 @@ +import type { CarveResult } from '@/lnurlcash/ops'; +import type { BearerChangeset } from '@/lnurlcash/storage'; +import type { Bearer, NewBearer } from '@/lnurlcash/types'; +import { TrustedMintPostCommitError } from '@/stores/wallet'; +import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; + +export type CarveWallet = { + readonly bearers: readonly Bearer[]; + readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise; + readonly applyChangeset: ( + changeset: BearerChangeset, + ownerFence: WalletOwnerFence, + ) => Promise; +}; + +type CarveCommitContext = Readonly<{ + ownerFence: WalletOwnerFence; + warn: (message: string) => void; +}>; + +export const addCommittedBearers = async ( + wallet: CarveWallet, + notes: NewBearer[], + context: CarveCommitContext, +): Promise => { + try { + return await wallet.addBearers(notes, context.ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + context.warn(error.message); + return error.committedBearers; + } +}; + +// A carve is ONE logical rotation: the fresh notes (target + change) and the +// spent marks of the burned inputs must land together or not at all - the +// mint already destroyed the inputs server-side, so a partial commit (added +// but not spent, or vice versa) would strand or double-show money. Hence a +// single changeset through the wallet's one-write boundary, never an +// add-then-markSpent sequence of separate writes. +export const commitCarve = async ( + wallet: CarveWallet, + carve: CarveResult, + context: CarveCommitContext, +): Promise => { + const existing = wallet.bearers.find((bearer) => bearer.url === carve.note.url); + const additions: NewBearer[] = []; + if (!existing) additions.push(carve.note); + if (carve.change) additions.push(carve.change); + let added: Bearer[]; + try { + added = await wallet.applyChangeset( + { + add: additions, + markSpent: carve.consumed.map((bearer) => bearer.id), + }, + context.ownerFence, + ); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + context.warn(error.message); + added = error.committedBearers; + } + const committed = existing ?? added[0]; + if (!committed) throw new Error('The carved note was not tracked.'); + return committed; +}; diff --git a/src/pages/MoveFundsPage.vue b/src/pages/MoveFundsPage.vue index 615c2e1..efbb062 100644 --- a/src/pages/MoveFundsPage.vue +++ b/src/pages/MoveFundsPage.vue @@ -64,10 +64,7 @@ class="q-mb-md" /> -
+