From e9bbb358aae79d3ce1fa6690749e9d63d3c748ff Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:56:10 +0200 Subject: [PATCH] fix: mirror trusted mint commits across tabs --- e2e/specs/trusted-mint-tabs.spec.ts | 208 ++++++++++++++++++ src/lnurlcash/trustedMints.visibility.test.ts | 149 +++++++++++++ src/lnurlcash/trustedMintsCommitStore.ts | 98 +++++++++ 3 files changed, 455 insertions(+) create mode 100644 e2e/specs/trusted-mint-tabs.spec.ts create mode 100644 src/lnurlcash/trustedMints.visibility.test.ts create mode 100644 src/lnurlcash/trustedMintsCommitStore.ts diff --git a/e2e/specs/trusted-mint-tabs.spec.ts b/e2e/specs/trusted-mint-tabs.spec.ts new file mode 100644 index 0000000..ff2566c --- /dev/null +++ b/e2e/specs/trusted-mint-tabs.spec.ts @@ -0,0 +1,208 @@ +import type { Page, TestInfo } from '@playwright/test'; + +import { test, expect } from '../fixtures'; +import { createFreshWallet } from '../helpers/wallet'; + +const MINT_KEY_A = `02${'aa'.repeat(32)}`; +const MINT_KEY_B = `03${'bb'.repeat(32)}`; +const MINT_KEY_C = `02${'cc'.repeat(32)}`; +const VIEWPORT_WIDTHS = [375, 768, 1280] as const; +const CONCURRENT_ADD_ROUNDS = 10; + +const mintsList = (page: Page) => page.locator('.q-list', { hasText: 'Your mints' }); + +const fillMintForm = async (page: Page, server: string, mintPubkey: string): Promise => { + await page.getByLabel('Server').fill(server); + await page.getByLabel('Signing key (66 hex characters)').fill(mintPubkey); +}; + +const addMint = async (page: Page, server: string, mintPubkey: string): Promise => { + await fillMintForm(page, server, mintPubkey); + await page.getByRole('button', { name: 'Trust this mint' }).click(); +}; + +const ownerId = async (page: Page): Promise => + page.evaluate(() => { + const raw = localStorage.getItem('sattle_linking_key'); + if (raw === null) return null; + const saved: unknown = JSON.parse(raw); + if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null; + return typeof saved.ownerId === 'string' ? saved.ownerId : null; + }); + +const storedMintServers = async (page: Page): Promise => + page.evaluate(() => { + const raw = localStorage.getItem('sattle_trusted_mints'); + if (raw === null) return []; + const registry: unknown = JSON.parse(raw); + if ( + typeof registry !== 'object' || + registry === null || + !('mints' in registry) || + !Array.isArray(registry.mints) + ) { + return []; + } + return registry.mints + .map((mint) => + typeof mint === 'object' && + mint !== null && + 'server' in mint && + typeof mint.server === 'string' + ? mint.server + : '', + ) + .sort(); + }); + +const retainFailureEvidence = async ( + testInfo: TestInfo, + pages: readonly Page[], + consoleMessages: readonly string[], +): Promise => { + for (const [index, page] of pages.entries()) { + if (page.isClosed()) continue; + const path = testInfo.outputPath(`tab-${index + 1}-failure.png`); + await page.screenshot({ path, fullPage: true }); + await testInfo.attach(`tab-${index + 1}-failure`, { path, contentType: 'image/png' }); + } + await testInfo.attach('browser-console', { + body: consoleMessages.join('\n'), + contentType: 'text/plain', + }); +}; + +test.describe('trusted mint tabs', () => { + test('remote updates converge and concurrent additions survive Web Locks', async ({ + page, + }, testInfo) => { + await createFreshWallet(page); + test.skip( + !(await page.evaluate(() => 'locks' in navigator)), + 'Web Locks are unavailable, so this browser provides no concurrent-write guarantee.', + ); + + const remote = await page.context().newPage(); + const consoleMessages: string[] = []; + for (const [name, current] of [ + ['first', page], + ['second', remote], + ] as const) { + current.on('console', (message) => { + consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`); + }); + } + + try { + await Promise.all([page.goto('/#/settings/mints'), remote.goto('/#/settings/mints')]); + await expect(mintsList(page).getByText('No mints yet', { exact: false })).toBeVisible(); + await expect(mintsList(remote).getByText('No mints yet', { exact: false })).toBeVisible(); + + // A real storage event from the second tab updates the first tab without reload. + await addMint(remote, 'remote.example', MINT_KEY_A); + await expect(mintsList(page).getByText('remote.example', { exact: true })).toBeVisible(); + + // Repeated UI races from separate pages must all survive Web Locks. + const expectedServers = ['remote.example']; + for (let round = 1; round <= CONCURRENT_ADD_ROUNDS; round++) { + const firstServer = `first-${round}.example`; + const secondServer = `second-${round}.example`; + expectedServers.push(firstServer, secondServer); + await Promise.all([ + fillMintForm(page, firstServer, MINT_KEY_B), + fillMintForm(remote, secondServer, MINT_KEY_C), + ]); + await Promise.all([ + page.getByRole('button', { name: 'Trust this mint' }).click(), + remote.getByRole('button', { name: 'Trust this mint' }).click(), + ]); + await expect.poll(() => storedMintServers(page)).toEqual([...expectedServers].sort()); + } + + for (const width of VIEWPORT_WIDTHS) { + await Promise.all([ + page.setViewportSize({ width, height: 900 }), + remote.setViewportSize({ width, height: 900 }), + ]); + await expect(mintsList(page).getByText('first-10.example', { exact: true })).toBeVisible(); + await expect(mintsList(page).getByText('second-10.example', { exact: true })).toBeVisible(); + await expect( + mintsList(remote).getByText('first-10.example', { exact: true }), + ).toBeVisible(); + await expect( + mintsList(remote).getByText('second-10.example', { exact: true }), + ).toBeVisible(); + } + expect(await storedMintServers(page)).toEqual([...expectedServers].sort()); + } catch (error) { + await retainFailureEvidence(testInfo, [page, remote], consoleMessages); + throw error; + } finally { + await remote.close(); + } + }); +}); + +test.describe('wallet ownership', () => { + test('a stale owner tab locks and cannot recreate trust or NWC state', async ({ + page, + }, testInfo) => { + await createFreshWallet(page); + const oldOwner = await ownerId(page); + expect(oldOwner).not.toBeNull(); + + const stale = await page.context().newPage(); + const consoleMessages: string[] = []; + for (const [name, current] of [ + ['successor', page], + ['stale', stale], + ] as const) { + current.on('console', (message) => { + consoleMessages.push(`[${name}] ${message.type()}: ${message.text()}`); + }); + } + + try { + await stale.goto('/#/settings/mints'); + await expect(mintsList(stale).getByText('No mints yet', { exact: false })).toBeVisible(); + + // The active page forgets owner A, then creates owner B through the rendered onboarding flow. + await page.evaluate(() => window.__sattleWalletTest.forget()); + await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible(); + await stale.goto('/#/'); + await expect(stale.getByText('Wallet locked')).toBeVisible(); + + await page.getByRole('button', { name: 'Get started' }).click(); + await page.getByRole('button', { name: 'Create wallet' }).click(); + await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible(); + + const successorOwner = await ownerId(page); + expect(successorOwner).not.toBe(oldOwner); + for (const width of VIEWPORT_WIDTHS) { + await stale.setViewportSize({ width, height: 900 }); + await expect(stale.getByText('Wallet locked')).toBeVisible(); + } + + // A stale settings route cannot call owner-bound mutations after invalidation. + await stale.goto('/#/settings/mints'); + await addMint(stale, 'stale.example', MINT_KEY_A); + await expect(stale.locator('.q-banner', { hasText: 'Wallet is locked.' })).toBeVisible(); + await stale.goto('/#/settings/nwc'); + await expect(stale.locator('.q-page', { hasText: 'Unlock your wallet first' })).toBeVisible(); + + const residue = await page.evaluate(() => ({ + trustedMints: localStorage.getItem('sattle_trusted_mints'), + nwcConnections: localStorage.getItem('sattle_nwc_connections'), + nwcEnabled: localStorage.getItem('sattle_nwc_enabled'), + })); + expect(residue).toEqual({ trustedMints: null, nwcConnections: null, nwcEnabled: null }); + } catch (error) { + await retainFailureEvidence(testInfo, [page, stale], consoleMessages); + throw error; + } finally { + await stale.close(); + } + }); +}); diff --git a/src/lnurlcash/trustedMints.visibility.test.ts b/src/lnurlcash/trustedMints.visibility.test.ts new file mode 100644 index 0000000..8dacb21 --- /dev/null +++ b/src/lnurlcash/trustedMints.visibility.test.ts @@ -0,0 +1,149 @@ +// A Web Lock handoff is not a localStorage visibility barrier. The next +// holder must reconcile from a durable cross-context commit before writing. + +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {linkingPubKeyHex, saveLinkingKey} from './keys' +import {stubLocalStorage} from './test-utils' +import {addTrustedMint, readTrustedMints, type TrustedMint} from './trustedMints' +import {trustedMintsCommitStore} from './trustedMintsCommitStore' + +const STORAGE_KEY = 'sattle_trusted_mints' +const LINKING_KEY = new Uint8Array(32).fill(7) +const OWNER_ID = linkingPubKeyHex(LINKING_KEY) +const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)) +const KEY_A = '02' + 'aa'.repeat(32) +const KEY_B = '03' + 'bb'.repeat(32) + +const envelope = (mints: TrustedMint[]): string => + JSON.stringify({version: 1, ownerId: OWNER_ID, mints}) + +const mint = (server: string): TrustedMint => ({ + server, + mintPubkey: KEY_A, + addedAt: 1, + locked: false, +}) + +beforeEach(async () => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + stubLocalStorage() + vi.stubGlobal('navigator', { + locks: { + request: (_name: string, callback: () => unknown): Promise => + Promise.resolve().then(callback), + }, + }) + await saveLinkingKey(LINKING_KEY) +}) + +describe('trusted-mint commit visibility', () => { + it('reconciles the previous holder when localStorage is stale after lock handoff', async () => { + // Given a durable commit mirror shared by two lock holders + let committedRaw: string | null = null + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + vi.spyOn(trustedMintsCommitStore, 'read').mockImplementation(async () => committedRaw) + vi.spyOn(trustedMintsCommitStore, 'write').mockImplementation(async (raw) => { + committedRaw = raw + }) + + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + await addTrustedMint('first.example', KEY_A, {ownerId: OWNER_ID}) + + // When the next holder sees the pre-commit localStorage view + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + const writes = vi.spyOn(localStorage, 'setItem') + await addTrustedMint('second.example', KEY_B, {ownerId: OWNER_ID}) + + // Then it writes one reconciled envelope and both accepted additions survive + expect(readTrustedMints(OWNER_ID).map((entry) => entry.server)).toEqual([ + 'remote.example', + 'first.example', + 'second.example', + ]) + expect(writes).toHaveBeenCalledTimes(1) + }) + + it('does not resolve success before the durable commit mirror completes', async () => { + // Given a commit store whose durable write is gated + let releaseCommit: (() => void) | undefined + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + vi.spyOn(trustedMintsCommitStore, 'read').mockResolvedValue(null) + vi.spyOn(trustedMintsCommitStore, 'write').mockImplementation( + () => + new Promise((resolve) => { + releaseCommit = resolve + }), + ) + let settled = false + + // When a mutation has written localStorage but not the commit mirror + const addition = addTrustedMint('first.example', KEY_A, {ownerId: OWNER_ID}).finally(() => { + settled = true + }) + await vi.waitFor(() => expect(releaseCommit).toBeTypeOf('function')) + + // Then success remains pending until the durable mirror completes + expect(settled).toBe(false) + releaseCommit?.() + await expect(addition).resolves.toBe('added') + expect(settled).toBe(true) + }) + + it('rejects malformed local bytes instead of trusting the commit mirror', async () => { + // Given a valid mirror but malformed canonical local storage + const malformed = '{' + localStorage.setItem(STORAGE_KEY, malformed) + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + const readMirror = vi + .spyOn(trustedMintsCommitStore, 'read') + .mockResolvedValue(envelope([mint('mirrored.example')])) + + // When a mutation attempts reconciliation, then malformed local bytes stay authoritative + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_ID})).rejects.toThrow( + /malformed/i, + ) + expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed) + expect(readMirror).not.toHaveBeenCalled() + }) + + it('rejects a foreign-owner commit mirror without rewriting local storage', async () => { + // Given an owner-A local registry and an owner-B durable mirror + const localRaw = envelope([mint('local.example')]) + const foreignRaw = JSON.stringify({ + version: 1, + ownerId: OTHER_OWNER_ID, + mints: [mint('foreign.example')], + }) + localStorage.setItem(STORAGE_KEY, localRaw) + vi.spyOn(trustedMintsCommitStore, 'available').mockReturnValue(true) + vi.spyOn(trustedMintsCommitStore, 'read').mockResolvedValue(foreignRaw) + + // When owner A mutates, then exact owner validation rejects both sources unchanged + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_ID})).rejects.toThrow( + /owner/i, + ) + expect(localStorage.getItem(STORAGE_KEY)).toBe(localRaw) + }) + + it('keeps the documented stale-write limitation when Web Locks are unavailable', async () => { + // Given no cross-tab lock capability, even if IndexedDB exists + vi.stubGlobal('navigator', {}) + const readMirror = vi.spyOn(trustedMintsCommitStore, 'read').mockResolvedValue(null) + const writeMirror = vi.spyOn(trustedMintsCommitStore, 'write').mockResolvedValue() + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + await addTrustedMint('first.example', KEY_A, {ownerId: OWNER_ID}) + + // When a later unlocked mutation reads a stale view, then no false convergence is claimed + localStorage.setItem(STORAGE_KEY, envelope([mint('remote.example')])) + await addTrustedMint('second.example', KEY_B, {ownerId: OWNER_ID}) + + expect(readTrustedMints(OWNER_ID).map((entry) => entry.server)).toEqual([ + 'remote.example', + 'second.example', + ]) + expect(readMirror).not.toHaveBeenCalled() + expect(writeMirror).not.toHaveBeenCalled() + }) +}) diff --git a/src/lnurlcash/trustedMintsCommitStore.ts b/src/lnurlcash/trustedMintsCommitStore.ts new file mode 100644 index 0000000..ceefec0 --- /dev/null +++ b/src/lnurlcash/trustedMintsCommitStore.ts @@ -0,0 +1,98 @@ +// Web Locks serialize registry writers but do not make one renderer's +// localStorage cache current in the next renderer. IndexedDB is the durable, +// cross-context commit mirror used to carry the last completed envelope. + +const DATABASE_NAME = 'sattle-storage-coordination' +const DATABASE_VERSION = 1 +const STORE_NAME = 'trusted-mints' +const REGISTRY_KEY = 'registry' + +let databasePromise: Promise | undefined + +export class TrustedMintsCommitStoreError extends Error { + override readonly name = 'TrustedMintsCommitStoreError' + + constructor(message: string, cause?: unknown) { + super(message, {cause}) + } +} + +const openDatabase = (): Promise => { + if (databasePromise) return databasePromise + databasePromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME) + } + } + request.onsuccess = () => { + const database = request.result + database.onversionchange = () => { + database.close() + databasePromise = undefined + } + resolve(database) + } + request.onerror = () => { + databasePromise = undefined + reject( + new TrustedMintsCommitStoreError( + 'Unable to open trusted-mint commit storage.', + request.error, + ), + ) + } + request.onblocked = () => { + databasePromise = undefined + reject(new TrustedMintsCommitStoreError('Trusted-mint commit storage upgrade is blocked.')) + } + }) + return databasePromise +} + +const runRequest = async ( + mode: IDBTransactionMode, + createRequest: (store: IDBObjectStore) => IDBRequest, +): Promise => { + const database = await openDatabase() + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, mode, { + durability: mode === 'readwrite' ? 'strict' : 'default', + }) + const request = createRequest(transaction.objectStore(STORE_NAME)) + transaction.oncomplete = () => resolve(request.result) + transaction.onerror = () => + reject( + new TrustedMintsCommitStoreError( + 'Trusted-mint commit storage transaction failed.', + transaction.error, + ), + ) + transaction.onabort = () => + reject( + new TrustedMintsCommitStoreError( + 'Trusted-mint commit storage transaction was aborted.', + transaction.error, + ), + ) + }) +} + +export const trustedMintsCommitStore = { + available: (): boolean => typeof indexedDB !== 'undefined', + read: async (): Promise => { + const value = await runRequest('readonly', (store) => store.get(REGISTRY_KEY)) + if (value === undefined) return null + if (typeof value !== 'string') { + throw new TrustedMintsCommitStoreError('Trusted-mint commit storage is malformed.') + } + return value + }, + write: async (raw: string): Promise => { + await runRequest('readwrite', (store) => store.put(raw, REGISTRY_KEY)) + }, + clear: async (): Promise => { + await runRequest('readwrite', (store) => store.delete(REGISTRY_KEY)) + }, +}