diff --git a/src/stores/nwc.lifecycle.test.ts b/src/stores/nwc.lifecycle.test.ts new file mode 100644 index 0000000..34c6f3b --- /dev/null +++ b/src/stores/nwc.lifecycle.test.ts @@ -0,0 +1,149 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit'; +import { createMockMint } from 'lnurlcash-conformance/mock-mint'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { NWC_RESPONSE_KIND, createConnection, readNwcConnections } from '@/lnurlcash/nwc'; +import type { NwcTransport } from '@/lnurlcash/nwc'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { + CLIENT_SECRET, + RELAYS, + createFakeRelay, + deferred, + methodRequest, +} from '@/lnurlcash/nwc.testProtocol'; +import { setNwcTransportForTests, useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); + setNwcTransportForTests(null); +}); + +describe('NWC wallet lifecycle drain', () => { + it('commits an accepted melted payment before ordinary lock invalidates its owner fence', async () => { + // Given a real encrypted wallet and NWC service whose payment is held + // after the mint burned its note but before the wallet commits the delta + const mint = await createMockMint(); + try { + const wallet = useWalletStore(); + await wallet.create('password'); + const ownerId = wallet.pubkey; + if (ownerId === null) throw new Error('Expected an unlocked owner.'); + const k1 = 'd7'.repeat(32); + mint.state.creditNote(k1, 21_000); + const url = buildNoteUrl(`${mint.url}/w`, k1, 21_000); + const info = await fetchNoteInfo(url); + await wallet.addBearers( + [ + { + url, + callback: info.callback, + amount: info.maxWithdrawable, + verified: true, + mintPubkey: mint.state.pubkey, + }, + ], + wallet.captureOwnerFence(), + ); + const connection = createConnection(wallet.requireLinkingKey(), { + relays: RELAYS, + budget: { maxMsat: 50_000, periodMs: 86_400_000 }, + clientSecret: CLIENT_SECRET, + }); + const relay = createFakeRelay(); + let accepted = false; + const transport = { + ...relay.transport, + subscribe: (relays, filter, onEvent) => + relay.transport.subscribe(relays, filter, (event) => { + accepted = true; + onEvent(event); + }), + } satisfies NwcTransport; + setNwcTransportForTests(transport); + const nwc = useNwcStore(); + await nwc.setEnabled(true); + expect(relay.subscriptionCount()).toBe(1); + + const commit = deferred(); + let commitStarted = false; + const applyChangeset = wallet.applyChangeset.bind(wallet); + vi.spyOn(wallet, 'applyChangeset').mockImplementation(async (changeset, ownerFence) => { + commitStarted = true; + await commit.promise; + return applyChangeset(changeset, ownerFence); + }); + const request = methodRequest( + connection.walletServicePubkey, + 'pay_invoice', + { + invoice: 'lnbc210n1pjqrstuvwxyz', + }, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emit(request); + expect(accepted).toBe(true); + await vi.waitFor(() => expect(commitStarted || nwc.lastError !== '').toBe(true), { + timeout: 5_000, + }); + expect(commitStarted, nwc.lastError).toBe(true); + expect(mint.state.noteState(k1)).toBe('burned'); + expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000); + + // When lock begins, it closes admission immediately but drains the + // already accepted payment while that payment's fence remains valid + let lockSettled = false; + const locking = wallet.lock().then(() => { + lockSettled = true; + }); + await vi.waitFor(() => expect(relay.subscriptionCount()).toBe(0)); + expect(nwc.running).toBe(false); + expect(lockSettled).toBe(false); + expect(() => wallet.captureOwnerFence()).toThrow(); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + const rejected = methodRequest( + connection.walletServicePubkey, + 'get_balance', + {}, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emitAfterClose(rejected); + expect( + relay.published.some( + (event) => + event.kind === NWC_RESPONSE_KIND && + event.tags.some((tag) => tag[0] === 'e' && tag[1] === rejected.id), + ), + ).toBe(false); + + // Then the accepted payment commits and responds before lock clears + // runtime keys; after unlock, durable budget and bearer state agree + commit.resolve(); + await locking; + expect(wallet.state).toBe('locked'); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + await wallet.unlock('password'); + expect(wallet.bearers).toHaveLength(1); + expect(wallet.bearers[0]?.spent).toBe(true); + expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000); + expect( + relay.published.some( + (event) => + event.kind === NWC_RESPONSE_KIND && + event.tags.some((tag) => tag[0] === 'e' && tag[1] === request.id), + ), + ).toBe(true); + } finally { + setNwcTransportForTests(null); + await mint.close(); + } + }); +}); diff --git a/src/stores/wallet.lifecycle.nwcDrain.test.ts b/src/stores/wallet.lifecycle.nwcDrain.test.ts new file mode 100644 index 0000000..9f9c65a --- /dev/null +++ b/src/stores/wallet.lifecycle.nwcDrain.test.ts @@ -0,0 +1,186 @@ +// Lock vs NWC drain integration: an accepted pay_invoice that already +// crossed its irreversible melt must reach its truthful durable outcome +// (budget debit, spent bearer, success response) BEFORE wallet.lock() +// invalidates the lifecycle fence and clears runtime keys. Uses the real +// wallet store, the real NWC store, the real service, and the real owner +// fence - only the relay transport is fake and the mint is the local +// conformance mock. Regresses the ordering where lock invalidated the +// captured fence first, leaving a debited budget, a locally unspent burned +// bearer, and no response. + +import { createPinia, setActivePinia } from 'pinia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildNoteUrl, fetchNoteInfo } from 'lnurlcash-kit'; +import { createMockMint } from 'lnurlcash-conformance/mock-mint'; +import { v2 as nip44v2 } from 'nostr-tools/nip44'; + +import { NWC_RESPONSE_KIND, createConnection, readNwcConnections } from '@/lnurlcash/nwc'; +import type { NostrEvent } from '@/lnurlcash/nwc'; +import { isJsonObject } from '@/lnurlcash/jsonParsing'; +import { requiredValue, stubLocalStorage } from '@/lnurlcash/test-utils'; +import { + CLIENT_SECRET, + RELAYS, + createFakeRelay, + deferred, + methodRequest, + waitFor, +} from '@/lnurlcash/nwc.testProtocol'; +import { setNwcTransportForTests, useNwcStore } from './nwc'; +import { useWalletStore } from './wallet'; + +const PASSWORD = 'correct horse battery staple'; +// decodes to exactly 21_000 msat, matching the credited note (exact carve) +const INVOICE_21K = 'lnbc210n1pjqrstuvwxyz'; + +type NwcResponsePayload = { + result_type: string; + error: { code: string; message: string } | null; + result: Record | null; +}; + +const isNwcResponsePayload = (value: unknown): value is NwcResponsePayload => + isJsonObject(value) && + typeof value.result_type === 'string' && + (value.error === null || + (isJsonObject(value.error) && + typeof value.error.code === 'string' && + typeof value.error.message === 'string')) && + (value.result === null || isJsonObject(value.result)); + +const readResponse = (published: NostrEvent[], requestId: string): NwcResponsePayload | null => { + const event = published.find( + (candidate) => + candidate.kind === NWC_RESPONSE_KIND && + candidate.tags.some((tag) => tag[0] === 'e' && tag[1] === requestId), + ); + if (!event) return null; + const plaintext = nip44v2.decrypt( + event.content, + nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey), + ); + const parsed: unknown = JSON.parse(plaintext); + if (!isNwcResponsePayload(parsed)) throw new TypeError('Expected a valid NWC response payload.'); + return parsed; +}; + +type Mint = Awaited>; +const mints: Mint[] = []; + +beforeEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', {}); + stubLocalStorage(); + setActivePinia(createPinia()); +}); + +afterEach(async () => { + setNwcTransportForTests(null); + await Promise.all(mints.splice(0).map((mint) => mint.close())); +}); + +describe('wallet lock with an in-flight NWC payment', () => { + it('drains an accepted post-melt pay to its durable outcome before invalidating the lifecycle', async () => { + // Given an encrypted unlocked wallet holding one exact-match note, served + // by a running NWC service over the fake relay + const mint = await createMockMint(); + mints.push(mint); + const wallet = useWalletStore(); + await wallet.create(PASSWORD); + const k1 = 'e1'.repeat(32); + mint.state.creditNote(k1, 21_000); + const noteUrl = buildNoteUrl(`${mint.url}/w`, k1, 21_000); + const noteInfo = await fetchNoteInfo(noteUrl); + const [bearer] = await wallet.addBearers( + [ + { + url: noteUrl, + callback: noteInfo.callback, + amount: noteInfo.maxWithdrawable, + verified: true, + mintPubkey: mint.state.pubkey, + }, + ], + wallet.captureOwnerFence(), + ); + if (!bearer) throw new Error('Expected the added bearer.'); + const ownerId = wallet.pubkey; + if (ownerId === null) throw new Error('Expected an unlocked owner.'); + const connection = createConnection(wallet.requireLinkingKey(), { + relays: RELAYS, + budget: { maxMsat: 1_000_000_000, periodMs: 86_400_000 }, + clientSecret: CLIENT_SECRET, + }); + const relay = createFakeRelay(); + setNwcTransportForTests(relay.transport); + const nwc = useNwcStore(); + await nwc.setEnabled(true); + expect(nwc.running).toBe(true); + + // ... and a pay that crosses the irreversible melt, then pauses at the + // durable bearer commit + const releaseCommit = deferred(); + let commitReached = false; + const applyChangeset = wallet.applyChangeset; + vi.spyOn(wallet, 'applyChangeset').mockImplementation(async (changeset, ownerFence) => { + commitReached = true; + await releaseCommit.promise; + return applyChangeset(changeset, ownerFence); + }); + const request = methodRequest( + connection.walletServicePubkey, + 'pay_invoice', + { invoice: INVOICE_21K }, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emit(request); + await waitFor(() => commitReached); + expect(mint.state.noteState(k1)).toBe('burned'); + + // When the holder locks the wallet mid-flight + let lockSettled = false; + const locking = wallet.lock().then(() => { + lockSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + + // Then the lock waits for the accepted pay instead of invalidating its + // commit: the lifecycle stays commit-capable while subscriptions are + // already closed, so no new work is accepted once the lock began + expect(lockSettled).toBe(false); + expect(wallet.state).toBe('unlocked'); + expect(relay.subscriptionCount()).toBe(0); + const lateRequest = methodRequest( + connection.walletServicePubkey, + 'get_balance', + {}, + 'nip44_v2', + Math.floor(Date.now() / 1000), + ); + relay.emitAfterClose(lateRequest); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(readResponse(relay.published, lateRequest.id)).toBeNull(); + + // When the paused payment resumes + releaseCommit.resolve(); + + // Then the client receives its deterministic success BEFORE the lock + // completes, and budget and bearer state commit consistently + await waitFor(() => readResponse(relay.published, request.id) !== null); + const response = requiredValue(readResponse(relay.published, request.id)); + expect(response.error).toBeNull(); + expect(typeof response.result?.preimage).toBe('string'); + expect(readNwcConnections(ownerId)[0]?.spent.msat).toBe(21_000); + await locking; + expect(wallet.state).toBe('locked'); + expect(wallet.bearers).toEqual([]); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + expect(nwc.running).toBe(false); + + // ... and the spent note survives the lock durably + await wallet.unlock(PASSWORD); + expect(wallet.bearers.find((candidate) => candidate.id === bearer.id)?.spent).toBe(true); + await nwc.setEnabled(false); + }); +}); diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index c2343bc..ec8b7ef 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -1,245 +1,263 @@ -import { computed, ref } from 'vue'; +import { computed, onScopeDispose, ref } from 'vue'; import { defineStore } from 'pinia'; -import { serverOf } from 'lnurlcash-kit'; import { - deriveWalletLinkingKey, deriveBearerAesKey, - saveLinkingKey, savedKeyExists, savedKeyIsEncrypted, - getPlainLinkingKey, - decryptSavedLinkingKey, + savedKeyOwnerId, clearSavedLinkingKey, - generateSeedPhrase, - isValidSeedPhrase, - linkingPubKeyHex, } from '@/lnurlcash/keys'; -import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { loadBearers, - persistBearer, - deleteBearerRecord, clearAllBearers, - newBearerId, - mergeBearers, + applyBackup, + parseBackupFile, + clearSettings, } from '@/lnurlcash/storage'; -import { - grandfatherTrustedMint, - lockTrustedMint, - clearTrustedMints, -} from '@/lnurlcash/trustedMints'; -import { clearSettings } from '@/lnurlcash/storage'; -import { unlockWithPasskey as unlockWithPasskeyEngine } from '@/lnurlcash/passkeys'; -import { disableBiometricUnlock, unlockWithBiometrics } from '@/capabilities/biometricUnlock'; -import { msatToSats } from '@/lnurlcash/units'; +import type { RestoreResult } from '@/lnurlcash/storage'; +import { disableBiometricUnlock } from '@/capabilities/biometricUnlock'; +import { restoreFromNostr as restoreFromNostrEngine } from '@/lnurlcash/nostrBackup'; import { useActivityStore } from './activity'; +import { createWalletFunds } from './walletFunds'; +import { createWalletIdleWatch } from './walletIdle'; +import { createWalletAccess } from './walletAccess'; +import { restoreHeldMintTrust } from './walletActivation'; +import { startWalletOwnerMonitor } from './walletOwnerMonitor'; +import { createWalletOwnerFence } from './walletOwnerFence'; +import type { WalletState } from './walletOwnerFence'; +import { + clearOwnerAuthorizations, + clearUnownedAuthorizations, + createSeedInstaller, + createWalletTransitionQueue, + migrateProvenLegacyOwner, + ownerOf, + stopWalletNwcSession, + WalletLifecycleError, +} from './walletLifecycle'; + +export { TrustedMintPostCommitError } from './walletFunds'; // 'none': no wallet on this device yet -> setup // 'locked': linking key present but password-encrypted -> unlock // 'unlocked': linking key (and thus the bearer AES key) in memory -export type WalletState = 'none' | 'locked' | 'unlocked'; - -// idle-timeout auto-lock: only meaningful for a password-encrypted key (see -// lock(), which no-ops otherwise) - 5 minutes with no activity anywhere in -// the tab locks the wallet. lockWarningSecondsLeft goes non-null 30s ahead -// of that so the UI can warn, and postponeLock() is the "stay unlocked" -// hook it offers. -const AUTO_LOCK_MS = 5 * 60 * 1000; -const LOCK_WARNING_MS = 30 * 1000; +export type { WalletState } from './walletOwnerFence'; // a plaintext-stored key also starts 'locked' - init() unlocks it // immediately without a password, keeping a single code path for deriving // the AES key and loading bearers -const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none'); - export const useWalletStore = defineStore('wallet', () => { - const state = ref(initialState()); - const bearers = ref([]); + const state = ref(savedKeyExists() ? 'locked' : 'none'); const pubkey = ref(null); + const auxiliaryError = ref(''); + const lifecycleError = ref(''); let aesKey: CryptoKey | null = null; // the linking key itself, only while unlocked - needed by backup/passkey // operations (nostrBackup derives the backup key from it, passkey // registration wraps it). Never exposed reactively; cleared on lock/forget let currentLinkingKey: Uint8Array | null = null; + let lifecycleToken = 0; + let acceptingOwnerWork = false; - // ---- idle auto-lock bookkeeping ---- - let lastActivity = Date.now(); - let idleTimer: ReturnType | null = null; const lockWarningSecondsLeft = ref(null); + const runTransition = createWalletTransitionQueue({ + onStart: () => (lifecycleError.value = ''), + onError: (error) => { + lifecycleError.value = error instanceof Error ? error.message : 'Wallet transition failed.'; + }, + }).run; const encrypted = computed(() => savedKeyIsEncrypted()); - // ---- balances: protocol layer is msat; sats are a display helper ---- - const unspentBearers = computed(() => bearers.value.filter((b) => !b.spent)); - const balanceMsat = computed(() => unspentBearers.value.reduce((sum, b) => sum + b.amount, 0)); - const balanceSats = computed(() => msatToSats(balanceMsat.value)); - const balanceByMintMsat = computed(() => { - const byMint = new Map(); - for (const b of unspentBearers.value) { - const server = serverOf(b.url); - byMint.set(server, (byMint.get(server) ?? 0) + b.amount); - } - return byMint; - }); - const balanceByMintSats = computed(() => { - const byMint = new Map(); - for (const [server, msat] of balanceByMintMsat.value) { - byMint.set(server, msatToSats(msat)); - } - return byMint; + const ownerFence = createWalletOwnerFence({ + state: () => state.value, + ownerId: () => pubkey.value, + lifecycleToken: () => lifecycleToken, + accepting: () => acceptingOwnerWork, }); - const stopIdleWatch = () => { - if (idleTimer) clearInterval(idleTimer); - idleTimer = null; - lockWarningSecondsLeft.value = null; - }; - - const lock = () => { - // only meaningful for a password-encrypted key - a plaintext one would - // just auto-unlock again, so the UI only offers Lock when encrypted - if (!savedKeyIsEncrypted()) return; + const clearRuntime = (): void => { aesKey = null; currentLinkingKey = null; pubkey.value = null; - bearers.value = []; + funds.clear(); useActivityStore().unload(); - stopIdleWatch(); + }; + + // ends every captured fence and drops the reactive owner identity. Only + // ever runs after accepted NWC work has drained (or failed to): an + // operation past its irreversible melt must stay commit-capable until + // then, and stop() rejects new requests the moment it is called, so no + // post-lock work is accepted while the fence stays valid + const invalidateLifecycle = (): void => { + acceptingOwnerWork = false; + lifecycleToken += 1; state.value = 'locked'; + pubkey.value = null; }; - // any real interaction restarts the 5-minute clock and clears the - // warning - the UI's "Stay unlocked" button calls this - const postponeLock = () => { - lastActivity = Date.now(); - lockWarningSecondsLeft.value = null; - }; - - // ticks once a second while unlocked and encrypted (the only state - // auto-lock applies to), comparing wall-clock time against lastActivity - // rather than relying on a single setTimeout, since a backgrounded tab - // throttles timers but Date.now() still reflects real elapsed time - // whenever this next gets to run - const startIdleWatch = () => { - stopIdleWatch(); - if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return; - lastActivity = Date.now(); - const registerActivity = () => { - // once the warning is up, passive activity is deliberately ignored - - // only postponeLock() dismisses it, so the "stay unlocked" button - // can't vanish out from under the pointer before the click lands - if (state.value === 'unlocked' && lockWarningSecondsLeft.value === null) { - lastActivity = Date.now(); - } - }; - for (const event of ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']) { - window.addEventListener(event, registerActivity, { passive: true }); + const deactivateSession = async (): Promise => { + acceptingOwnerWork = false; + stopOwnerChanges(); + idleWatch.stop(); + try { + await stopWalletNwcSession(); + } finally { + // even a rejected drain ends the session: 'locked' never holds key + // material and no captured fence stays valid + invalidateLifecycle(); + clearRuntime(); } - idleTimer = setInterval(() => { - if (state.value !== 'unlocked') return; - const elapsed = Date.now() - lastActivity; - if (elapsed >= AUTO_LOCK_MS) { - lock(); - } else if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) { - lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000); - } - }, 1000); }; - const activate = async (linkingKey: Uint8Array) => { + let stopOwnerChanges = (): void => {}; + const observeOwnerChanges = (): void => { + stopOwnerChanges(); + stopOwnerChanges = startWalletOwnerMonitor({ + snapshot: () => ({ token: lifecycleToken, state: state.value, ownerId: pubkey.value }), + deactivate: deactivateSession, + runTransition, + }); + }; + onScopeDispose(() => stopOwnerChanges()); + + const lock = (): Promise => + runTransition(async () => { + if (!savedKeyIsEncrypted()) return; + await deactivateSession(); + }); + + const idleWatch = createWalletIdleWatch({ + isEncrypted: savedKeyIsEncrypted, + isUnlocked: () => state.value === 'unlocked', + isLockWarningVisible: () => lockWarningSecondsLeft.value !== null, + lock, + setWarningSecondsLeft: (seconds) => { + lockWarningSecondsLeft.value = seconds; + }, + }); + + const activate = async (linkingKey: Uint8Array, ownerWasMissing: boolean): Promise => { + auxiliaryError.value = ''; + await migrateProvenLegacyOwner(linkingKey, ownerWasMissing); const key = await deriveBearerAesKey(linkingKey); - aesKey = key; - currentLinkingKey = linkingKey; - pubkey.value = linkingPubKeyHex(linkingKey); const loaded = await loadBearers(key); - bearers.value = loaded; const activity = useActivityStore(); await activity.loadFor(key); - // grandfather in every mint already backing a held bearer as trusted - - // holding funds there already implied trusting it. Storage-sourced - // claims only, though: grandfathering never locks and marks new pins - // unconfirmed - both are (re-)earned by live responses during actual - // bearer operations - for (const bearer of loaded) { - if (bearer.mintPubkey) { - grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey); - } - } + const ownerId = ownerOf(linkingKey); + await restoreHeldMintTrust(loaded, ownerId, (message) => { + auxiliaryError.value = message; + }); + aesKey = key; + currentLinkingKey = linkingKey; + lifecycleToken += 1; + observeOwnerChanges(); + pubkey.value = ownerId; + funds.replace(loaded); + acceptingOwnerWork = true; state.value = 'unlocked'; - startIdleWatch(); + idleWatch.start(); }; - // generates a fresh seed phrase, derives and saves the linking key, and - // unlocks. The phrase is returned exactly once - it is never stored, so - // the caller MUST show it to the holder before letting them move on. - const create = async (password?: string): Promise => { - const phrase = generateSeedPhrase(); - await restoreFromSeed(phrase, password); - return phrase; - }; - - const restoreFromSeed = async (seedPhrase: string, password?: string): Promise => { - if (!isValidSeedPhrase(seedPhrase)) { - throw new Error('Not a valid seed phrase.'); - } - const linkingKey = deriveWalletLinkingKey(seedPhrase); - await saveLinkingKey(linkingKey, password); - await activate(linkingKey); - }; - - const unlock = async (password?: string): Promise => { - const linkingKey = savedKeyIsEncrypted() - ? await decryptSavedLinkingKey(password || '') - : getPlainLinkingKey(); - if (!linkingKey) throw new Error('No wallet on this device.'); - await activate(linkingKey); - }; - - // passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key - // the password path protects, so activation is identical either way - const unlockWithPasskey = async (): Promise => { - await activate(await unlockWithPasskeyEngine()); - }; - - // native biometric unlock (capabilities/biometricUnlock.ts): a third wrap - // of the same linking key, behind the device credential prompt - const unlockWithBiometric = async (): Promise => { - await activate(await unlockWithBiometrics()); - }; - - // app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks - // without a password; an encrypted one waits on the unlock screen - const init = async (): Promise => { - if (state.value === 'locked' && !savedKeyIsEncrypted()) { - await unlock(); + const teardownCurrentOwner = async (resetRegistry = false): Promise => { + const ownerId = savedKeyOwnerId() ?? pubkey.value; + acceptingOwnerWork = false; + stopOwnerChanges(); + idleWatch.stop(); + try { + // the drain runs before the fence is invalidated and the runtime is + // cleared so an in-flight fund-critical changeset can still commit + // (its applyChangeset needs the live fence and key) + await stopWalletNwcSession(); + invalidateLifecycle(); + clearRuntime(); + if (ownerId === null) await clearUnownedAuthorizations(); + else await clearOwnerAuthorizations(ownerId, resetRegistry); + await disableBiometricUnlock(); + clearAllBearers(); + useActivityStore().unloadAndClear(); + clearSettings(); + clearSavedLinkingKey(); + state.value = 'none'; + } finally { + // a failed teardown still ends the session: 'locked' must never hold + // key material in memory, and no captured fence may stay usable + if (state.value !== 'none') invalidateLifecycle(); + clearRuntime(); } }; + const prepareInstallation = async (nextOwnerId: string): Promise => { + const installedOwner = savedKeyOwnerId() ?? pubkey.value; + if (savedKeyExists() && installedOwner === nextOwnerId) { + if (state.value === 'unlocked') await deactivateSession(); + return; + } + if (savedKeyExists() || state.value === 'unlocked') { + await teardownCurrentOwner(true); + } + await clearUnownedAuthorizations(); + }; + + const installSeed = createSeedInstaller({ + prepareInstallation, + activate: (linkingKey) => activate(linkingKey, false), + }); + + const access = createWalletAccess({ + runTransition, + installSeed, + activate, + canInit: () => state.value === 'locked', + }); + + const restoreFromBackup = (data: unknown): Promise => + runTransition(async () => { + const backup = parseBackupFile(data); + const hadSavedKey = savedKeyExists(); + const activeOwner = pubkey.value; + const activeKey = state.value === 'unlocked' ? requireLinkingKey() : null; + if (activeKey !== null) await deactivateSession(); + if (!hadSavedKey) await clearUnownedAuthorizations(); + const result = await applyBackup(backup, activeOwner ?? undefined); + if (activeKey !== null) await activate(activeKey, false); + else if (result.linkingKeyRestored) state.value = 'locked'; + return result; + }); + + const restoreFromNostr = ( + seedPhrase: string, + relays: string[], + password?: string, + ): Promise => + runTransition(() => + installSeed(seedPhrase, password, async (linkingKey) => { + await restoreFromNostrEngine(linkingKey, relays); + }), + ); + + const restoreCurrentFromNostr = (relays: string[]) => + runTransition(async () => { + const linkingKey = requireLinkingKey(); + await deactivateSession(); + const result = await restoreFromNostrEngine(linkingKey, relays); + await activate(linkingKey, false); + return result; + }); + // wipes this wallet from the device entirely - the linking key, every // bearer record, the activity log, and the non-secret registries that // would otherwise linger as a fingerprint of it. Not recoverable by // restoring the same seed afterward (the ciphertexts themselves are // gone); only a backup downloaded before this runs can bring the notes // back - the UI should prompt for one - const forgetWallet = () => { - clearSavedLinkingKey(); - // the biometric wrap belongs to this wallet's linking key - drop it too. - // The record removal is synchronous inside; the secure-storage delete - // trails behind, and an orphaned secret there is unusable without the - // record, so a failed delete is safe to swallow - void disableBiometricUnlock().catch(() => {}); - clearAllBearers(); - clearTrustedMints(); - clearSettings(); - useActivityStore().unloadAndClear(); - aesKey = null; - currentLinkingKey = null; - pubkey.value = null; - bearers.value = []; - stopIdleWatch(); - state.value = 'none'; - }; + const forgetWallet = (): Promise => + runTransition(() => + teardownCurrentOwner().catch((error) => { + throw new WalletLifecycleError('forget', error); + }), + ); const requireKey = (): CryptoKey => { if (!aesKey) throw new Error('Wallet is locked.'); @@ -250,99 +268,39 @@ export const useWalletStore = defineStore('wallet', () => { // (nostr backup key derivation, passkey registration) - never reactive, // throws when locked, so callers can't accidentally hold a stale key const requireLinkingKey = (): Uint8Array => { - if (!currentLinkingKey) throw new Error('Wallet is locked.'); + if (!acceptingOwnerWork || !currentLinkingKey) throw new Error('Wallet is locked.'); return currentLinkingKey; }; - // the one entry point for new notes (minted, received, carved outputs): - // persists first, then updates state. Holding a bearer from a mint - // trusts it by default - this is the one path that never asks (see - // trustedMints.ts); a DIFFERENT advertised key comes back as - // 'rekey-pending' and is staged on the mints store for review, never - // auto-applied. - const addBearers = async (notes: NewBearer[]): Promise => { - const now = Date.now(); - const added: Bearer[] = []; - for (const note of notes) { - const bearer: Bearer = { - id: newBearerId(), - ...note, - createdAt: now, - updatedAt: now, - }; - await persistBearer(requireKey(), bearer); - added.push(bearer); - } - bearers.value = [...added, ...bearers.value]; - for (const bearer of added) { - if (bearer.mintPubkey) { - lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey); - } - } - return added; - }; - - const updateBearer = async (id: string, changes: Partial>): Promise => { - const current = bearers.value.find((b) => b.id === id); - if (!current) return; - const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() }; - await persistBearer(requireKey(), updated); - bearers.value = bearers.value.map((b) => (b.id === id ? updated : b)); - if (updated.mintPubkey) { - lockTrustedMint(serverOf(updated.url), updated.mintPubkey); - } - }; - - const markSpent = async (id: string, spent = true): Promise => { - await updateBearer(id, { spent }); - }; - - const removeNote = async (id: string): Promise => { - bearers.value = bearers.value.filter((b) => b.id !== id); - await deleteBearerRecord(id); - }; - - // merges externally-produced bearers (a decrypted backup restore, later a - // nostr restore) into the live list: union by note identity, spent-wins - - // see storage.ts's mergeBearers. Persists every survivor. - const mergeExternalBearers = async (incoming: Bearer[]): Promise => { - const merged = mergeBearers(bearers.value, incoming); - for (const bearer of merged) { - await persistBearer(requireKey(), bearer); - } - bearers.value = merged; - }; - - const reloadBearers = async (): Promise => { - bearers.value = await loadBearers(requireKey()); - }; + const funds = createWalletFunds({ + requireKey, + ownerId: () => pubkey.value ?? undefined, + setAuxiliaryError: (message) => { + auxiliaryError.value = message; + }, + }); return { state, - bearers, pubkey, + auxiliaryError, + lifecycleError, encrypted, lockWarningSecondsLeft, - balanceMsat, - balanceSats, - balanceByMintMsat, - balanceByMintSats, - unspentBearers, - create, - restoreFromSeed, - unlock, - unlockWithPasskey, - unlockWithBiometric, + ...funds.public, + create: access.create, + restoreFromSeed: access.restoreFromSeed, + restoreFromBackup, + restoreFromNostr, + restoreCurrentFromNostr, + unlock: access.unlock, + unlockWithPasskey: access.unlockWithPasskey, + unlockWithBiometric: access.unlockWithBiometric, lock, - init, + init: access.init, forgetWallet, - postponeLock, + postponeLock: idleWatch.postpone, requireLinkingKey, - addBearers, - updateBearer, - markSpent, - removeNote, - mergeExternalBearers, - reloadBearers, + captureOwnerFence: ownerFence.capture, }; });