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