Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/wallet-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@inquirer/confirm": "^6.0.11",
"@metamask/remote-feature-flag-controller": "^4.2.0",
"@metamask/rpc-errors": "^7.0.2",
"@metamask/superstruct": "^3.1.0",
"@metamask/utils": "^11.9.0",
"@metamask/wallet": "^0.0.0",
"@oclif/core": "^4.10.5",
Expand Down
69 changes: 38 additions & 31 deletions packages/wallet-cli/src/daemon/daemon-entry.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validate } from '@metamask/superstruct';
import { mkdirSync } from 'node:fs';
import { appendFile, readFile, rm, writeFile } from 'node:fs/promises';

Expand Down Expand Up @@ -490,7 +491,7 @@ describe('daemon-entry', () => {

const callArgs = mockStartRpcSocketServer.mock.calls[0][0];
const { handlers } = callArgs;
const status = (await handlers.getStatus(null)) as {
const status = (await handlers.getStatus.run(null)) as {
pid: number;
uptime: number;
};
Expand Down Expand Up @@ -720,13 +721,18 @@ describe('daemon-entry', () => {

describe('call handler', () => {
/**
* Import the daemon entry and extract the `call` handler from the
* handlers map, along with the mock wallet for assertions.
* Import the daemon entry and extract the `call` handler definition from
* the handlers map, along with the mock wallet for assertions.
*
* @returns The call handler function and mock wallet result.
* @returns The call handler definition and mock wallet result.
*/
async function setupCallHandler(): Promise<{
callHandler: (params: unknown) => Promise<unknown>;
callHandler: {
paramsStruct: import('@metamask/superstruct').Struct<
[string, ...unknown[]]
>;
run: (params: [string, ...unknown[]]) => Promise<unknown>;
};
result: MockCreateWalletResult;
}> {
const result = createMockWallet();
Expand All @@ -736,28 +742,33 @@ describe('daemon-entry', () => {
await importDaemonEntry();

const callArgs = mockStartRpcSocketServer.mock.calls[0][0];
const callHandler = callArgs.handlers.call as (
params: unknown,
) => Promise<unknown>;
const callHandler = callArgs.handlers.call as unknown as {
paramsStruct: import('@metamask/superstruct').Struct<
[string, ...unknown[]]
>;
run: (params: [string, ...unknown[]]) => Promise<unknown>;
};
return { callHandler, result };
}

it('registers a call handler', async () => {
it('registers a call handler definition', async () => {
mockCreateWallet.mockResolvedValue(createMockWallet());
mockStartRpcSocketServer.mockResolvedValue(createMockHandle());

await importDaemonEntry();

const callArgs = mockStartRpcSocketServer.mock.calls[0][0];
expect(typeof callArgs.handlers.call).toBe('function');
const callDefinition = callArgs.handlers.call;
expect(callDefinition).toHaveProperty('paramsStruct');
expect(typeof callDefinition.run).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([
const callResult = await callHandler.run([
'Controller:action',
'arg1',
'arg2',
Expand All @@ -776,7 +787,7 @@ describe('daemon-entry', () => {
const mockCall = result.wallet.messenger.call as jest.Mock;
mockCall.mockReturnValue('ok');

await callHandler(['Controller:action']);
await callHandler.run(['Controller:action']);

expect(mockCall).toHaveBeenCalledWith('Controller:action');
});
Expand All @@ -786,7 +797,7 @@ describe('daemon-entry', () => {
const mockCall = result.wallet.messenger.call as jest.Mock;
mockCall.mockResolvedValue({ async: true });

const callResult = await callHandler(['Controller:asyncAction']);
const callResult = await callHandler.run(['Controller:asyncAction']);

expect(callResult).toStrictEqual({ async: true });
});
Expand All @@ -798,33 +809,29 @@ describe('daemon-entry', () => {
throw new Error('A handler for Unknown:action has not been registered');
});

await expect(callHandler(['Unknown:action'])).rejects.toThrow(
await expect(callHandler.run(['Unknown:action'])).rejects.toThrow(
'A handler for Unknown:action has not been registered',
);
});

it('throws when params is null', async () => {
it.each([
['null', null],
['empty array', []],
['non-string first element', [42]],
['non-array', { foo: 'bar' }],
])('paramsStruct rejects invalid params (%s)', async (_label, value) => {
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',
);
const [error] = validate(value, callHandler.paramsStruct);
expect(error).toBeDefined();
});

it('throws when action name is not a string', async () => {
it('paramsStruct accepts a non-empty array starting with a string', async () => {
const { callHandler } = await setupCallHandler();

await expect(callHandler([42])).rejects.toThrow(
'Expected params to be an array with an action name',
const [error] = validate(
['Controller:action', 1, 'two'],
callHandler.paramsStruct,
);
expect(error).toBeUndefined();
});
});
});
67 changes: 46 additions & 21 deletions packages/wallet-cli/src/daemon/daemon-entry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { define, literal } from '@metamask/superstruct';
import type { Json } from '@metamask/utils';
import type { Wallet } from '@metamask/wallet';
import { mkdirSync } from 'node:fs';
Expand All @@ -8,10 +9,33 @@ 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 { defineHandler } from './types';
import type {
DaemonStatusInfo,
RpcDispatcher,
RpcHandlerMap,
} from './types';
import { isErrorWithCode, isProcessAlive, readPidFile } from './utils';
import { createWallet } from './wallet-factory';

/**
* Params struct for the `call` RPC method. `params` must be a non-empty array
* whose first element is the messenger action name; remaining elements are
* positional action arguments forwarded as-is to `messenger.call`.
*/
const callParamsStruct = define<[string, ...Json[]]>('CallParams', (value) => {
if (!Array.isArray(value)) {
return 'Expected an array';
}
if (value.length === 0) {
return 'Expected a non-empty array';
}
if (typeof value[0] !== 'string') {
return 'Expected the first element to be a string action name';
}
return true;
});

const startTime = Date.now();

main().catch((error: unknown) => {
Expand Down Expand Up @@ -101,28 +125,29 @@ async function main(): Promise<void> {
}));

const constructedWallet = wallet;
// 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/socket are chmodded to 0o700/0o600 below so
// only the owning user can open them, but there is no in-process
// auth check beyond that filesystem-permission barrier. The messenger is
// strongly typed by action name; we narrow it once here to the
// RpcDispatcher shape the `call` handler needs.
const dispatch = constructedWallet.messenger.call.bind(
constructedWallet.messenger,
) as unknown as RpcDispatcher;

const handlers: RpcHandlerMap = {
getStatus: async (): Promise<DaemonStatusInfo> => ({
pid: process.pid,
uptime: Math.floor((Date.now() - startTime) / 1000),
getStatus: defineHandler(
literal(null),
async (): Promise<DaemonStatusInfo> => ({
pid: process.pid,
uptime: Math.floor((Date.now() - startTime) / 1000),
}),
),
call: defineHandler(callParamsStruct, async (params) => {
const [action, ...args] = params;
return await dispatch(action, ...args);
}),
// 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/socket are chmodded to 0o700/0o600 below 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;
},
};

handle = await startRpcSocketServer({
Expand Down
72 changes: 63 additions & 9 deletions packages/wallet-cli/src/daemon/rpc-socket-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { any, literal } from '@metamask/superstruct';
import { EventEmitter } from 'node:events';
import { unlink } from 'node:fs/promises';
import { createServer } from 'node:net';
import type { Server, Socket } from 'node:net';

import { startRpcSocketServer } from './rpc-socket-server';
import type { RpcHandlerMap } from './types';
import type { RpcHandlerDefinition, RpcHandlerMap } from './types';

/**
* Wrap a `jest.fn` as an {@link RpcHandlerDefinition} so existing tests can
* register a handler without writing out the `{ paramsStruct, run }` shape.
* Defaults to `any()` so the struct guard never rejects the test inputs.
*
* @param run - The mocked handler function.
* @returns A handler definition with an `any()` paramsStruct.
*/
function asHandler(run: jest.Mock): RpcHandlerDefinition<unknown, never> {
return {
paramsStruct: any(),
run: run as unknown as RpcHandlerDefinition<unknown, never>['run'],
};
}

jest.mock('node:fs/promises');
jest.mock('node:net');
Expand Down Expand Up @@ -172,7 +188,7 @@ describe('startRpcSocketServer', () => {
it('dispatches valid request to handler and returns result', async () => {
const { simulateConnection } = createMockServer();
const handlers: RpcHandlerMap = {
getStatus: jest.fn().mockResolvedValue({ status: 'ok' }),
getStatus: asHandler(jest.fn().mockResolvedValue({ status: 'ok' })),
};

await startRpcSocketServer({
Expand Down Expand Up @@ -200,7 +216,7 @@ describe('startRpcSocketServer', () => {
it('returns null result when handler returns undefined', async () => {
const { simulateConnection } = createMockServer();
const handlers: RpcHandlerMap = {
noop: jest.fn().mockResolvedValue(undefined),
noop: asHandler(jest.fn().mockResolvedValue(undefined)),
};

await startRpcSocketServer({
Expand Down Expand Up @@ -315,7 +331,9 @@ describe('startRpcSocketServer', () => {
it('returns -32603 when handler throws an Error', async () => {
const { simulateConnection } = createMockServer();
const handlers: RpcHandlerMap = {
failing: jest.fn().mockRejectedValue(new Error('handler failed')),
failing: asHandler(
jest.fn().mockRejectedValue(new Error('handler failed')),
),
};

await startRpcSocketServer({
Expand All @@ -341,7 +359,7 @@ describe('startRpcSocketServer', () => {
const { simulateConnection } = createMockServer();
const rpcError = { code: -32001, message: 'custom rpc' };
const handlers: RpcHandlerMap = {
failing: jest.fn().mockRejectedValue(rpcError),
failing: asHandler(jest.fn().mockRejectedValue(rpcError)),
};

await startRpcSocketServer({
Expand All @@ -364,7 +382,7 @@ describe('startRpcSocketServer', () => {
it('returns Internal error when handler throws a non-Error value', async () => {
const { simulateConnection } = createMockServer();
const handlers: RpcHandlerMap = {
failing: jest.fn().mockRejectedValue('string error'),
failing: asHandler(jest.fn().mockRejectedValue('string error')),
};

await startRpcSocketServer({
Expand Down Expand Up @@ -489,7 +507,7 @@ describe('startRpcSocketServer', () => {
it('accumulates partial data across multiple events', async () => {
const { simulateConnection } = createMockServer();
const handlers: RpcHandlerMap = {
test: jest.fn().mockResolvedValue('ok'),
test: asHandler(jest.fn().mockResolvedValue('ok')),
};

await startRpcSocketServer({
Expand Down Expand Up @@ -568,7 +586,7 @@ describe('startRpcSocketServer', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const handlers: RpcHandlerMap = {
bad: jest.fn().mockResolvedValue(circular),
bad: asHandler(jest.fn().mockResolvedValue(circular)),
};

await startRpcSocketServer({
Expand Down Expand Up @@ -641,10 +659,46 @@ describe('startRpcSocketServer', () => {
jest.useRealTimers();
});

it('returns -32602 when params fail the registered struct', async () => {
const { simulateConnection } = createMockServer();
const run = jest.fn();
const handlers: RpcHandlerMap = {
// Struct that only accepts the literal value `'expected'`.
strict: {
paramsStruct: literal('expected'),
run: run as unknown as RpcHandlerMap[string]['run'],
},
};

await startRpcSocketServer({
socketPath: '/tmp/test.sock',
handlers,
});

const socket = createMockSocket();
simulateConnection(socket);
sendRequest(socket, {
jsonrpc: '2.0',
id: '1',
method: 'strict',
params: ['something else'],
});

await flushPromises();

expect(getResponse(socket).error).toStrictEqual(
expect.objectContaining({
code: -32602,
message: expect.stringContaining('Invalid params for strict'),
}),
);
expect(run).not.toHaveBeenCalled();
});

it('wraps thrown object with code but no message as internal error', async () => {
const { simulateConnection } = createMockServer();
const handlers: RpcHandlerMap = {
failing: jest.fn().mockRejectedValue({ code: 42 }),
failing: asHandler(jest.fn().mockRejectedValue({ code: 42 })),
};

await startRpcSocketServer({
Expand Down
Loading
Loading