diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index d5dc94b9d8..cf1fee9105 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Auto-accept pending approval requests in the daemon so a transaction or signature flow resolves instead of hanging on the headless no-op `showApprovalRequest`; the subscription is torn down on `dispose` ([#9612](https://github.com/MetaMask/core/pull/9612)) + - **Security note:** the daemon approves every request without a per-request prompt; the trust boundary is its `0600`, same-user Unix socket. - Wire the `transactionController` slot in the daemon wallet's instance options, so the daemon runs the `TransactionController` with an explicit CLI-appropriate configuration (swaps processing disabled, no client hooks) rather than relying on the controller's implicit defaults ([#9509](https://github.com/MetaMask/core/pull/9509)) - Wire the `gasFeeController` slot in the daemon wallet's instance options, passing `clientId: 'cli'` so the CLI identifies itself to the gas estimation API, now that `@metamask/wallet` requires this option ([#9527](https://github.com/MetaMask/core/pull/9527)) - Add the `mm wallet unlock` command, which dispatches `KeyringController:submitPassword` over the daemon socket, allowing the keyring to be unlocked after a daemon start with no password or after a `mm daemon call KeyringController:setLocked` ([#8821](https://github.com/MetaMask/core/pull/8821)) @@ -25,8 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--password` / `MM_WALLET_PASSWORD` is now optional on `mm daemon start`; on subsequent runs, omitting it starts the daemon with a locked keyring, and the persisted vault is auto-unlocked when a password is supplied ([#8821](https://github.com/MetaMask/core/pull/8821)) - The daemon RPC server now validates `params` against each handler's superstruct before dispatch, returning a `-32602 invalidParams` error on mismatch instead of passing raw params to the handler ([#8846](https://github.com/MetaMask/core/pull/8846)) - Report daemon socket connection errors consistently across `mm daemon call` and `mm daemon list` ([#9339](https://github.com/MetaMask/core/pull/9339)) -- Bump `@metamask/wallet` from `^3.0.0` to `^8.1.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349), [#9396](https://github.com/MetaMask/core/pull/9396), [#9470](https://github.com/MetaMask/core/pull/9470), [#9629](https://github.com/MetaMask/core/pull/9629)) +- Bump `@metamask/wallet` from `^3.0.0` to `^8.1.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349), [#9396](https://github.com/MetaMask/core/pull/9396), [#9470](https://github.com/MetaMask/core/pull/9470), [#9609](https://github.com/MetaMask/core/pull/9609), [#9629](https://github.com/MetaMask/core/pull/9629)) - Wrap daemon password and SRP in opaque `Password` and `Srp` types that redact on logging; validated and unwrapped only at trust boundaries ([#8863](https://github.com/MetaMask/core/pull/8863)) -- Bump `@metamask/wallet` from `^7.0.1` to `8.0.0`. ([#9609](https://github.com/MetaMask/core/pull/9609)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md index 6da5493e94..fd83efa65f 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -57,6 +57,8 @@ mm daemon purge # stop, then delete all daemon state files (--force to State (socket, PID file, log, and the SQLite database) lives in the per-user oclif data directory; override it with `MM_DATA_DIR`. +> **Security model — the daemon auto-approves everything.** Because it is headless, the daemon accepts every approval request (transactions and signatures included) with no per-request prompt; otherwise an awaited request would hang forever with no UI to resolve it. The trust boundary is therefore the daemon's `0600`, same-user Unix socket — anything that can reach the socket can move funds. A scoped/opt-in approval policy is planned for when a user-facing send command lands. + ## Troubleshooting ### Rebuilding `better-sqlite3` diff --git a/packages/wallet-cli/src/daemon/auto-approval.test.ts b/packages/wallet-cli/src/daemon/auto-approval.test.ts new file mode 100644 index 0000000000..00890c3745 --- /dev/null +++ b/packages/wallet-cli/src/daemon/auto-approval.test.ts @@ -0,0 +1,191 @@ +import { subscribeToAutoApproval } from './auto-approval.js'; + +type ApprovalStateChangeHandler = (state: { + pendingApprovals: Record; +}) => void; + +type MessengerArg = Parameters[0]; + +/** + * Build a fake root messenger that records `subscribe`/`unsubscribe`/`call` and + * captures the state-change handler `subscribeToAutoApproval` registers, so a + * test can drive it directly. + * + * @returns The fake messenger, its jest mocks, and a `handler` getter. + */ +function makeMessenger(): { + messenger: MessengerArg; + call: jest.Mock; + subscribe: jest.Mock; + unsubscribe: jest.Mock; + handler: () => ApprovalStateChangeHandler; +} { + let captured: ApprovalStateChangeHandler | undefined; + const call = jest.fn().mockResolvedValue({ value: undefined }); + const subscribe = jest.fn( + (_eventType: string, subscribed: ApprovalStateChangeHandler) => { + captured = subscribed; + }, + ); + const unsubscribe = jest.fn(); + + return { + messenger: { call, subscribe, unsubscribe } as unknown as MessengerArg, + call, + subscribe, + unsubscribe, + handler: (): ApprovalStateChangeHandler => { + if (!captured) { + throw new Error('handler was not registered'); + } + return captured; + }, + }; +} + +/** + * Drain pending microtasks so the `.catch(...).finally(...)` chain attached to + * each accept settles before assertions run. Uses `setImmediate` to cross the + * I/O callback boundary, which flushes the entire microtask queue regardless + * of chain depth. + */ +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('subscribeToAutoApproval', () => { + it('subscribes to ApprovalController:stateChanged', () => { + const { messenger, subscribe } = makeMessenger(); + + subscribeToAutoApproval(messenger); + + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe).toHaveBeenCalledWith( + 'ApprovalController:stateChanged', + expect.any(Function), + ); + }); + + it('accepts every pending request via ApprovalController:acceptRequest', () => { + const { messenger, call, handler } = makeMessenger(); + subscribeToAutoApproval(messenger); + + handler()({ pendingApprovals: { 'id-a': {}, 'id-b': {} } }); + + expect(call).toHaveBeenCalledWith( + 'ApprovalController:acceptRequest', + 'id-a', + ); + expect(call).toHaveBeenCalledWith( + 'ApprovalController:acceptRequest', + 'id-b', + ); + expect(call).toHaveBeenCalledTimes(2); + }); + + it('does nothing when there are no pending requests', () => { + const { messenger, call, handler } = makeMessenger(); + subscribeToAutoApproval(messenger); + + handler()({ pendingApprovals: {} }); + + expect(call).not.toHaveBeenCalled(); + }); + + it('accepts an id only once while its accept is in flight, and again after it settles', async () => { + const { messenger, call, handler } = makeMessenger(); + subscribeToAutoApproval(messenger); + + handler()({ pendingApprovals: { 'id-a': {} } }); + handler()({ pendingApprovals: { 'id-a': {} } }); + expect(call).toHaveBeenCalledTimes(1); + + await flushMicrotasks(); + handler()({ pendingApprovals: { 'id-a': {} } }); + expect(call).toHaveBeenCalledTimes(2); + }); + + it('does not re-accept ids surfaced by the re-entrant state change that accepting itself triggers', () => { + const { messenger, call, handler } = makeMessenger(); + subscribeToAutoApproval(messenger); + call.mockImplementation((_action: string, id: string) => { + if (id === 'id-a') { + handler()({ pendingApprovals: { 'id-a': {}, 'id-b': {} } }); + } + return Promise.resolve({ value: undefined }); + }); + + handler()({ pendingApprovals: { 'id-a': {}, 'id-b': {} } }); + + expect(call.mock.calls.filter(([, id]) => id === 'id-a')).toHaveLength(1); + expect(call.mock.calls.filter(([, id]) => id === 'id-b')).toHaveLength(1); + }); + + it('logs and swallows an async rejection from an accept', async () => { + const { messenger, call, handler } = makeMessenger(); + call.mockRejectedValueOnce(new Error('accept boom')); + const log = jest.fn(); + subscribeToAutoApproval(messenger, log); + + expect(() => handler()({ pendingApprovals: { 'id-a': {} } })).not.toThrow(); + await flushMicrotasks(); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to auto-accept approval request id-a: Error: accept boom', + ), + ); + + // Verify .finally() ran so the id is retryable after an async rejection. + handler()({ pendingApprovals: { 'id-a': {} } }); + expect(call).toHaveBeenCalledTimes(2); + }); + + it('logs and swallows a synchronous throw from an accept, and can retry the id later', () => { + const { messenger, call, handler } = makeMessenger(); + call.mockImplementationOnce(() => { + throw new Error('sync boom'); + }); + const log = jest.fn(); + subscribeToAutoApproval(messenger, log); + + expect(() => handler()({ pendingApprovals: { 'id-a': {} } })).not.toThrow(); + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to auto-accept approval request id-a: Error: sync boom', + ), + ); + + handler()({ pendingApprovals: { 'id-a': {} } }); + expect(call).toHaveBeenCalledTimes(2); + }); + + it('falls back to console.error when no logger is supplied', async () => { + const { messenger, call, handler } = makeMessenger(); + call.mockRejectedValueOnce(new Error('accept boom')); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + subscribeToAutoApproval(messenger); + + handler()({ pendingApprovals: { 'id-a': {} } }); + await flushMicrotasks(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to auto-accept approval request id-a'), + ); + }); + + it('unsubscribes the same handler it subscribed', () => { + const { messenger, subscribe, unsubscribe } = makeMessenger(); + + const dispose = subscribeToAutoApproval(messenger); + dispose(); + + const [, subscribedHandler] = subscribe.mock.calls[0]; + expect(unsubscribe).toHaveBeenCalledWith( + 'ApprovalController:stateChanged', + subscribedHandler, + ); + }); +}); diff --git a/packages/wallet-cli/src/daemon/auto-approval.ts b/packages/wallet-cli/src/daemon/auto-approval.ts new file mode 100644 index 0000000000..b5c697479a --- /dev/null +++ b/packages/wallet-cli/src/daemon/auto-approval.ts @@ -0,0 +1,127 @@ +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '@metamask/wallet'; + +import type { Logger } from './types.js'; + +/** + * The slice of `ApprovalController` state this module reads. The full + * state-change payload is the controller's `ApprovalControllerState`; auto + * approval only needs the ids of the pending requests, so it looks at + * `pendingApprovals` alone (the request bodies are irrelevant to accepting + * them). + */ +type PendingApprovalsState = { + pendingApprovals: Record; +}; + +/** + * Handler for `ApprovalController`'s state-change event, narrowed to the slice + * auto approval reads. The `Patch[]` second argument from the raw event is + * intentionally absent — auto approval re-accepts on every state change and + * does not filter by patch. + */ +type ApprovalStateChangeHandler = (state: PendingApprovalsState) => void; + +/** + * Subscribe the daemon to auto-accept every pending approval request. + * + * Without this, any `ApprovalController:addRequest` call hangs forever on a + * headless daemon. **Everything is approved without a prompt** — trust boundary + * is the `0600` same-user socket. A scoped policy is tracked in + * {@link https://github.com/MetaMask/core/issues/9513}. + * + * The `inFlight` guard makes accepting each id idempotent: `acceptRequest` + * deletes the request on resolve, which itself re-emits `stateChanged`. Without + * it the same id would be accepted twice and the second accept would reject. + * Both sync throws and async rejections are logged and swallowed so one bad + * request cannot crash the daemon or wedge the subscription. + * + * @param messenger - The wallet root messenger. + * @param log - Optional logger for accept failures. Defaults to `console.error`. + * @returns A function that unsubscribes the auto-approval handler. + */ +export function subscribeToAutoApproval( + messenger: Readonly>, + log?: Logger, +): () => void { + const logFn = + log ?? + ((message: string): void => { + console.error(message); + }); + + const inFlight = new Set(); + + const logFailure = (id: string, error: unknown): void => { + logFn(`Failed to auto-accept approval request ${id}: ${String(error)}`); + }; + + const acceptRequest = (id: string): void => { + if (inFlight.has(id)) { + return; + } + inFlight.add(id); + + try { + messenger + .call('ApprovalController:acceptRequest', id) + .catch((error: unknown) => logFailure(id, error)) + .finally(() => inFlight.delete(id)); + } catch (error) { + // Synchronous throw means the Promise chain above was never built + // (no .catch/.finally attached), so clean up the in-flight guard here. + // The error is intentionally suppressed — a race-condition reject must + // not crash the daemon or permanently wedge the subscription. + inFlight.delete(id); + logFailure(id, error); + } + }; + + const handler: ApprovalStateChangeHandler = (state) => { + for (const id of Object.keys(state.pendingApprovals)) { + acceptRequest(id); + } + }; + + return subscribeToApprovalStateChanged(messenger, handler); +} + +/** + * Subscribe a handler to `ApprovalController:stateChanged`. + * + * `ApprovalControllerEvents` only declares `ApprovalController:stateChange` + * (via `ControllerStateChangeEvent`), not the non-deprecated + * `ApprovalController:stateChanged` variant that `BaseController` publishes at + * runtime. This helper localizes the unavoidable cast — using the same + * technique as `subscribeToStateChanged` in the persistence layer — behind a + * typed {@link ApprovalStateChangeHandler}, keeping the `state` payload + * compile-checked at the call site instead of erased by a statement-level + * `@ts-expect-error`. + * + * TODO: Remove this cast once `ApprovalControllerEvents` includes + * `ControllerStateChangedEvent` (`:stateChanged`), matching the union already + * exported by `BaseController`. + * + * @param messenger - The wallet root messenger. + * @param handler - The state-change handler to register. + * @returns A function that unsubscribes the handler. + */ +function subscribeToApprovalStateChanged( + messenger: Readonly>, + handler: ApprovalStateChangeHandler, +): () => void { + const subscriber = messenger as unknown as { + subscribe: (eventType: string, handler: ApprovalStateChangeHandler) => void; + unsubscribe: ( + eventType: string, + handler: ApprovalStateChangeHandler, + ) => void; + }; + subscriber.subscribe('ApprovalController:stateChanged', handler); + return () => { + subscriber.unsubscribe('ApprovalController:stateChanged', handler); + }; +} diff --git a/packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts index 6223dc6626..b3e1dacf45 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.integration.test.ts @@ -64,6 +64,36 @@ describe('createWallet (real Wallet, in-memory)', () => { await dispose(); } }); + + // Short timeout: auto-approval must resolve in milliseconds once the wallet + // is up. A regression hangs, so fail fast rather than waiting the file-wide + // 30 s (set for KDF/SQLite-heavy tests). + it('auto-accepts a pending approval request instead of hanging', async () => { + const { wallet, dispose } = await createWallet({ + databasePath: ':memory:', + password: Password.from(TEST_PASSWORD), + srp: Srp.from(TEST_PHRASE), + infuraProjectId: INFURA_PROJECT_ID, + log: () => undefined, + }); + + try { + // `shouldShowRequest: false` bypasses the no-op `showApprovalRequest` hook — + // acceptance must come purely from the auto-approval subscription. + const accepted = await wallet.messenger.call( + 'ApprovalController:addRequest', + { origin: 'https://example.com', type: 'test:approval' }, + false, + ); + expect(accepted).toBeUndefined(); + + expect( + wallet.messenger.call('ApprovalController:getState').pendingApprovals, + ).toStrictEqual({}); + } finally { + await dispose(); + } + }, 5_000); }); describe('createWallet (integration)', () => { diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index d50a2b4fd7..fd6e3451a0 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -12,6 +12,7 @@ import { join } from 'node:path'; import { KeyValueStore } from '../persistence/KeyValueStore.js'; import * as persistenceModule from '../persistence/persistence.js'; +import * as autoApprovalModule from './auto-approval.js'; import { Password, Srp } from './secrets.js'; import { createWallet } from './wallet-factory.js'; @@ -126,6 +127,21 @@ describe('createWallet', () => { await dispose(); }); + it('installs the headless auto-approval subscription on the real wallet messenger', async () => { + const autoApprovalSpy = jest + .spyOn(autoApprovalModule, 'subscribeToAutoApproval') + .mockReturnValue(() => undefined); + + const { wallet, dispose } = await createWallet(CONFIG); + + expect(autoApprovalSpy).toHaveBeenCalledWith( + wallet.messenger, + expect.any(Function), + ); + + await dispose(); + }); + it('reads metadata from a throwaway probe wallet and destroys it', async () => { const loadStateSpy = jest .spyOn(persistenceModule, 'loadState') @@ -501,6 +517,43 @@ describe('createWallet', () => { expect(closeSpy).toHaveBeenCalled(); }); + it('unsubscribes auto-approval before destroying the wallet', async () => { + const autoApprovalUnsubscribe = jest.fn(); + jest + .spyOn(autoApprovalModule, 'subscribeToAutoApproval') + .mockReturnValue(autoApprovalUnsubscribe); + + const { wallet, dispose } = await createWallet(CONFIG); + await dispose(); + + const destroyMock = wallet.destroy as jest.Mock; + expect(autoApprovalUnsubscribe).toHaveBeenCalledTimes(1); + expect(autoApprovalUnsubscribe.mock.invocationCallOrder[0]).toBeLessThan( + destroyMock.mock.invocationCallOrder[0], + ); + }); + + it('logs and continues when the auto-approval unsubscribe throws', async () => { + jest + .spyOn(autoApprovalModule, 'subscribeToAutoApproval') + .mockReturnValue(() => { + throw new Error('auto-approval unsub boom'); + }); + const log = jest.fn(); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + const { wallet, dispose } = await createWallet({ ...CONFIG, log }); + await dispose(); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Auto-approval unsubscribe failed during teardown', + ), + ); + expect(wallet.destroy as jest.Mock).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalled(); + }); + it('logs and still closes the store when wallet.destroy rejects', async () => { const log = jest.fn(); const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); @@ -610,6 +663,29 @@ describe('createWallet', () => { expect(closeSpy).toHaveBeenCalled(); }); + it('unsubscribes persistence, destroys the wallet, and closes the store when subscribeToAutoApproval throws', async () => { + const persistenceUnsubscribe = jest.fn(); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(persistenceUnsubscribe); + jest + .spyOn(autoApprovalModule, 'subscribeToAutoApproval') + .mockImplementation(() => { + throw new Error('auto-approval subscribe failed'); + }); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow( + 'auto-approval subscribe failed', + ); + const realWallet = MockWallet.mock.results[1]?.value as Wallet; + // The persistence subscription was installed before the throw, so it + // must be torn down even though autoApprovalUnsubscribe was never set. + expect(persistenceUnsubscribe).toHaveBeenCalledTimes(1); + expect(realWallet.destroy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalled(); + }); + it('unsubscribes, destroys the wallet, and closes the store when SRP import rejects on first run', async () => { const failure = new Error('bad SRP'); mockImportSrp.mockRejectedValue(failure); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 832c16a155..fd0d5113b6 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -16,6 +16,7 @@ import { rm } from 'node:fs/promises'; import { KeyValueStore } from '../persistence/KeyValueStore.js'; import { loadState, subscribeToChanges } from '../persistence/persistence.js'; +import { subscribeToAutoApproval } from './auto-approval.js'; import type { Password, Srp } from './secrets.js'; import type { Logger } from './types.js'; @@ -61,7 +62,13 @@ export type CreateWalletResult = { * - `remoteFeatureFlagController` — a `ClientConfigApiService` fetching real * flags over the network. * - `approvalController` — a no-op `showApprovalRequest` (the daemon is - * headless). + * headless); pending requests are accepted separately by the auto-approval + * subscription `createWallet` installs, not by this hook. + * - `gasFeeController` — only `clientId: 'cli'` (sent as `X-Client-Id` to the + * gas API); the API endpoints, poll interval, and compatibility callbacks are + * left at the wallet package's platform-agnostic defaults (the default + * `EIP1559APIEndpoint` is already the production URL, so the daemon does not + * re-specify it). * - `transactionController` — swaps processing disabled and no client hooks; * see the slot's inline comment for why the daemon relies on the * controller's defaults for everything else. @@ -77,6 +84,9 @@ function buildInstanceOptions( ): WalletOptions['instanceOptions'] { return { approvalController: { + // The daemon is headless, so there is no UI to open: requests are + // resolved by the auto-approval subscription (see `subscribeToAutoApproval`) + // rather than by this hook, which stays a no-op. // TODO: surface approval requests over the daemon transport. showApprovalRequest: (): undefined => undefined, }, @@ -174,7 +184,8 @@ export async function createWallet({ const logFn = log ?? ((message: string): void => console.error(message)); const store = new KeyValueStore(databasePath); let wallet: Wallet | undefined; - let unsubscribe: (() => void) | undefined; + let persistenceUnsubscribe: (() => void) | undefined; + let autoApprovalUnsubscribe: (() => void) | undefined; let wasFirstRun = false; try { @@ -195,13 +206,16 @@ export async function createWallet({ state, instanceOptions: buildInstanceOptions(infuraProjectId), }); - unsubscribe = subscribeToChanges( + persistenceUnsubscribe = subscribeToChanges( wallet.messenger, wallet.controllerMetadata, store, logFn, ); + // Installed before `init` so any approval raised during initialization is covered. + autoApprovalUnsubscribe = subscribeToAutoApproval(wallet.messenger, logFn); + // Complete post-construction controller setup before serving requests — // e.g. `NetworkController.init` applies the selected network so a provider // is available. `init` settles every step independently; a rejected step @@ -249,10 +263,22 @@ export async function createWallet({ return { wallet, dispose: async () => - (disposePromise ??= teardown(unsubscribe, wallet, store, logFn)), + (disposePromise ??= teardown( + persistenceUnsubscribe, + autoApprovalUnsubscribe, + wallet, + store, + logFn, + )), }; } catch (error) { - await teardown(unsubscribe, wallet, store, logFn); + await teardown( + persistenceUnsubscribe, + autoApprovalUnsubscribe, + wallet, + store, + logFn, + ); if (wasFirstRun && databasePath !== IN_MEMORY_DATABASE_PATH) { // Best-effort cleanup of the on-disk SQLite files (main, WAL, SHM) so a @@ -311,30 +337,54 @@ async function loadPersistedState( } } +/** + * Best-effort unsubscribe: run the function if present, logging (and + * swallowing) any error so a later teardown step still runs. + * + * @param unsubscribe - The unsubscribe function, if one was registered. + * @param label - Human-readable subscription name for the failure log. + * @param logFn - Logger for a failure. + */ +function runUnsubscribe( + unsubscribe: (() => void) | undefined, + label: string, + logFn: Logger, +): void { + if (!unsubscribe) { + return; + } + try { + unsubscribe(); + } catch (error) { + logFn( + `${label} unsubscribe failed during teardown — subscription may remain ` + + `live until the process exits: ${String(error)}`, + ); + } +} + /** * Persistence-safe teardown of a wallet and its store; see {@link * CreateWalletResult.dispose} for the ordering rationale. Each step is * best-effort, so a failure is logged and the remaining steps still run. * - * @param unsubscribe - The persistence-subscription unsubscribe function, if - * one was registered. + * @param persistenceUnsubscribe - The persistence-subscription unsubscribe + * function, if one was registered. + * @param autoApprovalUnsubscribe - The auto-approval-subscription unsubscribe + * function, if one was registered. * @param wallet - The wallet to destroy, if one was constructed. * @param store - The store to close. * @param logFn - Logger for step failures. */ async function teardown( - unsubscribe: (() => void) | undefined, + persistenceUnsubscribe: (() => void) | undefined, + autoApprovalUnsubscribe: (() => void) | undefined, wallet: Wallet | undefined, store: KeyValueStore, logFn: Logger, ): Promise { - if (unsubscribe) { - try { - unsubscribe(); - } catch (error) { - logFn(`Persistence unsubscribe failed during teardown: ${String(error)}`); - } - } + runUnsubscribe(persistenceUnsubscribe, 'Persistence', logFn); + runUnsubscribe(autoApprovalUnsubscribe, 'Auto-approval', logFn); if (wallet) { try { await wallet.destroy();