From 54031dc8de03448aa3cee6c2a8a9d035d92c752c Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:56:10 +0200 Subject: [PATCH] fix: publish activity only after persistence --- src/stores/activity.test.ts | 67 ++++++++++++++++++++++++++++++ src/stores/activity.ts | 82 ++++++++++++++++++++++--------------- 2 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 src/stores/activity.test.ts diff --git a/src/stores/activity.test.ts b/src/stores/activity.test.ts new file mode 100644 index 0000000..962c5b4 --- /dev/null +++ b/src/stores/activity.test.ts @@ -0,0 +1,67 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import type { ActivityEvent } from '@/lnurlcash/storage'; +import type * as StorageExports from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; + +const persistence = vi.hoisted(() => ({ + persistActivityEvent: vi.fn<(key: CryptoKey, event: ActivityEvent) => Promise>(), +})); + +vi.mock('@/lnurlcash/storage', async (importOriginal) => ({ + ...(await importOriginal()), + persistActivityEvent: persistence.persistActivityEvent, +})); + +import { useActivityStore } from './activity'; + +const LINKING_KEY = new Uint8Array(32).fill(7); + +beforeEach(() => { + vi.clearAllMocks(); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('activity store durability', () => { + it('publishes an event only after its encrypted write completes', async () => { + let finishWrite: (() => void) | undefined; + persistence.persistActivityEvent.mockReturnValue( + new Promise((resolve) => { + finishWrite = resolve; + }), + ); + const activity = useActivityStore(); + await activity.loadFor(await deriveBearerAesKey(LINKING_KEY)); + + const logging = activity.log('receive', 'Received funds.', () => { + throw new Error('Unexpected persistence failure.'); + }); + + expect(activity.events).toEqual([]); + finishWrite?.(); + await logging; + expect(activity.events).toHaveLength(1); + }); + + it('rejects a failed write without leaving a false reactive event', async () => { + const writeError = new Error('activity storage unavailable'); + persistence.persistActivityEvent.mockRejectedValue(writeError); + const activity = useActivityStore(); + await activity.loadFor(await deriveBearerAesKey(LINKING_KEY)); + + let surfaced: Error | null = null; + await activity.log('receive', 'Received funds.', (error) => { + surfaced = error; + }); + + expect(activity.events).toEqual([]); + expect(surfaced).toMatchObject({ + name: 'ActivityPersistenceError', + actionCommitted: true, + cause: writeError, + }); + }); +}); diff --git a/src/stores/activity.ts b/src/stores/activity.ts index 2787609..f5c1a96 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -1,60 +1,78 @@ -import {ref} from 'vue' -import {defineStore} from 'pinia' +import { ref } from 'vue'; +import { defineStore } from 'pinia'; -import type {ActivityEvent, ActivityKind} from '@/lnurlcash/storage' +import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage'; import { loadActivity, persistActivityEvent, clearAllActivity, newActivityId, - MAX_ACTIVITY_ENTRIES -} from '@/lnurlcash/storage' + MAX_ACTIVITY_ENTRIES, +} from '@/lnurlcash/storage'; + +export class ActivityPersistenceError extends Error { + override readonly name = 'ActivityPersistenceError'; + readonly actionCommitted = true; + + constructor(options: { cause: unknown }) { + super( + 'The wallet action completed, but activity history could not be saved. Do not retry the action.', + options, + ); + } +} // The activity log: append-only, encrypted at rest with the same // bearer-AES key as the notes themselves. Loaded by the wallet store on // unlock (loadFor) and dropped on lock (unload) - it never holds plaintext // while the wallet is locked. export const useActivityStore = defineStore('activity', () => { - const events = ref([]) - let aesKey: CryptoKey | null = null + const events = ref([]); + let aesKey: CryptoKey | null = null; const loadFor = async (key: CryptoKey): Promise => { - aesKey = key - events.value = await loadActivity(key) - } + aesKey = key; + events.value = await loadActivity(key); + }; const unload = (): void => { - aesKey = null - events.value = [] - } + aesKey = null; + events.value = []; + }; // both unload and wipe the stored log - part of forgetting a wallet const unloadAndClear = (): void => { - clearAllActivity() - unload() - } + clearAllActivity(); + unload(); + }; - // best-effort and silent on failure - a wallet action that already - // succeeded (the note was split/melted/whatever) must never surface an - // error just because the log entry for it couldn't be written - const log = (kind: ActivityKind, message: string): void => { - if (!aesKey) return + const log = async ( + kind: ActivityKind, + message: string, + onPersistenceError: (error: ActivityPersistenceError) => void, + ): Promise => { + if (!aesKey) return; const event: ActivityEvent = { id: newActivityId(), kind, message, - createdAt: Date.now() + createdAt: Date.now(), + }; + try { + await persistActivityEvent(aesKey, event); + } catch (error) { + const cause = + error instanceof Error ? error : new Error('Activity storage failed.', { cause: error }); + onPersistenceError(new ActivityPersistenceError({ cause })); + return; } - events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES) - persistActivityEvent(aesKey, event).catch(() => { - // deliberately swallowed - see the comment above - }) - } + events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES); + }; const clear = (): void => { - clearAllActivity() - events.value = [] - } + clearAllActivity(); + events.value = []; + }; - return {events, loadFor, unload, unloadAndClear, log, clear} -}) + return { events, loadFor, unload, unloadAndClear, log, clear }; +});