diff --git a/packages/multichain/package.json b/packages/multichain/package.json index 09a80e96bd..03274b7a34 100644 --- a/packages/multichain/package.json +++ b/packages/multichain/package.json @@ -51,13 +51,18 @@ "@metamask/controller-utils": "^11.4.3", "@metamask/eth-json-rpc-filters": "^7.0.0", "@metamask/rpc-errors": "^7.0.1", + "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^10.0.0", + "@open-rpc/schema-utils-js": "^2.0.5", + "jsonschema": "^1.2.4", "lodash": "^4.17.21" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/json-rpc-engine": "^10.0.1", "@metamask/network-controller": "^22.0.2", "@metamask/permission-controller": "^11.0.3", + "@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-middleware.test.ts b/packages/multichain/src/adapters/caip-permission-adapter-middleware.test.ts new file mode 100644 index 0000000000..c044c73b0f --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-middleware.test.ts @@ -0,0 +1,148 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +import { caipPermissionAdapterMiddleware } from './caip-permission-adapter-middleware'; + +const baseRequest = { + id: 1, + jsonrpc: '2.0' as const, + origin: 'http://test.com', + networkClientId: 'mainnet', + method: 'eth_call', + params: { + foo: 'bar', + }, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveat = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['net_version'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + unhandled: { + methods: ['foobar'], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + }); + const getNetworkConfigurationByNetworkClientId = jest + .fn() + .mockImplementation((networkClientId: string) => { + const chainId = + { + mainnet: '0x1', + goerli: '0x5', + }[networkClientId] || '0x999'; + return { + chainId, + }; + }); + const handler = ( + request: JsonRpcRequest & { + networkClientId: string; + origin: string; + }, + ) => + caipPermissionAdapterMiddleware(request, {}, next, end, { + getCaveat, + getNetworkConfigurationByNetworkClientId, + }); + + return { + next, + end, + getCaveat, + getNetworkConfigurationByNetworkClientId, + handler, + }; +}; + +describe('CaipPermissionAdapterMiddleware', () => { + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const { handler, getCaveat } = createMockedHandler(); + await handler(baseRequest); + expect(getCaveat).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('allows the request when there is no CAIP-25 endowment permission', async () => { + const { handler, getCaveat, next } = createMockedHandler(); + getCaveat.mockImplementation(() => { + throw new Error('permission not found'); + }); + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + + it('allows the request when the CAIP-25 endowment permission was not granted from the multichain API', async () => { + const { handler, getCaveat, next } = createMockedHandler(); + getCaveat.mockReturnValue({ + value: { + isMultichainOrigin: false, + }, + }); + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + + it('gets the chainId for the request networkClientId', async () => { + const { handler, getNetworkConfigurationByNetworkClientId } = + createMockedHandler(); + await handler(baseRequest); + expect(getNetworkConfigurationByNetworkClientId).toHaveBeenCalledWith( + 'mainnet', + ); + }); + + describe('when the CAIP-25 endowment permission was granted over the multichain API', () => { + it('throws an error if the requested method is not authorized for the scope specified in the request', async () => { + const { handler, end } = createMockedHandler(); + + await handler({ + ...baseRequest, + method: 'unauthorized_method', + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('allows the request if the requested scope method is authorized in the current scope', async () => { + const { handler, next } = createMockedHandler(); + + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/multichain/src/adapters/caip-permission-adapter-middleware.ts b/packages/multichain/src/adapters/caip-permission-adapter-middleware.ts new file mode 100644 index 0000000000..89b47e8f81 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-middleware.ts @@ -0,0 +1,79 @@ +import type { + NetworkConfiguration, + NetworkClientId, +} from '@metamask/network-controller'; +import type { Caveat } from '@metamask/permission-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +import { Eip1193OnlyMethods, KnownWalletScopeString } from '../scope/constants'; +import type { InternalScopeString } from '../scope/types'; +import { getSessionScopes } from './caip-permission-adapter-session-scopes'; + +/** + * Middleware to handle CAIP-25 permission requests. + * + * @param request - The request object. + * @param _response - The response object. + * @param next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveat - Function to retrieve a caveat. + * @param hooks.getNetworkConfigurationByNetworkClientId - Function to retrieve a network configuration. + */ +export async function caipPermissionAdapterMiddleware( + request: JsonRpcRequest & { + networkClientId: NetworkClientId; + origin: string; + }, + _response: unknown, + next: () => Promise, + end: (error?: Error) => void, + hooks: { + getCaveat: ( + ...args: unknown[] + ) => Caveat; + getNetworkConfigurationByNetworkClientId: ( + networkClientId: NetworkClientId, + ) => NetworkConfiguration; + }, +) { + const { networkClientId, method } = request; + + let caveat; + try { + caveat = hooks.getCaveat( + request.origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (err) { + // noop + } + if (!caveat?.value?.isMultichainOrigin) { + return next(); + } + + const { chainId } = + hooks.getNetworkConfigurationByNetworkClientId(networkClientId); + + const scope: InternalScopeString = `eip155:${parseInt(chainId, 16)}`; + + const sesionScopes = getSessionScopes(caveat.value); + + if ( + !sesionScopes[scope]?.methods?.includes(method) && + !sesionScopes[KnownWalletScopeString.Eip155]?.methods?.includes(method) && + !sesionScopes.wallet?.methods?.includes(method) && + !Eip1193OnlyMethods.includes(method) + ) { + return end(providerErrors.unauthorized()); + } + + return next(); +} 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 0000000000..8dd377f0ed --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-session-scopes.test.ts @@ -0,0 +1,116 @@ +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 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 0000000000..7e05eb01ad --- /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 0000000000..2583394203 --- /dev/null +++ b/packages/multichain/src/handlers/wallet-getSession.test.ts @@ -0,0 +1,165 @@ +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 getCaveat = 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, { + getCaveat, + }); + + return { + next, + response, + end, + getCaveat, + handler, + }; +}; + +describe('wallet_getSession', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const { handler, getCaveat } = createMockedHandler(); + + await handler(baseRequest); + expect(getCaveat).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('returns empty scopes if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, getCaveat } = createMockedHandler(); + getCaveat.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 0000000000..6a8f4bf2fb --- /dev/null +++ b/packages/multichain/src/handlers/wallet-getSession.ts @@ -0,0 +1,63 @@ +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. + * + * @param request - The request object. + * @param response - The response object. + * @param _next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveat - Function to retrieve a caveat. + */ +async function walletGetSessionHandler( + request: JsonRpcRequest & { origin: string }, + response: JsonRpcSuccess<{ sessionScopes: NormalizedScopesObject }>, + _next: () => void, + end: () => void, + hooks: { + getCaveat: ( + origin: string, + endowmentPermissionName: string, + caveatType: string, + ) => Caveat; + }, +) { + let caveat; + try { + caveat = hooks.getCaveat( + request.origin, + 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: { + getCaveat: 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 0000000000..a608e2866f --- /dev/null +++ b/packages/multichain/src/handlers/wallet-invokeMethod.test.ts @@ -0,0 +1,333 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import * as PermissionAdapterSessionScopes from '../adapters/caip-permission-adapter-session-scopes'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +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 getCaveat = 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: JsonRpcRequest & { origin: string }) => + walletInvokeMethod.implementation( + request, + { jsonrpc: '2.0', id: 1 }, + next, + end, + { + getCaveat, + findNetworkClientIdByChainId, + getSelectedNetworkClientId, + }, + ); + + return { + next, + end, + getCaveat, + 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: [], + }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const request = createMockedRequest(); + const { handler, getCaveat } = createMockedHandler(); + await handler(request); + expect(getCaveat).toHaveBeenCalledWith( + 'http://test.com', + 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, getCaveat, end } = createMockedHandler(); + getCaveat.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, getCaveat, end } = createMockedHandler(); + getCaveat.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 0000000000..4e9f6002bd --- /dev/null +++ b/packages/multichain/src/handlers/wallet-invokeMethod.ts @@ -0,0 +1,120 @@ +import type { Caveat } from '@metamask/permission-controller'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import type { + 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'; + +/** + * Handler for the `wallet_invokeMethod` RPC method. + * + * @param request - The request object. + * @param _response - The response object. + * @param next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveat - 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: JsonRpcRequest & { origin: string }, + _response: PendingJsonRpcResponse, + next: () => void, + end: (error: Error) => void, + hooks: { + getCaveat: ( + origin: string, + endowmentPermissionName: string, + caveatType: string, + ) => Caveat; + findNetworkClientIdByChainId: (chainId: string) => string | undefined; + getSelectedNetworkClientId: () => string; + }, +) { + const { scope, request: wrappedRequest } = request.params as { + scope: ExternalScopeString; + request: JsonRpcRequest; + }; + + assertIsInternalScopeString(scope); + + let caveat; + try { + caveat = hooks.getCaveat( + request.origin, + 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: { + getCaveat: 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 0000000000..e11b89f42c --- /dev/null +++ b/packages/multichain/src/handlers/wallet-revokeSession.test.ts @@ -0,0 +1,92 @@ +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 revokePermission = 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, { + revokePermission, + }); + + return { + next, + response, + end, + revokePermission, + handler, + }; +}; + +describe('wallet_revokeSession', () => { + it('revokes the the CAIP-25 endowment permission', async () => { + const { handler, revokePermission } = createMockedHandler(); + + await handler(baseRequest); + expect(revokePermission).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + ); + }); + + it('returns true if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, revokePermission } = createMockedHandler(); + revokePermission.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, revokePermission } = createMockedHandler(); + revokePermission.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, revokePermission, end } = createMockedHandler(); + revokePermission.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 0000000000..51e31073f0 --- /dev/null +++ b/packages/multichain/src/handlers/wallet-revokeSession.ts @@ -0,0 +1,54 @@ +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'; + +/** + * Handles the `wallet_revokeSession` RPC method. + * + * @param request - The JSON-RPC request object. + * @param response - The JSON-RPC response object. + * @param _next - The next middleware function. + * @param end - The end callback function. + * @param hooks - The hooks object. + * @param hooks.revokePermission - The revokePermission function. + */ +async function walletRevokeSessionHandler( + request: JsonRpcRequest & { origin: string }, + response: JsonRpcSuccess, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: { + revokePermission: (origin: string, permissionName: string) => void; + }, +) { + try { + hooks.revokePermission(request.origin, 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: { + revokePermission: true, + }, +}; diff --git a/packages/multichain/src/index.test.ts b/packages/multichain/src/index.test.ts index 61f0fdcc42..d7b6fed382 100644 --- a/packages/multichain/src/index.test.ts +++ b/packages/multichain/src/index.test.ts @@ -9,17 +9,37 @@ describe('@metamask/multichain', () => { "getPermittedEthChainIds", "addPermittedEthChainId", "setPermittedEthChainIds", + "getInternalScopesObject", + "getSessionScopes", + "caipPermissionAdapterMiddleware", + "walletGetSession", + "walletInvokeMethod", + "walletRevokeSession", + "multichainMethodCallValidatorMiddleware", + "MultichainMiddlewareManager", + "MultichainSubscriptionManager", + "assertScopeSupported", + "assertScopesSupported", "validateAndNormalizeScopes", + "bucketScopes", + "bucketScopesBySupport", + "filterScopesSupported", "KnownWalletRpcMethods", "KnownRpcMethods", "KnownWalletNamespaceRpcMethods", "KnownNotifications", "KnownWalletScopeString", "parseScopeString", + "isSupportedScopeString", + "isSupportedAccount", + "isSupportedMethod", + "isSupportedNotification", "normalizeScope", "mergeScopeObject", "mergeScopes", "normalizeAndMergeScopes", + "isValidScope", + "getValidScopes", "Caip25CaveatType", "createCaip25Caveat", "Caip25EndowmentPermissionName", diff --git a/packages/multichain/src/index.ts b/packages/multichain/src/index.ts index d322c2b74d..f227f2817a 100644 --- a/packages/multichain/src/index.ts +++ b/packages/multichain/src/index.ts @@ -7,9 +7,27 @@ export { addPermittedEthChainId, setPermittedEthChainIds, } from './adapters/caip-permission-adapter-permittedChains'; +export { + getInternalScopesObject, + getSessionScopes, +} from './adapters/caip-permission-adapter-session-scopes'; +export { caipPermissionAdapterMiddleware } from './adapters/caip-permission-adapter-middleware'; + +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 { assertScopeSupported, assertScopesSupported } from './scope/assert'; export type { Caip25Authorization } from './scope/authorization'; -export { validateAndNormalizeScopes } from './scope/authorization'; +export { + validateAndNormalizeScopes, + bucketScopes, +} from './scope/authorization'; +export { bucketScopesBySupport, filterScopesSupported } from './scope/filter'; export { KnownWalletRpcMethods, KnownRpcMethods, @@ -30,12 +48,21 @@ export type { NonWalletKnownCaipNamespace, } from './scope/types'; export { parseScopeString } from './scope/types'; +// Do these need to be exported? +export { + isSupportedScopeString, + isSupportedAccount, + isSupportedMethod, + isSupportedNotification, +} from './scope/supported'; export { normalizeScope, mergeScopeObject, mergeScopes, normalizeAndMergeScopes, } from './scope/transform'; +// does this need to be exported? +export { isValidScope, getValidScopes } from './scope/validation'; export type { Caip25CaveatValue } from './caip25Permission'; export { diff --git a/packages/multichain/src/middlewares/MultichainMiddlewareManager.test.ts b/packages/multichain/src/middlewares/MultichainMiddlewareManager.test.ts new file mode 100644 index 0000000000..4a358896fc --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainMiddlewareManager.test.ts @@ -0,0 +1,171 @@ +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', () => { + 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).toHaveBeenCalledWith( + { jsonrpc: '2.0' as const, id: 0, method: 'method', scope }, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed', 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 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 0000000000..32109a8c56 --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainMiddlewareManager.ts @@ -0,0 +1,132 @@ +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +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; +}; + +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 + // eslint-disable-next-line no-void + void middlewareEntry.middleware.destroy?.(); + + 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 (middlewareEntry) { + middlewareEntry.middleware(req, res, next, end); + } 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 0000000000..f08182d026 --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainSubscriptionManager.test.ts @@ -0,0 +1,160 @@ +import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; + +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 }; +}; + +describe('MultichainSubscriptionManager', () => { + const mockSubscriptionManager = { + events: { + on: jest.fn(), + }, + destroy: jest.fn(), + }; + + beforeEach(() => { + 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 onNotificationSpy = jest.fn(); + multichainSubscriptionManager.on('notification', onNotificationSpy); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + expect(onNotificationSpy).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); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + 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, + ); + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + 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); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + 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 0000000000..9a2259e2d3 --- /dev/null +++ b/packages/multichain/src/middlewares/MultichainSubscriptionManager.ts @@ -0,0 +1,154 @@ +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 EventEmitter from 'events'; + +import type { ExternalScopeString } from '../scope/types'; + +export type SubscriptionManager = { + events: EventEmitter; + destroy?: () => void; +}; + +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']; +}; + +export class MultichainSubscriptionManager extends SafeEventEmitter { + #findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + + #getNetworkClientById: NetworkController['getNetworkClientById']; + + #subscriptions: SubscriptionEntry[] = []; + + constructor(options: MultichainSubscriptionManagerOptions) { + super(); + this.#findNetworkClientIdByChainId = options.findNetworkClientIdByChainId; + this.#getNetworkClientById = options.getNetworkClientById; + } + + onNotification( + { 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.onNotification(subscriptionKey, message); + }, + ); + + this.#subscriptions.push({ + ...subscriptionKey, + subscriptionManager, + }); + + 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 0000000000..f951c26e9b --- /dev/null +++ b/packages/multichain/src/middlewares/multichainMethodCallValidator.test.ts @@ -0,0 +1,515 @@ +import type { + JsonRpcError, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import type { + Caip27Params, + Caip285Params, + Caip319Params, +} from '../scope/types'; +import { multichainMethodCallValidatorMiddleware } from './multichainMethodCallValidator'; + +describe('multichainMethodCallValidatorMiddleware', () => { + const mockNext = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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', + // @ts-expect-error test + params: { + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + expect(error).toBeDefined(); + expect((error as JsonRpcError).message).toBe( + 'Invalid method parameter(s).', + ); + expect((error as JsonRpcError).code).toBe(-32602); + expect((error as JsonRpcError).data).toStrictEqual([ + { + code: -32602, + message: 'scope is required, but is undefined', + data: { + param: 'scope', + path: [], + schema: { + pattern: '[-a-z0-9]{3,8}(:[-_a-zA-Z0-9]{1,32})?', + type: 'string', + }, + got: 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', + // @ts-expect-error test + params: { + scope: 'test', + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + expect(error).toBeDefined(); + expect((error as JsonRpcError).message).toBe( + 'Invalid method parameter(s).', + ); + expect((error as JsonRpcError).code).toBe(-32602); + expect((error as JsonRpcError).data).toStrictEqual([ + { + code: -32602, + data: { + got: undefined, + param: 'request', + path: [], + schema: { + properties: { + method: { + type: 'string', + }, + params: true, + }, + type: 'object', + }, + }, + message: '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', + // @ts-expect-error test + 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 { + expect(error).toBeDefined(); + expect((error as JsonRpcError).message).toBe( + 'Invalid method parameter(s).', + ); + expect((error as JsonRpcError).code).toBe(-32602); + expect((error as JsonRpcError).data).toStrictEqual([ + { + code: -32602, + data: { + got: { + method: {}, + params: { + test: 'test', + }, + }, + param: 'request', + path: ['method'], + schema: { + type: 'string', + }, + }, + message: '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', + // @ts-expect-error test + params: { + scope: 'test_scope', + request: { + data: {}, + }, + }, + }; + 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).', + ); + expect((error as JsonRpcError).data).toStrictEqual([ + { + code: -32602, + data: { + got: undefined, + param: 'notification', + path: [], + schema: { + properties: { + method: { + type: 'string', + }, + params: true, + }, + type: 'object', + }, + }, + message: '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', + // @ts-expect-error TODO figure out why this type is not working + 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', + // @ts-expect-error test + 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', + // @ts-expect-error test + params: { + request: { + method: 'test_method', + params: { + test: 'test', + }, + }, + }, + }; + const response = {} as JsonRpcResponse; + + await new Promise((resolve, reject) => { + multichainMethodCallValidatorMiddleware( + request, + response, + mockNext, + (error) => { + try { + expect(error).toBeDefined(); + console.log('error in test', error); + expect((error as JsonRpcError).message).toBe( + 'Invalid method parameter(s).', + ); + expect((error as JsonRpcError).code).toBe(-32602); + expect((error as JsonRpcError).data).toStrictEqual([ + { + code: -32601, + message: 'The method does not exist / is not available.', + data: { + method: 'unknown_method', + }, + }, + ]); + 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 0000000000..edd9a0fda2 --- /dev/null +++ b/packages/multichain/src/middlewares/multichainMethodCallValidator.ts @@ -0,0 +1,125 @@ +import { MultiChainOpenRPCDocument } from '@metamask/api-specs'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { isObject } from '@metamask/utils'; +import type { + Json, + JsonRpcError, + JsonRpcParams, + JsonRpcRequest, +} from '@metamask/utils'; +import type { + ContentDescriptorObject, + MethodObject, + OpenrpcDocument, +} 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'; + +import type { + Caip27Params, + Caip285Params, + Caip319Params, +} from '../scope/types'; + +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 { + code: -32602, // TODO: could be a different error code or not wrapped in json-rpc error, since this will also be wrapped in a -32602 invalid params error + 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({}), +); +const multichainMethodCallValidator = async ( + method: string, + params: JsonRpcParams | undefined, +) => { + const dereffed = await dereffedPromise; + + const methodToCheck = dereffed.methods.find( + (m) => (m as unknown as ContentDescriptorObject).name === method, + ); + + const errors: JsonRpcError[] = []; + if ( + !methodToCheck || + !isObject(methodToCheck) || + !('params' in methodToCheck) + ) { + return [ + { + code: -32601, + message: 'The method does not exist / is not available.', + data: { + method, + }, + }, + ]; + } + // check each param and aggregate errors + (methodToCheck as unknown as MethodObject).params.forEach((param) => { + const p = param as ContentDescriptorObject; + if (!isObject(params)) { + errors.push({ + code: -32602, + message: 'Invalid method parameter(s).', + }); + return; + } + 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; + }), + ); + } + }); + if (errors.length > 0) { + return errors; + } + // feels like this should return true to indicate that its valid but i'd rather check the falsy value since errors + // would be an array and return true if it's empty + return false; +}; + +export const multichainMethodCallValidatorMiddleware: JsonRpcMiddleware< + JsonRpcRequest | Caip27Params | Caip319Params | Caip285Params, + Json +> = function (request, _response, next, end) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + multichainMethodCallValidator(request.method, request.params).then( + (errors) => { + if (errors) { + return end(rpcErrors.invalidParams({ data: errors })); + } + return next(); + }, + ); +}; diff --git a/packages/multichain/src/scope/assert.test.ts b/packages/multichain/src/scope/assert.test.ts index 0fd23b5bf6..2b27abd672 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 0d2c8c16cb..873c577575 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 4759b40edd..2514c630dc 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 mayble 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 0f43fa33e4..b3f00cba09 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,32 @@ export const validateAndNormalizeScopes = ( normalizedOptionalScopes, }; }; + +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 8369ec721a..362bead5a7 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 6a9427fd4f..ea1291f7b9 100644 --- a/packages/multichain/src/scope/constants.ts +++ b/packages/multichain/src/scope/constants.ts @@ -28,13 +28,29 @@ export const KnownWalletRpcMethods: string[] = [ const WalletEip155Methods = ['wallet_addEthereumChain']; +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 0000000000..c49c739786 --- /dev/null +++ b/packages/multichain/src/scope/filter.test.ts @@ -0,0 +1,168 @@ +import * as Assert from './assert'; +import { filterScopesSupported, bucketScopesBySupport } from './filter'; + +jest.mock('./assert', () => ({ + assertScopeSupported: jest.fn(), +})); +const MockAssert = jest.mocked(Assert); + +describe('filter', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('filterScopesSupported', () => { + const isChainIdSupported = jest.fn(); + + it('checks if each scope is supported', () => { + filterScopesSupported( + { + '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 only supported scopes', () => { + MockAssert.assertScopeSupported.mockImplementation((scopeString) => { + if (scopeString === 'eip155:1') { + throw new Error('scope not supported'); + } + }); + + expect( + filterScopesSupported( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }, + { isChainIdSupported }, + ), + ).toStrictEqual({ + 'eip155:5': { + methods: ['b'], + notifications: [], + accounts: [], + }, + }); + }); + }); + + 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: [], + }, + }, + }); + }); + }); +}); diff --git a/packages/multichain/src/scope/filter.ts b/packages/multichain/src/scope/filter.ts new file mode 100644 index 0000000000..b8ab92f805 --- /dev/null +++ b/packages/multichain/src/scope/filter.ts @@ -0,0 +1,44 @@ +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { assertScopeSupported } from './assert'; +import type { NormalizedScopesObject } from './types'; + +export const bucketScopesBySupport = ( + scopes: NormalizedScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const supportedScopes: NormalizedScopesObject = {}; + const unsupportedScopes: NormalizedScopesObject = {}; + + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + try { + assertScopeSupported(scopeString, scopeObject, { + isChainIdSupported, + }); + supportedScopes[scopeString as CaipChainId] = scopeObject; + } catch (err) { + unsupportedScopes[scopeString as CaipChainId] = scopeObject; + } + } + + return { supportedScopes, unsupportedScopes }; +}; + +export const filterScopesSupported = ( + scopes: NormalizedScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const { supportedScopes } = bucketScopesBySupport(scopes, { + isChainIdSupported, + }); + + return supportedScopes; +}; diff --git a/packages/multichain/src/scope/types.ts b/packages/multichain/src/scope/types.ts index f639c58d2e..4d426be6a1 100644 --- a/packages/multichain/src/scope/types.ts +++ b/packages/multichain/src/scope/types.ts @@ -10,6 +10,7 @@ import type { KnownCaipNamespace, CaipNamespace, Json, + JsonRpcParams, } from '@metamask/utils'; /** @@ -60,7 +61,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 +76,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, @@ -117,3 +120,59 @@ export type NonWalletKnownCaipNamespace = Exclude< KnownCaipNamespace, KnownCaipNamespace.Wallet >; + +// { +// "id": 1, +// "jsonrpc": "2.0", +// "method": "wallet_invokeMethod", +// "params": { +// "sessionId": "0xdeadbeef", +// "scope": "eip155:1", +// "request": { +// "method": "eth_sendTransaction", +// "params": [ +// { +// "to": "0x4B0897b0513FdBeEc7C469D9aF4fA6C0752aBea7", +// "from": "0xDeaDbeefdEAdbeefdEadbEEFdeadbeefDEADbEEF", +// "gas": "0x76c0", +// "value": "0x8ac7230489e80000", +// "data": "0x", +// "gasPrice": "0x4a817c800" +// } +// ] +// } +// } +// } + +/** + * Parameters for the `wallet_invokeMethod` method as defined in CAIP-27. + */ +export type Caip27Params = { + scope: string; + request: { + method: string; + params: JsonRpcParams; + }; +}; + +/** + * Parameters for the `wallet_notify` method as defined in CAIP-319. + */ +export type Caip319Params = { + scope: string; + notification: { + method: string; + params: JsonRpcParams; + }; +}; + +/** + * Parameters for the `wallet_revokeSession` method as defined in CAIP-285. + */ +export type Caip285Params = { + scope: string; + request: { + method: string; + params: Record; + }; +}; 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 0000000000..5a51785b82 --- /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 0883828fed..d42e0c41a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1931,6 +1931,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" @@ -3068,13 +3110,18 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/controller-utils": "npm:^11.4.3" "@metamask/eth-json-rpc-filters": "npm:^7.0.0" + "@metamask/json-rpc-engine": "npm:^10.0.1" "@metamask/network-controller": "npm:^22.0.2" "@metamask/permission-controller": "npm:^11.0.3" "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^10.0.0" + "@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.2.4" lodash: "npm:^4.17.21" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" @@ -4029,6 +4076,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" @@ -4969,7 +5041,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: @@ -6358,6 +6430,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" @@ -7366,7 +7445,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 @@ -7606,6 +7685,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" @@ -7899,7 +7989,7 @@ __metadata: languageName: node linkType: hard -"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.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 @@ -8575,6 +8665,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-weakref@npm:^1.0.2": version: 1.0.2 resolution: "is-weakref@npm:1.0.2" @@ -9511,6 +9608,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.2.4": version: 1.4.1 resolution: "jsonschema@npm:1.4.1" @@ -12347,6 +12457,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.0": version: 1.1.0 resolution: "update-browserslist-db@npm:1.1.0"