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))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||
import { buildNoteUrl, deriveCashRoot, deriveCashSecret } from 'lnurlcash-kit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { deriveBearerAesKey } from '@/lnurlcash/keys';
|
||||
import { loadBearers } from '@/lnurlcash/storage';
|
||||
import { loadBearers, readFundsDocument } from '@/lnurlcash/storage';
|
||||
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
|
||||
import { StorageLocksUnavailableError } from '@/lnurlcash/storageLock';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
const CASH_ROOT = deriveCashRoot(new Uint8Array(64).fill(1));
|
||||
|
||||
// a present-but-non-serializing LockManager fake: walletFunds serializes its
|
||||
// own mutations, so single-store tests only need locks to EXIST
|
||||
const stubPassthroughLocks = (): void => {
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
|
||||
});
|
||||
};
|
||||
|
||||
const note = (secret: string): NewBearer => ({
|
||||
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
@@ -29,12 +41,45 @@ const rejectSecondEncryption = (): void => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
stubLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('wallet multi-bearer durability', () => {
|
||||
it('keeps both concurrent additions in reactive and persisted state', async () => {
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const encrypt = crypto.subtle.encrypt.bind(crypto.subtle);
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstPaused = new Promise<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 () => {
|
||||
const storage = stubLocalStorage();
|
||||
const wallet = useWalletStore();
|
||||
@@ -43,7 +88,7 @@ describe('wallet multi-bearer durability', () => {
|
||||
|
||||
await wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence());
|
||||
|
||||
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
|
||||
expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
|
||||
expect(wallet.bearers).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -105,7 +150,95 @@ describe('wallet multi-bearer durability', () => {
|
||||
|
||||
await wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence());
|
||||
|
||||
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
|
||||
expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
|
||||
expect(wallet.bearers).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wallet BIP-32 counter allocation', () => {
|
||||
it('reserves a range, derives its secrets, and stages the journal record in one write', async () => {
|
||||
const storage = stubLocalStorage();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const revisionBefore = wallet.fundsRevision;
|
||||
const writes = vi.spyOn(storage, 'setItem');
|
||||
|
||||
const allocated = await wallet.allocateCashSecrets(
|
||||
CASH_ROOT,
|
||||
'mint.example',
|
||||
3,
|
||||
wallet.captureOwnerFence(),
|
||||
);
|
||||
|
||||
expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
|
||||
expect(allocated.host).toBe('mint.example');
|
||||
expect(allocated.start).toBe(0);
|
||||
expect(allocated.secrets).toEqual([
|
||||
deriveCashSecret(CASH_ROOT, 'mint.example', 0),
|
||||
deriveCashSecret(CASH_ROOT, 'mint.example', 1),
|
||||
deriveCashSecret(CASH_ROOT, 'mint.example', 2),
|
||||
]);
|
||||
const doc = readFundsDocument();
|
||||
expect(doc.nextByHost).toEqual({ 'mint.example': 3 });
|
||||
expect(doc.pending).toHaveLength(1);
|
||||
expect(doc.pending[0]?.id).toBe(allocated.pendingId);
|
||||
expect(doc.pending[0]?.kind).toBe('cash-allocation');
|
||||
// counter-only commits are observable to the nostr-backup scheduler
|
||||
expect(wallet.fundsRevision).toBeGreaterThan(revisionBefore);
|
||||
// the staged secrets never touch disk in plaintext
|
||||
const raw = storage.getItem(FUNDS_STORAGE_KEY) ?? '';
|
||||
for (const secret of allocated.secrets) expect(raw).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('never hands out the same index twice across sequential reservations', async () => {
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const fence = wallet.captureOwnerFence();
|
||||
|
||||
const first = await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, fence);
|
||||
const second = await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, fence);
|
||||
const otherHost = await wallet.allocateCashSecrets(CASH_ROOT, 'other.example:9735', 1, fence);
|
||||
|
||||
expect(first.start).toBe(0);
|
||||
expect(second.start).toBe(2);
|
||||
expect(second.secrets[0]).toBe(deriveCashSecret(CASH_ROOT, 'mint.example', 2));
|
||||
// a different canonical host (port included) has its own counter
|
||||
expect(otherHost.start).toBe(0);
|
||||
expect(readFundsDocument().nextByHost).toEqual({
|
||||
'mint.example': 4,
|
||||
'other.example:9735': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('burns no index and writes nothing when the staging encryption fails', async () => {
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const before = readFundsDocument().revision;
|
||||
// the reservation performs exactly one encryption (the staged payload) -
|
||||
// reject them all
|
||||
vi.spyOn(crypto.subtle, 'encrypt').mockRejectedValue(new Error('crypto subsystem died'));
|
||||
|
||||
await expect(
|
||||
wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, wallet.captureOwnerFence()),
|
||||
).rejects.toThrow('crypto subsystem died');
|
||||
|
||||
const doc = readFundsDocument();
|
||||
expect(doc.nextByHost).toEqual({});
|
||||
expect(doc.pending).toEqual([]);
|
||||
expect(doc.revision).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects the allocation before derivation when Web Locks are unavailable', async () => {
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
vi.stubGlobal('navigator', {});
|
||||
const encrypt = vi.spyOn(crypto.subtle, 'encrypt');
|
||||
|
||||
await expect(
|
||||
wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 1, wallet.captureOwnerFence()),
|
||||
).rejects.toBeInstanceOf(StorageLocksUnavailableError);
|
||||
|
||||
expect(encrypt).not.toHaveBeenCalled();
|
||||
expect(readFundsDocument().nextByHost).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,24 @@ import { buildNoteUrl } from 'lnurlcash-kit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { deriveBearerAesKey, linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||
import { loadBearers, readEncryptedBearers } from '@/lnurlcash/storage';
|
||||
import { loadBearers, readEncryptedBearers, readFundsDocument } from '@/lnurlcash/storage';
|
||||
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
|
||||
import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner';
|
||||
import { StorageLocksUnavailableError } from '@/lnurlcash/storageLock';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import type { NewBearer } from '@/lnurlcash/types';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9));
|
||||
|
||||
// fund commits REQUIRE Web Locks now; tests that don't care about lock
|
||||
// timing install a present-but-non-serializing fake
|
||||
const stubPassthroughLocks = (): void => {
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
|
||||
});
|
||||
};
|
||||
|
||||
const note = (secret: string): NewBearer => ({
|
||||
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
|
||||
callback: 'https://mint.example/w/cb',
|
||||
@@ -71,7 +81,7 @@ beforeEach(() => {
|
||||
describe('stale-owner fencing of fund commits', () => {
|
||||
it('fences a changeset commit after a silent owner replacement', async () => {
|
||||
// Given an unlocked wallet with one bearer and no pending storage event
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
@@ -93,7 +103,7 @@ describe('stale-owner fencing of fund commits', () => {
|
||||
|
||||
it('fences a single-record spent mark after a silent owner replacement', async () => {
|
||||
// Given an unlocked wallet whose owner was silently replaced
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
@@ -114,7 +124,7 @@ describe('stale-owner fencing of fund commits', () => {
|
||||
|
||||
it('revalidates the owner inside the commit lock, after encryption', async () => {
|
||||
// Given a changeset commit whose storage write is held at the lock
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
@@ -143,7 +153,7 @@ describe('stale-owner fencing of fund commits', () => {
|
||||
it('rejects a fence captured by an earlier lifecycle of the same owner', async () => {
|
||||
// Given an operation accepted before the encrypted wallet locked and
|
||||
// unlocked again under the same persisted owner
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create('password');
|
||||
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence());
|
||||
@@ -164,7 +174,7 @@ describe('stale-owner fencing of fund commits', () => {
|
||||
});
|
||||
|
||||
it('revalidates a spent update inside its persistence lock', async () => {
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence());
|
||||
@@ -185,7 +195,7 @@ describe('stale-owner fencing of fund commits', () => {
|
||||
});
|
||||
|
||||
it('revalidates a deletion inside its persistence lock', async () => {
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubPassthroughLocks();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const [existing] = await wallet.addBearers([note('aa')], wallet.captureOwnerFence());
|
||||
@@ -203,4 +213,33 @@ describe('stale-owner fencing of fund commits', () => {
|
||||
expect(readEncryptedBearers()).toHaveLength(1);
|
||||
expect(wallet.bearers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a fund commit entirely when Web Locks are unavailable', async () => {
|
||||
// Given an unlocked wallet with one bearer, in a browser without Web Locks
|
||||
stubPassthroughLocks();
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const [existing] = await wallet.addBearers([note('aa')], ownerFence);
|
||||
if (!existing) throw new Error('Expected the initial bearer.');
|
||||
const bytesBefore = localStorage.getItem(FUNDS_STORAGE_KEY);
|
||||
|
||||
// When the environment cannot serialize storage access across tabs
|
||||
vi.stubGlobal('navigator', {});
|
||||
|
||||
// Then every mutation rejects before derivation, and the document is
|
||||
// byte-identical - a no-lock commit could burn a BIP-32 index twice
|
||||
await expect(
|
||||
wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence),
|
||||
).rejects.toBeInstanceOf(StorageLocksUnavailableError);
|
||||
await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf(
|
||||
StorageLocksUnavailableError,
|
||||
);
|
||||
await expect(wallet.removeNote(existing.id, ownerFence)).rejects.toBeInstanceOf(
|
||||
StorageLocksUnavailableError,
|
||||
);
|
||||
expect(localStorage.getItem(FUNDS_STORAGE_KEY)).toBe(bytesBefore);
|
||||
expect(readFundsDocument().revision).toBe(1);
|
||||
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+183
-75
@@ -1,17 +1,21 @@
|
||||
// allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary.
|
||||
import { computed, ref } from 'vue';
|
||||
import { serverOf } from 'lnurlcash-kit';
|
||||
import { deriveCashSecret, serverOf } from 'lnurlcash-kit';
|
||||
import type { CashNode } from 'lnurlcash-kit';
|
||||
|
||||
import {
|
||||
applyBearerChangeset,
|
||||
deleteBearerRecord,
|
||||
loadBearers,
|
||||
mergeBearers,
|
||||
persistBearer,
|
||||
readFundsRevision,
|
||||
reserveCashIndices,
|
||||
} from '@/lnurlcash/storage';
|
||||
import type { BearerChangeset } from '@/lnurlcash/storage';
|
||||
import { lockTrustedMint } from '@/lnurlcash/trustedMints';
|
||||
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
||||
import { msatToSats } from '@/lnurlcash/units';
|
||||
import type { CarveResult } from '@/lnurlcash/ops';
|
||||
import type { WalletOwnerFence } from './walletOwnerFence';
|
||||
|
||||
export class TrustedMintPostCommitError extends Error {
|
||||
@@ -29,6 +33,24 @@ export class TrustedMintPostCommitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class BearerNotFoundError extends Error {
|
||||
override readonly name = 'BearerNotFoundError';
|
||||
|
||||
constructor() {
|
||||
super('The wallet record no longer exists.');
|
||||
}
|
||||
}
|
||||
|
||||
// the result of one atomic counter reservation: the assigned BIP-32 index
|
||||
// range, the secrets derived at those indices, and the staged journal record
|
||||
// that makes the reservation recoverable after a crash
|
||||
export type AllocatedCashSecrets = {
|
||||
readonly host: string;
|
||||
readonly start: number;
|
||||
readonly secrets: string[];
|
||||
readonly pendingId: string;
|
||||
};
|
||||
|
||||
type WalletFundsOptions = {
|
||||
readonly requireKey: () => CryptoKey;
|
||||
readonly ownerId: () => string | undefined;
|
||||
@@ -37,6 +59,25 @@ type WalletFundsOptions = {
|
||||
|
||||
export const createWalletFunds = (options: WalletFundsOptions) => {
|
||||
const bearers = ref<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 balanceMsat = computed(() =>
|
||||
unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0),
|
||||
@@ -60,10 +101,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
||||
|
||||
const replace = (loaded: Bearer[]): void => {
|
||||
bearers.value = loaded;
|
||||
syncRevision();
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
bearers.value = [];
|
||||
fundsRevision.value = 0;
|
||||
};
|
||||
|
||||
const lockCommittedBearers = async (committed: Bearer[]): Promise<void> => {
|
||||
@@ -81,63 +124,87 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
||||
}
|
||||
};
|
||||
|
||||
const addBearers = async (
|
||||
notes: NewBearer[],
|
||||
ownerFence: WalletOwnerFence,
|
||||
): Promise<Bearer[]> => {
|
||||
options.setAuxiliaryError('');
|
||||
ownerFence();
|
||||
const next = await applyBearerChangeset(
|
||||
options.requireKey(),
|
||||
bearers.value,
|
||||
{ add: notes, markSpent: [] },
|
||||
// re-prove ownership inside the lock: encryption is async, so the
|
||||
// entry check alone would leave a cross-tab replacement window open
|
||||
{ beforeCommit: ownerFence },
|
||||
);
|
||||
const added = next.slice(0, notes.length);
|
||||
bearers.value = next;
|
||||
await lockCommittedBearers(added);
|
||||
return added;
|
||||
};
|
||||
const addBearers = async (notes: NewBearer[], ownerFence: WalletOwnerFence): Promise<Bearer[]> =>
|
||||
mutate(async () => {
|
||||
options.setAuxiliaryError('');
|
||||
ownerFence();
|
||||
const next = await applyBearerChangeset(
|
||||
options.requireKey(),
|
||||
bearers.value,
|
||||
{ add: notes, markSpent: [] },
|
||||
// re-prove ownership inside the lock: encryption is async, so the
|
||||
// entry check alone would leave a cross-tab replacement window open
|
||||
{ beforeCommit: ownerFence },
|
||||
);
|
||||
const added = next.slice(0, notes.length);
|
||||
bearers.value = next;
|
||||
await lockCommittedBearers(added);
|
||||
return added;
|
||||
});
|
||||
|
||||
const applyChangeset = async (
|
||||
changeset: BearerChangeset,
|
||||
ownerFence: WalletOwnerFence,
|
||||
): Promise<Bearer[]> => {
|
||||
options.setAuxiliaryError('');
|
||||
ownerFence();
|
||||
const next = await applyBearerChangeset(options.requireKey(), bearers.value, changeset, {
|
||||
beforeCommit: ownerFence,
|
||||
): Promise<Bearer[]> =>
|
||||
mutate(async () => {
|
||||
options.setAuxiliaryError('');
|
||||
ownerFence();
|
||||
const next = await applyBearerChangeset(options.requireKey(), bearers.value, changeset, {
|
||||
beforeCommit: ownerFence,
|
||||
});
|
||||
const added = next.slice(0, changeset.add.length);
|
||||
bearers.value = next;
|
||||
await lockCommittedBearers(added);
|
||||
return added;
|
||||
});
|
||||
|
||||
const commitCarve = (carve: CarveResult, ownerFence: WalletOwnerFence): Promise<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 (
|
||||
id: string,
|
||||
changes: Partial<Omit<Bearer, 'id'>>,
|
||||
ownerFence: WalletOwnerFence,
|
||||
): Promise<void> => {
|
||||
options.setAuxiliaryError('');
|
||||
const current = bearers.value.find((bearer) => bearer.id === id);
|
||||
if (!current) return;
|
||||
ownerFence();
|
||||
const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
|
||||
await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence });
|
||||
bearers.value = bearers.value.map((bearer) => (bearer.id === id ? updated : bearer));
|
||||
if (!updated.mintPubkey) return;
|
||||
try {
|
||||
await lockTrustedMint(serverOf(updated.url), updated.mintPubkey, options.ownerId());
|
||||
} catch (error) {
|
||||
const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.');
|
||||
const postCommitError = new TrustedMintPostCommitError([updated], { cause });
|
||||
options.setAuxiliaryError(postCommitError.message);
|
||||
throw postCommitError;
|
||||
}
|
||||
};
|
||||
): Promise<void> =>
|
||||
mutate(async () => {
|
||||
options.setAuxiliaryError('');
|
||||
const current = bearers.value.find((bearer) => bearer.id === id);
|
||||
if (!current) throw new BearerNotFoundError();
|
||||
ownerFence();
|
||||
const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
|
||||
await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence });
|
||||
bearers.value = bearers.value.map((bearer) => (bearer.id === id ? updated : bearer));
|
||||
if (!updated.mintPubkey) return;
|
||||
try {
|
||||
await lockTrustedMint(serverOf(updated.url), updated.mintPubkey, options.ownerId());
|
||||
} catch (error) {
|
||||
const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.');
|
||||
const postCommitError = new TrustedMintPostCommitError([updated], { cause });
|
||||
options.setAuxiliaryError(postCommitError.message);
|
||||
throw postCommitError;
|
||||
}
|
||||
});
|
||||
|
||||
const markSpent = async (
|
||||
id: string,
|
||||
@@ -147,40 +214,80 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
||||
await updateBearer(id, { spent }, ownerFence);
|
||||
};
|
||||
|
||||
const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise<void> => {
|
||||
ownerFence();
|
||||
await deleteBearerRecord(id, { beforeCommit: ownerFence });
|
||||
bearers.value = bearers.value.filter((bearer) => bearer.id !== id);
|
||||
};
|
||||
const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise<void> =>
|
||||
mutate(async () => {
|
||||
ownerFence();
|
||||
await deleteBearerRecord(id, { beforeCommit: ownerFence });
|
||||
bearers.value = bearers.value.filter((bearer) => bearer.id !== id);
|
||||
});
|
||||
|
||||
const mergeExternalBearers = async (
|
||||
incoming: Bearer[],
|
||||
ownerFence: WalletOwnerFence,
|
||||
): Promise<void> => {
|
||||
ownerFence();
|
||||
const merged = mergeBearers(bearers.value, incoming);
|
||||
const mergedIds = new Set(merged.map((bearer) => bearer.id));
|
||||
await applyBearerChangeset(
|
||||
options.requireKey(),
|
||||
bearers.value,
|
||||
{
|
||||
add: [],
|
||||
markSpent: [],
|
||||
upsert: merged,
|
||||
remove: bearers.value.filter((bearer) => !mergedIds.has(bearer.id)).map(({ id }) => id),
|
||||
},
|
||||
{ beforeCommit: ownerFence },
|
||||
);
|
||||
bearers.value = merged;
|
||||
};
|
||||
): Promise<void> =>
|
||||
mutate(async () => {
|
||||
ownerFence();
|
||||
const merged = mergeBearers(bearers.value, incoming);
|
||||
const mergedIds = new Set(merged.map((bearer) => bearer.id));
|
||||
await applyBearerChangeset(
|
||||
options.requireKey(),
|
||||
bearers.value,
|
||||
{
|
||||
add: [],
|
||||
markSpent: [],
|
||||
upsert: merged,
|
||||
remove: bearers.value.filter((bearer) => !mergedIds.has(bearer.id)).map(({ id }) => id),
|
||||
},
|
||||
{ beforeCommit: ownerFence },
|
||||
);
|
||||
bearers.value = merged;
|
||||
});
|
||||
|
||||
const reloadBearers = async (): Promise<void> => {
|
||||
bearers.value = await loadBearers(options.requireKey());
|
||||
};
|
||||
// Reserves `count` fresh BIP-32 cash secrets for a canonical mint host and
|
||||
// stages the reservation in the pending journal - allocation plus staging
|
||||
// land in ONE locked document write (see lnurlcash/storage/bearers.ts), so
|
||||
// a crash after this resolves can neither double-spend an index nor lose
|
||||
// the staged secrets. The cash root stays a caller-supplied parameter: this
|
||||
// store never holds key material it doesn't need persistently. Secrets are
|
||||
// derived inside the storage lock (the range only exists once serialized)
|
||||
// and returned to the caller; they are never logged.
|
||||
const allocateCashSecrets = (
|
||||
cashRoot: CashNode,
|
||||
host: string,
|
||||
count: number,
|
||||
ownerFence: WalletOwnerFence,
|
||||
): Promise<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 {
|
||||
public: {
|
||||
bearers,
|
||||
fundsRevision,
|
||||
unspentBearers,
|
||||
balanceMsat,
|
||||
balanceSats,
|
||||
@@ -188,11 +295,12 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
||||
balanceByMintSats,
|
||||
addBearers,
|
||||
applyChangeset,
|
||||
commitCarve,
|
||||
updateBearer,
|
||||
allocateCashSecrets,
|
||||
markSpent,
|
||||
removeNote,
|
||||
mergeExternalBearers,
|
||||
reloadBearers,
|
||||
},
|
||||
replace,
|
||||
clear,
|
||||
|
||||
Reference in New Issue
Block a user