feat(funds): persist BIP-32 counters atomically

This commit is contained in:
2026-09-05 08:22:02 +02:00
parent 7e4e67e1cd
commit 0cec423d6f
11 changed files with 1181 additions and 227 deletions
+29 -15
View File
@@ -16,6 +16,15 @@ import {
} from './storage'
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),
+6 -2
View File
@@ -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)
+20 -3
View File
@@ -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)
})
})
+365 -53
View File
@@ -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,18 +310,11 @@ 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()],
@@ -177,11 +322,8 @@ describe('applyBearerChangeset (single-write changeset commit)', () => {
}),
).rejects.toThrow('QuotaExceededError')
// nothing was persisted: the pre-existing record is byte-identical
expect(readEncryptedBearers()).toEqual(before)
} finally {
vi.unstubAllGlobals()
}
// 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()
})
})
+321 -54
View File
@@ -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
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})
}
await withStorageLock(BEARERS_STORAGE_KEY, () => {
options.beforeCommit?.()
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
+37 -1
View File
@@ -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'])
})
})
+20
View File
@@ -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))
}
+138 -5
View File
@@ -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({});
});
});
+46 -7
View File
@@ -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();
});
});
+127 -19
View File
@@ -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,10 +124,8 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
}
};
const addBearers = async (
notes: NewBearer[],
ownerFence: WalletOwnerFence,
): Promise<Bearer[]> => {
const addBearers = async (notes: NewBearer[], ownerFence: WalletOwnerFence): Promise<Bearer[]> =>
mutate(async () => {
options.setAuxiliaryError('');
ownerFence();
const next = await applyBearerChangeset(
@@ -99,12 +140,13 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
bearers.value = next;
await lockCommittedBearers(added);
return added;
};
});
const applyChangeset = async (
changeset: BearerChangeset,
ownerFence: WalletOwnerFence,
): Promise<Bearer[]> => {
): Promise<Bearer[]> =>
mutate(async () => {
options.setAuxiliaryError('');
ownerFence();
const next = await applyBearerChangeset(options.requireKey(), bearers.value, changeset, {
@@ -114,16 +156,41 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
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 updateBearer = async (
id: string,
changes: Partial<Omit<Bearer, 'id'>>,
ownerFence: WalletOwnerFence,
): Promise<void> => {
): Promise<void> =>
mutate(async () => {
options.setAuxiliaryError('');
const current = bearers.value.find((bearer) => bearer.id === id);
if (!current) return;
if (!current) throw new BearerNotFoundError();
ownerFence();
const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence });
@@ -137,7 +204,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
options.setAuxiliaryError(postCommitError.message);
throw postCommitError;
}
};
});
const markSpent = async (
id: string,
@@ -147,16 +214,18 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
await updateBearer(id, { spent }, ownerFence);
};
const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise<void> => {
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> => {
): Promise<void> =>
mutate(async () => {
ownerFence();
const merged = mergeBearers(bearers.value, incoming);
const mergedIds = new Set(merged.map((bearer) => bearer.id));
@@ -172,15 +241,53 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
{ 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,