mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: scope trusted mint state in Pinia
This commit is contained in:
@@ -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
@@ -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<TrustedMint[]>(readTrustedMints())
|
||||
onTrustedMintsChange(updated => {
|
||||
mints.value = updated
|
||||
})
|
||||
const wallet = useWalletStore();
|
||||
const mints = ref<TrustedMint[]>([]);
|
||||
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<string | null>(loadSettings().defaultMint ?? null)
|
||||
const defaultMint = ref<string | null>(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<TrustKeyResult> =>
|
||||
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<TrustKeyResult> =>
|
||||
lockTrustedMint(server, mintPubkey, requireOwner());
|
||||
|
||||
const confirmRekey = (server: string): void => confirmTrustedMintRekey(server)
|
||||
const dismissRekey = (server: string): void => dismissTrustedMintRekey(server)
|
||||
const confirmRekey = (server: string): Promise<void> =>
|
||||
confirmTrustedMintRekey(server, requireOwner());
|
||||
const dismissRekey = (server: string): Promise<void> =>
|
||||
dismissTrustedMintRekey(server, requireOwner());
|
||||
|
||||
// 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 =>
|
||||
cacheTrustedMintNodeInfo(server, nodeInfo)
|
||||
const cacheNodeInfo = (server: string, nodeInfo: TrustedMintNodeInfo): Promise<void> =>
|
||||
cacheTrustedMintNodeInfo(server, nodeInfo, requireOwner());
|
||||
|
||||
return {
|
||||
mints,
|
||||
@@ -86,7 +122,7 @@ export const useMintsStore = defineStore('mints', () => {
|
||||
dismissRekey,
|
||||
remove,
|
||||
cacheNodeInfo,
|
||||
isTrusted: isMintTrusted,
|
||||
trustedPubkey: getTrustedMintPubkey
|
||||
}
|
||||
})
|
||||
isTrusted,
|
||||
trustedPubkey,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user