mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: serialize wallet lifecycle transitions
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import './wallet.lifecycle.activation.cases';
|
||||
import './wallet.lifecycle.teardown.cases';
|
||||
import './wallet.lifecycle.lockFailure.cases';
|
||||
import './wallet.lifecycle.isolation.cases';
|
||||
import './wallet.lifecycle.invalidation.cases';
|
||||
import './wallet.lifecycle.backupOwner.cases';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
|
||||
import { encryptSecretParts, linkingPubKeyHex } from '@/lnurlcash/keys';
|
||||
import type * as NwcExports from '@/lnurlcash/nwc';
|
||||
import type * as NostrBackupExports from '@/lnurlcash/nostrBackup';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import { lifecycleMocks } from './wallet.lifecycle.testMocks';
|
||||
|
||||
export { lifecycleMocks as mocks } from './wallet.lifecycle.testMocks';
|
||||
|
||||
vi.mock('@/capabilities/biometricUnlock', async () => {
|
||||
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
||||
return {
|
||||
disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock,
|
||||
unlockWithBiometrics: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lnurlcash/nwc', async (importOriginal) => {
|
||||
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
||||
const actual = await importOriginal<typeof NwcExports>();
|
||||
return { ...actual, startService: lifecycleMocks.startService };
|
||||
});
|
||||
|
||||
vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => {
|
||||
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
||||
const actual = await importOriginal<typeof NostrBackupExports>();
|
||||
return { ...actual, restoreFromNostr: lifecycleMocks.restoreFromNostr };
|
||||
});
|
||||
|
||||
export const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
||||
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
||||
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
|
||||
export const PASSWORD = 'correct horse battery staple';
|
||||
export const MINT_KEY = '02' + 'aa'.repeat(32);
|
||||
|
||||
export const encryptedLinkingKeyRecord = async () => {
|
||||
const parts = await encryptSecretParts(
|
||||
Array.from(LINKING_KEY, (byte) => byte.toString(16).padStart(2, '0')).join(''),
|
||||
PASSWORD,
|
||||
);
|
||||
return { enc: true as const, ...parts };
|
||||
};
|
||||
|
||||
type Deferred = {
|
||||
readonly promise: Promise<void>;
|
||||
readonly resolve: () => void;
|
||||
};
|
||||
|
||||
export const deferred = (): Deferred => {
|
||||
let resolvePromise: (() => void) | undefined;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolvePromise?.(),
|
||||
};
|
||||
};
|
||||
|
||||
export const installLegacyOwnerlessResidue = (): void => {
|
||||
localStorage.setItem(
|
||||
'sattle_passkey_slots',
|
||||
JSON.stringify([
|
||||
{
|
||||
credentialId: '11'.repeat(16),
|
||||
hkdfSalt: '22'.repeat(16),
|
||||
iv: '33'.repeat(12),
|
||||
wrappedKey: '44'.repeat(48),
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
localStorage.setItem(
|
||||
'sattle_nwc_connections',
|
||||
JSON.stringify([
|
||||
{
|
||||
clientPubkey: '55'.repeat(32),
|
||||
relays: ['wss://relay.example'],
|
||||
budget: { maxMsat: 1000, periodMs: 60_000 },
|
||||
spent: { periodStart: 0, msat: 0 },
|
||||
createdAt: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
localStorage.setItem('sattle_nwc_enabled', 'true');
|
||||
localStorage.setItem(
|
||||
'sattle_trusted_mints',
|
||||
JSON.stringify([
|
||||
{
|
||||
server: 'legacy.example',
|
||||
mintPubkey: MINT_KEY,
|
||||
addedAt: 1,
|
||||
locked: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
export const installLegacyEncryptedWallet = async (): Promise<void> => {
|
||||
localStorage.setItem('sattle_linking_key', JSON.stringify(await encryptedLinkingKeyRecord()));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
stubLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
lifecycleMocks.disableBiometricUnlock.mockResolvedValue();
|
||||
lifecycleMocks.restoreFromNostr.mockResolvedValue({
|
||||
added: 0,
|
||||
skipped: 0,
|
||||
linkingKeyRestored: false,
|
||||
linkingKeySkipped: false,
|
||||
trustedMintsAdded: 0,
|
||||
settingsRestored: false,
|
||||
found: [],
|
||||
});
|
||||
lifecycleMocks.startService.mockResolvedValue({ stop: vi.fn().mockResolvedValue(undefined) });
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const lifecycleMocks = {
|
||||
disableBiometricUnlock: vi.fn<() => Promise<void>>(),
|
||||
restoreFromNostr: vi.fn(),
|
||||
startService: vi.fn(),
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
deriveWalletLinkingKey,
|
||||
ensureSavedKeyOwner,
|
||||
isValidSeedPhrase,
|
||||
linkingPubKeyHex,
|
||||
saveLinkingKey,
|
||||
} from '@/lnurlcash/keys';
|
||||
import { migrateLegacyPasskeySlots } from '@/lnurlcash/passkeys';
|
||||
import {
|
||||
clearPasskeySlotsForOwner,
|
||||
clearUnownedPasskeySlots,
|
||||
PASSKEY_SLOTS_STORAGE_KEY,
|
||||
} from '@/lnurlcash/storage/passkeySlots';
|
||||
import {
|
||||
clearNwcStorageForOwner,
|
||||
clearUnownedNwcStorage,
|
||||
migrateLegacyNwcStorage,
|
||||
} from '@/lnurlcash/storage/nwcConnections';
|
||||
import { withStorageLock } from '@/lnurlcash/storageLock';
|
||||
import {
|
||||
migrateLegacyTrustedMints,
|
||||
removeTrustedMintsForOwner,
|
||||
resetTrustedMintsForReplacement,
|
||||
} from '@/lnurlcash/trustedMints';
|
||||
|
||||
export type WalletTransitionQueue = {
|
||||
readonly run: <Result>(transition: () => Promise<Result>) => Promise<Result>;
|
||||
};
|
||||
|
||||
type WalletTransitionQueueOptions = {
|
||||
readonly onStart: () => void;
|
||||
readonly onError: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export const createWalletTransitionQueue = (
|
||||
options: WalletTransitionQueueOptions,
|
||||
): WalletTransitionQueue => {
|
||||
let tail: Promise<void> = Promise.resolve();
|
||||
return {
|
||||
run: <Result>(transition: () => Promise<Result>): Promise<Result> => {
|
||||
const execute = async (): Promise<Result> => {
|
||||
options.onStart();
|
||||
try {
|
||||
return await transition();
|
||||
} catch (error) {
|
||||
options.onError(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const operation = tail.then(execute, execute);
|
||||
tail = operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return operation;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export class WalletLifecycleError extends Error {
|
||||
override readonly name = 'WalletLifecycleError';
|
||||
|
||||
constructor(
|
||||
readonly transition: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
const detail = cause instanceof Error ? cause.message : 'Unknown failure.';
|
||||
super(`Wallet ${transition} failed: ${detail}`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
export const stopWalletNwcSession = async (): Promise<void> => {
|
||||
const { useNwcStore } = await import('./nwc');
|
||||
await useNwcStore().stop();
|
||||
};
|
||||
|
||||
export const migrateProvenLegacyOwner = async (
|
||||
linkingKey: Uint8Array,
|
||||
ownerWasMissing: boolean,
|
||||
): Promise<void> => {
|
||||
ensureSavedKeyOwner(linkingKey);
|
||||
if (!ownerWasMissing) return;
|
||||
await migrateLegacyPasskeySlots(linkingKey);
|
||||
migrateLegacyNwcStorage(linkingKey);
|
||||
await migrateLegacyTrustedMints(linkingKey);
|
||||
};
|
||||
|
||||
export const clearOwnerAuthorizations = async (
|
||||
ownerId: string,
|
||||
resetRegistry = false,
|
||||
): Promise<void> => {
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||
clearPasskeySlotsForOwner(ownerId);
|
||||
});
|
||||
clearNwcStorageForOwner(ownerId);
|
||||
if (resetRegistry) await resetTrustedMintsForReplacement();
|
||||
else await removeTrustedMintsForOwner(ownerId);
|
||||
};
|
||||
|
||||
export const clearUnownedAuthorizations = async (): Promise<void> => {
|
||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, clearUnownedPasskeySlots);
|
||||
clearUnownedNwcStorage();
|
||||
await resetTrustedMintsForReplacement();
|
||||
};
|
||||
|
||||
export const ownerOf = (linkingKey: Uint8Array): string => linkingPubKeyHex(linkingKey);
|
||||
|
||||
type SeedInstallerOptions = {
|
||||
readonly prepareInstallation: (ownerId: string) => Promise<void>;
|
||||
readonly activate: (linkingKey: Uint8Array) => Promise<void>;
|
||||
};
|
||||
|
||||
export type SeedInstaller = (
|
||||
seedPhrase: string,
|
||||
password?: string,
|
||||
restore?: (linkingKey: Uint8Array) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
export const createSeedInstaller =
|
||||
(options: SeedInstallerOptions): SeedInstaller =>
|
||||
async (seedPhrase, password, restore) => {
|
||||
if (!isValidSeedPhrase(seedPhrase)) throw new Error('Not a valid seed phrase.');
|
||||
const linkingKey = deriveWalletLinkingKey(seedPhrase);
|
||||
await options.prepareInstallation(ownerOf(linkingKey));
|
||||
if (restore) await restore(linkingKey);
|
||||
await saveLinkingKey(linkingKey, password);
|
||||
await options.activate(linkingKey);
|
||||
};
|
||||
Reference in New Issue
Block a user