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/knip.config.ts b/knip.config.ts index 94cae7510e..5fb3f5d7cb 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -157,6 +157,12 @@ const config: KnipConfig = { 'packages/user-operation-controller': { ignoreDependencies: ['immer'], }, + 'packages/wallet-cli': { + // `tsx` is the dev-mode loader: it's referenced only as a `node --import` + // argument string (in `daemon-spawn`'s source-entry path and `bin/dev`), + // never as a traceable import, so knip can't see it. + ignoreDependencies: ['tsx'], + }, 'packages/wallet-framework-docs': { // Source lives under `site/` instead of `src/`; tell knip to scan it // so the type imports of `@docusaurus/*` / `prism-react-renderer` in diff --git a/packages/wallet-cli/ARCHITECTURE.md b/packages/wallet-cli/ARCHITECTURE.md new file mode 100644 index 0000000000..5c1f9dd87f --- /dev/null +++ b/packages/wallet-cli/ARCHITECTURE.md @@ -0,0 +1,35 @@ +# Architecture + +`@metamask/wallet-cli` is an [oclif](https://oclif.io)-based `mm` CLI that drives a long-lived **daemon** process. The daemon holds an unlocked `@metamask/wallet` `Wallet` in memory and exposes its messenger over a per-user Unix socket, so short-lived CLI invocations can query and mutate wallet state without re-importing the secret recovery phrase on every call. + +## Layers + +| Layer | Files | Responsibility | +| :------------------ | :-------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Commands** | `src/commands/daemon/{start,stop,status,purge,call}.ts` | Thin oclif command classes. Parse flags/args and call into the daemon layers; no business logic of their own. | +| **Daemon entry** | `src/daemon/daemon-entry.ts` | The detached process's `main`. Claims the singleton slot (PID file + socket), builds the wallet, serves RPC, and tears everything down on signal. | +| **Spawn/lifecycle** | `src/daemon/{daemon-spawn,stop-daemon,utils,paths}.ts` | Start the daemon as a detached child and wait for readiness; stop it (RPC → SIGTERM → SIGKILL); resolve per-user state paths; PID/signal helpers. | +| **Factory** | `src/daemon/wallet-factory.ts` | Construct the `Wallet` with the instance options wired on `@metamask/wallet`, seed it from persisted state, and import the SRP on first run. Returns a `dispose` teardown handle. | +| **Persistence** | `src/persistence/{KeyValueStore,persistence}.ts` | SQLite-backed key-value store for controller state; load persisted (persist-flagged) state and subscribe the store to subsequent state changes. | +| **Transport** | `src/daemon/{rpc-socket-server,daemon-client,socket-line,prompts}.ts` | JSON-RPC over a Unix socket: line-framed read/write, a server hosting the handler map, and a client (`sendCommand`/`pingDaemon`). | + +## Flow + +``` +mm daemon start + └─ ensureDaemon (daemon-spawn) # ping existing socket; if absent, spawn detached daemon-entry + └─ daemon-entry.main + ├─ claimDaemonSlot # refuse to clobber a live sibling; write PID file (wx) + ├─ createWallet (wallet-factory) + │ ├─ KeyValueStore + loadState (persistence) # rehydrate persist-flagged state + │ ├─ new Wallet({ state, instanceOptions }) + │ └─ subscribeToChanges # write controller state through to SQLite + └─ startRpcSocketServer # bind 0o600 socket; expose { getStatus, call } + +mm daemon call/status/stop/purge + └─ daemon-client (sendCommand/pingDaemon) / stop-daemon # talk to the socket +``` + +`call` dispatches an arbitrary `Controller:method` action onto the wallet messenger. Access control is purely filesystem-based: the data directory is `0o700` and the socket is `0o600`, so only the owning user can connect — there is no in-process auth beyond that. + +Teardown (signal handler or `dispose`) runs in persistence-safe order: stop the state-change subscription → `await wallet.destroy()` → close the store, then remove the PID file and socket. diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index ba55b4dae8..6b62f0707c 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 +- Add the `mm daemon` command suite (`start`, `stop`, `status`, `purge`, and `call`) for running the wallet daemon and dispatching messenger actions over its socket ([#9255](https://github.com/MetaMask/core/pull/9255)) +- 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/README.md b/packages/wallet-cli/README.md index ddb25c377d..29ac82275b 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -10,6 +10,33 @@ or `npm install @metamask/wallet-cli` +## Usage + +The CLI drives a long-lived background **daemon** that holds an unlocked `@metamask/wallet` in memory and exposes its messenger over a per-user Unix socket. All commands live under the `mm daemon` topic; run `mm --help` (or `mm daemon --help`) for the full reference. + +Start the daemon (flags may also be supplied as the `INFURA_PROJECT_ID`, `MM_WALLET_PASSWORD`, and `MM_WALLET_SRP` environment variables — preferred for secrets): + +```sh +mm daemon start --infura-project-id --password --srp "" +``` + +Call any messenger action on the running wallet (positional JSON array for arguments, optional `--timeout`): + +```sh +mm daemon call AccountsController:listAccounts +mm daemon call KeyringController:getState --timeout 10000 +``` + +Inspect or tear it down: + +```sh +mm daemon status # PID + uptime, or why the socket is unreachable +mm daemon stop # graceful shutdown (falls back to SIGTERM/SIGKILL) +mm daemon purge # stop, then delete all daemon state files (--force to skip the prompt) +``` + +State (socket, PID file, log, and the SQLite database) lives in the per-user oclif data directory; override it with `MM_DATA_DIR`. + ## Troubleshooting ### Rebuilding `better-sqlite3` diff --git a/packages/wallet-cli/bin/dev.cmd b/packages/wallet-cli/bin/dev.cmd new file mode 100644 index 0000000000..ee0f58bfe9 --- /dev/null +++ b/packages/wallet-cli/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node --loader tsx --no-warnings=ExperimentalWarning "%~dp0\dev" %* diff --git a/packages/wallet-cli/bin/dev.mjs b/packages/wallet-cli/bin/dev.mjs new file mode 100755 index 0000000000..857ef9d96b --- /dev/null +++ b/packages/wallet-cli/bin/dev.mjs @@ -0,0 +1,3 @@ +import { execute } from '@oclif/core'; + +await execute({ development: true, dir: import.meta.url }); diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index ca08413339..aacc39c51a 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -14,6 +14,11 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // The test harness in `src/test/` is exercised by the command tests but + // not all of its error/edge branches are worth driving directly — it's + // production code's test infrastructure, not production code itself. + coveragePathIgnorePatterns: ['.*/src/test/.*'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 49eb5dcc33..72bf41a7e9 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", @@ -60,6 +62,7 @@ "deepmerge": "^4.2.2", "jest": "^29.7.0", "ts-jest": "^29.2.5", + "tsx": "^4.20.5", "typescript": "~5.3.3" }, "oclif": { diff --git a/packages/wallet-cli/src/commands/daemon/call.test.ts b/packages/wallet-cli/src/commands/daemon/call.test.ts new file mode 100644 index 0000000000..c43ec9c91b --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.test.ts @@ -0,0 +1,153 @@ +import { sendCommand } from '../../daemon/daemon-client'; +import { runCommand } from '../../test/run-command'; +import DaemonCall from './call'; + +jest.mock('../../daemon/daemon-client'); + +const mockSendCommand = jest.mocked(sendCommand); + +const ACTION = 'AccountsController:listAccounts'; + +describe('daemon call', () => { + beforeEach(() => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { accounts: [] }, + }); + }); + + it('dispatches the action with no params', async () => { + await runCommand(DaemonCall, [ACTION]); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'call', + params: [ACTION], + }), + ); + }); + + it('parses a JSON-array params argument and appends to the params list', async () => { + await runCommand(DaemonCall, [ACTION, '["arg1", 42]']); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'call', + params: [ACTION, 'arg1', 42], + }), + ); + }); + + it('errors when params is not valid JSON', async () => { + const { error } = await runCommand(DaemonCall, [ACTION, 'not json']); + + expect(error?.message).toContain('valid JSON'); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it('errors when params is JSON but not an array', async () => { + const { error } = await runCommand(DaemonCall, [ACTION, '{"foo":1}']); + + expect(error?.message).toContain('JSON array'); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it('passes the timeout flag through to sendCommand', async () => { + await runCommand(DaemonCall, [ACTION, '--timeout', '5000']); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 5000 }), + ); + }); + + it('returns a friendly hint when the daemon is not running (ENOENT)', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('no such file'), { code: 'ENOENT' }), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('returns a friendly hint when the daemon refuses the connection', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('surfaces other socket errors with the raw message', async () => { + mockSendCommand.mockRejectedValue(new Error('Socket read timed out')); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Socket read timed out'); + }); + + it('handles non-Error throws from sendCommand', async () => { + mockSendCommand.mockImplementation(async () => + // Simulate a non-Error throw (the call site does not narrow to Error). + Promise.reject('string error' as unknown as Error), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('string error'); + }); + + it('errors when the daemon returns a JSON-RPC failure response', async () => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { code: -32601, message: 'Method not found' }, + }); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Method not found'); + expect(error?.message).toContain('-32601'); + }); + + it('writes pretty JSON to a TTY stdout', async () => { + const original = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + const { stdout } = await runCommand(DaemonCall, [ACTION]); + + expect(stdout).toContain('"accounts": []'); + + Object.defineProperty(process.stdout, 'isTTY', { + value: original, + configurable: true, + }); + }); + + it('writes compact JSON to a piped (non-TTY) stdout', async () => { + const original = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + const writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + await runCommand(DaemonCall, [ACTION]); + + expect(writeSpy).toHaveBeenCalledWith('{"accounts":[]}\n'); + + writeSpy.mockRestore(); + Object.defineProperty(process.stdout, 'isTTY', { + value: original, + configurable: true, + }); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/call.ts b/packages/wallet-cli/src/commands/daemon/call.ts new file mode 100644 index 0000000000..37a902439b --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -0,0 +1,95 @@ +import type { Json } from '@metamask/utils'; +import { isJsonRpcFailure } from '@metamask/utils'; +import { Args, Command, Flags } from '@oclif/core'; + +import { sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { isErrorWithCode } from '../../daemon/utils'; + +export default class DaemonCall extends Command { + static override description = 'Call a messenger action on the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon call AccountsController:listAccounts', + '<%= config.bin %> daemon call NetworkController:getState', + '<%= config.bin %> daemon call KeyringController:getState --timeout 10000', + ]; + + static override args = { + action: Args.string({ + description: + 'The messenger action name (e.g. AccountsController:listAccounts)', + required: true, + }), + params: Args.string({ + description: 'JSON-encoded arguments array (e.g. \'["arg1", "arg2"]\')', + required: false, + }), + }; + + static override flags = { + timeout: Flags.integer({ + char: 't', + description: 'Response timeout in milliseconds', + required: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(DaemonCall); + const { action } = args; + const timeoutMs = flags.timeout; + + // The daemon's `call` RPC expects `[action, ...args]`. `JSON.parse` returns + // `unknown`, but anything it produces is structurally `Json`, so we cast to + // `Json[]` once we've confirmed the parsed payload is an array. + const rpcParams: Json[] = [action]; + if (args.params !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(args.params); + } catch { + this.error('params must be valid JSON'); + } + + if (!Array.isArray(parsed)) { + this.error('params must be a JSON array'); + } + + rpcParams.push(...(parsed as Json[])); + } + + const { socketPath } = getDaemonPaths(this.config.dataDir); + + let response; + try { + response = await sendCommand({ + socketPath, + method: 'call', + params: rpcParams, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + } catch (error) { + if ( + isErrorWithCode(error, 'ENOENT') || + isErrorWithCode(error, 'ECONNREFUSED') + ) { + this.error('Daemon is not running. Start it with `mm daemon start`.'); + } + this.error(error instanceof Error ? error.message : String(error)); + } + + if (isJsonRpcFailure(response)) { + this.error( + `${response.error.message} (code ${String(response.error.code)})`, + ); + } + + const isTTY = process.stdout.isTTY ?? false; + if (isTTY) { + this.log(JSON.stringify(response.result, null, 2)); + } else { + process.stdout.write(`${JSON.stringify(response.result)}\n`); + } + } +} diff --git a/packages/wallet-cli/src/commands/daemon/purge.test.ts b/packages/wallet-cli/src/commands/daemon/purge.test.ts new file mode 100644 index 0000000000..7189981e0c --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.test.ts @@ -0,0 +1,118 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon } from '../../daemon/daemon-client'; +import { confirmPurge } from '../../daemon/prompts'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { runCommand } from '../../test/run-command'; +import DaemonPurge from './purge'; + +jest.mock('node:fs/promises'); +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/stop-daemon'); +jest.mock('../../daemon/prompts'); + +const inquirerConfirm = jest.mocked(confirmPurge); +const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockStopDaemon = jest.mocked(stopDaemon); + +describe('daemon purge', () => { + beforeEach(() => { + mockRm.mockResolvedValue(undefined); + inquirerConfirm.mockResolvedValue(true); + }); + + it('aborts without prompting nor deleting when the user declines', async () => { + inquirerConfirm.mockResolvedValue(false); + + const { stdout, error } = await runCommand(DaemonPurge); + + expect(stdout).toContain('Aborted.'); + expect(mockStopDaemon).not.toHaveBeenCalled(); + expect(mockRm).not.toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('--force skips the confirmation prompt', async () => { + mockStopDaemon.mockResolvedValue(true); + + await runCommand(DaemonPurge, ['--force']); + + expect(inquirerConfirm).not.toHaveBeenCalled(); + expect(mockStopDaemon).toHaveBeenCalled(); + }); + + it('threads its log callback into stopDaemon so daemon-side messages reach the user', async () => { + mockStopDaemon.mockImplementation(async (_socket, _pid, log) => { + log?.('Stopping daemon...'); + return true; + }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('Stopping daemon...'); + }); + + it('refuses to delete state when the daemon is still responsive', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + + const { error } = await runCommand(DaemonPurge, ['--force']); + + expect(error?.message).toContain('still responsive'); + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('proceeds to delete the whitelist when stopDaemon returns false but the daemon is unresponsive', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'refused', + error: new Error('refused'), + }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('Could not confirm clean shutdown'); + expect(stdout).toContain('All daemon state deleted.'); + }); + + it('proceeds when stopDaemon returns false and the daemon is absent', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('All daemon state deleted.'); + }); + + it('deletes only the whitelisted daemon files (not the entire dataDir)', async () => { + mockStopDaemon.mockResolvedValue(true); + + await runCommand(DaemonPurge, ['--force']); + + const removed = mockRm.mock.calls.map(([path]) => path); + // The whitelist is built from getDaemonPaths(dataDir).{pidPath,socketPath, + // logPath,dbPath} plus the SQLite WAL/SHM sidecars. None of them is the + // dataDir itself. + expect(removed).not.toContain('/tmp/mm-cli-test-data'); + expect(removed.some((path) => String(path).endsWith('daemon.pid'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('daemon.sock'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('daemon.log'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db-wal'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db-shm'))).toBe( + true, + ); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts new file mode 100644 index 0000000000..cd25660bf8 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -0,0 +1,81 @@ +import { Command, Flags } from '@oclif/core'; +import { rm } from 'node:fs/promises'; + +import { pingDaemon } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { confirmPurge } from '../../daemon/prompts'; +import { stopDaemon } from '../../daemon/stop-daemon'; + +export default class DaemonPurge extends Command { + static override description = + 'Stop the daemon and delete all daemon state files'; + + static override examples = [ + '<%= config.bin %> daemon purge', + '<%= config.bin %> daemon purge --force', + ]; + + static override flags = { + force: Flags.boolean({ + char: 'f', + description: 'Skip confirmation prompt', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DaemonPurge); + + if (!flags.force) { + const confirmed = await confirmPurge(); + if (!confirmed) { + this.log('Aborted.'); + return; + } + } + + const paths = getDaemonPaths(this.config.dataDir); + + const stopped = await stopDaemon( + paths.socketPath, + paths.pidPath, + (message) => this.log(message), + ); + + if (!stopped) { + // `stopDaemon` returns false when it couldn't be sure the daemon + // exited — typically because the socket exists but the daemon never + // responded to signals, or because the PID file is stale and the + // socket is orphan. Purge is the user's escape hatch for exactly + // these states, so as long as the daemon is not currently + // responsive, we proceed with the deletion the user already + // confirmed. If the daemon IS responsive, we still refuse — that + // would risk corrupting live state. + const ping = await pingDaemon(paths.socketPath); + if (ping.status === 'responsive') { + this.error( + 'Refusing to delete state while the daemon is still responsive.', + ); + } + this.log( + 'Could not confirm clean shutdown; proceeding to delete state anyway.', + ); + } + + // Whitelist only the daemon-owned files rather than rm'ing the entire + // oclif dataDir, which may hold unrelated state (caches, oclif lock + // files, future config). `force: true` makes ENOENT a no-op for any + // file already removed by stopDaemon. + await Promise.all( + [ + paths.pidPath, + paths.socketPath, + paths.logPath, + paths.dbPath, + `${paths.dbPath}-wal`, + `${paths.dbPath}-shm`, + ].map(async (path) => rm(path, { force: true })), + ); + + this.log('All daemon state deleted.'); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/start.test.ts b/packages/wallet-cli/src/commands/daemon/start.test.ts new file mode 100644 index 0000000000..68e044010e --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.test.ts @@ -0,0 +1,41 @@ +import { ensureDaemon } from '../../daemon/daemon-spawn'; +import { runCommand } from '../../test/run-command'; +import DaemonStart from './start'; + +jest.mock('../../daemon/daemon-spawn'); + +const mockEnsureDaemon = jest.mocked(ensureDaemon); + +const FLAGS = [ + '--infura-project-id', + 'key', + '--password', + 'pw', + '--srp', + 'phrase', +]; + +describe('daemon start', () => { + it('reports the socket path on a fresh start', async () => { + mockEnsureDaemon.mockResolvedValue({ + state: 'started', + socketPath: '/tmp/daemon.sock', + }); + + const { stdout } = await runCommand(DaemonStart, FLAGS); + + expect(stdout).toContain('Daemon running. Socket: /tmp/daemon.sock'); + }); + + it('warns that flags were not applied when a daemon is already running', async () => { + mockEnsureDaemon.mockResolvedValue({ + state: 'already-running', + socketPath: '/tmp/daemon.sock', + }); + + const { stdout } = await runCommand(DaemonStart, FLAGS); + + expect(stdout).toContain('Daemon already running'); + expect(stdout).toContain('not applied'); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts new file mode 100644 index 0000000000..fb14e29f4d --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -0,0 +1,56 @@ +import { Command, Flags } from '@oclif/core'; + +import { ensureDaemon } from '../../daemon/daemon-spawn'; + +export default class DaemonStart extends Command { + static override description = 'Start the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon start --infura-project-id --password --srp ', + 'INFURA_PROJECT_ID= MM_WALLET_PASSWORD= MM_WALLET_SRP= <%= config.bin %> daemon start', + ]; + + static override flags = { + 'infura-project-id': Flags.string({ + description: 'Infura project ID for network access', + env: 'INFURA_PROJECT_ID', + required: true, + }), + password: Flags.string({ + description: + 'Wallet password (testing only — use MM_WALLET_PASSWORD env var in production)', + env: 'MM_WALLET_PASSWORD', + required: true, + }), + srp: Flags.string({ + description: + 'Secret recovery phrase (testing only — use MM_WALLET_SRP env var in production)', + env: 'MM_WALLET_SRP', + required: true, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DaemonStart); + const infuraProjectId = flags['infura-project-id']; + const { password, srp } = flags; + + const { state, socketPath } = await ensureDaemon({ + dataDir: this.config.dataDir, + infuraProjectId, + password, + srp, + packageRoot: this.config.root, + }); + + if (state === 'already-running') { + this.log( + `Daemon already running. Socket: ${socketPath}. ` + + `The provided flags were not applied; run \`mm daemon stop\` and start again to change them.`, + ); + return; + } + + this.log(`Daemon running. Socket: ${socketPath}`); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/status.test.ts b/packages/wallet-cli/src/commands/daemon/status.test.ts new file mode 100644 index 0000000000..47f18a9cfd --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.test.ts @@ -0,0 +1,118 @@ +import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; +import { readPidFile } from '../../daemon/utils'; +import { runCommand } from '../../test/run-command'; +import DaemonStatus from './status'; + +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/utils'); + +const mockPingDaemon = jest.mocked(pingDaemon); +const mockSendCommand = jest.mocked(sendCommand); +const mockReadPidFile = jest.mocked(readPidFile); + +describe('daemon status', () => { + beforeEach(() => { + mockReadPidFile.mockResolvedValue(12345); + }); + + it('reports "not running" when the socket is absent', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('Daemon is not running.'); + }); + + it('reports the unreachable reason and recorded PID', async () => { + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'refused', + error: new Error('ECONNREFUSED'), + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('is unresponsive'); + expect(stdout).toContain('recorded PID: 12345'); + expect(stdout).toContain('[refused]'); + expect(stdout).toContain('ECONNREFUSED'); + }); + + it('omits the PID suffix when no PID file is present', async () => { + mockReadPidFile.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'timeout', + error: new Error('timeout'), + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('is unresponsive'); + expect(stdout).not.toContain('recorded PID'); + }); + + it('reports a status-request failure distinctly from an absent or unreachable daemon', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockRejectedValue(new Error('timed out')); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('responsive but status request failed'); + expect(stdout).toContain('timed out'); + }); + + it('reports a JSON-RPC error response from getStatus', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { code: -32000, message: 'boom' }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('returned an error: boom'); + }); + + it('reports PID and uptime on success', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { pid: 12345, uptime: 42 }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('PID: 12345, Uptime: 42s'); + }); + + it('warns when the local PID file disagrees with the running daemon', async () => { + mockReadPidFile.mockResolvedValue(99999); + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { pid: 12345, uptime: 42 }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain( + 'Warning: PID file records 99999 but the running daemon reports 12345', + ); + }); + + it('handles non-Error throws from sendCommand', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockImplementation(async () => + // Simulate a non-Error throw (the call site does not narrow to Error). + Promise.reject('string error' as unknown as Error), + ); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('status request failed: string error'); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts new file mode 100644 index 0000000000..69611aad50 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -0,0 +1,66 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Command } from '@oclif/core'; + +import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import type { DaemonStatusInfo } from '../../daemon/types'; +import { readPidFile } from '../../daemon/utils'; + +export default class DaemonStatus extends Command { + static override description = 'Check the status of the wallet daemon'; + + static override examples = ['<%= config.bin %> daemon status']; + + public async run(): Promise { + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + const pid = await readPidFile(pidPath); + const ping = await pingDaemon(socketPath); + + if (ping.status === 'absent') { + this.log('Daemon is not running.'); + return; + } + + if (ping.status === 'unreachable') { + const pidPart = pid === undefined ? '' : ` (recorded PID: ${pid})`; + this.log( + `Daemon socket exists at ${socketPath} but is unresponsive${pidPart} ` + + `[${ping.reason}]: ${ping.error.message}`, + ); + return; + } + + let response; + try { + response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 5_000, + }); + } catch (error) { + this.log( + `Daemon socket is responsive but status request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + if (isJsonRpcFailure(response)) { + this.log( + `Daemon is running but returned an error: ${response.error.message}`, + ); + return; + } + + const status = response.result as DaemonStatusInfo; + if (pid !== undefined && pid !== status.pid) { + this.log( + `Warning: PID file records ${pid} but the running daemon reports ${status.pid}. ` + + `Local state may be stale; consider \`mm daemon purge\`.`, + ); + } + this.log( + `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, + ); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/stop.test.ts b/packages/wallet-cli/src/commands/daemon/stop.test.ts new file mode 100644 index 0000000000..b30d7152fa --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.test.ts @@ -0,0 +1,71 @@ +import { pingDaemon } from '../../daemon/daemon-client'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { readPidFile } from '../../daemon/utils'; +import { runCommand } from '../../test/run-command'; +import DaemonStop from './stop'; + +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/stop-daemon'); +jest.mock('../../daemon/utils'); + +const mockPingDaemon = jest.mocked(pingDaemon); +const mockStopDaemon = jest.mocked(stopDaemon); +const mockReadPidFile = jest.mocked(readPidFile); + +describe('daemon stop', () => { + it('reports "Daemon is not running" when no socket and no PID file exist', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + mockReadPidFile.mockResolvedValue(undefined); + + const { stdout, error } = await runCommand(DaemonStop); + + expect(stdout).toContain('Daemon is not running.'); + expect(mockStopDaemon).not.toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('invokes stopDaemon when a PID file exists even if the socket is absent', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(true); + + const { error } = await runCommand(DaemonStop); + + expect(mockStopDaemon).toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('invokes stopDaemon when the socket is responsive', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(true); + + const { error } = await runCommand(DaemonStop); + + expect(mockStopDaemon).toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('threads its log callback into stopDaemon so daemon-side messages reach the user', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockImplementation(async (_socket, _pid, log) => { + log?.('Stopping daemon...'); + return true; + }); + + const { stdout } = await runCommand(DaemonStop); + + expect(stdout).toContain('Stopping daemon...'); + }); + + it('errors when stopDaemon returns false', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(false); + + const { error } = await runCommand(DaemonStop); + + expect(error?.message).toContain('did not stop within timeout'); + }); +}); diff --git a/packages/wallet-cli/src/commands/daemon/stop.ts b/packages/wallet-cli/src/commands/daemon/stop.ts new file mode 100644 index 0000000000..62a85588fa --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -0,0 +1,33 @@ +import { Command } from '@oclif/core'; + +import { pingDaemon } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { readPidFile } from '../../daemon/utils'; + +export default class DaemonStop extends Command { + static override description = 'Stop the wallet daemon'; + + static override examples = ['<%= config.bin %> daemon stop']; + + public async run(): Promise { + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + // Distinguish "no daemon was running" from "successful stop" so the user + // gets feedback either way. + const ping = await pingDaemon(socketPath); + const pid = await readPidFile(pidPath); + if (ping.status === 'absent' && pid === undefined) { + this.log('Daemon is not running.'); + return; + } + + const stopped = await stopDaemon(socketPath, pidPath, (message) => + this.log(message), + ); + + if (!stopped) { + this.error('Daemon did not stop within timeout.'); + } + } +} 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..50188c61b2 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -0,0 +1,782 @@ +import { mkdirSync } from 'node:fs'; +import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'; + +import { pingDaemon } from './daemon-client'; +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'); +jest.mock('node:fs/promises'); +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 mockMkdirSync = jest.mocked(mkdirSync); +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); + 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(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { + recursive: true, + mode: 0o700, + }); + 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..9f8cc01cb3 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -0,0 +1,299 @@ +import type { Json } from '@metamask/utils'; +import type { Wallet } from '@metamask/wallet'; +import { mkdirSync } from 'node:fs'; +import { appendFile, chmod, readFile, rm, writeFile } from 'node:fs/promises'; + +import { pingDaemon } from './daemon-client'; +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'); + } + + // 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. + mkdirSync(dataDir, { recursive: true, mode: 0o700 }); + await chmod(dataDir, 0o700); + + 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/wallet-factory.e2e.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts new file mode 100644 index 0000000000..88c4f52b60 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts @@ -0,0 +1,33 @@ +import { createWallet } from './wallet-factory'; + +// Unlike the unit test alongside it, this does NOT mock `@metamask/wallet`, so +// it covers what the mocked test can't: that `buildInstanceOptions` produces a +// working real `Wallet`. Safe to run offline — `Wallet` construction never +// triggers RemoteFeatureFlagController's fetch (only `updateRemoteFeatureFlags` +// does). + +const TEST_SRP = 'test test test test test test test test test test test ball'; +const TEST_PASSWORD = 'testpass'; + +describe('createWallet (real Wallet, in-memory)', () => { + it('constructs an unlocked wallet on first run and dispatches messenger actions', async () => { + const { wallet, dispose } = await createWallet({ + databasePath: ':memory:', + password: TEST_PASSWORD, + srp: TEST_SRP, + log: () => undefined, + }); + + try { + expect(wallet.state.KeyringController?.isUnlocked).toBe(true); + + // `listAccounts` resolves synchronously; awaiting a non-thenable trips + // `@typescript-eslint/await-thenable`. + const accounts = wallet.messenger.call('AccountsController:listAccounts'); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toMatch(/^0x[0-9a-fA-F]{40}$/u); + } finally { + await dispose(); + } + }, 30_000); +}); 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..d3fc3c0d1a --- /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), + undefined, + ); + + 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..6bd7749e09 --- /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, + log, + ); + + 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/src/test/run-command.ts b/packages/wallet-cli/src/test/run-command.ts new file mode 100644 index 0000000000..de100c3ca2 --- /dev/null +++ b/packages/wallet-cli/src/test/run-command.ts @@ -0,0 +1,86 @@ +import { Command } from '@oclif/core'; +import type { Config } from '@oclif/core'; +import { CLIError } from '@oclif/core/errors'; + +type CommandCtor = new ( + argv: string[], + config: Config, +) => Command & { + _run: () => Promise; +}; + +const TEST_DATA_DIR = '/tmp/mm-cli-test-data'; +const TEST_PACKAGE_ROOT = '/tmp/mm-cli-test-root'; + +/** + * Invoke an oclif command class with the given argv and return the captured + * stdout/stderr/error so tests can assert on them. + * + * Bypasses `Command.run`'s static plugin-loading path (which requires a real + * `Config.load`) by constructing the command instance directly with a + * hand-rolled `Config` and invoking the protected `_run`. Spies on the + * Command prototype so `this.log` and `this.error` go to local buffers + * instead of stdout/stderr. + * + * @param CommandClass - The command class (a subclass of `@oclif/core` Command). + * @param argv - Command-line tokens (flags + positional args). + * @returns Captured stdout, stderr, and any `this.error()` payload. + */ +export async function runCommand( + CommandClass: CommandCtor, + argv: string[] = [], +): Promise<{ + stdout: string; + stderr: string; + error: CLIError | undefined; +}> { + let stdout = ''; + let stderr = ''; + let error: CLIError | undefined; + + const fakeConfig = { + dataDir: TEST_DATA_DIR, + root: TEST_PACKAGE_ROOT, + bin: 'mm', + name: '@metamask/wallet-cli', + version: '0.0.0-test', + pjson: { name: '@metamask/wallet-cli', version: '0.0.0-test' }, + findCommand: () => undefined, + runHook: async () => ({ successes: [], failures: [] }), + scopedEnvVar: () => undefined, + scopedEnvVarKey: () => '', + scopedEnvVarKeys: () => [], + scopedEnvVarTrue: () => false, + plugins: new Map(), + flexibleTaxonomy: false, + } as unknown as Config; + + const logSpy = jest + .spyOn(Command.prototype, 'log') + .mockImplementation((message: unknown = '') => { + stdout += `${String(message)}\n`; + }); + const errorSpy = jest + .spyOn(Command.prototype, 'error') + .mockImplementation((input: string | Error) => { + const message = typeof input === 'string' ? input : input.message; + throw new CLIError(message); + }); + + try { + const instance = new CommandClass(argv, fakeConfig); + await instance._run(); + } catch (caught: unknown) { + if (caught instanceof CLIError) { + error = caught; + stderr += `${caught.message}\n`; + } else { + throw caught; + } + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + + return { stdout, stderr, error }; +} 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..a4b69389c0 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" @@ -8867,6 +8869,7 @@ __metadata: immer: "npm:^9.0.6" jest: "npm:^29.7.0" ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" bin: mm: ./bin/run.mjs