diff --git a/src/components/welcome/WelcomeNostrPanel.vue b/src/components/welcome/WelcomeNostrPanel.vue index a0ee4a4..ec0d70b 100644 --- a/src/components/welcome/WelcomeNostrPanel.vue +++ b/src/components/welcome/WelcomeNostrPanel.vue @@ -131,7 +131,7 @@ const lookForBackup = async (): Promise => { looked.value = true; if (parts.notes || parts.mints || parts.settings) { found.value = { - notes: parts.notes?.length ?? 0, + notes: parts.notes?.bearers.length ?? 0, mints: parts.mints?.length ?? 0, settings: parts.settings !== undefined, }; diff --git a/src/lnurlcash/nostr/events.ts b/src/lnurlcash/nostr/events.ts index bffeae5..a45e915 100644 --- a/src/lnurlcash/nostr/events.ts +++ b/src/lnurlcash/nostr/events.ts @@ -9,11 +9,14 @@ // // Three d-tags carry three payloads in separate replaceable slots, so a // notes publish never clobbers settings: -// notes - the encrypted bearer records exactly as they sit in -// localStorage (each note is already an AES-GCM ciphertext -// under the seed-derived bearer key, so the blob goes up -// as-is; the linking key itself is NEVER part of a payload - -// the seed phrase is its recovery path) +// notes - the funds projection: encrypted bearer records exactly as +// they sit in the funds document (each note is already an +// AES-GCM ciphertext under the seed-derived bearer key, so the +// blob goes up as-is; the linking key itself is NEVER part of a +// payload - the seed phrase is its recovery path) plus the +// per-host BIP-32 next-unused counters, so a restored device +// never reissues a burned index. Pending journal state is +// device-local recovery data and is never published. // mints - the trusted-mint registry // settings - plaintext wallet settings @@ -46,9 +49,16 @@ export const BACKUP_D_TAGS: Record = { settings: 'settings', } +// the notes payload: bearers plus the counter map, from one consistent +// funds-document read +export type NotesBackupPayload = { + bearers: EncryptedBearerRecord[] + nextByHost: Record +} + // the decrypted payload of each part, without its envelope export type BackupPartPayload = { - notes: EncryptedBearerRecord[] + notes: NotesBackupPayload mints: TrustedMint[] settings: WalletSettings } @@ -77,10 +87,21 @@ const MAX_BACKUP_CONTENT_CHARS = 16 * 1024 * 1024 export const dTagOf = (event: NostrEvent): string => event.tags.find((t) => t[0] === 'd')?.[1] ?? '' +// per-part envelope versions: the notes part went v2 with the funds +// document (counters joined the payload - a breaking change); mints and +// settings envelopes are unchanged +const PART_ENVELOPE_VERSION: Record = { + notes: 2, + mints: 1, + settings: 1, +} + const envelopeFor = (part: BackupPart, payload: BackupPartPayload[BackupPart]): string => { switch (part) { - case 'notes': - return JSON.stringify({version: 1, bearers: payload}) + case 'notes': { + const notes = payload as NotesBackupPayload + return JSON.stringify({version: 2, bearers: notes.bearers, nextByHost: notes.nextByHost}) + } case 'mints': return JSON.stringify({version: 1, trustedMints: payload}) case 'settings': @@ -122,18 +143,18 @@ export const buildBackupEvents = ( } export type ParsedBackupEvent = - | {part: 'notes'; bearers: EncryptedBearerRecord[]} + | {part: 'notes'; bearers: EncryptedBearerRecord[]; nextByHost: Record} | {part: 'mints'; trustedMints: TrustedMint[]} | {part: 'settings'; settings: WalletSettings} // Light shape checks so parse returns typed values; the strict bounds -// (record counts, field lengths, pubkey patterns) are enforced by -// applyBackup / mergeTrustedMints on the restore path, same as file +// (record counts, field lengths, counter ranges and caps) are enforced by +// applyBackup / commitFundsRestore on the restore path, same as file // backups. const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null => { if (typeof data !== 'object' || data === null) return null const envelope = data as Record - if (envelope.version !== 1) return null + if (envelope.version !== PART_ENVELOPE_VERSION[dTag]) return null switch (dTag) { case 'notes': { if (!Array.isArray(envelope.bearers)) return null @@ -148,7 +169,14 @@ const parsePayload = (dTag: BackupPart, data: unknown): ParsedBackupEvent | null ) { return null } - return {part: 'notes', bearers: bearers as EncryptedBearerRecord[]} + if (typeof envelope.nextByHost !== 'object' || envelope.nextByHost === null) return null + const counters = envelope.nextByHost as Record + if (!Object.values(counters).every((v) => typeof v === 'number')) return null + return { + part: 'notes', + bearers: bearers as EncryptedBearerRecord[], + nextByHost: counters as Record, + } } case 'mints': { if (!Array.isArray(envelope.trustedMints)) return null diff --git a/src/lnurlcash/nostr/sync.ts b/src/lnurlcash/nostr/sync.ts index 67515cb..cb34d08 100644 --- a/src/lnurlcash/nostr/sync.ts +++ b/src/lnurlcash/nostr/sync.ts @@ -2,11 +2,11 @@ // // v1 is single-device last-writer-wins at the relay (addressable events // replace). Restore merges locally through storage/backup.ts's applyBackup -// - the SAME entry point as file restore (union by record id, mints merged -// unconfirmed, settings fill-only) - so the two restore paths can't drift -// apart. The note-level dedupe (same note under different record ids, -// spent-wins) happens after decrypt in bearers.ts's mergeBearers, exactly -// as with a file backup. +// - the SAME entry point as file restore (union by record id, counters +// merged upward-only, mints merged unconfirmed, settings fill-only) - so the +// two restore paths can't drift apart. The note-level dedupe (same note +// under different record ids, spent-wins) happens after decrypt in +// bearers.ts's mergeBearers, exactly as with a file backup. import type {RestoreResult} from '../storage/backup' import {applyBackup} from '../storage/backup' @@ -94,7 +94,7 @@ export const fetchBackup = async ( if (!parsed) continue switch (parsed.part) { case 'notes': - parts.notes = parsed.bearers + parts.notes = {bearers: parsed.bearers, nextByHost: parsed.nextByHost} break case 'mints': parts.mints = parsed.trustedMints @@ -134,9 +134,10 @@ export const restoreFromNostr = async ( const result = await applyBackup( { type: 'sattle-backup', - version: 1, + version: 2, createdAt: Date.now(), - bearers: parts.notes ?? [], + bearers: parts.notes?.bearers ?? [], + nextByHost: parts.notes?.nextByHost ?? {}, trustedMints: parts.mints, settings: parts.settings, }, diff --git a/src/lnurlcash/nostrBackup.codec.cases.ts b/src/lnurlcash/nostrBackup.codec.cases.ts index 8d84386..c6c8527 100644 --- a/src/lnurlcash/nostrBackup.codec.cases.ts +++ b/src/lnurlcash/nostrBackup.codec.cases.ts @@ -103,11 +103,12 @@ describe('buildBackupEvent / parseBackupEvent', () => { // assertion below {id: 'record-id-plaintext-sentinel-7f3a', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}, ] + const notes = {bearers: records, nextByHost: {'mint.example': 3}} const mints = [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: true}] const settings = {defaultMint: 'mint.example'} it('round-trips all three parts through build and parse', () => { - const events = buildBackupEvents(secretKey, {notes: records, mints, settings}, 1000) + const events = buildBackupEvents(secretKey, {notes, mints, settings}, 1000) expect(events).toHaveLength(3) expect(events.map((e) => e.kind)).toEqual([ BACKUP_EVENT_KIND, @@ -124,6 +125,7 @@ describe('buildBackupEvent / parseBackupEvent', () => { expect(parseBackupEvent(secretKey, requiredValue(events[0]))).toEqual({ part: 'notes', bearers: records, + nextByHost: {'mint.example': 3}, }) expect(parseBackupEvent(secretKey, requiredValue(events[1]))).toEqual({ part: 'mints', @@ -136,7 +138,7 @@ describe('buildBackupEvent / parseBackupEvent', () => { }) it('leaves no plaintext in the payload', () => { - const event = buildBackupEvent(secretKey, 'notes', records) + const event = buildBackupEvent(secretKey, 'notes', notes) expect(event.content).not.toContain('record-id-plaintext-sentinel-7f3a') expect(event.content).not.toContain('ciphertext') }) @@ -188,4 +190,25 @@ describe('buildBackupEvent / parseBackupEvent', () => { ) expect(parseBackupEvent(secretKey, event)).toBeNull() }) + + it('rejects a legacy version-1 notes envelope and a v2 notes envelope without counters', () => { + const conversationKey = nip44v2.utils.getConversationKey(secretKey, getPublicKey(secretKey)) + const build = (payload: unknown): NostrEvent => + finalizeEvent( + { + kind: BACKUP_EVENT_KIND, + created_at: 1000, + tags: [['d', 'notes']], + content: nip44v2.encrypt(JSON.stringify(payload), conversationKey), + }, + secretKey, + ) + // v1 notes carried bare bearer arrays and no counters - pre-funds-document + expect(parseBackupEvent(secretKey, build({version: 1, bearers: records}))).toBeNull() + expect(parseBackupEvent(secretKey, build({version: 2, bearers: records}))).toBeNull() + // a v2 envelope whose counters are malformed is rejected, not coerced + expect( + parseBackupEvent(secretKey, build({version: 2, bearers: records, nextByHost: 'junk'})), + ).toBeNull() + }) }) diff --git a/src/lnurlcash/nostrBackup.restore.cases.ts b/src/lnurlcash/nostrBackup.restore.cases.ts index fc394e7..35ea094 100644 --- a/src/lnurlcash/nostrBackup.restore.cases.ts +++ b/src/lnurlcash/nostrBackup.restore.cases.ts @@ -30,7 +30,9 @@ import { persistBearer, persistSettings, readEncryptedBearers, + readNextByHost, } from './storage' +import {readFundsDocument, writeFundsDocument} from './storage/bearers' import type {Bearer} from './types' import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints' import {requiredValue, stubLocalStorage} from './test-utils' @@ -108,7 +110,7 @@ describe('restoreFromNostr', () => { await publishBackup( secretKey, { - notes: readEncryptedBearers(), + notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}, mints: readTrustedMints(OWNER_ID), settings: loadSettings(), }, @@ -145,7 +147,7 @@ describe('restoreFromNostr', () => { // device A publishes its store holding the spendable note await persistBearer(aesKey, bearerFixture({id: 'rec-a'})) - await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, { transport, createdAt: 1000, }) @@ -155,7 +157,7 @@ describe('restoreFromNostr', () => { stubLocalStorage() await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) await persistBearer(aesKey, bearerFixture({id: 'rec-b', spent: true, updatedAt: 2000})) - await publishBackup(secretKey, {notes: readEncryptedBearers()}, RELAYS, { + await publishBackup(secretKey, {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, RELAYS, { transport, createdAt: 2000, }) @@ -190,7 +192,7 @@ describe('restoreFromNostr', () => { persistSettings({defaultMint: 'remote.example'}) await publishBackup( secretKey, - {notes: readEncryptedBearers(), settings: loadSettings()}, + {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}, settings: loadSettings()}, RELAYS, {transport, createdAt: 1000}, ) @@ -220,4 +222,50 @@ describe('restoreFromNostr', () => { expect(result.found).toEqual([]) expect(result.added).toBe(0) }) + + it('merges relayed counters upward-only and never carries pending state', async () => { + const aesKey = await deriveBearerAesKey(LINKING_KEY) + const secretKey = deriveBackupKey(LINKING_KEY) + const {transport} = createRecordingTransport() + + // device A: counter at 5 for mint.example, one note, all published + await persistBearer(aesKey, bearerFixture({id: 'note-a'})) + writeFundsDocument({ + version: 2, + bearers: readEncryptedBearers(), + pending: [ + {id: 'device-local-pending', kind: 'cash-allocation', phase: 'reserved', iv: '00', ciphertext: '00'}, + ], + nextByHost: {'mint.example': 5}, + revision: 3, + }) + await publishBackup( + secretKey, + {notes: {bearers: readEncryptedBearers(), nextByHost: readNextByHost()}}, + RELAYS, + {transport, createdAt: 1000}, + ) + + // device B: same seed, a HIGHER local counter for one host and a lower + // incoming one for the other; the pending record must not cross over + stubLocalStorage() + writeFundsDocument({ + version: 2, + bearers: [], + pending: [], + nextByHost: {'mint.example': 2, 'local-only.example': 7}, + revision: 1, + }) + + const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) + + expect(result.found).toEqual(['notes']) + expect(result.added).toBe(1) + expect(readFundsDocument().nextByHost).toEqual({ + 'mint.example': 5, + 'local-only.example': 7, + }) + expect(readFundsDocument().pending).toEqual([]) + expect(await loadBearers(aesKey)).toEqual([bearerFixture({id: 'note-a'})]) + }) }) diff --git a/src/lnurlcash/nostrBackup.transport.cases.ts b/src/lnurlcash/nostrBackup.transport.cases.ts index ab3873f..dfc3839 100644 --- a/src/lnurlcash/nostrBackup.transport.cases.ts +++ b/src/lnurlcash/nostrBackup.transport.cases.ts @@ -100,7 +100,10 @@ describe('publishBackup / fetchBackup', () => { it('publishes every part and fetches them back decrypted', async () => { const {transport} = createRecordingTransport() const parts = { - notes: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], + notes: { + bearers: [{id: 'r1', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)}], + nextByHost: {'mint.example': 2}, + }, mints: [{server: 'mint.example', mintPubkey: MINT_PUBKEY, addedAt: 1000, locked: false}], settings: {defaultMint: 'mint.example'}, } diff --git a/src/lnurlcash/storage.backup.test.ts b/src/lnurlcash/storage.backup.test.ts index 1c10fce..5d7926f 100644 --- a/src/lnurlcash/storage.backup.test.ts +++ b/src/lnurlcash/storage.backup.test.ts @@ -1,12 +1,16 @@ // Backup restore waits for owner-bound trusted-mint convergence before it -// reports success, while retaining the hostile-file merge policy. +// reports success, while retaining the hostile-file merge policy. The v2 +// format also projects the BIP-32 counters: backups carry nextByHost, +// restore merges upward-only, and pending journal state never leaves the +// device. import {beforeEach, describe, expect, it, vi} from 'vitest' import {linkingPubKeyHex, saveLinkingKey} from './keys' -import {applyBackup, buildBackup} from './storage' +import {applyBackup, buildBackup, parseBackupFile} from './storage' +import {readFundsDocument, writeFundsDocument} from './storage/bearers' import {addTrustedMint, readTrustedMints} from './trustedMints' -import {stubLocalStorage} from './test-utils' +import {requiredValue, stubLocalStorage} from './test-utils' const LINKING_KEY = new Uint8Array(32).fill(7) const OWNER_ID = linkingPubKeyHex(LINKING_KEY) @@ -40,9 +44,10 @@ class DeferredLocks { const backup = (server: string, mintPubkey: string) => ({ type: 'sattle-backup' as const, - version: 1 as const, + version: 2 as const, createdAt: 1, bearers: [], + nextByHost: {}, trustedMints: [ { server, @@ -153,3 +158,101 @@ describe('owner-bound backup restore', () => { ]) }) }) + +describe('backup counter projection', () => { + const fundsDoc = (nextByHost: Record) => ({ + version: 2 as const, + bearers: [] as {id: string; iv: string; ciphertext: string}[], + pending: [] as {id: string; kind: string; phase: string; iv: string; ciphertext: string}[], + nextByHost, + revision: 1, + }) + + it('exports the counters and never the pending journal', () => { + const doc = fundsDoc({'mint.example': 5}) + doc.pending.push({ + id: 'pending-sentinel-never-exported', + kind: 'cash-allocation', + phase: 'reserved', + iv: '00', + ciphertext: '00', + }) + writeFundsDocument(doc) + + const built = buildBackup(OWNER_ID) + + expect(built.version).toBe(2) + expect(built.nextByHost).toEqual({'mint.example': 5}) + expect('pending' in built).toBe(false) + expect(JSON.stringify(built)).not.toContain('pending-sentinel-never-exported') + }) + + it('round-trips counters through a restore and merges upward-only', async () => { + writeFundsDocument(fundsDoc({'mint.example': 5, 'other.example': 2})) + + const result = await applyBackup({ + type: 'sattle-backup', + version: 2, + createdAt: 1, + bearers: [], + nextByHost: {'mint.example': 3, 'other.example': 9, 'new.example': 4}, + }) + + expect(result.added).toBe(0) + // lower incoming values never rewind a counter; higher ones advance it; + // unknown hosts adopt the backup's value + expect(readFundsDocument().nextByHost).toEqual({ + 'mint.example': 5, + 'other.example': 9, + 'new.example': 4, + }) + }) + + it('skips out-of-bounds counter entries instead of rejecting the restore', async () => { + writeFundsDocument(fundsDoc({'mint.example': 5})) + + await applyBackup({ + type: 'sattle-backup', + version: 2, + createdAt: 1, + bearers: [], + nextByHost: { + [`${'h'.repeat(254)}`]: 9, + 'overflow.example': 2 ** 31, + 'negative.example': -1, + 'fractional.example': 1.5, + 'valid.example': 7, + }, + }) + + expect(readFundsDocument().nextByHost).toEqual({'mint.example': 5, 'valid.example': 7}) + }) + + it('never grows the counter map beyond ten hosts on restore', async () => { + const incoming: Record = {} + for (let i = 0; i < 12; i += 1) incoming[`mint-${i}.example`] = i + 1 + + await applyBackup({ + type: 'sattle-backup', + version: 2, + createdAt: 1, + bearers: [], + nextByHost: incoming, + }) + + const merged = readFundsDocument().nextByHost + expect(Object.keys(merged)).toHaveLength(10) + }) + + it('rejects a legacy version-1 backup file', () => { + expect(() => + parseBackupFile({type: 'sattle-backup', version: 1, createdAt: 1, bearers: []}), + ).toThrow('Not a valid sattle backup file.') + }) + + it('rejects a v2 file without a counter map', () => { + expect(() => + parseBackupFile({type: 'sattle-backup', version: 2, createdAt: 1, bearers: []}), + ).toThrow('Not a valid sattle backup file.') + }) +}) diff --git a/src/lnurlcash/storage/backup.ts b/src/lnurlcash/storage/backup.ts index c277264..f768ff4 100644 --- a/src/lnurlcash/storage/backup.ts +++ b/src/lnurlcash/storage/backup.ts @@ -1,8 +1,14 @@ // Backup files: everything exactly as it sits in localStorage - bearer -// ciphertexts always, the linking-key record only when it is itself -// password-encrypted. A plaintext linking key never leaves the device in a -// backup; the seed phrase is the recovery path for it instead. Trusted -// mints are plain (not secret - a mintPubkey is public), included as-is. +// ciphertexts and the BIP-32 counter map always, the linking-key record only +// when it is itself password-encrypted. A plaintext linking key never leaves +// the device in a backup; the seed phrase is the recovery path for it +// instead. Trusted mints are plain (not secret - a mintPubkey is public), +// included as-is. The pending-mutation journal is device-local recovery +// state and is deliberately NOT part of any backup. +// +// Format v2 (breaking): the funds document made bearers + counters one +// atomic unit, so the backup projects both from the same single read - +// they can never disagree about which generation they came from. import type {StoredSecret} from '../keys' import { @@ -16,39 +22,45 @@ import type {TrustedMint} from '../trustedMints' import {readTrustedMints, mergeTrustedMints} from '../trustedMints' import {isWalletOwnerId} from './walletOwner' import type {EncryptedBearerRecord} from './bearers' -import {readEncryptedBearers, writeEncryptedBearers} from './bearers' +import {commitFundsRestore, readFundsDocument} from './bearers' import type {WalletSettings} from './settings' import {loadSettings, persistSettings} from './settings' import {isJsonObject} from '../jsonParsing' export type BackupFile = { type: 'sattle-backup' - version: 1 + version: 2 createdAt: number ownerId?: unknown linkingKey?: StoredSecret bearers: EncryptedBearerRecord[] + nextByHost: Record trustedMints?: TrustedMint[] settings?: WalletSettings } type ParsedBackupFile = { type: 'sattle-backup' - version: 1 + version: 2 createdAt?: unknown ownerId?: unknown linkingKey?: unknown bearers: unknown[] + nextByHost: Record trustedMints?: unknown settings?: unknown } export const buildBackup = (ownerId?: string): BackupFile => { + // one document, one getItem: bearers and counters are snapshot-consistent + // by construction; pending journal records are never read here + const funds = readFundsDocument() const backup: BackupFile = { type: 'sattle-backup', - version: 1, + version: 2, createdAt: Date.now(), - bearers: readEncryptedBearers(), + bearers: funds.bearers, + nextByHost: funds.nextByHost, trustedMints: readTrustedMints(ownerId), settings: loadSettings(), } @@ -91,8 +103,9 @@ const MAX_BACKUP_FIELD_LENGTH = 64 * 1024 const isBackupFile = (data: unknown): data is ParsedBackupFile => isJsonObject(data) && data.type === 'sattle-backup' && - data.version === 1 && - Array.isArray(data.bearers) + data.version === 2 && + Array.isArray(data.bearers) && + isJsonObject(data.nextByHost) export const parseBackupFile = (data: unknown): ParsedBackupFile => { if (!isBackupFile(data)) { @@ -102,7 +115,10 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => { } // merges a backup into localStorage: bearer records are added by id -// (already present ids are left as-is - union, never overwrite), the +// (already present ids are left as-is - union, never overwrite) and counter +// entries merge upward-only (max per host - a stale backup must never rewind +// a BIP-32 counter and reopen burned indices), both inside ONE locked funds +// document write (commitFundsRestore enforces the counter bounds). The // backup's linking key is only installed when this device has none yet - // never overwriting an existing wallet. That guard is deliberate (a // stale/wrong backup must never clobber a wallet already holding funds), @@ -114,14 +130,12 @@ export const parseBackupFile = (data: unknown): ParsedBackupFile => { // decrypt, in bearers.ts's mergeBearers. export const applyBackup = async (data: unknown, ownerId?: string): Promise => { const backup = parseBackupFile(data) - const existing = readEncryptedBearers() - const existingIds = new Set(existing.map((r) => r.id)) if (backup.bearers.length > MAX_BACKUP_RECORDS) { throw new Error( `Backup holds ${backup.bearers.length} records - more than the ${MAX_BACKUP_RECORDS} a real wallet could produce.`, ) } - let added = 0 + const incomingBearers: EncryptedBearerRecord[] = [] let skipped = 0 for (const record of backup.bearers) { if ( @@ -136,20 +150,30 @@ export const applyBackup = async (data: unknown, ownerId?: string): Promise = {} + for (const [host, next] of Object.entries(backup.nextByHost)) { + if (typeof next === 'number') incomingCounters[host] = next + } + + let added: number try { - writeEncryptedBearers(existing) - } catch { - throw new Error( - 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.', - ) + const merged = await commitFundsRestore(incomingBearers, incomingCounters) + added = merged.added + skipped += merged.skipped + } catch (error) { + const name = error instanceof Error ? error.name : '' + const message = error instanceof Error ? error.message : '' + if (name === 'QuotaExceededError' || /quota|full/i.test(message)) { + throw new Error( + 'Local storage is full - the backup could not be written. Free up space (or forget unused wallets) and try again.', + {cause: error}, + ) + } + throw error } let linkingKeyRestored = false diff --git a/src/lnurlcash/storage/bearers.ts b/src/lnurlcash/storage/bearers.ts index 4c1ee6e..a26c33d 100644 --- a/src/lnurlcash/storage/bearers.ts +++ b/src/lnurlcash/storage/bearers.ts @@ -174,16 +174,6 @@ 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 diff --git a/src/stores/nostrBackup.test.ts b/src/stores/nostrBackup.test.ts new file mode 100644 index 0000000..db94d1b --- /dev/null +++ b/src/stores/nostrBackup.test.ts @@ -0,0 +1,68 @@ +// The nostr-backup store's scheduling surface: a counter-only funds commit +// (a BIP-32 reservation that adds no bearer) must still schedule a publish, +// and the published notes payload carries bearers plus counters - never the +// device-local pending journal. The engine's publish is mocked; everything +// below it (debounce, wallet wiring, payload assembly) is real. + +import { createPinia, setActivePinia } from 'pinia'; +import { deriveCashRoot } from 'lnurlcash-kit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { publishBackup } from '@/lnurlcash/nostrBackup'; +import type * as NostrBackupEngine from '@/lnurlcash/nostrBackup'; +import { readFundsDocument } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { useNostrBackupStore } from './nostrBackup'; +import { useWalletStore } from './wallet'; + +vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, publishBackup: vi.fn().mockResolvedValue({ published: [] }) }; +}); + +const CASH_ROOT = deriveCashRoot(new Uint8Array(64).fill(1)); + +const publishCalls = (): { parts: { notes?: { nextByHost: Record } } }[] => + vi.mocked(publishBackup).mock.calls.map((call) => ({ parts: call[1] })); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(publishBackup).mockResolvedValue({ published: [] }); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('counter-aware backup scheduling', () => { + it('schedules a publish for a counter-only reservation and includes the counters', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const backup = useNostrBackupStore(); + + vi.useFakeTimers(); + backup.setEnabled(true); + // the initial activation publish drains first + await vi.advanceTimersByTimeAsync(5000); + expect(publishCalls()).toHaveLength(1); + vi.mocked(publishBackup).mockClear(); + + // a reservation changes no bearer - only the counter map and the pending + // journal - yet it must still schedule a publish + await wallet.allocateCashSecrets(CASH_ROOT, 'mint.example', 2, wallet.captureOwnerFence()); + await vi.advanceTimersByTimeAsync(5000); + + const calls = publishCalls(); + expect(calls).toHaveLength(1); + expect(calls[0]?.parts.notes?.nextByHost).toEqual({ 'mint.example': 2 }); + // the pending journal never leaves the device + expect(JSON.stringify(calls[0]?.parts)).not.toContain('cash-allocation'); + expect(readFundsDocument().pending).toHaveLength(1); + }); +}); diff --git a/src/stores/nostrBackup.ts b/src/stores/nostrBackup.ts index 7f27ebd..7069c05 100644 --- a/src/stores/nostrBackup.ts +++ b/src/stores/nostrBackup.ts @@ -9,7 +9,7 @@ import { publishBackup, } from '@/lnurlcash/nostrBackup'; import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup'; -import { loadSettings, persistSettings, readEncryptedBearers } from '@/lnurlcash/storage'; +import { loadSettings, persistSettings, readFundsDocument } from '@/lnurlcash/storage'; import { readTrustedMints } from '@/lnurlcash/trustedMints'; import { useWalletStore } from './wallet'; import { useMintsStore } from './mints'; @@ -62,11 +62,17 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => { wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null, ); - const currentPayload = (): BackupPartPayload => ({ - notes: readEncryptedBearers(), - mints: readTrustedMints(wallet.pubkey ?? undefined), - settings: loadSettings(), - }); + const currentPayload = (): BackupPartPayload => { + // one document, one getItem: bearers and counters come from the same + // locked generation by construction; pending journal state is + // device-local and never published + const funds = readFundsDocument(); + return { + notes: { bearers: funds.bearers, nextByHost: funds.nextByHost }, + mints: readTrustedMints(wallet.pubkey ?? undefined), + settings: loadSettings(), + }; + }; const publishNow = async (): Promise => { const secretKey = deriveBackupKey(wallet.requireLinkingKey()); @@ -97,6 +103,10 @@ export const useNostrBackupStore = defineStore('nostrBackup', () => { const schedule = () => publisher?.schedule(currentPayload()); const stops = [ watch(() => wallet.bearers, schedule), + // counter-only commits (reservations) leave the bearer list untouched, + // so watch the persisted document revision as well - a restored or + // freshly burned counter must still reach the relays + watch(() => wallet.fundsRevision, schedule), watch(() => mints.mints, schedule), watch(() => mints.defaultMint, schedule), ];