feat: commit bearer changesets in one write

This commit is contained in:
2026-08-22 16:55:00 +02:00
parent 69fbdb68ee
commit 586d12b666
6 changed files with 773 additions and 61 deletions
@@ -0,0 +1,29 @@
import {describe, expect, it, vi} from 'vitest'
import {buildNoteUrl} from 'lnurlcash-kit'
import type {Bearer} from '../types'
import {deriveBearerAesKey} from '../keys'
import {deleteBearerRecord, newBearerId, persistBearer} from '../storage'
import {stubLocalStorage} from '../test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const K1 = 'aa'.repeat(32)
const bearerFixture = (): Bearer => ({
id: newBearerId(),
url: buildNoteUrl('https://mint.example/w', K1, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
})
describe('baseline: per-record bearer persistence', () => {
it('persistBearer/deleteBearerRecord perform one write per call', async () => {
const storage = stubLocalStorage()
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)
})
})
+305
View File
@@ -0,0 +1,305 @@
// Bearer changeset persistence: applyBearerChangeset (the single-write
// commit primitive) plus a baseline pin of the per-record write behavior it
// replaces at the call sites. Lives next to bearers.ts instead of inside
// ../storage.test.ts to keep both files under the project's module size
// ceiling. Runs in Node against an in-memory localStorage stub; WebCrypto
// (crypto.subtle) is native.
import {beforeEach, describe, expect, it, vi} from 'vitest'
import {buildNoteUrl} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import {deriveBearerAesKey, encryptRecord} from '../keys'
import {
applyBearerChangeset,
loadBearers,
newBearerId,
persistBearer,
readEncryptedBearers,
} from '../storage'
import type {BearerChangeset} from '../storage'
import {writeEncryptedBearers} from './bearers'
import {requiredValue, stubLocalStorage} from '../test-utils'
import type {LocalStorageStub} from '../test-utils'
import './bearers.baseline.cases'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const K1_C = 'cc'.repeat(32)
const K1_D = 'dd'.repeat(32)
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: newBearerId(),
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides,
})
const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
url: buildNoteUrl('https://mint.example/w', K1_C, 3_000),
callback: 'https://mint.example/w/cb',
amount: 3_000,
verified: true,
...overrides,
})
let stub: LocalStorageStub
beforeEach(() => {
stub = stubLocalStorage()
})
const bearerWrites = (spy: {mock: {calls: unknown[][]}}): unknown[][] =>
spy.mock.calls.filter(([k]) => k === 'sattle_bearers')
describe('applyBearerChangeset (single-write changeset commit)', () => {
it('commits additions and spent replacements with exactly one write', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const oldA = bearerFixture({id: 'old-a'})
const oldB = bearerFixture({
id: 'old-b',
url: buildNoteUrl('https://mint.example/w', K1_B, 5_000),
amount: 5_000,
})
await persistBearer(key, oldA)
await persistBearer(key, oldB)
const writes = vi.spyOn(stub, 'setItem')
const result = await applyBearerChangeset(key, [oldA, oldB], {
add: [
newBearerFixture(),
newBearerFixture({
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
amount: 4_000,
}),
],
markSpent: ['old-a', 'old-b'],
})
// the whole changeset is ONE setItem on sattle_bearers
expect(bearerWrites(writes)).toHaveLength(1)
// the returned next list: additions first, then the snapshot with spent
// marks applied
expect(result).toHaveLength(4)
const addA = requiredValue(result[0])
const addB = requiredValue(result[1])
const spentA = requiredValue(result[2])
const spentB = requiredValue(result[3])
expect(addA.id).not.toBe(addB.id)
expect(addA.amount).toBe(3_000)
expect(addB.amount).toBe(4_000)
expect(addA.createdAt).toBe(addA.updatedAt)
expect(spentA.id).toBe('old-a')
expect(spentA.spent).toBe(true)
expect(spentA.updatedAt).toBeGreaterThan(1000)
expect(spentB.id).toBe('old-b')
expect(spentB.spent).toBe(true)
// the source of truth is the reloaded ciphertext, not the return value
const reloaded = await loadBearers(key)
expect(reloaded.map((b) => b.id).sort()).toEqual(result.map((b) => b.id).sort())
expect(requiredValue(reloaded.find((b) => b.id === 'old-a')).spent).toBe(true)
expect(requiredValue(reloaded.find((b) => b.id === 'old-b')).spent).toBe(true)
expect(requiredValue(reloaded.find((b) => b.id === addA.id)).spent).toBeUndefined()
// nothing plaintext leaked: the fresh k1s are ciphertext-only at rest
const raw = requiredValue(localStorage.getItem('sattle_bearers'))
expect(raw).not.toContain(K1_C)
expect(raw).not.toContain(K1_D)
})
it('never mutates the caller snapshot or the changeset', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const snapshot = [bearerFixture({id: 's1'})]
const changeset: BearerChangeset = {
add: [newBearerFixture()],
markSpent: ['s1'],
}
await applyBearerChangeset(key, snapshot, changeset)
expect(requiredValue(snapshot[0]).spent).toBeUndefined()
expect('id' in requiredValue(changeset.add[0])).toBe(false)
expect(changeset.markSpent).toEqual(['s1'])
})
it('persists nothing when encryption fails', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
await persistBearer(key, bearerFixture({id: 'kept'}))
// a decrypt-only key makes every AES-GCM encrypt call reject
const decryptOnly = await crypto.subtle.importKey(
'raw',
new Uint8Array(32).fill(3),
'AES-GCM',
false,
['decrypt'],
)
const writes = vi.spyOn(stub, 'setItem')
await expect(
applyBearerChangeset(decryptOnly, [], {
add: [newBearerFixture()],
markSpent: [],
}),
).rejects.toThrow()
expect(bearerWrites(writes)).toHaveLength(0)
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['kept'])
})
it('rejects without a partial write when the storage write itself fails', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const kept = bearerFixture({id: 'kept'})
await persistBearer(key, kept)
const before = readEncryptedBearers()
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()
}
})
it('upserts changed ids and dedupes repeated markSpent ids', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const stale = bearerFixture({id: 'dup', updatedAt: 1000})
await persistBearer(key, stale)
const result = await applyBearerChangeset(key, [stale], {
add: [],
markSpent: ['dup', 'dup'],
})
// one record per id, never a duplicate append
expect(readEncryptedBearers().filter((r) => r.id === 'dup')).toHaveLength(1)
const reloaded = await loadBearers(key)
expect(reloaded).toHaveLength(1)
expect(requiredValue(reloaded[0]).spent).toBe(true)
expect(requiredValue(result[0]).spent).toBe(true)
})
it('preserves a record another tab commits between the snapshot and the lock', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const other = await deriveBearerAesKey(OTHER_KEY)
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)))
}
},
})
}),
},
})
try {
const commit = applyBearerChangeset(key, [mine], {
add: [newBearerFixture()],
markSpent: [],
})
// encryption happens BEFORE the lock request; wait for it to arrive
await vi.waitFor(() => {
expect(queue).toHaveLength(1)
})
const queuedLock = requiredValue(queue[0])
expect(queuedLock.name).toBe('sattle_bearers')
// while our commit waits on the lock, another tab commits a record we
// cannot even decrypt (written under a different seed's key)
const foreign = bearerFixture({
id: 'foreign-tab',
url: buildNoteUrl('https://mint.example/w', K1_B, 9_000),
})
const {id: foreignId, ...foreignPlain} = foreign
const foreignParts = await encryptRecord(other, foreignPlain)
writeEncryptedBearers([...readEncryptedBearers(), {id: foreignId, ...foreignParts}])
await queuedLock.release()
const result = await commit
// the foreign ciphertext survived our upsert, untouched
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['foreign-tab', 'mine', requiredValue(result[0]).id].sort())
expect((await loadBearers(other)).map((b) => b.id)).toEqual(['foreign-tab'])
expect((await loadBearers(key)).map((b) => b.id).sort()).toEqual(
['mine', requiredValue(result[0]).id].sort(),
)
} finally {
vi.unstubAllGlobals()
}
})
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 {{{')
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
expect(result).toHaveLength(1)
expect((await loadBearers(key)).map((b) => b.id)).toEqual([requiredValue(result[0]).id])
})
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 writes = vi.spyOn(stub, 'setItem')
// 'foreign' is not in the caller's snapshot; deriving its spent copy
// would require decrypting an unrelated record, which this primitive
// never does - so the changeset changes nothing and performs no write
const result = await applyBearerChangeset(key, [], {
add: [],
markSpent: ['foreign'],
})
expect(result).toEqual([])
expect(bearerWrites(writes)).toHaveLength(0)
expect(readEncryptedBearers().map((r) => r.id)).toEqual(['foreign'])
})
})
+136 -16
View File
@@ -4,7 +4,8 @@
import type {EncryptedRecordParts} from '../keys'
import {encryptRecord, decryptRecord} from '../keys'
import type {Bearer} from '../types'
import type {Bearer, NewBearer} from '../types'
import {isJsonObject} from '../jsonParsing'
import {noteK1, serverOf} from 'lnurlcash-kit'
import {withStorageLock} from '../storageLock'
@@ -15,11 +16,31 @@ export const compareBearerOrder = (a: Bearer, b: Bearer): number =>
export type EncryptedBearerRecord = {id: string} & EncryptedRecordParts
const isEncryptedBearerRecord = (value: unknown): value is EncryptedBearerRecord =>
isJsonObject(value) &&
typeof value.id === 'string' &&
typeof value.iv === 'string' &&
typeof value.ciphertext === 'string'
const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
isJsonObject(value) &&
typeof value.url === 'string' &&
typeof value.callback === 'string' &&
typeof value.amount === 'number' &&
typeof value.verified === 'boolean' &&
typeof value.createdAt === 'number' &&
typeof value.updatedAt === 'number' &&
(value.mintPubkey === undefined || typeof value.mintPubkey === 'string') &&
(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'
export const newBearerId = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map(b => b.toString(16).padStart(2, '0'))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
@@ -27,18 +48,18 @@ export const readEncryptedBearers = (): EncryptedBearerRecord[] => {
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
return Array.isArray(parsed) ? parsed.filter(isEncryptedBearerRecord) : []
} catch {
return []
}
}
export const writeEncryptedBearers = (
records: EncryptedBearerRecord[]
): void => {
export const writeEncryptedBearers = (records: EncryptedBearerRecord[]): void => {
localStorage.setItem(BEARERS_STORAGE_KEY, JSON.stringify(records))
}
type BearerCommitOptions = {beforeCommit?: () => void}
// 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
@@ -46,10 +67,12 @@ export const loadBearers = async (aesKey: CryptoKey): Promise<Bearer[]> => {
const bearers: Bearer[] = []
for (const record of readEncryptedBearers()) {
try {
const bearer = await decryptRecord<Omit<Bearer, 'id'>>(aesKey, record)
const bearer = await decryptRecord(aesKey, record)
if (!isStoredBearer(bearer)) throw new Error('Malformed encrypted bearer record.')
bearers.push({...bearer, id: record.id})
} catch {
} catch (error) {
// undecryptable with this key - leave it in place
if (!(error instanceof Error)) throw error
}
}
return bearers.sort((a, b) => b.createdAt - a.createdAt)
@@ -57,23 +80,123 @@ export const loadBearers = async (aesKey: CryptoKey): Promise<Bearer[]> => {
export const persistBearer = async (
aesKey: CryptoKey,
bearer: Bearer
bearer: Bearer,
options: BearerCommitOptions = {},
): Promise<void> => {
const {id, ...plain} = bearer
const parts = await encryptRecord(aesKey, plain)
await withStorageLock(BEARERS_STORAGE_KEY, () => {
const records = readEncryptedBearers().filter(r => r.id !== id)
options.beforeCommit?.()
const records = readEncryptedBearers().filter((r) => r.id !== id)
records.push({id, ...parts})
writeEncryptedBearers(records)
})
}
export const deleteBearerRecord = async (id: string): Promise<void> => {
export const deleteBearerRecord = async (
id: string,
options: BearerCommitOptions = {},
): Promise<void> => {
await withStorageLock(BEARERS_STORAGE_KEY, () => {
writeEncryptedBearers(readEncryptedBearers().filter(r => r.id !== id))
options.beforeCommit?.()
writeEncryptedBearers(readEncryptedBearers().filter((r) => r.id !== id))
})
}
// 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.
export type BearerChangeset = {
add: NewBearer[]
markSpent: string[]
upsert?: Bearer[]
remove?: string[]
}
// Commits a whole changeset as ONE storage 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:
//
// - 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)
// - 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
// the promise rejects and persisted state is untouched.
export const applyBearerChangeset = async (
aesKey: CryptoKey,
snapshot: Bearer[],
changeset: BearerChangeset,
options: BearerCommitOptions = {},
): Promise<Bearer[]> => {
const now = Date.now()
const added: Bearer[] = changeset.add.map((note) => ({
id: newBearerId(),
...note,
createdAt: now,
updatedAt: now,
}))
const spentIds = new Set(changeset.markSpent)
const spent = new Map<string, Bearer>()
for (const bearer of snapshot) {
if (spentIds.has(bearer.id)) {
spent.set(bearer.id, {...bearer, spent: true, updatedAt: now})
}
}
const upserted = changeset.upsert ?? []
const removedIds = new Set(changeset.remove ?? [])
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, () => {
options.beforeCommit?.()
const changedIds = new Set(encrypted.map((r) => r.id))
const records = readEncryptedBearers().filter(
(record) => !changedIds.has(record.id) && !removedIds.has(record.id),
)
records.push(...encrypted)
writeEncryptedBearers(records)
})
const snapshotIds = new Set(snapshot.map((bearer) => bearer.id))
const inserted = upserted.filter((bearer) => !snapshotIds.has(bearer.id))
const retained = snapshot
.filter((bearer) => !removedIds.has(bearer.id))
.map((bearer) => changedById.get(bearer.id) ?? bearer)
return [...added, ...inserted, ...retained]
}
// 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
@@ -91,10 +214,7 @@ export const clearAllBearers = (): void => {
// same spent state, the newer updatedAt wins. This is the merge a restore
// (backup file now, nostr later) applies after its records decrypt, and it
// is what makes multi-device restores converge instead of duplicate.
export const mergeBearers = (
current: Bearer[],
incoming: Bearer[]
): Bearer[] => {
export const mergeBearers = (current: Bearer[], incoming: Bearer[]): Bearer[] => {
const keyOf = (b: Bearer): string => {
const k1 = noteK1(b.url)
return k1 ? `${serverOf(b.url)}#${k1}` : `id#${b.id}`