diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index ddf877a414c..3f81e2a510e 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -3,23 +3,24 @@ "@typescript-eslint/no-base-to-string": 3, "@typescript-eslint/no-duplicate-enum-values": 2, "@typescript-eslint/no-unsafe-enum-comparison": 34, - "@typescript-eslint/no-unused-vars": 36, - "@typescript-eslint/prefer-promise-reject-errors": 13, - "@typescript-eslint/prefer-readonly": 145, + "@typescript-eslint/no-unused-vars": 41, + "@typescript-eslint/prefer-promise-reject-errors": 33, + "@typescript-eslint/prefer-readonly": 147, + "@typescript-eslint/switch-exhaustiveness-check": 0, "import-x/namespace": 189, "import-x/no-named-as-default": 1, "import-x/no-named-as-default-member": 8, - "import-x/order": 205, - "jest/no-conditional-in-test": 129, + "import-x/order": 211, + "jest/no-conditional-in-test": 138, "jest/prefer-lowercase-title": 2, "jest/prefer-strict-equal": 2, "jsdoc/check-tag-names": 375, - "jsdoc/require-returns": 22, - "jsdoc/tag-lines": 328, - "n/no-unsupported-features/node-builtins": 4, + "jsdoc/require-returns": 25, + "jsdoc/tag-lines": 335, + "n/no-unsupported-features/node-builtins": 14, "n/prefer-global/text-encoder": 4, "n/prefer-global/text-decoder": 4, - "prettier/prettier": 112, + "prettier/prettier": 116, "promise/always-return": 3, "promise/catch-or-return": 2, "promise/param-names": 8, diff --git a/packages/multichain/package.json b/packages/multichain/package.json index a1dedd8ee86..82ba5fb4a73 100644 --- a/packages/multichain/package.json +++ b/packages/multichain/package.json @@ -51,13 +51,18 @@ "@metamask/controller-utils": "^11.4.5", "@metamask/eth-json-rpc-filters": "^9.0.0", "@metamask/rpc-errors": "^7.0.2", + "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^11.0.1", + "@open-rpc/schema-utils-js": "^2.0.5", + "jsonschema": "^1.4.1", "lodash": "^4.17.21" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/json-rpc-engine": "^10.0.2", "@metamask/network-controller": "^22.1.1", "@metamask/permission-controller": "^11.0.5", + "@open-rpc/meta-schema": "^1.14.6", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts index f56ff36137a..a2dfffa7369 100644 --- a/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts +++ b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts @@ -1,6 +1,6 @@ import { toHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; -import { KnownCaipNamespace } from '@metamask/utils'; +import { hexToBigInt, KnownCaipNamespace } from '@metamask/utils'; import type { Caip25CaveatValue } from '../caip25Permission'; import { getUniqueArrayItems } from '../scope/transform'; @@ -57,7 +57,7 @@ export const addPermittedEthChainId = ( caip25CaveatValue: Caip25CaveatValue, chainId: Hex, ): Caip25CaveatValue => { - const scopeString = `eip155:${parseInt(chainId, 16)}`; + const scopeString = `eip155:${hexToBigInt(chainId).toString(10)}`; if ( Object.keys(caip25CaveatValue.requiredScopes).includes(scopeString) || Object.keys(caip25CaveatValue.optionalScopes).includes(scopeString) diff --git a/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.test.ts b/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.test.ts new file mode 100644 index 00000000000..62b183f5185 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.test.ts @@ -0,0 +1,135 @@ +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from '../scope/constants'; +import { + getInternalScopesObject, + getSessionScopes, +} from './caip-permission-adapter-session-scopes'; + +describe('CAIP-25 session scopes adapters', () => { + describe('getInternalScopesObject', () => { + it('returns an InternalScopesObject with only the accounts from each NormalizedScopeObject', () => { + const result = getInternalScopesObject({ + 'wallet:eip155': { + methods: ['foo', 'bar'], + notifications: ['baz'], + accounts: ['wallet:eip155:0xdead'], + }, + 'eip155:1': { + methods: ['eth_call'], + notifications: ['eth_subscription'], + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }); + + expect(result).toStrictEqual({ + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdead'], + }, + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }); + }); + }); + + describe('getSessionScopes', () => { + it('returns a NormalizedScopesObject for the wallet scope', () => { + const result = getSessionScopes({ + requiredScopes: {}, + optionalScopes: { + wallet: { + accounts: [], + }, + }, + }); + + expect(result).toStrictEqual({ + wallet: { + methods: KnownWalletRpcMethods, + notifications: [], + accounts: [], + }, + }); + }); + + it('returns a NormalizedScopesObject for the wallet:eip155 scope', () => { + const result = getSessionScopes({ + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + }); + + expect(result).toStrictEqual({ + 'wallet:eip155': { + methods: KnownWalletNamespaceRpcMethods.eip155, + notifications: [], + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }); + }); + + it('returns a NormalizedScopesObject with empty methods and notifications for scope with wallet namespace and unknown reference', () => { + const result = getSessionScopes({ + requiredScopes: {}, + optionalScopes: { + 'wallet:foobar': { + accounts: ['wallet:foobar:0xdeadbeef'], + }, + }, + }); + + expect(result).toStrictEqual({ + 'wallet:foobar': { + methods: [], + notifications: [], + accounts: ['wallet:foobar:0xdeadbeef'], + }, + }); + }); + + it('returns a NormalizedScopesObject with empty methods and notifications for scope not wallet namespace and unknown reference', () => { + const result = getSessionScopes({ + requiredScopes: {}, + optionalScopes: { + 'foo:1': { + accounts: ['foo:1:0xdeadbeef'], + }, + }, + }); + + expect(result).toStrictEqual({ + 'foo:1': { + methods: [], + notifications: [], + accounts: ['foo:1:0xdeadbeef'], + }, + }); + }); + + it('returns a NormalizedScopesObject for a eip155 namespaced scope', () => { + const result = getSessionScopes({ + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + }, + }); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:1:0xdeadbeef'], + }, + }); + }); + }); +}); diff --git a/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts b/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts new file mode 100644 index 00000000000..7e05eb01ad3 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.ts @@ -0,0 +1,101 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission'; +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from '../scope/constants'; +import { mergeScopes } from '../scope/transform'; +import type { + InternalScopesObject, + NonWalletKnownCaipNamespace, + NormalizedScopesObject, +} from '../scope/types'; +import { parseScopeString } from '../scope/types'; + +/** + * Converts an NormalizedScopesObject to a InternalScopesObject. + * @param normalizedScopesObject - The NormalizedScopesObject to convert. + * @returns An InternalScopesObject. + */ +export const getInternalScopesObject = ( + normalizedScopesObject: NormalizedScopesObject, +) => { + const internalScopes: InternalScopesObject = {}; + + Object.entries(normalizedScopesObject).forEach( + ([_scopeString, { accounts }]) => { + const scopeString = _scopeString as keyof typeof normalizedScopesObject; + + internalScopes[scopeString] = { + accounts, + }; + }, + ); + + return internalScopes; +}; + +/** + * Converts an InternalScopesObject to a NormalizedScopesObject. + * @param internalScopesObject - The InternalScopesObject to convert. + * @returns A NormalizedScopesObject. + */ +const getNormalizedScopesObject = ( + internalScopesObject: InternalScopesObject, +) => { + const normalizedScopes: NormalizedScopesObject = {}; + + Object.entries(internalScopesObject).forEach( + ([_scopeString, { accounts }]) => { + const scopeString = _scopeString as keyof typeof internalScopesObject; + const { namespace, reference } = parseScopeString(scopeString); + let methods: string[] = []; + let notifications: string[] = []; + + if (namespace === KnownCaipNamespace.Wallet) { + if (reference) { + methods = + KnownWalletNamespaceRpcMethods[ + reference as NonWalletKnownCaipNamespace + ] ?? []; + } else { + methods = KnownWalletRpcMethods; + } + } else { + methods = + KnownRpcMethods[namespace as NonWalletKnownCaipNamespace] ?? []; + notifications = + KnownNotifications[namespace as NonWalletKnownCaipNamespace] ?? []; + } + + normalizedScopes[scopeString] = { + methods, + notifications, + accounts, + }; + }, + ); + + return normalizedScopes; +}; + +/** + * Takes the scopes from an endowment:caip25 permission caveat value, + * hydrates them with supported methods and notifications, and returns a NormalizedScopesObject. + * @param caip25CaveatValue - The CAIP-25 CaveatValue to convert. + * @returns A NormalizedScopesObject. + */ +export const getSessionScopes = ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' + >, +) => { + return mergeScopes( + getNormalizedScopesObject(caip25CaveatValue.requiredScopes), + getNormalizedScopesObject(caip25CaveatValue.optionalScopes), + ); +}; diff --git a/packages/multichain/src/handlers/wallet-getSession.test.ts b/packages/multichain/src/handlers/wallet-getSession.test.ts new file mode 100644 index 00000000000..206f706eb0f --- /dev/null +++ b/packages/multichain/src/handlers/wallet-getSession.test.ts @@ -0,0 +1,160 @@ +import type { JsonRpcRequest } from '@metamask/utils'; + +import * as PermissionAdapterSessionScopes from '../adapters/caip-permission-adapter-session-scopes'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +import { walletGetSession } from './wallet-getSession'; + +jest.mock('../adapters/caip-permission-adapter-session-scopes', () => ({ + getSessionScopes: jest.fn(), +})); +const MockPermissionAdapterSessionScopes = jest.mocked( + PermissionAdapterSessionScopes, +); + +const baseRequest: JsonRpcRequest & { origin: string } = { + origin: 'http://test.com', + jsonrpc: '2.0' as const, + method: 'wallet_getSession', + params: {}, + id: 1, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveatForOrigin = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + }, + }); + const response = { + result: { + sessionScopes: {}, + }, + id: 1, + jsonrpc: '2.0' as const, + }; + const handler = (request: JsonRpcRequest & { origin: string }) => + walletGetSession.implementation(request, response, next, end, { + getCaveatForOrigin, + }); + + return { + next, + response, + end, + getCaveatForOrigin, + handler, + }; +}; + +describe('wallet_getSession', () => { + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const { handler, getCaveatForOrigin } = createMockedHandler(); + + await handler(baseRequest); + expect(getCaveatForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('returns empty scopes if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, getCaveatForOrigin } = createMockedHandler(); + getCaveatForOrigin.mockImplementation(() => { + throw new Error('permission not found'); + }); + + await handler(baseRequest); + expect(response.result).toStrictEqual({ + sessionScopes: {}, + }); + }); + + it('gets the session scopes from the CAIP-25 caveat value', async () => { + const { handler } = createMockedHandler(); + + await handler(baseRequest); + expect( + MockPermissionAdapterSessionScopes.getSessionScopes, + ).toHaveBeenCalledWith({ + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + }); + }); + + it('returns the session scopes', async () => { + const { handler, response } = createMockedHandler(); + + MockPermissionAdapterSessionScopes.getSessionScopes.mockReturnValue({ + 'eip155:1': { + methods: ['eth_call', 'net_version'], + notifications: ['chainChanged'], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + }); + + await handler(baseRequest); + expect(response.result).toStrictEqual({ + sessionScopes: { + 'eip155:1': { + methods: ['eth_call', 'net_version'], + notifications: ['chainChanged'], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + }, + }); + }); +}); diff --git a/packages/multichain/src/handlers/wallet-getSession.ts b/packages/multichain/src/handlers/wallet-getSession.ts new file mode 100644 index 00000000000..bf343495091 --- /dev/null +++ b/packages/multichain/src/handlers/wallet-getSession.ts @@ -0,0 +1,64 @@ +import type { Caveat } from '@metamask/permission-controller'; +import type { JsonRpcRequest, JsonRpcSuccess } from '@metamask/utils'; +import type { NormalizedScopesObject } from 'src/scope/types'; + +import { getSessionScopes } from '../adapters/caip-permission-adapter-session-scopes'; +import type { Caip25CaveatValue } from '../caip25Permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; + +/** + * 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, + * and that an empty object is returned for the `sessionScopes` result rather than throwing an error if there + * is no active session for the origin. + * + * @param request - The request object. + * @param response - The response object. + * @param _next - The next middleware function. Unused. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveatForOrigin - Function to retrieve a caveat for the origin. + */ +async function walletGetSessionHandler( + request: JsonRpcRequest & { origin: string }, + response: JsonRpcSuccess<{ sessionScopes: NormalizedScopesObject }>, + _next: () => void, + end: () => void, + hooks: { + getCaveatForOrigin: ( + endowmentPermissionName: string, + caveatType: string, + ) => Caveat; + }, +) { + let caveat; + try { + caveat = hooks.getCaveatForOrigin( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (e) { + // noop + } + + if (!caveat) { + response.result = { sessionScopes: {} }; + return end(); + } + + response.result = { + sessionScopes: getSessionScopes(caveat.value), + }; + return end(); +} + +export const walletGetSession = { + methodNames: ['wallet_getSession'], + implementation: walletGetSessionHandler, + hookNames: { + getCaveatForOrigin: true, + }, +}; diff --git a/packages/multichain/src/handlers/wallet-invokeMethod.test.ts b/packages/multichain/src/handlers/wallet-invokeMethod.test.ts new file mode 100644 index 00000000000..ae7da846565 --- /dev/null +++ b/packages/multichain/src/handlers/wallet-invokeMethod.test.ts @@ -0,0 +1,328 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; + +import * as PermissionAdapterSessionScopes from '../adapters/caip-permission-adapter-session-scopes'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +import type { WalletInvokeMethodRequest } from './wallet-invokeMethod'; +import { walletInvokeMethod } from './wallet-invokeMethod'; + +jest.mock('../adapters/caip-permission-adapter-session-scopes', () => ({ + getSessionScopes: jest.fn(), +})); +const MockPermissionAdapterSessionScopes = jest.mocked( + PermissionAdapterSessionScopes, +); + +const createMockedRequest = () => ({ + jsonrpc: '2.0' as const, + id: 0, + origin: 'http://test.com', + method: 'wallet_invokeMethod', + params: { + scope: 'eip155:1', + request: { + method: 'eth_call', + params: { + foo: 'bar', + }, + }, + }, +}); + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveatForOrigin = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + }); + const findNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); + const getSelectedNetworkClientId = jest + .fn() + .mockReturnValue('selectedNetworkClientId'); + const handler = (request: WalletInvokeMethodRequest) => + walletInvokeMethod.implementation( + request, + { jsonrpc: '2.0', id: 1 }, + next, + end, + { + getCaveatForOrigin, + findNetworkClientIdByChainId, + getSelectedNetworkClientId, + }, + ); + + return { + next, + end, + getCaveatForOrigin, + findNetworkClientIdByChainId, + getSelectedNetworkClientId, + handler, + }; +}; + +describe('wallet_invokeMethod', () => { + beforeEach(() => { + MockPermissionAdapterSessionScopes.getSessionScopes.mockReturnValue({ + 'eip155:1': { + methods: ['eth_call', 'net_version'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + 'unknown:scope': { + methods: ['foobar'], + notifications: [], + accounts: [], + }, + }); + }); + + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const request = createMockedRequest(); + const { handler, getCaveatForOrigin } = createMockedHandler(); + await handler(request); + expect(getCaveatForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('gets the session scopes from the CAIP-25 caveat value', async () => { + const request = createMockedRequest(); + const { handler } = createMockedHandler(); + await handler(request); + expect( + MockPermissionAdapterSessionScopes.getSessionScopes, + ).toHaveBeenCalledWith({ + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + wallet: { + accounts: [], + }, + }, + isMultichainOrigin: true, + }); + }); + + it('throws an unauthorized error when there is no CAIP-25 endowment permission', async () => { + const request = createMockedRequest(); + const { handler, getCaveatForOrigin, end } = createMockedHandler(); + getCaveatForOrigin.mockImplementation(() => { + throw new Error('permission not found'); + }); + await handler(request); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error when the CAIP-25 endowment permission was not granted from the multichain flow', async () => { + const request = createMockedRequest(); + const { handler, getCaveatForOrigin, end } = createMockedHandler(); + getCaveatForOrigin.mockReturnValue({ + value: { + isMultichainOrigin: false, + }, + }); + await handler(request); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error if the requested scope is not authorized', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'eip155:999', + }, + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error if the requested scope method is not authorized', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + request: { + ...request.params.request, + method: 'unauthorized_method', + }, + }, + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an internal error for authorized but unsupported scopes', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'unknown:scope', + request: { + ...request.params.request, + method: 'foobar', + }, + }, + }); + + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + describe('ethereum scope', () => { + it('gets the networkClientId for the chainId', async () => { + const request = createMockedRequest(); + const { handler, findNetworkClientIdByChainId } = createMockedHandler(); + + await handler(request); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + it('throws an internal error if a networkClientId does not exist for the chainId', async () => { + const request = createMockedRequest(); + const { handler, findNetworkClientIdByChainId, end } = + createMockedHandler(); + findNetworkClientIdByChainId.mockReturnValue(undefined); + + await handler(request); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('sets the networkClientId and unwraps the CAIP-27 request', async () => { + const request = createMockedRequest(); + const { handler, next } = createMockedHandler(); + + await handler(request); + expect(request).toStrictEqual({ + jsonrpc: '2.0' as const, + id: 0, + scope: 'eip155:1', + origin: 'http://test.com', + networkClientId: 'mainnet', + method: 'eth_call', + params: { + foo: 'bar', + }, + }); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('wallet scope', () => { + it('gets the networkClientId for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(getSelectedNetworkClientId).toHaveBeenCalled(); + }); + + it('throws an internal error if a networkClientId cannot be retrieved for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId, end } = + createMockedHandler(); + getSelectedNetworkClientId.mockReturnValue(undefined); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('sets the networkClientId and unwraps the CAIP-27 request', async () => { + const request = createMockedRequest(); + const { handler, next } = createMockedHandler(); + + const walletRequest = { + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }; + await handler(walletRequest); + expect(walletRequest).toStrictEqual({ + jsonrpc: '2.0' as const, + id: 0, + scope: 'wallet', + origin: 'http://test.com', + networkClientId: 'selectedNetworkClientId', + method: 'wallet_watchAsset', + params: { + foo: 'bar', + }, + }); + expect(next).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/multichain/src/handlers/wallet-invokeMethod.ts b/packages/multichain/src/handlers/wallet-invokeMethod.ts new file mode 100644 index 00000000000..cc2e8cf0b03 --- /dev/null +++ b/packages/multichain/src/handlers/wallet-invokeMethod.ts @@ -0,0 +1,127 @@ +import type { NetworkClientId } from '@metamask/network-controller'; +import type { Caveat } from '@metamask/permission-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { + Hex, + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { numberToHex } from '@metamask/utils'; + +import { getSessionScopes } from '../adapters/caip-permission-adapter-session-scopes'; +import type { Caip25CaveatValue } from '../caip25Permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +import { assertIsInternalScopeString } from '../scope/assert'; +import type { ExternalScopeString } from '../scope/types'; +import { parseScopeString } from '../scope/types'; + +export type WalletInvokeMethodRequest = JsonRpcRequest & { + origin: string; + params: { + scope: ExternalScopeString; + request: Pick; + }; +}; + +/** + * Handler for the `wallet_invokeMethod` RPC method as specified by [CAIP-27](https://chainagnostic.org/CAIPs/caip-27). + * The implementation below deviates from the linked spec in that it ignores the `sessionId` param + * and instead uses the singular session for the origin if available. + * + * @param request - The request object. + * @param _response - The response object. Unused. + * @param next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveatForOrigin - the hook for getting a caveat from a permission for an origin. + * @param hooks.findNetworkClientIdByChainId - the hook for finding the networkClientId for a chainId. + * @param hooks.getSelectedNetworkClientId - the hook for getting the current globally selected networkClientId. + */ +async function walletInvokeMethodHandler( + request: WalletInvokeMethodRequest, + _response: PendingJsonRpcResponse, + next: () => void, + end: (error: Error) => void, + hooks: { + getCaveatForOrigin: ( + endowmentPermissionName: string, + caveatType: string, + ) => Caveat; + findNetworkClientIdByChainId: (chainId: Hex) => NetworkClientId | undefined; + getSelectedNetworkClientId: () => NetworkClientId; + }, +) { + const { scope, request: wrappedRequest } = request.params; + + assertIsInternalScopeString(scope); + + let caveat; + try { + caveat = hooks.getCaveatForOrigin( + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (e) { + // noop + } + if (!caveat?.value?.isMultichainOrigin) { + return end(providerErrors.unauthorized()); + } + + const scopeObject = getSessionScopes(caveat.value)[scope]; + + if (!scopeObject?.methods?.includes(wrappedRequest.method)) { + return end(providerErrors.unauthorized()); + } + + const { namespace, reference } = parseScopeString(scope); + + let networkClientId; + switch (namespace) { + case 'wallet': + networkClientId = hooks.getSelectedNetworkClientId(); + break; + case 'eip155': + if (reference) { + networkClientId = hooks.findNetworkClientIdByChainId( + numberToHex(parseInt(reference, 10)), + ); + } + break; + default: + console.error( + 'failed to resolve namespace for wallet_invokeMethod', + request, + ); + return end(rpcErrors.internal()); + } + + if (!networkClientId) { + console.error( + 'failed to resolve network client for wallet_invokeMethod', + request, + ); + return end(rpcErrors.internal()); + } + + Object.assign(request, { + scope, + networkClientId, + method: wrappedRequest.method, + params: wrappedRequest.params, + }); + return next(); +} +export const walletInvokeMethod = { + methodNames: ['wallet_invokeMethod'], + implementation: walletInvokeMethodHandler, + hookNames: { + getCaveatForOrigin: true, + findNetworkClientIdByChainId: true, + getSelectedNetworkClientId: true, + }, +}; diff --git a/packages/multichain/src/handlers/wallet-revokeSession.test.ts b/packages/multichain/src/handlers/wallet-revokeSession.test.ts new file mode 100644 index 00000000000..c74c95a1f7c --- /dev/null +++ b/packages/multichain/src/handlers/wallet-revokeSession.test.ts @@ -0,0 +1,91 @@ +import { + PermissionDoesNotExistError, + UnrecognizedSubjectError, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import { Caip25EndowmentPermissionName } from '../caip25Permission'; +import { walletRevokeSession } from './wallet-revokeSession'; + +const baseRequest: JsonRpcRequest & { origin: string } = { + origin: 'http://test.com', + params: {}, + jsonrpc: '2.0' as const, + id: 1, + method: 'wallet_revokeSession', +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const revokePermissionForOrigin = jest.fn(); + const response = { + result: true, + id: 1, + jsonrpc: '2.0' as const, + }; + const handler = (request: JsonRpcRequest & { origin: string }) => + walletRevokeSession.implementation(request, response, next, end, { + revokePermissionForOrigin, + }); + + return { + next, + response, + end, + revokePermissionForOrigin, + handler, + }; +}; + +describe('wallet_revokeSession', () => { + it('revokes the the CAIP-25 endowment permission', async () => { + const { handler, revokePermissionForOrigin } = createMockedHandler(); + + await handler(baseRequest); + expect(revokePermissionForOrigin).toHaveBeenCalledWith( + Caip25EndowmentPermissionName, + ); + }); + + it('returns true if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, revokePermissionForOrigin } = createMockedHandler(); + revokePermissionForOrigin.mockImplementation(() => { + throw new PermissionDoesNotExistError( + 'foo.com', + Caip25EndowmentPermissionName, + ); + }); + + await handler(baseRequest); + expect(response.result).toBe(true); + }); + + it('returns true if the subject does not exist', async () => { + const { handler, response, revokePermissionForOrigin } = createMockedHandler(); + revokePermissionForOrigin.mockImplementation(() => { + throw new UnrecognizedSubjectError('foo.com'); + }); + + await handler(baseRequest); + expect(response.result).toBe(true); + }); + + it('throws an internal RPC error if something unexpected goes wrong with revoking the permission', async () => { + const { handler, revokePermissionForOrigin, end } = createMockedHandler(); + revokePermissionForOrigin.mockImplementation(() => { + throw new Error('revoke failed'); + }); + + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('returns true if the permission was revoked', async () => { + const { handler, response } = createMockedHandler(); + + await handler(baseRequest); + expect(response.result).toBe(true); + }); +}); diff --git a/packages/multichain/src/handlers/wallet-revokeSession.ts b/packages/multichain/src/handlers/wallet-revokeSession.ts new file mode 100644 index 00000000000..46878cac016 --- /dev/null +++ b/packages/multichain/src/handlers/wallet-revokeSession.ts @@ -0,0 +1,58 @@ +import type { + JsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, +} from '@metamask/json-rpc-engine'; +import { + PermissionDoesNotExistError, + UnrecognizedSubjectError, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcSuccess, Json, JsonRpcRequest } from '@metamask/utils'; + +import { Caip25EndowmentPermissionName } from '../caip25Permission'; + +/** + * Handler for the `wallet_revokeSession` RPC method as specified by [CAIP-285](https://chainagnostic.org/CAIPs/caip-285). + * The implementation below deviates from the linked spec in that it ignores the `sessionId` param + * and instead revokes the singular session for the origin if available. Additionally, + * the handler also does not return an error if there is currently no active session and instead + * returns true which is the same result returned if an active session was actually revoked. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The next middleware function. Unused. + * @param end - The end callback function. + * @param hooks - The hooks object. + * @param hooks.revokePermissionForOrigin - The hook for revoking a permission for an origin function. + */ +async function walletRevokeSessionHandler( + request: JsonRpcRequest & { origin: string }, + response: JsonRpcSuccess, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: { + revokePermissionForOrigin: (permissionName: string) => void; + }, +) { + try { + hooks.revokePermissionForOrigin(Caip25EndowmentPermissionName); + } catch (err) { + if ( + !(err instanceof UnrecognizedSubjectError) && + !(err instanceof PermissionDoesNotExistError) + ) { + console.error(err); + return end(rpcErrors.internal()); + } + } + + response.result = true; + return end(); +} +export const walletRevokeSession = { + methodNames: ['wallet_revokeSession'], + implementation: walletRevokeSessionHandler, + hookNames: { + revokePermissionForOrigin: true, + }, +}; diff --git a/packages/multichain/src/index.test.ts b/packages/multichain/src/index.test.ts index 38ab1bdde85..8465d5a24fd 100644 --- a/packages/multichain/src/index.test.ts +++ b/packages/multichain/src/index.test.ts @@ -9,12 +9,22 @@ describe('@metamask/multichain', () => { "getPermittedEthChainIds", "addPermittedEthChainId", "setPermittedEthChainIds", + "getInternalScopesObject", + "getSessionScopes", + "walletGetSession", + "walletInvokeMethod", + "walletRevokeSession", + "multichainMethodCallValidatorMiddleware", + "MultichainMiddlewareManager", + "MultichainSubscriptionManager", "validateAndNormalizeScopes", + "bucketScopes", "KnownWalletRpcMethods", "KnownRpcMethods", "KnownWalletNamespaceRpcMethods", "KnownNotifications", "KnownWalletScopeString", + "getSupportedScopeObjects", "parseScopeString", "normalizeScope", "mergeScopeObject", diff --git a/packages/multichain/src/index.ts b/packages/multichain/src/index.ts index 5b56923ffa9..60732796b47 100644 --- a/packages/multichain/src/index.ts +++ b/packages/multichain/src/index.ts @@ -7,9 +7,24 @@ export { addPermittedEthChainId, setPermittedEthChainIds, } from './adapters/caip-permission-adapter-permittedChains'; +export { + getInternalScopesObject, + getSessionScopes, +} from './adapters/caip-permission-adapter-session-scopes'; + +export { walletGetSession } from './handlers/wallet-getSession'; +export { walletInvokeMethod } from './handlers/wallet-invokeMethod'; +export { walletRevokeSession } from './handlers/wallet-revokeSession'; + +export { multichainMethodCallValidatorMiddleware } from './middlewares/multichainMethodCallValidator'; +export { MultichainMiddlewareManager } from './middlewares/MultichainMiddlewareManager'; +export { MultichainSubscriptionManager } from './middlewares/MultichainSubscriptionManager'; export type { Caip25Authorization } from './scope/authorization'; -export { validateAndNormalizeScopes } from './scope/authorization'; +export { + validateAndNormalizeScopes, + bucketScopes, +} from './scope/authorization'; export { KnownWalletRpcMethods, KnownRpcMethods, @@ -17,6 +32,7 @@ export { KnownNotifications, KnownWalletScopeString, } from './scope/constants'; +export { getSupportedScopeObjects } from './scope/filter'; export type { ExternalScopeString, ExternalScopeObject, diff --git a/packages/multichain/src/middlewares/MultichainMiddlewareManager.test.ts b/packages/multichain/src/middlewares/MultichainMiddlewareManager.test.ts new file mode 100644 index 00000000000..afb57036e8c --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainMiddlewareManager.test.ts @@ -0,0 +1,377 @@ +import { rpcErrors } from '@metamask/rpc-errors'; + +import type { ExtendedJsonRpcMiddleware } from './MultichainMiddlewareManager'; +import { MultichainMiddlewareManager } from './MultichainMiddlewareManager'; + +const scope = 'eip155:1'; +const origin = 'example.com'; +const tabId = 123; + +describe('MultichainMiddlewareManager', () => { + it('should add middleware and get called for the scope, origin, and tabId if request is "eth_subscribe', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_subscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).toHaveBeenCalledWith( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_subscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should add middleware and get called for the scope, origin, and tabId if request is "eth_unsubscribe', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_unsubscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).toHaveBeenCalledWith( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_unsubscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should add middleware and call next if called for the scope, origin, and tabId but request is not "eth_subscribe" or "eth_unsubscribe"', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled() + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('call next if no middleware exists for scope, origin, and tabId and request is not "eth_subscribe" or "eth_unsubscribe', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('return error if no middleware exists for scope, origin, and tabId and request is "eth_subscribe"', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_subscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).toHaveBeenCalledWith(rpcErrors.methodNotFound()); + }); + + it('return error if no middleware exists for scope, origin, and tabId and request is "eth_unsubscribe"', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'eth_unsubscribe', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).toHaveBeenCalledWith(rpcErrors.methodNotFound()); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed and the middleware has no destroy function', async () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + await middleware.destroy?.(); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed and the middleware destroy function resolves', async () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + // eslint-disable-next-line jest/prefer-spy-on + middlewareSpy.destroy = jest.fn().mockResolvedValue(undefined); + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + await middleware.destroy?.(); + + expect(middlewareSpy.destroy).toHaveBeenCalled(); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed and the middleware destroy function rejects', async () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + // eslint-disable-next-line jest/prefer-spy-on + middlewareSpy.destroy = jest + .fn() + .mockRejectedValue( + new Error('failed to destroy the actual underlying middleware'), + ); + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + await middleware.destroy?.(); + + expect(middlewareSpy.destroy).toHaveBeenCalled(); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by scope', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByScope(scope); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by scope and origin', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByScopeAndOrigin(scope, origin); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByOriginAndTabId(origin, tabId); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/multichain/src/middlewares/MultichainMiddlewareManager.ts b/packages/multichain/src/middlewares/MultichainMiddlewareManager.ts new file mode 100644 index 00000000000..8b056ea4f32 --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainMiddlewareManager.ts @@ -0,0 +1,152 @@ +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; + +import type { ExternalScopeString } from '../scope/types'; + +export type ExtendedJsonRpcMiddleware = { + ( + req: JsonRpcRequest & { scope: string }, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + ): void; + destroy?: () => void | Promise; +}; + +type MiddlewareKey = { + scope: ExternalScopeString; + origin: string; + tabId?: number; +}; +type MiddlewareEntry = MiddlewareKey & { + middleware: ExtendedJsonRpcMiddleware; +}; + +// Methods related to eth_subscriptions +const SubscriptionMethods = ['eth_subscribe', 'eth_unsubscribe']; + +/** + * A helper that facilates registering and calling of provided middleware instances + * in the RPC pipeline based on the incoming request's scope, origin, and tabId. + * The core purpose of this class is to enable and manage multichain subscriptions + * (i.e. eth_subscribe called accross different chains and domains). + * + * Note that only one middleware instance can be registered per scope, origin, tabId key. + */ +export class MultichainMiddlewareManager { + #middlewares: MiddlewareEntry[] = []; + + #getMiddlewareEntry({ + scope, + origin, + tabId, + }: MiddlewareKey): MiddlewareEntry | undefined { + return this.#middlewares.find((middlewareEntry) => { + return ( + middlewareEntry.scope === scope && + middlewareEntry.origin === origin && + middlewareEntry.tabId === tabId + ); + }); + } + + #removeMiddlewareEntry({ scope, origin, tabId }: MiddlewareEntry) { + this.#middlewares = this.#middlewares.filter((middlewareEntry) => { + return ( + middlewareEntry.scope !== scope || + middlewareEntry.origin !== origin || + middlewareEntry.tabId !== tabId + ); + }); + } + + addMiddleware(middlewareEntry: MiddlewareEntry) { + const { scope, origin, tabId } = middlewareEntry; + if (!this.#getMiddlewareEntry({ scope, origin, tabId })) { + this.#middlewares.push(middlewareEntry); + } + } + + #removeMiddleware(middlewareEntry: MiddlewareEntry) { + // When the destroy function on the middleware is async, + // we don't need to wait for it complete + Promise.resolve(middlewareEntry.middleware.destroy?.()).catch(() => { + // do nothing + }); + + this.#removeMiddlewareEntry(middlewareEntry); + } + + removeMiddlewareByScope(scope: ExternalScopeString) { + this.#middlewares.forEach((middlewareEntry) => { + if (middlewareEntry.scope === scope) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + removeMiddlewareByScopeAndOrigin(scope: ExternalScopeString, origin: string) { + this.#middlewares.forEach((middlewareEntry) => { + if ( + middlewareEntry.scope === scope && + middlewareEntry.origin === origin + ) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + removeMiddlewareByOriginAndTabId(origin: string, tabId?: number) { + this.#middlewares.forEach((middlewareEntry) => { + if ( + middlewareEntry.origin === origin && + middlewareEntry.tabId === tabId + ) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + generateMultichainMiddlewareForOriginAndTabId( + origin: string, + tabId?: number, + ) { + const middleware: ExtendedJsonRpcMiddleware = (req, res, next, end) => { + const { scope } = req; + const middlewareEntry = this.#getMiddlewareEntry({ + scope, + origin, + tabId, + }); + + if (SubscriptionMethods.includes(req.method)) { + if (middlewareEntry) { + middlewareEntry.middleware(req, res, next, end); + } else { + // TODO: Temporary safety guard to prevent requests with these methods + // from being forwarded to the RPC endpoint even though this scenario + // should not be possible. + return end(rpcErrors.methodNotFound()); + } + } else { + return next(); + } + return undefined; + }; + middleware.destroy = this.removeMiddlewareByOriginAndTabId.bind( + this, + origin, + tabId, + ); + + return middleware; + } +} diff --git a/packages/multichain/src/middlewares/MultichainSubscriptionManager.test.ts b/packages/multichain/src/middlewares/MultichainSubscriptionManager.test.ts new file mode 100644 index 00000000000..75c6d3df05f --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainSubscriptionManager.test.ts @@ -0,0 +1,165 @@ +import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; +import type SafeEventEmitter from '@metamask/safe-event-emitter'; + +import { MultichainSubscriptionManager } from './MultichainSubscriptionManager'; + +jest.mock('@metamask/eth-json-rpc-filters/subscriptionManager', () => + jest.fn(), +); +const MockCreateSubscriptionManager = jest.mocked(createSubscriptionManager); + +const newHeadsNotificationMock = { + method: 'eth_subscription', + params: { + result: { + difficulty: '0x15d9223a23aa', + extraData: '0xd983010305844765746887676f312e342e328777696e646f7773', + gasLimit: '0x47e7c4', + gasUsed: '0x38658', + logsBloom: + '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + miner: '0xf8b483dba2c3b7176a3da549ad41a48bb3121069', + nonce: '0x084149998194cc5f', + number: '0x1348c9', + parentHash: + '0x7736fab79e05dc611604d22470dadad26f56fe494421b5b333de816ce1f25701', + receiptRoot: + '0x2fab35823ad00c7bb388595cb46652fe7886e00660a01e867824d3dceb1c8d36', + sha3Uncles: + '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', + stateRoot: + '0xb3346685172db67de536d8765c43c31009d0eb3bd9c501c9be3229203f15f378', + timestamp: '0x56ffeff8', + }, + }, +}; + +const scope = 'eip155:1'; +const origin = 'example.com'; +const tabId = 123; + +const createMultichainSubscriptionManager = () => { + const mockFindNetworkClientIdByChainId = jest.fn(); + const mockGetNetworkClientById = jest.fn().mockImplementation(() => ({ + blockTracker: {}, + provider: {}, + })); + const multichainSubscriptionManager = new MultichainSubscriptionManager({ + findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, + getNetworkClientById: mockGetNetworkClientById, + }); + + return { multichainSubscriptionManager }; +}; + +const createMockSubscriptionManager = () => ({ + events: { + on: jest.fn(), + } as unknown as jest.Mocked, + destroy: jest.fn(), + middleware: { + destroy: jest.fn(), + }, +}); + +describe('MultichainSubscriptionManager', () => { + let mockSubscriptionManager = createMockSubscriptionManager(); + + beforeEach(() => { + mockSubscriptionManager = createMockSubscriptionManager(); + MockCreateSubscriptionManager.mockReturnValue(mockSubscriptionManager); + }); + + it('should not create a new subscriptionManager if one matches the passed in subscriptionKey', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + + const firstSubscription = multichainSubscriptionManager.subscribe({ + scope, + origin, + tabId, + }); + + const secondSubscription = multichainSubscriptionManager.subscribe({ + scope, + origin, + tabId, + }); + + expect(secondSubscription).toBe(firstSubscription); + expect(MockCreateSubscriptionManager).toHaveBeenCalledTimes(1); + }); + + it('should subscribe to a scope, origin, and tabId', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + const notifySpy = jest.fn(); + multichainSubscriptionManager.on('notification', notifySpy); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + expect(notifySpy).toHaveBeenCalledWith(origin, tabId, { + method: 'wallet_notify', + params: { + scope, + notification: newHeadsNotificationMock, + }, + }); + }); + + it('should unsubscribe from a scope', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScope(scope); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); + + it('should unsubscribe from a scope and origin', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScopeAndOrigin(scope, origin); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); + + it('should do nothing if an unsubscribe call does not match an existing subscription', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScope('eip155:10'); + multichainSubscriptionManager.unsubscribeByScopeAndOrigin( + scope, + 'other-origin', + ); + multichainSubscriptionManager.unsubscribeByOriginAndTabId( + 'other-origin', + 123, + ); + + expect(mockSubscriptionManager.destroy).not.toHaveBeenCalled(); + }); + + it('should unsubscribe from a origin and tabId', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByOriginAndTabId(origin, tabId); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); + + it('should unsubscribe when the middleware is destroyed', () => { + const { multichainSubscriptionManager } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + mockSubscriptionManager.middleware.destroy(); + + expect(mockSubscriptionManager.destroy).toHaveBeenCalled(); + }); +}); diff --git a/packages/multichain/src/middlewares/MultichainSubscriptionManager.ts b/packages/multichain/src/middlewares/MultichainSubscriptionManager.ts new file mode 100644 index 00000000000..9df0bb48518 --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainSubscriptionManager.ts @@ -0,0 +1,173 @@ +import { toHex } from '@metamask/controller-utils'; +import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; +import type { NetworkController } from '@metamask/network-controller'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import { parseCaipChainId } from '@metamask/utils'; + +import type { ExternalScopeString } from '../scope/types'; +import type { ExtendedJsonRpcMiddleware } from './MultichainMiddlewareManager'; + +export type SubscriptionManager = { + events: SafeEventEmitter; + destroy?: () => void; + middleware: ExtendedJsonRpcMiddleware; +}; + +type SubscriptionNotificationEvent = { + jsonrpc: '2.0'; + method: 'eth_subscription'; + params: { + subscription: Hex; + result: unknown; + }; +}; + +type SubscriptionKey = { + scope: ExternalScopeString; + origin: string; + tabId?: number; +}; +type SubscriptionEntry = SubscriptionKey & { + subscriptionManager: SubscriptionManager; +}; + +type MultichainSubscriptionManagerOptions = { + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + getNetworkClientById: NetworkController['getNetworkClientById']; +}; + +/** + * A helper that facilates the lifecycle of a SubscriptionManager instance that + * is meant to handle subscriptons for only one specific scope, origin, and tabId combination. + */ +export class MultichainSubscriptionManager extends SafeEventEmitter { + #findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + + #getNetworkClientById: NetworkController['getNetworkClientById']; + + #subscriptions: SubscriptionEntry[] = []; + + /** + * Construct a MultichainSubscriptionManager. + * + * @param options - The controller options. + * @param options.findNetworkClientIdByChainId - The hook to get the networkClientId from a chainId. + * @param options.getNetworkClientById - The hook to get the network client instance by its networkClientId. + */ + constructor(options: MultichainSubscriptionManagerOptions) { + super(); + this.#findNetworkClientIdByChainId = options.findNetworkClientIdByChainId; + this.#getNetworkClientById = options.getNetworkClientById; + } + + notify( + { scope, origin, tabId }: SubscriptionKey, + { method, params }: SubscriptionNotificationEvent, + ) { + this.emit('notification', origin, tabId, { + method: 'wallet_notify', + params: { + scope, + notification: { method, params }, + }, + }); + } + + #getSubscriptionEntry({ + scope, + origin, + tabId, + }: SubscriptionKey): SubscriptionEntry | undefined { + return this.#subscriptions.find((subscriptionEntry) => { + return ( + subscriptionEntry.scope === scope && + subscriptionEntry.origin === origin && + subscriptionEntry.tabId === tabId + ); + }); + } + + #removeSubscriptionEntry({ scope, origin, tabId }: SubscriptionEntry) { + this.#subscriptions = this.#subscriptions.filter((subscriptionEntry) => { + return ( + subscriptionEntry.scope !== scope || + subscriptionEntry.origin !== origin || + subscriptionEntry.tabId !== tabId + ); + }); + } + + subscribe(subscriptionKey: SubscriptionKey) { + const subscriptionEntry = this.#getSubscriptionEntry(subscriptionKey); + if (subscriptionEntry) { + return subscriptionEntry.subscriptionManager; + } + + const networkClientId = this.#findNetworkClientIdByChainId( + toHex(parseCaipChainId(subscriptionKey.scope as CaipChainId).reference), + ); + const networkClient = this.#getNetworkClientById(networkClientId); + const subscriptionManager = createSubscriptionManager({ + blockTracker: networkClient.blockTracker, + provider: networkClient.provider, + }); + + subscriptionManager.events.on( + 'notification', + (message: SubscriptionNotificationEvent) => { + this.notify(subscriptionKey, message); + }, + ); + + const newSubscriptionManagerEntry = { + ...subscriptionKey, + subscriptionManager, + }; + subscriptionManager.destroy = subscriptionManager.middleware.destroy; + subscriptionManager.middleware.destroy = this.#unsubscribe.bind( + this, + newSubscriptionManagerEntry, + ); + + this.#subscriptions.push(newSubscriptionManagerEntry); + + return subscriptionManager; + } + + #unsubscribe(subscriptionEntry: SubscriptionEntry) { + subscriptionEntry.subscriptionManager.destroy?.(); + + this.#removeSubscriptionEntry(subscriptionEntry); + } + + unsubscribeByScope(scope: ExternalScopeString) { + this.#subscriptions.forEach((subscriptionEntry) => { + if (subscriptionEntry.scope === scope) { + this.#unsubscribe(subscriptionEntry); + } + }); + } + + unsubscribeByScopeAndOrigin(scope: ExternalScopeString, origin: string) { + this.#subscriptions.forEach((subscriptionEntry) => { + if ( + subscriptionEntry.scope === scope && + subscriptionEntry.origin === origin + ) { + this.#unsubscribe(subscriptionEntry); + } + }); + } + + unsubscribeByOriginAndTabId(origin: string, tabId?: number) { + this.#subscriptions.forEach((subscriptionEntry) => { + if ( + subscriptionEntry.origin === origin && + subscriptionEntry.tabId === tabId + ) { + this.#unsubscribe(subscriptionEntry); + } + }); + } +} diff --git a/packages/multichain/src/middlewares/multichainMethodCallValidator.test.ts b/packages/multichain/src/middlewares/multichainMethodCallValidator.test.ts new file mode 100644 index 00000000000..3ba8bd4b4a2 --- /dev/null +++ b/packages/multichain/src/middlewares/multichainMethodCallValidator.test.ts @@ -0,0 +1,474 @@ +import type { + JsonRpcError, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import { multichainMethodCallValidatorMiddleware } from './multichainMethodCallValidator'; + +describe('multichainMethodCallValidatorMiddleware', () => { + const mockNext = jest.fn(); + + describe('"wallet_invokeMethod" request', () => { + it('should pass validation and call next when passed a valid "wallet_invokeMethod" request', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + scope: 'test', + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + it('should throw an error when passed a "wallet_invokeMethod" request with no scope', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: undefined, + param: 'scope', + path: [], + schema: { + pattern: '[-a-z0-9]{3,8}(:[-_a-zA-Z0-9]{1,32})?', + type: 'string', + }, + }); + expect(rpcError.data[0].message).toBe( + 'scope is required, but is undefined', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + it('should throw an error for a "wallet_invokeMethod" request without a nested request object', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + scope: 'test', + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: undefined, + param: 'request', + path: [], + schema: { + properties: { + method: { + type: 'string', + }, + params: true, + }, + type: 'object', + }, + }); + expect(rpcError.data[0].message).toBe( + 'request is required, but is undefined', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + it('should throw an error for an invalidly formatted "wallet_invokeMethod" request', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + scope: 'test', + request: { + method: {}, // expected to be a string + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: { + method: {}, + params: { + test: 'test', + }, + }, + param: 'request', + path: ['method'], + schema: { + type: 'string', + }, + }); + expect(rpcError.data[0].message).toBe( + 'request.method is not of a type(s) string', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + describe('"wallet_notify" request', () => { + it('should pass validation for a "wallet_notify" request and call next', async () => { + const request: JsonRpcRequest = { + id: 2, + jsonrpc: '2.0', + method: 'wallet_notify', + params: { + scope: 'test_scope', + notification: { + method: 'test_method', + params: { + data: { + key: 'value', + }, + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + + it('should throw an error for a "wallet_notify" request with invalid params', async () => { + const request: JsonRpcRequest = { + id: 2, + jsonrpc: '2.0', + method: 'wallet_notify', + params: { + scope: 'test_scope', + request: { + data: {}, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + got: undefined, + param: 'notification', + path: [], + schema: { + properties: { + method: { + type: 'string', + }, + params: true, + }, + type: 'object', + }, + }); + expect(rpcError.data[0].message).toBe( + 'notification is required, but is undefined', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + describe('"wallet_revokeSession" request', () => { + it('should pass validation and call next when passed a valid "wallet_revokeSession" request', async () => { + const request: JsonRpcRequest = { + id: 3, + jsonrpc: '2.0', + method: 'wallet_revokeSession', + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + describe('"wallet_getSession" request', () => { + it('should pass validation and call next when passed a valid "wallet_getSession" request', async () => { + const request: JsonRpcRequest = { + id: 5, + jsonrpc: '2.0', + method: 'wallet_getSession', + params: {}, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + reject(error); + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + }); + + it('should throw an error if the top level params are not an object', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: ['test'], + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + expect(error).toBeDefined(); + expect((error as JsonRpcError).code).toBe(-32602); + expect((error as JsonRpcError).message).toBe( + 'Invalid method parameter(s).', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); + + it('should throw an error when passed an unknown method at the top level', async () => { + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: 'unknown_method', + params: { + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + const rpcError = error as JsonRpcError & { data: JsonRpcError[] }; + expect(rpcError.message).toBe('Invalid method parameter(s).'); + expect(rpcError.code).toBe(-32602); + expect(rpcError.data[0].data).toStrictEqual({ + method: 'unknown_method', + }); + expect(rpcError.data[0].message).toBe( + 'The method does not exist / is not available.', + ); + resolve(); + } catch (e) { + reject(e); + } + }, + ); + + process.nextTick(() => { + try { + expect(mockNext).not.toHaveBeenCalled(); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + }); +}); diff --git a/packages/multichain/src/middlewares/multichainMethodCallValidator.ts b/packages/multichain/src/middlewares/multichainMethodCallValidator.ts new file mode 100644 index 00000000000..77977930849 --- /dev/null +++ b/packages/multichain/src/middlewares/multichainMethodCallValidator.ts @@ -0,0 +1,108 @@ +import { MultiChainOpenRPCDocument } from '@metamask/api-specs'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { isObject } from '@metamask/utils'; +import type { JsonRpcError, JsonRpcParams } from '@metamask/utils'; +import type { + ContentDescriptorObject, + MethodObject, + OpenrpcDocument, + ReferenceObject, +} from '@open-rpc/meta-schema'; +import dereferenceDocument from '@open-rpc/schema-utils-js/build/dereference-document'; +import { makeCustomResolver } from '@open-rpc/schema-utils-js/build/parse-open-rpc-document'; +import type { Schema, ValidationError } from 'jsonschema'; +import { Validator } from 'jsonschema'; + +const transformError = ( + error: ValidationError, + param: ContentDescriptorObject, + got: unknown, +) => { + // if there is a path, add it to the message + const message = `${param.name}${ + error.path.length > 0 ? `.${error.path.join('.')}` : '' + } ${error.message}`; + + return rpcErrors.invalidParams({ + message, + data: { + param: param.name, + path: error.path, + schema: error.schema, + got, + }, + }); +}; + +const v = new Validator(); + +const dereffedPromise = dereferenceDocument( + MultiChainOpenRPCDocument as unknown as OpenrpcDocument, + makeCustomResolver({}), +); + +/** + * Helper that utilizes the Multichain method specifications from `@metamask/api-specs` + * to validate the params of a Multichain request. + * + * @param method - The request's method. + * @param params - The request's optional JsonRpcParams object. + * @returns an array of error objects for each validation error or an empty array if no errors. + */ +const multichainMethodCallValidator = async ( + method: string, + params: JsonRpcParams | undefined, +) => { + const dereffed = await dereffedPromise; + + const methodToCheck = dereffed.methods.find( + (m: MethodObject | ReferenceObject) => (m as MethodObject).name === method, + ) as MethodObject | undefined; + + if ( + !methodToCheck || + !isObject(methodToCheck) || + !('params' in methodToCheck) + ) { + return [rpcErrors.methodNotFound({ data: { method } })] as JsonRpcError[]; + } + + const errors: JsonRpcError[] = []; + for (const param of methodToCheck.params) { + if (!isObject(params)) { + return [rpcErrors.invalidParams()] as JsonRpcError[]; + } + const p = param as ContentDescriptorObject; + const paramToCheck = params[p.name]; + + const result = v.validate(paramToCheck, p.schema as unknown as Schema, { + required: p.required, + }); + if (result.errors) { + errors.push( + ...result.errors.map((e) => { + return transformError(e, p, paramToCheck) as JsonRpcError; + }), + ); + } + } + return errors; +}; + +/** + * Middleware that validates the params of a Multichain method request + * using the specifications from `@metamask/api-specs`. + */ +export const multichainMethodCallValidatorMiddleware = createAsyncMiddleware( + async (request, _response, next) => { + const errors = await multichainMethodCallValidator( + request.method, + request.params, + ); + if (errors.length > 0) { + throw rpcErrors.invalidParams({ data: errors }); + } + return await next(); + }, +); diff --git a/packages/multichain/src/scope/assert.test.ts b/packages/multichain/src/scope/assert.test.ts index 0fd23b5bf61..2b27abd6728 100644 --- a/packages/multichain/src/scope/assert.test.ts +++ b/packages/multichain/src/scope/assert.test.ts @@ -5,6 +5,7 @@ import { assertScopesSupported, assertIsExternalScopesObject, assertIsInternalScopesObject, + assertIsInternalScopeString, } from './assert'; import { Caip25Errors } from './errors'; import * as Supported from './supported'; @@ -18,6 +19,7 @@ jest.mock('./supported', () => ({ jest.mock('@metamask/utils', () => ({ ...jest.requireActual('@metamask/utils'), + isCaipChainId: jest.fn(), isCaipReference: jest.fn(), isCaipAccountId: jest.fn(), })); @@ -33,6 +35,7 @@ const validScopeObject: NormalizedScopeObject = { describe('Scope Assert', () => { beforeEach(() => { + MockUtils.isCaipChainId.mockImplementation(() => true); MockUtils.isCaipReference.mockImplementation(() => true); MockUtils.isCaipAccountId.mockImplementation(() => true); }); @@ -261,7 +264,7 @@ describe('Scope Assert', () => { }); it('throws an error if passed an object with a key that is not a valid ExternalScopeString', () => { - jest.spyOn(Utils, 'isCaipReference').mockImplementation(() => false); + MockUtils.isCaipChainId.mockReturnValue(false); expect(() => assertIsExternalScopesObject({ 'invalid-scope-string': {} }), @@ -480,6 +483,44 @@ describe('Scope Assert', () => { }); }); + describe('assertIsInternalScopeString', () => { + it('throws an error if the value is not a string', () => { + expect(() => assertIsInternalScopeString({})).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(() => assertIsInternalScopeString(123)).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(() => assertIsInternalScopeString(undefined)).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(() => assertIsInternalScopeString(null)).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + }); + + it("does not throw an error if the value is 'wallet'", () => { + expect(assertIsInternalScopeString('wallet')).toBeUndefined(); + expect(MockUtils.isCaipChainId).not.toHaveBeenCalled(); + }); + + it('does not throw an error if the value is a valid CAIP-2 Chain ID', () => { + MockUtils.isCaipChainId.mockReturnValue(true); + + expect(assertIsInternalScopeString('scopeString')).toBeUndefined(); + expect(MockUtils.isCaipChainId).toHaveBeenCalledWith('scopeString'); + }); + + it('throws an error if the value is not a valid CAIP-2 Chain ID', () => { + MockUtils.isCaipChainId.mockReturnValue(false); + + expect(() => assertIsInternalScopeString('scopeString')).toThrow( + 'scopeString is not a valid InternalScopeString', + ); + expect(MockUtils.isCaipChainId).toHaveBeenCalledWith('scopeString'); + }); + }); + describe('assertIsInternalScopesObject', () => { it('does not throw if passed obj is a valid InternalScopesObject with all valid properties', () => { const obj = { @@ -509,7 +550,7 @@ describe('Scope Assert', () => { }); it('throws an error if passed an object with a key that is not a valid InternalScopeString', () => { - jest.spyOn(Utils, 'isCaipReference').mockImplementation(() => false); + MockUtils.isCaipChainId.mockReturnValue(false); expect(() => assertIsInternalScopesObject({ 'invalid-scope-string': {} }), diff --git a/packages/multichain/src/scope/assert.ts b/packages/multichain/src/scope/assert.ts index 0d2c8c16cb7..873c577575e 100644 --- a/packages/multichain/src/scope/assert.ts +++ b/packages/multichain/src/scope/assert.ts @@ -219,7 +219,7 @@ function assertIsInternalScopeObject( * Asserts that a scope string is a valid InternalScopeString. * @param scopeString - The scope string to assert. */ -function assertIsInternalScopeString( +export function assertIsInternalScopeString( scopeString: unknown, ): asserts scopeString is InternalScopeString { if ( diff --git a/packages/multichain/src/scope/authorization.test.ts b/packages/multichain/src/scope/authorization.test.ts index 4759b40edd0..885d71b0e07 100644 --- a/packages/multichain/src/scope/authorization.test.ts +++ b/packages/multichain/src/scope/authorization.test.ts @@ -1,8 +1,14 @@ -import { validateAndNormalizeScopes } from './authorization'; +import { bucketScopes, validateAndNormalizeScopes } from './authorization'; +import * as Filter from './filter'; import * as Transform from './transform'; import type { ExternalScopeObject } from './types'; import * as Validation from './validation'; +jest.mock('./filter', () => ({ + bucketScopesBySupport: jest.fn(), +})); +const MockFilter = jest.mocked(Filter); + jest.mock('./validation', () => ({ getValidScopes: jest.fn(), })); @@ -88,4 +94,129 @@ describe('Scope Authorization', () => { }); }); }); + + describe('bucketScopes', () => { + beforeEach(() => { + let callCount = 0; + MockFilter.bucketScopesBySupport.mockImplementation(() => { + callCount += 1; + return { + supportedScopes: { + 'mock:A': { + methods: [`mock_method_${callCount}`], + notifications: [], + accounts: [], + }, + }, + unsupportedScopes: { + 'mock:B': { + methods: [`mock_method_${callCount}`], + notifications: [], + accounts: [], + }, + }, + }; + }); + }); + + it('buckets the scopes by supported', () => { + const isChainIdSupported = jest.fn(); + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isChainIdSupported, + isChainIdSupportable: jest.fn(), + }, + ); + + expect(MockFilter.bucketScopesBySupport).toHaveBeenCalledWith( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isChainIdSupported, + }, + ); + }); + + it('buckets the maybe supportable scopes', () => { + const isChainIdSupportable = jest.fn(); + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isChainIdSupported: jest.fn(), + isChainIdSupportable, + }, + ); + + expect(MockFilter.bucketScopesBySupport).toHaveBeenCalledWith( + { + 'mock:B': { + methods: [`mock_method_1`], + notifications: [], + accounts: [], + }, + }, + { + isChainIdSupported: isChainIdSupportable, + }, + ); + }); + + it('returns the bucketed scopes', () => { + expect( + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + }, + { + isChainIdSupported: jest.fn(), + isChainIdSupportable: jest.fn(), + }, + ), + ).toStrictEqual({ + supportedScopes: { + 'mock:A': { + methods: [`mock_method_1`], + notifications: [], + accounts: [], + }, + }, + supportableScopes: { + 'mock:A': { + methods: [`mock_method_2`], + notifications: [], + accounts: [], + }, + }, + unsupportableScopes: { + 'mock:B': { + methods: [`mock_method_2`], + notifications: [], + accounts: [], + }, + }, + }); + }); + }); }); diff --git a/packages/multichain/src/scope/authorization.ts b/packages/multichain/src/scope/authorization.ts index 0f43fa33e40..97d796d8b6d 100644 --- a/packages/multichain/src/scope/authorization.ts +++ b/packages/multichain/src/scope/authorization.ts @@ -1,5 +1,6 @@ -import type { Json } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; +import { bucketScopesBySupport } from './filter'; import { normalizeAndMergeScopes } from './transform'; import type { ExternalScopesObject, @@ -51,3 +52,42 @@ export const validateAndNormalizeScopes = ( normalizedOptionalScopes, }; }; + +/** + * Groups a NormalizedScopesObject into three separate + * NormalizedScopesObjects for supported scopes, + * supportable scopes, and unsupportable scopes. + * @param scopes - The NormalizedScopesObject to group. + * @param hooks - The hooks. + * @param hooks.isChainIdSupported - A helper that returns true if an eth chainId is currently supported by the wallet. + * @param hooks.isChainIdSupportable - A helper that returns true if an eth chainId could be supported by the wallet. + * @returns an object with three NormalizedScopesObjects separated by support. + */ +export const bucketScopes = ( + scopes: NormalizedScopesObject, + { + isChainIdSupported, + isChainIdSupportable, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + isChainIdSupportable: (chainId: Hex) => boolean; + }, +): { + supportedScopes: NormalizedScopesObject; + supportableScopes: NormalizedScopesObject; + unsupportableScopes: NormalizedScopesObject; +} => { + const { supportedScopes, unsupportedScopes: maybeSupportableScopes } = + bucketScopesBySupport(scopes, { + isChainIdSupported, + }); + + const { + supportedScopes: supportableScopes, + unsupportedScopes: unsupportableScopes, + } = bucketScopesBySupport(maybeSupportableScopes, { + isChainIdSupported: isChainIdSupportable, + }); + + return { supportedScopes, supportableScopes, unsupportableScopes }; +}; diff --git a/packages/multichain/src/scope/constants.test.ts b/packages/multichain/src/scope/constants.test.ts index fa0da9ba41e..a01691f2bf5 100644 --- a/packages/multichain/src/scope/constants.test.ts +++ b/packages/multichain/src/scope/constants.test.ts @@ -6,15 +6,9 @@ describe('KnownRpcMethods', () => { Object { "bip122": Array [], "eip155": Array [ - "wallet_switchEthereumChain", - "wallet_getPermissions", - "wallet_requestPermissions", - "wallet_revokePermissions", "personal_sign", "eth_signTypedData_v4", "wallet_watchAsset", - "eth_requestAccounts", - "eth_accounts", "eth_sendTransaction", "eth_decrypt", "eth_getEncryptionPublicKey", @@ -24,7 +18,6 @@ describe('KnownRpcMethods', () => { "eth_blockNumber", "eth_call", "eth_chainId", - "eth_coinbase", "eth_estimateGas", "eth_feeHistory", "eth_gasPrice", diff --git a/packages/multichain/src/scope/constants.ts b/packages/multichain/src/scope/constants.ts index 4273b008d0a..8ad272a7a65 100644 --- a/packages/multichain/src/scope/constants.ts +++ b/packages/multichain/src/scope/constants.ts @@ -27,15 +27,37 @@ export const KnownWalletRpcMethods: string[] = [ 'wallet_scanQRCode', ]; +/** + * Methods that belong to the `wallet:eip155` scope. + */ const WalletEip155Methods = ['wallet_addEthereumChain']; +/** + * Methods that are only supported via the EIP-1193 API. + */ +export const Eip1193OnlyMethods = [ + 'wallet_switchEthereumChain', + 'wallet_getPermissions', + 'wallet_requestPermissions', + 'wallet_revokePermissions', + 'eth_requestAccounts', + 'eth_accounts', + 'eth_coinbase', + 'net_version', + 'metamask_logWeb3ShimUsage', + 'metamask_getProviderState', + 'metamask_sendDomainMetadata', + 'wallet_registerOnboarding', +]; + /** * All MetaMask methods, except for ones we have specified in the constants above. */ const Eip155Methods = MetaMaskOpenRPCDocument.methods .map(({ name }: { name: string }) => name) .filter((method: string) => !WalletEip155Methods.includes(method)) - .filter((method: string) => !KnownWalletRpcMethods.includes(method)); + .filter((method: string) => !KnownWalletRpcMethods.includes(method)) + .filter((method: string) => !Eip1193OnlyMethods.includes(method)); /** * Methods by ecosystem that are chain specific. diff --git a/packages/multichain/src/scope/filter.test.ts b/packages/multichain/src/scope/filter.test.ts new file mode 100644 index 00000000000..8be87ec7983 --- /dev/null +++ b/packages/multichain/src/scope/filter.test.ts @@ -0,0 +1,279 @@ +import * as Assert from './assert'; +import { + bucketScopesBySupport, + getSupportedScopeObjects, +} from './filter'; +import * as Supported from './supported'; + +jest.mock('./assert', () => ({ + ...jest.requireActual('./assert'), + assertScopeSupported: jest.fn(), +})); +const MockAssert = jest.mocked(Assert); + +jest.mock('./supported', () => ({ + ...jest.requireActual('./supported'), + isSupportedMethod: jest.fn(), + isSupportedNotification: jest.fn(), +})); +const MockSupported = jest.mocked(Supported); + +describe('filter', () => { + describe('bucketScopesBySupport', () => { + const isChainIdSupported = jest.fn(); + + it('checks if each scope is supported', () => { + bucketScopesBySupport( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }, + { isChainIdSupported }, + ); + + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:1', + { + methods: ['a'], + notifications: [], + accounts: [], + }, + { isChainIdSupported }, + ); + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:5', + { + methods: ['b'], + notifications: [], + accounts: [], + }, + { isChainIdSupported }, + ); + }); + + it('returns supported and unsupported scopes', () => { + MockAssert.assertScopeSupported.mockImplementation((scopeString) => { + if (scopeString === 'eip155:1') { + throw new Error('scope not supported'); + } + }); + + expect( + bucketScopesBySupport( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }, + { isChainIdSupported }, + ), + ).toStrictEqual({ + supportedScopes: { + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }, + unsupportedScopes: { + 'eip155:1': { + methods: ['a'], + notifications: [], + accounts: [], + }, + }, + }); + }); + }); + + describe('getSupportedScopeObjects', () => { + it('checks if each scopeObject method is supported', () => { + getSupportedScopeObjects({ + 'eip155:1': { + methods: ['method1', 'method2'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['methodA', 'methodB'], + notifications: [], + accounts: [], + }, + }); + + expect(MockSupported.isSupportedMethod).toHaveBeenCalledTimes(4); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:1', + 'method1', + ); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:1', + 'method2', + ); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:5', + 'methodA', + ); + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'eip155:5', + 'methodB', + ); + }); + + it('returns only supported methods', () => { + MockSupported.isSupportedMethod.mockImplementation( + (scopeString, method) => { + if (scopeString === 'eip155:1' && method === 'method1') { + return false; + } + if (scopeString === 'eip155:5' && method === 'methodB') { + return false; + } + return true; + }, + ); + + const result = getSupportedScopeObjects({ + 'eip155:1': { + methods: ['method1', 'method2'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['methodA', 'methodB'], + notifications: [], + accounts: [], + }, + }); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: ['method2'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['methodA'], + notifications: [], + accounts: [], + }, + }); + }); + + it('checks if each scopeObject notification is supported', () => { + getSupportedScopeObjects({ + 'eip155:1': { + methods: [], + notifications: ['notification1', 'notification2'], + accounts: [], + }, + 'eip155:5': { + methods: [], + notifications: ['notificationA', 'notificationB'], + accounts: [], + }, + }); + + expect(MockSupported.isSupportedNotification).toHaveBeenCalledTimes(4); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:1', + 'notification1', + ); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:1', + 'notification2', + ); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:5', + 'notificationA', + ); + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'eip155:5', + 'notificationB', + ); + }); + + it('returns only supported notifications', () => { + MockSupported.isSupportedNotification.mockImplementation( + (scopeString, notification) => { + if (scopeString === 'eip155:1' && notification === 'notification1') { + return false; + } + if (scopeString === 'eip155:5' && notification === 'notificationB') { + return false; + } + return true; + }, + ); + + const result = getSupportedScopeObjects({ + 'eip155:1': { + methods: [], + notifications: ['notification1', 'notification2'], + accounts: [], + }, + 'eip155:5': { + methods: [], + notifications: ['notificationA', 'notificationB'], + accounts: [], + }, + }); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: [], + notifications: ['notification2'], + accounts: [], + }, + 'eip155:5': { + methods: [], + notifications: ['notificationA'], + accounts: [], + }, + }); + }); + + it('does not modify accounts', () => { + const result = getSupportedScopeObjects({ + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xdeadbeef'], + }, + }); + + expect(result).toStrictEqual({ + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xdeadbeef'], + }, + }); + }); + }); +}); diff --git a/packages/multichain/src/scope/filter.ts b/packages/multichain/src/scope/filter.ts new file mode 100644 index 00000000000..0cd9a886620 --- /dev/null +++ b/packages/multichain/src/scope/filter.ts @@ -0,0 +1,92 @@ +import { type Hex } from '@metamask/utils'; + +import { assertIsInternalScopeString, assertScopeSupported } from './assert'; +import { isSupportedMethod, isSupportedNotification } from './supported'; +import type { + InternalScopeString, + NormalizedScopeObject, + NormalizedScopesObject, +} from './types'; + +/** + * Groups a NormalizedScopesObject into two separate + * NormalizedScopesObject with supported scopes in one + * and unsupported scopes in the other. + * @param scopes - The NormalizedScopesObject to group. + * @param hooks - The hooks. + * @param hooks.isChainIdSupported - A helper that returns true if an eth chainId is currently supported by the wallet. + * @returns an object with two NormalizedScopesObjects separated by support. + */ +export const bucketScopesBySupport = ( + scopes: NormalizedScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const supportedScopes: NormalizedScopesObject = {}; + const unsupportedScopes: NormalizedScopesObject = {}; + + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertIsInternalScopeString(scopeString); + try { + assertScopeSupported(scopeString, scopeObject, { + isChainIdSupported, + }); + supportedScopes[scopeString] = scopeObject; + } catch (err) { + unsupportedScopes[scopeString] = scopeObject; + } + } + + return { supportedScopes, unsupportedScopes }; +}; + +/** + * Returns a NormalizedScopeObject with + * unsupported methods and notifications removed. + * @param scopeString - The InternalScopeString for the scopeObject. + * @param scopeObject - The NormalizedScopeObject to filter. + * @returns a NormalizedScopeObject with only methods and notifications that are currently supported. + */ +const getSupportedScopeObject = ( + scopeString: InternalScopeString, + scopeObject: NormalizedScopeObject, +) => { + const { methods, notifications } = scopeObject; + + const supportedMethods = methods.filter((method) => + isSupportedMethod(scopeString, method), + ); + + const supportedNotifications = notifications.filter((notification) => + isSupportedNotification(scopeString, notification), + ); + + return { + ...scopeObject, + methods: supportedMethods, + notifications: supportedNotifications, + }; +}; + +/** + * Returns a NormalizedScopesObject with + * unsupported methods and notifications removed from scopeObjects. + * @param scopes - The NormalizedScopesObject to filter. + * @returns a NormalizedScopesObject with only methods, and notifications that are currently supported. + */ +export const getSupportedScopeObjects = (scopes: NormalizedScopesObject) => { + const filteredScopesObject: NormalizedScopesObject = {}; + + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertIsInternalScopeString(scopeString); + filteredScopesObject[scopeString] = getSupportedScopeObject( + scopeString, + scopeObject, + ); + } + + return filteredScopesObject; +}; diff --git a/packages/multichain/src/scope/types.ts b/packages/multichain/src/scope/types.ts index f639c58d2ea..b13b5edae75 100644 --- a/packages/multichain/src/scope/types.ts +++ b/packages/multichain/src/scope/types.ts @@ -60,7 +60,8 @@ export type InternalScopesObject = Record & { * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that * we resolve the `references` property into a scopeObject per reference and * assign an empty array to the `accounts` property if not already defined - * to more easily read chain specific permissions. + * to more easily perform support checks for `wallet_createSession` requests. + * Also used as the return type for `wallet_createSession` and `wallet_sessionChanged`. */ export type NormalizedScopeObject = { methods: string[]; @@ -74,7 +75,8 @@ export type NormalizedScopeObject = { * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that * we resolve the `references` property into a scopeObject per reference and * assign an empty array to the `accounts` property if not already defined - * to more easily read chain specific permissions. + * to more easily perform support checks for `wallet_createSession` requests. + * Also used as the return type for `wallet_createSession` and `wallet_sessionChanged`. */ export type NormalizedScopesObject = Record< CaipChainId, diff --git a/types/@metamask/eth-json-rpc-filters.d.ts b/types/@metamask/eth-json-rpc-filters.d.ts new file mode 100644 index 00000000000..5a51785b82b --- /dev/null +++ b/types/@metamask/eth-json-rpc-filters.d.ts @@ -0,0 +1 @@ +declare module '@metamask/eth-json-rpc-filters/subscriptionManager'; diff --git a/yarn.lock b/yarn.lock index bd9b7f73b17..c48d3f85a7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2142,6 +2142,48 @@ __metadata: languageName: node linkType: hard +"@json-schema-spec/json-pointer@npm:^0.1.2": + version: 0.1.2 + resolution: "@json-schema-spec/json-pointer@npm:0.1.2" + checksum: 10/2a691ffc11f1a266ca4d0c9e2c99791679d580f343ef69746fad623d1abcf4953adde987890e41f906767d7729604c0182341e9012388b73a44d5b21fb296453 + languageName: node + linkType: hard + +"@json-schema-tools/dereferencer@npm:^1.6.3": + version: 1.6.3 + resolution: "@json-schema-tools/dereferencer@npm:1.6.3" + dependencies: + "@json-schema-tools/reference-resolver": "npm:^1.2.6" + "@json-schema-tools/traverse": "npm:^1.10.4" + fast-safe-stringify: "npm:^2.1.1" + checksum: 10/da6ef5b82a8a9c3a7e62ffcab5c04c581f1e0f8165c0debdb272bb1e08ccd726107ee194487b8fa736cac00fb390b8df74bc1ad1b200eddbe25c98ee0d3d000b + languageName: node + linkType: hard + +"@json-schema-tools/meta-schema@npm:^1.7.5": + version: 1.7.5 + resolution: "@json-schema-tools/meta-schema@npm:1.7.5" + checksum: 10/707dc3a285c26c37d00f418e9d0ef8a2ad1c23d4936ad5aab0ce94c9ae36a7a6125c4ca5048513af64b7e6e527b5472a1701d1f709c379acdd7ad12f6409d2cd + languageName: node + linkType: hard + +"@json-schema-tools/reference-resolver@npm:^1.2.6": + version: 1.2.6 + resolution: "@json-schema-tools/reference-resolver@npm:1.2.6" + dependencies: + "@json-schema-spec/json-pointer": "npm:^0.1.2" + isomorphic-fetch: "npm:^3.0.0" + checksum: 10/91d6b4b2ac43f8163fd27bde6d826f29f339e9c7ce3b7e2b73b85e891fa78e3702fd487deda143a0701879cbc2fe28c53a4efce4cd2d2dd2fe6e82b64bbd9c9c + languageName: node + linkType: hard + +"@json-schema-tools/traverse@npm:^1.10.4": + version: 1.10.4 + resolution: "@json-schema-tools/traverse@npm:1.10.4" + checksum: 10/0027bc90df01c5eeee0833e722b7320b53be8b5ce3f4e0e4a6e45713a38e6f88f21aba31e3dd973093ef75cd21a40c07fe8f112da8f49a7919b1c0e44c904d20 + languageName: node + linkType: hard + "@keystonehq/alias-sampling@npm:^0.1.1": version: 0.1.2 resolution: "@keystonehq/alias-sampling@npm:0.1.2" @@ -3349,13 +3391,18 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/controller-utils": "npm:^11.4.5" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" + "@metamask/json-rpc-engine": "npm:^10.0.2" "@metamask/network-controller": "npm:^22.1.1" "@metamask/permission-controller": "npm:^11.0.5" "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.0.1" + "@open-rpc/meta-schema": "npm:^1.14.6" + "@open-rpc/schema-utils-js": "npm:^2.0.5" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" + jsonschema: "npm:^1.4.1" lodash: "npm:^4.17.21" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" @@ -4347,6 +4394,31 @@ __metadata: languageName: node linkType: hard +"@open-rpc/meta-schema@npm:^1.14.6, @open-rpc/meta-schema@npm:^1.14.9": + version: 1.14.9 + resolution: "@open-rpc/meta-schema@npm:1.14.9" + checksum: 10/51505dcf7aa1a2285c78953c9b33711cede5f2765aa37dcb9ee7756d689e2ff2a89cfc6039504f0569c52a805fb9aa18f30a7c02ad7a06e793c801e43b419104 + languageName: node + linkType: hard + +"@open-rpc/schema-utils-js@npm:^2.0.5": + version: 2.0.5 + resolution: "@open-rpc/schema-utils-js@npm:2.0.5" + dependencies: + "@json-schema-tools/dereferencer": "npm:^1.6.3" + "@json-schema-tools/meta-schema": "npm:^1.7.5" + "@json-schema-tools/reference-resolver": "npm:^1.2.6" + "@open-rpc/meta-schema": "npm:^1.14.9" + ajv: "npm:^6.10.0" + detect-node: "npm:^2.0.4" + fast-safe-stringify: "npm:^2.0.7" + fs-extra: "npm:^10.1.0" + is-url: "npm:^1.2.4" + isomorphic-fetch: "npm:^3.0.0" + checksum: 10/9e10215606e9a00a47b082c9cfd70d05bf0d38de6cf1c147246c545c6997375d94cd3caafe919b71178df58b5facadfd0dcc8b6857bf5e79c40e5e33683dd3d5 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -5903,7 +5975,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.4": +"ajv@npm:^6.10.0, ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" dependencies: @@ -7281,6 +7353,13 @@ __metadata: languageName: node linkType: hard +"detect-node@npm:^2.0.4": + version: 2.1.0 + resolution: "detect-node@npm:2.1.0" + checksum: 10/832184ec458353e41533ac9c622f16c19f7c02d8b10c303dfd3a756f56be93e903616c0bb2d4226183c9351c15fc0b3dba41a17a2308262afabcfa3776e6ae6e + languageName: node + linkType: hard + "diff-sequences@npm:^27.5.1": version: 27.5.1 resolution: "diff-sequences@npm:27.5.1" @@ -8154,7 +8233,7 @@ __metadata: languageName: node linkType: hard -"fast-safe-stringify@npm:^2.0.6": +"fast-safe-stringify@npm:^2.0.6, fast-safe-stringify@npm:^2.0.7, fast-safe-stringify@npm:^2.1.1": version: 2.1.1 resolution: "fast-safe-stringify@npm:2.1.1" checksum: 10/dc1f063c2c6ac9533aee14d406441f86783a8984b2ca09b19c2fe281f9ff59d315298bc7bc22fd1f83d26fe19ef2f20e2ddb68e96b15040292e555c5ced0c1e4 @@ -8380,6 +8459,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^10.1.0": + version: 10.1.0 + resolution: "fs-extra@npm:10.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10/05ce2c3b59049bcb7b52001acd000e44b3c4af4ec1f8839f383ef41ec0048e3cfa7fd8a637b1bddfefad319145db89be91f4b7c1db2908205d38bf91e7d1d3b7 + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -8685,7 +8775,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2 @@ -9276,6 +9366,13 @@ __metadata: languageName: node linkType: hard +"is-url@npm:^1.2.4": + version: 1.2.4 + resolution: "is-url@npm:1.2.4" + checksum: 10/100e74b3b1feab87a43ef7653736e88d997eb7bd32e71fd3ebc413e58c1cbe56269699c776aaea84244b0567f2a7d68dfaa512a062293ed2f9fdecb394148432 + languageName: node + linkType: hard + "is-windows@npm:^1.0.1, is-windows@npm:^1.0.2": version: 1.0.2 resolution: "is-windows@npm:1.0.2" @@ -10220,6 +10317,19 @@ __metadata: languageName: node linkType: hard +"jsonfile@npm:^6.0.1": + version: 6.1.0 + resolution: "jsonfile@npm:6.1.0" + dependencies: + graceful-fs: "npm:^4.1.6" + universalify: "npm:^2.0.0" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10/03014769e7dc77d4cf05fa0b534907270b60890085dd5e4d60a382ff09328580651da0b8b4cdf44d91e4c8ae64d91791d965f05707beff000ed494a38b6fec85 + languageName: node + linkType: hard + "jsonschema@npm:^1.4.1": version: 1.4.1 resolution: "jsonschema@npm:1.4.1" @@ -13051,6 +13161,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^2.0.0": + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: 10/ecd8469fe0db28e7de9e5289d32bd1b6ba8f7183db34f3bfc4ca53c49891c2d6aa05f3fb3936a81285a905cc509fb641a0c3fc131ec786167eff41236ae32e60 + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.1.1": version: 1.1.2 resolution: "update-browserslist-db@npm:1.1.2"