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
+249
View File
@@ -0,0 +1,249 @@
// Imported by storage.test.ts so the focused storage command exercises the
// fund-critical changeset boundary without mixing it into unrelated storage
// round-trip and backup cases.
import {describe, expect, it, vi} from 'vitest'
import {buildNoteUrl} from 'lnurlcash-kit'
import {deriveBearerAesKey, encryptRecord} from './keys'
import {
applyBearerChangeset,
deleteBearerRecord,
loadBearers,
newBearerId,
persistBearer,
readEncryptedBearers,
} from './storage'
import type {BearerChangeset} from './storage'
import type {Bearer, NewBearer} from './types'
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,
})
describe('baseline: per-record bearer persistence', () => {
it('writes once for every persist or delete call', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const writes = vi.spyOn(localStorage, 'setItem')
await persistBearer(key, bearerFixture({id: 'a'}))
await persistBearer(key, bearerFixture({id: 'b'}))
await deleteBearerRecord('a')
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(3)
})
})
describe('applyBearerChangeset', () => {
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 snapshot = [oldA, oldB]
const changeset: BearerChangeset = {
add: [
newBearerFixture(),
newBearerFixture({
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
amount: 4_000,
}),
],
markSpent: ['old-a', 'old-b'],
}
const writes = vi.spyOn(localStorage, 'setItem')
const result = await applyBearerChangeset(key, snapshot, changeset)
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(1)
expect(result).toHaveLength(4)
expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true])
expect(result[0]?.id).not.toBe(result[1]?.id)
expect(snapshot.map((bearer) => bearer.spent)).toEqual([undefined, undefined])
expect(changeset.markSpent).toEqual(['old-a', 'old-b'])
expect(changeset.add.some((note) => 'id' in note)).toBe(false)
expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual(
result.map((bearer) => bearer.id).sort(),
)
const raw = localStorage.getItem('sattle_bearers') ?? ''
expect(raw).not.toContain(K1_C)
expect(raw).not.toContain(K1_D)
})
it('writes nothing when the second record encryption fails', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
await persistBearer(key, bearerFixture({id: 'kept'}))
const writes = vi.spyOn(localStorage, 'setItem')
const encrypt = vi.spyOn(crypto.subtle, 'encrypt')
encrypt
.mockResolvedValueOnce(new ArrayBuffer(32))
.mockRejectedValueOnce(new Error('second encryption failed'))
try {
await expect(
applyBearerChangeset(key, [], {
add: [
newBearerFixture(),
newBearerFixture({
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
}),
],
markSpent: [],
}),
).rejects.toThrow('second encryption failed')
expect(encrypt).toHaveBeenCalledTimes(2)
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(0)
expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept'])
} finally {
encrypt.mockRestore()
}
})
it('rejects a failed storage write without changing persisted state', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const kept = bearerFixture({id: 'kept'})
await persistBearer(key, kept)
const before = localStorage.getItem('sattle_bearers')
const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
vi.stubGlobal('navigator', {})
try {
await expect(
applyBearerChangeset(key, [kept], {
add: [newBearerFixture()],
markSpent: ['kept'],
}),
).rejects.toThrow('QuotaExceededError')
expect(localStorage.getItem('sattle_bearers')).toBe(before)
} finally {
write.mockRestore()
vi.unstubAllGlobals()
}
})
it('deduplicates repeated spent ids into one stored replacement', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const bearer = bearerFixture({id: 'duplicate'})
await persistBearer(key, bearer)
const result = await applyBearerChangeset(key, [bearer], {
add: [],
markSpent: ['duplicate', 'duplicate'],
})
expect(readEncryptedBearers().filter((record) => record.id === 'duplicate')).toHaveLength(1)
expect(result[0]?.spent).toBe(true)
expect((await loadBearers(key))[0]?.spent).toBe(true)
})
it('encrypts before locking and preserves a fresh unrelated record', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const other = await deriveBearerAesKey(OTHER_KEY)
const mine = bearerFixture({id: 'mine'})
const foreign = bearerFixture({
id: 'fresh-tab',
url: buildNoteUrl('https://mint.example/w', K1_B, 9_000),
})
await persistBearer(key, mine)
const {id: foreignId, ...foreignPlain} = foreign
const foreignParts = await encryptRecord(other, foreignPlain)
const encrypted = vi.spyOn(crypto.subtle, 'encrypt')
const queue: {release: () => Promise<void>}[] = []
vi.stubGlobal('navigator', {
locks: {
request: (_name: string, fn: () => unknown): Promise<unknown> =>
new Promise((resolve, reject) => {
queue.push({
release: async () => {
await Promise.resolve(fn)
.then((callback) => callback())
.then(resolve, reject)
},
})
}),
},
})
try {
const commit = applyBearerChangeset(key, [mine], {
add: [newBearerFixture()],
markSpent: [],
})
await vi.waitFor(() => expect(queue).toHaveLength(1))
expect(encrypted).toHaveBeenCalledTimes(1)
localStorage.setItem(
'sattle_bearers',
JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]),
)
const pendingLock = queue.at(0)
if (pendingLock === undefined) throw new Error('Expected pending lock')
await pendingLock.release()
const result = await commit
expect(
readEncryptedBearers()
.map((record) => record.id)
.sort(),
).toEqual(['fresh-tab', 'mine', result[0]?.id].sort())
expect((await loadBearers(other)).map((bearer) => bearer.id)).toEqual(['fresh-tab'])
} finally {
encrypted.mockRestore()
vi.unstubAllGlobals()
}
})
it('replaces corrupt JSON with the single committed changeset write', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
localStorage.setItem('sattle_bearers', 'not json {{{')
const writes = vi.spyOn(localStorage, 'setItem')
const result = await applyBearerChangeset(key, [], {
add: [newBearerFixture()],
markSpent: ['missing'],
})
expect(result).toHaveLength(1)
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(1)
expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual(
result.map((bearer) => bearer.id),
)
})
})
+46 -35
View File
@@ -4,6 +4,7 @@
import {beforeEach, describe, expect, it} from 'vitest'
import {buildNoteUrl} from 'lnurlcash-kit'
import './storage.changeset.cases'
import type {Bearer} from './types'
import {deriveBearerAesKey} from './keys'
import {
@@ -18,10 +19,10 @@ import {
persistActivityEvent,
persistBearer,
readEncryptedBearers,
MAX_ACTIVITY_ENTRIES
MAX_ACTIVITY_ENTRIES,
} from './storage'
import {saveLinkingKey} from './keys'
import {stubLocalStorage} from './test-utils'
import {requiredValue, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
@@ -37,7 +38,7 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides
...overrides,
})
beforeEach(() => {
@@ -51,7 +52,7 @@ describe('encrypted bearer records', () => {
await persistBearer(key, bearer)
// at rest, nothing plaintext leaks: no k1, no amounts
const raw = localStorage.getItem('sattle_bearers')!
const raw = requiredValue(localStorage.getItem('sattle_bearers'))
expect(raw).not.toContain(K1_A)
expect(raw).not.toContain('21000')
@@ -63,12 +64,19 @@ describe('encrypted bearer records', () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const other = await deriveBearerAesKey(OTHER_KEY)
await persistBearer(key, bearerFixture({id: 'mine'}))
await persistBearer(other, bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}))
await persistBearer(
other,
bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}),
)
const loaded = await loadBearers(key)
expect(loaded.map(b => b.id)).toEqual(['mine'])
expect(loaded.map((b) => b.id)).toEqual(['mine'])
// the foreign ciphertext is still there, untouched
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual(['foreign', 'mine'])
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['foreign', 'mine'])
})
it('overwrites a record when the same id is persisted again', async () => {
@@ -79,7 +87,7 @@ describe('encrypted bearer records', () => {
const loaded = await loadBearers(key)
expect(loaded).toHaveLength(1)
expect(loaded[0]!.spent).toBe(true)
expect(requiredValue(loaded[0]).spent).toBe(true)
})
it('deletes a record by id and clears all', async () => {
@@ -90,7 +98,7 @@ describe('encrypted bearer records', () => {
await persistBearer(key, b)
await deleteBearerRecord('a')
expect((await loadBearers(key)).map(x => x.id)).toEqual(['b'])
expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b'])
clearAllBearers()
expect(readEncryptedBearers()).toEqual([])
@@ -104,7 +112,7 @@ describe('activity log', () => {
await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000})
const loaded = await loadActivity(key)
expect(loaded.map(e => e.id)).toEqual(['2', '1'])
expect(loaded.map((e) => e.id)).toEqual(['2', '1'])
})
it('caps the log, rolling the oldest entries off', async () => {
@@ -114,14 +122,14 @@ describe('activity log', () => {
id: `ev-${i}`,
kind: 'receive',
message: `event ${i}`,
createdAt: i
createdAt: i,
})
}
const loaded = await loadActivity(key)
expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES)
// the five oldest rolled off; the newest is first
expect(loaded[0]!.id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`)
expect(loaded.at(-1)!.id).toBe('ev-5')
expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`)
expect(requiredValue(loaded.at(-1)).id).toBe('ev-5')
}, 30_000)
})
@@ -130,7 +138,7 @@ describe('mergeBearers (union by note id, spent-wins)', () => {
const a = bearerFixture({id: 'a'})
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
const merged = mergeBearers([a], [b])
expect(merged.map(x => x.id).sort()).toEqual(['a', 'b'])
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b'])
})
it('lets the spent copy of a note win over a still-spendable one', () => {
@@ -141,8 +149,8 @@ describe('mergeBearers (union by note id, spent-wins)', () => {
// would resurrect burned money
const merged = mergeBearers([spendable], [spent])
expect(merged).toHaveLength(1)
expect(merged[0]!.id).toBe('new-copy')
expect(merged[0]!.spent).toBe(true)
expect(requiredValue(merged[0]).id).toBe('new-copy')
expect(requiredValue(merged[0]).spent).toBe(true)
})
it('keeps the newer copy when both agree on spent state', () => {
@@ -150,14 +158,14 @@ describe('mergeBearers (union by note id, spent-wins)', () => {
const fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2})
const merged = mergeBearers([stale], [fresh])
expect(merged).toHaveLength(1)
expect(merged[0]!.id).toBe('fresh')
expect(requiredValue(merged[0]).id).toBe('fresh')
})
it('treats the same secret on different servers as different notes', () => {
const here = bearerFixture({id: 'here'})
const there = bearerFixture({
id: 'there',
url: buildNoteUrl('https://other.example/w', K1_A, 21_000)
url: buildNoteUrl('https://other.example/w', K1_A, 21_000),
})
expect(mergeBearers([here], [there])).toHaveLength(2)
})
@@ -191,16 +199,17 @@ describe('backup', () => {
...backup,
bearers: [
...backup.bearers,
{id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}
]
{id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
],
}
const result = applyBackup(incoming)
const result = await applyBackup(incoming)
expect(result.added).toBe(1)
expect(result.skipped).toBe(1)
expect(readEncryptedBearers().map(r => r.id).sort()).toEqual([
'existing',
'from-backup'
])
expect(
readEncryptedBearers()
.map((r) => r.id)
.sort(),
).toEqual(['existing', 'from-backup'])
})
it('restores the linking key only onto a device that has none', async () => {
@@ -208,33 +217,35 @@ describe('backup', () => {
const backup = buildBackup()
// same device: a key already exists, so the backup's key is skipped
const here = applyBackup(backup)
const here = await applyBackup(backup)
expect(here.linkingKeySkipped).toBe(true)
expect(here.linkingKeyRestored).toBe(false)
// fresh device: the key installs
stubLocalStorage()
const fresh = applyBackup(backup)
const fresh = await applyBackup(backup)
expect(fresh.linkingKeyRestored).toBe(true)
expect(fresh.linkingKeySkipped).toBe(false)
})
it('rejects a file that is not a sattle backup', () => {
expect(() => applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []})).toThrow()
expect(() => applyBackup(null)).toThrow()
expect(() => applyBackup({type: 'sattle-backup', version: 2, bearers: []})).toThrow()
it('rejects a file that is not a sattle backup', async () => {
await expect(
applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}),
).rejects.toThrow()
await expect(applyBackup(null)).rejects.toThrow()
await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow()
})
it('skips malformed records instead of failing the whole restore', () => {
const result = applyBackup({
it('skips malformed records instead of failing the whole restore', async () => {
const result = await applyBackup({
type: 'sattle-backup',
version: 1,
createdAt: 1,
bearers: [
{id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
{id: 42, iv: null, ciphertext: 'xx'},
'garbage'
]
'garbage',
],
})
expect(result.added).toBe(1)
expect(result.skipped).toBe(2)
+8 -10
View File
@@ -5,7 +5,8 @@
// 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 + mergeBearers
// storage/bearers.ts - encrypted bearer records, changeset commits,
// mergeBearers
// storage/activityLog.ts - the append-only encrypted activity log
// storage/settings.ts - plaintext wallet settings
// storage/backup.ts - buildBackup / applyBackup
@@ -19,10 +20,11 @@ export {
loadBearers,
persistBearer,
deleteBearerRecord,
applyBearerChangeset,
clearAllBearers,
mergeBearers
mergeBearers,
} from './storage/bearers'
export type {EncryptedBearerRecord} from './storage/bearers'
export type {BearerChangeset, EncryptedBearerRecord} from './storage/bearers'
export {
newActivityId,
@@ -30,16 +32,12 @@ export {
loadActivity,
persistActivityEvent,
clearAllActivity,
MAX_ACTIVITY_ENTRIES
} from './storage/activityLog'
export type {
ActivityKind,
ActivityEvent,
EncryptedActivityRecord
MAX_ACTIVITY_ENTRIES,
} from './storage/activityLog'
export type {ActivityKind, ActivityEvent, EncryptedActivityRecord} from './storage/activityLog'
export {loadSettings, persistSettings, clearSettings} from './storage/settings'
export type {WalletSettings} from './storage/settings'
export {buildBackup, applyBackup, MAX_BACKUP_FILE_BYTES} from './storage/backup'
export {buildBackup, applyBackup, parseBackupFile, MAX_BACKUP_FILE_BYTES} from './storage/backup'
export type {BackupFile, RestoreResult} from './storage/backup'
@@ -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}`