feat: scope trusted mint state in Pinia

This commit is contained in:
2026-08-22 16:56:25 +02:00
parent e98373491f
commit 81c28ec16d
3 changed files with 304 additions and 36 deletions
+61
View File
@@ -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<typeof createPinia>;
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']),
);
});
});
+72 -36
View File
@@ -1,11 +1,7 @@
import {computed, ref} from 'vue' import { computed, onScopeDispose, ref, watch } from 'vue';
import {defineStore} from 'pinia' import { defineStore } from 'pinia';
import type { import type { TrustedMint, TrustedMintNodeInfo, TrustKeyResult } from '@/lnurlcash/trustedMints';
TrustedMint,
TrustedMintNodeInfo,
TrustKeyResult
} from '@/lnurlcash/trustedMints'
import { import {
PUBLIC_MINTS, PUBLIC_MINTS,
readTrustedMints, readTrustedMints,
@@ -17,9 +13,10 @@ import {
removeTrustedMint, removeTrustedMint,
cacheTrustedMintNodeInfo, cacheTrustedMintNodeInfo,
isMintTrusted, isMintTrusted,
getTrustedMintPubkey getTrustedMintPubkey,
} from '@/lnurlcash/trustedMints' } from '@/lnurlcash/trustedMints';
import {loadSettings, persistSettings} from '@/lnurlcash/storage' import { loadSettings, persistSettings } from '@/lnurlcash/storage';
import { useWalletStore } from './wallet';
// The trusted-mint registry as reactive state. The domain logic (pinning, // The trusted-mint registry as reactive state. The domain logic (pinning,
// rekey staging, backup merge rules) lives framework-free in // 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 // staged for review (pendingRekeys), never auto-applied: a silently
// rotated key would defeat the entire pinning model. // rotated key would defeat the entire pinning model.
export const useMintsStore = defineStore('mints', () => { export const useMintsStore = defineStore('mints', () => {
const mints = ref<TrustedMint[]>(readTrustedMints()) const wallet = useWalletStore();
onTrustedMintsChange(updated => { const mints = ref<TrustedMint[]>([]);
mints.value = updated 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 // mints with a staged rekey awaiting holder review - the UI should
// surface these loudly // surface these loudly
const pendingRekeys = computed(() => const pendingRekeys = computed(() => mints.value.filter((m) => m.pendingMintPubkey));
mints.value.filter(m => m.pendingMintPubkey)
)
// ---- default-mint selection (onboarding quick start) ---- // ---- default-mint selection (onboarding quick start) ----
const defaultMint = ref<string | null>(loadSettings().defaultMint ?? null) const defaultMint = ref<string | null>(loadSettings().defaultMint ?? null);
const setDefaultMint = (server: string | null): void => { const setDefaultMint = (server: string | null): void => {
defaultMint.value = server defaultMint.value = server;
const settings = loadSettings() const settings = loadSettings();
if (server === null) { if (server === null) {
persistSettings({...settings, defaultMint: undefined}) persistSettings({ ...settings, defaultMint: undefined });
} else { } else {
persistSettings({...settings, defaultMint: server}) persistSettings({ ...settings, defaultMint: server });
}
} }
};
// manual add from the mints settings, or a user-confirmed first // manual add from the mints settings, or a user-confirmed first
// encounter - validates and throws on junk input // encounter - validates and throws on junk input
const trust = ( const trust = (
server: string, server: string,
mintPubkey: string, mintPubkey: string,
nodeInfo?: TrustedMintNodeInfo nodeInfo?: TrustedMintNodeInfo,
): TrustKeyResult => addTrustedMint(server, mintPubkey, nodeInfo) ): Promise<TrustKeyResult> =>
addTrustedMint(server, mintPubkey, {
ownerId: requireOwner(),
nodeInfo,
});
// the silent path: this wallet holds (or just came to hold) a bearer from // 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 // this server - trust follows holding funds, never asks, and only ever
// STAGES a differing advertised key // STAGES a differing advertised key
const lockFromBearer = (server: string, mintPubkey: string): TrustKeyResult => const lockFromBearer = (server: string, mintPubkey: string): Promise<TrustKeyResult> =>
lockTrustedMint(server, mintPubkey) lockTrustedMint(server, mintPubkey, requireOwner());
const confirmRekey = (server: string): void => confirmTrustedMintRekey(server) const confirmRekey = (server: string): Promise<void> =>
const dismissRekey = (server: string): void => dismissTrustedMintRekey(server) confirmTrustedMintRekey(server, requireOwner());
const dismissRekey = (server: string): Promise<void> =>
dismissTrustedMintRekey(server, requireOwner());
// throws for a mint locked by a held bearer // throws for a mint locked by a held bearer
const remove = (server: string): void => removeTrustedMint(server) const remove = (server: string): Promise<void> => removeTrustedMint(server, requireOwner());
const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): void => const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): Promise<void> =>
cacheTrustedMintNodeInfo(server, nodeInfo) cacheTrustedMintNodeInfo(server, nodeInfo, requireOwner());
return { return {
mints, mints,
@@ -86,7 +122,7 @@ export const useMintsStore = defineStore('mints', () => {
dismissRekey, dismissRekey,
remove, remove,
cacheNodeInfo, cacheNodeInfo,
isTrusted: isMintTrusted, isTrusted,
trustedPubkey: getTrustedMintPubkey trustedPubkey,
} };
}) });
+171
View File
@@ -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<unknown> =>
new Promise((resolve) => {
this.requests.push({ callback, resolve });
});
async releaseNext(): Promise<void> {
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);
});
});