diff --git a/README.md b/README.md index 372e63843b..745d847683 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,8 @@ linkStyle default opacity:0.5 wallet --> remote_feature_flag_controller; wallet --> storage_service; wallet_cli --> base_controller; + wallet_cli --> remote_feature_flag_controller; + wallet_cli --> storage_service; wallet_cli --> wallet; ``` diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index ba55b4dae8..afb548ec48 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add a wallet factory and daemon entry point that construct a `@metamask/wallet` `Wallet` backed by the SQLite key-value store, hydrate it from persisted state, import the secret recovery phrase on first run, and expose a `dispose` teardown handle ([#9226](https://github.com/MetaMask/core/pull/9226)) - Add a daemon transport layer: a JSON-RPC client and server over a Unix socket, plus daemon spawn/stop lifecycle helpers ([#9108](https://github.com/MetaMask/core/pull/9108)) - Add SQLite-backed persistence for wallet controller state ([#9067](https://github.com/MetaMask/core/pull/9067)) - Initial package scaffold for `@metamask/wallet-cli`, an [oclif](https://oclif.io)-based `mm` CLI for `@metamask/wallet` ([#9065](https://github.com/MetaMask/core/pull/9065)). diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 49eb5dcc33..e9740760fb 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -45,7 +45,9 @@ "dependencies": { "@inquirer/confirm": "^6.0.11", "@metamask/base-controller": "^9.1.0", + "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/rpc-errors": "^7.0.2", + "@metamask/storage-service": "^1.0.2", "@metamask/utils": "^11.11.0", "@metamask/wallet": "^4.0.0", "@oclif/core": "^4.10.5", diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts new file mode 100644 index 0000000000..0a0d0d2b24 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -0,0 +1,780 @@ +import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; + +import { pingDaemon } from './daemon-client'; +import { ensureOwnerOnlyDirectory } from './data-dir'; +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import { isProcessAlive } from './utils'; +import { createWallet } from './wallet-factory'; + +jest.mock('node:fs/promises'); +jest.mock('./data-dir'); +jest.mock('./daemon-client'); +jest.mock('./paths'); +jest.mock('./rpc-socket-server'); +jest.mock('./utils', () => { + const actual = jest.requireActual('./utils'); + return { + ...actual, + isProcessAlive: jest.fn(), + }; +}); +jest.mock('./wallet-factory'); + +const mockEnsureOwnerOnlyDirectory = jest.mocked(ensureOwnerOnlyDirectory); +const mockAppendFile = jest.mocked(appendFile); +const mockReadFile = jest.mocked(readFile); +const mockWriteFile = jest.mocked(writeFile); +const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockGetDaemonPaths = jest.mocked(getDaemonPaths); +const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); +const mockCreateWallet = jest.mocked(createWallet); +const mockIsProcessAlive = jest.mocked(isProcessAlive); + +const ORIGINAL_ENV = process.env; + +const ABSENT = { status: 'absent' as const }; +const RESPONSIVE = { status: 'responsive' as const }; +const UNREACHABLE = { + status: 'unreachable' as const, + reason: 'refused' as const, + error: new Error('wedged'), +}; + +type MockCreateWalletResult = Awaited>; + +/** + * Build an ENOENT NodeJS.ErrnoException for fs/promises mock rejections. + * + * @returns An error mimicking what `readFile` throws when a file is missing. + */ +function enoent(): NodeJS.ErrnoException { + return Object.assign(new Error('not found'), { code: 'ENOENT' }); +} + +/** + * Create a mock createWallet result with a mocked wallet and dispose handle. + * + * @returns A mock createWallet result. + */ +function createMockWallet(): MockCreateWalletResult { + return { + wallet: { + messenger: { call: jest.fn() }, + state: {}, + }, + dispose: jest.fn().mockResolvedValue(undefined), + } as unknown as MockCreateWalletResult; +} + +/** + * Create a mock server handle. + * + * @returns A mock server handle. + */ +function createMockHandle(): RpcSocketServerHandle { + return { close: jest.fn().mockResolvedValue(undefined) }; +} + +describe('daemon-entry', () => { + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + process.env.MM_WALLET_PASSWORD = 'pass'; + process.env.MM_WALLET_SRP = + 'test test test test test test test test test test test ball'; + process.exitCode = undefined; + stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + mockGetDaemonPaths.mockReturnValue({ + socketPath: '/tmp/daemon.sock', + pidPath: '/tmp/daemon.pid', + logPath: '/tmp/daemon.log', + dbPath: '/tmp/wallet.db', + }); + // Default: no prior daemon state (pre-flight readFile + ownership readFile + // both miss). Tests that need a stale PID file override these per-call. + mockReadFile.mockRejectedValue(enoent()); + mockWriteFile.mockResolvedValue(undefined); + mockRm.mockResolvedValue(undefined); + mockAppendFile.mockResolvedValue(undefined); + mockEnsureOwnerOnlyDirectory.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue(ABSENT); + mockIsProcessAlive.mockReturnValue(false); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + process.exitCode = undefined; + }); + + /** + * Import daemon-entry in an isolated module scope so its top-level + * main() runs with the current mocks and env vars. + * Returns after main() settles. + */ + async function importDaemonEntry(): Promise { + await jest.isolateModulesAsync(async () => { + await import('./daemon-entry'); + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + }); + } + + it('writes to stderr and sets exitCode when MM_DAEMON_DATA_DIR is missing', async () => { + delete process.env.MM_DAEMON_DATA_DIR; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_DAEMON_DATA_DIR'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when INFURA_PROJECT_ID is missing', async () => { + delete process.env.INFURA_PROJECT_ID; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('INFURA_PROJECT_ID'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when MM_WALLET_PASSWORD is missing', async () => { + delete process.env.MM_WALLET_PASSWORD; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_PASSWORD'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when MM_WALLET_SRP is missing', async () => { + delete process.env.MM_WALLET_SRP; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_SRP'), + ); + expect(process.exitCode).toBe(1); + }); + + it('creates data dir, wallet, server, and writes PID exclusively on successful startup', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockEnsureOwnerOnlyDirectory).toHaveBeenCalledWith('/tmp/data'); + expect(mockCreateWallet).toHaveBeenCalledWith({ + databasePath: '/tmp/wallet.db', + password: 'pass', + srp: 'test test test test test test test test test test test ball', + log: expect.any(Function), + }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + expect.stringMatching(new RegExp(`^${process.pid}\\n\\d+\\n$`, 'u')), + { flag: 'wx' }, + ); + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/tmp/daemon.sock', + }), + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('uses MM_DAEMON_SOCKET_PATH override when set', async () => { + process.env.MM_DAEMON_SOCKET_PATH = '/custom/sock'; + + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/custom/sock', + }), + ); + }); + + it('refuses to start when a responsive daemon already owns the socket', async () => { + mockReadFile.mockResolvedValue('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(RESPONSIVE); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running'), + ); + expect(process.exitCode).toBe(1); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('refuses to start when a responsive daemon owns the socket without a PID file', async () => { + // No PID file (ENOENT default) but pingDaemon returns responsive. + mockPingDaemon.mockResolvedValue(RESPONSIVE); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running'), + ); + expect(process.exitCode).toBe(1); + }); + + it('removes a stale unreachable socket file when no PID file is present', async () => { + mockPingDaemon.mockResolvedValue(UNREACHABLE); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Removing stale socket'), + ); + }); + + it('surfaces non-ENOENT errors from reading the existing PID file during pre-flight', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('read denied'), { code: 'EACCES' }), + ); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('read denied'), + ); + expect(process.exitCode).toBe(1); + }); + + it('treats a malformed PID file as having no PID (takes over the slot)', async () => { + mockReadFile.mockResolvedValueOnce('not-a-number\n'); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Pre-flight treated the file as if no PID was present (existingPid === undefined), + // pinged, found nothing, then removed the stale socket. No error. + expect(process.exitCode).toBeUndefined(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + }); + + it('clears stale PID + socket files when the recorded daemon is no longer responsive', async () => { + // PID file is present and pingDaemon returns absent → take over. + mockReadFile.mockResolvedValueOnce('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(ABSENT); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + expect.any(String), + { flag: 'wx' }, + ); + }); + + it('disposes the wallet and removes the PID file when the server fails to start', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + // Second readFile call (ownership check during cleanup) sees the PID + // file we just wrote — return matching contents so removal proceeds. + mockReadFile + .mockRejectedValueOnce(enoent()) // pre-flight readPidFromFile + .mockImplementation(async () => { + const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; + return typeof lastWrite === 'string' ? lastWrite : ''; + }); + + await importDaemonEntry(); + + expect(result.dispose).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('removes the PID file when createWallet itself fails (no dispose handle yet)', async () => { + mockCreateWallet.mockRejectedValue(new Error('wallet failed')); + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockImplementation(async () => { + const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; + return typeof lastWrite === 'string' ? lastWrite : ''; + }); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('aborts when another daemon wins the exclusive PID-file write race', async () => { + // Simulate two daemons reaching the wx write nearly simultaneously: pre-flight + // sees no PID file (ENOENT), but writeFile rejects with EEXIST because a + // sibling already claimed the slot. Since the slot write now happens BEFORE + // createWallet, we never construct a wallet or open the DB. + const eexist = Object.assign(new Error('already exists'), { + code: 'EEXIST', + }); + mockWriteFile.mockRejectedValue(eexist); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('already exists'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to claim daemon slot'), + ); + expect(process.exitCode).toBe(1); + // Wallet must NOT be constructed when the slot write loses the race — + // this is the whole point of writing the PID before opening the DB. + expect(mockCreateWallet).not.toHaveBeenCalled(); + }); + + it('refuses to take over an unreachable socket whose recorded PID is alive', async () => { + mockReadFile.mockResolvedValue('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(UNREACHABLE); + mockIsProcessAlive.mockReturnValue(true); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('socket at /tmp/daemon.sock is unresponsive'), + ); + expect(process.exitCode).toBe(1); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('refuses to take over when the socket is absent but the recorded PID is alive', async () => { + mockReadFile.mockResolvedValue('9999\n12345\n'); + mockPingDaemon.mockResolvedValue(ABSENT); + mockIsProcessAlive.mockReturnValue(true); + mockCreateWallet.mockResolvedValue(createMockWallet()); + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('A daemon is already running'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('pid is still alive'), + ); + expect(process.exitCode).toBe(1); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('clears a corrupt PID file along with the socket so wx write can succeed', async () => { + // Pre-flight readPidFile returns undefined for a file that exists but + // doesn't parse as an integer (e.g. truncated/torn write from a crash). + // Without the rm pidPath in claimDaemonSlot, the wx write would fail + // with EEXIST and the daemon couldn't start. + mockReadFile.mockResolvedValueOnce('garbage-not-a-number\n'); + mockPingDaemon.mockResolvedValue(ABSENT); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.sock', { force: true }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + expect.any(String), + { flag: 'wx' }, + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('does not remove the PID file during cleanup if its contents no longer match', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + // Pre-flight finds no PID file (ENOENT). Cleanup readFile returns + // unrelated contents (a different daemon's PID file) — must not rm + // the sibling's file during cleanup. + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockResolvedValueOnce('99999\n9999999\n'); + + await importDaemonEntry(); + + // Pre-flight unconditionally rms pidPath once; cleanup must NOT add + // a second rm because removeOwnedPidFile saw mismatched contents. + const pidRmCalls = mockRm.mock.calls.filter( + ([path]) => path === '/tmp/daemon.pid', + ); + expect(pidRmCalls).toHaveLength(1); + expect(process.exitCode).toBe(1); + }); + + it('logs and continues when ownership-aware PID removal throws during error cleanup', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + // Force ownership check (readFile) to throw non-ENOENT so removeOwnedPidFile rejects. + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockRejectedValueOnce( + Object.assign(new Error('read denied'), { code: 'EACCES' }), + ); + + await importDaemonEntry(); + + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Failed to remove PID file during cleanup'), + ); + expect(process.exitCode).toBe(1); + }); + + it('exposes getStatus handler that returns pid and uptime', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const { handlers } = callArgs; + const status = (await handlers.getStatus(null)) as { + pid: number; + uptime: number; + }; + + expect(status.pid).toBe(process.pid); + expect(typeof status.uptime).toBe('number'); + }); + + it('logs to file via makeLogger', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Starting daemon...'), + ); + }); + + it('writes to stderr when appendFile fails in makeLogger', async () => { + mockAppendFile.mockRejectedValue(new Error('disk full')); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('log write failed'), + ); + }); + + it('registers SIGTERM and SIGINT handlers', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const registeredEvents = onSpy.mock.calls.map(([event]) => event); + expect(registeredEvents).toContain('SIGTERM'); + expect(registeredEvents).toContain('SIGINT'); + }); + + it('triggers shutdown when SIGTERM handler is called', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const sigTermCall = onSpy.mock.calls.find(([event]) => event === 'SIGTERM'); + const sigTermHandler = sigTermCall?.[1] as () => void; + sigTermHandler(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(handle.close).toHaveBeenCalled(); + expect(result.dispose).toHaveBeenCalled(); + }); + + it('triggers shutdown when SIGINT handler is called', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const sigIntCall = onSpy.mock.calls.find(([event]) => event === 'SIGINT'); + const sigIntHandler = sigIntCall?.[1] as () => void; + sigIntHandler(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(handle.close).toHaveBeenCalled(); + expect(result.dispose).toHaveBeenCalled(); + }); + + it('shutdown still disposes the wallet when handle.close fails', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(result.dispose).toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('handle.close() failed'), + ); + }); + + it('handles rm rejection during shutdown cleanup gracefully', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockImplementation(async () => { + const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; + return typeof lastWrite === 'string' ? lastWrite : ''; + }); + // claimDaemonSlot calls rm on both pidPath and socketPath up front; let + // those succeed, and reject only the shutdown-time rms so we can verify + // the failure is logged rather than thrown. + mockRm + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(result.dispose).toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Failed to remove socket file'), + ); + }); + + it('handles rm rejection in error cleanup path gracefully', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + mockRm.mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + expect(process.exitCode).toBe(1); + }); + + it('onShutdown closes the server and disposes the wallet', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + // Echo the written PID contents back for ownership check. + mockReadFile + .mockRejectedValueOnce(enoent()) + .mockImplementation(async () => { + const lastWrite = mockWriteFile.mock.calls.at(-1)?.[1]; + return typeof lastWrite === 'string' ? lastWrite : ''; + }); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(result.dispose).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + }); + + it('coalesces concurrent shutdown calls', async () => { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await Promise.all([onShutdown(), onShutdown()]); + + // The teardown body runs once even though shutdown was invoked twice. + expect(handle.close).toHaveBeenCalledTimes(1); + expect(result.dispose).toHaveBeenCalledTimes(1); + }); + + describe('call handler', () => { + /** + * Import the daemon entry and extract the `call` handler from the + * handlers map, along with the mock wallet for assertions. + * + * @returns The call handler function and mock wallet result. + */ + async function setupCallHandler(): Promise<{ + callHandler: (params: unknown) => Promise; + result: MockCreateWalletResult; + }> { + const result = createMockWallet(); + mockCreateWallet.mockResolvedValue(result); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const callHandler = callArgs.handlers.call as ( + params: unknown, + ) => Promise; + return { callHandler, result }; + } + + it('registers a call handler', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + expect(typeof callArgs.handlers.call).toBe('function'); + }); + + it('forwards action and args to messenger.call', async () => { + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue({ accounts: [] }); + + const callResult = await callHandler([ + 'Controller:action', + 'arg1', + 'arg2', + ]); + + expect(mockCall).toHaveBeenCalledWith( + 'Controller:action', + 'arg1', + 'arg2', + ); + expect(callResult).toStrictEqual({ accounts: [] }); + }); + + it('calls messenger.call with no extra args when only action is provided', async () => { + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue('ok'); + + await callHandler(['Controller:action']); + + expect(mockCall).toHaveBeenCalledWith('Controller:action'); + }); + + it('awaits async messenger.call results', async () => { + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; + mockCall.mockResolvedValue({ async: true }); + + const callResult = await callHandler(['Controller:asyncAction']); + + expect(callResult).toStrictEqual({ async: true }); + }); + + it('propagates errors thrown by messenger.call', async () => { + const { callHandler, result } = await setupCallHandler(); + const mockCall = result.wallet.messenger.call as jest.Mock; + mockCall.mockImplementation(() => { + throw new Error('A handler for Unknown:action has not been registered'); + }); + + await expect(callHandler(['Unknown:action'])).rejects.toThrow( + 'A handler for Unknown:action has not been registered', + ); + }); + + it('throws when params is null', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler(null)).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + + it('throws when params is an empty array', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + + it('throws when action name is not a string', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([42])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts new file mode 100644 index 0000000000..118a7b637b --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -0,0 +1,293 @@ +import type { Json } from '@metamask/utils'; +import type { Wallet } from '@metamask/wallet'; +import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; + +import { pingDaemon } from './daemon-client'; +import { ensureOwnerOnlyDirectory } from './data-dir'; +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import type { DaemonStatusInfo, RpcHandlerMap } from './types'; +import { isErrorWithCode, isProcessAlive, readPidFile } from './utils'; +import { createWallet } from './wallet-factory'; + +const startTime = Date.now(); + +main().catch((error: unknown) => { + process.stderr.write(`Daemon fatal: ${String(error)}\n`); + process.exitCode = 1; +}); + +/** + * Main daemon entry point. Starts the daemon process and keeps it running. + */ +async function main(): Promise { + const dataDir = process.env.MM_DAEMON_DATA_DIR; + if (!dataDir) { + throw new Error('MM_DAEMON_DATA_DIR environment variable is required'); + } + + // TODO(#9001): INFURA_PROJECT_ID is required by the spawn contract but not + // yet consumed — `NetworkController` is not wired on `@metamask/wallet`. + // Pass it into `createWallet`'s NetworkController slot once it lands. + if (!process.env.INFURA_PROJECT_ID) { + throw new Error('INFURA_PROJECT_ID environment variable is required'); + } + + const password = process.env.MM_WALLET_PASSWORD; + if (!password) { + throw new Error('MM_WALLET_PASSWORD environment variable is required'); + } + + const srp = process.env.MM_WALLET_SRP; + if (!srp) { + throw new Error('MM_WALLET_SRP environment variable is required'); + } + + await ensureOwnerOnlyDirectory(dataDir); + + const { + socketPath: defaultSocketPath, + pidPath, + logPath, + dbPath, + } = getDaemonPaths(dataDir); + const socketPath = process.env.MM_DAEMON_SOCKET_PATH ?? defaultSocketPath; + + const log = makeLogger(logPath); + log('Starting daemon...'); + + // Pre-flight: refuse to take over if a responsive daemon already owns this + // socket. If the existing PID file is stale (or the socket is dead), clean + // it up so the exclusive PID-file write below has a chance to succeed. + await claimDaemonSlot(pidPath, socketPath, log); + + const pidFileContents = `${process.pid}\n${startTime}\n`; + + // Claim the slot atomically BEFORE opening the SQLite database or + // constructing the Wallet. Two concurrent `daemon start` invocations can + // both pass `claimDaemonSlot` (the gap between its preflight and the slot + // write is racy); without this ordering, both would open `wallet.db` and + // both would run first-run SRP import before one loses the wx race. + try { + await writeFile(pidPath, pidFileContents, { flag: 'wx' }); + } catch (error) { + throw error instanceof Error + ? Object.assign(error, { + message: `Failed to claim daemon slot at ${pidPath}: ${error.message}`, + }) + : /* istanbul ignore next -- node:fs/promises always rejects with an Error */ + new Error( + `Failed to claim daemon slot at ${pidPath}: ${String(error)}`, + ); + } + + let wallet: Wallet | undefined; + let dispose: (() => Promise) | undefined; + let handle: RpcSocketServerHandle | undefined; + + try { + ({ wallet, dispose } = await createWallet({ + databasePath: dbPath, + password, + srp, + log, + })); + + const constructedWallet = wallet; + const handlers: RpcHandlerMap = { + getStatus: async (): Promise => ({ + pid: process.pid, + uptime: Math.floor((Date.now() - startTime) / 1000), + }), + // Arbitrary messenger dispatch is intentional: the CLI exposes the full + // messenger surface over a Unix socket inside the per-user oclif data + // directory. The dataDir is chmodded to 0o700 above and the socket to + // 0o600 by the RPC server on bind, so only the owning user can open + // them, but there is no in-process auth check beyond that + // filesystem-permission barrier. + call: async (params) => { + if (!Array.isArray(params) || typeof params[0] !== 'string') { + throw new Error('Expected params to be an array with an action name'); + } + const [action, ...args] = params as [string, ...Json[]]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. + const result = (constructedWallet.messenger as any).call( + action, + ...args, + ); + return (result instanceof Promise ? await result : result) as Json; + }, + }; + + // `startRpcSocketServer` restricts the socket to the owner (chmod 0o600) + // on bind and never leaves a live server/socket behind if it rejects, so + // the catch below has nothing of its own to close. + handle = await startRpcSocketServer({ + socketPath, + handlers, + onShutdown: async () => shutdown('RPC shutdown'), + log, + }); + } catch (error) { + // `dispose` is undefined only when `createWallet` itself threw — it has + // already torn down its own store in that case. + if (dispose) { + await dispose(); + } + // Only remove the PID file if it's still ours (we may have lost the race + // and the file now belongs to another daemon). + await removeOwnedPidFile(pidPath, pidFileContents).catch( + (rmError: unknown) => { + log(`Failed to remove PID file during cleanup: ${String(rmError)}`); + }, + ); + throw error; + } + + // Capture the now-resolved bindings so the shutdown closures below have + // a stable, non-undefined reference (TS narrowing across closure escape). + const activeHandle = handle; + const activeDispose = dispose; + + log(`Daemon started. Socket: ${socketPath}`); + + let shutdownPromise: Promise | undefined; + + /** + * Shut down the daemon idempotently. Concurrent calls coalesce. + * + * @param reason - A label describing why shutdown was triggered. + * @returns A promise that resolves when shutdown completes. + */ + async function shutdown(reason: string): Promise { + if (shutdownPromise === undefined) { + log(`Shutting down (${reason})...`); + shutdownPromise = (async (): Promise => { + try { + await activeHandle.close(); + } catch (closeError) { + log(`handle.close() failed: ${String(closeError)}`); + } + await activeDispose(); + await Promise.all([ + removeOwnedPidFile(pidPath, pidFileContents).catch( + (rmError: unknown) => { + log(`Failed to remove PID file: ${String(rmError)}`); + }, + ), + rm(socketPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove socket file: ${String(rmError)}`); + }), + ]); + })(); + } + return shutdownPromise; + } + + process.on('SIGTERM', () => { + /* istanbul ignore next */ + shutdown('SIGTERM').catch(() => undefined); + }); + process.on('SIGINT', () => { + /* istanbul ignore next */ + shutdown('SIGINT').catch(() => undefined); + }); +} + +/** + * Refuse to start if a responsive daemon already owns the socket. Otherwise + * clear any stale PID/socket files so the exclusive PID-file write can + * proceed. + * + * @param pidPath - The PID file path. + * @param socketPath - The socket path. + * @param log - Logger for diagnostic messages. + */ +async function claimDaemonSlot( + pidPath: string, + socketPath: string, + log: (message: string) => void, +): Promise { + const existingPid = await readPidFile(pidPath); + const ping = await pingDaemon(socketPath); + + if (ping.status === 'responsive') { + const pidPart = + existingPid === undefined + ? '(no PID file present)' + : `(pid ${existingPid})`; + throw new Error(`A daemon is already running on ${socketPath} ${pidPart}`); + } + + // Refuse to clobber when the recorded PID is still alive, regardless of + // whether the socket exists. Possible scenarios: + // - `unreachable`: wedged or mid-startup sibling daemon (socket present + // but not responding to JSON-RPC). + // - `absent`: a sibling daemon that hasn't yet bound its socket, or one + // whose socket was manually removed. In either case, removing its PID + // file would orphan it from `daemon stop`. + if (existingPid !== undefined && isProcessAlive(existingPid)) { + const detail = + ping.status === 'unreachable' + ? `socket at ${socketPath} is unresponsive (${ping.error.message})` + : `no socket at ${socketPath}, but pid is still alive`; + throw new Error( + `A daemon is already running (pid ${existingPid}): ${detail}. ` + + `Run \`mm daemon stop\` (or \`mm daemon purge\`) before starting a new daemon.`, + ); + } + + if (ping.status === 'unreachable') { + log(`Removing stale socket at ${socketPath} (${ping.error.message}).`); + } + // Always clear both files before claiming the slot. The PID file may be + // corrupt (truncated, partial write from a crashed run); without this, the + // exclusive `wx` write below would fail with EEXIST and the daemon could + // not start until a human manually deleted the file. + await Promise.all([ + rm(pidPath, { force: true }), + rm(socketPath, { force: true }), + ]); +} + +/** + * Remove the PID file only if it still contains our exact contents. Guards + * against a racing daemon's PID file being removed by this daemon during + * cleanup. + * + * @param pidPath - Path to the PID file. + * @param expectedContents - The contents we wrote when claiming the slot. + */ +async function removeOwnedPidFile( + pidPath: string, + expectedContents: string, +): Promise { + let actual: string; + try { + actual = await readFile(pidPath, 'utf-8'); + } catch (error: unknown) { + if (isErrorWithCode(error, 'ENOENT')) { + return; + } + throw error; + } + if (actual === expectedContents) { + await rm(pidPath, { force: true }); + } +} + +/** + * Create a simple file logger. + * + * @param logPath - The log file path. + * @returns A logging function. + */ +function makeLogger(logPath: string): (message: string) => void { + return (message: string): void => { + const line = `[${new Date().toISOString()}] ${message}\n`; + appendFile(logPath, line).catch((error: unknown) => { + process.stderr.write(`[log write failed: ${String(error)}] ${message}\n`); + }); + }; +} diff --git a/packages/wallet-cli/src/daemon/data-dir.test.ts b/packages/wallet-cli/src/daemon/data-dir.test.ts new file mode 100644 index 0000000000..1203a9d394 --- /dev/null +++ b/packages/wallet-cli/src/daemon/data-dir.test.ts @@ -0,0 +1,39 @@ +import { mkdirSync } from 'node:fs'; +import { chmod } from 'node:fs/promises'; + +import { ensureOwnerOnlyDirectory } from './data-dir'; + +jest.mock('node:fs'); +jest.mock('node:fs/promises'); + +const mockMkdirSync = jest.mocked(mkdirSync); +const mockChmod = jest.mocked(chmod); + +describe('ensureOwnerOnlyDirectory', () => { + beforeEach(() => { + mockChmod.mockResolvedValue(undefined); + }); + + it('creates the directory recursively with owner-only mode', async () => { + await ensureOwnerOnlyDirectory('/tmp/data'); + + expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { + recursive: true, + mode: 0o700, + }); + }); + + it('chmods the directory to owner-only after creating it', async () => { + await ensureOwnerOnlyDirectory('/tmp/data'); + + expect(mockChmod).toHaveBeenCalledWith('/tmp/data', 0o700); + }); + + it('propagates errors from chmod', async () => { + mockChmod.mockRejectedValue(new Error('chmod failed')); + + await expect(ensureOwnerOnlyDirectory('/tmp/data')).rejects.toThrow( + 'chmod failed', + ); + }); +}); diff --git a/packages/wallet-cli/src/daemon/data-dir.ts b/packages/wallet-cli/src/daemon/data-dir.ts new file mode 100644 index 0000000000..3ce16c9df6 --- /dev/null +++ b/packages/wallet-cli/src/daemon/data-dir.ts @@ -0,0 +1,19 @@ +import { mkdirSync } from 'node:fs'; +import { chmod } from 'node:fs/promises'; + +/** + * Create the daemon's data directory (if it does not exist) and restrict it to + * the owning user. + * + * The mode is `0o700` (owner-only). The daemon exposes the full wallet + * messenger over the socket inside this directory, so anyone who can traverse + * the dir can also `connect()` to the socket. Restricting to the owning user is + * the only access-control boundary. We `chmod` after `mkdir` because the `mode` + * option is ignored when the directory already exists. + * + * @param dataDir - The data directory to create and lock down. + */ +export async function ensureOwnerOnlyDirectory(dataDir: string): Promise { + mkdirSync(dataDir, { recursive: true, mode: 0o700 }); + await chmod(dataDir, 0o700); +} diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts new file mode 100644 index 0000000000..de99518b99 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -0,0 +1,517 @@ +import { ClientConfigApiService } from '@metamask/remote-feature-flag-controller'; +import { InMemoryStorageAdapter } from '@metamask/storage-service'; +import { + AlwaysOnlineAdapter, + importSecretRecoveryPhrase, + Wallet, +} from '@metamask/wallet'; +import { rmSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { KeyValueStore } from '../persistence/KeyValueStore'; +import * as persistenceModule from '../persistence/persistence'; +import { createWallet } from './wallet-factory'; + +jest.mock('@metamask/wallet'); +jest.mock('@metamask/remote-feature-flag-controller'); +jest.mock('node:fs/promises'); + +const MockWallet = jest.mocked(Wallet); +const mockImportSrp = jest.mocked(importSecretRecoveryPhrase); +const mockRm = jest.mocked(rm); + +const createdTempDbPaths: string[] = []; + +const SRP = 'test test test test test test test test test test test ball'; + +const CONFIG = { + databasePath: ':memory:', + password: 'test-pass', + srp: SRP, +}; + +/** + * Build a mock `Wallet` with a fresh messenger, metadata, and destroy mock. + * Each `Wallet` construction (the metadata probe, then the real wallet) gets + * its own instance so the two can be told apart in assertions. + * + * @returns A mock `Wallet`. + */ +function makeMockWallet(): Wallet { + return { + messenger: { + call: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, + controllerMetadata: {}, + state: {}, + destroy: jest.fn().mockResolvedValue(undefined), + } as unknown as Wallet; +} + +/** + * Build a unique on-disk path under the OS temp dir so SQLite can create the + * file, while keeping the test isolated from concurrent runs. The path is + * tracked and cleaned up after each test (the production `rm` is mocked, so + * the factory never deletes it during the test itself). + * + * @param label - A short label that makes the resulting filename traceable. + * @returns An absolute file path inside `os.tmpdir()`. + */ +function tempDbPath(label: string): string { + const path = join( + tmpdir(), + `wallet-cli-${label}-${Date.now()}-${Math.random()}.db`, + ); + createdTempDbPaths.push(path); + return path; +} + +describe('createWallet', () => { + beforeEach(() => { + MockWallet.mockImplementation(makeMockWallet); + mockRm.mockResolvedValue(undefined); + }); + + afterEach(() => { + while (createdTempDbPaths.length > 0) { + const path = createdTempDbPaths.pop() as string; + for (const candidate of [path, `${path}-wal`, `${path}-shm`]) { + rmSync(candidate, { force: true }); + } + } + }); + + it('constructs the wallet with the wired instance options', async () => { + const { dispose } = await createWallet(CONFIG); + + // The wallet is constructed twice: a short-lived metadata probe, then the + // real wallet. + expect(MockWallet).toHaveBeenCalledTimes(2); + const { instanceOptions } = MockWallet.mock.calls[1][0]; + + expect( + instanceOptions.approvalController?.showApprovalRequest?.(), + ).toBeUndefined(); + expect( + instanceOptions.connectivityController.connectivityAdapter, + ).toBeInstanceOf(AlwaysOnlineAdapter); + expect( + instanceOptions.remoteFeatureFlagController.clientConfigApiService, + ).toBeInstanceOf(ClientConfigApiService); + expect( + instanceOptions.remoteFeatureFlagController.getMetaMetricsId?.(), + ).toBe('cli'); + expect(instanceOptions.remoteFeatureFlagController.clientVersion).toBe( + '0.0.0', + ); + expect(instanceOptions.storageService.storage).toBeInstanceOf( + InMemoryStorageAdapter, + ); + expect(ClientConfigApiService).toHaveBeenCalled(); + + await dispose(); + }); + + it('reads metadata from a throwaway probe wallet and destroys it', async () => { + const loadStateSpy = jest + .spyOn(persistenceModule, 'loadState') + .mockReturnValue({}); + + const { dispose } = await createWallet(CONFIG); + + const probe = MockWallet.mock.results[0]?.value as Wallet; + expect(loadStateSpy).toHaveBeenCalledWith( + expect.any(KeyValueStore), + probe.controllerMetadata, + ); + expect(probe.destroy).toHaveBeenCalledTimes(1); + + await dispose(); + }); + + it('logs but tolerates the metadata probe failing to destroy', async () => { + MockWallet.mockImplementationOnce( + () => + ({ + ...makeMockWallet(), + destroy: jest.fn().mockRejectedValue(new Error('probe destroy boom')), + }) as unknown as Wallet, + ); + const log = jest.fn(); + + const { wallet, dispose } = await createWallet({ ...CONFIG, log }); + + expect(wallet).toBe(MockWallet.mock.results[1]?.value); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('Metadata probe destroy failed'), + ); + await dispose(); + }); + + it('seeds the real wallet with the state loaded from the store', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + AccountsController: { + internalAccounts: { accounts: {}, selectedAccount: '' }, + }, + }); + + const { dispose } = await createWallet(CONFIG); + + expect(MockWallet.mock.calls[1][0].state).toStrictEqual({ + AccountsController: { + internalAccounts: { accounts: {}, selectedAccount: '' }, + }, + }); + + await dispose(); + }); + + it('imports the secret recovery phrase on first run', async () => { + const { wallet, dispose } = await createWallet(CONFIG); + + expect(mockImportSrp).toHaveBeenCalledWith(wallet, 'test-pass', SRP); + + await dispose(); + }); + + it('skips importing the SRP when the store already contains a keyring vault', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + + const { dispose } = await createWallet(CONFIG); + + expect(mockImportSrp).not.toHaveBeenCalled(); + + await dispose(); + }); + + it('returns the real wallet and a dispose function', async () => { + const { wallet, dispose } = await createWallet(CONFIG); + + expect(wallet).toBe(MockWallet.mock.results[1]?.value); + expect(typeof dispose).toBe('function'); + + await dispose(); + }); + + it('subscribes the store to the real wallet state changes', async () => { + const subscribeSpy = jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(() => undefined); + + const { wallet, dispose } = await createWallet(CONFIG); + + expect(subscribeSpy).toHaveBeenCalledWith( + wallet.messenger, + wallet.controllerMetadata, + expect.any(KeyValueStore), + expect.any(Function), + ); + + await dispose(); + }); + + it('forwards the supplied log callback to subscribeToChanges', async () => { + const subscribeSpy = jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(() => undefined); + const log = jest.fn(); + + const { dispose } = await createWallet({ ...CONFIG, log }); + + expect(subscribeSpy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + log, + ); + + await dispose(); + }); + + describe('dispose', () => { + it('unsubscribes, destroys the wallet, then closes the store, in order', async () => { + const unsubscribe = jest.fn(); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(unsubscribe); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + const { wallet, dispose } = await createWallet(CONFIG); + await dispose(); + + const destroyMock = wallet.destroy as jest.Mock; + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(destroyMock).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(unsubscribe.mock.invocationCallOrder[0]).toBeLessThan( + destroyMock.mock.invocationCallOrder[0], + ); + expect(destroyMock.mock.invocationCallOrder[0]).toBeLessThan( + closeSpy.mock.invocationCallOrder[0], + ); + }); + + it('coalesces repeat calls onto a single teardown', async () => { + const unsubscribe = jest.fn(); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(unsubscribe); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + const { wallet, dispose } = await createWallet(CONFIG); + await Promise.all([dispose(), dispose()]); + await dispose(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(wallet.destroy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it('coalesces repeat calls even when a teardown step throws', async () => { + const unsubscribe = jest.fn(); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(unsubscribe); + const closeSpy = jest + .spyOn(KeyValueStore.prototype, 'close') + .mockImplementation(() => { + throw new Error('close boom'); + }); + + const { wallet, dispose } = await createWallet({ + ...CONFIG, + log: jest.fn(), + }); + await dispose(); + await dispose(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(wallet.destroy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it('logs and continues when unsubscribe throws', async () => { + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(() => { + throw new Error('unsub boom'); + }); + const log = jest.fn(); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + const { dispose } = await createWallet({ ...CONFIG, log }); + await dispose(); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Persistence unsubscribe failed during teardown', + ), + ); + 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'); + + const { wallet, dispose } = await createWallet({ ...CONFIG, log }); + (wallet.destroy as jest.Mock).mockRejectedValue( + new Error('destroy boom'), + ); + await dispose(); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining('wallet.destroy() failed during teardown'), + ); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('logs when store.close throws', async () => { + const log = jest.fn(); + + const { dispose } = await createWallet({ ...CONFIG, log }); + jest.spyOn(KeyValueStore.prototype, 'close').mockImplementation(() => { + throw new Error('close boom'); + }); + await dispose(); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining('store.close() failed during teardown'), + ); + }); + + it('falls back to console.error when no logger is supplied', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(() => { + throw new Error('unsub boom'); + }); + + const { dispose } = await createWallet(CONFIG); + await dispose(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Persistence unsubscribe failed during teardown', + ), + ); + }); + }); + + describe('startup failure cleanup', () => { + it('closes the store and rethrows when state hydration fails', async () => { + const failure = new Error('corrupt store'); + jest.spyOn(persistenceModule, 'loadState').mockImplementation(() => { + throw failure; + }); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow(failure); + expect(closeSpy).toHaveBeenCalled(); + // The probe is still torn down even though loadState threw. + expect(MockWallet.mock.results[0]?.value.destroy).toHaveBeenCalled(); + }); + + it('closes the store and rethrows when the real wallet construction fails', async () => { + const ctorError = new Error('wallet ctor failed'); + MockWallet.mockImplementationOnce(makeMockWallet).mockImplementationOnce( + () => { + throw ctorError; + }, + ); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow(ctorError); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('destroys the wallet and closes the store when subscribeToChanges throws', async () => { + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockImplementation(() => { + throw new Error('subscribe failed'); + }); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow('subscribe failed'); + const realWallet = MockWallet.mock.results[1]?.value as Wallet; + 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); + const unsubscribe = jest.fn(); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockReturnValue(unsubscribe); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet(CONFIG)).rejects.toThrow(failure); + const realWallet = MockWallet.mock.results[1]?.value as Wallet; + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(realWallet.destroy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('removes the on-disk database files when first-run SRP import rejects, after closing the store', async () => { + mockImportSrp.mockRejectedValue(new Error('bad SRP')); + const databasePath = tempDbPath('rm-on-failure'); + const closeSpy = jest.spyOn(KeyValueStore.prototype, 'close'); + + await expect(createWallet({ ...CONFIG, databasePath })).rejects.toThrow( + 'bad SRP', + ); + + expect(mockRm).toHaveBeenCalledWith(databasePath, { force: true }); + expect(mockRm).toHaveBeenCalledWith(`${databasePath}-wal`, { + force: true, + }); + expect(mockRm).toHaveBeenCalledWith(`${databasePath}-shm`, { + force: true, + }); + // The store must be closed before the files are removed. + expect(closeSpy.mock.invocationCallOrder[0]).toBeLessThan( + mockRm.mock.invocationCallOrder[0], + ); + }); + + it('does not remove an in-memory database when first-run SRP import rejects', async () => { + mockImportSrp.mockRejectedValue(new Error('bad SRP')); + + await expect(createWallet(CONFIG)).rejects.toThrow('bad SRP'); + + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('does not remove the database when SRP import succeeds on first run', async () => { + const databasePath = tempDbPath('success'); + + const { dispose } = await createWallet({ ...CONFIG, databasePath }); + + expect(mockRm).not.toHaveBeenCalled(); + await dispose(); + }); + + it('does not remove the database when failure occurs on a subsequent run', async () => { + jest.spyOn(persistenceModule, 'loadState').mockReturnValue({ + KeyringController: { vault: 'encrypted-vault-blob' }, + }); + jest + .spyOn(persistenceModule, 'subscribeToChanges') + .mockImplementation(() => { + throw new Error('subscribe failed'); + }); + + await expect( + createWallet({ ...CONFIG, databasePath: tempDbPath('subsequent-run') }), + ).rejects.toThrow('subscribe failed'); + + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('logs rm rejection during first-run cleanup and still rethrows the original error', async () => { + const original = new Error('bad SRP'); + mockImportSrp.mockRejectedValue(original); + mockRm.mockRejectedValue(new Error('disk gone')); + const log = jest.fn(); + + await expect( + createWallet({ + ...CONFIG, + databasePath: tempDbPath('rm-rejection'), + log, + }), + ).rejects.toThrow(original); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('during first-run cleanup'), + ); + }); + + it('tolerates wallet.destroy rejection during cleanup and still rethrows', async () => { + const original = new Error('bad SRP'); + mockImportSrp.mockRejectedValue(original); + MockWallet.mockImplementationOnce(makeMockWallet).mockImplementationOnce( + () => + ({ + ...makeMockWallet(), + destroy: jest.fn().mockRejectedValue(new Error('destroy failed')), + }) as unknown as Wallet, + ); + + await expect(createWallet({ ...CONFIG, log: jest.fn() })).rejects.toThrow( + original, + ); + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts new file mode 100644 index 0000000000..3187fab8c5 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -0,0 +1,266 @@ +import { + ClientConfigApiService, + ClientType, + DistributionType, + EnvironmentType, +} from '@metamask/remote-feature-flag-controller'; +import { InMemoryStorageAdapter } from '@metamask/storage-service'; +import type { Json } from '@metamask/utils'; +import { + AlwaysOnlineAdapter, + importSecretRecoveryPhrase, + Wallet, +} from '@metamask/wallet'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, + WalletOptions, +} from '@metamask/wallet'; +import { rm } from 'node:fs/promises'; + +import { KeyValueStore } from '../persistence/KeyValueStore'; +import { loadState, subscribeToChanges } from '../persistence/persistence'; + +const IN_MEMORY_DATABASE_PATH = ':memory:'; + +export type CreateWalletResult = { + wallet: Wallet; + /** + * Tear down everything `createWallet` set up, in the order that keeps + * in-flight persistence writes valid: stop the state-change subscription, + * destroy the wallet, then close the store (closing first would cause a + * teardown-time persistence write to fail). Resilient — a failure in any + * step is logged and the remaining steps still run — and idempotent (repeat + * calls coalesce onto the same teardown). + */ + dispose: () => Promise; +}; + +/** + * Build the per-instance options the daemon's `Wallet` is constructed with. + * + * Returns a fresh set on every call so the metadata probe and the real wallet + * never share adapter/service instances (the probe is destroyed, which may + * tear those instances down). + * + * Only the slots wired on `@metamask/wallet` today are populated: + * - `storageService` — backed by an in-memory adapter. This is the wallet's + * large-blob store (snap source, caches), distinct from the SQLite + * `KeyValueStore` that persists controller state; no wired controller + * offloads durable data to it yet, so in-memory suffices. + * - `connectivityController` — the `AlwaysOnlineAdapter` exported for + * node-like hosts that have no platform connectivity signal. + * - `remoteFeatureFlagController` — a `ClientConfigApiService` fetching real + * flags over the network. + * - `approvalController` — a no-op `showApprovalRequest` (the daemon is + * headless). + * + * @returns The `instanceOptions` for the `Wallet` constructor. + */ +function buildInstanceOptions(): WalletOptions['instanceOptions'] { + return { + approvalController: { + // TODO: surface approval requests over the daemon transport. + showApprovalRequest: (): undefined => undefined, + }, + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + remoteFeatureFlagController: { + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'cli', + clientVersion: '0.0.0', + }, + storageService: { + storage: new InMemoryStorageAdapter(), + }, + // TODO(#9001): add the `networkController` slot (fed by INFURA_PROJECT_ID) + // once it is wired on `@metamask/wallet`. + // TODO(#8975): add the `transactionController` slot once it is wired. + }; +} + +/** + * Create a configured `Wallet` for daemon use, backed by a SQLite key-value + * store for controller-state persistence. + * + * Loads any previously-persisted controller state from the store, seeds the + * wallet with it, then subscribes the store to subsequent state changes so all + * persist-flagged properties are written through. + * + * If the store does not yet contain a keyring vault (first run), the supplied + * secret recovery phrase is imported. On subsequent runs the persisted vault is + * reused — `password`/`srp` go unused and the wallet starts locked; the caller + * unlocks it via `KeyringController:submitPassword` before any keyring-bound + * operation. + * + * On any failure after the store is opened, the store is closed (and the wallet + * destroyed, if constructed). On a first-run failure, the on-disk database is + * also removed so a retry does not latch onto an orphaned partial vault. + * + * @param config - Wallet configuration. + * @param config.databasePath - Path to the SQLite database file (or + * `':memory:'` for ephemeral use). + * @param config.password - The wallet password. + * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). + * @param config.log - Optional logger for persistence-write and teardown + * failures. Without it, failures fall back to `console.error` (which a detached + * daemon's `stdio: 'ignore'` discards). + * @returns The `Wallet` and a `dispose` handle that tears it down. + */ +export async function createWallet({ + databasePath, + password, + srp, + log, +}: { + databasePath: string; + password: string; + srp: string; + log?: (message: string) => void; +}): Promise { + const logFn = log ?? ((message: string): void => console.error(message)); + const store = new KeyValueStore(databasePath); + let wallet: Wallet | undefined; + let unsubscribe: (() => void) | undefined; + let wasFirstRun = false; + + try { + const state = await loadPersistedState(store, logFn); + wasFirstRun = !hasPersistedKeyring(state); + + wallet = new Wallet({ state, instanceOptions: buildInstanceOptions() }); + // `wallet.messenger` is typed `Readonly`, but persistence must register + // (and later remove) subscriptions on it. + unsubscribe = subscribeToChanges( + wallet.messenger as RootMessenger, + wallet.controllerMetadata, + store, + logFn, + ); + + if (wasFirstRun) { + await importSecretRecoveryPhrase(wallet, password, srp); + } + + let disposePromise: Promise | undefined; + return { + wallet, + dispose: async () => + (disposePromise ??= teardown(unsubscribe, wallet, store, logFn)), + }; + } catch (error) { + await teardown(unsubscribe, wallet, store, logFn); + + if (wasFirstRun && databasePath !== IN_MEMORY_DATABASE_PATH) { + // Best-effort cleanup of the on-disk SQLite files (main, WAL, SHM) so a + // partially-persisted KeyringController vault cannot mislead the next run + // into skipping SRP import. Covers in-process failures only — a crash + // (SIGKILL/power loss) mid-import leaves the vault on disk. + await Promise.all( + [databasePath, `${databasePath}-wal`, `${databasePath}-shm`].map( + (path) => + rm(path, { force: true }).catch((rmError: unknown) => { + logFn( + `Failed to remove ${path} during first-run cleanup: ${String(rmError)}`, + ); + }), + ), + ); + } + + throw error; + } +} + +/** + * Load persisted controller state, filtered to currently persist-flagged + * properties. + * + * `loadState` filters against the live controller metadata, but that metadata + * is only knowable from a constructed `Wallet` — and its output is what seeds + * the real wallet. So this constructs a short-lived probe purely to read the + * metadata, then tears it down. + * + * TODO: drop the probe once `@metamask/wallet` exposes controller metadata + * without constructing a `Wallet`. + * + * @param store - The key-value store to read from. + * @param logFn - Logger for a probe-teardown failure. + * @returns The filtered persisted state, suitable for the `Wallet` `state` + * option. + */ +async function loadPersistedState( + store: KeyValueStore, + logFn: (message: string) => void, +): Promise>> { + const probe = new Wallet({ instanceOptions: buildInstanceOptions() }); + try { + return loadState(store, probe.controllerMetadata); + } finally { + await probe.destroy().catch((error: unknown) => { + logFn(`Metadata probe destroy failed: ${String(error)}`); + }); + } +} + +/** + * Tear down a wallet and its store in persistence-safe order: stop the + * subscription, destroy the wallet, then close the store. Each step is + * best-effort; a failure is logged and the remaining steps still run. + * + * @param unsubscribe - The persistence-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, + wallet: Wallet | undefined, + store: KeyValueStore, + logFn: (message: string) => void, +): Promise { + if (unsubscribe) { + try { + unsubscribe(); + } catch (error) { + logFn(`Persistence unsubscribe failed during teardown: ${String(error)}`); + } + } + if (wallet) { + try { + await wallet.destroy(); + } catch (error) { + logFn(`wallet.destroy() failed during teardown: ${String(error)}`); + } + } + try { + store.close(); + } catch (error) { + logFn(`store.close() failed during teardown: ${String(error)}`); + } +} + +/** + * Determine whether the loaded state already contains a keyring vault. + * + * The KeyringController persists its `vault` once an SRP has been imported, so + * its presence indicates that first-run setup completed before. + * + * @param state - The state loaded from the key-value store. + * @returns True if a KeyringController vault string is present. + */ +function hasPersistedKeyring( + state: Record>, +): boolean { + return typeof state.KeyringController?.vault === 'string'; +} diff --git a/packages/wallet-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json index 9c2e2b623e..934a11754a 100644 --- a/packages/wallet-cli/tsconfig.build.json +++ b/packages/wallet-cli/tsconfig.build.json @@ -7,6 +7,8 @@ }, "references": [ { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, + { "path": "../storage-service/tsconfig.build.json" }, { "path": "../wallet/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json index f648b01038..c75b54d488 100644 --- a/packages/wallet-cli/tsconfig.json +++ b/packages/wallet-cli/tsconfig.json @@ -5,6 +5,8 @@ }, "references": [ { "path": "../base-controller/tsconfig.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.json" }, + { "path": "../storage-service/tsconfig.json" }, { "path": "../wallet/tsconfig.json" } ], "include": ["../../types", "./bin", "./src"] diff --git a/yarn.lock b/yarn.lock index 858979fbeb..3de993eb7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8855,7 +8855,9 @@ __metadata: "@inquirer/confirm": "npm:^6.0.11" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/storage-service": "npm:^1.0.2" "@metamask/utils": "npm:^11.11.0" "@metamask/wallet": "npm:^4.0.0" "@oclif/core": "npm:^4.10.5"