From d735aca885a63ff2bf9d96dd6732f02e63c382d4 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:47 +0200 Subject: [PATCH] feat: serialize trusted mint transactions --- .../trustedMints.transactions.test.ts | 287 ++++++++++++++++++ src/lnurlcash/trustedMintsRepository.ts | 201 ++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 src/lnurlcash/trustedMints.transactions.test.ts create mode 100644 src/lnurlcash/trustedMintsRepository.ts diff --git a/src/lnurlcash/trustedMints.transactions.test.ts b/src/lnurlcash/trustedMints.transactions.test.ts new file mode 100644 index 0000000..77b9382 --- /dev/null +++ b/src/lnurlcash/trustedMints.transactions.test.ts @@ -0,0 +1,287 @@ +// Owner-bound trusted-mint transactions under deterministic Web Locks. +// The fake parks every request until the test releases it in FIFO order. + +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {linkingPubKeyHex, saveLinkingKey} from './keys' +import { + addTrustedMint, + cacheTrustedMintNodeInfo, + clearTrustedMints, + confirmTrustedMintRekey, + lockTrustedMint, + mergeTrustedMints, + onTrustedMintsChange, + readTrustedMints, + removeTrustedMint, + type TrustedMint, +} from './trustedMints' +import {stubLocalStorage} from './test-utils' + +const STORAGE_KEY = 'sattle_trusted_mints' +const LINKING_KEY_A = new Uint8Array(32).fill(7) +const OWNER_A = linkingPubKeyHex(LINKING_KEY_A) +const OWNER_B = linkingPubKeyHex(new Uint8Array(32).fill(9)) +const KEY_A = '02' + 'aa'.repeat(32) +const KEY_B = '03' + 'bb'.repeat(32) +const KEY_C = '02' + 'cc'.repeat(32) +const SERVER = 'mint.example' + +type LockRequest = { + readonly name: string + readonly callback: () => unknown + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void +} + +class DeferredLocks { + readonly requests: LockRequest[] = [] + held = false + + readonly request = (name: string, callback: () => unknown): Promise => + new Promise((resolve, reject) => { + this.requests.push({name, callback, resolve, reject}) + }) + + async releaseNext(): Promise { + const request = this.requests.shift() + if (!request) throw new Error('Expected a queued lock request.') + this.held = true + try { + request.resolve(await request.callback()) + } catch (error) { + request.reject(error instanceof Error ? error : new Error(String(error))) + } finally { + this.held = false + } + } +} + +const mint = (overrides: Partial = {}): TrustedMint => ({ + server: SERVER, + mintPubkey: KEY_A, + addedAt: 123, + locked: false, + ...overrides, +}) + +const store = (ownerId: string, mints: TrustedMint[]): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({version: 1, ownerId, mints})) +} + +const installLocks = (): DeferredLocks => { + const locks = new DeferredLocks() + vi.stubGlobal('navigator', {locks}) + return locks +} + +const waitForRequests = async (locks: DeferredLocks, count: number): Promise => { + await vi.waitFor(() => expect(locks.requests).toHaveLength(count)) +} + +beforeEach(async () => { + vi.unstubAllGlobals() + stubLocalStorage() + await saveLinkingKey(LINKING_KEY_A) +}) + +describe('serialized owner-bound mutations', () => { + it('reads current storage instead of retaining a stale snapshot', () => { + store(OWNER_A, [mint({nodeAlias: 'first'})]) + expect(readTrustedMints(OWNER_A)[0]?.nodeAlias).toBe('first') + + store(OWNER_A, [mint({nodeAlias: 'external update'})]) + + expect(readTrustedMints(OWNER_A)[0]?.nodeAlias).toBe('external update') + }) + + it('preserves two queued additions by reading fresh state in FIFO order', async () => { + const locks = installLocks() + const writes = vi.spyOn(localStorage, 'setItem') + + const first = addTrustedMint('one.example', KEY_A, {ownerId: OWNER_A}) + const second = addTrustedMint('two.example', KEY_B, {ownerId: OWNER_A}) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + + await expect(first).resolves.toBe('added') + await expect(second).resolves.toBe('added') + expect(readTrustedMints(OWNER_A).map((entry) => entry.server)).toEqual([ + 'one.example', + 'two.example', + ]) + expect(writes).toHaveBeenCalledTimes(2) + }) + + it('does not let metadata overwrite a concurrently staged rekey', async () => { + store(OWNER_A, [mint()]) + const locks = installLocks() + + const rekey = lockTrustedMint(SERVER, KEY_B, OWNER_A) + const metadata = cacheTrustedMintNodeInfo(SERVER, {nodeAlias: 'Fresh alias'}, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([rekey, metadata]) + + expect(readTrustedMints(OWNER_A)[0]).toMatchObject({ + mintPubkey: KEY_A, + pendingMintPubkey: KEY_B, + nodeAlias: 'Fresh alias', + }) + }) + + it('keeps a new staged key after queued explicit confirmation', async () => { + store(OWNER_A, [mint({pendingMintPubkey: KEY_B})]) + const locks = installLocks() + + const confirm = confirmTrustedMintRekey(SERVER, OWNER_A) + const stage = lockTrustedMint(SERVER, KEY_C, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([confirm, stage]) + + expect(readTrustedMints(OWNER_A)[0]).toMatchObject({ + mintPubkey: KEY_B, + pendingMintPubkey: KEY_C, + }) + }) + + it('lets a live lock corroborate a queued backup merge', async () => { + const locks = installLocks() + const incoming = mint({locked: true, pendingMintPubkey: KEY_B}) + + const merge = mergeTrustedMints([incoming], OWNER_A) + const live = lockTrustedMint(SERVER, KEY_A, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([merge, live]) + + expect(readTrustedMints(OWNER_A)[0]).toMatchObject({ + mintPubkey: KEY_A, + locked: true, + }) + expect(readTrustedMints(OWNER_A)[0]?.unconfirmed).toBeUndefined() + }) + + it('rejects queued removal after a live operation locks the mint', async () => { + store(OWNER_A, [mint()]) + const locks = installLocks() + + const live = lockTrustedMint(SERVER, KEY_A, OWNER_A) + const removal = removeTrustedMint(SERVER, OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + + await expect(live).resolves.toBe('unchanged') + await expect(removal).rejects.toThrow(/bearer/) + expect(readTrustedMints(OWNER_A)[0]?.locked).toBe(true) + }) + + it('applies a queued clear after an earlier writer', async () => { + store(OWNER_A, [mint()]) + const locks = installLocks() + + const add = addTrustedMint('queued.example', KEY_B, {ownerId: OWNER_A}) + const clear = clearTrustedMints(OWNER_A) + await waitForRequests(locks, 2) + + await locks.releaseNext() + await locks.releaseNext() + await Promise.all([add, clear]) + + expect(readTrustedMints(OWNER_A)).toEqual([]) + }) + + it('rejects stale, malformed, and foreign-owner mutations', async () => { + store(OWNER_B, [mint()]) + + await expect(addTrustedMint('stale.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + /owner/i, + ) + await expect(addTrustedMint('invalid.example', KEY_B, {ownerId: 'invalid'})).rejects.toThrow( + /owner/i, + ) + + expect(readTrustedMints(OWNER_B)).toEqual([mint()]) + }) + + it('rejects a malformed stored envelope without overwriting it', async () => { + const malformed = JSON.stringify({ + version: 1, + ownerId: 'invalid', + mints: [], + }) + localStorage.setItem(STORAGE_KEY, malformed) + + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + /malformed/i, + ) + + expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed) + }) + + it('rejects a malformed mint member without rewriting stored bytes', async () => { + const malformed = JSON.stringify({ + version: 1, + ownerId: OWNER_A, + mints: [mint(), mint({server: 'broken.example', mintPubkey: 'not-hex'})], + }) + localStorage.setItem(STORAGE_KEY, malformed) + + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + /malformed/i, + ) + + expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed) + }) + + it('keeps storage and listeners unchanged when the single write fails', async () => { + store(OWNER_A, [mint()]) + vi.stubGlobal('navigator', {}) + const before = localStorage.getItem(STORAGE_KEY) + const notified = vi.fn() + const unsubscribe = onTrustedMintsChange(notified) + const setItem = localStorage.setItem.bind(localStorage) + localStorage.setItem = (): void => { + throw new Error('QuotaExceededError') + } + + try { + await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow( + 'QuotaExceededError', + ) + expect(localStorage.getItem(STORAGE_KEY)).toBe(before) + expect(notified).not.toHaveBeenCalled() + } finally { + localStorage.setItem = setItem + unsubscribe() + } + }) + + it('notifies listeners only after the storage lock is released', async () => { + const locks = installLocks() + const lockStates: boolean[] = [] + const unsubscribe = onTrustedMintsChange(() => lockStates.push(locks.held)) + + try { + const addition = addTrustedMint(SERVER, KEY_A, {ownerId: OWNER_A}) + await waitForRequests(locks, 1) + await locks.releaseNext() + await addition + + expect(lockStates).toEqual([false]) + } finally { + unsubscribe() + } + }) +}) diff --git a/src/lnurlcash/trustedMintsRepository.ts b/src/lnurlcash/trustedMintsRepository.ts new file mode 100644 index 0000000..1e7bdae --- /dev/null +++ b/src/lnurlcash/trustedMintsRepository.ts @@ -0,0 +1,201 @@ +// Owner-bound trusted-mint persistence. Web Locks serialize writers while an +// IndexedDB commit mirror bridges stale cross-renderer localStorage views. + +import {isWalletOwnerId} from './storage/walletOwner' +import {savedKeyOwnerAllows} from './storage/currentOwner' +import {storageLocksAvailable, withStorageLock} from './storageLock' +import type {TrustedMint} from './trustedMints' +import {trustedMintsCommitStore} from './trustedMintsCommitStore' +import {notifyStoredTrustedMintsChange, TRUSTED_MINTS_STORAGE_KEY} from './trustedMintsEvents' +import { + parseLegacyTrustedMintsRegistry, + parseStoredTrustedMintsRegistry, + serializeTrustedMintsRegistry, + type StoredTrustedMintsRegistry, +} from './trustedMintsRegistry' +import type {MintTransition} from './trustedMintTransitions' + +const STORAGE_KEY = TRUSTED_MINTS_STORAGE_KEY + +type MutationState = { + readonly canonicalRaw: string | null + readonly current: TrustedMint[] + readonly localRaw: string | null + readonly mirrorEnabled: boolean +} + +export class InvalidTrustedMintsOwnerError extends Error { + override readonly name = 'InvalidTrustedMintsOwnerError' + + constructor() { + super('Trusted-mint mutation requires a valid wallet owner.') + } +} + +export class TrustedMintsOwnerMismatchError extends Error { + override readonly name = 'TrustedMintsOwnerMismatchError' + + constructor() { + super('Trusted-mint registry belongs to a different wallet owner.') + } +} + +export class MalformedTrustedMintsRegistryError extends Error { + override readonly name = 'MalformedTrustedMintsRegistryError' + + constructor() { + super('Trusted-mint registry storage is malformed.') + } +} + +const requireOwnedRegistry = ( + stored: StoredTrustedMintsRegistry, + ownerId: string, +): StoredTrustedMintsRegistry => { + if (stored.kind === 'malformed') throw new MalformedTrustedMintsRegistryError() + if (stored.kind === 'valid' && stored.envelope.ownerId !== ownerId) { + throw new TrustedMintsOwnerMismatchError() + } + return stored +} + +const mirrorIsEnabled = (): boolean => + storageLocksAvailable() && trustedMintsCommitStore.available() + +const readMutationState = async (ownerId: string): Promise => { + const localRaw = localStorage.getItem(STORAGE_KEY) + const local = requireOwnedRegistry(parseStoredTrustedMintsRegistry(localRaw), ownerId) + const mirrorEnabled = mirrorIsEnabled() + if (!mirrorEnabled) { + return { + canonicalRaw: localRaw, + current: local.kind === 'valid' ? local.envelope.mints : [], + localRaw, + mirrorEnabled, + } + } + + const mirrorRaw = await trustedMintsCommitStore.read() + if (mirrorRaw === null) { + return { + canonicalRaw: localRaw, + current: local.kind === 'valid' ? local.envelope.mints : [], + localRaw, + mirrorEnabled, + } + } + const mirror = requireOwnedRegistry(parseStoredTrustedMintsRegistry(mirrorRaw), ownerId) + if (mirror.kind !== 'valid') throw new MalformedTrustedMintsRegistryError() + return { + canonicalRaw: mirrorRaw, + current: mirror.envelope.mints, + localRaw, + mirrorEnabled, + } +} + +const restoreLocalRegistry = (raw: string | null): void => { + if (raw === null) localStorage.removeItem(STORAGE_KEY) + else localStorage.setItem(STORAGE_KEY, raw) +} + +const commitRegistry = async ( + previousRaw: string | null, + nextRaw: string, + mirrorEnabled: boolean, +): Promise => { + const localChanged = previousRaw !== nextRaw + if (localChanged) localStorage.setItem(STORAGE_KEY, nextRaw) + if (!mirrorEnabled) return localChanged + try { + await trustedMintsCommitStore.write(nextRaw) + } catch (error) { + if (localChanged) restoreLocalRegistry(previousRaw) + throw error + } + return localChanged +} + +export {onStoredTrustedMintsChange} from './trustedMintsEvents' + +export const readOwnedTrustedMints = (ownerId: unknown): TrustedMint[] => { + if (!isWalletOwnerId(ownerId)) return [] + const stored = parseStoredTrustedMintsRegistry(localStorage.getItem(STORAGE_KEY)) + return stored.kind === 'valid' && stored.envelope.ownerId === ownerId ? stored.envelope.mints : [] +} + +export const mutateStoredTrustedMints = async ( + ownerId: unknown, + transition: (mints: TrustedMint[]) => MintTransition, +): Promise => { + if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError() + + const committed = await withStorageLock(STORAGE_KEY, async () => { + if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError() + const state = await readMutationState(ownerId) + const next = transition(state.current) + const nextRaw = next.changed + ? serializeTrustedMintsRegistry(ownerId, next.mints) + : state.canonicalRaw + const localChanged = + nextRaw === null ? false : await commitRegistry(state.localRaw, nextRaw, state.mirrorEnabled) + return {localChanged, result: next.result} + }) + + if (committed.localChanged) notifyStoredTrustedMintsChange() + return committed.result +} + +export const adoptLegacyStoredTrustedMints = async (ownerId: unknown): Promise => { + if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError() + const adopted = await withStorageLock(STORAGE_KEY, async () => { + if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError() + const localRaw = localStorage.getItem(STORAGE_KEY) + const legacy = parseLegacyTrustedMintsRegistry(localRaw) + if (legacy === null) { + requireOwnedRegistry(parseStoredTrustedMintsRegistry(localRaw), ownerId) + return null + } + await commitRegistry( + localRaw, + serializeTrustedMintsRegistry(ownerId, legacy), + mirrorIsEnabled(), + ) + return legacy + }) + if (adopted !== null) notifyStoredTrustedMintsChange() + return adopted?.length ?? 0 +} + +export const removeStoredTrustedMintsForOwner = async (ownerId: unknown): Promise => { + if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError() + const removed = await withStorageLock(STORAGE_KEY, async () => { + if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError() + const state = await readMutationState(ownerId) + if (state.canonicalRaw === null) return false + if (state.localRaw !== null) localStorage.removeItem(STORAGE_KEY) + try { + if (state.mirrorEnabled) await trustedMintsCommitStore.clear() + } catch (error) { + if (state.localRaw !== null) restoreLocalRegistry(state.localRaw) + throw error + } + return true + }) + if (removed) notifyStoredTrustedMintsChange() +} + +export const resetStoredTrustedMints = async (): Promise => { + const removed = await withStorageLock(STORAGE_KEY, async () => { + const localRaw = localStorage.getItem(STORAGE_KEY) + if (localRaw !== null) localStorage.removeItem(STORAGE_KEY) + try { + if (mirrorIsEnabled()) await trustedMintsCommitStore.clear() + } catch (error) { + if (localRaw !== null) restoreLocalRegistry(localRaw) + throw error + } + return localRaw !== null + }) + if (removed) notifyStoredTrustedMintsChange() +}