From e785683a989ab9c8bc7f9fbbbff5f1a64d1d2a18 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:32:55 -0700 Subject: [PATCH 01/25] refactor(permission-controller)!: Remove unrestricted rpc methods --- packages/permission-controller/.eslintrc.js | 5 +- packages/permission-controller/src/index.ts | 8 +- .../src/rpc-methods/getPermissions.test.ts | 74 ------- .../src/rpc-methods/getPermissions.ts | 46 ---- .../src/rpc-methods/index.ts | 16 -- .../rpc-methods/requestPermissions.test.ts | 143 ------------- .../src/rpc-methods/requestPermissions.ts | 63 ------ .../src/rpc-methods/revokePermissions.test.ts | 196 ------------------ .../src/rpc-methods/revokePermissions.ts | 79 ------- packages/permission-controller/src/utils.ts | 49 ----- 10 files changed, 2 insertions(+), 677 deletions(-) delete mode 100644 packages/permission-controller/src/rpc-methods/getPermissions.test.ts delete mode 100644 packages/permission-controller/src/rpc-methods/getPermissions.ts delete mode 100644 packages/permission-controller/src/rpc-methods/index.ts delete mode 100644 packages/permission-controller/src/rpc-methods/requestPermissions.test.ts delete mode 100644 packages/permission-controller/src/rpc-methods/requestPermissions.ts delete mode 100644 packages/permission-controller/src/rpc-methods/revokePermissions.test.ts delete mode 100644 packages/permission-controller/src/rpc-methods/revokePermissions.ts diff --git a/packages/permission-controller/.eslintrc.js b/packages/permission-controller/.eslintrc.js index 29de5a0688d..1e60b9c9bd4 100644 --- a/packages/permission-controller/.eslintrc.js +++ b/packages/permission-controller/.eslintrc.js @@ -3,10 +3,7 @@ module.exports = { overrides: [ { - files: [ - 'src/PermissionController.test.ts', - 'src/rpc-methods/revokePermissions.test.ts', - ], + files: ['src/PermissionController.test.ts'], rules: { // This is taken directly from @metamask/eslint-config-typescript@12.1.0 '@typescript-eslint/naming-convention': [ diff --git a/packages/permission-controller/src/index.ts b/packages/permission-controller/src/index.ts index 36292db1874..ae79f7cacdb 100644 --- a/packages/permission-controller/src/index.ts +++ b/packages/permission-controller/src/index.ts @@ -27,14 +27,8 @@ export { createPermissionMiddlewareV2, type PermissionMiddlewareActions, } from './permission-middleware'; -export type { - ExtractSpecifications, - HandlerMiddlewareFunction, - HookNames, - PermittedHandlerExport, -} from './utils'; +export type { ExtractSpecifications } from './utils'; export { MethodNames } from './utils'; -export * as permissionRpcMethods from './rpc-methods'; export * from './SubjectMetadataController'; export type { SubjectMetadataControllerClearStateAction, diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts b/packages/permission-controller/src/rpc-methods/getPermissions.test.ts deleted file mode 100644 index f7724f4617b..00000000000 --- a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; -import { assertIsJsonRpcSuccess } from '@metamask/utils'; - -import type { PermissionConstraint } from '../Permission'; -import { getPermissionsHandler } from './getPermissions'; - -describe('getPermissions RPC method', () => { - it('returns the values of the object returned by getPermissionsForOrigin', async () => { - const { implementation } = getPermissionsHandler; - const mockGetPermissionsForOrigin = jest.fn().mockImplementationOnce(() => { - return { a: 'a', b: 'b', c: 'c' }; - }); - - const engine = new JsonRpcEngine(); - engine.push( - ( - req: JsonRpcRequest<[]>, - res: PendingJsonRpcResponse, - next, - end, - ) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - getPermissionsForOrigin: mockGetPermissionsForOrigin, - }); - }, - ); - - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - }); - assertIsJsonRpcSuccess(response); - expect(response.result).toStrictEqual(['a', 'b', 'c']); - expect(mockGetPermissionsForOrigin).toHaveBeenCalledTimes(1); - }); - - it('returns an empty array if getPermissionsForOrigin returns a falsy value', async () => { - const { implementation } = getPermissionsHandler; - const mockGetPermissionsForOrigin = jest - .fn() - .mockImplementationOnce(() => null); - - const engine = new JsonRpcEngine(); - engine.push( - ( - req: JsonRpcRequest<[]>, - res: PendingJsonRpcResponse, - next, - end, - ) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - getPermissionsForOrigin: mockGetPermissionsForOrigin, - }); - }, - ); - - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - }); - assertIsJsonRpcSuccess(response); - expect(response.result).toStrictEqual([]); - expect(mockGetPermissionsForOrigin).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.ts b/packages/permission-controller/src/rpc-methods/getPermissions.ts deleted file mode 100644 index b7119973227..00000000000 --- a/packages/permission-controller/src/rpc-methods/getPermissions.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; -import type { PendingJsonRpcResponse } from '@metamask/utils'; - -import type { PermissionConstraint } from '../Permission'; -import type { SubjectPermissions } from '../PermissionController'; -import type { PermittedHandlerExport } from '../utils'; -import { MethodNames } from '../utils'; - -export const getPermissionsHandler: PermittedHandlerExport< - GetPermissionsHooks, - [], - PermissionConstraint[] -> = { - methodNames: [MethodNames.GetPermissions], - implementation: getPermissionsImplementation, - hookNames: { - getPermissionsForOrigin: true, - }, -}; - -export type GetPermissionsHooks = { - // This must be bound to the requesting origin. - getPermissionsForOrigin: () => SubjectPermissions; -}; - -/** - * Get Permissions implementation to be used in JsonRpcEngine middleware. - * - * @param _req - The JsonRpcEngine request - unused - * @param res - The JsonRpcEngine result object - * @param _next - JsonRpcEngine next() callback - unused - * @param end - JsonRpcEngine end() callback - * @param options - Method hooks passed to the method implementation - * @param options.getPermissionsForOrigin - The specific method hook needed for this method implementation - * @returns A promise that resolves to nothing - */ -async function getPermissionsImplementation( - _req: unknown, - res: PendingJsonRpcResponse, - _next: unknown, - end: JsonRpcEngineEndCallback, - { getPermissionsForOrigin }: GetPermissionsHooks, -): Promise { - res.result = Object.values(getPermissionsForOrigin() || {}); - return end(); -} diff --git a/packages/permission-controller/src/rpc-methods/index.ts b/packages/permission-controller/src/rpc-methods/index.ts deleted file mode 100644 index 7f13a06cd31..00000000000 --- a/packages/permission-controller/src/rpc-methods/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { GetPermissionsHooks } from './getPermissions'; -import { getPermissionsHandler } from './getPermissions'; -import type { RequestPermissionsHooks } from './requestPermissions'; -import { requestPermissionsHandler } from './requestPermissions'; -import type { RevokePermissionsHooks } from './revokePermissions'; -import { revokePermissionsHandler } from './revokePermissions'; - -export type PermittedRpcMethodHooks = RequestPermissionsHooks & - GetPermissionsHooks & - RevokePermissionsHooks; - -export const handlers = [ - requestPermissionsHandler, - getPermissionsHandler, - revokePermissionsHandler, -] as const; diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts deleted file mode 100644 index 4422ed67a7e..00000000000 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - JsonRpcEngine, - createAsyncMiddleware, -} from '@metamask/json-rpc-engine'; -import { rpcErrors, serializeError } from '@metamask/rpc-errors'; -import { - assertIsJsonRpcFailure, - assertIsJsonRpcSuccess, - hasProperty, -} from '@metamask/utils'; -import type { - PermissionConstraint, - RequestedPermissions, -} from 'src/Permission'; - -import { requestPermissionsHandler } from './requestPermissions'; - -describe('requestPermissions RPC method', () => { - it('returns the values of the object returned by requestPermissionsForOrigin', async () => { - const { implementation } = requestPermissionsHandler; - const mockRequestPermissionsForOrigin = jest - .fn() - .mockImplementationOnce(() => { - // Resolve this promise after a timeout to ensure that the function - // is awaited properly. - return new Promise((resolve) => { - setTimeout(() => { - resolve([{ a: 'a', b: 'b', c: 'c' }]); - }, 10); - }); - }); - - const engine = new JsonRpcEngine(); - engine.push<[RequestedPermissions], PermissionConstraint[]>( - (req, res, next, end) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - requestPermissionsForOrigin: mockRequestPermissionsForOrigin, - }); - }, - ); - - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - params: [{}], - }); - assertIsJsonRpcSuccess(response); - - expect(response.result).toStrictEqual(['a', 'b', 'c']); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledTimes(1); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledWith({}); - }); - - it('returns an error if requestPermissionsForOrigin rejects', async () => { - const { implementation } = requestPermissionsHandler; - const mockRequestPermissionsForOrigin = jest - .fn() - .mockImplementationOnce(async () => { - throw new Error('foo'); - }); - - const engine = new JsonRpcEngine(); - const end = (): undefined => undefined; // this won't be called - - // Pass the middleware function to createAsyncMiddleware so the error - // is catched. - engine.push<[RequestedPermissions], PermissionConstraint[]>( - createAsyncMiddleware(async (req, res, next) => - // This promise will be awaited by the createAsyncMiddleware wrapper. - // eslint-disable-next-line @typescript-eslint/no-misused-promises - implementation(req, res, next, end, { - requestPermissionsForOrigin: mockRequestPermissionsForOrigin, - }), - ), - ); - - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - params: [{}], - }); - assertIsJsonRpcFailure(response); - - expect(hasProperty(response, 'result')).toBe(false); - delete response.error.stack; - // @ts-expect-error We do expect this property to exist. - delete response.error.data.cause.stack; - const expectedError = new Error('foo'); - delete expectedError.stack; - expect(response.error).toStrictEqual( - serializeError(expectedError, { shouldIncludeStack: false }), - ); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledTimes(1); - expect(mockRequestPermissionsForOrigin).toHaveBeenCalledWith({}); - }); - - it('returns an error if the request params are invalid', async () => { - const { implementation } = requestPermissionsHandler; - const mockRequestPermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push<[RequestedPermissions], PermissionConstraint[]>( - (req, res, next, end) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - requestPermissionsForOrigin: mockRequestPermissionsForOrigin, - }); - }, - ); - - for (const invalidParams of ['foo', ['bar']]) { - const request = { - jsonrpc: '2.0', - id: 1, - method: 'arbitraryName', - params: invalidParams, - }; - - const expectedError = rpcErrors - .invalidParams({ - data: { request: { ...request } }, - }) - .serialize(); - delete expectedError.stack; - - // @ts-expect-error Intentional destructive testing - // ESLint is confused; this signature is async. - // eslint-disable-next-line @typescript-eslint/await-thenable - const response = await engine.handle(request); - assertIsJsonRpcFailure(response); - delete response.error.stack; - expect(response.error).toStrictEqual(expectedError); - expect(mockRequestPermissionsForOrigin).not.toHaveBeenCalled(); - } - }); -}); diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.ts deleted file mode 100644 index ae77a2dfae5..00000000000 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { isPlainObject } from '@metamask/controller-utils'; -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; -import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; - -import { invalidParams } from '../errors'; -import type { PermissionConstraint, RequestedPermissions } from '../Permission'; -import type { PermittedHandlerExport } from '../utils'; -import { MethodNames } from '../utils'; - -export const requestPermissionsHandler: PermittedHandlerExport< - RequestPermissionsHooks, - [RequestedPermissions], - PermissionConstraint[] -> = { - methodNames: [MethodNames.RequestPermissions], - implementation: requestPermissionsImplementation, - hookNames: { - requestPermissionsForOrigin: true, - }, -}; - -type RequestPermissions = ( - requestedPermissions: RequestedPermissions, -) => Promise< - [Record, { id: string; origin: string }] ->; - -export type RequestPermissionsHooks = { - requestPermissionsForOrigin: RequestPermissions; -}; - -/** - * Request Permissions implementation to be used in JsonRpcEngine middleware. - * - * @param req - The JsonRpcEngine request - * @param res - The JsonRpcEngine result object - * @param _next - JsonRpcEngine next() callback - unused - * @param end - JsonRpcEngine end() callback - * @param options - Method hooks passed to the method implementation - * @param options.requestPermissionsForOrigin - The specific method hook needed for this method implementation - * @returns A promise that resolves to nothing - */ -async function requestPermissionsImplementation( - req: JsonRpcRequest<[RequestedPermissions]>, - res: PendingJsonRpcResponse, - _next: unknown, - end: JsonRpcEngineEndCallback, - { requestPermissionsForOrigin }: RequestPermissionsHooks, -): Promise { - const { params } = req; - - if (!Array.isArray(params) || !isPlainObject(params[0])) { - return end(invalidParams({ data: { request: req } })); - } - - const [requestedPermissions] = params; - const [grantedPermissions] = - await requestPermissionsForOrigin(requestedPermissions); - - // `wallet_requestPermission` is specified to return an array. - res.result = Object.values(grantedPermissions); - return end(); -} diff --git a/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts b/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts deleted file mode 100644 index 60465a1b475..00000000000 --- a/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import { rpcErrors } from '@metamask/rpc-errors'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; -import { - assertIsJsonRpcFailure, - assertIsJsonRpcSuccess, -} from '@metamask/utils'; - -import type { RevokePermissionArgs } from './revokePermissions'; -import { revokePermissionsHandler } from './revokePermissions'; - -describe('revokePermissionsHandler', () => { - it('has the expected shape', () => { - expect(revokePermissionsHandler).toStrictEqual({ - methodNames: ['wallet_revokePermissions'], - implementation: expect.any(Function), - hookNames: { - revokePermissionsForOrigin: true, - }, - }); - }); -}); - -describe('revokePermissions RPC method', () => { - it('revokes permissions using revokePermissionsForOrigin', async () => { - const { implementation } = revokePermissionsHandler; - const mockRevokePermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - revokePermissionsForOrigin: mockRevokePermissionsForOrigin, - }); - }); - - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'wallet_revokePermissions', - params: [ - { - snap_dialog: {}, - }, - ], - }); - assertIsJsonRpcSuccess(response); - - expect(response.result).toBeNull(); - expect(mockRevokePermissionsForOrigin).toHaveBeenCalledTimes(1); - expect(mockRevokePermissionsForOrigin).toHaveBeenCalledWith([ - 'snap_dialog', - ]); - }); - - it('returns an error if the request params is a plain object', async () => { - const { implementation } = revokePermissionsHandler; - const mockRevokePermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - revokePermissionsForOrigin: mockRevokePermissionsForOrigin, - }); - }); - - const req: JsonRpcRequest> = { - jsonrpc: '2.0', - id: 1, - method: 'wallet_revokePermissions', - params: {}, - }; - - const expectedError = rpcErrors - .invalidParams({ - data: { request: { ...req } }, - }) - .serialize(); - delete expectedError.stack; - - const response = await engine.handle(req); - assertIsJsonRpcFailure(response); - delete response.error.stack; - expect(response.error).toStrictEqual(expectedError); - expect(mockRevokePermissionsForOrigin).not.toHaveBeenCalled(); - }); - - it('returns an error if the permissionKeys is a plain object', async () => { - const { implementation } = revokePermissionsHandler; - const mockRevokePermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - revokePermissionsForOrigin: mockRevokePermissionsForOrigin, - }); - }); - - const req: JsonRpcRequest<[Record]> = { - jsonrpc: '2.0', - id: 1, - method: 'wallet_revokePermissions', - params: [{}], - }; - - const expectedError = rpcErrors - .invalidParams({ - data: { request: { ...req } }, - }) - .serialize(); - delete expectedError.stack; - - const response = await engine.handle(req); - assertIsJsonRpcFailure(response); - delete response.error.stack; - expect(response.error).toStrictEqual(expectedError); - expect(mockRevokePermissionsForOrigin).not.toHaveBeenCalled(); - }); - - it('returns an error if the params are not set', async () => { - const { implementation } = revokePermissionsHandler; - const mockRevokePermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - revokePermissionsForOrigin: mockRevokePermissionsForOrigin, - }); - }); - - const req: JsonRpcRequest<[]> = { - jsonrpc: '2.0', - id: 1, - method: 'wallet_revokePermissions', - }; - - const expectedError = rpcErrors - .invalidParams({ - data: { request: { ...req } }, - }) - .serialize(); - delete expectedError.stack; - - const response = await engine.handle(req); - assertIsJsonRpcFailure(response); - delete response.error.stack; - expect(response.error).toStrictEqual(expectedError); - expect(mockRevokePermissionsForOrigin).not.toHaveBeenCalled(); - }); - - it('returns an error if the request params is an empty array', async () => { - const { implementation } = revokePermissionsHandler; - const mockRevokePermissionsForOrigin = jest.fn(); - - const engine = new JsonRpcEngine(); - engine.push((req, res, next, end) => { - // We intentionally do not await this promise; JsonRpcEngine won't await - // middleware anyway. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - implementation(req, res, next, end, { - revokePermissionsForOrigin: mockRevokePermissionsForOrigin, - }); - }); - - const req: JsonRpcRequest = { - jsonrpc: '2.0', - id: 1, - method: 'wallet_revokePermissions', - params: [], - }; - - const expectedError = rpcErrors - .invalidParams({ - data: { request: { ...req } }, - }) - .serialize(); - delete expectedError.stack; - - const response = await engine.handle(req); - assertIsJsonRpcFailure(response); - delete response.error.stack; - expect(response.error).toStrictEqual(expectedError); - expect(mockRevokePermissionsForOrigin).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/permission-controller/src/rpc-methods/revokePermissions.ts b/packages/permission-controller/src/rpc-methods/revokePermissions.ts deleted file mode 100644 index 34b9b6352ef..00000000000 --- a/packages/permission-controller/src/rpc-methods/revokePermissions.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; -import { isNonEmptyArray } from '@metamask/utils'; -import type { - Json, - JsonRpcRequest, - NonEmptyArray, - PendingJsonRpcResponse, -} from '@metamask/utils'; - -import { invalidParams } from '../errors'; -import type { PermissionConstraint } from '../Permission'; -import type { PermittedHandlerExport } from '../utils'; -import { MethodNames } from '../utils'; - -export const revokePermissionsHandler: PermittedHandlerExport< - RevokePermissionsHooks, - RevokePermissionArgs, - null -> = { - methodNames: [MethodNames.RevokePermissions], - implementation: revokePermissionsImplementation, - hookNames: { - revokePermissionsForOrigin: true, - }, -}; - -export type RevokePermissionArgs = Record< - PermissionConstraint['parentCapability'], - Json ->; - -type RevokePermissions = ( - permissions: NonEmptyArray, -) => void; - -export type RevokePermissionsHooks = { - revokePermissionsForOrigin: RevokePermissions; -}; - -/** - * Revoke Permissions implementation to be used in JsonRpcEngine middleware. - * - * @param req - The JsonRpcEngine request - * @param res - The JsonRpcEngine result object - * @param _next - JsonRpcEngine next() callback - unused - * @param end - JsonRpcEngine end() callback - * @param options - Method hooks passed to the method implementation - * @param options.revokePermissionsForOrigin - A hook that revokes given permission keys for an origin - * @returns A promise that resolves to nothing - */ -async function revokePermissionsImplementation( - req: JsonRpcRequest, - res: PendingJsonRpcResponse, - _next: unknown, - end: JsonRpcEngineEndCallback, - { revokePermissionsForOrigin }: RevokePermissionsHooks, -): Promise { - const { params } = req; - - const param = params?.[0]; - - if (!param) { - return end(invalidParams({ data: { request: req } })); - } - - // For now, this API revokes the entire permission key - // even if caveats are specified. - const permissionKeys = Object.keys(param); - - if (!isNonEmptyArray(permissionKeys)) { - return end(invalidParams({ data: { request: req } })); - } - - revokePermissionsForOrigin(permissionKeys); - - res.result = null; - - return end(); -} diff --git a/packages/permission-controller/src/utils.ts b/packages/permission-controller/src/utils.ts index e26ac47efe1..d576924ad6d 100644 --- a/packages/permission-controller/src/utils.ts +++ b/packages/permission-controller/src/utils.ts @@ -1,14 +1,3 @@ -import type { - JsonRpcEngineEndCallback, - JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; -import type { - Json, - JsonRpcParams, - JsonRpcRequest, - PendingJsonRpcResponse, -} from '@metamask/utils'; - import type { CaveatConstraint, CaveatSpecificationConstraint, @@ -40,44 +29,6 @@ export type ExtractSpecifications< | PermissionSpecificationMap, > = SpecificationsMap[keyof SpecificationsMap]; -/** - * A middleware function for handling a permitted method. - */ -export type HandlerMiddlewareFunction< - Hooks, - Params extends JsonRpcParams, - Result extends Json, -> = ( - req: JsonRpcRequest, - res: PendingJsonRpcResponse, - next: JsonRpcEngineNextCallback, - end: JsonRpcEngineEndCallback, - hooks: Hooks, -) => void | Promise; - -/** - * We use a mapped object type in order to create a type that requires the - * presence of the names of all hooks for the given handler. - * This can then be used to select only the necessary hooks whenever a method - * is called for purposes of POLA. - */ -export type HookNames = { - [Property in keyof HookMap]: true; -}; - -/** - * A handler for a permitted method. - */ -export type PermittedHandlerExport< - Hooks, - Params extends JsonRpcParams, - Result extends Json, -> = { - implementation: HandlerMiddlewareFunction; - hookNames: HookNames; - methodNames: string[]; -}; - /** * Given two permission objects, computes 3 sets: * - The set of caveat pairs that are common to both permissions. From 3b9f35a9c6c8326f3973d85126abbd1072d54194 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:31:50 -0700 Subject: [PATCH 02/25] feat(json-rpc-engine): consolidate legacy `createMethodMiddleware` Add `createMethodMiddlewareFactory` to the legacy (v1) surface, consolidating the near-identical `makeMethodMiddlewareMaker` implementations from metamask-extension and metamask-mobile. The new export is deprecated in favor of the v2 `createMethodMiddleware`. Extract `selectHooks` and `assertExpectedHooks` into a shared `hookUtils.ts` module used by both v1 and v2. v2 now performs the same strict missing/extraneous hook validation as v1 at middleware construction time. Co-Authored-By: Claude Opus 4.7 --- packages/json-rpc-engine/CHANGELOG.md | 2 + .../src/createMethodMiddleware.test.ts | 209 ++++++++++++++++++ .../src/createMethodMiddleware.ts | 142 ++++++++++++ packages/json-rpc-engine/src/hookUtils.ts | 63 ++++++ packages/json-rpc-engine/src/index.test.ts | 1 + packages/json-rpc-engine/src/index.ts | 7 + .../src/v2/createMethodMiddleware.test.ts | 3 +- .../src/v2/createMethodMiddleware.ts | 40 +--- packages/json-rpc-engine/src/v2/index.test.ts | 1 - packages/json-rpc-engine/src/v2/index.ts | 2 +- 10 files changed, 435 insertions(+), 35 deletions(-) create mode 100644 packages/json-rpc-engine/src/createMethodMiddleware.test.ts create mode 100644 packages/json-rpc-engine/src/createMethodMiddleware.ts create mode 100644 packages/json-rpc-engine/src/hookUtils.ts diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index e4b3e9433c4..6854484f9f6 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `createOriginMiddleware` utility to `v2` ([#8522](https://github.com/MetaMask/core/pull/8522)) - Add `createMethodMiddleware` utility to `v2` ([#8506](https://github.com/MetaMask/core/pull/8506)) - This utility allows JSON-RPC method implementations to use both the hooks pattern and the messenger. +- Add `createMethodMiddlewareFactory` consolidating the bespoke `makeMethodMiddlewareMaker` implementations from `metamask-extension` and `metamask-mobile`. + - Deprecated in favor of the v2 `createMethodMiddleware`. ## [10.2.4] diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts new file mode 100644 index 00000000000..1b7519bb229 --- /dev/null +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -0,0 +1,209 @@ +import { + assertIsJsonRpcFailure, + assertIsJsonRpcSuccess, +} from '@metamask/utils'; +import type { Json, JsonRpcParams } from '@metamask/utils'; + +import { JsonRpcEngine, createMethodMiddlewareFactory } from '.'; +import type { MethodHandler } from './createMethodMiddleware'; + +type Hooks = { + hook1: () => number; + hook2: () => number; +}; + +const getHandler = (): MethodHandler => ({ + implementation: (req, res, _next, end, hooks): void => { + if (Array.isArray(req.params)) { + switch (req.params[0]) { + case 1: + res.result = hooks.hook1(); + break; + case 2: + res.result = hooks.hook2(); + break; + case 3: + return end(new Error('test error')); + case 4: + throw new Error('test error'); + case 5: + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'foo'; + default: + throw new Error(`unexpected param "${String(req.params[0])}"`); + } + } + return end(); + }, + hookNames: { hook1: true, hook2: true }, + methodNames: ['method1', 'method2'], +}); + +const getDefaultHooks = (): Hooks => ({ + hook1: () => 42, + hook2: () => 99, +}); + +const method1 = 'method1'; + +describe('createMethodMiddlewareFactory', () => { + it('throws an error if a required hook is missing', () => { + const createMiddleware = createMethodMiddlewareFactory([getHandler()]); + const hooks = { hook1: () => 42 } as unknown as Hooks; + + expect(() => createMiddleware(hooks)).toThrow('Missing expected hooks'); + }); + + it('throws an error if an extraneous hook is provided', () => { + const createMiddleware = createMethodMiddlewareFactory([getHandler()]); + const hooks = { + ...getDefaultHooks(), + extraneousHook: () => 100, + } as unknown as Hooks; + + expect(() => createMiddleware(hooks)).toThrow('Received unexpected hooks'); + }); + + it('calls the handler for the matching method (uses hook1)', async () => { + const middleware = createMethodMiddlewareFactory([getHandler()])( + getDefaultHooks(), + ); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: method1, + params: [1], + }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe(42); + }); + + it('calls the handler for the matching method (uses hook2)', async () => { + const middleware = createMethodMiddlewareFactory([getHandler()])( + getDefaultHooks(), + ); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: method1, + params: [2], + }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe(99); + }); + + it('does not call the handler for a non-matching method', async () => { + const middleware = createMethodMiddlewareFactory([getHandler()])( + getDefaultHooks(), + ); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'nonMatchingMethod', + }); + assertIsJsonRpcFailure(response); + + expect(response.error).toMatchObject({ + message: expect.stringMatching( + /Response has no error or result for request/u, + ), + }); + }); + + it('handles errors returned by the implementation', async () => { + const middleware = createMethodMiddlewareFactory([getHandler()])( + getDefaultHooks(), + ); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: method1, + params: [3], + }); + assertIsJsonRpcFailure(response); + + expect(response.error.message).toBe('test error'); + expect( + (response.error.data as { cause: { message: string } }).cause.message, + ).toBe('test error'); + }); + + it('handles errors thrown by the implementation', async () => { + const middleware = createMethodMiddlewareFactory([getHandler()])( + getDefaultHooks(), + ); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: method1, + params: [4], + }); + assertIsJsonRpcFailure(response); + + expect(response.error.message).toBe('test error'); + expect( + (response.error.data as { cause: { message: string } }).cause.message, + ).toBe('test error'); + }); + + it('handles non-errors thrown by the implementation', async () => { + const middleware = createMethodMiddlewareFactory([getHandler()])( + getDefaultHooks(), + ); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: method1, + params: [5], + }); + assertIsJsonRpcFailure(response); + + expect(response.error).toMatchObject({ + message: 'Internal JSON-RPC error.', + data: 'foo', + }); + }); + + it('invokes onError when a handler throws', async () => { + const onError = jest.fn(); + const middleware = createMethodMiddlewareFactory([getHandler()], { + onError, + })(getDefaultHooks()); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: method1, + params: [4], + }; + await engine.handle(request); + + expect(onError).toHaveBeenCalledTimes(1); + const [error, receivedRequest] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('test error'); + expect(receivedRequest).toMatchObject(request); + }); +}); diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts new file mode 100644 index 00000000000..cee5a1f92ad --- /dev/null +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -0,0 +1,142 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import { assertExpectedHooks, selectHooks } from './hookUtils'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + JsonRpcMiddleware, +} from './JsonRpcEngine'; + +/** + * A middleware function for handling a permitted method. + * + * @deprecated Use the v2 handler type from `./v2/createMethodMiddleware` instead. + */ +export type HandlerMiddlewareFunction< + Hooks, + Params extends JsonRpcParams, + Result extends Json, +> = ( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: Hooks, +) => void | Promise; + +/** + * We use a mapped object type in order to create a type that requires the + * presence of the names of all hooks for the given handler. + * This can then be used to select only the necessary hooks whenever a method + * is called for purposes of POLA. + * + * @deprecated Use the v2 handler type from `./v2/createMethodMiddleware` instead. + */ +export type HookNames = { + [Property in keyof HookMap]: true; +}; + +/** + * A handler for a permitted method. + * + * @deprecated Use the v2 `MethodHandler` from `./v2/createMethodMiddleware` instead. + */ +export type MethodHandler< + Hooks, + Params extends JsonRpcParams, + Result extends Json, +> = { + implementation: HandlerMiddlewareFunction; + hookNames: HookNames; + methodNames: string[]; +}; + +/** + * Options for {@link createMethodMiddlewareFactory}. + */ +export type CreateMethodMiddlewareFactoryOptions = { + /** + * Called when a handler throws, before the error is forwarded to `end`. + * Intended for logging; must not throw. + */ + onError?: (error: unknown, request: JsonRpcRequest) => void; +}; + +/** + * Creates a factory that produces a JSON-RPC method middleware from a set of + * handlers. The returned factory validates that the hooks it receives match + * exactly the union of hook names declared by the handlers (no missing, no + * extraneous), then returns a middleware that dispatches requests by method + * name. + * + * Consolidates the bespoke `makeMethodMiddlewareMaker` implementations from + * `metamask-extension` and `metamask-mobile`. + * + * @deprecated Use `createMethodMiddleware` from `./v2/createMethodMiddleware` instead. + * @param handlers - The method handlers the middleware should dispatch to. + * @param options - Optional configuration. + * @returns A function that takes the required hooks and returns a + * `JsonRpcMiddleware`. + */ +export function createMethodMiddlewareFactory( + handlers: MethodHandler[], + options: CreateMethodMiddlewareFactoryOptions = {}, +): (hooks: Hooks) => JsonRpcMiddleware { + const { onError } = options; + + const handlerMap = handlers.reduce< + Record> + >((map, handler) => { + for (const methodName of handler.methodNames) { + map[methodName] = handler; + } + return map; + }, {}); + + const expectedHookNames = new Set( + handlers.flatMap(({ hookNames }) => Object.getOwnPropertyNames(hookNames)), + ); + + return (hooks: Hooks) => { + assertExpectedHooks(hooks as Record, expectedHookNames); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + const middleware: JsonRpcMiddleware = async ( + req, + res, + next, + end, + ) => { + const handler = handlerMap[req.method]; + if (handler) { + const { implementation, hookNames } = handler; + try { + return await implementation( + req, + res, + next, + end, + selectHooks(hooks, hookNames) as Hooks, + ); + } catch (error) { + onError?.(error, req); + return end( + error instanceof Error + ? error + : rpcErrors.internal({ data: error as Json }), + ); + } + } + + return next(); + }; + + return middleware; + }; +} diff --git a/packages/json-rpc-engine/src/hookUtils.ts b/packages/json-rpc-engine/src/hookUtils.ts new file mode 100644 index 00000000000..8a63e84697c --- /dev/null +++ b/packages/json-rpc-engine/src/hookUtils.ts @@ -0,0 +1,63 @@ +import { hasProperty } from '@metamask/utils'; + +/** + * Returns the subset of the specified `hooks` that are included in the + * `hookNames` object. This is a Principle of Least Authority (POLA) measure + * to ensure that each RPC method implementation only has access to the + * API "hooks" it needs to do its job. + * + * @param hooks - The hooks to select from. + * @param hookNames - The names of the hooks to select. + * @returns The selected hooks, or `undefined` if `hookNames` is not provided. + * @template Hooks - The hooks to select from. + * @template HookName - The names of the hooks to select. + */ +export function selectHooks( + hooks: Hooks, + hookNames?: Record, +): Pick | undefined { + if (hookNames) { + return Object.keys(hookNames).reduce>>( + (subset, name) => { + const hookName = name as HookName; + subset[hookName] = hooks[hookName]; + return subset; + }, + {}, + ) as Pick; + } + return undefined; +} + +/** + * Asserts that `hooks` contains exactly the hook names in `expectedHookNames`. + * Throws on any missing hooks, then on any extraneous hooks. + * + * @param hooks - The hooks object to validate. + * @param expectedHookNames - The expected hook names. + */ +export function assertExpectedHooks( + hooks: Record, + expectedHookNames: Set, +): void { + const missingHookNames: string[] = []; + expectedHookNames.forEach((hookName) => { + if (!hasProperty(hooks, hookName)) { + missingHookNames.push(hookName); + } + }); + if (missingHookNames.length > 0) { + throw new Error( + `Missing expected hooks:\n\n${missingHookNames.join('\n')}\n`, + ); + } + + const extraneousHookNames = Object.getOwnPropertyNames(hooks).filter( + (hookName) => !expectedHookNames.has(hookName), + ); + if (extraneousHookNames.length > 0) { + throw new Error( + `Received unexpected hooks:\n\n${extraneousHookNames.join('\n')}\n`, + ); + } +} diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index ac4e8008419..e14c4c6e7d8 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -6,6 +6,7 @@ describe('@metamask/json-rpc-engine', () => { [ "asV2Middleware", "createAsyncMiddleware", + "createMethodMiddlewareFactory", "createScaffoldMiddleware", "getUniqueId", "createIdRemapMiddleware", diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 57e69b8d09b..6a9b0df057d 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -4,6 +4,13 @@ export type { AsyncJsonrpcMiddleware, } from './createAsyncMiddleware'; export { createAsyncMiddleware } from './createAsyncMiddleware'; +export type { + CreateMethodMiddlewareFactoryOptions, + HandlerMiddlewareFunction, + HookNames, + MethodHandler, +} from './createMethodMiddleware'; +export { createMethodMiddlewareFactory } from './createMethodMiddleware'; export { createScaffoldMiddleware } from './createScaffoldMiddleware'; export { getUniqueId } from './getUniqueId'; export { createIdRemapMiddleware } from './idRemapMiddleware'; diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts index 168744a3fec..ea280b27c96 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts @@ -6,13 +6,14 @@ import { MethodHandler, } from './createMethodMiddleware'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import { JsonRpcRequest } from './utils'; type TestAction = { type: 'Example:TestAction'; handler: () => Promise; }; -function setup(): { engine: JsonRpcEngineV2 } { +function setup(): { engine: JsonRpcEngineV2 } { const getValueA = { hookNames: { testHook: true }, implementation: ({ hooks }): Promise => hooks.testHook(), diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts index dfd87035a1d..c107ae92fb9 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts @@ -1,5 +1,6 @@ import { ActionConstraint, Messenger } from '@metamask/messenger'; +import { assertExpectedHooks, selectHooks } from '../hookUtils'; import { JsonRpcMiddleware, Next } from './JsonRpcEngineV2'; import { ContextConstraint } from './MiddlewareContext'; import { @@ -94,6 +95,13 @@ export function createMethodMiddleware< const { messenger: rootMessenger } = options; const allHooks = options.hooks as Record; + const expectedHookNames = new Set( + Object.values(options.handlers).flatMap((handler) => + handler.hookNames ? Object.getOwnPropertyNames(handler.hookNames) : [], + ), + ); + assertExpectedHooks(allHooks, expectedHookNames); + const handlers = Object.entries(options.handlers).reduce< Record >((accumulator, [handlerName, handler]) => { @@ -134,35 +142,3 @@ export function createMethodMiddleware< return implementation({ request, context, next, hooks, messenger }); }; } - -/** - * Returns the subset of the specified `hooks` that are included in the - * `hookNames` object. This is a Principle of Least Authority (POLA) measure - * to ensure that each RPC method implementation only has access to the - * API "hooks" it needs to do its job. - * - * @param hooks - The hooks to select from. - * @param hookNames - The names of the hooks to select. - * @returns The selected hooks. - * @template Hooks - The hooks to select from. - * @template HookName - The names of the hooks to select. - */ -export function selectHooks< - Hooks extends Record, - HookName extends keyof Hooks, ->( - hooks: Hooks, - hookNames?: Record, -): Pick | undefined { - if (hookNames) { - return Object.keys(hookNames).reduce>>( - (hookSubset, _hookName) => { - const hookName = _hookName as HookName; - hookSubset[hookName] = hooks[hookName]; - return hookSubset; - }, - {}, - ) as Pick; - } - return undefined; -} diff --git a/packages/json-rpc-engine/src/v2/index.test.ts b/packages/json-rpc-engine/src/v2/index.test.ts index a1e16b5ba66..6a05a07af6e 100644 --- a/packages/json-rpc-engine/src/v2/index.test.ts +++ b/packages/json-rpc-engine/src/v2/index.test.ts @@ -15,7 +15,6 @@ describe('@metamask/json-rpc-engine/v2', () => { "getUniqueId", "isNotification", "isRequest", - "selectHooks", ] `); }); diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts index 9f0906a4139..9a937a60603 100644 --- a/packages/json-rpc-engine/src/v2/index.ts +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -1,6 +1,6 @@ export { asLegacyMiddleware } from './asLegacyMiddleware'; export { getUniqueId } from '../getUniqueId'; -export { selectHooks, createMethodMiddleware } from './createMethodMiddleware'; +export { createMethodMiddleware } from './createMethodMiddleware'; export type { MethodHandler } from './createMethodMiddleware'; export { createOriginMiddleware } from './createOriginMiddleware'; export { createScaffoldMiddleware } from './createScaffoldMiddleware'; From b7faed3bf840ffaaadde8a64e73b0fd6a3905255 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:10:58 -0700 Subject: [PATCH 03/25] refactor(json-rpc-engine)!: Support messenger handlers in legacy method middleware --- packages/json-rpc-engine/CHANGELOG.md | 1 + .../src/createMethodMiddleware.test.ts | 118 ++++++++++++--- .../src/createMethodMiddleware.ts | 135 +++++++++++++----- .../src/{hookUtils.ts => middlewareUtils.ts} | 40 ++++++ .../src/v2/createMethodMiddleware.ts | 25 ++-- 5 files changed, 249 insertions(+), 70 deletions(-) rename packages/json-rpc-engine/src/{hookUtils.ts => middlewareUtils.ts} (62%) diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index 6854484f9f6..e2598e7dca8 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `createMethodMiddleware` utility to `v2` ([#8506](https://github.com/MetaMask/core/pull/8506)) - This utility allows JSON-RPC method implementations to use both the hooks pattern and the messenger. - Add `createMethodMiddlewareFactory` consolidating the bespoke `makeMethodMiddlewareMaker` implementations from `metamask-extension` and `metamask-mobile`. + - Handlers may now declare `actionNames` and receive a delegated messenger as the sixth argument to `implementation`, mirroring the v2 `createMethodMiddleware`. - Deprecated in favor of the v2 `createMethodMiddleware`. ## [10.2.4] diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts index 1b7519bb229..0e5516ecba5 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -1,8 +1,8 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import { assertIsJsonRpcFailure, assertIsJsonRpcSuccess, } from '@metamask/utils'; -import type { Json, JsonRpcParams } from '@metamask/utils'; import { JsonRpcEngine, createMethodMiddlewareFactory } from '.'; import type { MethodHandler } from './createMethodMiddleware'; @@ -12,7 +12,7 @@ type Hooks = { hook2: () => number; }; -const getHandler = (): MethodHandler => ({ +const getHandler = (): MethodHandler => ({ implementation: (req, res, _next, end, hooks): void => { if (Array.isArray(req.params)) { switch (req.params[0]) { @@ -30,7 +30,9 @@ const getHandler = (): MethodHandler => ({ // eslint-disable-next-line @typescript-eslint/only-throw-error throw 'foo'; default: - throw new Error(`unexpected param "${String(req.params[0])}"`); + throw new Error( + `unexpected param "${JSON.stringify(req.params[0])}"`, + ); } } return end(); @@ -44,18 +46,25 @@ const getDefaultHooks = (): Hooks => ({ hook2: () => 99, }); +const getRootMessenger = (): Messenger => + new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + const method1 = 'method1'; describe('createMethodMiddlewareFactory', () => { it('throws an error if a required hook is missing', () => { - const createMiddleware = createMethodMiddlewareFactory([getHandler()]); + const createMiddleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + }); const hooks = { hook1: () => 42 } as unknown as Hooks; expect(() => createMiddleware(hooks)).toThrow('Missing expected hooks'); }); it('throws an error if an extraneous hook is provided', () => { - const createMiddleware = createMethodMiddlewareFactory([getHandler()]); + const createMiddleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + }); const hooks = { ...getDefaultHooks(), extraneousHook: () => 100, @@ -65,9 +74,9 @@ describe('createMethodMiddlewareFactory', () => { }); it('calls the handler for the matching method (uses hook1)', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()])( - getDefaultHooks(), - ); + const middleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + })(getDefaultHooks()); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -83,9 +92,9 @@ describe('createMethodMiddlewareFactory', () => { }); it('calls the handler for the matching method (uses hook2)', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()])( - getDefaultHooks(), - ); + const middleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + })(getDefaultHooks()); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -101,9 +110,9 @@ describe('createMethodMiddlewareFactory', () => { }); it('does not call the handler for a non-matching method', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()])( - getDefaultHooks(), - ); + const middleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + })(getDefaultHooks()); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -122,9 +131,9 @@ describe('createMethodMiddlewareFactory', () => { }); it('handles errors returned by the implementation', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()])( - getDefaultHooks(), - ); + const middleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + })(getDefaultHooks()); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -143,9 +152,9 @@ describe('createMethodMiddlewareFactory', () => { }); it('handles errors thrown by the implementation', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()])( - getDefaultHooks(), - ); + const middleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + })(getDefaultHooks()); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -164,9 +173,9 @@ describe('createMethodMiddlewareFactory', () => { }); it('handles non-errors thrown by the implementation', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()])( - getDefaultHooks(), - ); + const middleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), + })(getDefaultHooks()); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -187,6 +196,7 @@ describe('createMethodMiddlewareFactory', () => { it('invokes onError when a handler throws', async () => { const onError = jest.fn(); const middleware = createMethodMiddlewareFactory([getHandler()], { + messenger: getRootMessenger(), onError, })(getDefaultHooks()); const engine = new JsonRpcEngine(); @@ -206,4 +216,66 @@ describe('createMethodMiddlewareFactory', () => { expect((error as Error).message).toBe('test error'); expect(receivedRequest).toMatchObject(request); }); + + it('works when no hooks and no messenger are configured', async () => { + const handler: MethodHandler = { + implementation: (_req, res, _next, end) => { + res.result = 'no-deps'; + return end(); + }, + methodNames: ['noDeps'], + }; + + const middleware = createMethodMiddlewareFactory([handler])(); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'noDeps', + }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe('no-deps'); + }); + + it('passes a delegated messenger to the handler', async () => { + type TestAction = { + type: 'Example:TestAction'; + handler: () => Promise; + }; + + const handler: MethodHandler = { + implementation: async (_req, res, _next, end, _hooks, messenger) => { + res.result = await messenger.call('Example:TestAction'); + return end(); + }, + methodNames: ['callAction'], + actionNames: ['Example:TestAction'], + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'Example:TestAction', + async () => 'action-result', + ); + + const middleware = createMethodMiddlewareFactory([handler], { + messenger: rootMessenger, + })(); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'callAction', + }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe('action-result'); + }); }); diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index cee5a1f92ad..1b7acdacbac 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -1,3 +1,5 @@ +import type { ActionConstraint } from '@metamask/messenger'; +import { Messenger } from '@metamask/messenger'; import { rpcErrors } from '@metamask/rpc-errors'; import type { Json, @@ -6,12 +8,16 @@ import type { PendingJsonRpcResponse, } from '@metamask/utils'; -import { assertExpectedHooks, selectHooks } from './hookUtils'; import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, JsonRpcMiddleware, } from './JsonRpcEngine'; +import { + assertExpectedHooks, + createHandlerMessenger, + selectHooks, +} from './middlewareUtils'; /** * A middleware function for handling a permitted method. @@ -19,15 +25,17 @@ import type { * @deprecated Use the v2 handler type from `./v2/createMethodMiddleware` instead. */ export type HandlerMiddlewareFunction< - Hooks, - Params extends JsonRpcParams, - Result extends Json, + Hooks extends Record = never, + MessengerActions extends ActionConstraint = never, + Params extends JsonRpcParams = JsonRpcParams, + Result extends Json = Json, > = ( req: JsonRpcRequest, res: PendingJsonRpcResponse, next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, hooks: Hooks, + messenger: Messenger, ) => void | Promise; /** @@ -48,25 +56,54 @@ export type HookNames = { * @deprecated Use the v2 `MethodHandler` from `./v2/createMethodMiddleware` instead. */ export type MethodHandler< - Hooks, - Params extends JsonRpcParams, - Result extends Json, + Hooks extends Record = never, + MessengerActions extends ActionConstraint = never, + Params extends JsonRpcParams = JsonRpcParams, + Result extends Json = Json, > = { - implementation: HandlerMiddlewareFunction; - hookNames: HookNames; + implementation: HandlerMiddlewareFunction< + Hooks, + MessengerActions, + Params, + Result + >; methodNames: string[]; -}; +} & ([Hooks] extends [never] + ? { hookNames?: undefined } + : { hookNames: HookNames }) & + ([MessengerActions] extends [never] + ? { actionNames?: undefined } + : { actionNames: readonly MessengerActions['type'][] }); /** * Options for {@link createMethodMiddlewareFactory}. */ -export type CreateMethodMiddlewareFactoryOptions = { +export type CreateMethodMiddlewareFactoryOptions< + MessengerActions extends ActionConstraint = never, +> = { /** * Called when a handler throws, before the error is forwarded to `end`. * Intended for logging; must not throw. */ onError?: (error: unknown, request: JsonRpcRequest) => void; -}; +} & ([MessengerActions] extends [never] + ? { + /** + * The root messenger. A per-handler messenger, namespaced to each handler + * and delegated the actions the handler declared, is passed to the + * handler's `implementation` at call time. Optional when no handler + * declares any actions. + */ + messenger?: undefined; + } + : { + /** + * The root messenger. A per-handler messenger, namespaced to each handler + * and delegated the actions the handler declared, is passed to the + * handler's `implementation` at call time. + */ + messenger: Messenger; + }); /** * Creates a factory that produces a JSON-RPC method middleware from a set of @@ -80,31 +117,57 @@ export type CreateMethodMiddlewareFactoryOptions = { * * @deprecated Use `createMethodMiddleware` from `./v2/createMethodMiddleware` instead. * @param handlers - The method handlers the middleware should dispatch to. - * @param options - Optional configuration. + * @param options - Options including the root messenger. * @returns A function that takes the required hooks and returns a * `JsonRpcMiddleware`. */ -export function createMethodMiddlewareFactory( - handlers: MethodHandler[], - options: CreateMethodMiddlewareFactoryOptions = {}, -): (hooks: Hooks) => JsonRpcMiddleware { - const { onError } = options; +export function createMethodMiddlewareFactory< + Hooks extends Record = never, + MessengerActions extends ActionConstraint = never, +>( + handlers: MethodHandler[], + options?: CreateMethodMiddlewareFactoryOptions, +): [Hooks] extends [never] + ? () => JsonRpcMiddleware + : (hooks: Hooks) => JsonRpcMiddleware { + const { onError } = options ?? {}; + const rootMessenger = + options?.messenger ?? + new Messenger({ + namespace: 'json-rpc-engine', + }); - const handlerMap = handlers.reduce< - Record> - >((map, handler) => { - for (const methodName of handler.methodNames) { - map[methodName] = handler; - } - return map; - }, {}); + type HandlerEntry = { + handler: MethodHandler; + messenger: Messenger; + }; + + const handlerMap = handlers.reduce>( + (map, handler) => { + const handlerMessenger = createHandlerMessenger({ + namespace: handler.methodNames.join(':'), + actionNames: handler.actionNames, + rootMessenger, + }); + for (const methodName of handler.methodNames) { + map[methodName] = { handler, messenger: handlerMessenger }; + } + return map; + }, + {}, + ); const expectedHookNames = new Set( - handlers.flatMap(({ hookNames }) => Object.getOwnPropertyNames(hookNames)), + handlers.flatMap((handler) => + handler.hookNames ? Object.getOwnPropertyNames(handler.hookNames) : [], + ), ); - return (hooks: Hooks) => { - assertExpectedHooks(hooks as Record, expectedHookNames); + return ((hooks?: Hooks) => { + assertExpectedHooks( + (hooks ?? {}) as Record, + expectedHookNames, + ); // eslint-disable-next-line @typescript-eslint/no-misused-promises const middleware: JsonRpcMiddleware = async ( @@ -113,9 +176,12 @@ export function createMethodMiddlewareFactory( next, end, ) => { - const handler = handlerMap[req.method]; - if (handler) { - const { implementation, hookNames } = handler; + const entry = handlerMap[req.method]; + if (entry) { + const { + handler: { implementation, hookNames }, + messenger, + } = entry; try { return await implementation( req, @@ -123,6 +189,7 @@ export function createMethodMiddlewareFactory( next, end, selectHooks(hooks, hookNames) as Hooks, + messenger, ); } catch (error) { onError?.(error, req); @@ -138,5 +205,7 @@ export function createMethodMiddlewareFactory( }; return middleware; - }; + }) as [Hooks] extends [never] + ? () => JsonRpcMiddleware + : (hooks: Hooks) => JsonRpcMiddleware; } diff --git a/packages/json-rpc-engine/src/hookUtils.ts b/packages/json-rpc-engine/src/middlewareUtils.ts similarity index 62% rename from packages/json-rpc-engine/src/hookUtils.ts rename to packages/json-rpc-engine/src/middlewareUtils.ts index 8a63e84697c..7f9230e80e9 100644 --- a/packages/json-rpc-engine/src/hookUtils.ts +++ b/packages/json-rpc-engine/src/middlewareUtils.ts @@ -1,3 +1,5 @@ +import type { ActionConstraint } from '@metamask/messenger'; +import { Messenger } from '@metamask/messenger'; import { hasProperty } from '@metamask/utils'; /** @@ -61,3 +63,41 @@ export function assertExpectedHooks( ); } } + +/** + * Creates a per-handler messenger namespaced to `namespace`, and delegates the + * specified `actionNames` from `rootMessenger` to it. This lets each handler + * call only the actions it declared, per POLA. + * + * @param options - The options. + * @param options.namespace - The namespace for the handler messenger. + * @param options.actionNames - Actions to delegate from the root messenger. + * @param options.rootMessenger - The root messenger to delegate from. + * @returns The per-handler messenger. + */ +export function createHandlerMessenger({ + namespace, + actionNames, + rootMessenger, +}: { + namespace: string; + actionNames: readonly Actions['type'][] | undefined; + rootMessenger: Messenger; +}): Messenger { + const handlerMessenger = new Messenger< + string, + Actions, + never, + typeof rootMessenger + >({ + namespace, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: (actionNames ?? []) as Actions['type'][], + messenger: handlerMessenger, + }); + + return handlerMessenger; +} diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts index c107ae92fb9..47bdebdae81 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts @@ -1,6 +1,10 @@ import { ActionConstraint, Messenger } from '@metamask/messenger'; -import { assertExpectedHooks, selectHooks } from '../hookUtils'; +import { + assertExpectedHooks, + createHandlerMessenger, + selectHooks, +} from '../middlewareUtils'; import { JsonRpcMiddleware, Next } from './JsonRpcEngineV2'; import { ContextConstraint } from './MiddlewareContext'; import { @@ -106,21 +110,14 @@ export function createMethodMiddleware< Record >((accumulator, [handlerName, handler]) => { const handlerHooks = selectHooks(allHooks, handler.hookNames) ?? {}; - const handlerMessenger = new Messenger< - string, - HandlerActions, - never, - typeof rootMessenger + const handlerMessenger = createHandlerMessenger< + HandlerActions >({ namespace: handlerName, - parent: rootMessenger, - }); - - rootMessenger.delegate({ - actions: (handler.actionNames ?? []) as HandlerActions< - Handlers[keyof Handlers] - >['type'][], - messenger: handlerMessenger, + actionNames: handler.actionNames as + | readonly HandlerActions['type'][] + | undefined, + rootMessenger, }); accumulator[handlerName] = { From be9aaca0938b0519170e6f8221f17ee3e32b4fc8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:17:27 -0700 Subject: [PATCH 04/25] chore: Update changelogs --- packages/json-rpc-engine/CHANGELOG.md | 3 ++- packages/permission-controller/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index e2598e7dca8..96265465a8d 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `createOriginMiddleware` utility to `v2` ([#8522](https://github.com/MetaMask/core/pull/8522)) - Add `createMethodMiddleware` utility to `v2` ([#8506](https://github.com/MetaMask/core/pull/8506)) - This utility allows JSON-RPC method implementations to use both the hooks pattern and the messenger. -- Add `createMethodMiddlewareFactory` consolidating the bespoke `makeMethodMiddlewareMaker` implementations from `metamask-extension` and `metamask-mobile`. +- Add legacy `createMethodMiddlewareFactory` ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Consolidates bespoke `makeMethodMiddlewareMaker` implementations from the MetaMask extension and mobile clients. - Handlers may now declare `actionNames` and receive a delegated messenger as the sixth argument to `implementation`, mirroring the v2 `createMethodMiddleware`. - Deprecated in favor of the v2 `createMethodMiddleware`. diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index c9e6f6ab06a..a91b108a3a1 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **BREAKING:** Remove `factoryHooks`, `validatorHooks`, and related fields from permission specification builders ([#8551](https://github.com/MetaMask/core/pull/8551)) +- **BREAKING:** Remove permitted method handlers and types ([#8583](https://github.com/MetaMask/core/pull/8583)) + - The permitted method handlers were unused in practice. Replacement types are available in `@metamask/json-rpc-engine@10.3.0`. ## [12.3.0] From 77f1777eb7ec76b32b8f2b2e90eaf5aafb7d6417 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:50:15 -0700 Subject: [PATCH 05/25] refactor(json-rpc-engine): Pre-resolve hooks per handler in legacy method middleware Allow `Messenger` in the no-actions branch of `CreateMethodMiddlewareFactoryOptions` so callers may still pass a no-op root messenger when handlers declare no actions. Pre-select hooks per handler at curried-call time and store them on a `ResolvedHandler` entry, mirroring v2's dispatch shape and removing `selectHooks` from the per-request path. Co-Authored-By: Claude Opus 4.7 --- .../src/createMethodMiddleware.ts | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index 1b7acdacbac..eb56a41b811 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -22,7 +22,7 @@ import { /** * A middleware function for handling a permitted method. * - * @deprecated Use the v2 handler type from `./v2/createMethodMiddleware` instead. + * @deprecated Use the v2 handler type from `./v2` instead. */ export type HandlerMiddlewareFunction< Hooks extends Record = never, @@ -44,7 +44,7 @@ export type HandlerMiddlewareFunction< * This can then be used to select only the necessary hooks whenever a method * is called for purposes of POLA. * - * @deprecated Use the v2 handler type from `./v2/createMethodMiddleware` instead. + * @deprecated Use the v2 handler type from `./v2` instead. */ export type HookNames = { [Property in keyof HookMap]: true; @@ -53,7 +53,7 @@ export type HookNames = { /** * A handler for a permitted method. * - * @deprecated Use the v2 `MethodHandler` from `./v2/createMethodMiddleware` instead. + * @deprecated Use the v2 `MethodHandler` from `./v2` instead. */ export type MethodHandler< Hooks extends Record = never, @@ -94,7 +94,7 @@ export type CreateMethodMiddlewareFactoryOptions< * handler's `implementation` at call time. Optional when no handler * declares any actions. */ - messenger?: undefined; + messenger?: Messenger | undefined; } : { /** @@ -115,7 +115,7 @@ export type CreateMethodMiddlewareFactoryOptions< * Consolidates the bespoke `makeMethodMiddlewareMaker` implementations from * `metamask-extension` and `metamask-mobile`. * - * @deprecated Use `createMethodMiddleware` from `./v2/createMethodMiddleware` instead. + * @deprecated Use `createMethodMiddleware` from `./v2` instead. * @param handlers - The method handlers the middleware should dispatch to. * @param options - Options including the root messenger. * @returns A function that takes the required hooks and returns a @@ -137,25 +137,25 @@ export function createMethodMiddlewareFactory< namespace: 'json-rpc-engine', }); - type HandlerEntry = { - handler: MethodHandler; + type ResolvedHandler = { + implementation: HandlerMiddlewareFunction< + Hooks, + MessengerActions, + JsonRpcParams, + Json + >; + hooks: Hooks; messenger: Messenger; }; - const handlerMap = handlers.reduce>( - (map, handler) => { - const handlerMessenger = createHandlerMessenger({ - namespace: handler.methodNames.join(':'), - actionNames: handler.actionNames, - rootMessenger, - }); - for (const methodName of handler.methodNames) { - map[methodName] = { handler, messenger: handlerMessenger }; - } - return map; - }, - {}, - ); + const baseHandlers = handlers.map((handler) => ({ + handler, + messenger: createHandlerMessenger({ + namespace: handler.methodNames.join(':'), + actionNames: handler.actionNames, + rootMessenger, + }), + })); const expectedHookNames = new Set( handlers.flatMap((handler) => @@ -169,6 +169,21 @@ export function createMethodMiddlewareFactory< expectedHookNames, ); + const resolvedHandlers = baseHandlers.reduce< + Record + >((map, { handler, messenger }) => { + const handlerHooks = (selectHooks(hooks, handler.hookNames) ?? + {}) as Hooks; + for (const methodName of handler.methodNames) { + map[methodName] = { + implementation: handler.implementation, + hooks: handlerHooks, + messenger, + }; + } + return map; + }, {}); + // eslint-disable-next-line @typescript-eslint/no-misused-promises const middleware: JsonRpcMiddleware = async ( req, @@ -176,19 +191,16 @@ export function createMethodMiddlewareFactory< next, end, ) => { - const entry = handlerMap[req.method]; - if (entry) { - const { - handler: { implementation, hookNames }, - messenger, - } = entry; + const resolved = resolvedHandlers[req.method]; + if (resolved) { + const { implementation, hooks: handlerHooks, messenger } = resolved; try { return await implementation( req, res, next, end, - selectHooks(hooks, hookNames) as Hooks, + handlerHooks, messenger, ); } catch (error) { From 09b26c5c36399cdf018d1faef488e7d0f21578ae Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:10:51 -0700 Subject: [PATCH 06/25] refactor: Harmonize legacy middleware with v2 version --- .../src/createMethodMiddleware.test.ts | 173 ++++++----- .../src/createMethodMiddleware.ts | 270 ++++++++---------- packages/json-rpc-engine/src/index.test.ts | 2 +- packages/json-rpc-engine/src/index.ts | 7 +- .../json-rpc-engine/src/middlewareUtils.ts | 103 ------- .../src/v2/createMethodMiddleware.ts | 13 +- packages/json-rpc-engine/src/v2/index.test.ts | 1 + packages/json-rpc-engine/src/v2/index.ts | 7 +- packages/json-rpc-engine/src/v2/utils.ts | 104 +++++++ 9 files changed, 354 insertions(+), 326 deletions(-) delete mode 100644 packages/json-rpc-engine/src/middlewareUtils.ts diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts index 0e5516ecba5..c0e8da312cc 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -2,44 +2,54 @@ import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import { assertIsJsonRpcFailure, assertIsJsonRpcSuccess, + Json, + JsonRpcRequest, } from '@metamask/utils'; -import { JsonRpcEngine, createMethodMiddlewareFactory } from '.'; -import type { MethodHandler } from './createMethodMiddleware'; +import { + MethodHandlerImplementation, + createMethodMiddleware, +} from './createMethodMiddleware'; +import { JsonRpcEngine, JsonRpcMiddleware } from './JsonRpcEngine'; type Hooks = { hook1: () => number; hook2: () => number; }; -const getHandler = (): MethodHandler => ({ - implementation: (req, res, _next, end, hooks): void => { - if (Array.isArray(req.params)) { - switch (req.params[0]) { - case 1: - res.result = hooks.hook1(); - break; - case 2: - res.result = hooks.hook2(); - break; - case 3: - return end(new Error('test error')); - case 4: - throw new Error('test error'); - case 5: - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw 'foo'; - default: - throw new Error( - `unexpected param "${JSON.stringify(req.params[0])}"`, - ); - } +const handlerImplementation: MethodHandlerImplementation = ( + req, + res, + _next, + end, + hooks, +): void => { + if (Array.isArray(req.params)) { + switch (req.params[0]) { + case 1: + res.result = hooks.hook1(); + break; + case 2: + res.result = hooks.hook2(); + break; + case 3: + return end(new Error('test error')); + case 4: + throw new Error('test error'); + case 5: + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'foo'; + default: + throw new Error(`unexpected param "${JSON.stringify(req.params[0])}"`); } - return end(); - }, - hookNames: { hook1: true, hook2: true }, - methodNames: ['method1', 'method2'], -}); + } + return end(); +}; + +const handler = { + implementation: handlerImplementation, + hookNames: { hook1: true as const, hook2: true as const }, +}; const getDefaultHooks = (): Hooks => ({ hook1: () => 42, @@ -51,32 +61,41 @@ const getRootMessenger = (): Messenger => const method1 = 'method1'; -describe('createMethodMiddlewareFactory', () => { +describe('createMethodMiddleware', () => { it('throws an error if a required hook is missing', () => { - const createMiddleware = createMethodMiddlewareFactory([getHandler()], { - messenger: getRootMessenger(), - }); - const hooks = { hook1: () => 42 } as unknown as Hooks; - - expect(() => createMiddleware(hooks)).toThrow('Missing expected hooks'); + const hooks = { hook1: (): number => 42 }; + + expect(() => + createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + messenger: getRootMessenger(), + // @ts-expect-error Intentionally missing a required hook. + hooks, + }), + ).toThrow('Missing expected hooks'); }); it('throws an error if an extraneous hook is provided', () => { - const createMiddleware = createMethodMiddlewareFactory([getHandler()], { - messenger: getRootMessenger(), - }); const hooks = { ...getDefaultHooks(), - extraneousHook: () => 100, - } as unknown as Hooks; + extraneousHook: (): number => 100, + }; - expect(() => createMiddleware(hooks)).toThrow('Received unexpected hooks'); + expect(() => + createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + messenger: getRootMessenger(), + hooks, + }), + ).toThrow('Received unexpected hooks'); }); it('calls the handler for the matching method (uses hook1)', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()], { + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, messenger: getRootMessenger(), - })(getDefaultHooks()); + hooks: getDefaultHooks(), + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -92,9 +111,11 @@ describe('createMethodMiddlewareFactory', () => { }); it('calls the handler for the matching method (uses hook2)', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()], { + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, messenger: getRootMessenger(), - })(getDefaultHooks()); + hooks: getDefaultHooks(), + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -110,9 +131,11 @@ describe('createMethodMiddlewareFactory', () => { }); it('does not call the handler for a non-matching method', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()], { + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, messenger: getRootMessenger(), - })(getDefaultHooks()); + hooks: getDefaultHooks(), + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -131,9 +154,11 @@ describe('createMethodMiddlewareFactory', () => { }); it('handles errors returned by the implementation', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()], { + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, messenger: getRootMessenger(), - })(getDefaultHooks()); + hooks: getDefaultHooks(), + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -152,9 +177,11 @@ describe('createMethodMiddlewareFactory', () => { }); it('handles errors thrown by the implementation', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()], { + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, messenger: getRootMessenger(), - })(getDefaultHooks()); + hooks: getDefaultHooks(), + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -173,9 +200,11 @@ describe('createMethodMiddlewareFactory', () => { }); it('handles non-errors thrown by the implementation', async () => { - const middleware = createMethodMiddlewareFactory([getHandler()], { + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, messenger: getRootMessenger(), - })(getDefaultHooks()); + hooks: getDefaultHooks(), + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -195,10 +224,12 @@ describe('createMethodMiddlewareFactory', () => { it('invokes onError when a handler throws', async () => { const onError = jest.fn(); - const middleware = createMethodMiddlewareFactory([getHandler()], { + const middleware = createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, messenger: getRootMessenger(), + hooks: getDefaultHooks(), onError, - })(getDefaultHooks()); + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -217,16 +248,19 @@ describe('createMethodMiddlewareFactory', () => { expect(receivedRequest).toMatchObject(request); }); - it('works when no hooks and no messenger are configured', async () => { - const handler: MethodHandler = { - implementation: (_req, res, _next, end) => { + it('works when no hooks are configured', async () => { + const noDepsHandler = { + implementation: ((_req, res, _next, end) => { res.result = 'no-deps'; return end(); - }, - methodNames: ['noDeps'], + }) as JsonRpcMiddleware, }; - const middleware = createMethodMiddlewareFactory([handler])(); + const middleware = createMethodMiddleware({ + handlers: { noDeps: noDepsHandler }, + messenger: getRootMessenger(), + hooks: {}, + }); const engine = new JsonRpcEngine(); engine.push(middleware); @@ -246,13 +280,12 @@ describe('createMethodMiddlewareFactory', () => { handler: () => Promise; }; - const handler: MethodHandler = { - implementation: async (_req, res, _next, end, _hooks, messenger) => { + const messengerHandler = { + implementation: (async (_req, res, _next, end, _hooks, messenger) => { res.result = await messenger.call('Example:TestAction'); return end(); - }, - methodNames: ['callAction'], - actionNames: ['Example:TestAction'], + }) as MethodHandlerImplementation, + actionNames: ['Example:TestAction'] as const, }; const rootMessenger = new Messenger({ @@ -263,9 +296,11 @@ describe('createMethodMiddlewareFactory', () => { async () => 'action-result', ); - const middleware = createMethodMiddlewareFactory([handler], { + const middleware = createMethodMiddleware({ + handlers: { callAction: messengerHandler }, messenger: rootMessenger, - })(); + hooks: {}, + }); const engine = new JsonRpcEngine(); engine.push(middleware); diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index eb56a41b811..cfbc2037843 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -17,14 +17,42 @@ import { assertExpectedHooks, createHandlerMessenger, selectHooks, -} from './middlewareUtils'; + UnionToIntersection, +} from './v2/utils'; + +type HandlerActions = Handler extends { + implementation: (...args: infer Args) => unknown; +} + ? Args extends [ + unknown, + unknown, + unknown, + unknown, + unknown, + infer HandlerMessenger, + ] + ? HandlerMessenger extends Messenger + ? Actions + : never + : never + : never; + +type HandlerHooks = Handler extends { + implementation: (...args: infer Args) => unknown; +} + ? Args extends [unknown, unknown, unknown, unknown, infer ArgHooks, unknown] + ? ArgHooks extends Record + ? ArgHooks + : never + : never + : never; /** - * A middleware function for handling a permitted method. + * A {@link MethodHandler} implementation. * - * @deprecated Use the v2 handler type from `./v2` instead. + * @deprecated Use the v2 `createMethodMiddleware` instead. */ -export type HandlerMiddlewareFunction< +export type MethodHandlerImplementation< Hooks extends Record = never, MessengerActions extends ActionConstraint = never, Params extends JsonRpcParams = JsonRpcParams, @@ -36,24 +64,12 @@ export type HandlerMiddlewareFunction< end: JsonRpcEngineEndCallback, hooks: Hooks, messenger: Messenger, -) => void | Promise; - -/** - * We use a mapped object type in order to create a type that requires the - * presence of the names of all hooks for the given handler. - * This can then be used to select only the necessary hooks whenever a method - * is called for purposes of POLA. - * - * @deprecated Use the v2 handler type from `./v2` instead. - */ -export type HookNames = { - [Property in keyof HookMap]: true; -}; +) => Promise | void; /** - * A handler for a permitted method. + * A handler for {@link createMethodMiddleware}. * - * @deprecated Use the v2 `MethodHandler` from `./v2` instead. + * @deprecated Use the v2 `createMethodMiddleware` instead. */ export type MethodHandler< Hooks extends Record = never, @@ -61,7 +77,7 @@ export type MethodHandler< Params extends JsonRpcParams = JsonRpcParams, Result extends Json = Json, > = { - implementation: HandlerMiddlewareFunction< + implementation: MethodHandlerImplementation< Hooks, MessengerActions, Params, @@ -70,154 +86,122 @@ export type MethodHandler< methodNames: string[]; } & ([Hooks] extends [never] ? { hookNames?: undefined } - : { hookNames: HookNames }) & + : { hookNames: { [Key in keyof Hooks]: true } }) & ([MessengerActions] extends [never] ? { actionNames?: undefined } : { actionNames: readonly MessengerActions['type'][] }); +type AnyMethodHandler = { + implementation( + this: void, + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: unknown, + messenger: unknown, + ): Promise | void; + hookNames?: Record; + actionNames?: readonly string[]; +}; + /** - * Options for {@link createMethodMiddlewareFactory}. + * Options for {@link createMethodMiddleware}. + * + * @deprecated Use the v2 `createMethodMiddleware` instead. */ -export type CreateMethodMiddlewareFactoryOptions< - MessengerActions extends ActionConstraint = never, +export type CreateMethodMiddlewareOptions< + Handlers extends Record, > = { + handlers: Handlers; + messenger: Messenger>; + hooks: UnionToIntersection>; /** * Called when a handler throws, before the error is forwarded to `end`. * Intended for logging; must not throw. */ onError?: (error: unknown, request: JsonRpcRequest) => void; -} & ([MessengerActions] extends [never] - ? { - /** - * The root messenger. A per-handler messenger, namespaced to each handler - * and delegated the actions the handler declared, is passed to the - * handler's `implementation` at call time. Optional when no handler - * declares any actions. - */ - messenger?: Messenger | undefined; - } - : { - /** - * The root messenger. A per-handler messenger, namespaced to each handler - * and delegated the actions the handler declared, is passed to the - * handler's `implementation` at call time. - */ - messenger: Messenger; - }); +}; + +type ResolvedHandler = { + implementation: AnyMethodHandler['implementation']; + hooks: Record; + messenger: Messenger; +}; /** - * Creates a factory that produces a JSON-RPC method middleware from a set of - * handlers. The returned factory validates that the hooks it receives match - * exactly the union of hook names declared by the handlers (no missing, no - * extraneous), then returns a middleware that dispatches requests by method - * name. + * Create a JSON-RPC middleware that handles the passed JSON-RPC method handlers using the messenger and hooks. * - * Consolidates the bespoke `makeMethodMiddlewareMaker` implementations from - * `metamask-extension` and `metamask-mobile`. - * - * @deprecated Use `createMethodMiddleware` from `./v2` instead. - * @param handlers - The method handlers the middleware should dispatch to. - * @param options - Options including the root messenger. - * @returns A function that takes the required hooks and returns a - * `JsonRpcMiddleware`. + * @deprecated Use the v2 `createMethodMiddleware` instead. + * @param options The options. + * @param options.handlers - The JSON-RPC method handler implementations. + * @param options.messenger - The messenger to be used by the handlers. + * @param options.hooks - The hooks to be used by the handlers. + * @returns A JsonRpcEngineV2 middleware. */ -export function createMethodMiddlewareFactory< - Hooks extends Record = never, - MessengerActions extends ActionConstraint = never, +export function createMethodMiddleware< + Handlers extends Record, >( - handlers: MethodHandler[], - options?: CreateMethodMiddlewareFactoryOptions, -): [Hooks] extends [never] - ? () => JsonRpcMiddleware - : (hooks: Hooks) => JsonRpcMiddleware { - const { onError } = options ?? {}; - const rootMessenger = - options?.messenger ?? - new Messenger({ - namespace: 'json-rpc-engine', - }); - - type ResolvedHandler = { - implementation: HandlerMiddlewareFunction< - Hooks, - MessengerActions, - JsonRpcParams, - Json - >; - hooks: Hooks; - messenger: Messenger; - }; - - const baseHandlers = handlers.map((handler) => ({ - handler, - messenger: createHandlerMessenger({ - namespace: handler.methodNames.join(':'), - actionNames: handler.actionNames, - rootMessenger, - }), - })); + options: CreateMethodMiddlewareOptions, +): JsonRpcMiddleware { + const { messenger: rootMessenger, onError } = options; + const allHooks = options.hooks as Record; const expectedHookNames = new Set( - handlers.flatMap((handler) => + Object.values(options.handlers).flatMap((handler) => handler.hookNames ? Object.getOwnPropertyNames(handler.hookNames) : [], ), ); + assertExpectedHooks(allHooks, expectedHookNames); + + const handlers = Object.entries(options.handlers).reduce< + Record + >((accumulator, [handlerName, handler]) => { + const handlerHooks = selectHooks(allHooks, handler.hookNames) ?? {}; + const handlerMessenger = createHandlerMessenger< + HandlerActions + >({ + namespace: handlerName, + actionNames: handler.actionNames as + | readonly HandlerActions['type'][] + | undefined, + rootMessenger, + }); - return ((hooks?: Hooks) => { - assertExpectedHooks( - (hooks ?? {}) as Record, - expectedHookNames, - ); + accumulator[handlerName] = { + implementation: handler.implementation, + hooks: handlerHooks, + messenger: handlerMessenger, + }; + return accumulator; + }, {}); - const resolvedHandlers = baseHandlers.reduce< - Record - >((map, { handler, messenger }) => { - const handlerHooks = (selectHooks(hooks, handler.hookNames) ?? - {}) as Hooks; - for (const methodName of handler.methodNames) { - map[methodName] = { - implementation: handler.implementation, - hooks: handlerHooks, + // This should technically use createAsyncMiddleware, but we get around this by catching + // all handler errors. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async (req, res, next, end) => { + const resolved = handlers[req.method]; + if (resolved) { + const { implementation, hooks: handlerHooks, messenger } = resolved; + try { + return await implementation( + req, + res, + next, + end, + handlerHooks, messenger, - }; - } - return map; - }, {}); - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const middleware: JsonRpcMiddleware = async ( - req, - res, - next, - end, - ) => { - const resolved = resolvedHandlers[req.method]; - if (resolved) { - const { implementation, hooks: handlerHooks, messenger } = resolved; - try { - return await implementation( - req, - res, - next, - end, - handlerHooks, - messenger, - ); - } catch (error) { - onError?.(error, req); - return end( - error instanceof Error - ? error - : rpcErrors.internal({ data: error as Json }), - ); - } + ); + } catch (error) { + onError?.(error, req); + return end( + error instanceof Error + ? error + : rpcErrors.internal({ data: error as Json }), + ); } + } - return next(); - }; - - return middleware; - }) as [Hooks] extends [never] - ? () => JsonRpcMiddleware - : (hooks: Hooks) => JsonRpcMiddleware; + return next(); + }; } diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index e14c4c6e7d8..e2b811b55aa 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -6,7 +6,7 @@ describe('@metamask/json-rpc-engine', () => { [ "asV2Middleware", "createAsyncMiddleware", - "createMethodMiddlewareFactory", + "createMethodMiddleware", "createScaffoldMiddleware", "getUniqueId", "createIdRemapMiddleware", diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 6a9b0df057d..746cd0c3628 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -5,12 +5,11 @@ export type { } from './createAsyncMiddleware'; export { createAsyncMiddleware } from './createAsyncMiddleware'; export type { - CreateMethodMiddlewareFactoryOptions, - HandlerMiddlewareFunction, - HookNames, + CreateMethodMiddlewareOptions, MethodHandler, + MethodHandlerImplementation, } from './createMethodMiddleware'; -export { createMethodMiddlewareFactory } from './createMethodMiddleware'; +export { createMethodMiddleware } from './createMethodMiddleware'; export { createScaffoldMiddleware } from './createScaffoldMiddleware'; export { getUniqueId } from './getUniqueId'; export { createIdRemapMiddleware } from './idRemapMiddleware'; diff --git a/packages/json-rpc-engine/src/middlewareUtils.ts b/packages/json-rpc-engine/src/middlewareUtils.ts deleted file mode 100644 index 7f9230e80e9..00000000000 --- a/packages/json-rpc-engine/src/middlewareUtils.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ActionConstraint } from '@metamask/messenger'; -import { Messenger } from '@metamask/messenger'; -import { hasProperty } from '@metamask/utils'; - -/** - * Returns the subset of the specified `hooks` that are included in the - * `hookNames` object. This is a Principle of Least Authority (POLA) measure - * to ensure that each RPC method implementation only has access to the - * API "hooks" it needs to do its job. - * - * @param hooks - The hooks to select from. - * @param hookNames - The names of the hooks to select. - * @returns The selected hooks, or `undefined` if `hookNames` is not provided. - * @template Hooks - The hooks to select from. - * @template HookName - The names of the hooks to select. - */ -export function selectHooks( - hooks: Hooks, - hookNames?: Record, -): Pick | undefined { - if (hookNames) { - return Object.keys(hookNames).reduce>>( - (subset, name) => { - const hookName = name as HookName; - subset[hookName] = hooks[hookName]; - return subset; - }, - {}, - ) as Pick; - } - return undefined; -} - -/** - * Asserts that `hooks` contains exactly the hook names in `expectedHookNames`. - * Throws on any missing hooks, then on any extraneous hooks. - * - * @param hooks - The hooks object to validate. - * @param expectedHookNames - The expected hook names. - */ -export function assertExpectedHooks( - hooks: Record, - expectedHookNames: Set, -): void { - const missingHookNames: string[] = []; - expectedHookNames.forEach((hookName) => { - if (!hasProperty(hooks, hookName)) { - missingHookNames.push(hookName); - } - }); - if (missingHookNames.length > 0) { - throw new Error( - `Missing expected hooks:\n\n${missingHookNames.join('\n')}\n`, - ); - } - - const extraneousHookNames = Object.getOwnPropertyNames(hooks).filter( - (hookName) => !expectedHookNames.has(hookName), - ); - if (extraneousHookNames.length > 0) { - throw new Error( - `Received unexpected hooks:\n\n${extraneousHookNames.join('\n')}\n`, - ); - } -} - -/** - * Creates a per-handler messenger namespaced to `namespace`, and delegates the - * specified `actionNames` from `rootMessenger` to it. This lets each handler - * call only the actions it declared, per POLA. - * - * @param options - The options. - * @param options.namespace - The namespace for the handler messenger. - * @param options.actionNames - Actions to delegate from the root messenger. - * @param options.rootMessenger - The root messenger to delegate from. - * @returns The per-handler messenger. - */ -export function createHandlerMessenger({ - namespace, - actionNames, - rootMessenger, -}: { - namespace: string; - actionNames: readonly Actions['type'][] | undefined; - rootMessenger: Messenger; -}): Messenger { - const handlerMessenger = new Messenger< - string, - Actions, - never, - typeof rootMessenger - >({ - namespace, - parent: rootMessenger, - }); - - rootMessenger.delegate({ - actions: (actionNames ?? []) as Actions['type'][], - messenger: handlerMessenger, - }); - - return handlerMessenger; -} diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts index 47bdebdae81..89b9ab441a8 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts @@ -1,20 +1,17 @@ import { ActionConstraint, Messenger } from '@metamask/messenger'; +import { JsonRpcMiddleware, Next } from './JsonRpcEngineV2'; +import { ContextConstraint } from './MiddlewareContext'; import { assertExpectedHooks, createHandlerMessenger, selectHooks, -} from '../middlewareUtils'; -import { JsonRpcMiddleware, Next } from './JsonRpcEngineV2'; -import { ContextConstraint } from './MiddlewareContext'; -import { Json, JsonRpcParams, JsonRpcRequest, UnionToIntersection, } from './utils'; -// The helpers below seem excessive, but they are required for inference of hooks/actions. type HandlerActions = Handler extends { implementation: (options: infer Options) => unknown; } @@ -31,6 +28,9 @@ type HandlerHooks = Handler extends { : never : never; +/** + * A `JsonRpcEngineV2` method middleware handler. + */ export type MethodHandler< Hooks extends Record = never, MessengerActions extends ActionConstraint = never, @@ -67,6 +67,9 @@ type AnyMethodHandler = { actionNames?: readonly string[]; }; +/** + * Options for {@link createMethodMiddleware}. + */ export type CreateMethodMiddlewareOptions< Handlers extends Record, > = { diff --git a/packages/json-rpc-engine/src/v2/index.test.ts b/packages/json-rpc-engine/src/v2/index.test.ts index 6a05a07af6e..a1e16b5ba66 100644 --- a/packages/json-rpc-engine/src/v2/index.test.ts +++ b/packages/json-rpc-engine/src/v2/index.test.ts @@ -15,6 +15,7 @@ describe('@metamask/json-rpc-engine/v2', () => { "getUniqueId", "isNotification", "isRequest", + "selectHooks", ] `); }); diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts index 9a937a60603..c64763a4c6c 100644 --- a/packages/json-rpc-engine/src/v2/index.ts +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -18,7 +18,12 @@ export type { export { JsonRpcServer } from './JsonRpcServer'; export { MiddlewareContext } from './MiddlewareContext'; export type { EmptyContext, ContextConstraint } from './MiddlewareContext'; -export { isNotification, isRequest, JsonRpcEngineError } from './utils'; +export { + isNotification, + isRequest, + JsonRpcEngineError, + selectHooks, +} from './utils'; export type { Json, JsonRpcCall, diff --git a/packages/json-rpc-engine/src/v2/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts index 75aeb3c073d..c9327bc502a 100644 --- a/packages/json-rpc-engine/src/v2/utils.ts +++ b/packages/json-rpc-engine/src/v2/utils.ts @@ -1,3 +1,5 @@ +import type { ActionConstraint } from '@metamask/messenger'; +import { Messenger } from '@metamask/messenger'; import { hasProperty, isObject } from '@metamask/utils'; import type { JsonRpcNotification, @@ -87,3 +89,105 @@ export class JsonRpcEngineError extends Error { return isInstance(value, JsonRpcEngineErrorSymbol); } } + +// Method middleware utils + +/** + * Returns the subset of the specified `hooks` that are included in the + * `hookNames` object. This is a Principle of Least Authority (POLA) measure + * to ensure that each RPC method implementation only has access to the + * API "hooks" it needs to do its job. + * + * @param hooks - The hooks to select from. + * @param hookNames - The names of the hooks to select. + * @returns The selected hooks, or `undefined` if `hookNames` is not provided. + * @template Hooks - The hooks to select from. + * @template HookName - The names of the hooks to select. + */ +export function selectHooks( + hooks: Hooks, + hookNames?: Record, +): Pick | undefined { + if (hookNames) { + return Object.keys(hookNames).reduce>>( + (subset, name) => { + const hookName = name as HookName; + subset[hookName] = hooks[hookName]; + return subset; + }, + {}, + ) as Pick; + } + return undefined; +} + +/** + * Asserts that `hooks` contains exactly the hook names in `expectedHookNames`. + * Throws on any missing hooks, then on any extraneous hooks. + * + * @param hooks - The hooks object to validate. + * @param expectedHookNames - The expected hook names. + */ +export function assertExpectedHooks( + hooks: Record, + expectedHookNames: Set, +): void { + const missingHookNames: string[] = []; + expectedHookNames.forEach((hookName) => { + if (!hasProperty(hooks, hookName)) { + missingHookNames.push(hookName); + } + }); + if (missingHookNames.length > 0) { + throw new Error( + `Missing expected hooks:\n\n${missingHookNames.join('\n')}\n`, + ); + } + + const extraneousHookNames = Object.getOwnPropertyNames(hooks).filter( + (hookName) => !expectedHookNames.has(hookName), + ); + if (extraneousHookNames.length > 0) { + throw new Error( + `Received unexpected hooks:\n\n${extraneousHookNames.join('\n')}\n`, + ); + } +} + +/** + * Creates a per-handler messenger namespaced to `namespace`, and delegates the + * specified `actionNames` from `rootMessenger` to it. This lets each handler + * call only the actions it declared, per POLA. + * + * @param options - The options. + * @param options.namespace - The namespace for the handler messenger. + * @param options.actionNames - Actions to delegate from the root messenger. + * @param options.rootMessenger - The root messenger to delegate from. + * @returns The per-handler messenger. + */ +export function createHandlerMessenger({ + namespace, + actionNames, + rootMessenger, +}: { + namespace: string; + actionNames: readonly Actions['type'][] | undefined; + rootMessenger: Messenger; +}): Messenger { + const handlerMessenger = new Messenger< + string, + Actions, + never, + typeof rootMessenger + >({ + namespace, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: (actionNames ?? []) as Actions['type'][], + messenger: handlerMessenger, + }); + + return handlerMessenger; +} From dc38a25627d5bafd4644cdd8506a1dbe9ce41ed4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:21:09 -0700 Subject: [PATCH 07/25] chore: Tweak permission-controller changelog --- packages/permission-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index a91b108a3a1..5e5f9b556c4 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `factoryHooks`, `validatorHooks`, and related fields from permission specification builders ([#8551](https://github.com/MetaMask/core/pull/8551)) - **BREAKING:** Remove permitted method handlers and types ([#8583](https://github.com/MetaMask/core/pull/8583)) - - The permitted method handlers were unused in practice. Replacement types are available in `@metamask/json-rpc-engine@10.3.0`. + - The permitted method handlers were unused in practice. Replacement types for generic RPC method implementations are available in `@metamask/json-rpc-engine@10.3.0`. ## [12.3.0] From fc5c197b99dbf3abfc643a82cbcf1f4cbe4eb26e Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:49:14 -0700 Subject: [PATCH 08/25] refactor: Address review --- .../src/createMethodMiddleware.test.ts | 16 ++++++--- .../src/createMethodMiddleware.ts | 36 ++++++++----------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts index c0e8da312cc..42a63d29cbe 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -7,6 +7,7 @@ import { } from '@metamask/utils'; import { + MethodHandler, MethodHandlerImplementation, createMethodMiddleware, } from './createMethodMiddleware'; @@ -49,7 +50,7 @@ const handlerImplementation: MethodHandlerImplementation = ( const handler = { implementation: handlerImplementation, hookNames: { hook1: true as const, hook2: true as const }, -}; +} satisfies MethodHandler; const getDefaultHooks = (): Hooks => ({ hook1: () => 42, @@ -281,12 +282,19 @@ describe('createMethodMiddleware', () => { }; const messengerHandler = { - implementation: (async (_req, res, _next, end, _hooks, messenger) => { + implementation: async ( + _req, + res, + _next, + end, + _hooks, + messenger, + ): Promise => { res.result = await messenger.call('Example:TestAction'); return end(); - }) as MethodHandlerImplementation, + }, actionNames: ['Example:TestAction'] as const, - }; + } satisfies MethodHandler; const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index cfbc2037843..53fd18b2bbd 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -83,7 +83,6 @@ export type MethodHandler< Params, Result >; - methodNames: string[]; } & ([Hooks] extends [never] ? { hookNames?: undefined } : { hookNames: { [Key in keyof Hooks]: true } }) & @@ -180,28 +179,21 @@ export function createMethodMiddleware< // all handler errors. // eslint-disable-next-line @typescript-eslint/no-misused-promises return async (req, res, next, end) => { - const resolved = handlers[req.method]; - if (resolved) { - const { implementation, hooks: handlerHooks, messenger } = resolved; - try { - return await implementation( - req, - res, - next, - end, - handlerHooks, - messenger, - ); - } catch (error) { - onError?.(error, req); - return end( - error instanceof Error - ? error - : rpcErrors.internal({ data: error as Json }), - ); - } + const handler = handlers[req.method]; + if (!handler) { + return next(); } - return next(); + const { implementation, hooks: handlerHooks, messenger } = handler; + try { + return await implementation(req, res, next, end, handlerHooks, messenger); + } catch (error) { + onError?.(error, req); + return end( + error instanceof Error + ? error + : rpcErrors.internal({ data: error as Json }), + ); + } }; } From ce46bdb898ef3a70293d0cfbf8fb10d6bda52888 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:23:50 -0700 Subject: [PATCH 09/25] feat(json-rpc-engine): optional root messenger for method middleware Overload createMethodMiddleware (v1 and v2) so messenger is optional when handlers do not use messenger actions. Move actionNames/root validation into createHandlerMessenger; add tests for no-messenger and missing-root cases. Made-with: Cursor --- .../src/createMethodMiddleware.test.ts | 82 ++++++++++--------- .../src/createMethodMiddleware.ts | 26 ++++-- .../src/v2/createMethodMiddleware.test.ts | 34 +++++++- .../src/v2/createMethodMiddleware.ts | 22 +++-- packages/json-rpc-engine/src/v2/utils.ts | 24 ++++-- 5 files changed, 125 insertions(+), 63 deletions(-) diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts index 42a63d29cbe..f9a8de09c49 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -57,44 +57,12 @@ const getDefaultHooks = (): Hooks => ({ hook2: () => 99, }); -const getRootMessenger = (): Messenger => - new Messenger({ namespace: MOCK_ANY_NAMESPACE }); - const method1 = 'method1'; describe('createMethodMiddleware', () => { - it('throws an error if a required hook is missing', () => { - const hooks = { hook1: (): number => 42 }; - - expect(() => - createMethodMiddleware({ - handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), - // @ts-expect-error Intentionally missing a required hook. - hooks, - }), - ).toThrow('Missing expected hooks'); - }); - - it('throws an error if an extraneous hook is provided', () => { - const hooks = { - ...getDefaultHooks(), - extraneousHook: (): number => 100, - }; - - expect(() => - createMethodMiddleware({ - handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), - hooks, - }), - ).toThrow('Received unexpected hooks'); - }); - it('calls the handler for the matching method (uses hook1)', async () => { const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), hooks: getDefaultHooks(), }); const engine = new JsonRpcEngine(); @@ -114,7 +82,6 @@ describe('createMethodMiddleware', () => { it('calls the handler for the matching method (uses hook2)', async () => { const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), hooks: getDefaultHooks(), }); const engine = new JsonRpcEngine(); @@ -134,7 +101,6 @@ describe('createMethodMiddleware', () => { it('does not call the handler for a non-matching method', async () => { const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), hooks: getDefaultHooks(), }); const engine = new JsonRpcEngine(); @@ -157,7 +123,6 @@ describe('createMethodMiddleware', () => { it('handles errors returned by the implementation', async () => { const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), hooks: getDefaultHooks(), }); const engine = new JsonRpcEngine(); @@ -180,7 +145,6 @@ describe('createMethodMiddleware', () => { it('handles errors thrown by the implementation', async () => { const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), hooks: getDefaultHooks(), }); const engine = new JsonRpcEngine(); @@ -203,7 +167,6 @@ describe('createMethodMiddleware', () => { it('handles non-errors thrown by the implementation', async () => { const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), hooks: getDefaultHooks(), }); const engine = new JsonRpcEngine(); @@ -227,7 +190,6 @@ describe('createMethodMiddleware', () => { const onError = jest.fn(); const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, - messenger: getRootMessenger(), hooks: getDefaultHooks(), onError, }); @@ -259,7 +221,6 @@ describe('createMethodMiddleware', () => { const middleware = createMethodMiddleware({ handlers: { noDeps: noDepsHandler }, - messenger: getRootMessenger(), hooks: {}, }); const engine = new JsonRpcEngine(); @@ -275,6 +236,23 @@ describe('createMethodMiddleware', () => { expect(response.result).toBe('no-deps'); }); + it('throws if handler actionNames are configured without a messenger', () => { + const actionHandler = { + implementation: (() => undefined) as JsonRpcMiddleware< + JsonRpcRequest, + Json + >, + actionNames: ['Example:TestAction'] as const, + }; + + expect(() => + createMethodMiddleware({ + handlers: { callAction: actionHandler }, + hooks: {}, + }), + ).toThrow('A messenger is required when a handler declares actionNames.'); + }); + it('passes a delegated messenger to the handler', async () => { type TestAction = { type: 'Example:TestAction'; @@ -321,4 +299,30 @@ describe('createMethodMiddleware', () => { expect(response.result).toBe('action-result'); }); + + it('throws an error if a required hook is missing', () => { + const hooks = { hook1: (): number => 42 }; + + expect(() => + createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + // @ts-expect-error Intentionally missing a required hook. + hooks, + }), + ).toThrow('Missing expected hooks'); + }); + + it('throws an error if an extraneous hook is provided', () => { + const hooks = { + ...getDefaultHooks(), + extraneousHook: (): number => 100, + }; + + expect(() => + createMethodMiddleware({ + handlers: { method1: handler, method2: handler }, + hooks, + }), + ).toThrow('Received unexpected hooks'); + }); }); diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index 53fd18b2bbd..389e31871d3 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -104,16 +104,10 @@ type AnyMethodHandler = { actionNames?: readonly string[]; }; -/** - * Options for {@link createMethodMiddleware}. - * - * @deprecated Use the v2 `createMethodMiddleware` instead. - */ -export type CreateMethodMiddlewareOptions< +type CreateMethodMiddlewareBaseOptions< Handlers extends Record, > = { handlers: Handlers; - messenger: Messenger>; hooks: UnionToIntersection>; /** * Called when a handler throws, before the error is forwarded to `end`. @@ -122,10 +116,26 @@ export type CreateMethodMiddlewareOptions< onError?: (error: unknown, request: JsonRpcRequest) => void; }; +/** + * Options for {@link createMethodMiddleware}. + * + * @deprecated Use the v2 `createMethodMiddleware` instead. + */ +export type CreateMethodMiddlewareOptions< + Handlers extends Record, +> = CreateMethodMiddlewareBaseOptions & + ([HandlerActions] extends [never] + ? { + messenger?: undefined; + } + : { + messenger: Messenger>; + }); + type ResolvedHandler = { implementation: AnyMethodHandler['implementation']; hooks: Record; - messenger: Messenger; + messenger?: Messenger | undefined; }; /** diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts index ea280b27c96..2c204762ad0 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts @@ -42,9 +42,25 @@ function setup(): { engine: JsonRpcEngineV2 } { return { engine }; } +function setupWithoutMessenger(): { engine: JsonRpcEngineV2 } { + const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), + } satisfies MethodHandler<{ testHook: () => Promise }>; + + const middleware = createMethodMiddleware({ + handlers: { getValueA }, + hooks: { testHook: async () => 'A' }, + }); + + const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + + return { engine }; +} + describe('createMethodMiddleware', () => { - it('passes in the requested hooks', async () => { - const { engine } = setup(); + it('passes in the requested hooks without a messenger', async () => { + const { engine } = setupWithoutMessenger(); const result = await engine.handle(makeRequest({ method: 'getValueA' })); expect(result).toBe('A'); @@ -64,4 +80,18 @@ describe('createMethodMiddleware', () => { engine.handle(makeRequest({ method: 'getValueC' })), ).rejects.toThrow('Nothing ended request'); }); + + it('throws if handler actionNames are configured without a messenger', () => { + const getValueB = { + actionNames: ['Example:TestAction'], + implementation: (): Promise => Promise.resolve('B'), + } satisfies MethodHandler; + + expect(() => + createMethodMiddleware({ + handlers: { getValueB }, + hooks: {}, + }), + ).toThrow('A messenger is required when a handler declares actionNames.'); + }); }); diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts index 89b9ab441a8..423496ea530 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts @@ -67,21 +67,31 @@ type AnyMethodHandler = { actionNames?: readonly string[]; }; -/** - * Options for {@link createMethodMiddleware}. - */ -export type CreateMethodMiddlewareOptions< +type CreateMethodMiddlewareBaseOptions< Handlers extends Record, > = { handlers: Handlers; - messenger: Messenger>; hooks: UnionToIntersection>; }; +/** + * Options for {@link createMethodMiddleware}. + */ +export type CreateMethodMiddlewareOptions< + Handlers extends Record, +> = CreateMethodMiddlewareBaseOptions & + ([HandlerActions] extends [never] + ? { + messenger?: undefined; + } + : { + messenger: Messenger>; + }); + type ResolvedHandler = { implementation: AnyMethodHandler['implementation']; hooks: Record; - messenger: Messenger; + messenger?: Messenger | undefined; }; /** diff --git a/packages/json-rpc-engine/src/v2/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts index c9327bc502a..ab80dd98c18 100644 --- a/packages/json-rpc-engine/src/v2/utils.ts +++ b/packages/json-rpc-engine/src/v2/utils.ts @@ -162,7 +162,8 @@ export function assertExpectedHooks( * @param options - The options. * @param options.namespace - The namespace for the handler messenger. * @param options.actionNames - Actions to delegate from the root messenger. - * @param options.rootMessenger - The root messenger to delegate from. + * @param options.rootMessenger - The root messenger to delegate from. Required + * when `actionNames` are provided. * @returns The per-handler messenger. */ export function createHandlerMessenger({ @@ -172,20 +173,27 @@ export function createHandlerMessenger({ }: { namespace: string; actionNames: readonly Actions['type'][] | undefined; - rootMessenger: Messenger; -}): Messenger { + rootMessenger?: Messenger | undefined; +}): Messenger | undefined { + if (!actionNames) { + return undefined; + } + + if (!rootMessenger) { + throw new Error( + 'A messenger is required when a handler declares actionNames.', + ); + } + const handlerMessenger = new Messenger< string, Actions, never, typeof rootMessenger - >({ - namespace, - parent: rootMessenger, - }); + >({ namespace, parent: rootMessenger }); rootMessenger.delegate({ - actions: (actionNames ?? []) as Actions['type'][], + actions: actionNames as Actions['type'][], messenger: handlerMessenger, }); From 473387f68b2d7388225e5aa1c6776567e59ab8a3 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:08:34 -0700 Subject: [PATCH 10/25] feat(json-rpc-engine): optional RequestExtras on legacy MethodHandler Add a fifth generic to MethodHandlerImplementation and MethodHandler so callers can type non-standard JSON-RPC request fields (e.g. origin) without widening JsonRpcEngine types. Extras are optionalized on the request parameter so middleware can pass a plain JsonRpcRequest. Add a test and changelog note. Made-with: Cursor --- packages/json-rpc-engine/CHANGELOG.md | 2 +- .../src/createMethodMiddleware.test.ts | 33 +++++++++++++++++++ .../src/createMethodMiddleware.ts | 16 +++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index 96265465a8d..055f9930117 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `createOriginMiddleware` utility to `v2` ([#8522](https://github.com/MetaMask/core/pull/8522)) -- Add `createMethodMiddleware` utility to `v2` ([#8506](https://github.com/MetaMask/core/pull/8506)) +- Add `createMethodMiddleware` utility to `v2` ([#8506](https://github.com/MetaMask/core/pull/8506), [#8583](https://github.com/MetaMask/core/pull/8583)) - This utility allows JSON-RPC method implementations to use both the hooks pattern and the messenger. - Add legacy `createMethodMiddlewareFactory` ([#8583](https://github.com/MetaMask/core/pull/8583)) - Consolidates bespoke `makeMethodMiddlewareMaker` implementations from the MetaMask extension and mobile clients. diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts index f9a8de09c49..d1e9ffdb446 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -3,6 +3,7 @@ import { assertIsJsonRpcFailure, assertIsJsonRpcSuccess, Json, + JsonRpcParams, JsonRpcRequest, } from '@metamask/utils'; @@ -236,6 +237,38 @@ describe('createMethodMiddleware', () => { expect(response.result).toBe('no-deps'); }); + it('allows typing non-standard request fields via RequestExtras', async () => { + const originHandler = { + implementation: (req, res, _next, end): void => { + res.result = req.origin ?? 'missing'; + return end(); + }, + } satisfies MethodHandler< + never, + never, + JsonRpcParams, + Json, + { origin: string } + >; + + const middleware = createMethodMiddleware({ + handlers: { reportOrigin: originHandler }, + hooks: {}, + }); + const engine = new JsonRpcEngine(); + engine.push(middleware); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'reportOrigin', + origin: 'https://example.com', + } as JsonRpcRequest & { origin: string }); + assertIsJsonRpcSuccess(response); + + expect(response.result).toBe('https://example.com'); + }); + it('throws if handler actionNames are configured without a messenger', () => { const actionHandler = { implementation: (() => undefined) as JsonRpcMiddleware< diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index 389e31871d3..eaed42616d9 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -47,6 +47,15 @@ type HandlerHooks = Handler extends { : never : never; +/** + * Optional fields merged into {@link JsonRpcRequest} for a method handler. + * Keys are optionalized so a {@link JsonRpcMiddleware} `req` value stays + * assignable at the engine boundary while handlers can narrow at runtime. + */ +type PartialRequestExtras> = { + [K in keyof Extras]: Extras[K]; +}; + /** * A {@link MethodHandler} implementation. * @@ -57,8 +66,9 @@ export type MethodHandlerImplementation< MessengerActions extends ActionConstraint = never, Params extends JsonRpcParams = JsonRpcParams, Result extends Json = Json, + RequestExtras extends Record = Record, > = ( - req: JsonRpcRequest, + req: JsonRpcRequest & PartialRequestExtras, res: PendingJsonRpcResponse, next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, @@ -76,12 +86,14 @@ export type MethodHandler< MessengerActions extends ActionConstraint = never, Params extends JsonRpcParams = JsonRpcParams, Result extends Json = Json, + RequestExtras extends Record = Record, > = { implementation: MethodHandlerImplementation< Hooks, MessengerActions, Params, - Result + Result, + RequestExtras >; } & ([Hooks] extends [never] ? { hookNames?: undefined } From afad6dca8b536c8ca2ce416ff054b7839c3586f3 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:42:19 -0700 Subject: [PATCH 11/25] refactor(multichain-api-middleware): Migrate to new method handler pattern --- eslint-suppressions.json | 4 +- .../multichain-api-middleware/CHANGELOG.md | 3 + .../src/handlers/index.ts | 16 ++++ .../src/handlers/wallet-createSession.ts | 70 ++++++++++------ .../src/handlers/wallet-getSession.ts | 55 ++++++++---- .../src/handlers/wallet-invokeMethod.test.ts | 16 ++++ .../src/handlers/wallet-invokeMethod.ts | 84 ++++++++++++------- .../src/handlers/wallet-revokeSession.ts | 36 +++++--- .../src/index.test.ts | 5 +- .../multichain-api-middleware/src/index.ts | 5 +- 10 files changed, 200 insertions(+), 94 deletions(-) create mode 100644 packages/multichain-api-middleware/src/handlers/index.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 793f8d99be9..5f29dfa2e73 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1334,7 +1334,7 @@ }, "packages/multichain-api-middleware/src/handlers/wallet-createSession.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 2 + "count": 1 }, "@typescript-eslint/prefer-nullish-coalescing": { "count": 2 @@ -2369,4 +2369,4 @@ "count": 10 } } -} +} \ No newline at end of file diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 5c651f7f6af..320ea17dd64 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Consolidate method handlers into a single `methodHandlers` export ([#8583](https://github.com/MetaMask/core/pull/8583)) + - The individual handler exports have been removed. They can still be accessed as properties on the `methodHandlers` export. + - The new handlers follow the format expected by `createMethodMiddleware` from `@metamask/json-rpc-engine@10.3.0`. - Bump `@metamask/json-rpc-engine` from `^10.2.3` to `^10.2.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) - Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) - Bump `@metamask/permission-controller` from `^12.2.1` to `^12.3.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) diff --git a/packages/multichain-api-middleware/src/handlers/index.ts b/packages/multichain-api-middleware/src/handlers/index.ts new file mode 100644 index 00000000000..36ee0634cae --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/index.ts @@ -0,0 +1,16 @@ +import { walletCreateSessionHandler } from './wallet-createSession'; +import { walletGetSessionHandler } from './wallet-getSession'; +import { walletInvokeMethodHandler } from './wallet-invokeMethod'; +import { walletRevokeSessionHandler } from './wallet-revokeSession'; + +type MethodHandlers = typeof walletCreateSessionHandler & + typeof walletGetSessionHandler & + typeof walletInvokeMethodHandler & + typeof walletRevokeSessionHandler; + +export const methodHandlers: Readonly = { + ...walletCreateSessionHandler, + ...walletGetSessionHandler, + ...walletInvokeMethodHandler, + ...walletRevokeSessionHandler, +} as const; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index 5844d6cfdda..10527923dad 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -19,6 +19,7 @@ import type { } from '@metamask/chain-agnostic-permission'; import { isEqualCaseInsensitive } from '@metamask/controller-utils'; import type { + MethodHandler, JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, } from '@metamask/json-rpc-engine'; @@ -37,13 +38,36 @@ import type { Hex, Json, JsonRpcRequest, - JsonRpcSuccess, + PendingJsonRpcResponse, } from '@metamask/utils'; import type { GrantedPermissions } from './types'; const SOLANA_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; +type WalletCreateSessionHooks = { + listAccounts: () => { address: string }[]; + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + requestPermissionsForOrigin: ( + requestedPermissions: RequestedPermissions, + metadata?: Record, + ) => Promise<[GrantedPermissions]>; + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + isNonEvmScopeSupported: (scope: CaipChainId) => boolean; + getNonEvmAccountAddresses: (scope: CaipChainId) => CaipAccountId[]; + sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; + trackSessionCreatedEvent?: ( + approvedCaip25CaveatValue: Caip25CaveatValue, + ) => void; +}; + +type Params = Caip25Authorization; + +type Result = { + sessionScopes: NormalizedScopesObject; + sessionProperties?: Record; +}; + /** * Handler for the `wallet_createSession` RPC method which is responsible * for prompting for approval and granting a CAIP-25 permission. @@ -70,32 +94,13 @@ const SOLANA_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; * @param hooks.trackSessionCreatedEvent - An optional hook for platform specific logic to run. Can be undefined. * @returns A promise with wallet_createSession handler */ -async function walletCreateSessionHandler( - req: JsonRpcRequest & { origin: string }, - res: JsonRpcSuccess<{ - sessionScopes: NormalizedScopesObject; - sessionProperties?: Record; - }>, +async function handlewalletCreateSession( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, - hooks: { - listAccounts: () => { address: string }[]; - findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; - requestPermissionsForOrigin: ( - requestedPermissions: RequestedPermissions, - metadata?: Record, - ) => Promise<[GrantedPermissions]>; - getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; - isNonEvmScopeSupported: (scope: CaipChainId) => boolean; - getNonEvmAccountAddresses: (scope: CaipChainId) => CaipAccountId[]; - sortAccountIdsByLastSelected: ( - accounts: CaipAccountId[], - ) => CaipAccountId[]; - trackSessionCreatedEvent?: ( - approvedCaip25CaveatValue: Caip25CaveatValue, - ) => void; - }, -) { + hooks: WalletCreateSessionHooks, +): Promise { if (!isPlainObject(req.params)) { return end(invalidParams({ data: { request: req } })); } @@ -285,9 +290,16 @@ async function walletCreateSessionHandler( } } +type WalletCreateSessionMethodHandler = MethodHandler< + WalletCreateSessionHooks, + never, + Params, + Result, + { origin: string } +>; + export const walletCreateSession = { - methodNames: ['wallet_createSession'], - implementation: walletCreateSessionHandler, + implementation: handlewalletCreateSession, hookNames: { findNetworkClientIdByChainId: true, listAccounts: true, @@ -298,4 +310,8 @@ export const walletCreateSession = { sortAccountIdsByLastSelected: true, trackSessionCreatedEvent: true, }, +} satisfies WalletCreateSessionMethodHandler; + +export const walletCreateSessionHandler = { + wallet_createSession: walletCreateSession, }; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts index cd1b62bcd71..29f5aa3859d 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts @@ -7,14 +7,31 @@ import { Caip25EndowmentPermissionName, getSessionScopes, } from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; import type { Caveat } from '@metamask/permission-controller'; -import type { CaipAccountId } from '@metamask/utils'; import type { + CaipAccountId, CaipChainId, + JsonRpcParams, JsonRpcRequest, - JsonRpcSuccess, + PendingJsonRpcResponse, } from '@metamask/utils'; +type WalletGetSessionResult = { sessionScopes: NormalizedScopesObject }; + +type WalletGetSessionHooks = { + getCaveatForOrigin: ( + endowmentPermissionName: string, + caveatType: string, + ) => Caveat; + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; +}; + /** * Handler for the `wallet_getSession` RPC method as specified by [CAIP-312](https://chainagnostic.org/CAIPs/caip-312). * The implementation below deviates from the linked spec in that it ignores the `sessionId` param entirely, @@ -31,21 +48,12 @@ import type { * @param hooks.sortAccountIdsByLastSelected - A function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by corresponding last selected account in the wallet. * @returns Nothing. */ -async function walletGetSessionHandler( +async function handleWalletGetSession( _request: JsonRpcRequest & { origin: string }, - response: JsonRpcSuccess<{ sessionScopes: NormalizedScopesObject }>, - _next: () => void, - end: () => void, - hooks: { - getCaveatForOrigin: ( - endowmentPermissionName: string, - caveatType: string, - ) => Caveat; - getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; - sortAccountIdsByLastSelected: ( - accounts: CaipAccountId[], - ) => CaipAccountId[]; - }, + response: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: WalletGetSessionHooks, ) { let caveat; try { @@ -71,12 +79,23 @@ async function walletGetSessionHandler( return end(); } +type WalletGetSessionMethodHandler = MethodHandler< + WalletGetSessionHooks, + never, + JsonRpcParams, + WalletGetSessionResult, + { origin: string } +>; + export const walletGetSession = { - methodNames: ['wallet_getSession'], - implementation: walletGetSessionHandler, + implementation: handleWalletGetSession, hookNames: { getCaveatForOrigin: true, getNonEvmSupportedMethods: true, sortAccountIdsByLastSelected: true, }, +} satisfies WalletGetSessionMethodHandler; + +export const walletGetSessionHandler = { + wallet_getSession: walletGetSession, }; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts index 42e4fb84a91..41166ccf735 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts @@ -118,6 +118,22 @@ describe('wallet_invokeMethod', () => { }); }); + it('returns invalid params when params is not a plain object', async () => { + const { handler, end, getCaveatForOrigin, next } = createMockedHandler(); + const request = { + ...createMockedRequest(), + params: [ + 'not-a-plain-object', + ] as unknown as WalletInvokeMethodRequest['params'], + }; + await handler(request); + expect(end).toHaveBeenCalledWith( + rpcErrors.invalidParams({ data: { request } }), + ); + expect(getCaveatForOrigin).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { const request = createMockedRequest(); const { handler, getCaveatForOrigin } = createMockedHandler(); diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts index c485109aba1..63ff87227d7 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts @@ -9,9 +9,19 @@ import { getSessionScopes, parseScopeString, } from '@metamask/chain-agnostic-permission'; +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, +} from '@metamask/json-rpc-engine'; import type { NetworkClientId } from '@metamask/network-controller'; import type { Caveat } from '@metamask/permission-controller'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import { + isPlainObject, + KnownCaipNamespace, + numberToHex, +} from '@metamask/utils'; import type { CaipAccountId, CaipChainId, @@ -20,14 +30,31 @@ import type { JsonRpcRequest, PendingJsonRpcResponse, } from '@metamask/utils'; -import { KnownCaipNamespace, numberToHex } from '@metamask/utils'; -export type WalletInvokeMethodRequest = JsonRpcRequest & { - origin: string; - params: { - scope: ExternalScopeString; - request: Pick; +export type WalletInvokeMethodParams = { + scope: ExternalScopeString; + request: Pick; +}; + +export type WalletInvokeMethodRequest = + JsonRpcRequest & { + origin: string; }; + +type WalletInvokeMethodHooks = { + getCaveatForOrigin: ( + endowmentPermissionName: string, + caveatType: string, + ) => Caveat; + findNetworkClientIdByChainId: (chainId: Hex) => NetworkClientId | undefined; + getSelectedNetworkClientId: () => NetworkClientId; + getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; + sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; + handleNonEvmRequestForOrigin: (params: { + connectedAddresses: CaipAccountId[]; + scope: CaipChainId; + request: JsonRpcRequest; + }) => Promise; }; /** @@ -48,29 +75,16 @@ export type WalletInvokeMethodRequest = JsonRpcRequest & { * @param hooks.handleNonEvmRequestForOrigin - A function that sends a request to the MultichainRouter for processing. * @returns Nothing. */ -async function walletInvokeMethodHandler( +async function handleWalletInvokeMethod( request: WalletInvokeMethodRequest, - response: PendingJsonRpcResponse, - next: () => void, - end: (error?: Error) => void, - hooks: { - getCaveatForOrigin: ( - endowmentPermissionName: string, - caveatType: string, - ) => Caveat; - findNetworkClientIdByChainId: (chainId: Hex) => NetworkClientId | undefined; - getSelectedNetworkClientId: () => NetworkClientId; - getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; - sortAccountIdsByLastSelected: ( - accounts: CaipAccountId[], - ) => CaipAccountId[]; - handleNonEvmRequestForOrigin: (params: { - connectedAddresses: CaipAccountId[]; - scope: CaipChainId; - request: JsonRpcRequest; - }) => Promise; - }, + response: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: WalletInvokeMethodHooks, ) { + if (!isPlainObject(request.params)) { + return end(rpcErrors.invalidParams({ data: { request } })); + } const { scope, request: wrappedRequest } = request.params; assertIsInternalScopeString(scope); @@ -151,9 +165,17 @@ async function walletInvokeMethodHandler( } return end(); } + +type WalletInvokeMethodMethodHandler = MethodHandler< + WalletInvokeMethodHooks, + never, + WalletInvokeMethodParams, + Json, + { origin: string } +>; + export const walletInvokeMethod = { - methodNames: ['wallet_invokeMethod'], - implementation: walletInvokeMethodHandler, + implementation: handleWalletInvokeMethod, hookNames: { getCaveatForOrigin: true, findNetworkClientIdByChainId: true, @@ -162,4 +184,8 @@ export const walletInvokeMethod = { sortAccountIdsByLastSelected: true, handleNonEvmRequestForOrigin: true, }, +} satisfies WalletInvokeMethodMethodHandler; + +export const walletInvokeMethodHandler = { + wallet_invokeMethod: walletInvokeMethod, }; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts index cf1a4752c28..cedf66bf9d3 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -5,8 +5,9 @@ import { getCaipAccountIdsFromCaip25CaveatValue, } from '@metamask/chain-agnostic-permission'; import type { - JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, } from '@metamask/json-rpc-engine'; import { CaveatMutatorOperation, @@ -15,10 +16,16 @@ import { } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; import { isObject } from '@metamask/utils'; -import type { JsonRpcSuccess, JsonRpcRequest } from '@metamask/utils'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; import type { WalletRevokeSessionHooks } from './types'; +type WalletRevokeSessionParams = { scopes?: string[] }; + /** * Check whether the given error is a permission error. * @@ -110,12 +117,9 @@ function partialRevokePermissions( * @param hooks.getCaveatForOrigin - The hook to fetch an existing caveat for the origin of the request. * @returns Nothing. */ -async function walletRevokeSessionHandler( - request: JsonRpcRequest & { - origin: string; - params: { scopes?: string[] }; - }, - response: JsonRpcSuccess, +async function handleWalletRevokeSession( + request: JsonRpcRequest & { origin: string }, + response: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, hooks: WalletRevokeSessionHooks, @@ -136,12 +140,24 @@ async function walletRevokeSessionHandler( response.result = true; return end(); } + +type WalletRevokeSessionMethodHandler = MethodHandler< + WalletRevokeSessionHooks, + never, + WalletRevokeSessionParams, + Json, + { origin: string } +>; + export const walletRevokeSession = { - methodNames: ['wallet_revokeSession'], - implementation: walletRevokeSessionHandler, + implementation: handleWalletRevokeSession, hookNames: { revokePermissionForOrigin: true, updateCaveat: true, getCaveatForOrigin: true, }, +} satisfies WalletRevokeSessionMethodHandler; + +export const walletRevokeSessionHandler = { + wallet_revokeSession: walletRevokeSession, }; diff --git a/packages/multichain-api-middleware/src/index.test.ts b/packages/multichain-api-middleware/src/index.test.ts index 5792d47e342..58bef74e359 100644 --- a/packages/multichain-api-middleware/src/index.test.ts +++ b/packages/multichain-api-middleware/src/index.test.ts @@ -4,10 +4,7 @@ describe('@metamask/multichain-api-middleware', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` [ - "walletCreateSession", - "walletGetSession", - "walletInvokeMethod", - "walletRevokeSession", + "methodHandlers", "multichainMethodCallValidatorMiddleware", "MultichainMiddlewareManager", "MultichainSubscriptionManager", diff --git a/packages/multichain-api-middleware/src/index.ts b/packages/multichain-api-middleware/src/index.ts index 739547ee4cf..ce6500298e8 100644 --- a/packages/multichain-api-middleware/src/index.ts +++ b/packages/multichain-api-middleware/src/index.ts @@ -1,7 +1,4 @@ -export { walletCreateSession } from './handlers/wallet-createSession'; -export { walletGetSession } from './handlers/wallet-getSession'; -export { walletInvokeMethod } from './handlers/wallet-invokeMethod'; -export { walletRevokeSession } from './handlers/wallet-revokeSession'; +export { methodHandlers } from './handlers'; export { multichainMethodCallValidatorMiddleware } from './middlewares/multichainMethodCallValidatorMiddleware'; export { MultichainMiddlewareManager } from './middlewares/MultichainMiddlewareManager'; From e7105fc1941e260216efeaca3d26326d9baf3db8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:06:09 -0700 Subject: [PATCH 12/25] refactor(eip1193-permission-middleware): Migrate to new method handler pattern --- .../CHANGELOG.md | 3 ++ .../src/index.test.ts | 4 +- .../src/index.ts | 16 +++++-- .../src/wallet-getPermissions.test.ts | 4 +- .../src/wallet-getPermissions.ts | 43 ++++++++++++------- .../src/wallet-requestPermissions.test.ts | 4 +- .../src/wallet-requestPermissions.ts | 40 +++++++++++------ .../src/wallet-revokePermissions.test.ts | 6 +-- .../src/wallet-revokePermissions.ts | 25 ++++++++--- 9 files changed, 98 insertions(+), 47 deletions(-) diff --git a/packages/eip1193-permission-middleware/CHANGELOG.md b/packages/eip1193-permission-middleware/CHANGELOG.md index bd8c25f51fb..f5bf5257ac8 100644 --- a/packages/eip1193-permission-middleware/CHANGELOG.md +++ b/packages/eip1193-permission-middleware/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Consolidate method handlers into a single `methodHandlers` export ([#8583](https://github.com/MetaMask/core/pull/8583)) + - The individual handler exports have been removed. They can still be accessed as properties on the `methodHandlers` export. + - The new handlers follow the format expected by `createMethodMiddleware` from `@metamask/json-rpc-engine@10.3.0`. - Bump `@metamask/chain-agnostic-permission` from `^1.4.0` to `^1.5.0` ([#8290](https://github.com/MetaMask/core/pull/8290)) - Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.2.4` ([#7642](https://github.com/MetaMask/core/pull/7642), [#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317)) - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) diff --git a/packages/eip1193-permission-middleware/src/index.test.ts b/packages/eip1193-permission-middleware/src/index.test.ts index 63fa4531016..11d5a120fe8 100644 --- a/packages/eip1193-permission-middleware/src/index.test.ts +++ b/packages/eip1193-permission-middleware/src/index.test.ts @@ -4,9 +4,7 @@ describe('@metamask/eip1193-permission-middleware', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` [ - "getPermissionsHandler", - "requestPermissionsHandler", - "revokePermissionsHandler", + "methodHandlers", ] `); }); diff --git a/packages/eip1193-permission-middleware/src/index.ts b/packages/eip1193-permission-middleware/src/index.ts index 3cf3a13c49a..ef6ee317810 100644 --- a/packages/eip1193-permission-middleware/src/index.ts +++ b/packages/eip1193-permission-middleware/src/index.ts @@ -1,3 +1,13 @@ -export { getPermissionsHandler } from './wallet-getPermissions'; -export { requestPermissionsHandler } from './wallet-requestPermissions'; -export { revokePermissionsHandler } from './wallet-revokePermissions'; +import { getPermissionsHandler } from './wallet-getPermissions'; +import { requestPermissionsHandler } from './wallet-requestPermissions'; +import { revokePermissionsHandler } from './wallet-revokePermissions'; + +type MethodHandlers = typeof getPermissionsHandler & + typeof requestPermissionsHandler & + typeof revokePermissionsHandler; + +export const methodHandlers: Readonly = { + ...getPermissionsHandler, + ...requestPermissionsHandler, + ...revokePermissionsHandler, +} as const; diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts index a13091bb69e..2d6b958e37c 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/utils'; import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -import { getPermissionsHandler } from './wallet-getPermissions'; +import { getPermissions } from './wallet-getPermissions'; jest.mock('@metamask/chain-agnostic-permission', () => ({ ...jest.requireActual('@metamask/chain-agnostic-permission'), @@ -70,7 +70,7 @@ const createMockedHandler = () => { id: 0, }; const handler = (request: JsonRpcRequest) => - getPermissionsHandler.implementation(request, response, next, end, { + getPermissions.implementation(request, response, next, end, { getPermissionsForOrigin, getAccounts, }); diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts index 705899e16ab..239146ef624 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -5,8 +5,9 @@ import { getPermittedEthChainIds, } from '@metamask/chain-agnostic-permission'; import type { - AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, + MethodHandler, } from '@metamask/json-rpc-engine'; import { MethodNames } from '@metamask/permission-controller'; import type { @@ -22,13 +23,34 @@ import type { import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -export const getPermissionsHandler = { - methodNames: [MethodNames.GetPermissions], +type GetPermissionsHooks = { + getPermissionsForOrigin: () => ReturnType< + PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + >['getPermissions'] + >; + getAccounts: (options?: { ignoreLock?: boolean }) => string[]; +}; + +type GetPermissionsHandler = MethodHandler< + GetPermissionsHooks, + never, + Json[], + Json, + { origin: string } +>; + +export const getPermissions = { implementation: getPermissionsImplementation, hookNames: { getPermissionsForOrigin: true, getAccounts: true, }, +} satisfies GetPermissionsHandler; + +export const getPermissionsHandler = { + [MethodNames.GetPermissions]: getPermissions, }; /** @@ -47,20 +69,9 @@ export const getPermissionsHandler = { async function getPermissionsImplementation( _req: JsonRpcRequest, res: PendingJsonRpcResponse, - _next: AsyncJsonRpcEngineNextCallback, + _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, - { - getPermissionsForOrigin, - getAccounts, - }: { - getPermissionsForOrigin: () => ReturnType< - PermissionController< - PermissionSpecificationConstraint, - CaveatSpecificationConstraint - >['getPermissions'] - >; - getAccounts: (options?: { ignoreLock?: boolean }) => string[]; - }, + { getPermissionsForOrigin, getAccounts }: GetPermissionsHooks, ) { const permissions = { ...getPermissionsForOrigin() }; const caip25Endowment = permissions[Caip25EndowmentPermissionName]; diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts index c14728e930d..068b1a33b3a 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts @@ -7,7 +7,7 @@ import type { RequestedPermissions } from '@metamask/permission-controller'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -import { requestPermissionsHandler } from './wallet-requestPermissions'; +import { requestPermissions } from './wallet-requestPermissions'; const getBaseRequest = (overrides = {}) => ({ jsonrpc: '2.0' as const, @@ -39,7 +39,7 @@ const createMockedHandler = () => { id: 0, }; const handler = (request: unknown) => - requestPermissionsHandler.implementation( + requestPermissions.implementation( request as JsonRpcRequest<[RequestedPermissions]> & { origin: string }, response, next, diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts index 6b882e3dc07..e465aceb5d1 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -6,8 +6,9 @@ import { } from '@metamask/chain-agnostic-permission'; import { isPlainObject } from '@metamask/controller-utils'; import type { - AsyncJsonRpcEngineNextCallback, + JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, + MethodHandler, } from '@metamask/json-rpc-engine'; import { invalidParams, MethodNames } from '@metamask/permission-controller'; import type { @@ -27,14 +28,35 @@ import { pick } from 'lodash'; import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -export const requestPermissionsHandler = { - methodNames: [MethodNames.RequestPermissions], +type RequestPermissionsHooks = { + getAccounts: () => string[]; + requestPermissionsForOrigin: ( + requestedPermissions: RequestedPermissions, + ) => Promise<[GrantedPermissions]>; + getCaip25PermissionFromLegacyPermissionsForOrigin: ( + requestedPermissions?: RequestedPermissions, + ) => RequestedPermissions; +}; + +type RequestPermissionsHandler = MethodHandler< + RequestPermissionsHooks, + never, + [RequestedPermissions], + Json, + { origin: string } +>; + +export const requestPermissions = { implementation: requestPermissionsImplementation, hookNames: { getAccounts: true, requestPermissionsForOrigin: true, getCaip25PermissionFromLegacyPermissionsForOrigin: true, }, +} satisfies RequestPermissionsHandler; + +export const requestPermissionsHandler = { + [MethodNames.RequestPermissions]: requestPermissions, }; type AbstractPermissionController = PermissionController< @@ -63,21 +85,13 @@ type GrantedPermissions = Awaited< async function requestPermissionsImplementation( req: JsonRpcRequest<[RequestedPermissions]> & { origin: string }, res: PendingJsonRpcResponse, - _next: AsyncJsonRpcEngineNextCallback, + _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, { getAccounts, requestPermissionsForOrigin, getCaip25PermissionFromLegacyPermissionsForOrigin, - }: { - getAccounts: () => string[]; - requestPermissionsForOrigin: ( - requestedPermissions: RequestedPermissions, - ) => Promise<[GrantedPermissions]>; - getCaip25PermissionFromLegacyPermissionsForOrigin: ( - requestedPermissions?: RequestedPermissions, - ) => RequestedPermissions; - }, + }: RequestPermissionsHooks, ) { const { params } = req; diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts index 34bb499c4c3..f805080d85b 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts @@ -7,7 +7,7 @@ import type { } from '@metamask/utils'; import { EndowmentTypes, RestrictedMethods } from './types'; -import { revokePermissionsHandler } from './wallet-revokePermissions'; +import { revokePermissions } from './wallet-revokePermissions'; const baseRequest = { jsonrpc: '2.0' as const, @@ -31,7 +31,7 @@ const createMockedHandler = () => { id: 0, }; const handler = (request: JsonRpcRequest) => - revokePermissionsHandler.implementation(request, response, next, end, { + revokePermissions.implementation(request, response, next, end, { revokePermissionsForOrigin, }); @@ -44,7 +44,7 @@ const createMockedHandler = () => { }; }; -describe('revokePermissionsHandler', () => { +describe('revokePermissions', () => { it('returns an error if params is malformed', () => { const { handler, end } = createMockedHandler(); diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts index 828679498bb..a5166dcc54d 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -1,7 +1,8 @@ import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permission'; import type { - AsyncJsonRpcEngineNextCallback, + JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, + MethodHandler, } from '@metamask/json-rpc-engine'; import { invalidParams, MethodNames } from '@metamask/permission-controller'; import { isNonEmptyArray } from '@metamask/utils'; @@ -13,13 +14,27 @@ import type { import { EndowmentTypes, RestrictedMethods } from './types'; -export const revokePermissionsHandler = { - methodNames: [MethodNames.RevokePermissions], +type RevokePermissionsHooks = { + revokePermissionsForOrigin: (permissionKeys: string[]) => void; +}; + +type RevokePermissionsHandler = MethodHandler< + RevokePermissionsHooks, + never, + Json[], + Json, + { origin: string } +>; + +export const revokePermissions = { implementation: revokePermissionsImplementation, hookNames: { revokePermissionsForOrigin: true, - updateCaveat: true, }, +} satisfies RevokePermissionsHandler; + +export const revokePermissionsHandler = { + [MethodNames.RevokePermissions]: revokePermissions, }; /** @@ -36,7 +51,7 @@ export const revokePermissionsHandler = { function revokePermissionsImplementation( req: JsonRpcRequest, res: PendingJsonRpcResponse, - _next: AsyncJsonRpcEngineNextCallback, + _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, { revokePermissionsForOrigin, From 45a82ea8e6000a58534a3681cc5c2bac56855a90 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:14:49 -0700 Subject: [PATCH 13/25] refactor: Minor fixups --- eslint-suppressions.json | 2 +- packages/json-rpc-engine/CHANGELOG.md | 2 +- .../json-rpc-engine/src/createMethodMiddleware.ts | 13 ++----------- .../src/handlers/wallet-createSession.ts | 4 ++-- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5f29dfa2e73..829215c5187 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2369,4 +2369,4 @@ "count": 10 } } -} \ No newline at end of file +} diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index 055f9930117..6efb15160d5 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `createOriginMiddleware` utility to `v2` ([#8522](https://github.com/MetaMask/core/pull/8522)) - Add `createMethodMiddleware` utility to `v2` ([#8506](https://github.com/MetaMask/core/pull/8506), [#8583](https://github.com/MetaMask/core/pull/8583)) - This utility allows JSON-RPC method implementations to use both the hooks pattern and the messenger. -- Add legacy `createMethodMiddlewareFactory` ([#8583](https://github.com/MetaMask/core/pull/8583)) +- Add legacy `createMethodMiddleware` ([#8583](https://github.com/MetaMask/core/pull/8583)) - Consolidates bespoke `makeMethodMiddlewareMaker` implementations from the MetaMask extension and mobile clients. - Handlers may now declare `actionNames` and receive a delegated messenger as the sixth argument to `implementation`, mirroring the v2 `createMethodMiddleware`. - Deprecated in favor of the v2 `createMethodMiddleware`. diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index eaed42616d9..c4edab423ef 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -1,5 +1,5 @@ import type { ActionConstraint } from '@metamask/messenger'; -import { Messenger } from '@metamask/messenger'; +import type { Messenger } from '@metamask/messenger'; import { rpcErrors } from '@metamask/rpc-errors'; import type { Json, @@ -47,15 +47,6 @@ type HandlerHooks = Handler extends { : never : never; -/** - * Optional fields merged into {@link JsonRpcRequest} for a method handler. - * Keys are optionalized so a {@link JsonRpcMiddleware} `req` value stays - * assignable at the engine boundary while handlers can narrow at runtime. - */ -type PartialRequestExtras> = { - [K in keyof Extras]: Extras[K]; -}; - /** * A {@link MethodHandler} implementation. * @@ -68,7 +59,7 @@ export type MethodHandlerImplementation< Result extends Json = Json, RequestExtras extends Record = Record, > = ( - req: JsonRpcRequest & PartialRequestExtras, + req: JsonRpcRequest & RequestExtras, res: PendingJsonRpcResponse, next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index 10527923dad..b331dc17dea 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -94,7 +94,7 @@ type Result = { * @param hooks.trackSessionCreatedEvent - An optional hook for platform specific logic to run. Can be undefined. * @returns A promise with wallet_createSession handler */ -async function handlewalletCreateSession( +async function handleWalletCreateSession( req: JsonRpcRequest & { origin: string }, res: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, @@ -299,7 +299,7 @@ type WalletCreateSessionMethodHandler = MethodHandler< >; export const walletCreateSession = { - implementation: handlewalletCreateSession, + implementation: handleWalletCreateSession, hookNames: { findNetworkClientIdByChainId: true, listAccounts: true, From 86dd857cda582d81c5b4ab8c46f54c6ea331c170 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:11:15 -0700 Subject: [PATCH 14/25] fix: Fix legacy method middleware hook inference --- eslint-suppressions.json | 2 +- .../src/index.test.ts | 29 +++++++++++++ .../src/wallet-getPermissions.ts | 2 +- .../src/wallet-requestPermissions.ts | 2 +- .../src/wallet-revokePermissions.ts | 2 +- .../src/createMethodMiddleware.ts | 9 +++- .../src/handlers/index.test.ts | 43 +++++++++++++++++++ .../src/handlers/types.ts | 18 -------- .../src/handlers/wallet-createSession.ts | 6 +-- .../src/handlers/wallet-getSession.ts | 2 +- .../src/handlers/wallet-invokeMethod.ts | 2 +- .../src/handlers/wallet-revokeSession.ts | 15 ++++++- 12 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 packages/multichain-api-middleware/src/handlers/index.test.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 829215c5187..5f29dfa2e73 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2369,4 +2369,4 @@ "count": 10 } } -} +} \ No newline at end of file diff --git a/packages/eip1193-permission-middleware/src/index.test.ts b/packages/eip1193-permission-middleware/src/index.test.ts index 11d5a120fe8..09a1892a4af 100644 --- a/packages/eip1193-permission-middleware/src/index.test.ts +++ b/packages/eip1193-permission-middleware/src/index.test.ts @@ -1,4 +1,25 @@ +import { createMethodMiddleware } from '@metamask/json-rpc-engine'; + import * as allExports from '.'; +import type { GetPermissionsHooks } from './wallet-getPermissions'; +import type { RequestPermissionsHooks } from './wallet-requestPermissions'; +import type { RevokePermissionsHooks } from './wallet-revokePermissions'; + +type Hooks = GetPermissionsHooks & + RequestPermissionsHooks & + RevokePermissionsHooks; + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +const makeMockHooks = () => + ({ + getPermissionsForOrigin: (() => ({})) as Hooks['getPermissionsForOrigin'], + getAccounts: () => ['0x123'], + requestPermissionsForOrigin: (() => + Promise.resolve([{}])) as Hooks['requestPermissionsForOrigin'], + revokePermissionsForOrigin: () => undefined, + getCaip25PermissionFromLegacyPermissionsForOrigin: () => ({}), + }) satisfies Hooks; +/* eslint-enable @typescript-eslint/explicit-function-return-type */ describe('@metamask/eip1193-permission-middleware', () => { it('has expected JavaScript exports', () => { @@ -8,4 +29,12 @@ describe('@metamask/eip1193-permission-middleware', () => { ] `); }); + + it('constructs a method middleware from the handlers', () => { + const middleware = createMethodMiddleware({ + handlers: allExports.methodHandlers, + hooks: makeMockHooks(), + }); + expect(middleware).toBeDefined(); + }); }); diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts index 239146ef624..dcd845b5202 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -23,7 +23,7 @@ import type { import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -type GetPermissionsHooks = { +export type GetPermissionsHooks = { getPermissionsForOrigin: () => ReturnType< PermissionController< PermissionSpecificationConstraint, diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts index e465aceb5d1..7636a508538 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -28,7 +28,7 @@ import { pick } from 'lodash'; import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -type RequestPermissionsHooks = { +export type RequestPermissionsHooks = { getAccounts: () => string[]; requestPermissionsForOrigin: ( requestedPermissions: RequestedPermissions, diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts index a5166dcc54d..09b0e9b6095 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -14,7 +14,7 @@ import type { import { EndowmentTypes, RestrictedMethods } from './types'; -type RevokePermissionsHooks = { +export type RevokePermissionsHooks = { revokePermissionsForOrigin: (permissionKeys: string[]) => void; }; diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index c4edab423ef..f59bb2d5423 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -40,7 +40,14 @@ type HandlerActions = Handler extends { type HandlerHooks = Handler extends { implementation: (...args: infer Args) => unknown; } - ? Args extends [unknown, unknown, unknown, unknown, infer ArgHooks, unknown] + ? Args extends [ + unknown, + unknown, + unknown, + unknown, + infer ArgHooks, + ...unknown[], + ] ? ArgHooks extends Record ? ArgHooks : never diff --git a/packages/multichain-api-middleware/src/handlers/index.test.ts b/packages/multichain-api-middleware/src/handlers/index.test.ts new file mode 100644 index 00000000000..5aa56316d42 --- /dev/null +++ b/packages/multichain-api-middleware/src/handlers/index.test.ts @@ -0,0 +1,43 @@ +import { createMethodMiddleware } from '@metamask/json-rpc-engine'; + +import { methodHandlers } from '.'; +import type { WalletCreateSessionHooks } from './wallet-createSession'; +import type { WalletGetSessionHooks } from './wallet-getSession'; +import type { WalletInvokeMethodHooks } from './wallet-invokeMethod'; +import type { WalletRevokeSessionHooks } from './wallet-revokeSession'; + +type Hooks = WalletCreateSessionHooks & + WalletGetSessionHooks & + WalletInvokeMethodHooks & + WalletRevokeSessionHooks; + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +const makeMockHooks = () => + ({ + listAccounts: () => [{ address: '0x123' }], + findNetworkClientIdByChainId: (() => + '1') as Hooks['findNetworkClientIdByChainId'], + requestPermissionsForOrigin: (() => + Promise.resolve([{}])) as Hooks['requestPermissionsForOrigin'], + getNonEvmSupportedMethods: () => [], + isNonEvmScopeSupported: () => false, + getNonEvmAccountAddresses: () => [], + sortAccountIdsByLastSelected: () => [], + getCaveatForOrigin: (() => ({}) as unknown) as Hooks['getCaveatForOrigin'], + getSelectedNetworkClientId: () => 'mainnet', + handleNonEvmRequestForOrigin: () => Promise.resolve(null), + revokePermissionForOrigin: () => undefined, + updateCaveat: () => undefined, + trackSessionCreatedEvent: () => undefined, + }) satisfies Hooks; +/* eslint-enable @typescript-eslint/explicit-function-return-type */ + +describe('methodHandlers', () => { + it('constructs a method middleware from the handlers', () => { + const middleware = createMethodMiddleware({ + handlers: methodHandlers, + hooks: makeMockHooks(), + }); + expect(middleware).toBeDefined(); + }); +}); diff --git a/packages/multichain-api-middleware/src/handlers/types.ts b/packages/multichain-api-middleware/src/handlers/types.ts index b5a4bec7c83..5c0f3f336a6 100644 --- a/packages/multichain-api-middleware/src/handlers/types.ts +++ b/packages/multichain-api-middleware/src/handlers/types.ts @@ -1,9 +1,4 @@ import type { - Caip25CaveatType, - Caip25CaveatValue, -} from '@metamask/chain-agnostic-permission'; -import type { - Caveat, CaveatSpecificationConstraint, PermissionController, PermissionSpecificationConstraint, @@ -24,16 +19,3 @@ type AbstractPermissionController = PermissionController< export type GrantedPermissions = Awaited< ReturnType >[0]; - -export type WalletRevokeSessionHooks = { - revokePermissionForOrigin: (permissionName: string) => void; - updateCaveat: ( - target: string, - caveatType: string, - caveatValue: Caip25CaveatValue, - ) => void; - getCaveatForOrigin: ( - endowmentPermissionName: string, - caveatType: string, - ) => Caveat; -}; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index b331dc17dea..5ec7634632d 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -45,7 +45,7 @@ import type { GrantedPermissions } from './types'; const SOLANA_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; -type WalletCreateSessionHooks = { +export type WalletCreateSessionHooks = { listAccounts: () => { address: string }[]; findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; requestPermissionsForOrigin: ( @@ -56,7 +56,7 @@ type WalletCreateSessionHooks = { isNonEvmScopeSupported: (scope: CaipChainId) => boolean; getNonEvmAccountAddresses: (scope: CaipChainId) => CaipAccountId[]; sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; - trackSessionCreatedEvent?: ( + trackSessionCreatedEvent: ( approvedCaip25CaveatValue: Caip25CaveatValue, ) => void; }; @@ -278,7 +278,7 @@ async function handleWalletCreateSession( const { sessionProperties: approvedSessionProperties = {} } = approvedCaip25CaveatValue; - hooks.trackSessionCreatedEvent?.(approvedCaip25CaveatValue); + hooks.trackSessionCreatedEvent(approvedCaip25CaveatValue); res.result = { sessionScopes, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts index 29f5aa3859d..c5880c0a570 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts @@ -23,7 +23,7 @@ import type { type WalletGetSessionResult = { sessionScopes: NormalizedScopesObject }; -type WalletGetSessionHooks = { +export type WalletGetSessionHooks = { getCaveatForOrigin: ( endowmentPermissionName: string, caveatType: string, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts index 63ff87227d7..e5eee158e17 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts @@ -41,7 +41,7 @@ export type WalletInvokeMethodRequest = origin: string; }; -type WalletInvokeMethodHooks = { +export type WalletInvokeMethodHooks = { getCaveatForOrigin: ( endowmentPermissionName: string, caveatType: string, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts index cedf66bf9d3..3d3ca4d1110 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -4,6 +4,7 @@ import { Caip25EndowmentPermissionName, getCaipAccountIdsFromCaip25CaveatValue, } from '@metamask/chain-agnostic-permission'; +import type { Caip25CaveatValue } from '@metamask/chain-agnostic-permission'; import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, @@ -14,6 +15,7 @@ import { PermissionDoesNotExistError, UnrecognizedSubjectError, } from '@metamask/permission-controller'; +import type { Caveat } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; import { isObject } from '@metamask/utils'; import type { @@ -22,7 +24,18 @@ import type { PendingJsonRpcResponse, } from '@metamask/utils'; -import type { WalletRevokeSessionHooks } from './types'; +export type WalletRevokeSessionHooks = { + revokePermissionForOrigin: (permissionName: string) => void; + updateCaveat: ( + target: string, + caveatType: string, + caveatValue: Caip25CaveatValue, + ) => void; + getCaveatForOrigin: ( + endowmentPermissionName: string, + caveatType: string, + ) => Caveat; +}; type WalletRevokeSessionParams = { scopes?: string[] }; From 3763b572cc373e42ff95c4b78baa12c70d30ef55 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:16:01 -0700 Subject: [PATCH 15/25] chore: Lint --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5f29dfa2e73..829215c5187 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2369,4 +2369,4 @@ "count": 10 } } -} \ No newline at end of file +} From c4a83395e5d089307b2b4a47b4ee3dccf05b10a8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:24:00 -0700 Subject: [PATCH 16/25] refactor: Fix trackSessionCreatedEvent optionality --- .../multichain-api-middleware/CHANGELOG.md | 2 ++ .../src/handlers/index.test.ts | 2 +- .../src/handlers/wallet-createSession.test.ts | 18 ++++++++++++++---- .../src/handlers/wallet-createSession.ts | 10 +++++----- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 320ea17dd64..d5ebb06d472 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Consolidate method handlers into a single `methodHandlers` export ([#8583](https://github.com/MetaMask/core/pull/8583)) - The individual handler exports have been removed. They can still be accessed as properties on the `methodHandlers` export. - The new handlers follow the format expected by `createMethodMiddleware` from `@metamask/json-rpc-engine@10.3.0`. +- **BREAKING:** Make `trackSessionCreatedEvent` hook required in `wallet_createSession` handler ([#8583](https://github.com/MetaMask/core/pull/8583)) + - If the hook is not required, `null` can be passed instead. - Bump `@metamask/json-rpc-engine` from `^10.2.3` to `^10.2.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) - Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) - Bump `@metamask/permission-controller` from `^12.2.1` to `^12.3.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) diff --git a/packages/multichain-api-middleware/src/handlers/index.test.ts b/packages/multichain-api-middleware/src/handlers/index.test.ts index 5aa56316d42..14383507e45 100644 --- a/packages/multichain-api-middleware/src/handlers/index.test.ts +++ b/packages/multichain-api-middleware/src/handlers/index.test.ts @@ -28,7 +28,7 @@ const makeMockHooks = () => handleNonEvmRequestForOrigin: () => Promise.resolve(null), revokePermissionForOrigin: () => undefined, updateCaveat: () => undefined, - trackSessionCreatedEvent: () => undefined, + trackSessionCreatedEvent: null, }) satisfies Hooks; /* eslint-enable @typescript-eslint/explicit-function-return-type */ diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts index cc9711b0aa1..97c8289e71b 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts @@ -63,7 +63,7 @@ const baseRequest = { }, }; -const createMockedHandler = () => { +const createMockedHandler = (trackSessionEvents: boolean = true) => { const next = jest.fn(); const end = jest.fn(); const requestPermissionsForOrigin = jest.fn().mockResolvedValue([ @@ -92,7 +92,9 @@ const createMockedHandler = () => { }, ]); const findNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); - const trackSessionCreatedEvent = jest.fn().mockImplementation(undefined); + const trackSessionCreatedEvent = trackSessionEvents + ? jest.fn().mockImplementation(undefined) + : null; const listAccounts = jest.fn().mockReturnValue([]); const getNonEvmSupportedMethods = jest.fn().mockReturnValue([]); const isNonEvmScopeSupported = jest.fn().mockReturnValue(false); @@ -722,9 +724,17 @@ describe('wallet_createSession', () => { ); }); - it('calls trackSessionCreatedEvent hook if defined', async () => { + it('ignores trackSessionCreatedEvent hook if it is null', async () => { + const { handler, trackSessionCreatedEvent } = createMockedHandler(false); + await handler(baseRequest); + + expect(trackSessionCreatedEvent).toBeNull(); + }); + it('calls trackSessionCreatedEvent hook if not null', async () => { const { handler, trackSessionCreatedEvent } = createMockedHandler(); - trackSessionCreatedEvent.mockImplementation(() => { + expect(trackSessionCreatedEvent).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + trackSessionCreatedEvent!.mockImplementation(() => { // mock implementation }); await handler(baseRequest); diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index 5ec7634632d..9c3fb718500 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -56,9 +56,9 @@ export type WalletCreateSessionHooks = { isNonEvmScopeSupported: (scope: CaipChainId) => boolean; getNonEvmAccountAddresses: (scope: CaipChainId) => CaipAccountId[]; sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; - trackSessionCreatedEvent: ( - approvedCaip25CaveatValue: Caip25CaveatValue, - ) => void; + trackSessionCreatedEvent: + | ((approvedCaip25CaveatValue: Caip25CaveatValue) => void) + | null; }; type Params = Caip25Authorization; @@ -91,7 +91,7 @@ type Result = { * @param hooks.isNonEvmScopeSupported - The hook that returns true if a non EVM scope is supported. * @param hooks.getNonEvmAccountAddresses - The hook that returns a list of CaipAccountIds that are supported for a CaipChainId. * @param hooks.sortAccountIdsByLastSelected - A function that accepts an array of CaipAccountId and returns an array of CaipAccountId sorted by last selected. - * @param hooks.trackSessionCreatedEvent - An optional hook for platform specific logic to run. Can be undefined. + * @param hooks.trackSessionCreatedEvent - An optional hook for platform specific logic to run. * @returns A promise with wallet_createSession handler */ async function handleWalletCreateSession( @@ -278,7 +278,7 @@ async function handleWalletCreateSession( const { sessionProperties: approvedSessionProperties = {} } = approvedCaip25CaveatValue; - hooks.trackSessionCreatedEvent(approvedCaip25CaveatValue); + hooks.trackSessionCreatedEvent?.(approvedCaip25CaveatValue); res.result = { sessionScopes, From f43f66436b26c5990a6b7e350268298432dd737c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:46:34 -0700 Subject: [PATCH 17/25] fix: Require hooks in createMethodMiddleware options Type the `hooks` property as `Record` (rather than collapsing to `unknown` via `UnionToIntersection`) when no handler declares hooks. This forces callers to spell out `hooks: {}` and prevents TypeScript from co-resolving the shared `Handlers` generic in a way that also relaxes the `messenger` requirement. Co-Authored-By: Claude Opus 4.7 --- packages/json-rpc-engine/src/createMethodMiddleware.ts | 8 +++++++- packages/json-rpc-engine/src/v2/createMethodMiddleware.ts | 4 +++- .../src/handlers/wallet-invokeMethod.ts | 6 +++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.ts b/packages/json-rpc-engine/src/createMethodMiddleware.ts index f59bb2d5423..5425fcec67d 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.ts @@ -118,7 +118,13 @@ type CreateMethodMiddlewareBaseOptions< Handlers extends Record, > = { handlers: Handlers; - hooks: UnionToIntersection>; + // Due to a quirk of TypeScript's inference over generics, the hooks property must + // be present even if no hooks are needed. Otherwise, TypeScript will fail to infer + // the correct type for the messenger property. `Record` is the + // (hopefully) least confusing way to satisfy this requirement. + hooks: [HandlerHooks] extends [never] + ? Record + : UnionToIntersection>; /** * Called when a handler throws, before the error is forwarded to `end`. * Intended for logging; must not throw. diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts index 423496ea530..d1310b9c1bd 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts @@ -71,7 +71,9 @@ type CreateMethodMiddlewareBaseOptions< Handlers extends Record, > = { handlers: Handlers; - hooks: UnionToIntersection>; + hooks: [HandlerHooks] extends [never] + ? Record + : UnionToIntersection>; }; /** diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts index e5eee158e17..6417452f180 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts @@ -18,7 +18,7 @@ import type { NetworkClientId } from '@metamask/network-controller'; import type { Caveat } from '@metamask/permission-controller'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import { - isPlainObject, + isObject, KnownCaipNamespace, numberToHex, } from '@metamask/utils'; @@ -82,11 +82,11 @@ async function handleWalletInvokeMethod( end: JsonRpcEngineEndCallback, hooks: WalletInvokeMethodHooks, ) { - if (!isPlainObject(request.params)) { + if (!isObject(request.params)) { return end(rpcErrors.invalidParams({ data: { request } })); } - const { scope, request: wrappedRequest } = request.params; + const { scope, request: wrappedRequest } = request.params; assertIsInternalScopeString(scope); let caveat; From 7114b69f9b47822e5984a7adb5e60cf966d6d36a Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:49:54 -0700 Subject: [PATCH 18/25] test: Round out v2 method middleware test suite --- .../src/v2/createMethodMiddleware.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts index 2c204762ad0..7b11bc47d88 100644 --- a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts @@ -81,6 +81,39 @@ describe('createMethodMiddleware', () => { ).rejects.toThrow('Nothing ended request'); }); + it('handles a handler with no hooks or actions', async () => { + const noDeps = { + implementation: (): Promise => Promise.resolve('ok'), + } satisfies MethodHandler; + + const middleware = createMethodMiddleware({ + handlers: { noDeps }, + hooks: {}, + }); + const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + + const result = await engine.handle(makeRequest({ method: 'noDeps' })); + expect(result).toBe('ok'); + }); + + it('propagates errors thrown by the implementation', async () => { + const failing = { + implementation: (): Promise => { + throw new Error('test error'); + }, + } satisfies MethodHandler; + + const middleware = createMethodMiddleware({ + handlers: { failing }, + hooks: {}, + }); + const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + + await expect( + engine.handle(makeRequest({ method: 'failing' })), + ).rejects.toThrow('test error'); + }); + it('throws if handler actionNames are configured without a messenger', () => { const getValueB = { actionNames: ['Example:TestAction'], @@ -94,4 +127,38 @@ describe('createMethodMiddleware', () => { }), ).toThrow('A messenger is required when a handler declares actionNames.'); }); + + it('throws if a required hook is missing', () => { + const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), + } satisfies MethodHandler<{ testHook: () => Promise }>; + + expect(() => + createMethodMiddleware({ + handlers: { getValueA }, + // @ts-expect-error Intentionally missing a required hook. + hooks: {}, + }), + ).toThrow('Missing expected hooks'); + }); + + it('throws if an extraneous hook is provided', () => { + const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), + } satisfies MethodHandler<{ testHook: () => Promise }>; + + const hooks = { + testHook: async (): Promise => 'A', + extraneousHook: (): number => 100, + }; + + expect(() => + createMethodMiddleware({ + handlers: { getValueA }, + hooks, + }), + ).toThrow('Received unexpected hooks'); + }); }); From 897b1aed8824e80b51aa8428926526a1c1df0a19 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:29:34 -0700 Subject: [PATCH 19/25] chore: lint --- .../src/handlers/wallet-invokeMethod.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts index 6417452f180..f88712b3a65 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts @@ -17,11 +17,7 @@ import type { import type { NetworkClientId } from '@metamask/network-controller'; import type { Caveat } from '@metamask/permission-controller'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; -import { - isObject, - KnownCaipNamespace, - numberToHex, -} from '@metamask/utils'; +import { isObject, KnownCaipNamespace, numberToHex } from '@metamask/utils'; import type { CaipAccountId, CaipChainId, From 846bfc4767103cd114204ab113bbb46710a3f903 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:26:48 -0700 Subject: [PATCH 20/25] refactor: Address review feedback --- .../src/createMethodMiddleware.test.ts | 169 +++++++++--------- packages/json-rpc-engine/src/v2/utils.ts | 9 +- .../multichain-api-middleware/CHANGELOG.md | 5 + 3 files changed, 92 insertions(+), 91 deletions(-) diff --git a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts index d1e9ffdb446..9e5f60563a1 100644 --- a/packages/json-rpc-engine/src/createMethodMiddleware.test.ts +++ b/packages/json-rpc-engine/src/createMethodMiddleware.test.ts @@ -14,54 +14,40 @@ import { } from './createMethodMiddleware'; import { JsonRpcEngine, JsonRpcMiddleware } from './JsonRpcEngine'; -type Hooks = { +type AllHooks = { hook1: () => number; hook2: () => number; }; -const handlerImplementation: MethodHandlerImplementation = ( - req, - res, - _next, - end, - hooks, -): void => { - if (Array.isArray(req.params)) { - switch (req.params[0]) { - case 1: - res.result = hooks.hook1(); - break; - case 2: - res.result = hooks.hook2(); - break; - case 3: - return end(new Error('test error')); - case 4: - throw new Error('test error'); - case 5: - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw 'foo'; - default: - throw new Error(`unexpected param "${JSON.stringify(req.params[0])}"`); - } - } - return end(); -}; - -const handler = { - implementation: handlerImplementation, - hookNames: { hook1: true as const, hook2: true as const }, -} satisfies MethodHandler; - -const getDefaultHooks = (): Hooks => ({ +const getDefaultHooks = (): AllHooks => ({ hook1: () => 42, hook2: () => 99, }); +const makeHandler = >( + implementation: MethodHandlerImplementation, + hookNames: { [Name in keyof Hooks]: true }, + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type +) => ({ implementation, hookNames }); + const method1 = 'method1'; +const baseRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: method1, +}; + describe('createMethodMiddleware', () => { it('calls the handler for the matching method (uses hook1)', async () => { + const handler = makeHandler( + (_req, res, _next, end, hooks) => { + res.result = hooks.hook1(); + return end(); + }, + { hook1: true, hook2: true }, + ); + const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, hooks: getDefaultHooks(), @@ -69,18 +55,21 @@ describe('createMethodMiddleware', () => { const engine = new JsonRpcEngine(); engine.push(middleware); - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: method1, - params: [1], - }); + const response = await engine.handle(baseRequest); assertIsJsonRpcSuccess(response); expect(response.result).toBe(42); }); it('calls the handler for the matching method (uses hook2)', async () => { + const handler = makeHandler( + (_req, res, _next, end, hooks) => { + res.result = hooks.hook2(); + return end(); + }, + { hook1: true, hook2: true }, + ); + const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, hooks: getDefaultHooks(), @@ -88,18 +77,21 @@ describe('createMethodMiddleware', () => { const engine = new JsonRpcEngine(); engine.push(middleware); - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: method1, - params: [2], - }); + const response = await engine.handle(baseRequest); assertIsJsonRpcSuccess(response); expect(response.result).toBe(99); }); it('does not call the handler for a non-matching method', async () => { + const handler = makeHandler( + (_req, res, _next, end) => { + res.result = 'unreachable'; + return end(); + }, + { hook1: true, hook2: true }, + ); + const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, hooks: getDefaultHooks(), @@ -108,8 +100,7 @@ describe('createMethodMiddleware', () => { engine.push(middleware); const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, + ...baseRequest, method: 'nonMatchingMethod', }); assertIsJsonRpcFailure(response); @@ -122,6 +113,11 @@ describe('createMethodMiddleware', () => { }); it('handles errors returned by the implementation', async () => { + const handler = makeHandler( + (_req, _res, _next, end) => end(new Error('test error')), + { hook1: true, hook2: true }, + ); + const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, hooks: getDefaultHooks(), @@ -129,12 +125,7 @@ describe('createMethodMiddleware', () => { const engine = new JsonRpcEngine(); engine.push(middleware); - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: method1, - params: [3], - }); + const response = await engine.handle(baseRequest); assertIsJsonRpcFailure(response); expect(response.error.message).toBe('test error'); @@ -144,6 +135,13 @@ describe('createMethodMiddleware', () => { }); it('handles errors thrown by the implementation', async () => { + const handler = makeHandler( + () => { + throw new Error('test error'); + }, + { hook1: true, hook2: true }, + ); + const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, hooks: getDefaultHooks(), @@ -151,12 +149,7 @@ describe('createMethodMiddleware', () => { const engine = new JsonRpcEngine(); engine.push(middleware); - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: method1, - params: [4], - }); + const response = await engine.handle(baseRequest); assertIsJsonRpcFailure(response); expect(response.error.message).toBe('test error'); @@ -166,6 +159,14 @@ describe('createMethodMiddleware', () => { }); it('handles non-errors thrown by the implementation', async () => { + const handler = makeHandler( + () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'foo'; + }, + { hook1: true, hook2: true }, + ); + const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, hooks: getDefaultHooks(), @@ -173,12 +174,7 @@ describe('createMethodMiddleware', () => { const engine = new JsonRpcEngine(); engine.push(middleware); - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: method1, - params: [5], - }); + const response = await engine.handle(baseRequest); assertIsJsonRpcFailure(response); expect(response.error).toMatchObject({ @@ -189,6 +185,13 @@ describe('createMethodMiddleware', () => { it('invokes onError when a handler throws', async () => { const onError = jest.fn(); + const handler = makeHandler( + () => { + throw new Error('test error'); + }, + { hook1: true, hook2: true }, + ); + const middleware = createMethodMiddleware({ handlers: { method1: handler, method2: handler }, hooks: getDefaultHooks(), @@ -197,19 +200,13 @@ describe('createMethodMiddleware', () => { const engine = new JsonRpcEngine(); engine.push(middleware); - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: method1, - params: [4], - }; - await engine.handle(request); + await engine.handle(baseRequest); expect(onError).toHaveBeenCalledTimes(1); const [error, receivedRequest] = onError.mock.calls[0]; expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe('test error'); - expect(receivedRequest).toMatchObject(request); + expect(receivedRequest).toMatchObject(baseRequest); }); it('works when no hooks are configured', async () => { @@ -227,11 +224,7 @@ describe('createMethodMiddleware', () => { const engine = new JsonRpcEngine(); engine.push(middleware); - const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, - method: 'noDeps', - }); + const response = await engine.handle({ ...baseRequest, method: 'noDeps' }); assertIsJsonRpcSuccess(response); expect(response.result).toBe('no-deps'); @@ -259,8 +252,7 @@ describe('createMethodMiddleware', () => { engine.push(middleware); const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, + ...baseRequest, method: 'reportOrigin', origin: 'https://example.com', } as JsonRpcRequest & { origin: string }); @@ -324,8 +316,7 @@ describe('createMethodMiddleware', () => { engine.push(middleware); const response = await engine.handle({ - jsonrpc: '2.0', - id: 1, + ...baseRequest, method: 'callAction', }); assertIsJsonRpcSuccess(response); @@ -334,6 +325,10 @@ describe('createMethodMiddleware', () => { }); it('throws an error if a required hook is missing', () => { + const handler = makeHandler((_req, _res, _next, end) => end(), { + hook1: true, + hook2: true, + }); const hooks = { hook1: (): number => 42 }; expect(() => @@ -346,6 +341,10 @@ describe('createMethodMiddleware', () => { }); it('throws an error if an extraneous hook is provided', () => { + const handler = makeHandler((_req, _res, _next, end) => end(), { + hook1: true, + hook2: true, + }); const hooks = { ...getDefaultHooks(), extraneousHook: (): number => 100, diff --git a/packages/json-rpc-engine/src/v2/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts index ab80dd98c18..5cc38239040 100644 --- a/packages/json-rpc-engine/src/v2/utils.ts +++ b/packages/json-rpc-engine/src/v2/utils.ts @@ -132,12 +132,9 @@ export function assertExpectedHooks( hooks: Record, expectedHookNames: Set, ): void { - const missingHookNames: string[] = []; - expectedHookNames.forEach((hookName) => { - if (!hasProperty(hooks, hookName)) { - missingHookNames.push(hookName); - } - }); + const missingHookNames = Array.from(expectedHookNames).filter( + (hookName) => !hasProperty(hooks, hookName), + ); if (missingHookNames.length > 0) { throw new Error( `Missing expected hooks:\n\n${missingHookNames.join('\n')}\n`, diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index d5ebb06d472..c0389038d45 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/multichain-transactions-controller` from `^7.0.3` to `^7.0.4` ([#8325](https://github.com/MetaMask/core/pull/8325)) - Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) +### Fixed + +- `wallet_invokeMethod` fails early with an `invalidParams` error when the `params` object is not an object ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Previously it would fail with a less specific error. + ## [2.0.0] ### Added From a1caffc9c1476e1f7ce32b982872cdd9feda78c9 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:43:31 -0700 Subject: [PATCH 21/25] refactor: Rename method handler exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the per-method handler constants to `Handler` (e.g. `getPermissions` → `getPermissionsHandler`, `walletCreateSession` → `walletCreateSessionHandler`) and export their corresponding type aliases. Drop the per-file `Record` wrappers; the consolidated `methodHandlers` map is now built directly in each package's index, using computed property keys from a `MethodNames` const object (or the existing enum, in `eip1193-permission-middleware`) to satisfy the naming-convention lint rule. Co-Authored-By: Claude Opus 4.7 --- .../src/index.ts | 18 ++++++++----- .../src/wallet-getPermissions.test.ts | 4 +-- .../src/wallet-getPermissions.ts | 9 ++----- .../src/wallet-requestPermissions.test.ts | 4 +-- .../src/wallet-requestPermissions.ts | 10 +++---- .../src/wallet-revokePermissions.test.ts | 6 ++--- .../src/wallet-revokePermissions.ts | 10 +++---- .../src/handlers/index.ts | 27 ++++++++++++------- .../src/handlers/wallet-createSession.test.ts | 4 +-- .../src/handlers/wallet-createSession.ts | 10 +++---- .../src/handlers/wallet-getSession.test.ts | 4 +-- .../src/handlers/wallet-getSession.ts | 10 +++---- .../src/handlers/wallet-invokeMethod.test.ts | 4 +-- .../src/handlers/wallet-invokeMethod.ts | 10 +++---- .../src/handlers/wallet-revokeSession.test.ts | 4 +-- .../src/handlers/wallet-revokeSession.ts | 10 +++---- 16 files changed, 64 insertions(+), 80 deletions(-) diff --git a/packages/eip1193-permission-middleware/src/index.ts b/packages/eip1193-permission-middleware/src/index.ts index ef6ee317810..ef0b2336e84 100644 --- a/packages/eip1193-permission-middleware/src/index.ts +++ b/packages/eip1193-permission-middleware/src/index.ts @@ -1,13 +1,17 @@ +import { MethodNames } from '@metamask/permission-controller'; + import { getPermissionsHandler } from './wallet-getPermissions'; import { requestPermissionsHandler } from './wallet-requestPermissions'; import { revokePermissionsHandler } from './wallet-revokePermissions'; -type MethodHandlers = typeof getPermissionsHandler & - typeof requestPermissionsHandler & - typeof revokePermissionsHandler; +type MethodHandlers = { + [MethodNames.GetPermissions]: typeof getPermissionsHandler; + [MethodNames.RequestPermissions]: typeof requestPermissionsHandler; + [MethodNames.RevokePermissions]: typeof revokePermissionsHandler; +}; export const methodHandlers: Readonly = { - ...getPermissionsHandler, - ...requestPermissionsHandler, - ...revokePermissionsHandler, -} as const; + [MethodNames.GetPermissions]: getPermissionsHandler, + [MethodNames.RequestPermissions]: requestPermissionsHandler, + [MethodNames.RevokePermissions]: revokePermissionsHandler, +}; diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts index 2d6b958e37c..a13091bb69e 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.test.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/utils'; import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -import { getPermissions } from './wallet-getPermissions'; +import { getPermissionsHandler } from './wallet-getPermissions'; jest.mock('@metamask/chain-agnostic-permission', () => ({ ...jest.requireActual('@metamask/chain-agnostic-permission'), @@ -70,7 +70,7 @@ const createMockedHandler = () => { id: 0, }; const handler = (request: JsonRpcRequest) => - getPermissions.implementation(request, response, next, end, { + getPermissionsHandler.implementation(request, response, next, end, { getPermissionsForOrigin, getAccounts, }); diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts index dcd845b5202..1b9de686876 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -9,7 +9,6 @@ import type { JsonRpcEngineNextCallback, MethodHandler, } from '@metamask/json-rpc-engine'; -import { MethodNames } from '@metamask/permission-controller'; import type { CaveatSpecificationConstraint, PermissionController, @@ -33,7 +32,7 @@ export type GetPermissionsHooks = { getAccounts: (options?: { ignoreLock?: boolean }) => string[]; }; -type GetPermissionsHandler = MethodHandler< +export type GetPermissionsHandler = MethodHandler< GetPermissionsHooks, never, Json[], @@ -41,7 +40,7 @@ type GetPermissionsHandler = MethodHandler< { origin: string } >; -export const getPermissions = { +export const getPermissionsHandler = { implementation: getPermissionsImplementation, hookNames: { getPermissionsForOrigin: true, @@ -49,10 +48,6 @@ export const getPermissions = { }, } satisfies GetPermissionsHandler; -export const getPermissionsHandler = { - [MethodNames.GetPermissions]: getPermissions, -}; - /** * Get Permissions implementation to be used in JsonRpcEngine middleware, specifically for `wallet_getPermissions` RPC method. * It makes use of a CAIP-25 endowment permission returned by `getPermissionsForOrigin` hook, if it exists. diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts index 068b1a33b3a..c14728e930d 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.test.ts @@ -7,7 +7,7 @@ import type { RequestedPermissions } from '@metamask/permission-controller'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; -import { requestPermissions } from './wallet-requestPermissions'; +import { requestPermissionsHandler } from './wallet-requestPermissions'; const getBaseRequest = (overrides = {}) => ({ jsonrpc: '2.0' as const, @@ -39,7 +39,7 @@ const createMockedHandler = () => { id: 0, }; const handler = (request: unknown) => - requestPermissions.implementation( + requestPermissionsHandler.implementation( request as JsonRpcRequest<[RequestedPermissions]> & { origin: string }, response, next, diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts index 7636a508538..bd2051a459a 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -10,7 +10,7 @@ import type { JsonRpcEngineEndCallback, MethodHandler, } from '@metamask/json-rpc-engine'; -import { invalidParams, MethodNames } from '@metamask/permission-controller'; +import { invalidParams } from '@metamask/permission-controller'; import type { Caveat, CaveatSpecificationConstraint, @@ -38,7 +38,7 @@ export type RequestPermissionsHooks = { ) => RequestedPermissions; }; -type RequestPermissionsHandler = MethodHandler< +export type RequestPermissionsHandler = MethodHandler< RequestPermissionsHooks, never, [RequestedPermissions], @@ -46,7 +46,7 @@ type RequestPermissionsHandler = MethodHandler< { origin: string } >; -export const requestPermissions = { +export const requestPermissionsHandler = { implementation: requestPermissionsImplementation, hookNames: { getAccounts: true, @@ -55,10 +55,6 @@ export const requestPermissions = { }, } satisfies RequestPermissionsHandler; -export const requestPermissionsHandler = { - [MethodNames.RequestPermissions]: requestPermissions, -}; - type AbstractPermissionController = PermissionController< PermissionSpecificationConstraint, CaveatSpecificationConstraint diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts index f805080d85b..34bb499c4c3 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.test.ts @@ -7,7 +7,7 @@ import type { } from '@metamask/utils'; import { EndowmentTypes, RestrictedMethods } from './types'; -import { revokePermissions } from './wallet-revokePermissions'; +import { revokePermissionsHandler } from './wallet-revokePermissions'; const baseRequest = { jsonrpc: '2.0' as const, @@ -31,7 +31,7 @@ const createMockedHandler = () => { id: 0, }; const handler = (request: JsonRpcRequest) => - revokePermissions.implementation(request, response, next, end, { + revokePermissionsHandler.implementation(request, response, next, end, { revokePermissionsForOrigin, }); @@ -44,7 +44,7 @@ const createMockedHandler = () => { }; }; -describe('revokePermissions', () => { +describe('revokePermissionsHandler', () => { it('returns an error if params is malformed', () => { const { handler, end } = createMockedHandler(); diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts index 09b0e9b6095..006b1880f69 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -4,7 +4,7 @@ import type { JsonRpcEngineEndCallback, MethodHandler, } from '@metamask/json-rpc-engine'; -import { invalidParams, MethodNames } from '@metamask/permission-controller'; +import { invalidParams } from '@metamask/permission-controller'; import { isNonEmptyArray } from '@metamask/utils'; import type { Json, @@ -18,7 +18,7 @@ export type RevokePermissionsHooks = { revokePermissionsForOrigin: (permissionKeys: string[]) => void; }; -type RevokePermissionsHandler = MethodHandler< +export type RevokePermissionsHandler = MethodHandler< RevokePermissionsHooks, never, Json[], @@ -26,17 +26,13 @@ type RevokePermissionsHandler = MethodHandler< { origin: string } >; -export const revokePermissions = { +export const revokePermissionsHandler = { implementation: revokePermissionsImplementation, hookNames: { revokePermissionsForOrigin: true, }, } satisfies RevokePermissionsHandler; -export const revokePermissionsHandler = { - [MethodNames.RevokePermissions]: revokePermissions, -}; - /** * Revoke Permissions implementation to be used in JsonRpcEngine middleware. * diff --git a/packages/multichain-api-middleware/src/handlers/index.ts b/packages/multichain-api-middleware/src/handlers/index.ts index 36ee0634cae..2ea7ceb0e18 100644 --- a/packages/multichain-api-middleware/src/handlers/index.ts +++ b/packages/multichain-api-middleware/src/handlers/index.ts @@ -3,14 +3,23 @@ import { walletGetSessionHandler } from './wallet-getSession'; import { walletInvokeMethodHandler } from './wallet-invokeMethod'; import { walletRevokeSessionHandler } from './wallet-revokeSession'; -type MethodHandlers = typeof walletCreateSessionHandler & - typeof walletGetSessionHandler & - typeof walletInvokeMethodHandler & - typeof walletRevokeSessionHandler; +const MethodNames = { + WalletCreateSession: 'wallet_createSession', + WalletGetSession: 'wallet_getSession', + WalletInvokeMethod: 'wallet_invokeMethod', + WalletRevokeSession: 'wallet_revokeSession', +} as const; + +type MethodHandlers = { + [MethodNames.WalletCreateSession]: typeof walletCreateSessionHandler; + [MethodNames.WalletGetSession]: typeof walletGetSessionHandler; + [MethodNames.WalletInvokeMethod]: typeof walletInvokeMethodHandler; + [MethodNames.WalletRevokeSession]: typeof walletRevokeSessionHandler; +}; export const methodHandlers: Readonly = { - ...walletCreateSessionHandler, - ...walletGetSessionHandler, - ...walletInvokeMethodHandler, - ...walletRevokeSessionHandler, -} as const; + [MethodNames.WalletCreateSession]: walletCreateSessionHandler, + [MethodNames.WalletGetSession]: walletGetSessionHandler, + [MethodNames.WalletInvokeMethod]: walletInvokeMethodHandler, + [MethodNames.WalletRevokeSession]: walletRevokeSessionHandler, +}; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts index 97c8289e71b..5ca1de503fd 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.test.ts @@ -18,7 +18,7 @@ import type { JsonRpcSuccess, } from '@metamask/utils'; -import { walletCreateSession } from './wallet-createSession'; +import { walletCreateSessionHandler } from './wallet-createSession'; jest.mock('@metamask/rpc-errors', () => ({ ...jest.requireActual('@metamask/rpc-errors'), @@ -110,7 +110,7 @@ const createMockedHandler = (trackSessionEvents: boolean = true) => { const handler = ( request: JsonRpcRequest & { origin: string }, ) => - walletCreateSession.implementation(request, response, next, end, { + walletCreateSessionHandler.implementation(request, response, next, end, { findNetworkClientIdByChainId, requestPermissionsForOrigin, listAccounts, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index 9c3fb718500..b8c7db6e0aa 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -290,7 +290,7 @@ async function handleWalletCreateSession( } } -type WalletCreateSessionMethodHandler = MethodHandler< +export type WalletCreateSessionHandler = MethodHandler< WalletCreateSessionHooks, never, Params, @@ -298,7 +298,7 @@ type WalletCreateSessionMethodHandler = MethodHandler< { origin: string } >; -export const walletCreateSession = { +export const walletCreateSessionHandler = { implementation: handleWalletCreateSession, hookNames: { findNetworkClientIdByChainId: true, @@ -310,8 +310,4 @@ export const walletCreateSession = { sortAccountIdsByLastSelected: true, trackSessionCreatedEvent: true, }, -} satisfies WalletCreateSessionMethodHandler; - -export const walletCreateSessionHandler = { - wallet_createSession: walletCreateSession, -}; +} satisfies WalletCreateSessionHandler; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts index 54709e7089e..d3805893d5a 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-getSession.test.ts @@ -1,7 +1,7 @@ import * as chainAgnosticPermissionModule from '@metamask/chain-agnostic-permission'; import type { JsonRpcRequest } from '@metamask/utils'; -import { walletGetSession } from './wallet-getSession'; +import { walletGetSessionHandler } from './wallet-getSession'; jest.mock('@metamask/chain-agnostic-permission', () => ({ ...jest.requireActual('@metamask/chain-agnostic-permission'), @@ -52,7 +52,7 @@ const createMockedHandler = () => { jsonrpc: '2.0' as const, }; const handler = (request: JsonRpcRequest & { origin: string }) => - walletGetSession.implementation(request, response, next, end, { + walletGetSessionHandler.implementation(request, response, next, end, { getCaveatForOrigin, getNonEvmSupportedMethods, sortAccountIdsByLastSelected, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts index c5880c0a570..ff98d2e7b7c 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts @@ -79,7 +79,7 @@ async function handleWalletGetSession( return end(); } -type WalletGetSessionMethodHandler = MethodHandler< +export type WalletGetSessionHandler = MethodHandler< WalletGetSessionHooks, never, JsonRpcParams, @@ -87,15 +87,11 @@ type WalletGetSessionMethodHandler = MethodHandler< { origin: string } >; -export const walletGetSession = { +export const walletGetSessionHandler = { implementation: handleWalletGetSession, hookNames: { getCaveatForOrigin: true, getNonEvmSupportedMethods: true, sortAccountIdsByLastSelected: true, }, -} satisfies WalletGetSessionMethodHandler; - -export const walletGetSessionHandler = { - wallet_getSession: walletGetSession, -}; +} satisfies WalletGetSessionHandler; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts index 41166ccf735..ad4f83f9e78 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.test.ts @@ -2,7 +2,7 @@ import * as chainAgnosticPermissionModule from '@metamask/chain-agnostic-permiss import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { WalletInvokeMethodRequest } from './wallet-invokeMethod'; -import { walletInvokeMethod } from './wallet-invokeMethod'; +import { walletInvokeMethodHandler } from './wallet-invokeMethod'; // Allow individual modules to be mocked jest.mock('@metamask/chain-agnostic-permission', () => ({ @@ -62,7 +62,7 @@ const createMockedHandler = () => { const handleNonEvmRequestForOrigin = jest.fn().mockResolvedValue(null); const response = { jsonrpc: '2.0' as const, id: 1 }; const handler = (request: WalletInvokeMethodRequest) => - walletInvokeMethod.implementation(request, response, next, end, { + walletInvokeMethodHandler.implementation(request, response, next, end, { getCaveatForOrigin, findNetworkClientIdByChainId, getSelectedNetworkClientId, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts index f88712b3a65..15f8326825f 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts @@ -162,7 +162,7 @@ async function handleWalletInvokeMethod( return end(); } -type WalletInvokeMethodMethodHandler = MethodHandler< +export type WalletInvokeMethodHandler = MethodHandler< WalletInvokeMethodHooks, never, WalletInvokeMethodParams, @@ -170,7 +170,7 @@ type WalletInvokeMethodMethodHandler = MethodHandler< { origin: string } >; -export const walletInvokeMethod = { +export const walletInvokeMethodHandler = { implementation: handleWalletInvokeMethod, hookNames: { getCaveatForOrigin: true, @@ -180,8 +180,4 @@ export const walletInvokeMethod = { sortAccountIdsByLastSelected: true, handleNonEvmRequestForOrigin: true, }, -} satisfies WalletInvokeMethodMethodHandler; - -export const walletInvokeMethodHandler = { - wallet_invokeMethod: walletInvokeMethod, -}; +} satisfies WalletInvokeMethodHandler; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts index 26ca5e7e16d..b04c9104ec2 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts @@ -9,7 +9,7 @@ import { import { rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest } from '@metamask/utils'; -import { walletRevokeSession } from './wallet-revokeSession'; +import { walletRevokeSessionHandler } from './wallet-revokeSession'; const baseRequest: JsonRpcRequest & { origin: string; @@ -39,7 +39,7 @@ const createMockedHandler = () => { params: { scopes?: string[] }; }, ) => - walletRevokeSession.implementation(request, response, next, end, { + walletRevokeSessionHandler.implementation(request, response, next, end, { revokePermissionForOrigin, updateCaveat, getCaveatForOrigin, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts index 3d3ca4d1110..d2e34e1ea35 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -154,7 +154,7 @@ async function handleWalletRevokeSession( return end(); } -type WalletRevokeSessionMethodHandler = MethodHandler< +export type WalletRevokeSessionHandler = MethodHandler< WalletRevokeSessionHooks, never, WalletRevokeSessionParams, @@ -162,15 +162,11 @@ type WalletRevokeSessionMethodHandler = MethodHandler< { origin: string } >; -export const walletRevokeSession = { +export const walletRevokeSessionHandler = { implementation: handleWalletRevokeSession, hookNames: { revokePermissionForOrigin: true, updateCaveat: true, getCaveatForOrigin: true, }, -} satisfies WalletRevokeSessionMethodHandler; - -export const walletRevokeSessionHandler = { - wallet_revokeSession: walletRevokeSession, -}; +} satisfies WalletRevokeSessionHandler; From 24f0ea0a5a42d3e6564c3b6284f1ee6910d4c88f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:11:32 -0700 Subject: [PATCH 22/25] feat(multichain-api-middleware): Add MethodHandlerHooks type Export a `MethodHandlerHooks` type from `multichain-api-middleware` that intersects the hook types of all method handlers using `UnionToIntersection`. Consumers can use it to type the hooks bag they pass to `createMethodMiddleware` without restating each handler's hooks individually. Also export `UnionToIntersection` from the `@metamask/json-rpc-engine/v2` subpath so it can be reused by consumers performing similar derivations. Co-Authored-By: Claude Opus 4.7 --- packages/json-rpc-engine/src/v2/index.ts | 1 + .../multichain-api-middleware/src/handlers/index.ts | 13 +++++++++++++ packages/multichain-api-middleware/src/index.ts | 1 + 3 files changed, 15 insertions(+) diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts index c64763a4c6c..ba83b5158e8 100644 --- a/packages/json-rpc-engine/src/v2/index.ts +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -30,4 +30,5 @@ export type { JsonRpcNotification, JsonRpcParams, JsonRpcRequest, + UnionToIntersection, } from './utils'; diff --git a/packages/multichain-api-middleware/src/handlers/index.ts b/packages/multichain-api-middleware/src/handlers/index.ts index 2ea7ceb0e18..ab8ad4ea991 100644 --- a/packages/multichain-api-middleware/src/handlers/index.ts +++ b/packages/multichain-api-middleware/src/handlers/index.ts @@ -1,8 +1,21 @@ +import type { UnionToIntersection } from '@metamask/json-rpc-engine/v2'; + +import type { WalletCreateSessionHooks } from './wallet-createSession'; import { walletCreateSessionHandler } from './wallet-createSession'; +import type { WalletGetSessionHooks } from './wallet-getSession'; import { walletGetSessionHandler } from './wallet-getSession'; +import type { WalletInvokeMethodHooks } from './wallet-invokeMethod'; import { walletInvokeMethodHandler } from './wallet-invokeMethod'; +import type { WalletRevokeSessionHooks } from './wallet-revokeSession'; import { walletRevokeSessionHandler } from './wallet-revokeSession'; +export type MethodHandlerHooks = UnionToIntersection< + | WalletCreateSessionHooks + | WalletGetSessionHooks + | WalletInvokeMethodHooks + | WalletRevokeSessionHooks +>; + const MethodNames = { WalletCreateSession: 'wallet_createSession', WalletGetSession: 'wallet_getSession', diff --git a/packages/multichain-api-middleware/src/index.ts b/packages/multichain-api-middleware/src/index.ts index ce6500298e8..d126c158795 100644 --- a/packages/multichain-api-middleware/src/index.ts +++ b/packages/multichain-api-middleware/src/index.ts @@ -1,4 +1,5 @@ export { methodHandlers } from './handlers'; +export type { MethodHandlerHooks } from './handlers'; export { multichainMethodCallValidatorMiddleware } from './middlewares/multichainMethodCallValidatorMiddleware'; export { MultichainMiddlewareManager } from './middlewares/MultichainMiddlewareManager'; From 087bb7b31a72cfc27fd0890f086af3387196bead Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:33:27 -0700 Subject: [PATCH 23/25] refactor(multichain-api-middleware): Consolidate duplicate hook types Extract repeated hook property definitions (getCaveatForOrigin, getNonEvmSupportedMethods, sortAccountIdsByLastSelected) into shared types in handlers/types.ts, and compose each handler hooks type via intersection. Adds MethodHandlerHooks as a public export, fixes wallet_revokeSession to return true instead of an internal error when no active session exists during a partial revoke, and updates changelogs. Co-Authored-By: Claude Opus 4.7 --- .../CHANGELOG.md | 1 + .../src/index.test.ts | 6 +-- .../src/wallet-getPermissions.ts | 11 +--- .../src/wallet-requestPermissions.ts | 13 ++--- .../src/wallet-revokePermissions.ts | 5 +- .../multichain-api-middleware/CHANGELOG.md | 8 +++ .../multichain-api-middleware/package.json | 2 + .../src/handlers/index.test.ts | 20 ++++++-- .../src/handlers/types.ts | 34 ++++++++++--- .../src/handlers/wallet-createSession.ts | 42 +++++++++------- .../src/handlers/wallet-getSession.ts | 30 +++++------ .../src/handlers/wallet-invokeMethod.ts | 50 ++++++++++--------- .../src/handlers/wallet-revokeSession.test.ts | 17 +++++++ .../src/handlers/wallet-revokeSession.ts | 25 ++++++---- .../tsconfig.build.json | 1 + .../multichain-api-middleware/tsconfig.json | 1 + yarn.lock | 2 + 17 files changed, 165 insertions(+), 103 deletions(-) diff --git a/packages/eip1193-permission-middleware/CHANGELOG.md b/packages/eip1193-permission-middleware/CHANGELOG.md index f5bf5257ac8..23244ecd9b3 100644 --- a/packages/eip1193-permission-middleware/CHANGELOG.md +++ b/packages/eip1193-permission-middleware/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Consolidate method handlers into a single `methodHandlers` export ([#8583](https://github.com/MetaMask/core/pull/8583)) - The individual handler exports have been removed. They can still be accessed as properties on the `methodHandlers` export. - The new handlers follow the format expected by `createMethodMiddleware` from `@metamask/json-rpc-engine@10.3.0`. + - The hook types have been updated to cohere with the corresponding `@metamask/permission-controller` methods. - Bump `@metamask/chain-agnostic-permission` from `^1.4.0` to `^1.5.0` ([#8290](https://github.com/MetaMask/core/pull/8290)) - Bump `@metamask/json-rpc-engine` from `^10.2.0` to `^10.2.4` ([#7642](https://github.com/MetaMask/core/pull/7642), [#7856](https://github.com/MetaMask/core/pull/7856), [#8078](https://github.com/MetaMask/core/pull/8078), [#8317](https://github.com/MetaMask/core/pull/8317)) - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) diff --git a/packages/eip1193-permission-middleware/src/index.test.ts b/packages/eip1193-permission-middleware/src/index.test.ts index 09a1892a4af..feed1294273 100644 --- a/packages/eip1193-permission-middleware/src/index.test.ts +++ b/packages/eip1193-permission-middleware/src/index.test.ts @@ -12,10 +12,10 @@ type Hooks = GetPermissionsHooks & /* eslint-disable @typescript-eslint/explicit-function-return-type */ const makeMockHooks = () => ({ - getPermissionsForOrigin: (() => ({})) as Hooks['getPermissionsForOrigin'], + getPermissionsForOrigin: () => ({}), getAccounts: () => ['0x123'], - requestPermissionsForOrigin: (() => - Promise.resolve([{}])) as Hooks['requestPermissionsForOrigin'], + requestPermissionsForOrigin: () => + Promise.resolve([{}, { id: '1', origin: 'test' }]), revokePermissionsForOrigin: () => undefined, getCaip25PermissionFromLegacyPermissionsForOrigin: () => ({}), }) satisfies Hooks; diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts index 1b9de686876..52914bc1ff4 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -9,11 +9,7 @@ import type { JsonRpcEngineNextCallback, MethodHandler, } from '@metamask/json-rpc-engine'; -import type { - CaveatSpecificationConstraint, - PermissionController, - PermissionSpecificationConstraint, -} from '@metamask/permission-controller'; +import type { GenericPermissionController } from '@metamask/permission-controller'; import type { Json, JsonRpcRequest, @@ -24,10 +20,7 @@ import { CaveatTypes, EndowmentTypes, RestrictedMethods } from './types'; export type GetPermissionsHooks = { getPermissionsForOrigin: () => ReturnType< - PermissionController< - PermissionSpecificationConstraint, - CaveatSpecificationConstraint - >['getPermissions'] + GenericPermissionController['getPermissions'] >; getAccounts: (options?: { ignoreLock?: boolean }) => string[]; }; diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts index bd2051a459a..ec723df7ba3 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -13,9 +13,7 @@ import type { import { invalidParams } from '@metamask/permission-controller'; import type { Caveat, - CaveatSpecificationConstraint, - PermissionController, - PermissionSpecificationConstraint, + GenericPermissionController, RequestedPermissions, ValidPermission, } from '@metamask/permission-controller'; @@ -32,7 +30,7 @@ export type RequestPermissionsHooks = { getAccounts: () => string[]; requestPermissionsForOrigin: ( requestedPermissions: RequestedPermissions, - ) => Promise<[GrantedPermissions]>; + ) => ReturnType; getCaip25PermissionFromLegacyPermissionsForOrigin: ( requestedPermissions?: RequestedPermissions, ) => RequestedPermissions; @@ -55,13 +53,8 @@ export const requestPermissionsHandler = { }, } satisfies RequestPermissionsHandler; -type AbstractPermissionController = PermissionController< - PermissionSpecificationConstraint, - CaveatSpecificationConstraint ->; - type GrantedPermissions = Awaited< - ReturnType + ReturnType >[0]; /** diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts index 006b1880f69..8f051393a16 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -5,6 +5,7 @@ import type { MethodHandler, } from '@metamask/json-rpc-engine'; import { invalidParams } from '@metamask/permission-controller'; +import type { GenericPermissionController } from '@metamask/permission-controller'; import { isNonEmptyArray } from '@metamask/utils'; import type { Json, @@ -15,7 +16,9 @@ import type { import { EndowmentTypes, RestrictedMethods } from './types'; export type RevokePermissionsHooks = { - revokePermissionsForOrigin: (permissionKeys: string[]) => void; + revokePermissionsForOrigin: ( + permissionKeys: string[], + ) => ReturnType; }; export type RevokePermissionsHandler = MethodHandler< diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index c0389038d45..9f9d3430997 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `MethodHandlerHooks` type, the intersection of all method handler hook types ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Consumers can use this to type the hooks object passed to `createMethodMiddleware` without restating each handler's hooks individually. + ### Changed - **BREAKING:** Consolidate method handlers into a single `methodHandlers` export ([#8583](https://github.com/MetaMask/core/pull/8583)) - The individual handler exports have been removed. They can still be accessed as properties on the `methodHandlers` export. - The new handlers follow the format expected by `createMethodMiddleware` from `@metamask/json-rpc-engine@10.3.0`. + - The hook types have been updated to cohere with their corresponding MetaMask controller methods. - **BREAKING:** Make `trackSessionCreatedEvent` hook required in `wallet_createSession` handler ([#8583](https://github.com/MetaMask/core/pull/8583)) - If the hook is not required, `null` can be passed instead. - Bump `@metamask/json-rpc-engine` from `^10.2.3` to `^10.2.4` ([#8317](https://github.com/MetaMask/core/pull/8317)) @@ -24,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `wallet_invokeMethod` fails early with an `invalidParams` error when the `params` object is not an object ([#8583](https://github.com/MetaMask/core/pull/8583)) - Previously it would fail with a less specific error. +- `wallet_revokeSession` now returns `true` when no active session exists and specific scopes are requested, consistent with its full-revoke behavior ([#8583](https://github.com/MetaMask/core/pull/8583)) + - Previously it would return an internal error. ## [2.0.0] diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index 8bcf9794487..0a7b74eb7f3 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -51,6 +51,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/accounts-controller": "^37.2.0", "@metamask/api-specs": "^0.14.0", "@metamask/chain-agnostic-permission": "^1.5.0", "@metamask/controller-utils": "^11.20.0", @@ -58,6 +59,7 @@ "@metamask/network-controller": "^30.0.1", "@metamask/permission-controller": "^12.3.0", "@metamask/rpc-errors": "^7.0.2", + "@metamask/snaps-controllers": "^19.0.0", "@metamask/utils": "^11.9.0", "@open-rpc/meta-schema": "^1.14.6", "@open-rpc/schema-utils-js": "^2.0.5", diff --git a/packages/multichain-api-middleware/src/handlers/index.test.ts b/packages/multichain-api-middleware/src/handlers/index.test.ts index 14383507e45..e51ed606980 100644 --- a/packages/multichain-api-middleware/src/handlers/index.test.ts +++ b/packages/multichain-api-middleware/src/handlers/index.test.ts @@ -14,11 +14,25 @@ type Hooks = WalletCreateSessionHooks & /* eslint-disable @typescript-eslint/explicit-function-return-type */ const makeMockHooks = () => ({ - listAccounts: () => [{ address: '0x123' }], + listAccounts: () => [ + { + type: 'eip155:eoa', + address: '0x123', + id: '1', + options: {}, + scopes: [], + methods: [], + metadata: { + name: 'Account 1', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + }, + ], findNetworkClientIdByChainId: (() => '1') as Hooks['findNetworkClientIdByChainId'], - requestPermissionsForOrigin: (() => - Promise.resolve([{}])) as Hooks['requestPermissionsForOrigin'], + requestPermissionsForOrigin: () => + Promise.resolve([{}, { id: '1', origin: 'test' }]), getNonEvmSupportedMethods: () => [], isNonEvmScopeSupported: () => false, getNonEvmAccountAddresses: () => [], diff --git a/packages/multichain-api-middleware/src/handlers/types.ts b/packages/multichain-api-middleware/src/handlers/types.ts index 5c0f3f336a6..7d59834ddc6 100644 --- a/packages/multichain-api-middleware/src/handlers/types.ts +++ b/packages/multichain-api-middleware/src/handlers/types.ts @@ -1,8 +1,13 @@ +import { + Caip25CaveatType, + Caip25CaveatValue, +} from '@metamask/chain-agnostic-permission'; import type { - CaveatSpecificationConstraint, - PermissionController, - PermissionSpecificationConstraint, + GenericPermissionController, + Caveat, } from '@metamask/permission-controller'; +import type { MultichainRoutingService } from '@metamask/snaps-controllers'; +import type { CaipAccountId } from '@metamask/utils'; /** * Multichain API notifications currently supported by/known to the wallet. @@ -11,11 +16,24 @@ export enum MultichainApiNotifications { sessionChanged = 'wallet_sessionChanged', walletNotify = 'wallet_notify', } -type AbstractPermissionController = PermissionController< - PermissionSpecificationConstraint, - CaveatSpecificationConstraint ->; + +export type Caip25Caveat = Caveat; export type GrantedPermissions = Awaited< - ReturnType + ReturnType >[0]; + +export type GetCaveatForOriginHook = { + getCaveatForOrigin: ( + endowmentPermissionName: string, + caveatType: string, + ) => ReturnType; +}; + +export type GetNonEvmSupportedMethodsHook = { + getNonEvmSupportedMethods: MultichainRoutingService['getSupportedMethods']; +}; + +export type SortAccountIdsByLastSelectedHook = { + sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; +}; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index b8c7db6e0aa..2041367bee6 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -1,3 +1,4 @@ +import type { AccountsController } from '@metamask/accounts-controller'; import { Caip25CaveatType, Caip25EndowmentPermissionName, @@ -25,8 +26,12 @@ import type { } from '@metamask/json-rpc-engine'; import type { NetworkController } from '@metamask/network-controller'; import { invalidParams } from '@metamask/permission-controller'; -import type { RequestedPermissions } from '@metamask/permission-controller'; +import type { + GenericPermissionController, + RequestedPermissions, +} from '@metamask/permission-controller'; import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import type { MultichainRoutingService } from '@metamask/snaps-controllers'; import { isPlainObject, KnownCaipNamespace, @@ -34,32 +39,33 @@ import { } from '@metamask/utils'; import type { CaipAccountId, - CaipChainId, Hex, Json, JsonRpcRequest, PendingJsonRpcResponse, } from '@metamask/utils'; -import type { GrantedPermissions } from './types'; +import type { + GetNonEvmSupportedMethodsHook, + SortAccountIdsByLastSelectedHook, +} from './types'; const SOLANA_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; -export type WalletCreateSessionHooks = { - listAccounts: () => { address: string }[]; - findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; - requestPermissionsForOrigin: ( - requestedPermissions: RequestedPermissions, - metadata?: Record, - ) => Promise<[GrantedPermissions]>; - getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; - isNonEvmScopeSupported: (scope: CaipChainId) => boolean; - getNonEvmAccountAddresses: (scope: CaipChainId) => CaipAccountId[]; - sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; - trackSessionCreatedEvent: - | ((approvedCaip25CaveatValue: Caip25CaveatValue) => void) - | null; -}; +export type WalletCreateSessionHooks = GetNonEvmSupportedMethodsHook & + SortAccountIdsByLastSelectedHook & { + listAccounts: AccountsController['listAccounts']; + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + requestPermissionsForOrigin: ( + requestedPermissions: RequestedPermissions, + metadata?: Record, + ) => ReturnType; + isNonEvmScopeSupported: MultichainRoutingService['isSupportedScope']; + getNonEvmAccountAddresses: MultichainRoutingService['getSupportedAccounts']; + trackSessionCreatedEvent: + | ((approvedCaip25CaveatValue: Caip25CaveatValue) => void) + | null; + }; type Params = Caip25Authorization; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts index ff98d2e7b7c..f093e832931 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-getSession.ts @@ -1,7 +1,4 @@ -import type { - Caip25CaveatValue, - NormalizedScopesObject, -} from '@metamask/chain-agnostic-permission'; +import type { NormalizedScopesObject } from '@metamask/chain-agnostic-permission'; import { Caip25CaveatType, Caip25EndowmentPermissionName, @@ -12,25 +9,24 @@ import type { JsonRpcEngineNextCallback, MethodHandler, } from '@metamask/json-rpc-engine'; -import type { Caveat } from '@metamask/permission-controller'; import type { - CaipAccountId, - CaipChainId, JsonRpcParams, JsonRpcRequest, PendingJsonRpcResponse, } from '@metamask/utils'; +import type { + Caip25Caveat, + GetCaveatForOriginHook, + GetNonEvmSupportedMethodsHook, + SortAccountIdsByLastSelectedHook, +} from './types'; + type WalletGetSessionResult = { sessionScopes: NormalizedScopesObject }; -export type WalletGetSessionHooks = { - getCaveatForOrigin: ( - endowmentPermissionName: string, - caveatType: string, - ) => Caveat; - getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; - sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; -}; +export type WalletGetSessionHooks = GetCaveatForOriginHook & + GetNonEvmSupportedMethodsHook & + SortAccountIdsByLastSelectedHook; /** * Handler for the `wallet_getSession` RPC method as specified by [CAIP-312](https://chainagnostic.org/CAIPs/caip-312). @@ -55,12 +51,12 @@ async function handleWalletGetSession( end: JsonRpcEngineEndCallback, hooks: WalletGetSessionHooks, ) { - let caveat; + let caveat: Caip25Caveat | undefined; try { caveat = hooks.getCaveatForOrigin( Caip25EndowmentPermissionName, Caip25CaveatType, - ); + ) as Caip25Caveat | undefined; } catch { // noop } diff --git a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts index 15f8326825f..1305d43ae87 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts @@ -1,7 +1,4 @@ -import type { - Caip25CaveatValue, - ExternalScopeString, -} from '@metamask/chain-agnostic-permission'; +import type { ExternalScopeString } from '@metamask/chain-agnostic-permission'; import { Caip25CaveatType, Caip25EndowmentPermissionName, @@ -14,19 +11,28 @@ import type { JsonRpcEngineNextCallback, MethodHandler, } from '@metamask/json-rpc-engine'; -import type { NetworkClientId } from '@metamask/network-controller'; -import type { Caveat } from '@metamask/permission-controller'; +import type { + NetworkClientId, + NetworkController, +} from '@metamask/network-controller'; import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { MultichainRoutingService } from '@metamask/snaps-controllers'; import { isObject, KnownCaipNamespace, numberToHex } from '@metamask/utils'; import type { CaipAccountId, CaipChainId, - Hex, Json, JsonRpcRequest, PendingJsonRpcResponse, } from '@metamask/utils'; +import type { + Caip25Caveat, + GetCaveatForOriginHook, + GetNonEvmSupportedMethodsHook, + SortAccountIdsByLastSelectedHook, +} from './types'; + export type WalletInvokeMethodParams = { scope: ExternalScopeString; request: Pick; @@ -37,21 +43,17 @@ export type WalletInvokeMethodRequest = origin: string; }; -export type WalletInvokeMethodHooks = { - getCaveatForOrigin: ( - endowmentPermissionName: string, - caveatType: string, - ) => Caveat; - findNetworkClientIdByChainId: (chainId: Hex) => NetworkClientId | undefined; - getSelectedNetworkClientId: () => NetworkClientId; - getNonEvmSupportedMethods: (scope: CaipChainId) => string[]; - sortAccountIdsByLastSelected: (accounts: CaipAccountId[]) => CaipAccountId[]; - handleNonEvmRequestForOrigin: (params: { - connectedAddresses: CaipAccountId[]; - scope: CaipChainId; - request: JsonRpcRequest; - }) => Promise; -}; +export type WalletInvokeMethodHooks = GetCaveatForOriginHook & + GetNonEvmSupportedMethodsHook & + SortAccountIdsByLastSelectedHook & { + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + getSelectedNetworkClientId: () => NetworkClientId; + handleNonEvmRequestForOrigin: (params: { + connectedAddresses: CaipAccountId[]; + scope: CaipChainId; + request: JsonRpcRequest; + }) => ReturnType; + }; /** * Handler for the `wallet_invokeMethod` RPC method as specified by [CAIP-27](https://chainagnostic.org/CAIPs/caip-27). @@ -85,12 +87,12 @@ async function handleWalletInvokeMethod( const { scope, request: wrappedRequest } = request.params; assertIsInternalScopeString(scope); - let caveat; + let caveat: Caip25Caveat | undefined; try { caveat = hooks.getCaveatForOrigin( Caip25EndowmentPermissionName, Caip25CaveatType, - ); + ) as Caip25Caveat | undefined; } catch { // noop } diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts index b04c9104ec2..9c14f205366 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.test.ts @@ -86,6 +86,23 @@ describe('wallet_revokeSession', () => { expect(response.result).toBe(true); }); + it('returns true without revoking if there is no active session and scopes are specified', async () => { + const { + handler, + getCaveatForOrigin, + revokePermissionForOrigin, + updateCaveat, + response, + } = createMockedHandler(); + getCaveatForOrigin.mockReturnValue(undefined); + + await handler({ ...baseRequest, params: { scopes: ['eip155:1'] } }); + + expect(revokePermissionForOrigin).not.toHaveBeenCalled(); + expect(updateCaveat).not.toHaveBeenCalled(); + expect(response.result).toBe(true); + }); + it('partially revokes the CAIP-25 endowment permission if `scopes` param is passed in', async () => { const { handler, getCaveatForOrigin, updateCaveat } = createMockedHandler(); getCaveatForOrigin.mockImplementation(() => ({ diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts index d2e34e1ea35..ce22f546a68 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -12,10 +12,10 @@ import type { } from '@metamask/json-rpc-engine'; import { CaveatMutatorOperation, + GenericPermissionController, PermissionDoesNotExistError, UnrecognizedSubjectError, } from '@metamask/permission-controller'; -import type { Caveat } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; import { isObject } from '@metamask/utils'; import type { @@ -24,17 +24,17 @@ import type { PendingJsonRpcResponse, } from '@metamask/utils'; -export type WalletRevokeSessionHooks = { - revokePermissionForOrigin: (permissionName: string) => void; +import type { Caip25Caveat, GetCaveatForOriginHook } from './types'; + +export type WalletRevokeSessionHooks = GetCaveatForOriginHook & { + revokePermissionForOrigin: ( + permissionName: string, + ) => ReturnType; updateCaveat: ( target: string, caveatType: string, caveatValue: Caip25CaveatValue, - ) => void; - getCaveatForOrigin: ( - endowmentPermissionName: string, - caveatType: string, - ) => Caveat; + ) => ReturnType; }; type WalletRevokeSessionParams = { scopes?: string[] }; @@ -74,10 +74,15 @@ function partialRevokePermissions( scopes: string[], hooks: WalletRevokeSessionHooks, ) { - let updatedCaveatValue = hooks.getCaveatForOrigin( + const caveat = hooks.getCaveatForOrigin( Caip25EndowmentPermissionName, Caip25CaveatType, - ).value; + ) as Caip25Caveat | undefined; + if (!caveat) { + return; + } + + let updatedCaveatValue = caveat.value; for (const scopeString of scopes) { const result = Caip25CaveatMutators[Caip25CaveatType].removeScope( diff --git a/packages/multichain-api-middleware/tsconfig.build.json b/packages/multichain-api-middleware/tsconfig.build.json index d3f977a6176..6674054205c 100644 --- a/packages/multichain-api-middleware/tsconfig.build.json +++ b/packages/multichain-api-middleware/tsconfig.build.json @@ -7,6 +7,7 @@ "rootDir": "./src" }, "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, { "path": "../chain-agnostic-permission/tsconfig.build.json" }, { "path": "../json-rpc-engine/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, diff --git a/packages/multichain-api-middleware/tsconfig.json b/packages/multichain-api-middleware/tsconfig.json index d38c2522293..65fc1ac134f 100644 --- a/packages/multichain-api-middleware/tsconfig.json +++ b/packages/multichain-api-middleware/tsconfig.json @@ -6,6 +6,7 @@ "rootDir": "../.." }, "references": [ + { "path": "../accounts-controller" }, { "path": "../chain-agnostic-permission" }, { "path": "../json-rpc-engine" }, { "path": "../network-controller" }, diff --git a/yarn.lock b/yarn.lock index 207f7f147ad..d0ef7d43d66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4587,6 +4587,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-api-middleware@workspace:packages/multichain-api-middleware" dependencies: + "@metamask/accounts-controller": "npm:^37.2.0" "@metamask/api-specs": "npm:^0.14.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/chain-agnostic-permission": "npm:^1.5.0" @@ -4598,6 +4599,7 @@ __metadata: "@metamask/permission-controller": "npm:^12.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" + "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/utils": "npm:^11.9.0" "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/schema-utils-js": "npm:^2.0.5" From 36dd65e6377ca54c02622a2b6ded007233a966bf Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:40:50 -0700 Subject: [PATCH 24/25] docs: Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3122d17adc4..ab10b22fdc0 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,7 @@ linkStyle default opacity:0.5 multichain_account_service --> keyring_controller; multichain_account_service --> messenger; multichain_account_service --> controller_utils; + multichain_api_middleware --> accounts_controller; multichain_api_middleware --> chain_agnostic_permission; multichain_api_middleware --> controller_utils; multichain_api_middleware --> json_rpc_engine; From 3b99eda77824de0df38f66456d944e80cdee2100 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:52:28 -0700 Subject: [PATCH 25/25] refactor: Remove unused type --- packages/multichain-api-middleware/src/handlers/types.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/multichain-api-middleware/src/handlers/types.ts b/packages/multichain-api-middleware/src/handlers/types.ts index 7d59834ddc6..1e7114a1979 100644 --- a/packages/multichain-api-middleware/src/handlers/types.ts +++ b/packages/multichain-api-middleware/src/handlers/types.ts @@ -19,10 +19,6 @@ export enum MultichainApiNotifications { export type Caip25Caveat = Caveat; -export type GrantedPermissions = Awaited< - ReturnType ->[0]; - export type GetCaveatForOriginHook = { getCaveatForOrigin: ( endowmentPermissionName: string,