From e892a330fc386e68f5ff11237cd3fd54534d68c6 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 24 Aug 2026 16:26:29 +0200 Subject: [PATCH] fix: consume owner monitor transition failures --- .../wallet.lifecycle.invalidation.cases.ts | 33 ++++++++++++- src/stores/walletOwnerMonitor.test.ts | 47 +++++++++++++++++++ src/stores/walletOwnerMonitor.ts | 26 +++++----- 3 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 src/stores/walletOwnerMonitor.test.ts diff --git a/src/stores/wallet.lifecycle.invalidation.cases.ts b/src/stores/wallet.lifecycle.invalidation.cases.ts index f3926c9..03f80dd 100644 --- a/src/stores/wallet.lifecycle.invalidation.cases.ts +++ b/src/stores/wallet.lifecycle.invalidation.cases.ts @@ -1,10 +1,11 @@ -import { MINT_KEY, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness'; +import { MINT_KEY, mocks, OTHER_OWNER_ID, PASSWORD } from './wallet.lifecycle.testHarness'; import { describe, expect, it, vi } from 'vitest'; import { savedKeyOwnerId } from '@/lnurlcash/keys'; import { readNwcEnabled, writeNwcConnections, writeNwcEnabled } from '@/lnurlcash/nwc'; import type { NwcConnectionRecord } from '@/lnurlcash/nwc'; import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints'; +import { useNwcStore } from './nwc'; import { useWalletStore } from './wallet'; describe('cross-tab owner invalidation', () => { @@ -41,6 +42,36 @@ describe('cross-tab owner invalidation', () => { expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false); }); + it('surfaces a failed stale-tab drain without an unhandled rejection', async () => { + // Given an unlocked old-owner tab whose live NWC service rejects shutdown + const events = new EventTarget(); + vi.stubGlobal('window', events); + const wallet = useWalletStore(); + const nwc = useNwcStore(); + await wallet.create(PASSWORD); + const serviceStop = vi.fn().mockRejectedValue(new Error('stale drain failed')); + mocks.startService.mockResolvedValue({ connections: [], stop: serviceStop }); + await nwc.setEnabled(true); + + // When another tab replaces the saved wallet owner + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ enc: false, value: '09'.repeat(32), ownerId: OTHER_OWNER_ID, version: 1 }), + ); + events.dispatchEvent( + Object.defineProperties(new Event('storage'), { + key: { value: 'sattle_linking_key' }, + }), + ); + await vi.waitFor(() => expect(wallet.state).toBe('locked')); + + // Then the stale runtime is cleared and the queue surfaces the drain failure + expect(wallet.lifecycleError).toMatch(/stale drain failed/i); + expect(() => wallet.requireLinkingKey()).toThrow('Wallet is locked.'); + expect(nwc.running).toBe(false); + expect(serviceStop).toHaveBeenCalledTimes(1); + }); + it('rejects old-owner trust and NWC writes during the markerless forget gap', async () => { // Given wallet A was active and an old tab retained only its owner identifier const wallet = useWalletStore(); diff --git a/src/stores/walletOwnerMonitor.test.ts b/src/stores/walletOwnerMonitor.test.ts new file mode 100644 index 0000000..ce963ff --- /dev/null +++ b/src/stores/walletOwnerMonitor.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { stubLocalStorage } from '@/lnurlcash/test-utils'; +import { startWalletOwnerMonitor } from './walletOwnerMonitor'; + +const OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(7)); +const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); + +describe('wallet owner monitor', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + stubLocalStorage(); + }); + + it('consumes a background transition rejection after an owner replacement', () => { + // Given an unlocked stale owner and a transition promise observed by the queue + const events = new EventTarget(); + vi.stubGlobal('window', events); + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: false, + value: '09'.repeat(32), + ownerId: OTHER_OWNER_ID, + version: 1, + }), + ); + const transition = Promise.resolve(); + const catchRejection = vi.spyOn(transition, 'catch'); + startWalletOwnerMonitor({ + snapshot: () => ({ token: 1, state: 'unlocked', ownerId: OWNER_ID }), + deactivate: vi.fn().mockResolvedValue(undefined), + runTransition: vi.fn().mockReturnValue(transition), + }); + + // When the browser reports that the saved owner changed + events.dispatchEvent( + Object.defineProperties(new Event('storage'), { + key: { value: 'sattle_linking_key' }, + }), + ); + + // Then the fire-and-forget transition has a rejection consumer + expect(catchRejection).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/stores/walletOwnerMonitor.ts b/src/stores/walletOwnerMonitor.ts index 666f352..5453b7f 100644 --- a/src/stores/walletOwnerMonitor.ts +++ b/src/stores/walletOwnerMonitor.ts @@ -24,16 +24,18 @@ export const startWalletOwnerMonitor = (monitor: WalletOwnerMonitor): (() => voi ) { return; } - void monitor.runTransition(async () => { - const current = monitor.snapshot(); - if ( - current.token !== expected.token || - current.state !== 'unlocked' || - current.ownerId !== expected.ownerId || - savedKeyOwnerId() === expected.ownerId - ) { - return; - } - await monitor.deactivate(); - }); + void monitor + .runTransition(async () => { + const current = monitor.snapshot(); + if ( + current.token !== expected.token || + current.state !== 'unlocked' || + current.ownerId !== expected.ownerId || + savedKeyOwnerId() === expected.ownerId + ) { + return; + } + await monitor.deactivate(); + }) + .catch(() => undefined); });