mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(funds): persist BIP-32 counters atomically
This commit is contained in:
@@ -16,6 +16,15 @@ import {
|
||||
} from './storage'
|
||||
import type {BearerChangeset} from './storage'
|
||||
import type {Bearer, NewBearer} from './types'
|
||||
import {FUNDS_STORAGE_KEY, readFundsDocument, writeFundsDocument} from './storage/bearers'
|
||||
|
||||
// mutations of the funds document require Web Locks; tests that don't care
|
||||
// about lock timing install a present-but-non-serializing fake
|
||||
const stubPassthroughLocks = (): void => {
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)},
|
||||
})
|
||||
}
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
||||
@@ -45,6 +54,7 @@ const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
|
||||
|
||||
describe('baseline: per-record bearer persistence', () => {
|
||||
it('writes once for every persist or delete call', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const writes = vi.spyOn(localStorage, 'setItem')
|
||||
|
||||
@@ -53,13 +63,14 @@ describe('baseline: per-record bearer persistence', () => {
|
||||
await deleteBearerRecord('a')
|
||||
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
|
||||
).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBearerChangeset', () => {
|
||||
it('commits additions and spent replacements with exactly one write', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const oldA = bearerFixture({id: 'old-a'})
|
||||
const oldB = bearerFixture({
|
||||
@@ -85,7 +96,7 @@ describe('applyBearerChangeset', () => {
|
||||
const result = await applyBearerChangeset(key, snapshot, changeset)
|
||||
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
|
||||
).toHaveLength(1)
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true])
|
||||
@@ -96,12 +107,13 @@ describe('applyBearerChangeset', () => {
|
||||
expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual(
|
||||
result.map((bearer) => bearer.id).sort(),
|
||||
)
|
||||
const raw = localStorage.getItem('sattle_bearers') ?? ''
|
||||
const raw = localStorage.getItem(FUNDS_STORAGE_KEY) ?? ''
|
||||
expect(raw).not.toContain(K1_C)
|
||||
expect(raw).not.toContain(K1_D)
|
||||
})
|
||||
|
||||
it('writes nothing when the second record encryption fails', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
const writes = vi.spyOn(localStorage, 'setItem')
|
||||
@@ -124,7 +136,7 @@ describe('applyBearerChangeset', () => {
|
||||
).rejects.toThrow('second encryption failed')
|
||||
expect(encrypt).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
|
||||
).toHaveLength(0)
|
||||
expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept'])
|
||||
} finally {
|
||||
@@ -133,14 +145,14 @@ describe('applyBearerChangeset', () => {
|
||||
})
|
||||
|
||||
it('rejects a failed storage write without changing persisted state', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const kept = bearerFixture({id: 'kept'})
|
||||
await persistBearer(key, kept)
|
||||
const before = localStorage.getItem('sattle_bearers')
|
||||
const before = localStorage.getItem(FUNDS_STORAGE_KEY)
|
||||
const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
vi.stubGlobal('navigator', {})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
@@ -149,7 +161,7 @@ describe('applyBearerChangeset', () => {
|
||||
markSpent: ['kept'],
|
||||
}),
|
||||
).rejects.toThrow('QuotaExceededError')
|
||||
expect(localStorage.getItem('sattle_bearers')).toBe(before)
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
vi.unstubAllGlobals()
|
||||
@@ -157,6 +169,7 @@ describe('applyBearerChangeset', () => {
|
||||
})
|
||||
|
||||
it('deduplicates repeated spent ids into one stored replacement', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const bearer = bearerFixture({id: 'duplicate'})
|
||||
await persistBearer(key, bearer)
|
||||
@@ -171,7 +184,7 @@ describe('applyBearerChangeset', () => {
|
||||
expect((await loadBearers(key))[0]?.spent).toBe(true)
|
||||
})
|
||||
|
||||
it('encrypts before locking and preserves a fresh unrelated record', async () => {
|
||||
it('fresh-reads inside the lock and preserves a fresh unrelated record', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
const mine = bearerFixture({id: 'mine'})
|
||||
@@ -205,11 +218,12 @@ describe('applyBearerChangeset', () => {
|
||||
markSpent: [],
|
||||
})
|
||||
await vi.waitFor(() => expect(queue).toHaveLength(1))
|
||||
expect(encrypted).toHaveBeenCalledTimes(1)
|
||||
localStorage.setItem(
|
||||
'sattle_bearers',
|
||||
JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]),
|
||||
)
|
||||
// nothing is encrypted before the lock: the document is re-read fresh
|
||||
// inside it, so a record another tab commits in the meantime survives
|
||||
expect(encrypted).toHaveBeenCalledTimes(0)
|
||||
const doc = readFundsDocument()
|
||||
doc.bearers.push({id: foreignId, ...foreignParts})
|
||||
writeFundsDocument(doc)
|
||||
const pendingLock = queue.at(0)
|
||||
if (pendingLock === undefined) throw new Error('Expected pending lock')
|
||||
|
||||
@@ -230,7 +244,7 @@ describe('applyBearerChangeset', () => {
|
||||
|
||||
it('replaces corrupt JSON with the single committed changeset write', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
localStorage.setItem('sattle_bearers', 'not json {{{')
|
||||
localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{')
|
||||
const writes = vi.spyOn(localStorage, 'setItem')
|
||||
|
||||
const result = await applyBearerChangeset(key, [], {
|
||||
@@ -240,7 +254,7 @@ describe('applyBearerChangeset', () => {
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
|
||||
writes.mock.calls.filter(([storageKey]) => storageKey === FUNDS_STORAGE_KEY),
|
||||
).toHaveLength(1)
|
||||
expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual(
|
||||
result.map((bearer) => bearer.id),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MAX_ACTIVITY_ENTRIES,
|
||||
} from './storage'
|
||||
import {saveLinkingKey} from './keys'
|
||||
import {FUNDS_STORAGE_KEY} from './storage/bearers'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
@@ -52,7 +53,7 @@ describe('encrypted bearer records', () => {
|
||||
await persistBearer(key, bearer)
|
||||
|
||||
// at rest, nothing plaintext leaks: no k1, no amounts
|
||||
const raw = requiredValue(localStorage.getItem('sattle_bearers'))
|
||||
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
expect(raw).not.toContain(K1_A)
|
||||
expect(raw).not.toContain('21000')
|
||||
|
||||
@@ -233,19 +234,22 @@ describe('backup', () => {
|
||||
applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}),
|
||||
).rejects.toThrow()
|
||||
await expect(applyBackup(null)).rejects.toThrow()
|
||||
await expect(applyBackup({type: 'sattle-backup', version: 1, bearers: []})).rejects.toThrow()
|
||||
// a v2 file without the counter map is not a valid backup either
|
||||
await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('skips malformed records instead of failing the whole restore', async () => {
|
||||
const result = await applyBackup({
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
version: 2,
|
||||
createdAt: 1,
|
||||
bearers: [
|
||||
{id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
|
||||
{id: 42, iv: null, ciphertext: 'xx'},
|
||||
'garbage',
|
||||
],
|
||||
nextByHost: {},
|
||||
})
|
||||
expect(result.added).toBe(1)
|
||||
expect(result.skipped).toBe(2)
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
// the Pinia mints store and this module's backup both use it.
|
||||
//
|
||||
// Split by concern; this façade re-exports everything:
|
||||
// storage/bearers.ts - encrypted bearer records, changeset commits,
|
||||
// mergeBearers
|
||||
// storage/bearers.ts - the funds document (sattle_funds_v2): encrypted
|
||||
// bearer records, pending journal, BIP-32 counters;
|
||||
// locked single-write commit primitives, mergeBearers
|
||||
// storage/activityLog.ts - the append-only encrypted activity log
|
||||
// storage/settings.ts - plaintext wallet settings
|
||||
// storage/backup.ts - buildBackup / applyBackup
|
||||
@@ -17,14 +18,30 @@ export {
|
||||
compareBearerOrder,
|
||||
newBearerId,
|
||||
readEncryptedBearers,
|
||||
readFundsDocument,
|
||||
readFundsRevision,
|
||||
readNextByHost,
|
||||
readPendingJournal,
|
||||
loadBearers,
|
||||
persistBearer,
|
||||
deleteBearerRecord,
|
||||
applyBearerChangeset,
|
||||
reserveCashIndices,
|
||||
commitFundsRestore,
|
||||
clearAllBearers,
|
||||
mergeBearers,
|
||||
FUNDS_STORAGE_KEY,
|
||||
} from './storage/bearers'
|
||||
export type {
|
||||
BearerChangeset,
|
||||
EncryptedBearerRecord,
|
||||
EncryptedJournalRecord,
|
||||
FundsReservation,
|
||||
FundsRestoreMerge,
|
||||
PendingStaging,
|
||||
ReservedCashRange,
|
||||
StoredFundsV2,
|
||||
} from './storage/bearers'
|
||||
export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers'
|
||||
|
||||
export {
|
||||
newActivityId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
import type {Bearer} from '../types'
|
||||
import {deriveBearerAesKey} from '../keys'
|
||||
import {deleteBearerRecord, newBearerId, persistBearer} from '../storage'
|
||||
import {FUNDS_STORAGE_KEY} from './bearers'
|
||||
import {stubLocalStorage} from '../test-utils'
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
@@ -19,11 +20,14 @@ const bearerFixture = (): Bearer => ({
|
||||
describe('baseline: per-record bearer persistence', () => {
|
||||
it('persistBearer/deleteBearerRecord perform one write per call', async () => {
|
||||
const storage = stubLocalStorage()
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)},
|
||||
})
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const writes = vi.spyOn(storage, 'setItem')
|
||||
await persistBearer(key, {...bearerFixture(), id: 'a'})
|
||||
await persistBearer(key, {...bearerFixture(), id: 'b'})
|
||||
await deleteBearerRecord('a')
|
||||
expect(writes.mock.calls.filter(([keyName]) => keyName === 'sattle_bearers')).toHaveLength(3)
|
||||
expect(writes.mock.calls.filter(([keyName]) => keyName === FUNDS_STORAGE_KEY)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,15 +10,22 @@ import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {deriveBearerAesKey, encryptRecord} from '../keys'
|
||||
import {StorageLocksUnavailableError} from '../storageLock'
|
||||
import {
|
||||
applyBearerChangeset,
|
||||
loadBearers,
|
||||
newBearerId,
|
||||
persistBearer,
|
||||
readEncryptedBearers,
|
||||
reserveCashIndices,
|
||||
} from '../storage'
|
||||
import type {BearerChangeset} from '../storage'
|
||||
import {writeEncryptedBearers} from './bearers'
|
||||
import {
|
||||
FUNDS_STORAGE_KEY,
|
||||
MAX_COUNTER_HOSTS,
|
||||
readFundsDocument,
|
||||
writeFundsDocument,
|
||||
} from './bearers'
|
||||
import {requiredValue, stubLocalStorage} from '../test-utils'
|
||||
import type {LocalStorageStub} from '../test-utils'
|
||||
import './bearers.baseline.cases'
|
||||
@@ -50,16 +57,159 @@ const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// a present-but-non-serializing LockManager fake: enough for single-writer
|
||||
// tests that must not trip the no-locks refusal
|
||||
const stubPassthroughLocks = (): void => {
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn)},
|
||||
})
|
||||
}
|
||||
|
||||
type LockRequest = {
|
||||
readonly name: string
|
||||
readonly callback: () => unknown
|
||||
readonly resolve: (value: unknown) => void
|
||||
readonly reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
// a controllable Web Locks fake: lock requests park until the test releases
|
||||
// them, so a foreign write can interleave deterministically
|
||||
class DeferredLocks {
|
||||
readonly requests: LockRequest[] = []
|
||||
|
||||
readonly request = (name: string, callback: () => unknown): Promise<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
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
stub = stubLocalStorage()
|
||||
})
|
||||
|
||||
const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] =>
|
||||
spy.mock.calls.filter(([k]) => k === 'sattle_bearers')
|
||||
const fundsWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] =>
|
||||
spy.mock.calls.filter(([k]) => k === FUNDS_STORAGE_KEY)
|
||||
|
||||
describe('stored funds document', () => {
|
||||
it('persists one strict v2 document holding bearers, pending, counters and revision', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'a'}))
|
||||
|
||||
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
const doc: unknown = JSON.parse(raw)
|
||||
expect(doc).toEqual({
|
||||
version: 2,
|
||||
bearers: [expect.objectContaining({id: 'a'})],
|
||||
pending: [],
|
||||
nextByHost: {},
|
||||
revision: 1,
|
||||
})
|
||||
// strict: exactly these five keys, and the legacy bearer key is gone
|
||||
expect(Object.keys(requiredValue(doc as object)).sort()).toEqual([
|
||||
'bearers',
|
||||
'nextByHost',
|
||||
'pending',
|
||||
'revision',
|
||||
'version',
|
||||
])
|
||||
expect(localStorage.getItem('sattle_bearers')).toBeNull()
|
||||
})
|
||||
|
||||
it('bumps the revision on every committed write', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'a'}))
|
||||
await persistBearer(key, bearerFixture({id: 'b'}))
|
||||
expect(readFundsDocument().revision).toBe(2)
|
||||
})
|
||||
|
||||
it('reads work without Web Locks', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'a'}))
|
||||
vi.stubGlobal('navigator', {})
|
||||
|
||||
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['a'])
|
||||
expect(await loadBearers(key)).toHaveLength(1)
|
||||
expect(readFundsDocument().revision).toBe(1)
|
||||
})
|
||||
|
||||
it('treats a malformed document as empty instead of throwing', () => {
|
||||
localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify({version: 2, bearers: 'junk'}))
|
||||
expect(readFundsDocument()).toEqual({
|
||||
version: 2,
|
||||
bearers: [],
|
||||
pending: [],
|
||||
nextByHost: {},
|
||||
revision: 0,
|
||||
})
|
||||
localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{')
|
||||
expect(readFundsDocument().bearers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('required locks', () => {
|
||||
it('rejects a changeset mutation before any encryption when Web Locks are unavailable', async () => {
|
||||
stubPassthroughLocks()
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
vi.stubGlobal('navigator', {})
|
||||
const encrypt = vi.spyOn(crypto.subtle, 'encrypt')
|
||||
|
||||
await expect(
|
||||
applyBearerChangeset(key, [], {add: [newBearerFixture()], markSpent: []}),
|
||||
).rejects.toBeInstanceOf(StorageLocksUnavailableError)
|
||||
|
||||
expect(encrypt).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
})
|
||||
|
||||
it('rejects per-record writes and reservations without Web Locks', async () => {
|
||||
vi.stubGlobal('navigator', {})
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const encrypt = vi.spyOn(crypto.subtle, 'encrypt')
|
||||
|
||||
await expect(persistBearer(key, bearerFixture({id: 'a'}))).rejects.toBeInstanceOf(
|
||||
StorageLocksUnavailableError,
|
||||
)
|
||||
await expect(
|
||||
reserveCashIndices(key, {host: 'mint.example', count: 1}, () => ({
|
||||
kind: 'cash-allocation',
|
||||
phase: 'reserved',
|
||||
payload: {},
|
||||
})),
|
||||
).rejects.toBeInstanceOf(StorageLocksUnavailableError)
|
||||
|
||||
expect(encrypt).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
beforeEach(() => {
|
||||
stubPassthroughLocks()
|
||||
})
|
||||
|
||||
it('commits additions and spent replacements with exactly one write', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const oldA = bearerFixture({id: 'old-a'})
|
||||
@@ -83,8 +233,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
markSpent: ['old-a', 'old-b'],
|
||||
})
|
||||
|
||||
// the whole changeset is ONE setItem on sattle_bearers
|
||||
expect(bearerWrites(writes)).toHaveLength(1)
|
||||
// the whole changeset is ONE setItem on the funds document
|
||||
expect(fundsWrites(writes)).toHaveLength(1)
|
||||
|
||||
// the returned next list: additions first, then the snapshot with spent
|
||||
// marks applied
|
||||
@@ -110,7 +260,7 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
expect(requiredValue(reloaded.find((b) => b.id === 'old-b')).spent).toBe(true)
|
||||
expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined()
|
||||
// nothing plaintext leaked: the fresh k1s are ciphertext-only at rest
|
||||
const raw = requiredValue(localStorage.getItem('sattle_bearers'))
|
||||
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
expect(raw).not.toContain(K1_C)
|
||||
expect(raw).not.toContain(K1_D)
|
||||
})
|
||||
@@ -133,6 +283,7 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
it('persists nothing when encryption fails', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
// a decrypt-only key makes every AES-GCM encrypt call reject
|
||||
const decryptOnly = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -150,7 +301,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(bearerWrites(writes)).toHaveLength(0)
|
||||
expect(fundsWrites(writes)).toHaveLength(0)
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
@@ -158,30 +310,20 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const kept = bearerFixture({id: 'kept'})
|
||||
await persistBearer(key, kept)
|
||||
const before = readEncryptedBearers()
|
||||
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
stub.setItem = (): void => {
|
||||
throw new Error('QuotaExceededError')
|
||||
}
|
||||
// run this one through the unlocked fallback on purpose: a quota throw
|
||||
// is SYNCHRONOUS, and Node 24's real navigator.locks never releases a
|
||||
// lock whose callback throws synchronously (verified Node quirk - every
|
||||
// browser releases per the Web Locks spec), which would wedge
|
||||
// 'sattle_bearers' for the rest of the file. Bonus: one test keeps the
|
||||
// documented plain-Node fallback path (storageLock.ts) exercised.
|
||||
vi.stubGlobal('navigator', {})
|
||||
try {
|
||||
await expect(
|
||||
applyBearerChangeset(key, [kept], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['kept'],
|
||||
}),
|
||||
).rejects.toThrow('QuotaExceededError')
|
||||
|
||||
// nothing was persisted: the pre-existing record is byte-identical
|
||||
expect(readEncryptedBearers()).toEqual(before)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
await expect(
|
||||
applyBearerChangeset(key, [kept], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['kept'],
|
||||
}),
|
||||
).rejects.toThrow('QuotaExceededError')
|
||||
|
||||
// nothing was persisted: the document is byte-identical
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
})
|
||||
|
||||
it('upserts changed ids and dedupes repeated markSpent ids', async () => {
|
||||
@@ -208,37 +350,19 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
const mine = bearerFixture({id: 'mine'})
|
||||
await persistBearer(key, mine)
|
||||
|
||||
// a controllable Web Locks fake: lock requests park until the test
|
||||
// releases them, so a foreign write can interleave deterministically
|
||||
const queue: {name: string; release: () => Promise<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)))
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
const locks = new DeferredLocks()
|
||||
locks.stub()
|
||||
try {
|
||||
const commit = applyBearerChangeset(key, [mine], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: [],
|
||||
})
|
||||
// encryption happens BEFORE the lock request; wait for it to arrive
|
||||
// the lock request arrives before any inside-lock work
|
||||
await vi.waitFor(() => {
|
||||
expect(queue).toHaveLength(1)
|
||||
expect(locks.requests).toHaveLength(1)
|
||||
})
|
||||
const queuedLock = requiredValue(queue[0])
|
||||
expect(queuedLock.name).toBe('sattle_bearers')
|
||||
const queuedLock = requiredValue(locks.requests[0])
|
||||
expect(queuedLock.name).toBe(FUNDS_STORAGE_KEY)
|
||||
|
||||
// while our commit waits on the lock, another tab commits a record we
|
||||
// cannot even decrypt (written under a different seed's key)
|
||||
@@ -248,9 +372,11 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
})
|
||||
const {id: foreignId, ...foreignPlain} = foreign
|
||||
const foreignParts = await encryptRecord(other, foreignPlain)
|
||||
writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}])
|
||||
const doc = readFundsDocument()
|
||||
doc.bearers.push({id: foreignId, ...foreignParts})
|
||||
writeFundsDocument(doc)
|
||||
|
||||
await queuedLock.release()
|
||||
await locks.releaseNext()
|
||||
const result = await commit
|
||||
|
||||
// the foreign ciphertext survived our upsert, untouched
|
||||
@@ -268,26 +394,71 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts the whole write when the inside-lock fence throws', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
await expect(
|
||||
applyBearerChangeset(
|
||||
key,
|
||||
[],
|
||||
{add: [newBearerFixture()], markSpent: []},
|
||||
{
|
||||
beforeCommit: () => {
|
||||
throw new Error('stale owner')
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('stale owner')
|
||||
|
||||
expect(fundsWrites(writes)).toHaveLength(0)
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
})
|
||||
|
||||
it('clears pending journal records in the same write as the changeset', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const staged = await reserveCashIndices(key, {host: 'mint.example', count: 2}, () => ({
|
||||
kind: 'cash-allocation',
|
||||
phase: 'reserved',
|
||||
payload: {host: 'mint.example', start: 0, count: 2},
|
||||
}))
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
await applyBearerChangeset(key, [], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: [],
|
||||
clearPending: [staged.pendingId],
|
||||
})
|
||||
|
||||
expect(fundsWrites(writes)).toHaveLength(1)
|
||||
expect(readFundsDocument().pending).toEqual([])
|
||||
expect(readFundsDocument().nextByHost).toEqual({'mint.example': 2})
|
||||
})
|
||||
|
||||
it('treats corrupted stored JSON as an empty record set instead of throwing', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
localStorage.setItem('sattle_bearers', 'not json {{{')
|
||||
localStorage.setItem(FUNDS_STORAGE_KEY, 'not json {{{')
|
||||
|
||||
const result = await applyBearerChangeset(key, [], {
|
||||
add: [newBearerFixture()],
|
||||
markSpent: ['gone'],
|
||||
})
|
||||
|
||||
// readEncryptedBearers' long-standing contract: unparseable storage
|
||||
// reads as [] (malformed entries are dropped, never thrown on) - the
|
||||
// changeset still commits and its single write replaces the corrupt blob
|
||||
// readFundsDocument's contract: unparseable storage reads as the empty
|
||||
// document - the changeset still commits and its single write replaces
|
||||
// the corrupt blob with a valid one
|
||||
expect(result).toHaveLength(1)
|
||||
expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id])
|
||||
expect(readFundsDocument().version).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores markSpent ids absent from the snapshot and writes nothing when nothing changed', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
await persistBearer(other, bearerFixture({id: 'foreign'}))
|
||||
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
// 'foreign' is not in the caller's snapshot; deriving its spent copy
|
||||
@@ -299,7 +470,148 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(bearerWrites(writes)).toHaveLength(0)
|
||||
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign'])
|
||||
expect(fundsWrites(writes)).toHaveLength(0)
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserveCashIndices (counter reservation plus staging)', () => {
|
||||
beforeEach(() => {
|
||||
stubPassthroughLocks()
|
||||
})
|
||||
|
||||
const stage = (reserved: {host: string; start: number; count: number}) => ({
|
||||
kind: 'cash-allocation',
|
||||
phase: 'reserved',
|
||||
payload: reserved,
|
||||
})
|
||||
|
||||
it('reserves a range and stages one encrypted pending record in a single write', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
const reserved = await reserveCashIndices(key, {host: 'mint.example', count: 3}, stage)
|
||||
|
||||
expect(reserved).toEqual({
|
||||
host: 'mint.example',
|
||||
start: 0,
|
||||
count: 3,
|
||||
pendingId: expect.any(String),
|
||||
})
|
||||
expect(fundsWrites(writes)).toHaveLength(1)
|
||||
const doc = readFundsDocument()
|
||||
expect(doc.nextByHost).toEqual({'mint.example': 3})
|
||||
expect(doc.revision).toBe(1)
|
||||
expect(doc.pending).toHaveLength(1)
|
||||
const record = requiredValue(doc.pending[0])
|
||||
expect(record.id).toBe(reserved.pendingId)
|
||||
expect(record.kind).toBe('cash-allocation')
|
||||
expect(record.phase).toBe('reserved')
|
||||
expect(typeof record.iv).toBe('string')
|
||||
expect(typeof record.ciphertext).toBe('string')
|
||||
// the staged payload is ciphertext-only at rest
|
||||
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
expect(raw).not.toContain('"start":0')
|
||||
})
|
||||
|
||||
it('hands two same-host tabs disjoint ranges under a contended lock', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const locks = new DeferredLocks()
|
||||
locks.stub()
|
||||
|
||||
// both tabs request the same lock; B's callback must not run (and must
|
||||
// not fresh-read) until A's reservation has fully landed
|
||||
const tabA = reserveCashIndices(key, {host: 'mint.example', count: 3}, stage)
|
||||
const tabB = reserveCashIndices(key, {host: 'mint.example', count: 2}, stage)
|
||||
await vi.waitFor(() => expect(locks.requests).toHaveLength(2))
|
||||
|
||||
await locks.releaseNext()
|
||||
// A committed; B is still parked on the lock with no counter consumed
|
||||
expect(readFundsDocument().nextByHost).toEqual({'mint.example': 3})
|
||||
await locks.releaseNext()
|
||||
|
||||
const [a, b] = await Promise.all([tabA, tabB])
|
||||
expect(a.start).toBe(0)
|
||||
expect(b.start).toBe(3)
|
||||
const doc = readFundsDocument()
|
||||
expect(doc.nextByHost).toEqual({'mint.example': 5})
|
||||
expect(doc.pending).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects counter overflow without writing or encrypting', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const doc = readFundsDocument()
|
||||
doc.nextByHost['mint.example'] = 2 ** 31 - 2
|
||||
writeFundsDocument(doc)
|
||||
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
const encrypt = vi.spyOn(crypto.subtle, 'encrypt')
|
||||
|
||||
await expect(
|
||||
reserveCashIndices(key, {host: 'mint.example', count: 3}, stage),
|
||||
).rejects.toThrow(/exhausted|overflow/i)
|
||||
|
||||
expect(fundsWrites(writes)).toHaveLength(0)
|
||||
expect(encrypt).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
})
|
||||
|
||||
it('rejects an overlong host and an eleventh counter host', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const before = localStorage.getItem(FUNDS_STORAGE_KEY)
|
||||
|
||||
await expect(
|
||||
reserveCashIndices(key, {host: `${'h'.repeat(253)}.example`, count: 1}, stage),
|
||||
).rejects.toThrow(/host/i)
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
|
||||
const doc = readFundsDocument()
|
||||
for (let i = 0; i < MAX_COUNTER_HOSTS; i += 1) {
|
||||
doc.nextByHost[`mint-${i}.example`] = 1
|
||||
}
|
||||
writeFundsDocument(doc)
|
||||
const filled = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
|
||||
await expect(
|
||||
reserveCashIndices(key, {host: 'mint-overflow.example', count: 1}, stage),
|
||||
).rejects.toThrow(/host/i)
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(filled)
|
||||
})
|
||||
|
||||
it('burns no index and writes nothing when the staging encryption fails', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'kept'}))
|
||||
const before = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
const decryptOnly = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new Uint8Array(32).fill(3),
|
||||
'AES-GCM',
|
||||
false,
|
||||
['decrypt'],
|
||||
)
|
||||
|
||||
await expect(
|
||||
reserveCashIndices(decryptOnly, {host: 'mint.example', count: 2}, stage),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(before)
|
||||
expect(readFundsDocument().nextByHost).toEqual({})
|
||||
expect(readFundsDocument().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('writes nothing when the inside-lock fence throws before reservation', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const writes = vi.spyOn(stub, 'setItem')
|
||||
|
||||
await expect(
|
||||
reserveCashIndices(key, {host: 'mint.example', count: 1}, stage, {
|
||||
beforeCommit: () => {
|
||||
throw new Error('stale owner')
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('stale owner')
|
||||
|
||||
expect(fundsWrites(writes)).toHaveLength(0)
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
// Encrypted bearer-note persistence: each note is an AES-GCM ciphertext
|
||||
// record under a key derived from the linking key (see keys.ts), so a note
|
||||
// URL - which IS the money - never touches disk in plaintext.
|
||||
// allow: SIZE_OK — one document, one lock, one write path; splitting the
|
||||
// schema from its commit primitive would invite drift between them.
|
||||
//
|
||||
// The funds document: every fund-critical fact lives in ONE localStorage
|
||||
// value, `sattle_funds_v2` - encrypted bearer records, the pending-mutation
|
||||
// journal (opaque encrypted records; the per-flow taxonomy is defined by the
|
||||
// recovery seam, not here), the per-host BIP-32 next-unused cash indices, and
|
||||
// a monotonic revision. A note URL - which IS the money - never touches disk
|
||||
// in plaintext: bearers and journal payloads are AES-GCM ciphertexts under a
|
||||
// key derived from the linking key (see keys.ts).
|
||||
//
|
||||
// Why one document: a counter bump without its staged outputs (or vice
|
||||
// versa) is how indices get burned twice or fresh secrets get lost. Because
|
||||
// everything shares one key, one locked fresh-read…write commits or fails as
|
||||
// a unit, and one getItem is a consistent snapshot for backups.
|
||||
//
|
||||
// Mutation discipline (fund-critical, non-negotiable):
|
||||
// - EVERY mutation runs inside withRequiredStorageLock on the document key:
|
||||
// fresh-read the document, re-validate, run the caller's owner fence,
|
||||
// reserve counter indices, derive/encrypt, then ONE setItem. There is no
|
||||
// unlocked fallback - without Web Locks a second tab could interleave a
|
||||
// fresh-read…write and burn the same BIP-32 index twice, so mutations fail
|
||||
// closed (StorageLocksUnavailableError) before any derivation, encryption,
|
||||
// or I/O. Reads never need the lock.
|
||||
// - encryption happens INSIDE the lock: the staged payload depends on the
|
||||
// reserved range, which only exists once the counter bump is serialized.
|
||||
// Web Locks callbacks may be async; holding the lock across crypto costs
|
||||
// latency but never correctness, and the alternative (encrypt first,
|
||||
// re-read later) cannot express counter reservation at all.
|
||||
// - any failure (crypto, quota, fence, overflow) rejects with the document
|
||||
// bytes untouched and zero network I/O performed.
|
||||
|
||||
import type {EncryptedRecordParts} from '../keys'
|
||||
import {encryptRecord, decryptRecord} from '../keys'
|
||||
import type {Bearer, NewBearer} from '../types'
|
||||
import {isJsonObject} from '../jsonParsing'
|
||||
import {noteK1, serverOf} from 'lnurlcash-kit'
|
||||
import {withStorageLock} from '../storageLock'
|
||||
import {
|
||||
StorageLocksUnavailableError,
|
||||
storageLocksAvailable,
|
||||
withRequiredStorageLock,
|
||||
} from '../storageLock'
|
||||
|
||||
// the wallet's default note order (newest first) with manually dragged
|
||||
// notes taking priority once they have an explicit rank
|
||||
@@ -16,12 +48,48 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number =>
|
||||
|
||||
export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts
|
||||
|
||||
// the forward-compatible pending-mutation journal slot: an opaque encrypted
|
||||
// payload behind a kind/phase pair. The per-flow variants (rotate, split,
|
||||
// merge, mint, melt-return, transfer) are defined by the recovery seam; this
|
||||
// layer only guarantees they persist atomically with everything else.
|
||||
export type EncryptedJournalRecord = {
|
||||
id: string
|
||||
kind: string
|
||||
phase: string
|
||||
} & EncryptedRecordParts
|
||||
|
||||
export const FUNDS_STORAGE_KEY = 'sattle_funds_v2'
|
||||
export const FUNDS_DOCUMENT_VERSION = 2 as const
|
||||
|
||||
// counter bounds: BIP-32 indices stay below the hardened offset; the host
|
||||
// map stays small enough that a corrupt or hostile restore cannot exhaust
|
||||
// quota or smuggle unbounded state (253 = DNS name ceiling)
|
||||
export const MAX_CASH_INDEX = 2 ** 31
|
||||
export const MAX_COUNTER_HOSTS = 10
|
||||
export const MAX_COUNTER_HOST_LENGTH = 253
|
||||
|
||||
export type StoredFundsV2 = {
|
||||
version: typeof FUNDS_DOCUMENT_VERSION
|
||||
bearers: EncryptedBearerRecord[]
|
||||
pending: EncryptedJournalRecord[]
|
||||
nextByHost: Record<string, number>
|
||||
revision: number
|
||||
}
|
||||
|
||||
const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord =>
|
||||
isJsonObject(value) &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.iv === 'string' &&
|
||||
typeof value.ciphertext === 'string'
|
||||
|
||||
const isEncryptedJournalRecord = (value: unknown): value is EncryptedJournalRecord =>
|
||||
isEncryptedBearerRecord(value) &&
|
||||
typeof (value as EncryptedJournalRecord).kind === 'string' &&
|
||||
typeof (value as EncryptedJournalRecord).phase === 'string'
|
||||
|
||||
const isValidCounter = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value < MAX_CASH_INDEX
|
||||
|
||||
const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
|
||||
isJsonObject(value) &&
|
||||
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.sortIndex === undefined || typeof value.sortIndex === 'number') &&
|
||||
(value.label === undefined || typeof value.label === 'string') &&
|
||||
(value.deviceId === undefined || typeof value.deviceId === 'string')
|
||||
|
||||
const BEARERS_STORAGE_KEY = 'sattle_bearers'
|
||||
(value.deviceId === undefined || typeof value.deviceId === 'string') &&
|
||||
(value.pendingMint === undefined ||
|
||||
(isJsonObject(value.pendingMint) &&
|
||||
(value.pendingMint.sourceBearerId === undefined ||
|
||||
typeof value.pendingMint.sourceBearerId === 'string') &&
|
||||
(value.pendingMint.sourceRecoverySecret === undefined ||
|
||||
typeof value.pendingMint.sourceRecoverySecret === 'string') &&
|
||||
(value.pendingMint.mintPubkey === undefined ||
|
||||
typeof value.pendingMint.mintPubkey === 'string') &&
|
||||
(value.pendingMint.retireAfter === undefined ||
|
||||
(typeof value.pendingMint.retireAfter === 'number' &&
|
||||
Number.isSafeInteger(value.pendingMint.retireAfter) &&
|
||||
value.pendingMint.retireAfter > 0))))
|
||||
|
||||
export const newBearerId = (): string =>
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(8)))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
|
||||
const raw = localStorage.getItem(BEARERS_STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : []
|
||||
} catch {
|
||||
return []
|
||||
const emptyFundsDocument = (): StoredFundsV2 => ({
|
||||
version: FUNDS_DOCUMENT_VERSION,
|
||||
bearers: [],
|
||||
pending: [],
|
||||
nextByHost: {},
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
// strict shape, tolerant read: anything that is not a well-formed v2
|
||||
// document reads as the empty document (the long-standing "never throw on
|
||||
// read" contract - a corrupt blob must not wedge the wallet), and malformed
|
||||
// entries inside a valid document are dropped, never thrown on
|
||||
const parseFundsDocument = (value: unknown): StoredFundsV2 | null => {
|
||||
if (!isJsonObject(value) || value.version !== FUNDS_DOCUMENT_VERSION) return null
|
||||
if (!Array.isArray(value.bearers) || !Array.isArray(value.pending)) return null
|
||||
if (!isJsonObject(value.nextByHost)) return null
|
||||
if (
|
||||
typeof value.revision !== 'number' ||
|
||||
!Number.isSafeInteger(value.revision) ||
|
||||
value.revision < 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const nextByHost: Record<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 => {
|
||||
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records))
|
||||
export const readFundsDocument = (): StoredFundsV2 => {
|
||||
const raw = localStorage.getItem(FUNDS_STORAGE_KEY)
|
||||
if (!raw) return emptyFundsDocument()
|
||||
try {
|
||||
return parseFundsDocument(JSON.parse(raw)) ?? emptyFundsDocument()
|
||||
} catch {
|
||||
return emptyFundsDocument()
|
||||
}
|
||||
}
|
||||
|
||||
export const writeFundsDocument = (doc: StoredFundsV2): void => {
|
||||
localStorage.setItem(FUNDS_STORAGE_KEY, JSON.stringify(doc))
|
||||
}
|
||||
|
||||
// COMPAT shim for the pre-counter backup path (storage/backup.ts before the
|
||||
// backup-projection commit): replaces the document's bearer array wholesale,
|
||||
// preserving pending journal and counters. Deleted together with that path's
|
||||
// last call site; new code writes through the locked commit primitives only.
|
||||
export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => {
|
||||
const doc = readFundsDocument()
|
||||
doc.bearers = records
|
||||
writeFundsDocument(doc)
|
||||
}
|
||||
|
||||
export const readEncryptedBearers = (): EncryptedBearerRecord[] => readFundsDocument().bearers
|
||||
|
||||
export const readPendingJournal = (): EncryptedJournalRecord[] => readFundsDocument().pending
|
||||
|
||||
export const readNextByHost = (): Record<string, number> => ({...readFundsDocument().nextByHost})
|
||||
|
||||
export const readFundsRevision = (): number => readFundsDocument().revision
|
||||
|
||||
type BearerCommitOptions = {beforeCommit?: () => void}
|
||||
|
||||
// mutations have no unlocked fallback: reject BEFORE any derivation or
|
||||
// encryption when cross-tab serialization cannot be guaranteed
|
||||
const requireMutationLocks = (): void => {
|
||||
if (!storageLocksAvailable()) throw new StorageLocksUnavailableError()
|
||||
}
|
||||
|
||||
// decrypts everything currently stored - a record that fails to decrypt
|
||||
// (e.g. written by a different seed's key) is skipped, not destroyed: it
|
||||
// stays in localStorage untouched and simply doesn't show up
|
||||
@@ -83,13 +223,16 @@ export const persistBearer = async (
|
||||
bearer: Bearer,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<void> => {
|
||||
requireMutationLocks()
|
||||
const {id, ...plain} = bearer
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => {
|
||||
const doc = readFundsDocument()
|
||||
options.beforeCommit?.()
|
||||
const records = readEncryptedBearers().filter((r) => r.id !== id)
|
||||
records.push({id, ...parts})
|
||||
writeEncryptedBearers(records)
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
doc.bearers = doc.bearers.filter((r) => r.id !== id)
|
||||
doc.bearers.push({id, ...parts})
|
||||
doc.revision += 1
|
||||
writeFundsDocument(doc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,51 +240,49 @@ export const deleteBearerRecord = async (
|
||||
id: string,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<void> => {
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
requireMutationLocks()
|
||||
await withRequiredStorageLock(FUNDS_STORAGE_KEY, () => {
|
||||
const doc = readFundsDocument()
|
||||
options.beforeCommit?.()
|
||||
writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id))
|
||||
doc.bearers = doc.bearers.filter((r) => r.id !== id)
|
||||
doc.revision += 1
|
||||
writeFundsDocument(doc)
|
||||
})
|
||||
}
|
||||
|
||||
// The atomic unit of bearer persistence: fresh notes to start tracking plus
|
||||
// ids of snapshot notes to lock as spent. Born-spent notes (carved and
|
||||
// melted away in one flow) are deliberately not representable - they were
|
||||
// never the wallet's money in a trackable state.
|
||||
// never the wallet's money in a trackable state. clearPending removes
|
||||
// pending-journal records (by id) in the SAME write, which is how a
|
||||
// finalized staged mutation retires its journal entry atomically with the
|
||||
// bearer changes that settle it.
|
||||
export type BearerChangeset = {
|
||||
add: NewBearer[]
|
||||
markSpent: string[]
|
||||
upsert?: Bearer[]
|
||||
remove?: string[]
|
||||
clearPending?: string[]
|
||||
}
|
||||
|
||||
// Commits a whole changeset as ONE storage write - the fund-critical
|
||||
// Commits a whole changeset as ONE funds-document write - the fund-critical
|
||||
// boundary a caller (NWC service, wallet store) awaits before reporting
|
||||
// success. The per-record path above (persistBearer in a loop) can die
|
||||
// halfway through a melt: some records persisted, some not, while the
|
||||
// caller's reactive state already moved on. Here nothing becomes
|
||||
// observable until the single locked write lands:
|
||||
// success. Nothing becomes observable until the single locked write lands:
|
||||
//
|
||||
// - every changed Bearer value is derived from the caller's snapshot: added
|
||||
// notes get their id/timestamps assigned HERE (so state and storage can
|
||||
// never disagree about them), spent marks copy the snapshot's record
|
||||
// - ALL encryption happens BEFORE the lock is taken - crypto is the slow,
|
||||
// async part and a storage lock must never be held across it (see
|
||||
// storageLock.ts); if any record fails to encrypt, no write happens at
|
||||
// all
|
||||
// - inside the lock the encrypted records are re-read FRESH, so records
|
||||
// another tab committed after the caller's snapshot survive the upsert -
|
||||
// changed ids replace their stored copy, everything else is kept as-is
|
||||
// (unrelated records are never decrypted or re-encrypted)
|
||||
// - the caller's snapshot is only a PLAN: inside the lock the document is
|
||||
// re-read FRESH, so records another tab committed after the snapshot
|
||||
// survive the upsert - changed ids replace their stored copy, everything
|
||||
// else is kept as-is (unrelated records are never decrypted or
|
||||
// re-encrypted)
|
||||
// - added notes get their id/timestamps assigned HERE (so state and storage
|
||||
// can never disagree about them), spent marks copy the snapshot's record
|
||||
// - the owner fence (options.beforeCommit) runs INSIDE the lock after the
|
||||
// fresh read and before any encryption: throwing aborts the commit with
|
||||
// the document untouched
|
||||
// - markSpent ids absent from the snapshot are ignored: deriving them would
|
||||
// require decrypting a record the caller doesn't hold
|
||||
// - a changeset that changes nothing performs no write at all
|
||||
// - caller arrays are never mutated
|
||||
// - options.beforeCommit runs synchronously INSIDE the lock, immediately
|
||||
// before the single write: the caller's last-chance fence (the wallet
|
||||
// store revalidates persisted ownership there). Throwing aborts the
|
||||
// commit with storage untouched. Four parameters are deliberate here:
|
||||
// the fence is an orthogonal hook, not changeset data, and grouping it
|
||||
// into the changeset would let callers persist it by accident.
|
||||
//
|
||||
// Returns the next local bearer list (additions first, then the snapshot
|
||||
// with spent marks applied) only after the write succeeded; on any failure
|
||||
@@ -168,26 +309,33 @@ export const applyBearerChangeset = async (
|
||||
}
|
||||
const upserted = changeset.upsert ?? []
|
||||
const removedIds = new Set(changeset.remove ?? [])
|
||||
const clearPendingIds = new Set(changeset.clearPending ?? [])
|
||||
const changedById = new Map<string, 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 added) changedById.set(bearer.id, bearer)
|
||||
const changed = [...changedById.values()]
|
||||
if (changed.length === 0 && removedIds.size === 0) return snapshot
|
||||
const encrypted: EncryptedBearerRecord[] = []
|
||||
for (const bearer of changed) {
|
||||
const {id, ...plain} = bearer
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
encrypted.push({id, ...parts})
|
||||
}
|
||||
await withStorageLock(BEARERS_STORAGE_KEY, () => {
|
||||
if (changed.length === 0 && removedIds.size === 0 && clearPendingIds.size === 0) return snapshot
|
||||
requireMutationLocks()
|
||||
await withRequiredStorageLock(FUNDS_STORAGE_KEY, async () => {
|
||||
const doc = readFundsDocument()
|
||||
options.beforeCommit?.()
|
||||
const encrypted: EncryptedBearerRecord[] = []
|
||||
for (const bearer of changed) {
|
||||
const {id, ...plain} = bearer
|
||||
const parts = await encryptRecord(aesKey, plain)
|
||||
encrypted.push({id, ...parts})
|
||||
}
|
||||
const changedIds = new Set(encrypted.map((r) => r.id))
|
||||
const records = readEncryptedBearers().filter(
|
||||
doc.bearers = doc.bearers.filter(
|
||||
(record) => !changedIds.has(record.id) && !removedIds.has(record.id),
|
||||
)
|
||||
records.push(...encrypted)
|
||||
writeEncryptedBearers(records)
|
||||
doc.bearers.push(...encrypted)
|
||||
if (clearPendingIds.size > 0) {
|
||||
doc.pending = doc.pending.filter((record) => !clearPendingIds.has(record.id))
|
||||
}
|
||||
doc.revision += 1
|
||||
writeFundsDocument(doc)
|
||||
})
|
||||
const snapshotIds = new Set(snapshot.map((bearer) => bearer.id))
|
||||
const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id))
|
||||
@@ -197,12 +345,131 @@ export const applyBearerChangeset = async (
|
||||
return [...added, ...inserted, ...retained]
|
||||
}
|
||||
|
||||
// ---- counter reservation ----
|
||||
|
||||
export type FundsReservation = {host: string; count: number}
|
||||
export type ReservedCashRange = {host: string; start: number; count: number}
|
||||
|
||||
// what the caller wants staged for the reserved range - the payload is
|
||||
// opaque to this layer (encrypted as-is); kind/phase let the recovery seam
|
||||
// route it later
|
||||
export type PendingStaging = {kind: string; phase: string; payload: object}
|
||||
|
||||
const assertValidReservation = ({host, count}: FundsReservation): void => {
|
||||
if (typeof host !== 'string' || host.length === 0 || host.length > MAX_COUNTER_HOST_LENGTH) {
|
||||
throw new Error(`A counter host is a canonical server name of at most ${MAX_COUNTER_HOST_LENGTH} characters.`)
|
||||
}
|
||||
if (!Number.isSafeInteger(count) || count < 1 || count >= MAX_CASH_INDEX) {
|
||||
throw new Error('A reservation count is a positive safe integer below 2^31.')
|
||||
}
|
||||
}
|
||||
|
||||
// Reserves `count` fresh BIP-32 indices for a canonical host and stages one
|
||||
// encrypted pending-journal record for them - allocation plus staging in ONE
|
||||
// locked write, before any mutation may reach a mint. The reserved range is
|
||||
// only known once the counter bump is serialized, so the caller's `stage`
|
||||
// callback runs INSIDE the lock with the assigned range (that is where the
|
||||
// secrets for [start, start+count) get derived and encrypted into the
|
||||
// payload). Burned gaps are never reused: a crash after this commit leaves
|
||||
// the counter advanced, which is safe - reuse never is.
|
||||
//
|
||||
// Rejects - with the document bytes untouched and no encryption performed -
|
||||
// on a stale owner fence, an overlong host, an eleventh counter host, and
|
||||
// counter exhaustion at the 2^31 BIP-32 ceiling.
|
||||
export const reserveCashIndices = async (
|
||||
aesKey: CryptoKey,
|
||||
reservation: FundsReservation,
|
||||
stage: (reserved: ReservedCashRange) => PendingStaging,
|
||||
options: BearerCommitOptions = {},
|
||||
): Promise<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
|
||||
// just the linking key, this is not recoverable by restoring the same seed:
|
||||
// the ciphertexts themselves are gone, so only a previously downloaded
|
||||
// backup file can bring them back
|
||||
export const clearAllBearers = (): void => {
|
||||
localStorage.removeItem(BEARERS_STORAGE_KEY)
|
||||
localStorage.removeItem(FUNDS_STORAGE_KEY)
|
||||
}
|
||||
|
||||
// Merge two decrypted bearer lists into one, keyed by note identity
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {describe, expect, it, vi} from 'vitest'
|
||||
|
||||
import {withStorageLock} from './storageLock'
|
||||
import {StorageLocksUnavailableError, withRequiredStorageLock, withStorageLock} from './storageLock'
|
||||
|
||||
describe('withStorageLock fallback', () => {
|
||||
it('runs unlocked when Web Locks are unavailable without promising serialization', async () => {
|
||||
@@ -28,3 +28,39 @@ describe('withStorageLock fallback', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('withRequiredStorageLock', () => {
|
||||
it('rejects before running the callback when Web Locks are unavailable', async () => {
|
||||
vi.stubGlobal('navigator', {})
|
||||
let ran = false
|
||||
try {
|
||||
await expect(
|
||||
withRequiredStorageLock('funds', () => {
|
||||
ran = true
|
||||
}),
|
||||
).rejects.toBeInstanceOf(StorageLocksUnavailableError)
|
||||
expect(ran).toBe(false)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes same-name callbacks across concurrent callers when locks exist', async () => {
|
||||
const entered: string[] = []
|
||||
let releaseFirst: (() => void) | undefined
|
||||
const first = withRequiredStorageLock('funds', async () => {
|
||||
entered.push('first')
|
||||
await new Promise<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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,3 +13,23 @@ export const withStorageLock = <T>(name: string, fn: () => T | Promise<T>): Prom
|
||||
|
||||
export const storageLocksAvailable = (): boolean =>
|
||||
typeof navigator !== 'undefined' && navigator.locks !== undefined
|
||||
|
||||
// fund-critical mutations have no fallback: without Web Locks a second tab
|
||||
// could interleave its own fresh-read…write between this tab's read and
|
||||
// write and burn the same BIP-32 indices twice (or resurrect a spent note).
|
||||
// So the funds document refuses to mutate at all where serialization cannot
|
||||
// be guaranteed - reads stay available, writes fail closed before any key
|
||||
// derivation, encryption, or network work has happened.
|
||||
export class StorageLocksUnavailableError extends Error {
|
||||
override readonly name = 'StorageLocksUnavailableError'
|
||||
|
||||
constructor() {
|
||||
super('This browser cannot guarantee exclusive storage access (Web Locks unavailable).')
|
||||
}
|
||||
}
|
||||
|
||||
export const withRequiredStorageLock = <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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user