refactor: isolate Nostr backup publishing transport

This commit is contained in:
2026-08-22 16:54:29 +02:00
parent 2a90343e7e
commit b258d86af7
3 changed files with 219 additions and 8 deletions
+6 -6
View File
@@ -22,9 +22,7 @@ export type BackupPublisherOptions = {
onError?: (error: unknown) => void
}
export const createBackupPublisher = (
options: BackupPublisherOptions
): BackupPublisher => {
export const createBackupPublisher = (options: BackupPublisherOptions): BackupPublisher => {
let timer: ReturnType<typeof setTimeout> | null = null
let pending: Partial<BackupPartPayload> | null = null
let running: Promise<void> | null = null
@@ -45,7 +43,9 @@ export const createBackupPublisher = (
try {
await options.publish(snapshot)
} catch (error) {
options.onError?.(error)
options.onError?.(
error instanceof Error ? error : new Error('Backup publication failed.', {cause: error}),
)
}
}
}
@@ -61,7 +61,7 @@ export const createBackupPublisher = (
}
return {
schedule: parts => {
schedule: (parts) => {
pending = parts
clearTimer()
timer = setTimeout(() => void fire(), options.delayMs)
@@ -70,6 +70,6 @@ export const createBackupPublisher = (
cancel: () => {
clearTimer()
pending = null
}
},
}
}
+2 -2
View File
@@ -23,10 +23,10 @@ export const defaultTransport = async (): Promise<BackupTransport> => {
const results = await Promise.allSettled(pool.publish(relays, event))
// one honest relay keeping the event is enough - addressable events
// are re-publishable, and the next debounced publish retries anyway
if (!results.some(r => r.status === 'fulfilled')) {
if (!results.some((r) => r.status === 'fulfilled')) {
throw new Error('No relay accepted the backup event.')
}
},
fetch: (relays, filter) => pool.querySync(relays, filter)
fetch: (relays, filter) => pool.querySync(relays, filter),
}
}
@@ -0,0 +1,211 @@
// Nostr backup: key derivation stability, event build/parse round-trips,
// tamper rejection, publish/fetch and restore against an in-memory relay
// (the transport is injected - no network), and the debounced publisher.
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import type {NostrEvent} from 'nostr-tools/core'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit'
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys'
import {
BACKUP_EVENT_KIND,
backupPubkey,
buildBackupEvent,
buildBackupEvents,
createBackupPublisher,
deriveBackupKey,
fetchBackup,
parseBackupEvent,
publishBackup,
restoreFromNostr,
} from './nostrBackup'
import type {BackupPartPayload, BackupTransport} from './nostrBackup'
import {
loadBearers,
loadSettings,
mergeBearers,
persistBearer,
persistSettings,
readEncryptedBearers,
} from './storage'
import type {Bearer} from './types'
import {addTrustedMint, isMintUnconfirmed, readTrustedMints} from './trustedMints'
import {requiredValue, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32)
const MINT_PUBKEY = 'ab'.repeat(33)
// never connected - the recording transport below stands in for the relays
const RELAYS = ['wss://relay-a.example', 'wss://relay-b.example']
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: 'fixture',
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,
})
// an in-memory relay set. It serves EVERY event it ever accepted, older
// addressable copies included - like a relay that never replaces - which
// is exactly the case fetchBackup's client-side latest-pick exists for
const createRecordingTransport = (): {
transport: BackupTransport
events: NostrEvent[]
} => {
const events: NostrEvent[] = []
const transport: BackupTransport = {
publish: (_relays, event) => {
events.push(event)
return Promise.resolve()
},
fetch: (_relays, filter) =>
Promise.resolve(
events.filter(
(e) =>
(!filter.kinds || filter.kinds.includes(e.kind)) &&
(!filter.authors || filter.authors.includes(e.pubkey)),
),
),
}
return {transport, events}
}
// flips the end of a base64 payload to different-but-valid characters
const tamperContent = (content: string): string =>
content.slice(0, -4) + (content.endsWith('AAAA') ? 'BBBB' : 'AAAA')
beforeEach(() => {
stubLocalStorage()
})
afterEach(() => {
vi.useRealTimers()
})
describe('createBackupPublisher', () => {
it('coalesces rapid schedules into a single publish of the latest snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
},
delayMs: 1000,
})
publisher.schedule({settings: {defaultMint: 'a'}})
publisher.schedule({settings: {defaultMint: 'b'}})
publisher.schedule({settings: {defaultMint: 'c'}})
await vi.advanceTimersByTimeAsync(999)
expect(published).toEqual([])
await vi.advanceTimersByTimeAsync(1)
expect(published).toEqual([{settings: {defaultMint: 'c'}}])
})
it('publishes again when a change lands after the quiet window', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
},
delayMs: 1000,
})
publisher.schedule({settings: {defaultMint: 'a'}})
await vi.advanceTimersByTimeAsync(1000)
publisher.schedule({settings: {defaultMint: 'b'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([{settings: {defaultMint: 'a'}}, {settings: {defaultMint: 'b'}}])
})
it('publishes a snapshot that lands mid-publish instead of losing it', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
// the publish callback re-schedules on the publisher being created -
// a holder indirection keeps both const
const holder: {publisher?: ReturnType<typeof createBackupPublisher>} = {}
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
// a local change lands while the first publish is in flight
if (published.length === 1) {
holder.publisher?.schedule({settings: {defaultMint: 'mid-flight'}})
}
return Promise.resolve()
},
delayMs: 1000,
})
holder.publisher = publisher
publisher.schedule({settings: {defaultMint: 'first'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([
{settings: {defaultMint: 'first'}},
{settings: {defaultMint: 'mid-flight'}},
])
})
it('flush publishes immediately; cancel drops the pending snapshot', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const publisher = createBackupPublisher({
publish: (p) => {
published.push(p)
return Promise.resolve()
},
delayMs: 60_000,
})
publisher.schedule({settings: {defaultMint: 'a'}})
await publisher.flush()
expect(published).toEqual([{settings: {defaultMint: 'a'}}])
publisher.schedule({settings: {defaultMint: 'b'}})
publisher.cancel()
await vi.advanceTimersByTimeAsync(60_000)
expect(published).toHaveLength(1)
})
it('reports a failed publish via onError and retries on the next change', async () => {
vi.useFakeTimers()
const published: Partial<BackupPartPayload>[] = []
const errors: unknown[] = []
let failing = true
const publisher = createBackupPublisher({
publish: (p) => {
if (failing) return Promise.reject(new Error('relay down'))
published.push(p)
return Promise.resolve()
},
delayMs: 1000,
onError: (e) => {
errors.push(e)
},
})
publisher.schedule({settings: {defaultMint: 'a'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([])
expect(errors).toHaveLength(1)
failing = false
publisher.schedule({settings: {defaultMint: 'b'}})
await vi.advanceTimersByTimeAsync(1000)
expect(published).toEqual([{settings: {defaultMint: 'b'}}])
})
})