diff --git a/src/stores/nwc.atomic.test.ts b/src/stores/nwc.atomic.test.ts new file mode 100644 index 0000000..c2cc13a --- /dev/null +++ b/src/stores/nwc.atomic.test.ts @@ -0,0 +1,115 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NwcService, NwcServiceDeps } from '@/lnurlcash/nwc'; +import type * as NwcExports from '@/lnurlcash/nwc'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { useActivityStore } from './activity'; + +type StartService = (linkingKey: Uint8Array, deps: NwcServiceDeps) => Promise; +const mocks = vi.hoisted(() => ({ startService: vi.fn() })); + +vi.mock('@/lnurlcash/nwc', async (importOriginal) => ({ + ...(await importOriginal()), + startService: mocks.startService, +})); + +import { useNwcStore } from './nwc'; +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, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); + mocks.startService.mockResolvedValue({ + connections: [], + stop: vi.fn().mockResolvedValue(undefined), + }); +}); + +describe('NWC store changeset adapter', () => { + it('commits the complete engine changeset in one bearer write', async () => { + const storage = stubLocalStorage(); + 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 nwc = useNwcStore(); + await nwc.setEnabled(true); + const start = mocks.startService.mock.calls.at(-1); + if (!start) throw new Error('Expected the NWC service to start.'); + const writes = vi.spyOn(storage, 'setItem'); + + await start[1].applyChangeset( + { add: [note('b')], markSpent: [existing.id] }, + { + record: { + version: 1, + ownerId: wallet.pubkey ?? '', + clientPubkey: '11'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 100_000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + walletServicePubkey: '22'.repeat(32), + }, + 'pay_invoice', + start[1].assertCurrentOwner, + ); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(2); + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + }); + + it('surfaces activity durability failure without rolling back committed funds', async () => { + const storage = stubLocalStorage(); + 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 nwc = useNwcStore(); + await nwc.setEnabled(true); + const start = mocks.startService.mock.calls.at(-1); + if (!start) throw new Error('Expected the NWC service to start.'); + const originalSetItem = storage.setItem; + storage.setItem = (key, value): void => { + if (key === 'sattle_activity') throw new Error('activity storage unavailable'); + originalSetItem(key, value); + }; + + await start[1].applyChangeset( + { add: [], markSpent: [existing.id] }, + { + record: { + version: 1, + ownerId: wallet.pubkey ?? '', + clientPubkey: '11'.repeat(32), + relays: ['wss://relay.example'], + budget: { maxMsat: 100_000, periodMs: 60_000 }, + spent: { periodStart: 0, msat: 0 }, + createdAt: 1, + }, + walletServicePubkey: '22'.repeat(32), + }, + 'pay_invoice', + start[1].assertCurrentOwner, + ); + + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + expect(useActivityStore().events).toEqual([]); + expect(nwc.lastError).toMatch(/activity history.*do not retry/i); + }); +}); diff --git a/src/stores/nwc.ts b/src/stores/nwc.ts index c3b7f94..9fdc156 100644 --- a/src/stores/nwc.ts +++ b/src/stores/nwc.ts @@ -14,12 +14,15 @@ import type { import { createConnection, persistNwcConnection, + readNwcEnabled, readNwcConnections, removeNwcConnection, startService, + writeNwcEnabled, } from '@/lnurlcash/nwc'; +import { linkingPubKeyHex } from '@/lnurlcash/keys'; import { msatToSats } from '@/lnurlcash/units'; -import { useWalletStore } from './wallet'; +import { TrustedMintPostCommitError, useWalletStore } from './wallet'; import { useMintsStore } from './mints'; import { useActivityStore } from './activity'; @@ -34,12 +37,6 @@ export const NWC_DEFAULT_BUDGET: NwcBudget = { periodMs: NWC_PERIOD_DAY_MS, }; -// the enabled flag lives outside wallet settings on purpose: settings are -// part of the nostr-backup payload, and a restored device must not start -// answering payment requests before its holder opted in there -const NWC_ENABLED_KEY = 'sattle_nwc_enabled'; -const readNwcEnabled = (): boolean => localStorage.getItem(NWC_ENABLED_KEY) === 'true'; - // e2e test hook: a fake transport so the suite never touches a real relay. // Set before enabling; production never calls this (exposed on window only // in dev builds, at the bottom of this file). @@ -48,6 +45,14 @@ export const setNwcTransportForTests = (transport: NwcTransport | null): void => transportOverride = transport; }; +declare global { + interface Window { + __sattleNwcTest?: { + readonly setTransport: typeof setNwcTransportForTests; + }; + } +} + const fingerprint = (pubkey: string): string => pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey; @@ -64,48 +69,60 @@ export const useNwcStore = defineStore('nwc', () => { const mints = useMintsStore(); const activity = useActivityStore(); - const enabled = ref(readNwcEnabled()); - const connections = ref(readNwcConnections()); + const enabled = ref(false); + const connections = ref([]); const running = ref(false); // background failures (a rejected publish, a lost claim) have no caller // to throw to - the page surfaces them here const lastError = ref(''); - const refresh = (): void => { - connections.value = readNwcConnections(); + const ownerFromWallet = (): string => linkingPubKeyHex(wallet.requireLinkingKey()); + + const refresh = (ownerId: string = ownerFromWallet()): void => { + connections.value = readNwcConnections(ownerId); }; // ---- changeset application ---- // the engine hands money-moving deltas here after an op ran: new notes to // persist, bearer ids to lock spent. Both go through the wallet store's - // one entry points (persist-then-state); failures surface as lastError - // rather than vanishing, since the engine already committed its side. - const applyChangeset = ( + // one entry points (persist-then-state) and are awaited: the engine holds + // its success answer until this resolves, so a failure rejects back into + // the engine's onError (surfaced as lastError) instead of a false success. + const applyChangeset = async ( changeset: NwcChangeset, connection: NwcConnectionInfo, method: NwcMethod, - ): void => { + ownerFence: () => void, + ): Promise => { const client = fingerprint(connection.record.clientPubkey); + try { + await wallet.applyChangeset(changeset, ownerFence); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + } if (method === 'pay_invoice') { - // the melt's amount, from the bearers about to be locked spent const spentMsat = changeset.markSpent.reduce( (sum, id) => sum + (wallet.bearers.find((b) => b.id === id)?.amount ?? 0), 0, ); - activity.log('nwc', `NWC client ${client} paid ${formatSats(spentMsat)} sats.`); + await activity.log( + 'nwc', + `NWC client ${client} paid ${formatSats(spentMsat)} sats.`, + (error) => { + lastError.value = error.message; + }, + ); } if (method === 'make_invoice' && changeset.add.length > 0) { const mintedMsat = changeset.add.reduce((sum, note) => sum + note.amount, 0); - activity.log('nwc', `Received ${formatSats(mintedMsat)} sats via NWC client ${client}.`); - } - const onFailure = (error: unknown) => { - lastError.value = error instanceof Error ? error.message : 'Applying an NWC change failed.'; - }; - if (changeset.add.length > 0) { - void wallet.addBearers(changeset.add).catch(onFailure); - } - for (const id of changeset.markSpent) { - void wallet.markSpent(id).catch(onFailure); + await activity.log( + 'nwc', + `Received ${formatSats(mintedMsat)} sats via NWC client ${client}.`, + (error) => { + lastError.value = error.message; + }, + ); } }; @@ -115,15 +132,21 @@ export const useNwcStore = defineStore('nwc', () => { // a start that is still in flight when stop (or a restart) lands. let service: NwcService | null = null; let startToken = 0; + const pendingStarts = new Set>(); + let stopping: Promise = Promise.resolve(); + let pendingStop: Promise | null = null; - const start = async (): Promise => { - const token = ++startToken; + const startNow = async (token: number): Promise => { + await stopping; + if (token !== startToken || wallet.state !== 'unlocked' || !enabled.value) return; lastError.value = ''; try { + const ownerFence = wallet.captureOwnerFence(); const started = await startService(wallet.requireLinkingKey(), { // only spendable notes may back an NWC payment getBearers: () => wallet.unspentBearers, getDefaultMint: () => mints.defaultMint, + assertCurrentOwner: ownerFence, applyChangeset, transport: transportOverride ?? undefined, onError: (error) => { @@ -133,7 +156,7 @@ export const useNwcStore = defineStore('nwc', () => { }); if (token !== startToken) { // stopped (or restarted) while we were subscribing - started.stop(); + await started.stop(); return; } service = started; @@ -146,34 +169,74 @@ export const useNwcStore = defineStore('nwc', () => { } }; - const stop = (): void => { - startToken++; - service?.stop(); + const start = (): Promise => { + pendingStop = null; + const token = ++startToken; + const operation = startNow(token); + pendingStarts.add(operation); + void operation.then( + () => pendingStarts.delete(operation), + () => pendingStarts.delete(operation), + ); + return operation; + }; + + const stop = (): Promise => { + if (service === null && pendingStarts.size === 0 && pendingStop !== null) { + const result = pendingStop; + pendingStop = null; + return result; + } + startToken += 1; + const active = service; service = null; running.value = false; + const priorStop = stopping; + const activeStop = active?.stop() ?? Promise.resolve(); + const completion = Promise.all([priorStop, activeStop, ...pendingStarts]).then(() => undefined); + pendingStop = completion; + stopping = completion.catch(() => undefined); + return completion; }; watch( - () => [wallet.state, enabled.value] as const, - ([state, on]) => { - if (state === 'unlocked' && on) void start(); - else stop(); + () => wallet.state, + (state) => { + void stop() + .then(() => { + if (state !== 'unlocked') { + enabled.value = false; + connections.value = []; + return; + } + const ownerId = ownerFromWallet(); + refresh(ownerId); + enabled.value = readNwcEnabled(ownerId); + if (enabled.value) return start(); + }) + .catch((error: unknown) => { + lastError.value = + error instanceof Error ? error.message : 'The NWC service failed to stop.'; + }); }, { immediate: true }, ); // the served set is a startup snapshot, so any change to the connection // records (create / budget edit / revoke) restarts the service to match - const restartIfRunning = (): void => { + const restartIfRunning = async (): Promise => { if (!running.value) return; - stop(); - if (wallet.state === 'unlocked' && enabled.value) void start(); + await stop(); + if (wallet.state === 'unlocked' && enabled.value) await start(); }; // ---- settings ---- - const setEnabled = (value: boolean): void => { + const setEnabled = async (value: boolean): Promise => { + const ownerId = ownerFromWallet(); + writeNwcEnabled(ownerId, value); enabled.value = value; - localStorage.setItem(NWC_ENABLED_KEY, String(value)); + if (value) await start(); + else await stop(); }; // ---- connection management ---- @@ -183,22 +246,24 @@ export const useNwcStore = defineStore('nwc', () => { const create = (relays: string[], budget: NwcBudget): CreatedConnection => { const created = createConnection(wallet.requireLinkingKey(), { relays, budget }); refresh(); - restartIfRunning(); + void restartIfRunning(); return created; }; const updateBudget = (clientPubkey: string, budget: NwcBudget): void => { - const record = readNwcConnections().find((r) => r.clientPubkey === clientPubkey); + const ownerId = ownerFromWallet(); + const record = readNwcConnections(ownerId).find((r) => r.clientPubkey === clientPubkey); if (!record) return; - persistNwcConnection({ ...record, budget }); - refresh(); - restartIfRunning(); + persistNwcConnection(ownerId, { ...record, budget }); + refresh(ownerId); + void restartIfRunning(); }; const revoke = (clientPubkey: string): void => { - removeNwcConnection(clientPubkey); - refresh(); - restartIfRunning(); + const ownerId = ownerFromWallet(); + removeNwcConnection(ownerId, clientPubkey); + refresh(ownerId); + void restartIfRunning(); }; return { @@ -206,6 +271,7 @@ export const useNwcStore = defineStore('nwc', () => { connections, running, lastError, + stop, setEnabled, create, updateBudget, @@ -216,7 +282,7 @@ export const useNwcStore = defineStore('nwc', () => { // dev-only e2e hook: lets a spec inject a fake relay transport before // enabling the service, so the suite opens no real WebSocket if (import.meta.env.DEV && typeof window !== 'undefined') { - (window as unknown as Record).__sattleNwcTest = { + window.__sattleNwcTest = { setTransport: setNwcTransportForTests, }; }