feat: serialize wallet lifecycle transitions

This commit is contained in:
2026-08-22 16:56:11 +02:00
parent 5b3a69e910
commit 62b93baa05
4 changed files with 263 additions and 0 deletions
+6
View File
@@ -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';
+122
View File
@@ -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) });
});
+7
View File
@@ -0,0 +1,7 @@
import { vi } from 'vitest';
export const lifecycleMocks = {
disableBiometricUnlock: vi.fn<() => Promise<void>>(),
restoreFromNostr: vi.fn(),
startService: vi.fn(),
};
+128
View File
@@ -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);
};