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 { 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,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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