From 0cec423d6fe6edafa1d4d3a6520d2bc659906a1c Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 08:22:02 +0200 Subject: [PATCH] feat(funds): persist BIP-32 counters atomically --- src/lnurlcash/storage.changeset.cases.ts | 44 +- src/lnurlcash/storage.test.ts | 8 +- src/lnurlcash/storage.ts | 23 +- .../storage/bearers.baseline.cases.ts | 6 +- src/lnurlcash/storage/bearers.test.ts | 430 +++++++++++++++--- src/lnurlcash/storage/bearers.ts | 385 +++++++++++++--- src/lnurlcash/storageLock.test.ts | 38 +- src/lnurlcash/storageLock.ts | 20 + src/stores/walletFunds.atomic.test.ts | 143 +++++- src/stores/walletFunds.fencing.test.ts | 53 ++- src/stores/walletFunds.ts | 258 ++++++++--- 11 files changed, 1181 insertions(+), 227 deletions(-) diff --git a/src/lnurlcash/storage.changeset.cases.ts b/src/lnurlcash/storage.changeset.cases.ts index 4ca5fef..8b4cda3 100644 --- a/src/lnurlcash/storage.changeset.cases.ts +++ b/src/lnurlcash/storage.changeset.cases.ts @@ -16,6 +16,15 @@ import { } from './storage' import type {BearerChangeset} from './storage' import type {Bearer, NewBearer} from './types' +import {FUNDS_STORAGE_KEY, readFundsDocument, writeFundsDocument} from './storage/bearers' + +// mutations of the funds document require Web Locks; tests that don't care +// about lock timing install a present-but-non-serializing fake +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)}, + }) +} const LINKING_KEY = new Uint8Array(32).fill(7) const OTHER_KEY = new Uint8Array(32).fill(9) @@ -45,6 +54,7 @@ const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ describe('baseline: per-record bearer persistence', () => { it('writes once for every persist or delete call', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const writes = vi.spyOn(localStorage, 'setItem') @@ -53,13 +63,14 @@ describe('baseline: per-record bearer persistence', () => { await deleteBearerRecord('a') expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).toHaveLength(3) }) }) describe('applyBearerChangeset', () => { it('commits additions and spent replacements with exactly one write', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const oldA = bearerFixture({id: 'old-a'}) const oldB = bearerFixture({ @@ -85,7 +96,7 @@ describe('applyBearerChangeset', () => { const result = await applyBearerChangeset(key, snapshot, changeset) expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).toHaveLength(1) expect(result).toHaveLength(4) expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true]) @@ -96,12 +107,13 @@ describe('applyBearerChangeset', () => { expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual( result.map((bearer) => bearer.id).sort(), ) - const raw = localStorage.getItem('sattle_bearers') ?? '' + const raw = localStorage.getItem(FUNDS_STORAGE_KEY) ?? '' expect(raw).not.toContain(K1_C) expect(raw).not.toContain(K1_D) }) it('writes nothing when the second record encryption fails', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) await persistBearer(key, bearerFixture({id: 'kept'})) const writes = vi.spyOn(localStorage, 'setItem') @@ -124,7 +136,7 @@ describe('applyBearerChangeset', () => { ).rejects.toThrow('second encryption failed') expect(encrypt).toHaveBeenCalledTimes(2) expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).toHaveLength(0) expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept']) } finally { @@ -133,14 +145,14 @@ describe('applyBearerChangeset', () => { }) it('rejects a failed storage write without changing persisted state', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const kept = bearerFixture({id: 'kept'}) await persistBearer(key, kept) - const before = localStorage.getItem('sattle_bearers') + const before = localStorage.getItem(FUNDS_STORAGE_KEY) const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError') }) - vi.stubGlobal('navigator', {}) try { await expect( @@ -149,7 +161,7 @@ describe('applyBearerChangeset', () => { markSpent: ['kept'], }), ).rejects.toThrow('QuotaExceededError') - expect(localStorage.getItem('sattle_bearers')).toBe(before) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) } finally { write.mockRestore() vi.unstubAllGlobals() @@ -157,6 +169,7 @@ describe('applyBearerChangeset', () => { }) it('deduplicates repeated spent ids into one stored replacement', async () => { + stubPassthroughLocks() const key = await deriveBearerAesKey(LINKING_KEY) const bearer = bearerFixture({id: 'duplicate'}) await persistBearer(key, bearer) @@ -171,7 +184,7 @@ describe('applyBearerChangeset', () => { expect((await loadBearers(key))[0]?.spent).toBe(true) }) - it('encrypts before locking and preserves a fresh unrelated record', async () => { + it('fresh-reads inside the lock and preserves a fresh unrelated record', async () => { const key = await deriveBearerAesKey(LINKING_KEY) const other = await deriveBearerAesKey(OTHER_KEY) const mine = bearerFixture({id: 'mine'}) @@ -205,11 +218,12 @@ describe('applyBearerChangeset', () => { markSpent: [], }) await vi.waitFor(() => expect(queue).toHaveLength(1)) - expect(encrypted).toHaveBeenCalledTimes(1) - localStorage.setItem( - 'sattle_bearers', - JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]), - ) + // nothing is encrypted before the lock: the document is re-read fresh + // inside it, so a record another tab commits in the meantime survives + expect(encrypted).toHaveBeenCalledTimes(0) + const doc = readFundsDocument() + doc.bearers.push({id: foreignId, ...foreignParts}) + writeFundsDocument(doc) const pendingLock = queue.at(0) if (pendingLock === undefined) throw new Error('Expected pending lock') @@ -230,7 +244,7 @@ describe('applyBearerChangeset', () => { it('replaces corrupt JSON with the single committed changeset write', async () => { const key = await deriveBearerAesKey(LINKING_KEY) - localStorage.setItem('sattle_bearers', 'not json {{{') + localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{') const writes = vi.spyOn(localStorage, 'setItem') const result = await applyBearerChangeset(key, [], { @@ -240,7 +254,7 @@ describe('applyBearerChangeset', () => { expect(result).toHaveLength(1) expect( - writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), + writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY), ).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 4f00acd..eed155b 100644 --- a/src/lnurlcash/storage.test.ts +++ b/src/lnurlcash/storage.test.ts @@ -22,6 +22,7 @@ import { MAX_ACTIVITY_ENTRIES, } from './storage' import {saveLinkingKey} from './keys' +import {FUNDS_STORAGE_KEY} from './storage/bearers' import {requiredValue, stubLocalStorage} from './test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) @@ -52,7 +53,7 @@ describe('encrypted bearer records', () => { await persistBearer(key, bearer) // at rest, nothing plaintext leaks: no k1, no amounts - const raw = requiredValue(localStorage.getItem('sattle_bearers')) + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) expect(raw).not.toContain(K1_A) expect(raw).not.toContain('21000') @@ -233,19 +234,22 @@ describe('backup', () => { applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}), ).rejects.toThrow() await expect(applyBackup(null)).rejects.toThrow() + await expect(applyBackup({type: 'sattle-backup', version: 1, bearers: []})).rejects.toThrow() + // a v2 file without the counter map is not a valid backup either await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow() }) it('skips malformed records instead of failing the whole restore', async () => { const result = await applyBackup({ type: 'sattle-backup', - version: 1, + version: 2, createdAt: 1, bearers: [ {id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, {id: 42, iv: null, ciphertext: 'xx'}, 'garbage', ], + nextByHost: {}, }) expect(result.added).toBe(1) expect(result.skipped).toBe(2) diff --git a/src/lnurlcash/storage.ts b/src/lnurlcash/storage.ts index 6f53b75..579bbb9 100644 --- a/src/lnurlcash/storage.ts +++ b/src/lnurlcash/storage.ts @@ -5,8 +5,9 @@ // 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, changeset commits, -// mergeBearers +// storage/bearers.ts - the funds document (sattle_funds_v2): encrypted +// bearer records, pending journal, BIP-32 counters; +// locked single-write commit primitives, mergeBearers // storage/activityLog.ts - the append-only encrypted activity log // storage/settings.ts - plaintext wallet settings // storage/backup.ts - buildBackup / applyBackup @@ -17,14 +18,30 @@ export { compareBearerOrder, newBearerId, readEncryptedBearers, + readFundsDocument, + readFundsRevision, + readNextByHost, + readPendingJournal, loadBearers, persistBearer, deleteBearerRecord, applyBearerChangeset, + reserveCashIndices, + commitFundsRestore, clearAllBearers, mergeBearers, + FUNDS_STORAGE_KEY, +} from './storage/bearers' +export type { + BearerChangeset, + EncryptedBearerRecord, + EncryptedJournalRecord, + FundsReservation, + FundsRestoreMerge, + PendingStaging, + ReservedCashRange, + StoredFundsV2, } from './storage/bearers' -export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers' export { newActivityId, diff --git a/src/lnurlcash/storage/bearers.baseline.cases.ts b/src/lnurlcash/storage/bearers.baseline.cases.ts index 8631efe..957a0b4 100644 --- a/src/lnurlcash/storage/bearers.baseline.cases.ts +++ b/src/lnurlcash/storage/bearers.baseline.cases.ts @@ -3,6 +3,7 @@ import {buildNoteUrl} from 'lnurlcash-kit' import type {Bearer} from '../types' import {deriveBearerAesKey} from '../keys' import {deleteBearerRecord, newBearerId, persistBearer} from '../storage' +import {FUNDS_STORAGE_KEY} from './bearers' import {stubLocalStorage} from '../test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) @@ -19,11 +20,14 @@ const bearerFixture = (): Bearer => ({ describe('baseline: per-record bearer persistence', () => { it('persistBearer/deleteBearerRecord perform one write per call', async () => { const storage = stubLocalStorage() + vi.stubGlobal('navigator', { + locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)}, + }) 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) + expect(writes.mock.calls.filter(([keyName]) => keyName === FUNDS_STORAGE_KEY)).toHaveLength(3) }) }) diff --git a/src/lnurlcash/storage/bearers.test.ts b/src/lnurlcash/storage/bearers.test.ts index 91d1886..54874eb 100644 --- a/src/lnurlcash/storage/bearers.test.ts +++ b/src/lnurlcash/storage/bearers.test.ts @@ -10,15 +10,22 @@ import {buildNoteUrl} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {deriveBearerAesKey, encryptRecord} from '../keys' +import {StorageLocksUnavailableError} from '../storageLock' import { applyBearerChangeset, loadBearers, newBearerId, persistBearer, readEncryptedBearers, + reserveCashIndices, } from '../storage' import type {BearerChangeset} from '../storage' -import {writeEncryptedBearers} from './bearers' +import { + FUNDS_STORAGE_KEY, + MAX_COUNTER_HOSTS, + readFundsDocument, + writeFundsDocument, +} from './bearers' import {requiredValue, stubLocalStorage} from '../test-utils' import type {LocalStorageStub} from '../test-utils' import './bearers.baseline.cases' @@ -50,16 +57,159 @@ const newBearerFixture = (overrides: Partial = {}): NewBearer => ({ ...overrides, }) +// a present-but-non-serializing LockManager fake: enough for single-writer +// tests that must not trip the no-locks refusal +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)}, + }) +} + +type LockRequest = { + readonly name: string + readonly callback: () => unknown + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void +} + +// a controllable Web Locks fake: lock requests park until the test releases +// them, so a foreign write can interleave deterministically +class DeferredLocks { + readonly requests: LockRequest[] = [] + + readonly request = (name: string, callback: () => unknown): Promise => + new Promise((resolve, reject) => { + this.requests.push({name, callback, resolve, reject}) + }) + + async releaseNext(): Promise { + const request = this.requests.shift() + if (!request) throw new Error('Expected a queued lock request.') + try { + request.resolve(await request.callback()) + } catch (error) { + request.reject(error instanceof Error ? error : new Error(String(error))) + } + } + + readonly stub = (): void => { + vi.stubGlobal('navigator', {locks: this}) + } +} + let stub: LocalStorageStub beforeEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() stub = stubLocalStorage() }) -const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] => - spy.mock.calls.filter(([k]) => k === 'sattle_bearers') +const fundsWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] => + spy.mock.calls.filter(([k]) => k === FUNDS_STORAGE_KEY) + +describe('stored funds document', () => { + it('persists one strict v2 document holding bearers, pending, counters and revision', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'a'})) + + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const doc: unknown = JSON.parse(raw) + expect(doc).toEqual({ + version: 2, + bearers: [expect.objectContaining({id: 'a'})], + pending: [], + nextByHost: {}, + revision: 1, + }) + // strict: exactly these five keys, and the legacy bearer key is gone + expect(Object.keys(requiredValue(doc as object)).sort()).toEqual([ + 'bearers', + 'nextByHost', + 'pending', + 'revision', + 'version', + ]) + expect(localStorage.getItem('sattle_bearers')).toBeNull() + }) + + it('bumps the revision on every committed write', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'a'})) + await persistBearer(key, bearerFixture({id: 'b'})) + expect(readFundsDocument().revision).toBe(2) + }) + + it('reads work without Web Locks', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'a'})) + vi.stubGlobal('navigator', {}) + + expect(readEncryptedBearers().map((r) => r.id)).toEqual(['a']) + expect(await loadBearers(key)).toHaveLength(1) + expect(readFundsDocument().revision).toBe(1) + }) + + it('treats a malformed document as empty instead of throwing', () => { + localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify({version: 2, bearers: 'junk'})) + expect(readFundsDocument()).toEqual({ + version: 2, + bearers: [], + pending: [], + nextByHost: {}, + revision: 0, + }) + localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{') + expect(readFundsDocument().bearers).toEqual([]) + }) +}) + +describe('required locks', () => { + it('rejects a changeset mutation before any encryption when Web Locks are unavailable', async () => { + stubPassthroughLocks() + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + vi.stubGlobal('navigator', {}) + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + + await expect( + applyBearerChangeset(key, [], {add: [newBearerFixture()], markSpent: []}), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError) + + expect(encrypt).not.toHaveBeenCalled() + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) + + it('rejects per-record writes and reservations without Web Locks', async () => { + vi.stubGlobal('navigator', {}) + const key = await deriveBearerAesKey(LINKING_KEY) + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + + await expect(persistBearer(key, bearerFixture({id: 'a'}))).rejects.toBeInstanceOf( + StorageLocksUnavailableError, + ) + await expect( + reserveCashIndices(key, {host: 'mint.example', count: 1}, () => ({ + kind: 'cash-allocation', + phase: 'reserved', + payload: {}, + })), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError) + + expect(encrypt).not.toHaveBeenCalled() + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBeNull() + }) +}) describe('applyBearerChangeset (single-write changeset commit)', () => { + beforeEach(() => { + stubPassthroughLocks() + }) + it('commits additions and spent replacements with exactly one write', async () => { const key = await deriveBearerAesKey(LINKING_KEY) const oldA = bearerFixture({id: 'old-a'}) @@ -83,8 +233,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { markSpent: ['old-a', 'old-b'], }) - // the whole changeset is ONE setItem on sattle_bearers - expect(bearerWrites(writes)).toHaveLength(1) + // the whole changeset is ONE setItem on the funds document + expect(fundsWrites(writes)).toHaveLength(1) // the returned next list: additions first, then the snapshot with spent // marks applied @@ -110,7 +260,7 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { 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')) + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) expect(raw).not.toContain(K1_C) expect(raw).not.toContain(K1_D) }) @@ -133,6 +283,7 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { it('persists nothing when encryption fails', async () => { const key = await deriveBearerAesKey(LINKING_KEY) await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) // a decrypt-only key makes every AES-GCM encrypt call reject const decryptOnly = await crypto.subtle.importKey( 'raw', @@ -150,7 +301,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { }), ).rejects.toThrow() - expect(bearerWrites(writes)).toHaveLength(0) + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept']) }) @@ -158,30 +310,20 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { const key = await deriveBearerAesKey(LINKING_KEY) const kept = bearerFixture({id: 'kept'}) await persistBearer(key, kept) - const before = readEncryptedBearers() + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) 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() - } + await expect( + applyBearerChangeset(key, [kept], { + add: [newBearerFixture()], + markSpent: ['kept'], + }), + ).rejects.toThrow('QuotaExceededError') + + // nothing was persisted: the document is byte-identical + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) }) it('upserts changed ids and dedupes repeated markSpent ids', async () => { @@ -208,37 +350,19 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { 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))) - } - }, - }) - }), - }, - }) + const locks = new DeferredLocks() + locks.stub() try { const commit = applyBearerChangeset(key, [mine], { add: [newBearerFixture()], markSpent: [], }) - // encryption happens BEFORE the lock request; wait for it to arrive + // the lock request arrives before any inside-lock work await vi.waitFor(() => { - expect(queue).toHaveLength(1) + expect(locks.requests).toHaveLength(1) }) - const queuedLock = requiredValue(queue[0]) - expect(queuedLock.name).toBe('sattle_bearers') + const queuedLock = requiredValue(locks.requests[0]) + expect(queuedLock.name).toBe(FUNDS_STORAGE_KEY) // while our commit waits on the lock, another tab commits a record we // cannot even decrypt (written under a different seed's key) @@ -248,9 +372,11 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { }) const {id: foreignId, ...foreignPlain} = foreign const foreignParts = await encryptRecord(other, foreignPlain) - writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]) + const doc = readFundsDocument() + doc.bearers.push({id: foreignId, ...foreignParts}) + writeFundsDocument(doc) - await queuedLock.release() + await locks.releaseNext() const result = await commit // the foreign ciphertext survived our upsert, untouched @@ -268,26 +394,71 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { } }) + it('aborts the whole write when the inside-lock fence throws', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const writes = vi.spyOn(stub, 'setItem') + + await expect( + applyBearerChangeset( + key, + [], + {add: [newBearerFixture()], markSpent: []}, + { + beforeCommit: () => { + throw new Error('stale owner') + }, + }, + ), + ).rejects.toThrow('stale owner') + + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) + + it('clears pending journal records in the same write as the changeset', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const staged = await reserveCashIndices(key, {host: 'mint.example', count: 2}, () => ({ + kind: 'cash-allocation', + phase: 'reserved', + payload: {host: 'mint.example', start: 0, count: 2}, + })) + const writes = vi.spyOn(stub, 'setItem') + + await applyBearerChangeset(key, [], { + add: [newBearerFixture()], + markSpent: [], + clearPending: [staged.pendingId], + }) + + expect(fundsWrites(writes)).toHaveLength(1) + expect(readFundsDocument().pending).toEqual([]) + expect(readFundsDocument().nextByHost).toEqual({'mint.example': 2}) + }) + 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 {{{') + localStorage.setItem(FUNDS_STORAGE_KEY, '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 + // readFundsDocument's contract: unparseable storage reads as the empty + // document - the changeset still commits and its single write replaces + // the corrupt blob with a valid one expect(result).toHaveLength(1) expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id]) + expect(readFundsDocument().version).toBe(2) }) 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 before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) const writes = vi.spyOn(stub, 'setItem') // 'foreign' is not in the caller's snapshot; deriving its spent copy @@ -299,7 +470,148 @@ describe('applyBearerChangeset (single-write changeset commit)', () => { }) expect(result).toEqual([]) - expect(bearerWrites(writes)).toHaveLength(0) - expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign']) + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) +}) + +describe('reserveCashIndices (counter reservation plus staging)', () => { + beforeEach(() => { + stubPassthroughLocks() + }) + + const stage = (reserved: {host: string; start: number; count: number}) => ({ + kind: 'cash-allocation', + phase: 'reserved', + payload: reserved, + }) + + it('reserves a range and stages one encrypted pending record in a single write', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(stub, 'setItem') + + const reserved = await reserveCashIndices(key, {host: 'mint.example', count: 3}, stage) + + expect(reserved).toEqual({ + host: 'mint.example', + start: 0, + count: 3, + pendingId: expect.any(String), + }) + expect(fundsWrites(writes)).toHaveLength(1) + const doc = readFundsDocument() + expect(doc.nextByHost).toEqual({'mint.example': 3}) + expect(doc.revision).toBe(1) + expect(doc.pending).toHaveLength(1) + const record = requiredValue(doc.pending[0]) + expect(record.id).toBe(reserved.pendingId) + expect(record.kind).toBe('cash-allocation') + expect(record.phase).toBe('reserved') + expect(typeof record.iv).toBe('string') + expect(typeof record.ciphertext).toBe('string') + // the staged payload is ciphertext-only at rest + const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + expect(raw).not.toContain('"start":0') + }) + + it('hands two same-host tabs disjoint ranges under a contended lock', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const locks = new DeferredLocks() + locks.stub() + + // both tabs request the same lock; B's callback must not run (and must + // not fresh-read) until A's reservation has fully landed + const tabA = reserveCashIndices(key, {host: 'mint.example', count: 3}, stage) + const tabB = reserveCashIndices(key, {host: 'mint.example', count: 2}, stage) + await vi.waitFor(() => expect(locks.requests).toHaveLength(2)) + + await locks.releaseNext() + // A committed; B is still parked on the lock with no counter consumed + expect(readFundsDocument().nextByHost).toEqual({'mint.example': 3}) + await locks.releaseNext() + + const [a, b] = await Promise.all([tabA, tabB]) + expect(a.start).toBe(0) + expect(b.start).toBe(3) + const doc = readFundsDocument() + expect(doc.nextByHost).toEqual({'mint.example': 5}) + expect(doc.pending).toHaveLength(2) + }) + + it('rejects counter overflow without writing or encrypting', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const doc = readFundsDocument() + doc.nextByHost['mint.example'] = 2 ** 31 - 2 + writeFundsDocument(doc) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const writes = vi.spyOn(stub, 'setItem') + const encrypt = vi.spyOn(crypto.subtle, 'encrypt') + + await expect( + reserveCashIndices(key, {host: 'mint.example', count: 3}, stage), + ).rejects.toThrow(/exhausted|overflow/i) + + expect(fundsWrites(writes)).toHaveLength(0) + expect(encrypt).not.toHaveBeenCalled() + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + }) + + it('rejects an overlong host and an eleventh counter host', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const before = localStorage.getItem(FUNDS_STORAGE_KEY) + + await expect( + reserveCashIndices(key, {host: `${'h'.repeat(253)}.example`, count: 1}, stage), + ).rejects.toThrow(/host/i) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + + const doc = readFundsDocument() + for (let i = 0; i < MAX_COUNTER_HOSTS; i += 1) { + doc.nextByHost[`mint-${i}.example`] = 1 + } + writeFundsDocument(doc) + const filled = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + + await expect( + reserveCashIndices(key, {host: 'mint-overflow.example', count: 1}, stage), + ).rejects.toThrow(/host/i) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(filled) + }) + + it('burns no index and writes nothing when the staging encryption fails', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + await persistBearer(key, bearerFixture({id: 'kept'})) + const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY)) + const decryptOnly = await crypto.subtle.importKey( + 'raw', + new Uint8Array(32).fill(3), + 'AES-GCM', + false, + ['decrypt'], + ) + + await expect( + reserveCashIndices(decryptOnly, {host: 'mint.example', count: 2}, stage), + ).rejects.toThrow() + + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before) + expect(readFundsDocument().nextByHost).toEqual({}) + expect(readFundsDocument().pending).toEqual([]) + }) + + it('writes nothing when the inside-lock fence throws before reservation', async () => { + const key = await deriveBearerAesKey(LINKING_KEY) + const writes = vi.spyOn(stub, 'setItem') + + await expect( + reserveCashIndices(key, {host: 'mint.example', count: 1}, stage, { + beforeCommit: () => { + throw new Error('stale owner') + }, + }), + ).rejects.toThrow('stale owner') + + expect(fundsWrites(writes)).toHaveLength(0) + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBeNull() }) }) diff --git a/src/lnurlcash/storage/bearers.ts b/src/lnurlcash/storage/bearers.ts index 8e9cdf4..4c1ee6e 100644 --- a/src/lnurlcash/storage/bearers.ts +++ b/src/lnurlcash/storage/bearers.ts @@ -1,13 +1,45 @@ -// Encrypted bearer-note persistence: each note is an AES-GCM ciphertext -// record under a key derived from the linking key (see keys.ts), so a note -// URL - which IS the money - never touches disk in plaintext. +// allow: SIZE_OK — one document, one lock, one write path; splitting the +// schema from its commit primitive would invite drift between them. +// +// The funds document: every fund-critical fact lives in ONE localStorage +// value, `sattle_funds_v2` - encrypted bearer records, the pending-mutation +// journal (opaque encrypted records; the per-flow taxonomy is defined by the +// recovery seam, not here), the per-host BIP-32 next-unused cash indices, and +// a monotonic revision. A note URL - which IS the money - never touches disk +// in plaintext: bearers and journal payloads are AES-GCM ciphertexts under a +// key derived from the linking key (see keys.ts). +// +// Why one document: a counter bump without its staged outputs (or vice +// versa) is how indices get burned twice or fresh secrets get lost. Because +// everything shares one key, one locked fresh-read…write commits or fails as +// a unit, and one getItem is a consistent snapshot for backups. +// +// Mutation discipline (fund-critical, non-negotiable): +// - EVERY mutation runs inside withRequiredStorageLock on the document key: +// fresh-read the document, re-validate, run the caller's owner fence, +// reserve counter indices, derive/encrypt, then ONE setItem. There is no +// unlocked fallback - without Web Locks a second tab could interleave a +// fresh-read…write and burn the same BIP-32 index twice, so mutations fail +// closed (StorageLocksUnavailableError) before any derivation, encryption, +// or I/O. Reads never need the lock. +// - encryption happens INSIDE the lock: the staged payload depends on the +// reserved range, which only exists once the counter bump is serialized. +// Web Locks callbacks may be async; holding the lock across crypto costs +// latency but never correctness, and the alternative (encrypt first, +// re-read later) cannot express counter reservation at all. +// - any failure (crypto, quota, fence, overflow) rejects with the document +// bytes untouched and zero network I/O performed. import type {EncryptedRecordParts} from '../keys' import {encryptRecord, decryptRecord} from '../keys' import type {Bearer, NewBearer} from '../types' import {isJsonObject} from '../jsonParsing' import {noteK1, serverOf} from 'lnurlcash-kit' -import {withStorageLock} from '../storageLock' +import { + StorageLocksUnavailableError, + storageLocksAvailable, + withRequiredStorageLock, +} from '../storageLock' // the wallet's default note order (newest first) with manually dragged // notes taking priority once they have an explicit rank @@ -16,12 +48,48 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number => export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts +// the forward-compatible pending-mutation journal slot: an opaque encrypted +// payload behind a kind/phase pair. The per-flow variants (rotate, split, +// merge, mint, melt-return, transfer) are defined by the recovery seam; this +// layer only guarantees they persist atomically with everything else. +export type EncryptedJournalRecord = { + id: string + kind: string + phase: string +} & EncryptedRecordParts + +export const FUNDS_STORAGE_KEY = 'sattle_funds_v2' +export const FUNDS_DOCUMENT_VERSION = 2 as const + +// counter bounds: BIP-32 indices stay below the hardened offset; the host +// map stays small enough that a corrupt or hostile restore cannot exhaust +// quota or smuggle unbounded state (253 = DNS name ceiling) +export const MAX_CASH_INDEX = 2 ** 31 +export const MAX_COUNTER_HOSTS = 10 +export const MAX_COUNTER_HOST_LENGTH = 253 + +export type StoredFundsV2 = { + version: typeof FUNDS_DOCUMENT_VERSION + bearers: EncryptedBearerRecord[] + pending: EncryptedJournalRecord[] + nextByHost: Record + revision: number +} + const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord => isJsonObject(value) && typeof value.id === 'string' && typeof value.iv === 'string' && typeof value.ciphertext === 'string' +const isEncryptedJournalRecord = (value: unknown): value is EncryptedJournalRecord => + isEncryptedBearerRecord(value) && + typeof (value as EncryptedJournalRecord).kind === 'string' && + typeof (value as EncryptedJournalRecord).phase === 'string' + +const isValidCounter = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value < MAX_CASH_INDEX + const isStoredBearer = (value: unknown): value is Omit => isJsonObject(value) && typeof value.url === 'string' && @@ -34,32 +102,104 @@ const isStoredBearer = (value: unknown): value is Omit => (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' + (value.deviceId === undefined || typeof value.deviceId === 'string') && + (value.pendingMint === undefined || + (isJsonObject(value.pendingMint) && + (value.pendingMint.sourceBearerId === undefined || + typeof value.pendingMint.sourceBearerId === 'string') && + (value.pendingMint.sourceRecoverySecret === undefined || + typeof value.pendingMint.sourceRecoverySecret === 'string') && + (value.pendingMint.mintPubkey === undefined || + typeof value.pendingMint.mintPubkey === 'string') && + (value.pendingMint.retireAfter === undefined || + (typeof value.pendingMint.retireAfter === 'number' && + Number.isSafeInteger(value.pendingMint.retireAfter) && + value.pendingMint.retireAfter > 0)))) export const newBearerId = (): string => Array.from(crypto.getRandomValues(new Uint8Array(8))) .map((b) => b.toString(16).padStart(2, '0')) .join('') -export const readEncryptedBearers = (): EncryptedBearerRecord[] => { - const raw = localStorage.getItem(BEARERS_STORAGE_KEY) - if (!raw) return [] - try { - const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : [] - } catch { - return [] +const emptyFundsDocument = (): StoredFundsV2 => ({ + version: FUNDS_DOCUMENT_VERSION, + bearers: [], + pending: [], + nextByHost: {}, + revision: 0, +}) + +// strict shape, tolerant read: anything that is not a well-formed v2 +// document reads as the empty document (the long-standing "never throw on +// read" contract - a corrupt blob must not wedge the wallet), and malformed +// entries inside a valid document are dropped, never thrown on +const parseFundsDocument = (value: unknown): StoredFundsV2 | null => { + if (!isJsonObject(value) || value.version !== FUNDS_DOCUMENT_VERSION) return null + if (!Array.isArray(value.bearers) || !Array.isArray(value.pending)) return null + if (!isJsonObject(value.nextByHost)) return null + if ( + typeof value.revision !== 'number' || + !Number.isSafeInteger(value.revision) || + value.revision < 0 + ) { + return null + } + const nextByHost: Record = {} + for (const [host, next] of Object.entries(value.nextByHost)) { + if (host.length === 0 || host.length > MAX_COUNTER_HOST_LENGTH || !isValidCounter(next)) { + continue + } + nextByHost[host] = next + } + return { + version: FUNDS_DOCUMENT_VERSION, + bearers: value.bearers.filter(isEncryptedBearerRecord), + pending: value.pending.filter(isEncryptedJournalRecord), + nextByHost, + revision: value.revision, } } -export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => { - localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records)) +export const readFundsDocument = (): StoredFundsV2 => { + const raw = localStorage.getItem(FUNDS_STORAGE_KEY) + if (!raw) return emptyFundsDocument() + try { + return parseFundsDocument(JSON.parse(raw)) ?? emptyFundsDocument() + } catch { + return emptyFundsDocument() + } } +export const writeFundsDocument = (doc: StoredFundsV2): void => { + localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify(doc)) +} + +// COMPAT shim for the pre-counter backup path (storage/backup.ts before the +// backup-projection commit): replaces the document's bearer array wholesale, +// preserving pending journal and counters. Deleted together with that path's +// last call site; new code writes through the locked commit primitives only. +export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => { + const doc = readFundsDocument() + doc.bearers = records + writeFundsDocument(doc) +} + +export const readEncryptedBearers = (): EncryptedBearerRecord[] => readFundsDocument().bearers + +export const readPendingJournal = (): EncryptedJournalRecord[] => readFundsDocument().pending + +export const readNextByHost = (): Record => ({...readFundsDocument().nextByHost}) + +export const readFundsRevision = (): number => readFundsDocument().revision + type BearerCommitOptions = {beforeCommit?: () => void} +// mutations have no unlocked fallback: reject BEFORE any derivation or +// encryption when cross-tab serialization cannot be guaranteed +const requireMutationLocks = (): void => { + if (!storageLocksAvailable()) throw new StorageLocksUnavailableError() +} + // 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 @@ -83,13 +223,16 @@ export const persistBearer = async ( bearer: Bearer, options: BearerCommitOptions = {}, ): Promise => { + requireMutationLocks() const {id, ...plain} = bearer - const parts = await encryptRecord(aesKey, plain) - await withStorageLock(BEARERS_STORAGE_KEY, () => { + await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => { + const doc = readFundsDocument() options.beforeCommit?.() - const records = readEncryptedBearers().filter((r) => r.id !== id) - records.push({id, ...parts}) - writeEncryptedBearers(records) + const parts = await encryptRecord(aesKey, plain) + doc.bearers = doc.bearers.filter((r) => r.id !== id) + doc.bearers.push({id, ...parts}) + doc.revision += 1 + writeFundsDocument(doc) }) } @@ -97,51 +240,49 @@ export const deleteBearerRecord = async ( id: string, options: BearerCommitOptions = {}, ): Promise => { - await withStorageLock(BEARERS_STORAGE_KEY, () => { + requireMutationLocks() + await withRequiredStorageLock(FUNDS_STORAGE_KEY, () => { + const doc = readFundsDocument() options.beforeCommit?.() - writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id)) + doc.bearers = doc.bearers.filter((r) => r.id !== id) + doc.revision += 1 + writeFundsDocument(doc) }) } // 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. +// never the wallet's money in a trackable state. clearPending removes +// pending-journal records (by id) in the SAME write, which is how a +// finalized staged mutation retires its journal entry atomically with the +// bearer changes that settle it. export type BearerChangeset = { add: NewBearer[] markSpent: string[] upsert?: Bearer[] remove?: string[] + clearPending?: string[] } -// Commits a whole changeset as ONE storage write - the fund-critical +// Commits a whole changeset as ONE funds-document 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: +// success. 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) +// - the caller's snapshot is only a PLAN: inside the lock the document is +// re-read FRESH, so records another tab committed after the snapshot +// survive the upsert - changed ids replace their stored copy, everything +// else is kept as-is (unrelated records are never decrypted or +// re-encrypted) +// - added notes get their id/timestamps assigned HERE (so state and storage +// can never disagree about them), spent marks copy the snapshot's record +// - the owner fence (options.beforeCommit) runs INSIDE the lock after the +// fresh read and before any encryption: throwing aborts the commit with +// the document untouched // - 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 @@ -168,26 +309,33 @@ export const applyBearerChangeset = async ( } const upserted = changeset.upsert ?? [] const removedIds = new Set(changeset.remove ?? []) + const clearPendingIds = new Set(changeset.clearPending ?? []) 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, () => { + if (changed.length === 0 && removedIds.size === 0 && clearPendingIds.size === 0) return snapshot + requireMutationLocks() + await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => { + const doc = readFundsDocument() options.beforeCommit?.() + const encrypted: EncryptedBearerRecord[] = [] + for (const bearer of changed) { + const {id, ...plain} = bearer + const parts = await encryptRecord(aesKey, plain) + encrypted.push({id, ...parts}) + } const changedIds = new Set(encrypted.map((r) => r.id)) - const records = readEncryptedBearers().filter( + doc.bearers = doc.bearers.filter( (record) => !changedIds.has(record.id) && !removedIds.has(record.id), ) - records.push(...encrypted) - writeEncryptedBearers(records) + doc.bearers.push(...encrypted) + if (clearPendingIds.size > 0) { + doc.pending = doc.pending.filter((record) => !clearPendingIds.has(record.id)) + } + doc.revision += 1 + writeFundsDocument(doc) }) const snapshotIds = new Set(snapshot.map((bearer) => bearer.id)) const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id)) @@ -197,12 +345,131 @@ export const applyBearerChangeset = async ( return [...added, ...inserted, ...retained] } +// ---- counter reservation ---- + +export type FundsReservation = {host: string; count: number} +export type ReservedCashRange = {host: string; start: number; count: number} + +// what the caller wants staged for the reserved range - the payload is +// opaque to this layer (encrypted as-is); kind/phase let the recovery seam +// route it later +export type PendingStaging = {kind: string; phase: string; payload: object} + +const assertValidReservation = ({host, count}: FundsReservation): void => { + if (typeof host !== 'string' || host.length === 0 || host.length > MAX_COUNTER_HOST_LENGTH) { + throw new Error(`A counter host is a canonical server name of at most ${MAX_COUNTER_HOST_LENGTH} characters.`) + } + if (!Number.isSafeInteger(count) || count < 1 || count >= MAX_CASH_INDEX) { + throw new Error('A reservation count is a positive safe integer below 2^31.') + } +} + +// Reserves `count` fresh BIP-32 indices for a canonical host and stages one +// encrypted pending-journal record for them - allocation plus staging in ONE +// locked write, before any mutation may reach a mint. The reserved range is +// only known once the counter bump is serialized, so the caller's `stage` +// callback runs INSIDE the lock with the assigned range (that is where the +// secrets for [start, start+count) get derived and encrypted into the +// payload). Burned gaps are never reused: a crash after this commit leaves +// the counter advanced, which is safe - reuse never is. +// +// Rejects - with the document bytes untouched and no encryption performed - +// on a stale owner fence, an overlong host, an eleventh counter host, and +// counter exhaustion at the 2^31 BIP-32 ceiling. +export const reserveCashIndices = async ( + aesKey: CryptoKey, + reservation: FundsReservation, + stage: (reserved: ReservedCashRange) => PendingStaging, + options: BearerCommitOptions = {}, +): Promise => { + assertValidReservation(reservation) + requireMutationLocks() + const {host, count} = reservation + return withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => { + const doc = readFundsDocument() + options.beforeCommit?.() + const start = doc.nextByHost[host] ?? 0 + if (doc.nextByHost[host] === undefined) { + if (Object.keys(doc.nextByHost).length >= MAX_COUNTER_HOSTS) { + throw new Error( + `The wallet already tracks counters for ${MAX_COUNTER_HOSTS} mint hosts; no room for another.`, + ) + } + } + if (start + count >= MAX_CASH_INDEX) { + throw new Error('The cash index space for this mint host is exhausted.') + } + const staged = stage({host, start, count}) + if (typeof staged.kind !== 'string' || typeof staged.phase !== 'string') { + throw new Error('A staged pending record needs string kind and phase.') + } + const parts = await encryptRecord(aesKey, staged.payload) + const pendingId = newBearerId() + doc.pending.push({id: pendingId, kind: staged.kind, phase: staged.phase, ...parts}) + doc.nextByHost[host] = start + count + doc.revision += 1 + writeFundsDocument(doc) + return {host, start, count, pendingId} + }) +} + +export type FundsRestoreMerge = {added: number; skipped: number} + +// The funds half of a backup restore, as one locked write: bearer records +// union by id (already-present ids are left as-is, never overwritten) and +// counters merge upward-only - max(local, incoming) per host, so a stale +// backup can never rewind a counter and reopen burned indices. Local hosts +// always keep their slot; incoming hosts beyond the counter cap are skipped. +// Invalid incoming entries are skipped, never thrown on (same policy as the +// bearer records). Pending journal state is device-local and never part of +// a backup, so it is not a parameter here at all. +export const commitFundsRestore = async ( + bearersToMerge: EncryptedBearerRecord[], + countersToMerge: Record, +): Promise => { + const counters = Object.entries(countersToMerge).filter( + ([host, next]) => + host.length > 0 && host.length <= MAX_COUNTER_HOST_LENGTH && isValidCounter(next), + ) + if (bearersToMerge.length === 0 && counters.length === 0) { + return {added: 0, skipped: 0} + } + requireMutationLocks() + return withRequiredStorageLock(FUNDS_STORAGE_KEY, () => { + const doc = readFundsDocument() + const existingIds = new Set(doc.bearers.map((r) => r.id)) + let added = 0 + let skipped = 0 + for (const record of bearersToMerge) { + if (!isEncryptedBearerRecord(record) || existingIds.has(record.id)) { + skipped++ + continue + } + doc.bearers.push({id: record.id, iv: record.iv, ciphertext: record.ciphertext}) + existingIds.add(record.id) + added++ + } + for (const [host, next] of counters) { + const local = doc.nextByHost[host] + if (local === undefined) { + if (Object.keys(doc.nextByHost).length >= MAX_COUNTER_HOSTS) continue + doc.nextByHost[host] = next + } else if (next > local) { + doc.nextByHost[host] = next + } + } + doc.revision += 1 + writeFundsDocument(doc) + return {added, skipped} + }) +} + // 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 // backup file can bring them back export const clearAllBearers = (): void => { - localStorage.removeItem(BEARERS_STORAGE_KEY) + localStorage.removeItem(FUNDS_STORAGE_KEY) } // Merge two decrypted bearer lists into one, keyed by note identity diff --git a/src/lnurlcash/storageLock.test.ts b/src/lnurlcash/storageLock.test.ts index a6a9afb..f976c44 100644 --- a/src/lnurlcash/storageLock.test.ts +++ b/src/lnurlcash/storageLock.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from 'vitest' -import {withStorageLock} from './storageLock' +import {StorageLocksUnavailableError, withRequiredStorageLock, withStorageLock} from './storageLock' describe('withStorageLock fallback', () => { it('runs unlocked when Web Locks are unavailable without promising serialization', async () => { @@ -28,3 +28,39 @@ describe('withStorageLock fallback', () => { } }) }) + +describe('withRequiredStorageLock', () => { + it('rejects before running the callback when Web Locks are unavailable', async () => { + vi.stubGlobal('navigator', {}) + let ran = false + try { + await expect( + withRequiredStorageLock('funds', () => { + ran = true + }), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError) + expect(ran).toBe(false) + } finally { + vi.unstubAllGlobals() + } + }) + + it('serializes same-name callbacks across concurrent callers when locks exist', async () => { + const entered: string[] = [] + let releaseFirst: (() => void) | undefined + const first = withRequiredStorageLock('funds', async () => { + entered.push('first') + await new Promise((resolve) => { + releaseFirst = resolve + }) + }) + const second = withRequiredStorageLock('funds', () => { + entered.push('second') + }) + + await vi.waitFor(() => expect(entered).toEqual(['first'])) + releaseFirst?.() + await Promise.all([first, second]) + expect(entered).toEqual(['first', 'second']) + }) +}) diff --git a/src/lnurlcash/storageLock.ts b/src/lnurlcash/storageLock.ts index 16ff91b..c5024fc 100644 --- a/src/lnurlcash/storageLock.ts +++ b/src/lnurlcash/storageLock.ts @@ -13,3 +13,23 @@ export const withStorageLock = (name: string, fn: () => T | Promise): Prom export const storageLocksAvailable = (): boolean => typeof navigator !== 'undefined' && navigator.locks !== undefined + +// fund-critical mutations have no fallback: without Web Locks a second tab +// could interleave its own fresh-read…write between this tab's read and +// write and burn the same BIP-32 indices twice (or resurrect a spent note). +// So the funds document refuses to mutate at all where serialization cannot +// be guaranteed - reads stay available, writes fail closed before any key +// derivation, encryption, or network work has happened. +export class StorageLocksUnavailableError extends Error { + override readonly name = 'StorageLocksUnavailableError' + + constructor() { + super('This browser cannot guarantee exclusive storage access (Web Locks unavailable).') + } +} + +export const withRequiredStorageLock = (name: string, fn: () => T | Promise): Promise => { + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined + if (!locks) return Promise.reject(new StorageLocksUnavailableError()) + return locks.request(name, () => Promise.resolve().then(fn)) +} diff --git a/src/stores/walletFunds.atomic.test.ts b/src/stores/walletFunds.atomic.test.ts index 0c989b2..5f53fae 100644 --- a/src/stores/walletFunds.atomic.test.ts +++ b/src/stores/walletFunds.atomic.test.ts @@ -1,13 +1,25 @@ import { createPinia, setActivePinia } from 'pinia'; -import { buildNoteUrl } from 'lnurlcash-kit'; +import { buildNoteUrl, deriveCashRoot, deriveCashSecret } from 'lnurlcash-kit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveBearerAesKey } from '@/lnurlcash/keys'; -import { loadBearers } from '@/lnurlcash/storage'; +import { loadBearers, readFundsDocument } from '@/lnurlcash/storage'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; +import { StorageLocksUnavailableError } from '@/lnurlcash/storageLock'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { useWalletStore } from './wallet'; +const CASH_ROOT = deriveCashRoot(new Uint8Array(64).fill(1)); + +// a present-but-non-serializing LockManager fake: walletFunds serializes its +// own mutations, so single-store tests only need locks to EXIST +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + const note = (secret: string): NewBearer => ({ url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), callback: 'https://mint.example/w/cb', @@ -29,12 +41,45 @@ const rejectSecondEncryption = (): void => { beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); stubLocalStorage(); setActivePinia(createPinia()); }); describe('wallet multi-bearer durability', () => { + it('keeps both concurrent additions in reactive and persisted state', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const encrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let releaseFirst: (() => void) | undefined; + const firstPaused = new Promise((resolve) => { + releaseFirst = resolve; + }); + let firstStarted: (() => void) | undefined; + const firstEntered = new Promise((resolve) => { + firstStarted = resolve; + }); + let encryptions = 0; + vi.spyOn(crypto.subtle, 'encrypt').mockImplementation(async (algorithm, key, data) => { + encryptions += 1; + if (encryptions === 1) { + firstStarted?.(); + await firstPaused; + } + return encrypt(algorithm, key, data); + }); + + const first = wallet.addBearers([note('a')], wallet.captureOwnerFence()); + await firstEntered; + const second = wallet.addBearers([note('b')], wallet.captureOwnerFence()); + releaseFirst?.(); + await Promise.all([first, second]); + + expect(wallet.bearers).toHaveLength(2); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toHaveLength(2); + }); + it('adds two bearers with one established changeset write', async () => { const storage = stubLocalStorage(); const wallet = useWalletStore(); @@ -43,7 +88,7 @@ describe('wallet multi-bearer durability', () => { await wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()); - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); expect(wallet.bearers).toHaveLength(2); }); @@ -105,7 +150,95 @@ describe('wallet multi-bearer durability', () => { await wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence()); - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); expect(wallet.bearers).toHaveLength(3); }); }); + +describe('wallet BIP-32 counter allocation', () => { + it('reserves a range, derives its secrets, and stages the journal record in one write', async () => { + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const revisionBefore = wallet.fundsRevision; + const writes = vi.spyOn(storage, 'setItem'); + + const allocated = await wallet.allocateCashSecrets( + CASH_ROOT, + 'mint.example', + 3, + wallet.captureOwnerFence(), + ); + + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); + expect(allocated.host).toBe('mint.example'); + expect(allocated.start).toBe(0); + expect(allocated.secrets).toEqual([ + deriveCashSecret(CASH_ROOT, 'mint.example', 0), + deriveCashSecret(CASH_ROOT, 'mint.example', 1), + deriveCashSecret(CASH_ROOT, 'mint.example', 2), + ]); + const doc = readFundsDocument(); + expect(doc.nextByHost).toEqual({ 'mint.example': 3 }); + expect(doc.pending).toHaveLength(1); + expect(doc.pending[0]?.id).toBe(allocated.pendingId); + expect(doc.pending[0]?.kind).toBe('cash-allocation'); + // counter-only commits are observable to the nostr-backup scheduler + expect(wallet.fundsRevision).toBeGreaterThan(revisionBefore); + // the staged secrets never touch disk in plaintext + const raw = storage.getItem(FUNDS_STORAGE_KEY) ?? ''; + for (const secret of allocated.secrets) expect(raw).not.toContain(secret); + }); + + it('never hands out the same index twice across sequential reservations', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const fence = wallet.captureOwnerFence(); + + const first = await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, fence); + const second = await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, fence); + const otherHost = await wallet.allocateCashSecrets(CASH_ROOT, 'other.example:9735', 1, fence); + + expect(first.start).toBe(0); + expect(second.start).toBe(2); + expect(second.secrets[0]).toBe(deriveCashSecret(CASH_ROOT, 'mint.example', 2)); + // a different canonical host (port included) has its own counter + expect(otherHost.start).toBe(0); + expect(readFundsDocument().nextByHost).toEqual({ + 'mint.example': 4, + 'other.example:9735': 1, + }); + }); + + it('burns no index and writes nothing when the staging encryption fails', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const before = readFundsDocument().revision; + // the reservation performs exactly one encryption (the staged payload) - + // reject them all + vi.spyOn(crypto.subtle, 'encrypt').mockRejectedValue(new Error('crypto subsystem died')); + + await expect( + wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, wallet.captureOwnerFence()), + ).rejects.toThrow('crypto subsystem died'); + + const doc = readFundsDocument(); + expect(doc.nextByHost).toEqual({}); + expect(doc.pending).toEqual([]); + expect(doc.revision).toBe(before); + }); + + it('rejects the allocation before derivation when Web Locks are unavailable', async () => { + const wallet = useWalletStore(); + await wallet.create(); + vi.stubGlobal('navigator', {}); + const encrypt = vi.spyOn(crypto.subtle, 'encrypt'); + + await expect( + wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 1, wallet.captureOwnerFence()), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError); + + expect(encrypt).not.toHaveBeenCalled(); + expect(readFundsDocument().nextByHost).toEqual({}); + }); +}); diff --git a/src/stores/walletFunds.fencing.test.ts b/src/stores/walletFunds.fencing.test.ts index 3da2409..000cf4c 100644 --- a/src/stores/walletFunds.fencing.test.ts +++ b/src/stores/walletFunds.fencing.test.ts @@ -3,14 +3,24 @@ import { buildNoteUrl } from 'lnurlcash-kit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deriveBearerAesKey, linkingPubKeyHex } from '@/lnurlcash/keys'; -import { loadBearers, readEncryptedBearers } from '@/lnurlcash/storage'; +import { loadBearers, readEncryptedBearers, readFundsDocument } from '@/lnurlcash/storage'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner'; +import { StorageLocksUnavailableError } from '@/lnurlcash/storageLock'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { NewBearer } from '@/lnurlcash/types'; import { useWalletStore } from './wallet'; const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); +// fund commits REQUIRE Web Locks now; tests that don't care about lock +// timing install a present-but-non-serializing fake +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + const note = (secret: string): NewBearer => ({ url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), callback: 'https://mint.example/w/cb', @@ -71,7 +81,7 @@ beforeEach(() => { describe('stale-owner fencing of fund commits', () => { it('fences a changeset commit after a silent owner replacement', async () => { // Given an unlocked wallet with one bearer and no pending storage event - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const ownerFence = wallet.captureOwnerFence(); @@ -93,7 +103,7 @@ describe('stale-owner fencing of fund commits', () => { it('fences a single-record spent mark after a silent owner replacement', async () => { // Given an unlocked wallet whose owner was silently replaced - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const ownerFence = wallet.captureOwnerFence(); @@ -114,7 +124,7 @@ describe('stale-owner fencing of fund commits', () => { it('revalidates the owner inside the commit lock, after encryption', async () => { // Given a changeset commit whose storage write is held at the lock - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const ownerFence = wallet.captureOwnerFence(); @@ -143,7 +153,7 @@ describe('stale-owner fencing of fund commits', () => { it('rejects a fence captured by an earlier lifecycle of the same owner', async () => { // Given an operation accepted before the encrypted wallet locked and // unlocked again under the same persisted owner - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create('password'); const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); @@ -164,7 +174,7 @@ describe('stale-owner fencing of fund commits', () => { }); it('revalidates a spent update inside its persistence lock', async () => { - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); @@ -185,7 +195,7 @@ describe('stale-owner fencing of fund commits', () => { }); it('revalidates a deletion inside its persistence lock', async () => { - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); const wallet = useWalletStore(); await wallet.create(); const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); @@ -203,4 +213,33 @@ describe('stale-owner fencing of fund commits', () => { expect(readEncryptedBearers()).toHaveLength(1); expect(wallet.bearers).toHaveLength(1); }); + + it('refuses a fund commit entirely when Web Locks are unavailable', async () => { + // Given an unlocked wallet with one bearer, in a browser without Web Locks + stubPassthroughLocks(); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([note('aa')], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + const bytesBefore = localStorage.getItem(FUNDS_STORAGE_KEY); + + // When the environment cannot serialize storage access across tabs + vi.stubGlobal('navigator', {}); + + // Then every mutation rejects before derivation, and the document is + // byte-identical - a no-lock commit could burn a BIP-32 index twice + await expect( + wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence), + ).rejects.toBeInstanceOf(StorageLocksUnavailableError); + await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf( + StorageLocksUnavailableError, + ); + await expect(wallet.removeNote(existing.id, ownerFence)).rejects.toBeInstanceOf( + StorageLocksUnavailableError, + ); + expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(bytesBefore); + expect(readFundsDocument().revision).toBe(1); + expect(wallet.bearers[0]?.spent).toBeUndefined(); + }); }); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index 32ac47b..bd97251 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -1,17 +1,21 @@ +// allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. import { computed, ref } from 'vue'; -import { serverOf } from 'lnurlcash-kit'; +import { deriveCashSecret, serverOf } from 'lnurlcash-kit'; +import type { CashNode } from 'lnurlcash-kit'; import { applyBearerChangeset, deleteBearerRecord, - loadBearers, mergeBearers, persistBearer, + readFundsRevision, + reserveCashIndices, } 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 { CarveResult } from '@/lnurlcash/ops'; import type { WalletOwnerFence } from './walletOwnerFence'; export class TrustedMintPostCommitError extends Error { @@ -29,6 +33,24 @@ export class TrustedMintPostCommitError extends Error { } } +export class BearerNotFoundError extends Error { + override readonly name = 'BearerNotFoundError'; + + constructor() { + super('The wallet record no longer exists.'); + } +} + +// the result of one atomic counter reservation: the assigned BIP-32 index +// range, the secrets derived at those indices, and the staged journal record +// that makes the reservation recoverable after a crash +export type AllocatedCashSecrets = { + readonly host: string; + readonly start: number; + readonly secrets: string[]; + readonly pendingId: string; +}; + type WalletFundsOptions = { readonly requireKey: () => CryptoKey; readonly ownerId: () => string | undefined; @@ -37,6 +59,25 @@ type WalletFundsOptions = { export const createWalletFunds = (options: WalletFundsOptions) => { const bearers = ref([]); + // reactive mirror of the persisted document revision: counter-only commits + // (reservations) don't touch the bearer list, so nostr backup scheduling + // watches this instead of inferring change from the notes alone + const fundsRevision = ref(0); + const syncRevision = (): void => { + fundsRevision.value = readFundsRevision(); + }; + let mutationTail: Promise = Promise.resolve(); + const mutate = (operation: () => Promise): Promise => { + const result = mutationTail.then(operation, operation); + mutationTail = result.then( + () => undefined, + () => undefined, + ); + // a post-commit failure (trusted-mint update) still bumped the persisted + // revision, so refresh on settle, not just on success + void result.then(syncRevision, syncRevision); + return result; + }; const unspentBearers = computed(() => bearers.value.filter((bearer) => !bearer.spent)); const balanceMsat = computed(() => unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0), @@ -60,10 +101,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => { const replace = (loaded: Bearer[]): void => { bearers.value = loaded; + syncRevision(); }; const clear = (): void => { bearers.value = []; + fundsRevision.value = 0; }; const lockCommittedBearers = async (committed: Bearer[]): Promise => { @@ -81,63 +124,87 @@ export const createWalletFunds = (options: WalletFundsOptions) => { } }; - 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 addBearers = async (notes: NewBearer[], ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + 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, + ): Promise => + mutate(async () => { + 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 commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + const existing = bearers.value.find((bearer) => bearer.url === carve.note.url); + const additions: NewBearer[] = []; + if (!existing) additions.push(carve.note); + if (carve.change) additions.push(carve.change); + ownerFence(); + const next = await applyBearerChangeset( + options.requireKey(), + bearers.value, + { + add: additions, + markSpent: carve.consumed.map((bearer) => bearer.id), + }, + { beforeCommit: ownerFence }, + ); + const added = next.slice(0, additions.length); + bearers.value = next; + await lockCommittedBearers(added); + const committed = existing ?? added[0]; + if (!committed) throw new Error('The carved note was not tracked.'); + return committed; }); - 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; - } - }; + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + const current = bearers.value.find((bearer) => bearer.id === id); + if (!current) throw new BearerNotFoundError(); + 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, @@ -147,40 +214,80 @@ export const createWalletFunds = (options: WalletFundsOptions) => { 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 removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise => + mutate(async () => { + 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; - }; + ): Promise => + mutate(async () => { + 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()); - }; + // Reserves `count` fresh BIP-32 cash secrets for a canonical mint host and + // stages the reservation in the pending journal - allocation plus staging + // land in ONE locked document write (see lnurlcash/storage/bearers.ts), so + // a crash after this resolves can neither double-spend an index nor lose + // the staged secrets. The cash root stays a caller-supplied parameter: this + // store never holds key material it doesn't need persistently. Secrets are + // derived inside the storage lock (the range only exists once serialized) + // and returned to the caller; they are never logged. + const allocateCashSecrets = ( + cashRoot: CashNode, + host: string, + count: number, + ownerFence: WalletOwnerFence, + ): Promise => + mutate(async () => { + options.setAuxiliaryError(''); + ownerFence(); + let secrets: string[] = []; + const reserved = await reserveCashIndices( + options.requireKey(), + { host, count }, + ({ host: reservedHost, start, count: reservedCount }) => { + secrets = Array.from({ length: reservedCount }, (_, offset) => + deriveCashSecret(cashRoot, reservedHost, start + offset), + ); + return { + kind: 'cash-allocation', + phase: 'reserved', + payload: { host: reservedHost, start, count: reservedCount, secrets }, + }; + }, + { beforeCommit: ownerFence }, + ); + return { + host: reserved.host, + start: reserved.start, + secrets, + pendingId: reserved.pendingId, + }; + }); return { public: { bearers, + fundsRevision, unspentBearers, balanceMsat, balanceSats, @@ -188,11 +295,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => { balanceByMintSats, addBearers, applyChangeset, + commitCarve, updateBearer, + allocateCashSecrets, markSpent, removeNote, mergeExternalBearers, - reloadBearers, }, replace, clear,