From 586d12b666e13f040254ffbc1c618859cf7659b4 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:00 +0200 Subject: [PATCH] feat: commit bearer changesets in one write --- src/lnurlcash/storage.changeset.cases.ts | 249 ++++++++++++++ src/lnurlcash/storage.test.ts | 81 +++-- src/lnurlcash/storage.ts | 18 +- .../storage/bearers.baseline.cases.ts | 29 ++ src/lnurlcash/storage/bearers.test.ts | 305 ++++++++++++++++++ src/lnurlcash/storage/bearers.ts | 152 ++++++++- 6 files changed, 773 insertions(+), 61 deletions(-) create mode 100644 src/lnurlcash/storage.changeset.cases.ts create mode 100644 src/lnurlcash/storage/bearers.baseline.cases.ts create mode 100644 src/lnurlcash/storage/bearers.test.ts diff --git a/src/lnurlcash/storage.changeset.cases.ts b/src/lnurlcash/storage.changeset.cases.ts new file mode 100644 index 0000000..4ca5fef --- /dev/null +++ b/src/lnurlcash/storage.changeset.cases.ts @@ -0,0 +1,249 @@ +// Imported by storage.test.ts so the focused storage command exercises the +// fund-critical changeset boundary without mixing it into unrelated storage +// round-trip and backup cases. + +import {describe, expect, it, vi} from 'vitest' +import {buildNoteUrl} from 'lnurlcash-kit' + +import {deriveBearerAesKey, encryptRecord} from './keys' +import { + applyBearerChangeset, + deleteBearerRecord, + loadBearers, + newBearerId, + persistBearer, + readEncryptedBearers, +} from './storage' +import type {BearerChangeset} from './storage' +import type {Bearer, NewBearer} from './types' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const K1_C = 'cc'.repeat(32) +const K1_D = 'dd'.repeat(32) + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: newBearerId(), + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', K1_C, 3_000), + callback: 'https://mint.example/w/cb', + amount: 3_000, + verified: true, + ...overrides, +}) + +describe('baseline: per-record bearer persistence', () => { + it('writes once for every persist or delete call', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(localStorage, 'setItem') + + await persistBearer(key, bearerFixture({id: 'a'})) + await persistBearer(key, bearerFixture({id: 'b'})) + await deleteBearerRecord('a') + + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(3) + }) +}) + +describe('applyBearerChangeset', () => { + it('commits additions and spent replacements with exactly one write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const oldA = bearerFixture({id: 'old-a'}) + const oldB = bearerFixture({ + id: 'old-b', + url: buildNoteUrl('https://mint.example/w', K1_B, 5_000), + amount: 5_000, + }) + await persistBearer(key, oldA) + await persistBearer(key, oldB) + const snapshot = [oldA, oldB] + const changeset: BearerChangeset = { + add: [ + newBearerFixture(), + newBearerFixture({ + url: buildNoteUrl('https://mint.example/w', K1_D, 4_000), + amount: 4_000, + }), + ], + markSpent: ['old-a', 'old-b'], + } + const writes = vi.spyOn(localStorage, 'setItem') + + const result = await applyBearerChangeset(key, snapshot, changeset) + + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(1) + expect(result).toHaveLength(4) + expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true]) + expect(result[0]?.id).not.toBe(result[1]?.id) + expect(snapshot.map((bearer) => bearer.spent)).toEqual([undefined, undefined]) + expect(changeset.markSpent).toEqual(['old-a', 'old-b']) + expect(changeset.add.some((note) => 'id' in note)).toBe(false) + expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual( + result.map((bearer) => bearer.id).sort(), + ) + const raw = localStorage.getItem('sattle_bearers') ?? '' + expect(raw).not.toContain(K1_C) + expect(raw).not.toContain(K1_D) + }) + + it('writes nothing when the second record encryption fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const writes = vi.spyOn(localStorage, 'setItem') + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + encrypt + .mockResolvedValueOnce(new ArrayBuffer(32)) + .mockRejectedValueOnce(new Error('second encryption failed')) + + try { + await expect( + applyBearerChangeset(key, [], { + add: [ + newBearerFixture(), + newBearerFixture({ + url: buildNoteUrl('https://mint.example/w', K1_D, 4_000), + }), + ], + markSpent: [], + }), + ).rejects.toThrow('second encryption failed') + expect(encrypt).toHaveBeenCalledTimes(2) + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(0) + expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept']) + } finally { + encrypt.mockRestore() + } + }) + + it('rejects a failed storage write without changing persisted state', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const kept = bearerFixture({id: 'kept'}) + await persistBearer(key, kept) + const before = localStorage.getItem('sattle_bearers') + const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + vi.stubGlobal('navigator', {}) + + try { + await expect( + applyBearerChangeset(key, [kept], { + add: [newBearerFixture()], + markSpent: ['kept'], + }), + ).rejects.toThrow('QuotaExceededError') + expect(localStorage.getItem('sattle_bearers')).toBe(before) + } finally { + write.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('deduplicates repeated spent ids into one stored replacement', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const bearer = bearerFixture({id: 'duplicate'}) + await persistBearer(key, bearer) + + const result = await applyBearerChangeset(key, [bearer], { + add: [], + markSpent: ['duplicate', 'duplicate'], + }) + + expect(readEncryptedBearers().filter((record) => record.id === 'duplicate')).toHaveLength(1) + expect(result[0]?.spent).toBe(true) + expect((await loadBearers(key))[0]?.spent).toBe(true) + }) + + it('encrypts before locking and preserves a fresh unrelated record', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const other = await deriveBearerAesKey(OTHER_KEY) + const mine = bearerFixture({id: 'mine'}) + const foreign = bearerFixture({ + id: 'fresh-tab', + url: buildNoteUrl('https://mint.example/w', K1_B, 9_000), + }) + await persistBearer(key, mine) + const {id: foreignId, ...foreignPlain} = foreign + const foreignParts = await encryptRecord(other, foreignPlain) + const encrypted = vi.spyOn(crypto.subtle, 'encrypt') + const queue: {release: () => Promise}[] = [] + vi.stubGlobal('navigator', { + locks: { + request: (_name: string, fn: () => unknown): Promise => + new Promise((resolve, reject) => { + queue.push({ + release: async () => { + await Promise.resolve(fn) + .then((callback) => callback()) + .then(resolve, reject) + }, + }) + }), + }, + }) + + try { + const commit = applyBearerChangeset(key, [mine], { + add: [newBearerFixture()], + markSpent: [], + }) + await vi.waitFor(() => expect(queue).toHaveLength(1)) + expect(encrypted).toHaveBeenCalledTimes(1) + localStorage.setItem( + 'sattle_bearers', + JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]), + ) + const pendingLock = queue.at(0) + if (pendingLock === undefined) throw new Error('Expected pending lock') + + await pendingLock.release() + const result = await commit + + expect( + readEncryptedBearers() + .map((record) => record.id) + .sort(), + ).toEqual(['fresh-tab', 'mine', result[0]?.id].sort()) + expect((await loadBearers(other)).map((bearer) => bearer.id)).toEqual(['fresh-tab']) + } finally { + encrypted.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('replaces corrupt JSON with the single committed changeset write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + localStorage.setItem('sattle_bearers', 'not json {{{') + const writes = vi.spyOn(localStorage, 'setItem') + + const result = await applyBearerChangeset(key, [], { + add: [newBearerFixture()], + markSpent: ['missing'], + }) + + expect(result).toHaveLength(1) + expect( + writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + ).toHaveLength(1) + expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual( + result.map((bearer) => bearer.id), + ) + }) +}) diff --git a/src/lnurlcash/storage.test.ts b/src/lnurlcash/storage.test.ts index 96fd16e..4f00acd 100644 --- a/src/lnurlcash/storage.test.ts +++ b/src/lnurlcash/storage.test.ts @@ -4,6 +4,7 @@ import {beforeEach, describe, expect, it} from 'vitest' import {buildNoteUrl} from 'lnurlcash-kit' +import './storage.changeset.cases' import type {Bearer} from './types' import {deriveBearerAesKey} from './keys' import { @@ -18,10 +19,10 @@ import { persistActivityEvent, persistBearer, readEncryptedBearers, - MAX_ACTIVITY_ENTRIES + MAX_ACTIVITY_ENTRIES, } from './storage' import {saveLinkingKey} from './keys' -import {stubLocalStorage} from './test-utils' +import {requiredValue, stubLocalStorage} from './test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_KEY = new Uint8Array(32).fill(9) @@ -37,7 +38,7 @@ const bearerFixture = (overrides: Partial = {}): Bearer => ({ verified: true, createdAt: 1000, updatedAt: 1000, - ...overrides + ...overrides, }) beforeEach(() => { @@ -51,7 +52,7 @@ describe('encrypted bearer records', () => { await persistBearer(key, bearer) // at rest, nothing plaintext leaks: no k1, no amounts - const raw = localStorage.getItem('sattle_bearers')! + const raw = requiredValue(localStorage.getItem('sattle_bearers')) expect(raw).not.toContain(K1_A) expect(raw).not.toContain('21000') @@ -63,12 +64,19 @@ describe('encrypted bearer records', () => { const key = await deriveBearerAesKey(LINKING_KEY) const other = await deriveBearerAesKey(OTHER_KEY) await persistBearer(key, bearerFixture({id: 'mine'})) - await persistBearer(other, bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})) + await persistBearer( + other, + bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}), + ) const loaded = await loadBearers(key) - expect(loaded.map(b => b.id)).toEqual(['mine']) + expect(loaded.map((b) => b.id)).toEqual(['mine']) // the foreign ciphertext is still there, untouched - expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['foreign', 'mine']) + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['foreign', 'mine']) }) it('overwrites a record when the same id is persisted again', async () => { @@ -79,7 +87,7 @@ describe('encrypted bearer records', () => { const loaded = await loadBearers(key) expect(loaded).toHaveLength(1) - expect(loaded[0]!.spent).toBe(true) + expect(requiredValue(loaded[0]).spent).toBe(true) }) it('deletes a record by id and clears all', async () => { @@ -90,7 +98,7 @@ describe('encrypted bearer records', () => { await persistBearer(key, b) await deleteBearerRecord('a') - expect((await loadBearers(key)).map(x => x.id)).toEqual(['b']) + expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b']) clearAllBearers() expect(readEncryptedBearers()).toEqual([]) @@ -104,7 +112,7 @@ describe('activity log', () => { await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000}) const loaded = await loadActivity(key) - expect(loaded.map(e => e.id)).toEqual(['2', '1']) + expect(loaded.map((e) => e.id)).toEqual(['2', '1']) }) it('caps the log, rolling the oldest entries off', async () => { @@ -114,14 +122,14 @@ describe('activity log', () => { id: `ev-${i}`, kind: 'receive', message: `event ${i}`, - createdAt: i + createdAt: i, }) } const loaded = await loadActivity(key) expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES) // the five oldest rolled off; the newest is first - expect(loaded[0]!.id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`) - expect(loaded.at(-1)!.id).toBe('ev-5') + expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`) + expect(requiredValue(loaded.at(-1)).id).toBe('ev-5') }, 30_000) }) @@ -130,7 +138,7 @@ describe('mergeBearers (union by note id, spent-wins)', () => { const a = bearerFixture({id: 'a'}) const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}) const merged = mergeBearers([a], [b]) - expect(merged.map(x => x.id).sort()).toEqual(['a', 'b']) + expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']) }) it('lets the spent copy of a note win over a still-spendable one', () => { @@ -141,8 +149,8 @@ describe('mergeBearers (union by note id, spent-wins)', () => { // would resurrect burned money const merged = mergeBearers([spendable], [spent]) expect(merged).toHaveLength(1) - expect(merged[0]!.id).toBe('new-copy') - expect(merged[0]!.spent).toBe(true) + expect(requiredValue(merged[0]).id).toBe('new-copy') + expect(requiredValue(merged[0]).spent).toBe(true) }) it('keeps the newer copy when both agree on spent state', () => { @@ -150,14 +158,14 @@ describe('mergeBearers (union by note id, spent-wins)', () => { const fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2}) const merged = mergeBearers([stale], [fresh]) expect(merged).toHaveLength(1) - expect(merged[0]!.id).toBe('fresh') + expect(requiredValue(merged[0]).id).toBe('fresh') }) it('treats the same secret on different servers as different notes', () => { const here = bearerFixture({id: 'here'}) const there = bearerFixture({ id: 'there', - url: buildNoteUrl('https://other.example/w', K1_A, 21_000) + url: buildNoteUrl('https://other.example/w', K1_A, 21_000), }) expect(mergeBearers([here], [there])).toHaveLength(2) }) @@ -191,16 +199,17 @@ describe('backup', () => { ...backup, bearers: [ ...backup.bearers, - {id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)} - ] + {id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, + ], } - const result = applyBackup(incoming) + const result = await applyBackup(incoming) expect(result.added).toBe(1) expect(result.skipped).toBe(1) - expect(readEncryptedBearers().map(r => r.id).sort()).toEqual([ - 'existing', - 'from-backup' - ]) + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['existing', 'from-backup']) }) it('restores the linking key only onto a device that has none', async () => { @@ -208,33 +217,35 @@ describe('backup', () => { const backup = buildBackup() // same device: a key already exists, so the backup's key is skipped - const here = applyBackup(backup) + const here = await applyBackup(backup) expect(here.linkingKeySkipped).toBe(true) expect(here.linkingKeyRestored).toBe(false) // fresh device: the key installs stubLocalStorage() - const fresh = applyBackup(backup) + const fresh = await applyBackup(backup) expect(fresh.linkingKeyRestored).toBe(true) expect(fresh.linkingKeySkipped).toBe(false) }) - it('rejects a file that is not a sattle backup', () => { - expect(() => applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []})).toThrow() - expect(() => applyBackup(null)).toThrow() - expect(() => applyBackup({type: 'sattle-backup', version: 2, bearers: []})).toThrow() + it('rejects a file that is not a sattle backup', async () => { + await expect( + applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}), + ).rejects.toThrow() + await expect(applyBackup(null)).rejects.toThrow() + await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow() }) - it('skips malformed records instead of failing the whole restore', () => { - const result = applyBackup({ + it('skips malformed records instead of failing the whole restore', async () => { + const result = await applyBackup({ type: 'sattle-backup', version: 1, createdAt: 1, bearers: [ {id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, {id: 42, iv: null, ciphertext: 'xx'}, - 'garbage' - ] + 'garbage', + ], }) expect(result.added).toBe(1) expect(result.skipped).toBe(2) diff --git a/src/lnurlcash/storage.ts b/src/lnurlcash/storage.ts index 5ac33ca..6f53b75 100644 --- a/src/lnurlcash/storage.ts +++ b/src/lnurlcash/storage.ts @@ -5,7 +5,8 @@ // the Pinia mints store and this module's backup both use it. // // Split by concern; this façade re-exports everything: -// storage/bearers.ts - encrypted bearer records + mergeBearers +// storage/bearers.ts - encrypted bearer records, changeset commits, +// mergeBearers // storage/activityLog.ts - the append-only encrypted activity log // storage/settings.ts - plaintext wallet settings // storage/backup.ts - buildBackup / applyBackup @@ -19,10 +20,11 @@ export { loadBearers, persistBearer, deleteBearerRecord, + applyBearerChangeset, clearAllBearers, - mergeBearers + mergeBearers, } from './storage/bearers' -export type {EncryptedBearerRecord} from './storage/bearers' +export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers' export { newActivityId, @@ -30,16 +32,12 @@ export { loadActivity, persistActivityEvent, clearAllActivity, - MAX_ACTIVITY_ENTRIES -} from './storage/activityLog' -export type { - ActivityKind, - ActivityEvent, - EncryptedActivityRecord + MAX_ACTIVITY_ENTRIES, } from './storage/activityLog' +export type {ActivityKind, ActivityEvent, EncryptedActivityRecord} from './storage/activityLog' export {loadSettings, persistSettings, clearSettings} from './storage/settings' export type {WalletSettings} from './storage/settings' -export {buildBackup, applyBackup, MAX_BACKUP_FILE_BYTES} from './storage/backup' +export {buildBackup, applyBackup, parseBackupFile, MAX_BACKUP_FILE_BYTES} from './storage/backup' export type {BackupFile, RestoreResult} from './storage/backup' diff --git a/src/lnurlcash/storage/bearers.baseline.cases.ts b/src/lnurlcash/storage/bearers.baseline.cases.ts new file mode 100644 index 0000000..8631efe --- /dev/null +++ b/src/lnurlcash/storage/bearers.baseline.cases.ts @@ -0,0 +1,29 @@ +import {describe, expect, it, vi} from 'vitest' +import {buildNoteUrl} from 'lnurlcash-kit' +import type {Bearer} from '../types' +import {deriveBearerAesKey} from '../keys' +import {deleteBearerRecord, newBearerId, persistBearer} from '../storage' +import {stubLocalStorage} from '../test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const K1 = 'aa'.repeat(32) +const bearerFixture = (): Bearer => ({ + id: newBearerId(), + url: buildNoteUrl('https://mint.example/w', K1, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, +}) +describe('baseline: per-record bearer persistence', () => { + it('persistBearer/deleteBearerRecord perform one write per call', async () => { + const storage = stubLocalStorage() + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(storage, 'setItem') + await persistBearer(key, {...bearerFixture(), id: 'a'}) + await persistBearer(key, {...bearerFixture(), id: 'b'}) + await deleteBearerRecord('a') + expect(writes.mock.calls.filter(([keyName]) => keyName === 'sattle_bearers')).toHaveLength(3) + }) +}) diff --git a/src/lnurlcash/storage/bearers.test.ts b/src/lnurlcash/storage/bearers.test.ts new file mode 100644 index 0000000..91d1886 --- /dev/null +++ b/src/lnurlcash/storage/bearers.test.ts @@ -0,0 +1,305 @@ +// Bearer changeset persistence: applyBearerChangeset (the single-write +// commit primitive) plus a baseline pin of the per-record write behavior it +// replaces at the call sites. Lives next to bearers.ts instead of inside +// ../storage.test.ts to keep both files under the project's module size +// ceiling. Runs in Node against an in-memory localStorage stub; WebCrypto +// (crypto.subtle) is native. + +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {buildNoteUrl} from 'lnurlcash-kit' + +import type {Bearer, NewBearer} from '../types' +import {deriveBearerAesKey, encryptRecord} from '../keys' +import { + applyBearerChangeset, + loadBearers, + newBearerId, + persistBearer, + readEncryptedBearers, +} from '../storage' +import type {BearerChangeset} from '../storage' +import {writeEncryptedBearers} from './bearers' +import {requiredValue, stubLocalStorage} from '../test-utils' +import type {LocalStorageStub} from '../test-utils' +import './bearers.baseline.cases' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_KEY = new Uint8Array(32).fill(9) + +const K1_A = 'aa'.repeat(32) +const K1_B = 'bb'.repeat(32) +const K1_C = 'cc'.repeat(32) +const K1_D = 'dd'.repeat(32) + +const bearerFixture = (overrides: Partial = {}): Bearer => ({ + id: newBearerId(), + url: buildNoteUrl('https://mint.example/w', K1_A, 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + createdAt: 1000, + updatedAt: 1000, + ...overrides, +}) + +const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ + url: buildNoteUrl('https://mint.example/w', K1_C, 3_000), + callback: 'https://mint.example/w/cb', + amount: 3_000, + verified: true, + ...overrides, +}) + +let stub: LocalStorageStub + +beforeEach(() => { + stub = stubLocalStorage() +}) + +const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] => + spy.mock.calls.filter(([k]) => k === 'sattle_bearers') + +describe('applyBearerChangeset (single-write changeset commit)', () => { + it('commits additions and spent replacements with exactly one write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const oldA = bearerFixture({id: 'old-a'}) + const oldB = bearerFixture({ + id: 'old-b', + url: buildNoteUrl('https://mint.example/w', K1_B, 5_000), + amount: 5_000, + }) + await persistBearer(key, oldA) + await persistBearer(key, oldB) + + const writes = vi.spyOn(stub, 'setItem') + const result = await applyBearerChangeset(key, [oldA, oldB], { + add: [ + newBearerFixture(), + newBearerFixture({ + url: buildNoteUrl('https://mint.example/w', K1_D, 4_000), + amount: 4_000, + }), + ], + markSpent: ['old-a', 'old-b'], + }) + + // the whole changeset is ONE setItem on sattle_bearers + expect(bearerWrites(writes)).toHaveLength(1) + + // the returned next list: additions first, then the snapshot with spent + // marks applied + expect(result).toHaveLength(4) + const addA = requiredValue(result[0]) + const addB = requiredValue(result[1]) + const spentA = requiredValue(result[2]) + const spentB = requiredValue(result[3]) + expect(addA.id).not.toBe(addB.id) + expect(addA.amount).toBe(3_000) + expect(addB.amount).toBe(4_000) + expect(addA.createdAt).toBe(addA.updatedAt) + expect(spentA.id).toBe('old-a') + expect(spentA.spent).toBe(true) + expect(spentA.updatedAt).toBeGreaterThan(1000) + expect(spentB.id).toBe('old-b') + expect(spentB.spent).toBe(true) + + // the source of truth is the reloaded ciphertext, not the return value + const reloaded = await loadBearers(key) + expect(reloaded.map((b) => b.id).sort()).toEqual(result.map((b) => b.id).sort()) + expect(requiredValue(reloaded.find((b) => b.id === 'old-a')).spent).toBe(true) + expect(requiredValue(reloaded.find((b) => b.id === 'old-b')).spent).toBe(true) + expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined() + // nothing plaintext leaked: the fresh k1s are ciphertext-only at rest + const raw = requiredValue(localStorage.getItem('sattle_bearers')) + expect(raw).not.toContain(K1_C) + expect(raw).not.toContain(K1_D) + }) + + it('never mutates the caller snapshot or the changeset', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const snapshot = [bearerFixture({id: 's1'})] + const changeset: BearerChangeset = { + add: [newBearerFixture()], + markSpent: ['s1'], + } + + await applyBearerChangeset(key, snapshot, changeset) + + expect(requiredValue(snapshot[0]).spent).toBeUndefined() + expect('id' in requiredValue(changeset.add[0])).toBe(false) + expect(changeset.markSpent).toEqual(['s1']) + }) + + it('persists nothing when encryption fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + // a decrypt-only key makes every AES-GCM encrypt call reject + const decryptOnly = await crypto.subtle.importKey( + 'raw', + new Uint8Array(32).fill(3), + 'AES-GCM', + false, + ['decrypt'], + ) + const writes = vi.spyOn(stub, 'setItem') + + await expect( + applyBearerChangeset(decryptOnly, [], { + add: [newBearerFixture()], + markSpent: [], + }), + ).rejects.toThrow() + + expect(bearerWrites(writes)).toHaveLength(0) + expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept']) + }) + + it('rejects without a partial write when the storage write itself fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const kept = bearerFixture({id: 'kept'}) + await persistBearer(key, kept) + const before = readEncryptedBearers() + stub.setItem = (): void => { + throw new Error('QuotaExceededError') + } + // run this one through the unlocked fallback on purpose: a quota throw + // is SYNCHRONOUS, and Node 24's real navigator.locks never releases a + // lock whose callback throws synchronously (verified Node quirk - every + // browser releases per the Web Locks spec), which would wedge + // 'sattle_bearers' for the rest of the file. Bonus: one test keeps the + // documented plain-Node fallback path (storageLock.ts) exercised. + vi.stubGlobal('navigator', {}) + try { + await expect( + applyBearerChangeset(key, [kept], { + add: [newBearerFixture()], + markSpent: ['kept'], + }), + ).rejects.toThrow('QuotaExceededError') + + // nothing was persisted: the pre-existing record is byte-identical + expect(readEncryptedBearers()).toEqual(before) + } finally { + vi.unstubAllGlobals() + } + }) + + it('upserts changed ids and dedupes repeated markSpent ids', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const stale = bearerFixture({id: 'dup', updatedAt: 1000}) + await persistBearer(key, stale) + + const result = await applyBearerChangeset(key, [stale], { + add: [], + markSpent: ['dup', 'dup'], + }) + + // one record per id, never a duplicate append + expect(readEncryptedBearers().filter((r) => r.id === 'dup')).toHaveLength(1) + const reloaded = await loadBearers(key) + expect(reloaded).toHaveLength(1) + expect(requiredValue(reloaded[0]).spent).toBe(true) + expect(requiredValue(result[0]).spent).toBe(true) + }) + + it('preserves a record another tab commits between the snapshot and the lock', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const other = await deriveBearerAesKey(OTHER_KEY) + const mine = bearerFixture({id: 'mine'}) + await persistBearer(key, mine) + + // a controllable Web Locks fake: lock requests park until the test + // releases them, so a foreign write can interleave deterministically + const queue: {name: string; release: () => Promise}[] = [] + vi.stubGlobal('navigator', { + locks: { + request: (name: string, fn: () => unknown): Promise => + new Promise((resolve, reject) => { + queue.push({ + name, + release: async () => { + try { + resolve(await fn()) + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))) + } + }, + }) + }), + }, + }) + try { + const commit = applyBearerChangeset(key, [mine], { + add: [newBearerFixture()], + markSpent: [], + }) + // encryption happens BEFORE the lock request; wait for it to arrive + await vi.waitFor(() => { + expect(queue).toHaveLength(1) + }) + const queuedLock = requiredValue(queue[0]) + expect(queuedLock.name).toBe('sattle_bearers') + + // while our commit waits on the lock, another tab commits a record we + // cannot even decrypt (written under a different seed's key) + const foreign = bearerFixture({ + id: 'foreign-tab', + url: buildNoteUrl('https://mint.example/w', K1_B, 9_000), + }) + const {id: foreignId, ...foreignPlain} = foreign + const foreignParts = await encryptRecord(other, foreignPlain) + writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]) + + await queuedLock.release() + const result = await commit + + // the foreign ciphertext survived our upsert, untouched + expect( + readEncryptedBearers() + .map((r) => r.id) + .sort(), + ).toEqual(['foreign-tab', 'mine', requiredValue(result[0]).id].sort()) + expect((await loadBearers(other)).map((b) => b.id)).toEqual(['foreign-tab']) + expect((await loadBearers(key)).map((b) => b.id).sort()).toEqual( + ['mine', requiredValue(result[0]).id].sort(), + ) + } finally { + vi.unstubAllGlobals() + } + }) + + it('treats corrupted stored JSON as an empty record set instead of throwing', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + localStorage.setItem('sattle_bearers', 'not json {{{') + + const result = await applyBearerChangeset(key, [], { + add: [newBearerFixture()], + markSpent: ['gone'], + }) + + // readEncryptedBearers' long-standing contract: unparseable storage + // reads as [] (malformed entries are dropped, never thrown on) - the + // changeset still commits and its single write replaces the corrupt blob + expect(result).toHaveLength(1) + expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id]) + }) + + it('ignores markSpent ids absent from the snapshot and writes nothing when nothing changed', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const other = await deriveBearerAesKey(OTHER_KEY) + await persistBearer(other, bearerFixture({id: 'foreign'})) + const writes = vi.spyOn(stub, 'setItem') + + // 'foreign' is not in the caller's snapshot; deriving its spent copy + // would require decrypting an unrelated record, which this primitive + // never does - so the changeset changes nothing and performs no write + const result = await applyBearerChangeset(key, [], { + add: [], + markSpent: ['foreign'], + }) + + expect(result).toEqual([]) + expect(bearerWrites(writes)).toHaveLength(0) + expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign']) + }) +}) diff --git a/src/lnurlcash/storage/bearers.ts b/src/lnurlcash/storage/bearers.ts index 87bad1f..8e9cdf4 100644 --- a/src/lnurlcash/storage/bearers.ts +++ b/src/lnurlcash/storage/bearers.ts @@ -4,7 +4,8 @@ import type {EncryptedRecordParts} from '../keys' import {encryptRecord, decryptRecord} from '../keys' -import type {Bearer} from '../types' +import type {Bearer, NewBearer} from '../types' +import {isJsonObject} from '../jsonParsing' import {noteK1, serverOf} from 'lnurlcash-kit' import {withStorageLock} from '../storageLock' @@ -15,11 +16,31 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number => export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts +const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord => + isJsonObject(value) && + typeof value.id === 'string' && + typeof value.iv === 'string' && + typeof value.ciphertext === 'string' + +const isStoredBearer = (value: unknown): value is Omit => + isJsonObject(value) && + typeof value.url === 'string' && + typeof value.callback === 'string' && + typeof value.amount === 'number' && + typeof value.verified === 'boolean' && + typeof value.createdAt === 'number' && + typeof value.updatedAt === 'number' && + (value.mintPubkey === undefined || typeof value.mintPubkey === 'string') && + (value.spent === undefined || typeof value.spent === 'boolean') && + (value.sortIndex === undefined || typeof value.sortIndex === 'number') && + (value.label === undefined || typeof value.label === 'string') && + (value.deviceId === undefined || typeof value.deviceId === 'string') + const BEARERS_STORAGE_KEY = 'sattle_bearers' export const newBearerId = (): string => Array.from(crypto.getRandomValues(new Uint8Array(8))) - .map(b => b.toString(16).padStart(2, '0')) + .map((b) => b.toString(16).padStart(2, '0')) .join('') export const readEncryptedBearers = (): EncryptedBearerRecord[] => { @@ -27,18 +48,18 @@ export const readEncryptedBearers = (): EncryptedBearerRecord[] => { if (!raw) return [] try { const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) ? parsed : [] + return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : [] } catch { return [] } } -export const writeEncryptedBearers = ( - records: EncryptedBearerRecord[] -): void => { +export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => { localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records)) } +type BearerCommitOptions = {beforeCommit?: () => void} + // decrypts everything currently stored - a record that fails to decrypt // (e.g. written by a different seed's key) is skipped, not destroyed: it // stays in localStorage untouched and simply doesn't show up @@ -46,10 +67,12 @@ export const loadBearers = async (aesKey: CryptoKey): Promise => { const bearers: Bearer[] = [] for (const record of readEncryptedBearers()) { try { - const bearer = await decryptRecord>(aesKey, record) + const bearer = await decryptRecord(aesKey, record) + if (!isStoredBearer(bearer)) throw new Error('Malformed encrypted bearer record.') bearers.push({...bearer, id: record.id}) - } catch { + } catch (error) { // undecryptable with this key - leave it in place + if (!(error instanceof Error)) throw error } } return bearers.sort((a, b) => b.createdAt - a.createdAt) @@ -57,23 +80,123 @@ export const loadBearers = async (aesKey: CryptoKey): Promise => { export const persistBearer = async ( aesKey: CryptoKey, - bearer: Bearer + bearer: Bearer, + options: BearerCommitOptions = {}, ): Promise => { const {id, ...plain} = bearer const parts = await encryptRecord(aesKey, plain) await withStorageLock(BEARERS_STORAGE_KEY, () => { - const records = readEncryptedBearers().filter(r => r.id !== id) + options.beforeCommit?.() + const records = readEncryptedBearers().filter((r) => r.id !== id) records.push({id, ...parts}) writeEncryptedBearers(records) }) } -export const deleteBearerRecord = async (id: string): Promise => { +export const deleteBearerRecord = async ( + id: string, + options: BearerCommitOptions = {}, +): Promise => { await withStorageLock(BEARERS_STORAGE_KEY, () => { - writeEncryptedBearers(readEncryptedBearers().filter(r => r.id !== id)) + options.beforeCommit?.() + writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id)) }) } +// The atomic unit of bearer persistence: fresh notes to start tracking plus +// ids of snapshot notes to lock as spent. Born-spent notes (carved and +// melted away in one flow) are deliberately not representable - they were +// never the wallet's money in a trackable state. +export type BearerChangeset = { + add: NewBearer[] + markSpent: string[] + upsert?: Bearer[] + remove?: string[] +} + +// Commits a whole changeset as ONE storage write - the fund-critical +// boundary a caller (NWC service, wallet store) awaits before reporting +// success. The per-record path above (persistBearer in a loop) can die +// halfway through a melt: some records persisted, some not, while the +// caller's reactive state already moved on. Here nothing becomes +// observable until the single locked write lands: +// +// - every changed Bearer value is derived from the caller's snapshot: added +// notes get their id/timestamps assigned HERE (so state and storage can +// never disagree about them), spent marks copy the snapshot's record +// - ALL encryption happens BEFORE the lock is taken - crypto is the slow, +// async part and a storage lock must never be held across it (see +// storageLock.ts); if any record fails to encrypt, no write happens at +// all +// - inside the lock the encrypted records are re-read FRESH, so records +// another tab committed after the caller's snapshot survive the upsert - +// changed ids replace their stored copy, everything else is kept as-is +// (unrelated records are never decrypted or re-encrypted) +// - markSpent ids absent from the snapshot are ignored: deriving them would +// require decrypting a record the caller doesn't hold +// - a changeset that changes nothing performs no write at all +// - caller arrays are never mutated +// - options.beforeCommit runs synchronously INSIDE the lock, immediately +// before the single write: the caller's last-chance fence (the wallet +// store revalidates persisted ownership there). Throwing aborts the +// commit with storage untouched. Four parameters are deliberate here: +// the fence is an orthogonal hook, not changeset data, and grouping it +// into the changeset would let callers persist it by accident. +// +// Returns the next local bearer list (additions first, then the snapshot +// with spent marks applied) only after the write succeeded; on any failure +// the promise rejects and persisted state is untouched. +export const applyBearerChangeset = async ( + aesKey: CryptoKey, + snapshot: Bearer[], + changeset: BearerChangeset, + options: BearerCommitOptions = {}, +): Promise => { + const now = Date.now() + const added: Bearer[] = changeset.add.map((note) => ({ + id: newBearerId(), + ...note, + createdAt: now, + updatedAt: now, + })) + const spentIds = new Set(changeset.markSpent) + const spent = new Map() + for (const bearer of snapshot) { + if (spentIds.has(bearer.id)) { + spent.set(bearer.id, {...bearer, spent: true, updatedAt: now}) + } + } + const upserted = changeset.upsert ?? [] + const removedIds = new Set(changeset.remove ?? []) + const changedById = new Map() + for (const bearer of spent.values()) changedById.set(bearer.id, bearer) + for (const bearer of upserted) changedById.set(bearer.id, bearer) + for (const bearer of added) changedById.set(bearer.id, bearer) + const changed = [...changedById.values()] + if (changed.length === 0 && removedIds.size === 0) return snapshot + const encrypted: EncryptedBearerRecord[] = [] + for (const bearer of changed) { + const {id, ...plain} = bearer + const parts = await encryptRecord(aesKey, plain) + encrypted.push({id, ...parts}) + } + await withStorageLock(BEARERS_STORAGE_KEY, () => { + options.beforeCommit?.() + const changedIds = new Set(encrypted.map((r) => r.id)) + const records = readEncryptedBearers().filter( + (record) => !changedIds.has(record.id) && !removedIds.has(record.id), + ) + records.push(...encrypted) + writeEncryptedBearers(records) + }) + const snapshotIds = new Set(snapshot.map((bearer) => bearer.id)) + const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id)) + const retained = snapshot + .filter((bearer) => !removedIds.has(bearer.id)) + .map((bearer) => changedById.get(bearer.id) ?? bearer) + return [...added, ...inserted, ...retained] +} + // wipes every bearer record from this device outright - unlike forgetting // just the linking key, this is not recoverable by restoring the same seed: // the ciphertexts themselves are gone, so only a previously downloaded @@ -91,10 +214,7 @@ export const clearAllBearers = (): void => { // same spent state, the newer updatedAt wins. This is the merge a restore // (backup file now, nostr later) applies after its records decrypt, and it // is what makes multi-device restores converge instead of duplicate. -export const mergeBearers = ( - current: Bearer[], - incoming: Bearer[] -): Bearer[] => { +export const mergeBearers = (current: Bearer[], incoming: Bearer[]): Bearer[] => { const keyOf = (b: Bearer): string => { const k1 = noteK1(b.url) return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}`