Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BREAKING: Refactor RPC method params and add tests #922

Merged
merged 3 commits into from
Nov 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/rpc-methods/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ module.exports = deepmerge(baseConfig, {
coveragePathIgnorePatterns: ['./src/index.ts'],
coverageThreshold: {
global: {
branches: 82.85,
functions: 80.85,
lines: 59.77,
statements: 59.77,
branches: 87.01,
functions: 87.03,
lines: 78.96,
statements: 78.96,
},
},
testTimeout: 2500,
Expand Down
90 changes: 90 additions & 0 deletions packages/rpc-methods/src/permitted/invokeSnapSugar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
JsonRpcEngineEndCallback,
JsonRpcEngineNextCallback,
JsonRpcRequest,
} from '@metamask/types';
import { ethErrors } from 'eth-rpc-errors';
import { getValidatedParams, invokeSnapSugar } from './invokeSnapSugar';

describe('wallet_invokeSnap', () => {
describe('invokeSnapSugar', () => {
it('invokes snap with next()', () => {
const req: JsonRpcRequest<unknown> = {
id: 'some-id',
jsonrpc: '2.0',
method: 'wallet_invokeSnap',
params: {
snapId: 'npm:@metamask/example-snap',
request: { method: 'hello' },
},
};
const _res: unknown = {};
const next: JsonRpcEngineNextCallback = jest
.fn()
.mockResolvedValueOnce(true);
const end: JsonRpcEngineEndCallback = jest.fn();

invokeSnapSugar(req, _res, next, end);

expect(next).toHaveBeenCalledTimes(1);
});

it('ends with an error if params are invalid', () => {
const req: JsonRpcRequest<unknown> = {
id: 'some-id',
jsonrpc: '2.0',
method: 'wallet_invokeSnap',
params: {
snapId: undefined,
request: [],
},
};
const _res: unknown = {};
const next: JsonRpcEngineNextCallback = jest
.fn()
.mockResolvedValueOnce(true);
const end: JsonRpcEngineEndCallback = jest.fn();

invokeSnapSugar(req, _res, next, end);

expect(end).toHaveBeenCalledWith(
ethErrors.rpc.invalidParams({
message: 'Must specify a valid snap ID.',
}),
);
expect(next).not.toHaveBeenCalled();
});
});

describe('getValidatedParams', () => {
it('throws an error if the params is not an object', () => {
expect(() => getValidatedParams([])).toThrow(
'Expected params to be a single object.',
);
});

it('throws an error if the snap ID is missing from params object', () => {
expect(() =>
getValidatedParams({ snapId: undefined, request: {} }),
).toThrow('Must specify a valid snap ID.');
});

it('throws an error if the request is not a plain object', () => {
expect(() =>
getValidatedParams({ snapId: 'snap-id', request: [] }),
).toThrow('Expected request to be a single object.');
});

it('returns valid parameters', () => {
expect(
getValidatedParams({
snapId: 'npm:@metamask/example-snap',
request: { method: 'hello' },
}),
).toStrictEqual({
snapId: 'npm:@metamask/example-snap',
request: { method: 'hello' },
});
});
});
});
57 changes: 44 additions & 13 deletions packages/rpc-methods/src/permitted/invokeSnapSugar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
import { isObject } from '@metamask/utils';
import { SNAP_PREFIX } from '@metamask/snaps-utils';

export type InvokeSnapSugarArgs = {
snapId: string;
request: JsonRpcRequest<unknown>;
};

/**
* `wallet_invokeSnap` attempts to invoke an RPC method of the specified Snap.
*/
Expand All @@ -33,25 +38,51 @@ export const invokeSnapSugarHandler: PermittedHandlerExport<
* @returns Nothing.
* @throws If the params are invalid.
*/
function invokeSnapSugar(
export function invokeSnapSugar(
req: JsonRpcRequest<unknown>,
_res: unknown,
next: JsonRpcEngineNextCallback,
end: JsonRpcEngineEndCallback,
): void {
if (
!Array.isArray(req.params) ||
typeof req.params[0] !== 'string' ||
!isObject(req.params[1])
) {
return end(
ethErrors.rpc.invalidParams({
message: 'Must specify a string snap ID and a plain request object.',
}),
);
let params: InvokeSnapSugarArgs;
try {
params = getValidatedParams(req.params);
} catch (error) {
return end(error);
}

req.method = SNAP_PREFIX + req.params[0];
req.params = [req.params[1]];
req.method = SNAP_PREFIX + params.snapId;
req.params = params.request;
return next();
}

/**
* Validates the wallet_invokeSnap method `params` and returns them cast to the correct
* type. Throws if validation fails.
*
* @param params - The unvalidated params object from the method request.
* @returns The validated method parameter object.
*/
export function getValidatedParams(params: unknown): InvokeSnapSugarArgs {
if (!isObject(params)) {
throw ethErrors.rpc.invalidParams({
message: 'Expected params to be a single object.',
});
}

const { snapId, request } = params;

if (!snapId || typeof snapId !== 'string' || snapId === '') {
throw ethErrors.rpc.invalidParams({
message: 'Must specify a valid snap ID.',
});
}

if (!isObject(request)) {
throw ethErrors.rpc.invalidParams({
message: 'Expected request to be a single object.',
});
}

return params as InvokeSnapSugarArgs;
}
6 changes: 3 additions & 3 deletions packages/rpc-methods/src/restricted/invokeSnap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('implementation', () => {
await implementation({
context: { origin: MOCK_ORIGIN },
method: `wallet_snap_${MOCK_SNAP_ID}`,
params: [{ method: 'foo', params: {} }],
params: { method: 'foo', params: {} },
});

expect(hooks.getSnap).toHaveBeenCalledTimes(1);
Expand All @@ -73,7 +73,7 @@ describe('implementation', () => {
implementation({
context: { origin: MOCK_ORIGIN },
method: `wallet_snap_${MOCK_SNAP_ID}`,
params: [{ method: 'foo', params: {} }],
params: { method: 'foo', params: {} },
}),
).rejects.toThrow(
`The snap "${MOCK_SNAP_ID}" is not installed. This is a bug, please report it.`,
Expand All @@ -92,7 +92,7 @@ describe('implementation', () => {
implementation({
context: { origin: MOCK_ORIGIN },
method: `wallet_snap_${MOCK_SNAP_ID}`,
params: [{}],
params: {},
}),
).rejects.toThrow(
'Must specify a valid JSON-RPC request object as single parameter.',
Expand Down
7 changes: 3 additions & 4 deletions packages/rpc-methods/src/restricted/invokeSnap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,11 @@ export function getInvokeSnapImplementation({
handleSnapRpcRequest,
}: InvokeSnapMethodHooks) {
return async function invokeSnap(
options: RestrictedMethodOptions<[Record<string, Json>]>,
options: RestrictedMethodOptions<Record<string, Json>>,
): Promise<Json> {
const { params = [], method, context } = options;
const rawRequest = params[0];
const { params = {}, method, context } = options;

const request = { jsonrpc: '2.0', id: nanoid(), ...rawRequest };
const request = { jsonrpc: '2.0', id: nanoid(), ...params };

if (!isJsonRpcRequest(request)) {
throw ethErrors.rpc.invalidParams({
Expand Down