feat(funds): persist BIP-32 counters atomically

This commit is contained in:
2026-09-05 08:22:02 +02:00
parent 7e4e67e1cd
commit 0cec423d6f
11 changed files with 1181 additions and 227 deletions
+29 -15
View File
@@ -16,6 +16,15 @@ import {
} from './storage' } from './storage'
import type {BearerChangeset} from './storage' import type {BearerChangeset} from './storage'
import type {Bearer, NewBearer} from './types' 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 LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9)
@@ -45,6 +54,7 @@ const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
describe('baseline: per-record bearer persistence', () => { describe('baseline: per-record bearer persistence', () => {
it('writes once for every persist or delete call', async () => { it('writes once for every persist or delete call', async () => {
stubPassthroughLocks()
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
const writes = vi.spyOn(localStorage, 'setItem') const writes = vi.spyOn(localStorage, 'setItem')
@@ -53,13 +63,14 @@ describe('baseline: per-record bearer persistence', () => {
await deleteBearerRecord('a') await deleteBearerRecord('a')
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(3) ).toHaveLength(3)
}) })
}) })
describe('applyBearerChangeset', () => { describe('applyBearerChangeset', () => {
it('commits additions and spent replacements with exactly one write', async () => { it('commits additions and spent replacements with exactly one write', async () => {
stubPassthroughLocks()
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
const oldA = bearerFixture({id: 'old-a'}) const oldA = bearerFixture({id: 'old-a'})
const oldB = bearerFixture({ const oldB = bearerFixture({
@@ -85,7 +96,7 @@ describe('applyBearerChangeset', () => {
const result = await applyBearerChangeset(key, snapshot, changeset) const result = await applyBearerChangeset(key, snapshot, changeset)
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(1) ).toHaveLength(1)
expect(result).toHaveLength(4) expect(result).toHaveLength(4)
expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true]) 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( expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual(
result.map((bearer) => bearer.id).sort(), 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_C)
expect(raw).not.toContain(K1_D) expect(raw).not.toContain(K1_D)
}) })
it('writes nothing when the second record encryption fails', async () => { it('writes nothing when the second record encryption fails', async () => {
stubPassthroughLocks()
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
await persistBearer(key, bearerFixture({id: 'kept'})) await persistBearer(key, bearerFixture({id: 'kept'}))
const writes = vi.spyOn(localStorage, 'setItem') const writes = vi.spyOn(localStorage, 'setItem')
@@ -124,7 +136,7 @@ describe('applyBearerChangeset', () => {
).rejects.toThrow('second encryption failed') ).rejects.toThrow('second encryption failed')
expect(encrypt).toHaveBeenCalledTimes(2) expect(encrypt).toHaveBeenCalledTimes(2)
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(0) ).toHaveLength(0)
expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept']) expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept'])
} finally { } finally {
@@ -133,14 +145,14 @@ describe('applyBearerChangeset', () => {
}) })
it('rejects a failed storage write without changing persisted state', async () => { it('rejects a failed storage write without changing persisted state', async () => {
stubPassthroughLocks()
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
const kept = bearerFixture({id: 'kept'}) const kept = bearerFixture({id: 'kept'})
await persistBearer(key, 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(() => { const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError') throw new Error('QuotaExceededError')
}) })
vi.stubGlobal('navigator', {})
try { try {
await expect( await expect(
@@ -149,7 +161,7 @@ describe('applyBearerChangeset', () => {
markSpent: ['kept'], markSpent: ['kept'],
}), }),
).rejects.toThrow('QuotaExceededError') ).rejects.toThrow('QuotaExceededError')
expect(localStorage.getItem('sattle_bearers')).toBe(before) expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
} finally { } finally {
write.mockRestore() write.mockRestore()
vi.unstubAllGlobals() vi.unstubAllGlobals()
@@ -157,6 +169,7 @@ describe('applyBearerChangeset', () => {
}) })
it('deduplicates repeated spent ids into one stored replacement', async () => { it('deduplicates repeated spent ids into one stored replacement', async () => {
stubPassthroughLocks()
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
const bearer = bearerFixture({id: 'duplicate'}) const bearer = bearerFixture({id: 'duplicate'})
await persistBearer(key, bearer) await persistBearer(key, bearer)
@@ -171,7 +184,7 @@ describe('applyBearerChangeset', () => {
expect((await loadBearers(key))[0]?.spent).toBe(true) 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 key = await deriveBearerAesKey(LINKING_KEY)
const other = await deriveBearerAesKey(OTHER_KEY) const other = await deriveBearerAesKey(OTHER_KEY)
const mine = bearerFixture({id: 'mine'}) const mine = bearerFixture({id: 'mine'})
@@ -205,11 +218,12 @@ describe('applyBearerChangeset', () => {
markSpent: [], markSpent: [],
}) })
await vi.waitFor(() => expect(queue).toHaveLength(1)) await vi.waitFor(() => expect(queue).toHaveLength(1))
expect(encrypted).toHaveBeenCalledTimes(1) // nothing is encrypted before the lock: the document is re-read fresh
localStorage.setItem( // inside it, so a record another tab commits in the meantime survives
'sattle_bearers', expect(encrypted).toHaveBeenCalledTimes(0)
JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]), const doc = readFundsDocument()
) doc.bearers.push({id: foreignId, ...foreignParts})
writeFundsDocument(doc)
const pendingLock = queue.at(0) const pendingLock = queue.at(0)
if (pendingLock === undefined) throw new Error('Expected pending lock') 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 () => { it('replaces corrupt JSON with the single committed changeset write', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) 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 writes = vi.spyOn(localStorage, 'setItem')
const result = await applyBearerChangeset(key, [], { const result = await applyBearerChangeset(key, [], {
@@ -240,7 +254,7 @@ describe('applyBearerChangeset', () => {
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect( expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'), writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
).toHaveLength(1) ).toHaveLength(1)
expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual( expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual(
result.map((bearer) => bearer.id), result.map((bearer) => bearer.id),
+6 -2
View File
@@ -22,6 +22,7 @@ import {
MAX_ACTIVITY_ENTRIES, MAX_ACTIVITY_ENTRIES,
} from './storage' } from './storage'
import {saveLinkingKey} from './keys' import {saveLinkingKey} from './keys'
import {FUNDS_STORAGE_KEY} from './storage/bearers'
import {requiredValue, stubLocalStorage} from './test-utils' import {requiredValue, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7)
@@ -52,7 +53,7 @@ describe('encrypted bearer records', () => {
await persistBearer(key, bearer) await persistBearer(key, bearer)
// at rest, nothing plaintext leaks: no k1, no amounts // 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(K1_A)
expect(raw).not.toContain('21000') expect(raw).not.toContain('21000')
@@ -233,19 +234,22 @@ describe('backup', () => {
applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}), applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}),
).rejects.toThrow() ).rejects.toThrow()
await expect(applyBackup(null)).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() await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow()
}) })
it('skips malformed records instead of failing the whole restore', async () => { it('skips malformed records instead of failing the whole restore', async () => {
const result = await applyBackup({ const result = await applyBackup({
type: 'sattle-backup', type: 'sattle-backup',
version: 1, version: 2,
createdAt: 1, createdAt: 1,
bearers: [ bearers: [
{id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, {id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
{id: 42, iv: null, ciphertext: 'xx'}, {id: 42, iv: null, ciphertext: 'xx'},
'garbage', 'garbage',
], ],
nextByHost: {},
}) })
expect(result.added).toBe(1) expect(result.added).toBe(1)
expect(result.skipped).toBe(2) expect(result.skipped).toBe(2)
+20 -3
View File
@@ -5,8 +5,9 @@
// the Pinia mints store and this module's backup both use it. // the Pinia mints store and this module's backup both use it.
// //
// Split by concern; this façade re-exports everything: // Split by concern; this façade re-exports everything:
// storage/bearers.ts - encrypted bearer records, changeset commits, // storage/bearers.ts - the funds document (sattle_funds_v2): encrypted
// mergeBearers // bearer records, pending journal, BIP-32 counters;
// locked single-write commit primitives, mergeBearers
// storage/activityLog.ts - the append-only encrypted activity log // storage/activityLog.ts - the append-only encrypted activity log
// storage/settings.ts - plaintext wallet settings // storage/settings.ts - plaintext wallet settings
// storage/backup.ts - buildBackup / applyBackup // storage/backup.ts - buildBackup / applyBackup
@@ -17,14 +18,30 @@ export {
compareBearerOrder, compareBearerOrder,
newBearerId, newBearerId,
readEncryptedBearers, readEncryptedBearers,
readFundsDocument,
readFundsRevision,
readNextByHost,
readPendingJournal,
loadBearers, loadBearers,
persistBearer, persistBearer,
deleteBearerRecord, deleteBearerRecord,
applyBearerChangeset, applyBearerChangeset,
reserveCashIndices,
commitFundsRestore,
clearAllBearers, clearAllBearers,
mergeBearers, mergeBearers,
FUNDS_STORAGE_KEY,
} from './storage/bearers'
export type {
BearerChangeset,
EncryptedBearerRecord,
EncryptedJournalRecord,
FundsReservation,
FundsRestoreMerge,
PendingStaging,
ReservedCashRange,
StoredFundsV2,
} from './storage/bearers' } from './storage/bearers'
export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers'
export { export {
newActivityId, newActivityId,
@@ -3,6 +3,7 @@ import {buildNoteUrl} from 'lnurlcash-kit'
import type {Bearer} from '../types' import type {Bearer} from '../types'
import {deriveBearerAesKey} from '../keys' import {deriveBearerAesKey} from '../keys'
import {deleteBearerRecord, newBearerId, persistBearer} from '../storage' import {deleteBearerRecord, newBearerId, persistBearer} from '../storage'
import {FUNDS_STORAGE_KEY} from './bearers'
import {stubLocalStorage} from '../test-utils' import {stubLocalStorage} from '../test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7)
@@ -19,11 +20,14 @@ const bearerFixture = (): Bearer => ({
describe('baseline: per-record bearer persistence', () => { describe('baseline: per-record bearer persistence', () => {
it('persistBearer/deleteBearerRecord perform one write per call', async () => { it('persistBearer/deleteBearerRecord perform one write per call', async () => {
const storage = stubLocalStorage() const storage = stubLocalStorage()
vi.stubGlobal('navigator', {
locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)},
})
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
const writes = vi.spyOn(storage, 'setItem') const writes = vi.spyOn(storage, 'setItem')
await persistBearer(key, {...bearerFixture(), id: 'a'}) await persistBearer(key, {...bearerFixture(), id: 'a'})
await persistBearer(key, {...bearerFixture(), id: 'b'}) await persistBearer(key, {...bearerFixture(), id: 'b'})
await deleteBearerRecord('a') 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)
}) })
}) })
+371 -59
View File
@@ -10,15 +10,22 @@ import {buildNoteUrl} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import {deriveBearerAesKey, encryptRecord} from '../keys' import {deriveBearerAesKey, encryptRecord} from '../keys'
import {StorageLocksUnavailableError} from '../storageLock'
import { import {
applyBearerChangeset, applyBearerChangeset,
loadBearers, loadBearers,
newBearerId, newBearerId,
persistBearer, persistBearer,
readEncryptedBearers, readEncryptedBearers,
reserveCashIndices,
} from '../storage' } from '../storage'
import type {BearerChangeset} 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 {requiredValue, stubLocalStorage} from '../test-utils'
import type {LocalStorageStub} from '../test-utils' import type {LocalStorageStub} from '../test-utils'
import './bearers.baseline.cases' import './bearers.baseline.cases'
@@ -50,16 +57,159 @@ const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
...overrides, ...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<unknown> =>
new Promise((resolve, reject) => {
this.requests.push({name, callback, resolve, reject})
})
async releaseNext(): Promise<void> {
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 let stub: LocalStorageStub
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
stub = stubLocalStorage() stub = stubLocalStorage()
}) })
const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] => const fundsWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] =>
spy.mock.calls.filter(([k]) => k === 'sattle_bearers') 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)', () => { describe('applyBearerChangeset (single-write changeset commit)', () => {
beforeEach(() => {
stubPassthroughLocks()
})
it('commits additions and spent replacements with exactly one write', async () => { it('commits additions and spent replacements with exactly one write', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
const oldA = bearerFixture({id: 'old-a'}) const oldA = bearerFixture({id: 'old-a'})
@@ -83,8 +233,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
markSpent: ['old-a', 'old-b'], markSpent: ['old-a', 'old-b'],
}) })
// the whole changeset is ONE setItem on sattle_bearers // the whole changeset is ONE setItem on the funds document
expect(bearerWrites(writes)).toHaveLength(1) expect(fundsWrites(writes)).toHaveLength(1)
// the returned next list: additions first, then the snapshot with spent // the returned next list: additions first, then the snapshot with spent
// marks applied // 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 === 'old-b')).spent).toBe(true)
expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined() expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined()
// nothing plaintext leaked: the fresh k1s are ciphertext-only at rest // 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_C)
expect(raw).not.toContain(K1_D) expect(raw).not.toContain(K1_D)
}) })
@@ -133,6 +283,7 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
it('persists nothing when encryption fails', async () => { it('persists nothing when encryption fails', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
await persistBearer(key, bearerFixture({id: 'kept'})) 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 // a decrypt-only key makes every AES-GCM encrypt call reject
const decryptOnly = await crypto.subtle.importKey( const decryptOnly = await crypto.subtle.importKey(
'raw', 'raw',
@@ -150,7 +301,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
}), }),
).rejects.toThrow() ).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']) 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 key = await deriveBearerAesKey(LINKING_KEY)
const kept = bearerFixture({id: 'kept'}) const kept = bearerFixture({id: 'kept'})
await persistBearer(key, kept) await persistBearer(key, kept)
const before = readEncryptedBearers() const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
stub.setItem = (): void => { stub.setItem = (): void => {
throw new Error('QuotaExceededError') 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 await expect(
expect(readEncryptedBearers()).toEqual(before) applyBearerChangeset(key, [kept], {
} finally { add: [newBearerFixture()],
vi.unstubAllGlobals() 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 () => { 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'}) const mine = bearerFixture({id: 'mine'})
await persistBearer(key, mine) await persistBearer(key, mine)
// a controllable Web Locks fake: lock requests park until the test const locks = new DeferredLocks()
// releases them, so a foreign write can interleave deterministically locks.stub()
const queue: {name: string; release: () => Promise<void>}[] = []
vi.stubGlobal('navigator', {
locks: {
request: (name: string, fn: () => unknown): Promise<unknown> =>
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 { try {
const commit = applyBearerChangeset(key, [mine], { const commit = applyBearerChangeset(key, [mine], {
add: [newBearerFixture()], add: [newBearerFixture()],
markSpent: [], markSpent: [],
}) })
// encryption happens BEFORE the lock request; wait for it to arrive // the lock request arrives before any inside-lock work
await vi.waitFor(() => { await vi.waitFor(() => {
expect(queue).toHaveLength(1) expect(locks.requests).toHaveLength(1)
}) })
const queuedLock = requiredValue(queue[0]) const queuedLock = requiredValue(locks.requests[0])
expect(queuedLock.name).toBe('sattle_bearers') expect(queuedLock.name).toBe(FUNDS_STORAGE_KEY)
// while our commit waits on the lock, another tab commits a record we // while our commit waits on the lock, another tab commits a record we
// cannot even decrypt (written under a different seed's key) // 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 {id: foreignId, ...foreignPlain} = foreign
const foreignParts = await encryptRecord(other, foreignPlain) 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 const result = await commit
// the foreign ciphertext survived our upsert, untouched // 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 () => { it('treats corrupted stored JSON as an empty record set instead of throwing', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
localStorage.setItem('sattle_bearers', 'not json {{{') localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{')
const result = await applyBearerChangeset(key, [], { const result = await applyBearerChangeset(key, [], {
add: [newBearerFixture()], add: [newBearerFixture()],
markSpent: ['gone'], markSpent: ['gone'],
}) })
// readEncryptedBearers' long-standing contract: unparseable storage // readFundsDocument's contract: unparseable storage reads as the empty
// reads as [] (malformed entries are dropped, never thrown on) - the // document - the changeset still commits and its single write replaces
// changeset still commits and its single write replaces the corrupt blob // the corrupt blob with a valid one
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id]) 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 () => { it('ignores markSpent ids absent from the snapshot and writes nothing when nothing changed', async () => {
const key = await deriveBearerAesKey(LINKING_KEY) const key = await deriveBearerAesKey(LINKING_KEY)
const other = await deriveBearerAesKey(OTHER_KEY) const other = await deriveBearerAesKey(OTHER_KEY)
await persistBearer(other, bearerFixture({id: 'foreign'})) await persistBearer(other, bearerFixture({id: 'foreign'}))
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
const writes = vi.spyOn(stub, 'setItem') const writes = vi.spyOn(stub, 'setItem')
// 'foreign' is not in the caller's snapshot; deriving its spent copy // '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(result).toEqual([])
expect(bearerWrites(writes)).toHaveLength(0) expect(fundsWrites(writes)).toHaveLength(0)
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign']) 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()
}) })
}) })
+326 -59
View File
@@ -1,13 +1,45 @@
// Encrypted bearer-note persistence: each note is an AES-GCM ciphertext // allow: SIZE_OK — one document, one lock, one write path; splitting the
// record under a key derived from the linking key (see keys.ts), so a note // schema from its commit primitive would invite drift between them.
// URL - which IS the money - never touches disk in plaintext. //
// 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 type {EncryptedRecordParts} from '../keys'
import {encryptRecord, decryptRecord} from '../keys' import {encryptRecord, decryptRecord} from '../keys'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import {isJsonObject} from '../jsonParsing' import {isJsonObject} from '../jsonParsing'
import {noteK1, serverOf} from 'lnurlcash-kit' 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 // the wallet's default note order (newest first) with manually dragged
// notes taking priority once they have an explicit rank // 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 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<string, number>
revision: number
}
const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord => const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord =>
isJsonObject(value) && isJsonObject(value) &&
typeof value.id === 'string' && typeof value.id === 'string' &&
typeof value.iv === 'string' && typeof value.iv === 'string' &&
typeof value.ciphertext === '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<Bearer, 'id'> => const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
isJsonObject(value) && isJsonObject(value) &&
typeof value.url === 'string' && typeof value.url === 'string' &&
@@ -34,32 +102,104 @@ const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
(value.spent === undefined || typeof value.spent === 'boolean') && (value.spent === undefined || typeof value.spent === 'boolean') &&
(value.sortIndex === undefined || typeof value.sortIndex === 'number') && (value.sortIndex === undefined || typeof value.sortIndex === 'number') &&
(value.label === undefined || typeof value.label === 'string') && (value.label === undefined || typeof value.label === 'string') &&
(value.deviceId === undefined || typeof value.deviceId === 'string') (value.deviceId === undefined || typeof value.deviceId === 'string') &&
(value.pendingMint === undefined ||
const BEARERS_STORAGE_KEY = 'sattle_bearers' (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 => export const newBearerId = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(8))) Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map((b) => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join('') .join('')
export const readEncryptedBearers = (): EncryptedBearerRecord[] => { const emptyFundsDocument = (): StoredFundsV2 => ({
const raw = localStorage.getItem(BEARERS_STORAGE_KEY) version: FUNDS_DOCUMENT_VERSION,
if (!raw) return [] bearers: [],
try { pending: [],
const parsed: unknown = JSON.parse(raw) nextByHost: {},
return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : [] revision: 0,
} catch { })
return []
// 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<string, number> = {}
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 => { export const readFundsDocument = (): StoredFundsV2 => {
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records)) 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<string, number> => ({...readFundsDocument().nextByHost})
export const readFundsRevision = (): number => readFundsDocument().revision
type BearerCommitOptions = {beforeCommit?: () => void} 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 // decrypts everything currently stored - a record that fails to decrypt
// (e.g. written by a different seed's key) is skipped, not destroyed: it // (e.g. written by a different seed's key) is skipped, not destroyed: it
// stays in localStorage untouched and simply doesn't show up // stays in localStorage untouched and simply doesn't show up
@@ -83,13 +223,16 @@ export const persistBearer = async (
bearer: Bearer, bearer: Bearer,
options: BearerCommitOptions = {}, options: BearerCommitOptions = {},
): Promise<void> => { ): Promise<void> => {
requireMutationLocks()
const {id, ...plain} = bearer const {id, ...plain} = bearer
const parts = await encryptRecord(aesKey, plain) await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => {
await withStorageLock(BEARERS_STORAGE_KEY, () => { const doc = readFundsDocument()
options.beforeCommit?.() options.beforeCommit?.()
const records = readEncryptedBearers().filter((r) => r.id !== id) const parts = await encryptRecord(aesKey, plain)
records.push({id, ...parts}) doc.bearers = doc.bearers.filter((r) => r.id !== id)
writeEncryptedBearers(records) doc.bearers.push({id, ...parts})
doc.revision += 1
writeFundsDocument(doc)
}) })
} }
@@ -97,51 +240,49 @@ export const deleteBearerRecord = async (
id: string, id: string,
options: BearerCommitOptions = {}, options: BearerCommitOptions = {},
): Promise<void> => { ): Promise<void> => {
await withStorageLock(BEARERS_STORAGE_KEY, () => { requireMutationLocks()
await withRequiredStorageLock(FUNDS_STORAGE_KEY, () => {
const doc = readFundsDocument()
options.beforeCommit?.() 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 // 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 // ids of snapshot notes to lock as spent. Born-spent notes (carved and
// melted away in one flow) are deliberately not representable - they were // 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 = { export type BearerChangeset = {
add: NewBearer[] add: NewBearer[]
markSpent: string[] markSpent: string[]
upsert?: Bearer[] upsert?: Bearer[]
remove?: string[] 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 // boundary a caller (NWC service, wallet store) awaits before reporting
// success. The per-record path above (persistBearer in a loop) can die // success. Nothing becomes observable until the single locked write lands:
// 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 // - the caller's snapshot is only a PLAN: inside the lock the document is
// notes get their id/timestamps assigned HERE (so state and storage can // re-read FRESH, so records another tab committed after the snapshot
// never disagree about them), spent marks copy the snapshot's record // survive the upsert - changed ids replace their stored copy, everything
// - ALL encryption happens BEFORE the lock is taken - crypto is the slow, // else is kept as-is (unrelated records are never decrypted or
// async part and a storage lock must never be held across it (see // re-encrypted)
// storageLock.ts); if any record fails to encrypt, no write happens at // - added notes get their id/timestamps assigned HERE (so state and storage
// all // can never disagree about them), spent marks copy the snapshot's record
// - inside the lock the encrypted records are re-read FRESH, so records // - the owner fence (options.beforeCommit) runs INSIDE the lock after the
// another tab committed after the caller's snapshot survive the upsert - // fresh read and before any encryption: throwing aborts the commit with
// changed ids replace their stored copy, everything else is kept as-is // the document untouched
// (unrelated records are never decrypted or re-encrypted)
// - markSpent ids absent from the snapshot are ignored: deriving them would // - markSpent ids absent from the snapshot are ignored: deriving them would
// require decrypting a record the caller doesn't hold // require decrypting a record the caller doesn't hold
// - a changeset that changes nothing performs no write at all // - a changeset that changes nothing performs no write at all
// - caller arrays are never mutated // - 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 // Returns the next local bearer list (additions first, then the snapshot
// with spent marks applied) only after the write succeeded; on any failure // 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 upserted = changeset.upsert ?? []
const removedIds = new Set(changeset.remove ?? []) const removedIds = new Set(changeset.remove ?? [])
const clearPendingIds = new Set(changeset.clearPending ?? [])
const changedById = new Map<string, Bearer>() const changedById = new Map<string, Bearer>()
for (const bearer of spent.values()) changedById.set(bearer.id, bearer) 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 upserted) changedById.set(bearer.id, bearer)
for (const bearer of added) changedById.set(bearer.id, bearer) for (const bearer of added) changedById.set(bearer.id, bearer)
const changed = [...changedById.values()] const changed = [...changedById.values()]
if (changed.length === 0 && removedIds.size === 0) return snapshot if (changed.length === 0 && removedIds.size === 0 && clearPendingIds.size === 0) return snapshot
const encrypted: EncryptedBearerRecord[] = [] requireMutationLocks()
for (const bearer of changed) { await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => {
const {id, ...plain} = bearer const doc = readFundsDocument()
const parts = await encryptRecord(aesKey, plain)
encrypted.push({id, ...parts})
}
await withStorageLock(BEARERS_STORAGE_KEY, () => {
options.beforeCommit?.() 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 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), (record) => !changedIds.has(record.id) && !removedIds.has(record.id),
) )
records.push(...encrypted) doc.bearers.push(...encrypted)
writeEncryptedBearers(records) 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 snapshotIds = new Set(snapshot.map((bearer) => bearer.id))
const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id)) const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id))
@@ -197,12 +345,131 @@ export const applyBearerChangeset = async (
return [...added, ...inserted, ...retained] 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<ReservedCashRange & {pendingId: string}> => {
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<string, number>,
): Promise<FundsRestoreMerge> => {
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 // wipes every bearer record from this device outright - unlike forgetting
// just the linking key, this is not recoverable by restoring the same seed: // just the linking key, this is not recoverable by restoring the same seed:
// the ciphertexts themselves are gone, so only a previously downloaded // the ciphertexts themselves are gone, so only a previously downloaded
// backup file can bring them back // backup file can bring them back
export const clearAllBearers = (): void => { 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 // Merge two decrypted bearer lists into one, keyed by note identity
+37 -1
View File
@@ -1,6 +1,6 @@
import {describe, expect, it, vi} from 'vitest' import {describe, expect, it, vi} from 'vitest'
import {withStorageLock} from './storageLock' import {StorageLocksUnavailableError, withRequiredStorageLock, withStorageLock} from './storageLock'
describe('withStorageLock fallback', () => { describe('withStorageLock fallback', () => {
it('runs unlocked when Web Locks are unavailable without promising serialization', async () => { 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<void>((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'])
})
})
+20
View File
@@ -13,3 +13,23 @@ export const withStorageLock = <T>(name: string, fn: () => T | Promise<T>): Prom
export const storageLocksAvailable = (): boolean => export const storageLocksAvailable = (): boolean =>
typeof navigator !== 'undefined' && navigator.locks !== undefined 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 = <T>(name: string, fn: () => T | Promise<T>): Promise<T> => {
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined
if (!locks) return Promise.reject(new StorageLocksUnavailableError())
return locks.request(name, () => Promise.resolve().then(fn))
}
+138 -5
View File
@@ -1,13 +1,25 @@
import { createPinia, setActivePinia } from 'pinia'; 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey } from '@/lnurlcash/keys'; 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 { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { Bearer, NewBearer } from '@/lnurlcash/types'; import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { useWalletStore } from './wallet'; 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 => ({ const note = (secret: string): NewBearer => ({
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
callback: 'https://mint.example/w/cb', callback: 'https://mint.example/w/cb',
@@ -29,12 +41,45 @@ const rejectSecondEncryption = (): void => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
stubLocalStorage(); stubLocalStorage();
setActivePinia(createPinia()); setActivePinia(createPinia());
}); });
describe('wallet multi-bearer durability', () => { 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<void>((resolve) => {
releaseFirst = resolve;
});
let firstStarted: (() => void) | undefined;
const firstEntered = new Promise<void>((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 () => { it('adds two bearers with one established changeset write', async () => {
const storage = stubLocalStorage(); const storage = stubLocalStorage();
const wallet = useWalletStore(); const wallet = useWalletStore();
@@ -43,7 +88,7 @@ describe('wallet multi-bearer durability', () => {
await wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()); 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); expect(wallet.bearers).toHaveLength(2);
}); });
@@ -105,7 +150,95 @@ describe('wallet multi-bearer durability', () => {
await wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence()); 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); 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({});
});
});
+46 -7
View File
@@ -3,14 +3,24 @@ import { buildNoteUrl } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey, linkingPubKeyHex } from '@/lnurlcash/keys'; 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 { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner';
import { StorageLocksUnavailableError } from '@/lnurlcash/storageLock';
import { stubLocalStorage } from '@/lnurlcash/test-utils'; import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { NewBearer } from '@/lnurlcash/types'; import type { NewBearer } from '@/lnurlcash/types';
import { useWalletStore } from './wallet'; import { useWalletStore } from './wallet';
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); 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 => ({ const note = (secret: string): NewBearer => ({
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000), url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
callback: 'https://mint.example/w/cb', callback: 'https://mint.example/w/cb',
@@ -71,7 +81,7 @@ beforeEach(() => {
describe('stale-owner fencing of fund commits', () => { describe('stale-owner fencing of fund commits', () => {
it('fences a changeset commit after a silent owner replacement', async () => { it('fences a changeset commit after a silent owner replacement', async () => {
// Given an unlocked wallet with one bearer and no pending storage event // Given an unlocked wallet with one bearer and no pending storage event
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
const ownerFence = wallet.captureOwnerFence(); 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 () => { it('fences a single-record spent mark after a silent owner replacement', async () => {
// Given an unlocked wallet whose owner was silently replaced // Given an unlocked wallet whose owner was silently replaced
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
const ownerFence = wallet.captureOwnerFence(); 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 () => { it('revalidates the owner inside the commit lock, after encryption', async () => {
// Given a changeset commit whose storage write is held at the lock // Given a changeset commit whose storage write is held at the lock
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
const ownerFence = wallet.captureOwnerFence(); 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 () => { it('rejects a fence captured by an earlier lifecycle of the same owner', async () => {
// Given an operation accepted before the encrypted wallet locked and // Given an operation accepted before the encrypted wallet locked and
// unlocked again under the same persisted owner // unlocked again under the same persisted owner
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create('password'); await wallet.create('password');
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); 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 () => { it('revalidates a spent update inside its persistence lock', async () => {
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); 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 () => { it('revalidates a deletion inside its persistence lock', async () => {
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence()); 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(readEncryptedBearers()).toHaveLength(1);
expect(wallet.bearers).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();
});
}); });
+183 -75
View File
@@ -1,17 +1,21 @@
// allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary.
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { serverOf } from 'lnurlcash-kit'; import { deriveCashSecret, serverOf } from 'lnurlcash-kit';
import type { CashNode } from 'lnurlcash-kit';
import { import {
applyBearerChangeset, applyBearerChangeset,
deleteBearerRecord, deleteBearerRecord,
loadBearers,
mergeBearers, mergeBearers,
persistBearer, persistBearer,
readFundsRevision,
reserveCashIndices,
} from '@/lnurlcash/storage'; } from '@/lnurlcash/storage';
import type { BearerChangeset } from '@/lnurlcash/storage'; import type { BearerChangeset } from '@/lnurlcash/storage';
import { lockTrustedMint } from '@/lnurlcash/trustedMints'; import { lockTrustedMint } from '@/lnurlcash/trustedMints';
import type { Bearer, NewBearer } from '@/lnurlcash/types'; import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { msatToSats } from '@/lnurlcash/units'; import { msatToSats } from '@/lnurlcash/units';
import type { CarveResult } from '@/lnurlcash/ops';
import type { WalletOwnerFence } from './walletOwnerFence'; import type { WalletOwnerFence } from './walletOwnerFence';
export class TrustedMintPostCommitError extends Error { 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 = { type WalletFundsOptions = {
readonly requireKey: () => CryptoKey; readonly requireKey: () => CryptoKey;
readonly ownerId: () => string | undefined; readonly ownerId: () => string | undefined;
@@ -37,6 +59,25 @@ type WalletFundsOptions = {
export const createWalletFunds = (options: WalletFundsOptions) => { export const createWalletFunds = (options: WalletFundsOptions) => {
const bearers = ref<Bearer[]>([]); const bearers = ref<Bearer[]>([]);
// 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<void> = Promise.resolve();
const mutate = <Result>(operation: () => Promise<Result>): Promise<Result> => {
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 unspentBearers = computed(() => bearers.value.filter((bearer) => !bearer.spent));
const balanceMsat = computed(() => const balanceMsat = computed(() =>
unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0), unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0),
@@ -60,10 +101,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
const replace = (loaded: Bearer[]): void => { const replace = (loaded: Bearer[]): void => {
bearers.value = loaded; bearers.value = loaded;
syncRevision();
}; };
const clear = (): void => { const clear = (): void => {
bearers.value = []; bearers.value = [];
fundsRevision.value = 0;
}; };
const lockCommittedBearers = async (committed: Bearer[]): Promise<void> => { const lockCommittedBearers = async (committed: Bearer[]): Promise<void> => {
@@ -81,63 +124,87 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
} }
}; };
const addBearers = async ( const addBearers = async (notes: NewBearer[], ownerFence: WalletOwnerFence): Promise<Bearer[]> =>
notes: NewBearer[], mutate(async () => {
ownerFence: WalletOwnerFence, options.setAuxiliaryError('');
): Promise<Bearer[]> => { ownerFence();
options.setAuxiliaryError(''); const next = await applyBearerChangeset(
ownerFence(); options.requireKey(),
const next = await applyBearerChangeset( bearers.value,
options.requireKey(), { add: notes, markSpent: [] },
bearers.value, // re-prove ownership inside the lock: encryption is async, so the
{ add: notes, markSpent: [] }, // entry check alone would leave a cross-tab replacement window open
// re-prove ownership inside the lock: encryption is async, so the { beforeCommit: ownerFence },
// entry check alone would leave a cross-tab replacement window open );
{ beforeCommit: ownerFence }, const added = next.slice(0, notes.length);
); bearers.value = next;
const added = next.slice(0, notes.length); await lockCommittedBearers(added);
bearers.value = next; return added;
await lockCommittedBearers(added); });
return added;
};
const applyChangeset = async ( const applyChangeset = async (
changeset: BearerChangeset, changeset: BearerChangeset,
ownerFence: WalletOwnerFence, ownerFence: WalletOwnerFence,
): Promise<Bearer[]> => { ): Promise<Bearer[]> =>
options.setAuxiliaryError(''); mutate(async () => {
ownerFence(); options.setAuxiliaryError('');
const next = await applyBearerChangeset(options.requireKey(), bearers.value, changeset, { ownerFence();
beforeCommit: 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<Bearer> =>
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 ( const updateBearer = async (
id: string, id: string,
changes: Partial<Omit<Bearer, 'id'>>, changes: Partial<Omit<Bearer, 'id'>>,
ownerFence: WalletOwnerFence, ownerFence: WalletOwnerFence,
): Promise<void> => { ): Promise<void> =>
options.setAuxiliaryError(''); mutate(async () => {
const current = bearers.value.find((bearer) => bearer.id === id); options.setAuxiliaryError('');
if (!current) return; const current = bearers.value.find((bearer) => bearer.id === id);
ownerFence(); if (!current) throw new BearerNotFoundError();
const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() }; ownerFence();
await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence }); const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
bearers.value = bearers.value.map((bearer) => (bearer.id === id ? updated : bearer)); await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence });
if (!updated.mintPubkey) return; bearers.value = bearers.value.map((bearer) => (bearer.id === id ? updated : bearer));
try { if (!updated.mintPubkey) return;
await lockTrustedMint(serverOf(updated.url), updated.mintPubkey, options.ownerId()); try {
} catch (error) { await lockTrustedMint(serverOf(updated.url), updated.mintPubkey, options.ownerId());
const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.'); } catch (error) {
const postCommitError = new TrustedMintPostCommitError([updated], { cause }); const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.');
options.setAuxiliaryError(postCommitError.message); const postCommitError = new TrustedMintPostCommitError([updated], { cause });
throw postCommitError; options.setAuxiliaryError(postCommitError.message);
} throw postCommitError;
}; }
});
const markSpent = async ( const markSpent = async (
id: string, id: string,
@@ -147,40 +214,80 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
await updateBearer(id, { spent }, ownerFence); await updateBearer(id, { spent }, ownerFence);
}; };
const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise<void> => { const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise<void> =>
ownerFence(); mutate(async () => {
await deleteBearerRecord(id, { beforeCommit: ownerFence }); ownerFence();
bearers.value = bearers.value.filter((bearer) => bearer.id !== id); await deleteBearerRecord(id, { beforeCommit: ownerFence });
}; bearers.value = bearers.value.filter((bearer) => bearer.id !== id);
});
const mergeExternalBearers = async ( const mergeExternalBearers = async (
incoming: Bearer[], incoming: Bearer[],
ownerFence: WalletOwnerFence, ownerFence: WalletOwnerFence,
): Promise<void> => { ): Promise<void> =>
ownerFence(); mutate(async () => {
const merged = mergeBearers(bearers.value, incoming); ownerFence();
const mergedIds = new Set(merged.map((bearer) => bearer.id)); const merged = mergeBearers(bearers.value, incoming);
await applyBearerChangeset( const mergedIds = new Set(merged.map((bearer) => bearer.id));
options.requireKey(), await applyBearerChangeset(
bearers.value, options.requireKey(),
{ bearers.value,
add: [], {
markSpent: [], add: [],
upsert: merged, markSpent: [],
remove: bearers.value.filter((bearer) => !mergedIds.has(bearer.id)).map(({ id }) => id), upsert: merged,
}, remove: bearers.value.filter((bearer) => !mergedIds.has(bearer.id)).map(({ id }) => id),
{ beforeCommit: ownerFence }, },
); { beforeCommit: ownerFence },
bearers.value = merged; );
}; bearers.value = merged;
});
const reloadBearers = async (): Promise<void> => { // Reserves `count` fresh BIP-32 cash secrets for a canonical mint host and
bearers.value = await loadBearers(options.requireKey()); // 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<AllocatedCashSecrets> =>
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 { return {
public: { public: {
bearers, bearers,
fundsRevision,
unspentBearers, unspentBearers,
balanceMsat, balanceMsat,
balanceSats, balanceSats,
@@ -188,11 +295,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
balanceByMintSats, balanceByMintSats,
addBearers, addBearers,
applyChangeset, applyChangeset,
commitCarve,
updateBearer, updateBearer,
allocateCashSecrets,
markSpent, markSpent,
removeNote, removeNote,
mergeExternalBearers, mergeExternalBearers,
reloadBearers,
}, },
replace, replace,
clear, clear,