feat: activate only proven wallet owners

This commit is contained in:
2026-08-22 16:56:24 +02:00
parent 62b93baa05
commit c319e11d3e
5 changed files with 339 additions and 0 deletions
@@ -0,0 +1,102 @@
import {
OWNER_ID,
PASSWORD,
deferred,
installLegacyEncryptedWallet,
installLegacyOwnerlessResidue,
mocks,
} from './wallet.lifecycle.testHarness';
import { describe, expect, it, vi } from 'vitest';
import { savedKeyExists, savedKeyOwnerId } from '@/lnurlcash/keys';
import { readNwcConnections } from '@/lnurlcash/nwc';
import { readPasskeySlots } from '@/lnurlcash/passkeys';
import { readTrustedMints } from '@/lnurlcash/trustedMints';
import { useNwcStore } from './nwc';
import { useWalletStore } from './wallet';
describe('serialized wallet activation', () => {
it('migrates a proven legacy owner before NWC can observe unlocked', async () => {
// Given an encrypted legacy wallet with ownerless authorization residue
await installLegacyEncryptedWallet();
installLegacyOwnerlessResidue();
const wallet = useWalletStore();
useNwcStore();
// When password proof unlocks the wallet
await wallet.unlock(PASSWORD);
// Then every legacy namespace belongs to the proven owner before startup
expect(savedKeyOwnerId()).toBe(OWNER_ID);
expect(readPasskeySlots()).toHaveLength(1);
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
expect(readTrustedMints(OWNER_ID)).toHaveLength(1);
expect(wallet.state).toBe('unlocked');
await vi.waitFor(() => expect(mocks.startService).toHaveBeenCalledTimes(1));
});
it('serializes a queued create behind an interrupted forget', async () => {
// Given an unlocked wallet whose NWC drain is deferred
const wallet = useWalletStore();
const nwc = useNwcStore();
await wallet.create(PASSWORD);
const drain = deferred();
const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise);
// When forget and create are requested without waiting between them
const forgetting = wallet.forgetWallet();
const creating = wallet.create();
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
// Then the successor cannot install until the old owner drain completes,
// and the session keeps its commit capability while the drain runs
expect(savedKeyExists()).toBe(true);
expect(wallet.state).toBe('unlocked');
drain.resolve();
await forgetting;
const phrase = await creating;
expect(phrase.split(' ')).toHaveLength(12);
expect(wallet.state).toBe('unlocked');
});
it('drains the active session before file restore reactivation', async () => {
// Given an unlocked wallet whose NWC drain is deferred
const wallet = useWalletStore();
const nwc = useNwcStore();
await wallet.create(PASSWORD);
const drain = deferred();
const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise);
// When a valid file restore starts
const restoring = wallet.restoreFromBackup({
type: 'sattle-backup',
version: 1,
createdAt: 1,
bearers: [],
});
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
// Then deactivation waits for the drain before the lifecycle is
// invalidated, and reactivation returns to unlocked afterward
expect(wallet.state).toBe('unlocked');
drain.resolve();
await restoring;
expect(wallet.state).toBe('unlocked');
});
it('serializes current-wallet Nostr restore through full reactivation', async () => {
// Given an unlocked wallet and a relay restore result
const wallet = useWalletStore();
useNwcStore();
await wallet.create(PASSWORD);
const ownerId = wallet.pubkey;
// When the active-wallet Nostr restore runs
await wallet.restoreCurrentFromNostr(['wss://relay.example']);
// Then the same owner is active only after the restore completed
expect(mocks.restoreFromNostr).toHaveBeenCalledTimes(1);
expect(wallet.state).toBe('unlocked');
expect(wallet.pubkey).toBe(ownerId);
});
});