From 81c28ec16dee42b7705ac97dc047a3486045c9ab Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:56:25 +0200 Subject: [PATCH] feat: scope trusted mint state in Pinia --- src/stores/mints.storageEvents.test.ts | 61 +++++++++ src/stores/mints.ts | 108 ++++++++++------ src/stores/wallet.trust.test.ts | 171 +++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 36 deletions(-) create mode 100644 src/stores/mints.storageEvents.test.ts create mode 100644 src/stores/wallet.trust.test.ts diff --git a/src/stores/mints.storageEvents.test.ts b/src/stores/mints.storageEvents.test.ts new file mode 100644 index 0000000..f7318b1 --- /dev/null +++ b/src/stores/mints.storageEvents.test.ts @@ -0,0 +1,61 @@ +import { createPinia, disposePinia, setActivePinia } from 'pinia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { useMintsStore } from './mints'; +import { useWalletStore } from './wallet'; + +const LINKING_KEY_HEX = '07'.repeat(32); +const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)); +const MINT_KEY = '02' + 'aa'.repeat(32); +let testPinia: ReturnType; + +const storageEvent = (): Event => { + const event = new Event('storage'); + Object.defineProperties(event, { + key: { value: 'sattle_trusted_mints' }, + newValue: { value: 'obsolete' }, + }); + return event; +}; + +beforeEach(() => { + vi.unstubAllGlobals(); + stubLocalStorage(); + testPinia = createPinia(); + setActivePinia(testPinia); +}); + +afterEach(() => disposePinia(testPinia)); + +describe('mints store storage-event convergence', () => { + it('refreshes the active owner view from live storage without reload', async () => { + // Given an unlocked wallet and its mounted mints store + const events = new EventTarget(); + vi.stubGlobal('window', events); + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ enc: false, value: LINKING_KEY_HEX, ownerId: OWNER_ID, version: 1 }), + ); + const wallet = useWalletStore(); + await wallet.init(); + const mints = useMintsStore(); + + // When another tab stores a trusted mint before an obsolete event arrives + localStorage.setItem( + 'sattle_trusted_mints', + JSON.stringify({ + version: 1, + ownerId: OWNER_ID, + mints: [{ server: 'remote.example', mintPubkey: MINT_KEY, addedAt: 1, locked: false }], + }), + ); + events.dispatchEvent(storageEvent()); + + // Then Pinia renders the active owner's live registry without a reload + await vi.waitFor(() => + expect(mints.mints.map((entry) => entry.server)).toEqual(['remote.example']), + ); + }); +}); diff --git a/src/stores/mints.ts b/src/stores/mints.ts index 185b5f5..85fbb9f 100644 --- a/src/stores/mints.ts +++ b/src/stores/mints.ts @@ -1,11 +1,7 @@ -import {computed, ref} from 'vue' -import {defineStore} from 'pinia' +import { computed, onScopeDispose, ref, watch } from 'vue'; +import { defineStore } from 'pinia'; -import type { - TrustedMint, - TrustedMintNodeInfo, - TrustKeyResult -} from '@/lnurlcash/trustedMints' +import type { TrustedMint, TrustedMintNodeInfo, TrustKeyResult } from '@/lnurlcash/trustedMints'; import { PUBLIC_MINTS, readTrustedMints, @@ -17,9 +13,10 @@ import { removeTrustedMint, cacheTrustedMintNodeInfo, isMintTrusted, - getTrustedMintPubkey -} from '@/lnurlcash/trustedMints' -import {loadSettings, persistSettings} from '@/lnurlcash/storage' + getTrustedMintPubkey, +} from '@/lnurlcash/trustedMints'; +import { loadSettings, persistSettings } from '@/lnurlcash/storage'; +import { useWalletStore } from './wallet'; // The trusted-mint registry as reactive state. The domain logic (pinning, // rekey staging, backup merge rules) lives framework-free in @@ -28,51 +25,90 @@ import {loadSettings, persistSettings} from '@/lnurlcash/storage' // staged for review (pendingRekeys), never auto-applied: a silently // rotated key would defeat the entire pinning model. export const useMintsStore = defineStore('mints', () => { - const mints = ref(readTrustedMints()) - onTrustedMintsChange(updated => { - mints.value = updated - }) + const wallet = useWalletStore(); + const mints = ref([]); + const activeOwner = (): string | null => (wallet.state === 'unlocked' ? wallet.pubkey : null); + let stopTrustedMintsChanges: (() => void) | null = null; + watch( + () => [wallet.state, wallet.pubkey] as const, + () => { + stopTrustedMintsChanges?.(); + stopTrustedMintsChanges = null; + const ownerId = activeOwner(); + if (ownerId === null) { + mints.value = []; + return; + } + mints.value = readTrustedMints(ownerId); + stopTrustedMintsChanges = onTrustedMintsChange(() => { + if (activeOwner() !== ownerId) return; + mints.value = readTrustedMints(ownerId); + }); + }, + { immediate: true, flush: 'sync' }, + ); + onScopeDispose(() => stopTrustedMintsChanges?.()); + + const requireOwner = (): string => { + const ownerId = activeOwner(); + if (ownerId === null) throw new Error('Wallet is locked.'); + return ownerId; + }; + + const isTrusted = (server: string): boolean => { + const ownerId = activeOwner(); + return ownerId === null ? false : isMintTrusted(server, ownerId); + }; + + const trustedPubkey = (server: string): string | null => { + const ownerId = activeOwner(); + return ownerId === null ? null : getTrustedMintPubkey(server, ownerId); + }; // mints with a staged rekey awaiting holder review - the UI should // surface these loudly - const pendingRekeys = computed(() => - mints.value.filter(m => m.pendingMintPubkey) - ) + const pendingRekeys = computed(() => mints.value.filter((m) => m.pendingMintPubkey)); // ---- default-mint selection (onboarding quick start) ---- - const defaultMint = ref(loadSettings().defaultMint ?? null) + const defaultMint = ref(loadSettings().defaultMint ?? null); const setDefaultMint = (server: string | null): void => { - defaultMint.value = server - const settings = loadSettings() + defaultMint.value = server; + const settings = loadSettings(); if (server === null) { - persistSettings({...settings, defaultMint: undefined}) + persistSettings({ ...settings, defaultMint: undefined }); } else { - persistSettings({...settings, defaultMint: server}) + persistSettings({ ...settings, defaultMint: server }); } - } + }; // manual add from the mints settings, or a user-confirmed first // encounter - validates and throws on junk input const trust = ( server: string, mintPubkey: string, - nodeInfo?: TrustedMintNodeInfo - ): TrustKeyResult => addTrustedMint(server, mintPubkey, nodeInfo) + nodeInfo?: TrustedMintNodeInfo, + ): Promise => + addTrustedMint(server, mintPubkey, { + ownerId: requireOwner(), + nodeInfo, + }); // the silent path: this wallet holds (or just came to hold) a bearer from // this server - trust follows holding funds, never asks, and only ever // STAGES a differing advertised key - const lockFromBearer = (server: string, mintPubkey: string): TrustKeyResult => - lockTrustedMint(server, mintPubkey) + const lockFromBearer = (server: string, mintPubkey: string): Promise => + lockTrustedMint(server, mintPubkey, requireOwner()); - const confirmRekey = (server: string): void => confirmTrustedMintRekey(server) - const dismissRekey = (server: string): void => dismissTrustedMintRekey(server) + const confirmRekey = (server: string): Promise => + confirmTrustedMintRekey(server, requireOwner()); + const dismissRekey = (server: string): Promise => + dismissTrustedMintRekey(server, requireOwner()); // throws for a mint locked by a held bearer - const remove = (server: string): void => removeTrustedMint(server) + const remove = (server: string): Promise => removeTrustedMint(server, requireOwner()); - const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): void => - cacheTrustedMintNodeInfo(server, nodeInfo) + const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): Promise => + cacheTrustedMintNodeInfo(server, nodeInfo, requireOwner()); return { mints, @@ -86,7 +122,7 @@ export const useMintsStore = defineStore('mints', () => { dismissRekey, remove, cacheNodeInfo, - isTrusted: isMintTrusted, - trustedPubkey: getTrustedMintPubkey - } -}) + isTrusted, + trustedPubkey, + }; +}); diff --git a/src/stores/wallet.trust.test.ts b/src/stores/wallet.trust.test.ts new file mode 100644 index 0000000..9ddb091 --- /dev/null +++ b/src/stores/wallet.trust.test.ts @@ -0,0 +1,171 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl } from 'lnurlcash-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deriveBearerAesKey } from '@/lnurlcash/keys'; +import { loadBearers } from '@/lnurlcash/storage'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import type { NewBearer } from '@/lnurlcash/types'; +import { TrustedMintPostCommitError, useWalletStore } from './wallet'; +import { useMintsStore } from './mints'; + +const MINT_PUBKEY = '02' + 'aa'.repeat(32); +const NOTE: NewBearer = { + url: buildNoteUrl('https://mint.example/w', 'bb'.repeat(32), 21_000), + callback: 'https://mint.example/w/cb', + amount: 21_000, + verified: true, + mintPubkey: MINT_PUBKEY, +}; + +type LockRequest = { + readonly callback: () => unknown; + readonly resolve: (value: unknown) => void; +}; + +class DeferredLocks { + readonly requests: LockRequest[] = []; + + readonly request = (_name: string, callback: () => unknown): Promise => + new Promise((resolve) => { + this.requests.push({ callback, resolve }); + }); + + async releaseNext(): Promise { + const request = this.requests.shift(); + if (!request) throw new Error('Expected a queued lock request.'); + request.resolve(await request.callback()); + } +} + +beforeEach(() => { + vi.unstubAllGlobals(); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +describe('bearer commit trust side effect', () => { + it('commits a combined addition and spent marker in one bearer write', async () => { + vi.stubGlobal('navigator', {}); + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([{ ...NOTE, mintPubkey: undefined }], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + const writes = vi.spyOn(storage, 'setItem'); + const applyChangeset = Reflect.get(wallet, 'applyChangeset'); + if (typeof applyChangeset !== 'function') { + throw new TypeError('Expected the wallet to expose atomic changeset application.'); + } + + await Reflect.apply(applyChangeset, wallet, [ + { add: [{ ...NOTE, mintPubkey: undefined }], markSpent: [existing.id] }, + ownerFence, + ]); + + expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(wallet.bearers).toHaveLength(2); + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + }); + + it('keeps an atomic changeset committed when trust convergence fails afterward', async () => { + vi.stubGlobal('navigator', {}); + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const [existing] = await wallet.addBearers([{ ...NOTE, mintPubkey: undefined }], ownerFence); + if (!existing) throw new Error('Expected the initial bearer.'); + const originalSetItem = storage.setItem; + storage.setItem = (key, value) => { + if (key === 'sattle_trusted_mints') throw new Error('trust storage unavailable'); + originalSetItem(key, value); + }; + + await expect( + wallet.applyChangeset({ add: [NOTE], markSpent: [existing.id] }, ownerFence), + ).rejects.toBeInstanceOf(TrustedMintPostCommitError); + + expect(wallet.bearers).toHaveLength(2); + expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + const persisted = await loadBearers(key); + expect(persisted).toHaveLength(2); + expect(persisted.find(({ id }) => id === existing.id)?.spent).toBe(true); + }); + + it('waits for trust convergence after the bearer is committed', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const locks = new DeferredLocks(); + vi.stubGlobal('navigator', { locks }); + + let settled = false; + const adding = wallet.addBearers([NOTE], ownerFence).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(locks.requests).toHaveLength(1)); + await locks.releaseNext(); + await vi.waitFor(() => expect(locks.requests).toHaveLength(1)); + + expect(wallet.bearers).toHaveLength(1); + expect(settled).toBe(false); + await locks.releaseNext(); + + expect(await adding).toHaveLength(1); + }); + + it('reports trust failure as post-commit while preserving durable funds', async () => { + vi.stubGlobal('navigator', {}); + const storage = stubLocalStorage(); + const wallet = useWalletStore(); + await wallet.create(); + const ownerFence = wallet.captureOwnerFence(); + const originalSetItem = storage.setItem; + storage.setItem = (key, value) => { + if (key === 'sattle_trusted_mints') { + throw new Error('trust storage unavailable'); + } + originalSetItem(key, value); + }; + + const adding = wallet.addBearers([NOTE], ownerFence); + + await expect(adding).rejects.toMatchObject({ + name: 'TrustedMintPostCommitError', + fundsCommitted: true, + message: expect.stringMatching(/saved|committed/i), + }); + await expect(adding).rejects.toBeInstanceOf(TrustedMintPostCommitError); + expect(wallet.bearers).toHaveLength(1); + expect(wallet.auxiliaryError).toMatch(/receive succeeded.*do not retry/i); + const key = await deriveBearerAesKey(wallet.requireLinkingKey()); + expect(await loadBearers(key)).toHaveLength(1); + }); +}); + +describe('owner-bound trusted-mint reads', () => { + it('uses only the unlocked wallet owner and fails closed while locked', async () => { + const wallet = useWalletStore(); + const mints = useMintsStore(); + + expect(mints.isTrusted('mint.example')).toBe(false); + expect(mints.trustedPubkey('mint.example')).toBeNull(); + + await wallet.create('password'); + await mints.trust('mint.example', MINT_PUBKEY); + expect(mints.isTrusted('mint.example')).toBe(true); + expect(mints.trustedPubkey('mint.example')).toBe(MINT_PUBKEY); + + await wallet.lock(); + expect(mints.isTrusted('mint.example')).toBe(false); + expect(mints.trustedPubkey('mint.example')).toBeNull(); + + await wallet.unlock('password'); + expect(mints.isTrusted('mint.example')).toBe(true); + expect(mints.trustedPubkey('mint.example')).toBe(MINT_PUBKEY); + }); +});