diff --git a/src/stores/walletFunds.atomic.test.ts b/src/stores/walletFunds.atomic.test.ts new file mode 100644 index 0000000..0c989b2 --- /dev/null +++ b/src/stores/walletFunds.atomic.test.ts @@ -0,0 +1,111 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import { loadBearers } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { Bearer, NewBearer } from '@/lnurlcash/types'; +import { useWalletStore } from './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 rejectSecondEncryption = (): void => { + const encrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let encryptions = 0; + vi.spyOn(crypto.subtle, 'encrypt').mockImplementation((algorithm, key, data) => { + encryptions += 1; + return encryptions === 2 + ? Promise.reject(new Error('second encryption failed')) + : encrypt(algorithm, key, data); + }); +}; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('wallet multi-bearer durability', () => { + it('adds two bearers with one established changeset write', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const writes = vi.spyOn(storage, 'setItem'); + + await wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(2); + }); + + it('persists no partial addition when the second bearer encryption fails', async () => { + const wallet = useWalletStore(); + await wallet.create(); + rejectSecondEncryption(); + + await expect( + wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()), + ).rejects.toThrow('second encryption failed'); + + expect(wallet.bearers).toEqual([]); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toEqual([]); + }); + + it('persists no partial external merge when the second encryption fails', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const [existing] = await wallet.addBearers([note('a')], wallet.captureOwnerFence()); + if (!existing) throw new Error('Expected the initial bearer.'); + const incoming: Bearer[] = [ + { + id: 'incoming-b', + ...note('b'), + createdAt: Date.now() + 1, + updatedAt: Date.now() + 1, + }, + { + id: 'incoming-c', + ...note('c'), + createdAt: Date.now() + 2, + updatedAt: Date.now() + 2, + }, + ]; + rejectSecondEncryption(); + + await expect(wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence())).rejects.toThrow( + 'second encryption failed', + ); + + expect(wallet.bearers).toEqual([existing]); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toEqual([existing]); + }); + + it('merges multiple external bearers with one established changeset write', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + await wallet.addBearers([note('a')], wallet.captureOwnerFence()); + const now = Date.now(); + const incoming: Bearer[] = [ + { id: 'incoming-b', ...note('b'), createdAt: now + 1, updatedAt: now + 1 }, + { id: 'incoming-c', ...note('c'), createdAt: now + 2, updatedAt: now + 2 }, + ]; + const writes = vi.spyOn(storage, 'setItem'); + + await wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence()); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(3); + }); +}); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts new file mode 100644 index 0000000..32ac47b --- /dev/null +++ b/src/stores/walletFunds.ts @@ -0,0 +1,200 @@ +import { computed, ref } from 'vue'; +import { serverOf } from 'lnurlcash-kit'; + +import { + applyBearerChangeset, + deleteBearerRecord, + loadBearers, + mergeBearers, + persistBearer, +} from '@/lnurlcash/storage'; +import type { BearerChangeset } from '@/lnurlcash/storage'; +import { lockTrustedMint } from '@/lnurlcash/trustedMints'; +import type { Bearer, NewBearer } from '@/lnurlcash/types'; +import { msatToSats } from '@/lnurlcash/units'; +import type { WalletOwnerFence } from './walletOwnerFence'; + +export class TrustedMintPostCommitError extends Error { + override readonly name = 'TrustedMintPostCommitError'; + readonly fundsCommitted = true; + + constructor( + readonly committedBearers: Bearer[], + options: { cause: unknown }, + ) { + super( + 'Funds were saved, but the trusted-mint registry could not be updated. The receive succeeded; do not retry it.', + options, + ); + } +} + +type WalletFundsOptions = { + readonly requireKey: () => CryptoKey; + readonly ownerId: () => string | undefined; + readonly setAuxiliaryError: (message: string) => void; +}; + +export const createWalletFunds = (options: WalletFundsOptions) => { + const bearers = ref([]); + const unspentBearers = computed(() => bearers.value.filter((bearer) => !bearer.spent)); + const balanceMsat = computed(() => + unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0), + ); + const balanceSats = computed(() => msatToSats(balanceMsat.value)); + const balanceByMintMsat = computed(() => { + const byMint = new Map(); + for (const bearer of unspentBearers.value) { + const server = serverOf(bearer.url); + byMint.set(server, (byMint.get(server) ?? 0) + bearer.amount); + } + return byMint; + }); + const balanceByMintSats = computed(() => { + const byMint = new Map(); + for (const [server, msat] of balanceByMintMsat.value) { + byMint.set(server, msatToSats(msat)); + } + return byMint; + }); + + const replace = (loaded: Bearer[]): void => { + bearers.value = loaded; + }; + + const clear = (): void => { + bearers.value = []; + }; + + const lockCommittedBearers = async (committed: Bearer[]): Promise => { + try { + for (const bearer of committed) { + if (bearer.mintPubkey) { + await lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey, options.ownerId()); + } + } + } catch (error) { + const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.'); + const postCommitError = new TrustedMintPostCommitError(committed, { cause }); + options.setAuxiliaryError(postCommitError.message); + throw postCommitError; + } + }; + + const addBearers = async ( + notes: NewBearer[], + ownerFence: WalletOwnerFence, + ): Promise => { + options.setAuxiliaryError(''); + ownerFence(); + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { add: notes, markSpent: [] }, + // re-prove ownership inside the lock: encryption is async, so the + // entry check alone would leave a cross-tab replacement window open + { beforeCommit: ownerFence }, + ); + const added = next.slice(0, notes.length); + bearers.value = next; + await lockCommittedBearers(added); + return added; + }; + + const applyChangeset = async ( + changeset: BearerChangeset, + ownerFence: WalletOwnerFence, + ): Promise => { + options.setAuxiliaryError(''); + ownerFence(); + const next = await applyBearerChangeset(options.requireKey(), bearers.value, changeset, { + beforeCommit: ownerFence, + }); + const added = next.slice(0, changeset.add.length); + bearers.value = next; + await lockCommittedBearers(added); + return added; + }; + + const updateBearer = async ( + id: string, + changes: Partial>, + ownerFence: WalletOwnerFence, + ): Promise => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) return; + ownerFence(); + const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() }; + await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence }); + bearers.value = bearers.value.map((bearer) => (bearer.id === id ? updated : bearer)); + if (!updated.mintPubkey) return; + try { + await lockTrustedMint(serverOf(updated.url), updated.mintPubkey, options.ownerId()); + } catch (error) { + const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.'); + const postCommitError = new TrustedMintPostCommitError([updated], { cause }); + options.setAuxiliaryError(postCommitError.message); + throw postCommitError; + } + }; + + const markSpent = async ( + id: string, + ownerFence: WalletOwnerFence, + spent = true, + ): Promise => { + await updateBearer(id, { spent }, ownerFence); + }; + + const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise => { + ownerFence(); + await deleteBearerRecord(id, { beforeCommit: ownerFence }); + bearers.value = bearers.value.filter((bearer) => bearer.id !== id); + }; + + const mergeExternalBearers = async ( + incoming: Bearer[], + ownerFence: WalletOwnerFence, + ): Promise => { + ownerFence(); + const merged = mergeBearers(bearers.value, incoming); + const mergedIds = new Set(merged.map((bearer) => bearer.id)); + await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: [], + markSpent: [], + upsert: merged, + remove: bearers.value.filter((bearer) => !mergedIds.has(bearer.id)).map(({ id }) => id), + }, + { beforeCommit: ownerFence }, + ); + bearers.value = merged; + }; + + const reloadBearers = async (): Promise => { + bearers.value = await loadBearers(options.requireKey()); + }; + + return { + public: { + bearers, + unspentBearers, + balanceMsat, + balanceSats, + balanceByMintMsat, + balanceByMintSats, + addBearers, + applyChangeset, + updateBearer, + markSpent, + removeNote, + mergeExternalBearers, + reloadBearers, + }, + replace, + clear, + }; +};