diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 14a12f19d2..14bd3fa9dc 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -5014,7 +5014,7 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: {}, }), ).rejects.toThrow( - 'A snap must request at least one of the following permissions: endowment:rpc, endowment:transaction-insight, endowment:cronjob, endowment:name-lookup, endowment:lifecycle-hooks, endowment:keyring, endowment:page-home, endowment:signature-insight.', + 'A snap must request at least one of the following permissions: endowment:rpc, endowment:transaction-insight, endowment:cronjob, endowment:name-lookup, endowment:lifecycle-hooks, endowment:keyring, endowment:page-home, endowment:signature-insight, endowment:protocol.', ); controller.destroy(); diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json index a60ace0c3e..fb77c776d9 100644 --- a/packages/snaps-execution-environments/coverage.json +++ b/packages/snaps-execution-environments/coverage.json @@ -1,5 +1,5 @@ { - "branches": 80, + "branches": 80.13, "functions": 90.06, "lines": 90.77, "statements": 90.15 diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts index cddf7bbfde..65679d57ba 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -1449,6 +1449,39 @@ describe('BaseSnapExecutor', () => { }); }); + it('supports onProtocolRequest export', async () => { + const CODE = ` + module.exports.onProtocolRequest = ({ request }) => request.params[0]; + `; + + const executor = new TestSnapExecutor(); + await executor.executeSnap(1, MOCK_SNAP_ID, CODE, []); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'OK', + }); + + await executor.writeCommand({ + jsonrpc: '2.0', + id: 2, + method: 'snapRpc', + params: [ + MOCK_SNAP_ID, + HandlerType.OnProtocolRequest, + MOCK_ORIGIN, + { jsonrpc: '2.0', method: 'foo', params: ['bar'] }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + id: 2, + jsonrpc: '2.0', + result: 'bar', + }); + }); + it('supports onHomePage export', async () => { const CODE = ` module.exports.onHomePage = () => ({ content: { type: 'panel', children: [] }}); diff --git a/packages/snaps-execution-environments/src/common/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts index 32d073f71f..43c32ac8a9 100644 --- a/packages/snaps-execution-environments/src/common/commands.ts +++ b/packages/snaps-execution-environments/src/common/commands.ts @@ -76,6 +76,7 @@ export function getHandlerArguments( } case HandlerType.OnRpcRequest: case HandlerType.OnKeyringRequest: + case HandlerType.OnProtocolRequest: return { origin, request }; case HandlerType.OnCronjob: diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index a00a3ec4a2..3c7c4cd280 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,7 +10,7 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 91.21, + branches: 90.95, functions: 96.96, lines: 97.51, statements: 96.97, diff --git a/packages/snaps-rpc-methods/src/endowments/enum.ts b/packages/snaps-rpc-methods/src/endowments/enum.ts index f0d1577c6f..87e844f4b3 100644 --- a/packages/snaps-rpc-methods/src/endowments/enum.ts +++ b/packages/snaps-rpc-methods/src/endowments/enum.ts @@ -10,4 +10,5 @@ export enum SnapEndowments { LifecycleHooks = 'endowment:lifecycle-hooks', Keyring = 'endowment:keyring', HomePage = 'endowment:page-home', + Protocol = 'endowment:protocol', } diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index faa0c8fe06..708af633ff 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -26,6 +26,7 @@ import { nameLookupEndowmentBuilder, } from './name-lookup'; import { networkAccessEndowmentBuilder } from './network-access'; +import { getProtocolCaveatMapper, protocolEndowmentBuilder } from './protocol'; import { getRpcCaveatMapper, rpcCaveatSpecifications, @@ -55,6 +56,7 @@ export const endowmentPermissionBuilders = { [nameLookupEndowmentBuilder.targetName]: nameLookupEndowmentBuilder, [lifecycleHooksEndowmentBuilder.targetName]: lifecycleHooksEndowmentBuilder, [keyringEndowmentBuilder.targetName]: keyringEndowmentBuilder, + [protocolEndowmentBuilder.targetName]: protocolEndowmentBuilder, [homePageEndowmentBuilder.targetName]: homePageEndowmentBuilder, [signatureInsightEndowmentBuilder.targetName]: signatureInsightEndowmentBuilder, @@ -88,6 +90,9 @@ export const endowmentCaveatMappers: Record< [keyringEndowmentBuilder.targetName]: createMaxRequestTimeMapper( getKeyringCaveatMapper, ), + [protocolEndowmentBuilder.targetName]: createMaxRequestTimeMapper( + getProtocolCaveatMapper, + ), [signatureInsightEndowmentBuilder.targetName]: createMaxRequestTimeMapper( getSignatureInsightCaveatMapper, ), @@ -106,6 +111,7 @@ export const handlerEndowments: Record = { [HandlerType.OnKeyringRequest]: keyringEndowmentBuilder.targetName, [HandlerType.OnHomePage]: homePageEndowmentBuilder.targetName, [HandlerType.OnSignature]: signatureInsightEndowmentBuilder.targetName, + [HandlerType.OnProtocolRequest]: protocolEndowmentBuilder.targetName, [HandlerType.OnUserInput]: null, }; @@ -117,3 +123,8 @@ export { getChainIdsCaveat, getLookupMatchersCaveat } from './name-lookup'; export { getKeyringCaveatOrigins } from './keyring'; export { getMaxRequestTimeCaveat } from './caveats'; export { getCronjobCaveatJobs } from './cronjob'; +export { + getProtocolCaveatChainIds, + getProtocolCaveatOrigins, + getProtocolCaveatRpcMethods, +} from './protocol'; diff --git a/packages/snaps-rpc-methods/src/endowments/protocol.test.ts b/packages/snaps-rpc-methods/src/endowments/protocol.test.ts new file mode 100644 index 0000000000..8dc8893fbc --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/protocol.test.ts @@ -0,0 +1,131 @@ +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; + +import { SnapEndowments } from './enum'; +import { + getProtocolCaveatChainIds, + getProtocolCaveatMapper, + getProtocolCaveatOrigins, + protocolEndowmentBuilder, +} from './protocol'; + +describe('endowment:protocol', () => { + it('builds the expected permission specification', () => { + const specification = protocolEndowmentBuilder.specificationBuilder({}); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetName: SnapEndowments.Protocol, + endowmentGetter: expect.any(Function), + allowedCaveats: [ + SnapCaveatType.KeyringOrigin, + SnapCaveatType.ChainIds, + SnapCaveatType.MaxRequestTime, + ], + subjectTypes: [SubjectType.Snap], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeUndefined(); + }); + + describe('validator', () => { + it('throws if the caveat is not a "keyringOrigin" and "chainIds"', () => { + const specification = protocolEndowmentBuilder.specificationBuilder({}); + + expect(() => + specification.validator({ + // @ts-expect-error Missing other required permission types. + caveats: undefined, + }), + ).toThrow('Expected the following caveats: "keyringOrigin", "chainIds".'); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [{ type: 'foo', value: 'bar' }], + }), + ).toThrow( + 'Expected the following caveats: "keyringOrigin", "chainIds", "maxRequestTime", received "foo".', + ); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [ + { type: 'keyringOrigin', value: { allowedOrgins: ['foo.com'] } }, + ], + }), + ).toThrow('Expected the following caveats: "keyringOrigin", "chainIds".'); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [ + { type: 'keyringOrigin', value: { allowedOrgins: ['foo.com'] } }, + { type: 'keyringOrigin', value: { allowedOrgins: ['bar.com'] } }, + ], + }), + ).toThrow('Duplicate caveats are not allowed.'); + }); + }); +}); + +describe('getProtocolCaveatMapper', () => { + it('maps a value to a caveat', () => { + expect( + getProtocolCaveatMapper({ + allowedOrigins: ['foo.com'], + chains: ['bip122:000000000019d6689c085ae165831e93'], + }), + ).toStrictEqual({ + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: ['bip122:000000000019d6689c085ae165831e93'], + }, + { + type: SnapCaveatType.KeyringOrigin, + value: { allowedOrigins: ['foo.com'] }, + }, + ], + }); + }); + + it('returns null if the input is null', () => { + expect(getProtocolCaveatMapper(null)).toStrictEqual({ + caveats: null, + }); + }); +}); + +describe('getProtocolCaveatOrigins', () => { + it('returns the origins from the caveat', () => { + expect( + // @ts-expect-error Missing other required permission types. + getProtocolCaveatOrigins({ + caveats: [ + { + type: SnapCaveatType.KeyringOrigin, + value: { allowedOrigins: ['foo.com'] }, + }, + ], + }), + ).toStrictEqual({ allowedOrigins: ['foo.com'] }); + }); +}); + +describe('getProtocolCaveatChainIds', () => { + it('returns the chain ids from the caveat', () => { + expect( + // @ts-expect-error Missing other required permission types. + getProtocolCaveatChainIds({ + caveats: [ + { + type: SnapCaveatType.ChainIds, + value: ['bip122:000000000019d6689c085ae165831e93'], + }, + ], + }), + ).toStrictEqual(['bip122:000000000019d6689c085ae165831e93']); + }); +}); diff --git a/packages/snaps-rpc-methods/src/endowments/protocol.ts b/packages/snaps-rpc-methods/src/endowments/protocol.ts new file mode 100644 index 0000000000..30600b14f8 --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/protocol.ts @@ -0,0 +1,199 @@ +import type { + Caveat, + CaveatConstraint, + CaveatSpecificationConstraint, + EndowmentGetterParams, + PermissionConstraint, + PermissionSpecificationBuilder, + PermissionValidatorConstraint, + ValidPermissionSpecification, +} from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { KeyringOrigins } from '@metamask/snaps-utils'; +import { + ProtocolRpcMethodsStruct, + SnapCaveatType, +} from '@metamask/snaps-utils'; +import type { Json, NonEmptyArray } from '@metamask/utils'; +import { + assertStruct, + hasProperty, + isObject, + isPlainObject, +} from '@metamask/utils'; + +import { createGenericPermissionValidator } from './caveats'; +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.Protocol; + +type ProtocolEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof permissionName; + endowmentGetter: (_options?: EndowmentGetterParams) => undefined; + allowedCaveats: Readonly> | null; + validator: PermissionValidatorConstraint; + subjectTypes: readonly SubjectType[]; +}>; + +/** + * `endowment:protocol` returns nothing; it is intended to be used as a flag + * by the client to detect whether the Snap supports the Protocol API. + * + * @param _builderOptions - Optional specification builder options. + * @returns The specification for the accounts chain endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + any, + ProtocolEndowmentSpecification +> = (_builderOptions?: unknown) => { + return { + permissionType: PermissionType.Endowment, + targetName: permissionName, + allowedCaveats: [ + SnapCaveatType.KeyringOrigin, + SnapCaveatType.ChainIds, + SnapCaveatType.SnapRpcMethods, + SnapCaveatType.MaxRequestTime, + ], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => undefined, + validator: createGenericPermissionValidator([ + { type: SnapCaveatType.KeyringOrigin }, + { type: SnapCaveatType.ChainIds }, + { type: SnapCaveatType.SnapRpcMethods }, + { type: SnapCaveatType.MaxRequestTime, optional: true }, + ]), + subjectTypes: [SubjectType.Snap], + }; +}; + +export const protocolEndowmentBuilder = Object.freeze({ + targetName: permissionName, + specificationBuilder, +} as const); + +/** + * Map a raw value from the `initialPermissions` to a caveat specification. + * Note that this function does not do any validation, that's handled by the + * PermissionsController when the permission is requested. + * + * @param value - The raw value from the `initialPermissions`. + * @returns The caveat specification. + */ +export function getProtocolCaveatMapper( + value: Json, +): Pick { + if (!value || !isObject(value) || Object.keys(value).length === 0) { + return { caveats: null }; + } + + const caveats = []; + + if (value.chains) { + caveats.push({ + type: SnapCaveatType.ChainIds, + value: value.chains, + }); + } + + if (value.allowedOrigins) { + caveats.push({ + type: SnapCaveatType.KeyringOrigin, + value: { allowedOrigins: value.allowedOrigins }, + }); + } + + if (value.methods) { + caveats.push({ + type: SnapCaveatType.SnapRpcMethods, + value: value.methods, + }); + } + + return { caveats: caveats as NonEmptyArray }; +} + +/** + * Getter function to get the {@link KeyringOrigins} caveat value from a + * permission. + * + * @param permission - The permission to get the caveat value from. + * @returns The caveat value. + */ +export function getProtocolCaveatOrigins( + permission?: PermissionConstraint, +): KeyringOrigins | null { + const caveat = permission?.caveats?.find( + (permCaveat) => permCaveat.type === SnapCaveatType.KeyringOrigin, + ) as Caveat | undefined; + + return caveat ? caveat.value : null; +} + +/** + * Getter function to get the {@link ChainIds} caveat value from a + * permission. + * + * @param permission - The permission to get the caveat value from. + * @returns The caveat value. + */ +export function getProtocolCaveatChainIds( + permission?: PermissionConstraint, +): string[] | null { + const caveat = permission?.caveats?.find( + (permCaveat) => permCaveat.type === SnapCaveatType.ChainIds, + ) as Caveat | undefined; + + return caveat ? caveat.value : null; +} + +/** + * Getter function to get the {@link SnapRpcMethods} caveat value from a + * permission. + * + * @param permission - The permission to get the caveat value from. + * @returns The caveat value. + */ +export function getProtocolCaveatRpcMethods( + permission?: PermissionConstraint, +): string[] | null { + const caveat = permission?.caveats?.find( + (permCaveat) => permCaveat.type === SnapCaveatType.SnapRpcMethods, + ) as Caveat | undefined; + + return caveat ? caveat.value : null; +} + +/** + * Validates the type of the caveat value. + * + * @param caveat - The caveat to validate. + * @throws If the caveat value is invalid. + */ +function validateCaveat(caveat: Caveat): void { + if (!hasProperty(caveat, 'value') || !isPlainObject(caveat)) { + throw rpcErrors.invalidParams({ + message: 'Expected a plain object.', + }); + } + + const { value } = caveat; + assertStruct( + value, + ProtocolRpcMethodsStruct, + 'Invalid RPC methods specified', + rpcErrors.invalidParams, + ); +} + +export const protocolCaveatSpecifications: Record< + SnapCaveatType.SnapRpcMethods, + CaveatSpecificationConstraint +> = { + [SnapCaveatType.SnapRpcMethods]: Object.freeze({ + type: SnapCaveatType.SnapRpcMethods, + validator: (caveat: Caveat) => validateCaveat(caveat), + }), +}; diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index b0b707364b..43fbee7eea 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -5,6 +5,7 @@ import { getFileHandler } from './getFile'; import { getInterfaceStateHandler } from './getInterfaceState'; import { getSnapsHandler } from './getSnaps'; import { invokeKeyringHandler } from './invokeKeyring'; +import { invokeProtocolSnapHandler } from './invokeProtocolSnap'; import { invokeSnapSugarHandler } from './invokeSnapSugar'; import { requestSnapsHandler } from './requestSnaps'; import { updateInterfaceHandler } from './updateInterface'; @@ -16,6 +17,7 @@ export const methodHandlers = { wallet_requestSnaps: requestSnapsHandler, wallet_invokeSnap: invokeSnapSugarHandler, wallet_invokeKeyring: invokeKeyringHandler, + wallet_invokeProtocolSnap: invokeProtocolSnapHandler, snap_getClientStatus: getClientStatusHandler, snap_getFile: getFileHandler, snap_createInterface: createInterfaceHandler, diff --git a/packages/snaps-rpc-methods/src/permitted/invokeProtocolSnap.test.ts b/packages/snaps-rpc-methods/src/permitted/invokeProtocolSnap.test.ts new file mode 100644 index 0000000000..531e4b1bcd --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/invokeProtocolSnap.test.ts @@ -0,0 +1,445 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { InvokeAccountsSnapParams } from '@metamask/snaps-sdk'; +import { AccountsSnapHandlerType } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { MOCK_SNAP_ID, getSnapObject } from '@metamask/snaps-utils/test-utils'; +import type { + JsonRpcRequest, + JsonRpcFailure, + JsonRpcSuccess, +} from '@metamask/utils'; + +import { invokeProtocolSnapHandler } from './invokeProtocolSnap'; + +describe('wallet_invokeProtocolSnap', () => { + describe('invokeProtocolSnapHandler', () => { + it('has the expected shape', () => { + expect(invokeProtocolSnapHandler).toMatchObject({ + methodNames: ['wallet_invokeAccountsSnap'], + implementation: expect.any(Function), + hookNames: { + getSnap: true, + handleSnapRpcRequest: true, + hasPermission: true, + }, + }); + }); + }); + describe('invokeProtocolSnapImplementation', () => { + // Mirror the origin middleware in the extension + const createOriginMiddleware = + (origin: string) => + (request: any, _response: unknown, next: () => void, _end: unknown) => { + request.origin = origin; + next(); + }; + + const getMockHooks = () => + ({ + getSnap: jest.fn(), + hasPermission: jest.fn(), + handleSnapRpcRequest: jest.fn(), + getAllowedKeyringMethods: jest.fn(), + } as any); + + it('invokes the snap and returns the result', async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => true); + hooks.getSnap.mockImplementation(() => getSnapObject()); + hooks.handleSnapRpcRequest.mockImplementation(() => 'bar'); + hooks.getAllowedKeyringMethods.mockImplementation(() => ['foo']); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { method: 'foo' }, + type: AccountsSnapHandlerType.Keyring, + }, + })) as JsonRpcSuccess; + + expect(response.result).toBe('bar'); + expect(hooks.handleSnapRpcRequest).toHaveBeenCalledWith({ + handler: HandlerType.OnKeyringRequest, + request: { method: 'foo' }, + snapId: MOCK_SNAP_ID, + }); + }); + + it('invokes the snap and returns the result when using the chain type', async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => true); + hooks.getSnap.mockImplementation(() => getSnapObject()); + hooks.handleSnapRpcRequest.mockImplementation(() => 'bar'); + hooks.getAllowedKeyringMethods.mockImplementation(() => ['foo']); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { method: 'foo' }, + type: AccountsSnapHandlerType.Chain, + }, + })) as JsonRpcSuccess; + + expect(response.result).toBe('bar'); + expect(hooks.handleSnapRpcRequest).toHaveBeenCalledWith({ + handler: HandlerType.OnAccountsChainRequest, + request: { method: 'foo' }, + snapId: MOCK_SNAP_ID, + }); + }); + + it('fails if invoking the snap fails', async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => true); + hooks.getSnap.mockImplementation(() => getSnapObject()); + hooks.handleSnapRpcRequest.mockImplementation(() => { + throw rpcErrors.invalidRequest({ + message: 'Failed to start snap.', + }); + }); + hooks.getAllowedKeyringMethods.mockImplementation(() => ['foo']); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { method: 'foo' }, + type: AccountsSnapHandlerType.Keyring, + }, + })) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + ...rpcErrors + .invalidRequest({ + message: 'Failed to start snap.', + }) + .serialize(), + stack: expect.any(String), + }); + }); + + it('fails if the type is invalid', async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => true); + hooks.getSnap.mockImplementation(() => getSnapObject()); + hooks.handleSnapRpcRequest.mockImplementation(() => 'bar'); + hooks.getAllowedKeyringMethods.mockImplementation(() => ['foo']); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { method: 'foo' }, + type: 'baz', + }, + })) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + ...rpcErrors + .invalidParams({ + message: 'The handler type "baz" does not exist.', + }) + .serialize(), + stack: expect.any(String), + }); + }); + + it('fails if origin is not authorized to call the method', async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => true); + hooks.getSnap.mockImplementation(() => getSnapObject()); + hooks.handleSnapRpcRequest.mockImplementation(() => { + throw rpcErrors.invalidRequest({ + message: 'Failed to start snap.', + }); + }); + hooks.getAllowedKeyringMethods.mockImplementation(() => ['bar']); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { method: 'foo' }, + type: AccountsSnapHandlerType.Keyring, + }, + })) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + ...rpcErrors + .invalidRequest({ + message: + 'The origin "metamask.io" is not allowed to invoke the method "foo".', + }) + .serialize(), + stack: expect.any(String), + }); + }); + + it("fails if the request doesn't have a method name", async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => true); + hooks.getSnap.mockImplementation(() => getSnapObject()); + hooks.handleSnapRpcRequest.mockImplementation(() => { + throw rpcErrors.invalidRequest({ + message: 'Failed to start snap.', + }); + }); + hooks.getAllowedKeyringMethods.mockImplementation(() => ['foo']); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { something: 'foo' }, + type: AccountsSnapHandlerType.Keyring, + }, + })) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + ...rpcErrors + .invalidRequest({ message: 'The request must have a method.' }) + .serialize(), + stack: expect.any(String), + }); + }); + + it("fails if the origin doesn't have the permission to invoke the snap", async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => false); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { method: 'foo' }, + type: AccountsSnapHandlerType.Keyring, + }, + })) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + ...rpcErrors + .invalidRequest({ + message: `The snap "${MOCK_SNAP_ID}" is not connected to "metamask.io". Please connect before invoking the snap.`, + }) + .serialize(), + stack: expect.any(String), + }); + }); + + it('fails if the snap is not installed', async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + hooks.hasPermission.mockImplementation(() => true); + hooks.getSnap.mockImplementation(() => undefined); + + const engine = new JsonRpcEngine(); + engine.push(createOriginMiddleware('metamask.io')); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + snapId: MOCK_SNAP_ID, + request: { method: 'foo' }, + type: AccountsSnapHandlerType.Keyring, + }, + })) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + ...rpcErrors + .invalidRequest({ + message: `The snap "${MOCK_SNAP_ID}" is not installed. Please install it first, before invoking the snap.`, + }) + .serialize(), + stack: expect.any(String), + }); + }); + + it('fails if params are invalid', async () => { + const { implementation } = invokeProtocolSnapHandler; + + const hooks = getMockHooks(); + + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => { + const result = implementation( + req as JsonRpcRequest, + res, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = (await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_invokeAccountsSnap', + params: { + request: [], + }, + })) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + ...rpcErrors + .invalidParams({ + message: 'Must specify a valid snap ID.', + }) + .serialize(), + stack: expect.any(String), + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/invokeProtocolSnap.ts b/packages/snaps-rpc-methods/src/permitted/invokeProtocolSnap.ts new file mode 100644 index 0000000000..c20a458a4d --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/invokeProtocolSnap.ts @@ -0,0 +1,161 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { + PermissionConstraint, + PermittedHandlerExport, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + InvokeProtocolSnapParams, + InvokeProtocolSnapResult, +} from '@metamask/snaps-sdk'; +import type { Snap, SnapRpcHookArgs } from '@metamask/snaps-utils'; +import { HandlerType, WALLET_SNAP_PERMISSION_KEY } from '@metamask/snaps-utils'; +import type { PendingJsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; +import { hasProperty, type Json } from '@metamask/utils'; + +import { SnapEndowments, getProtocolCaveatRpcMethods } from '../endowments'; +import type { MethodHooksObject } from '../utils'; +import { getValidatedParams } from './invokeSnapSugar'; + +const hookNames: MethodHooksObject = { + hasPermission: true, + handleSnapRpcRequest: true, + getSnap: true, + getSubjectPermissions: true, +}; + +/** + * `wallet_invokeProtocolSnap` invokes a protocol Snap. + */ +export const invokeProtocolSnapHandler: PermittedHandlerExport< + InvokeProtocolSnapHooks, + InvokeProtocolSnapParams, + InvokeProtocolSnapResult +> = { + methodNames: ['wallet_invokeProtocolSnap'], + implementation: invokeProtocolSnapImplementation, + hookNames, +}; + +export type InvokeProtocolSnapHooks = { + hasPermission: (permissionName: string) => boolean; + + handleSnapRpcRequest: ({ + snapId, + handler, + request, + }: Omit & { snapId: string }) => Promise; + + getSnap: (snapId: string) => Snap | undefined; + + getSubjectPermissions: ( + origin: string, + ) => Promise | undefined>; +}; + +// These RPC methods are assumed to be supported by the Snap as they are required. +// TODO: Verify these. +const DEFAULT_RPC_METHODS = [ + 'chain_listTransactions', + 'chain_estimateFees', + 'chain_getBalances', + 'chain_broadcastTransaction', + 'chain_getDataForTransaction', + 'chain_getTransactionStatus', +]; + +/** + * The `wallet_invokeProtocolSnap` method implementation. + * Invokes onKeyringRequest or onProtocolRequest if the snap requested is installed and connected to the dapp. + * + * @param req - The JSON-RPC request object. + * @param res - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.handleSnapRpcRequest - Invokes a snap with a given RPC request. + * @param hooks.hasPermission - Checks whether a given origin has a given permission. + * @param hooks.getSnap - Gets information about a given snap. + * @param hooks.getSubjectPermissions - Get the permissions for a specific origin. + * @returns Nothing. + */ +async function invokeProtocolSnapImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { + handleSnapRpcRequest, + hasPermission, + getSnap, + getSubjectPermissions, + }: InvokeProtocolSnapHooks, +): Promise { + let params: InvokeProtocolSnapParams; + try { + params = getValidatedParams(req.params) as InvokeProtocolSnapParams; + } catch (error) { + return end(error); + } + + // We expect the MM middleware stack to always add the origin to requests + const { origin } = req as JsonRpcRequest & { origin: string }; + const { snapId, request } = params; + + if (!origin || !hasPermission(WALLET_SNAP_PERMISSION_KEY)) { + return end( + rpcErrors.invalidRequest({ + message: `The snap "${snapId}" is not connected to "${origin}". Please connect before invoking the snap.`, + }), + ); + } + + if (!getSnap(snapId)) { + return end( + rpcErrors.invalidRequest({ + message: `The snap "${snapId}" is not installed. Please install it first, before invoking the snap.`, + }), + ); + } + + if (!hasProperty(request, 'method') || typeof request.method !== 'string') { + return end( + rpcErrors.invalidRequest({ + message: 'The request must have a method.', + }), + ); + } + + const snapPermissions = await getSubjectPermissions(snapId); + + const protocolPermission = snapPermissions?.[SnapEndowments.Protocol]; + + const additionalRpcMethods = + getProtocolCaveatRpcMethods(protocolPermission) ?? []; + + const supportedMethods = [...DEFAULT_RPC_METHODS, ...additionalRpcMethods]; + + const isSigningRequest = !supportedMethods.includes(request.method); + + const wrappedRequest = isSigningRequest + ? // TODO: Other params + { method: 'keyring_submitRequest', params: { request } } + : request; + + const handler = isSigningRequest + ? HandlerType.OnKeyringRequest + : HandlerType.OnProtocolRequest; + + try { + res.result = (await handleSnapRpcRequest({ + snapId, + request: wrappedRequest, + handler, + })) as Json; + } catch (error) { + return end(error); + } + + return end(); +} diff --git a/packages/snaps-sdk/src/types/handlers/protocol.ts b/packages/snaps-sdk/src/types/handlers/protocol.ts new file mode 100644 index 0000000000..8d17c915c7 --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/protocol.ts @@ -0,0 +1,23 @@ +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; + +/** + * The `onProtocolRequest` handler, which is called when a Snap receives a + * accounts chain request. + * + * Note that using this handler requires the `endowment:protocol` permission. + * + * @param args - The request arguments. + * @param args.origin - The origin of the request. This can be the ID of another + * Snap, or the URL of a website. + * @param args.request - The keyring request sent to the Snap. This includes + * the method name and parameters. + * @returns The response to the keyring request. This must be a + * JSON-serializable value. In order to return an error, throw a `SnapError` + * instead. + */ +export type OnProtocolRequestHandler< + Params extends JsonRpcParams = JsonRpcParams, +> = (args: { + origin: string; + request: JsonRpcRequest; +}) => Promise; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index d3722bc9d6..3fa14eef5c 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -9,6 +9,7 @@ export * from './get-file'; export * from './get-interface-state'; export * from './get-locale'; export * from './get-snaps'; +export * from './invoke-protocol-snap'; export * from './invoke-keyring'; export * from './invoke-snap'; export * from './manage-accounts'; diff --git a/packages/snaps-sdk/src/types/methods/invoke-protocol-snap.ts b/packages/snaps-sdk/src/types/methods/invoke-protocol-snap.ts new file mode 100644 index 0000000000..674fad1101 --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/invoke-protocol-snap.ts @@ -0,0 +1,18 @@ +import type { Json } from '@metamask/utils'; + +import type { InvokeSnapParams } from './invoke-snap'; + +/** + * The request parameters for the `wallet_invokeProtocolSnap` method. + * + * @property snapId - The ID of the snap to invoke. + * @property request - The JSON-RPC request to send to the snap. + * @property type - The type of handler to invoke. + */ +export type InvokeProtocolSnapParams = InvokeSnapParams; + +/** + * The result returned by the `wallet_invokeProtocolSnap` method, which is the result + * returned by the Snap. + */ +export type InvokeProtocolSnapResult = Json; diff --git a/packages/snaps-utils/src/caveats.ts b/packages/snaps-utils/src/caveats.ts index 61bd80910e..7599e95b4c 100644 --- a/packages/snaps-utils/src/caveats.ts +++ b/packages/snaps-utils/src/caveats.ts @@ -53,4 +53,9 @@ export enum SnapCaveatType { * Caveat specifying the max request time for a handler endowment. */ MaxRequestTime = 'maxRequestTime', + + /** + * Caveat specifying a list of RPC methods serviced by an endowment. + */ + SnapRpcMethods = 'snapRpcMethods', } diff --git a/packages/snaps-utils/src/handler-types.ts b/packages/snaps-utils/src/handler-types.ts index 642c201fec..ac3592520d 100644 --- a/packages/snaps-utils/src/handler-types.ts +++ b/packages/snaps-utils/src/handler-types.ts @@ -7,6 +7,7 @@ export enum HandlerType { OnUpdate = 'onUpdate', OnNameLookup = 'onNameLookup', OnKeyringRequest = 'onKeyringRequest', + OnProtocolRequest = 'OnProtocolRequest', OnHomePage = 'onHomePage', OnUserInput = 'onUserInput', } diff --git a/packages/snaps-utils/src/handlers.ts b/packages/snaps-utils/src/handlers.ts index 1353982705..e98856f88e 100644 --- a/packages/snaps-utils/src/handlers.ts +++ b/packages/snaps-utils/src/handlers.ts @@ -82,6 +82,13 @@ export const SNAP_EXPORTS = { return typeof snapExport === 'function'; }, }, + [HandlerType.OnAccountsChainRequest]: { + type: HandlerType.OnAccountsChainRequest, + required: true, + validator: (snapExport: unknown): snapExport is OnUserInputHandler => { + return typeof snapExport === 'function'; + }, + }, [HandlerType.OnHomePage]: { type: HandlerType.OnHomePage, required: true, diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index 3064c2daf1..2e5c9d7658 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -180,6 +180,8 @@ export const HandlerCaveatsStruct = object({ maxRequestTime: optional(MaxRequestTimeStruct), }); +export const ProtocolRpcMethodsStruct = array(string()); + export type HandlerCaveats = Infer; export const EmptyObjectStruct = object({}) as unknown as Struct< @@ -199,6 +201,15 @@ export const PermissionsStruct: Describe = type({ 'endowment:keyring': optional( assign(HandlerCaveatsStruct, KeyringOriginsStruct), ), + 'endowment:protocol': optional( + assign( + HandlerCaveatsStruct, + assign( + KeyringOriginsStruct, + object({ chains: ChainIdsStruct, methods: ProtocolRpcMethodsStruct }), + ), + ), + ), 'endowment:lifecycle-hooks': optional(HandlerCaveatsStruct), 'endowment:name-lookup': optional( assign(