diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index 519426b3f0..cae0972ed8 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 95.16, - functions: 98.71, - lines: 98.87, - statements: 98.56, + branches: 95.32, + functions: 98.73, + lines: 98.89, + statements: 98.59, }, }, }); diff --git a/packages/snaps-rpc-methods/src/index.ts b/packages/snaps-rpc-methods/src/index.ts index 1577ae0549..ba9bd4cbb5 100644 --- a/packages/snaps-rpc-methods/src/index.ts +++ b/packages/snaps-rpc-methods/src/index.ts @@ -6,5 +6,6 @@ export type { PermittedRpcMethodHooks } from './permitted'; export { SnapCaveatType } from '@metamask/snaps-utils'; export { selectHooks } from './utils'; export * from './endowments'; +export * from './middleware'; export * from './permissions'; export * from './restricted'; diff --git a/packages/snaps-rpc-methods/src/middleware/index.ts b/packages/snaps-rpc-methods/src/middleware/index.ts new file mode 100644 index 0000000000..70c016a1f4 --- /dev/null +++ b/packages/snaps-rpc-methods/src/middleware/index.ts @@ -0,0 +1 @@ +export * from './preinstalled-snaps'; diff --git a/packages/snaps-rpc-methods/src/middleware/preinstalled-snaps.test.ts b/packages/snaps-rpc-methods/src/middleware/preinstalled-snaps.test.ts new file mode 100644 index 0000000000..a0998fcd86 --- /dev/null +++ b/packages/snaps-rpc-methods/src/middleware/preinstalled-snaps.test.ts @@ -0,0 +1,348 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; + +import { createPreinstalledSnapsMiddleware } from './preinstalled-snaps'; +import { SnapEndowments } from '../endowments'; + +describe('createPreinstalledSnapsMiddleware', () => { + it('grants permissions for accounts not already permitted', async () => { + const hooks = { + getAllEvmAccounts: jest + .fn() + .mockReturnValue(['0x1234567890123456789012345678901234567890']), + getPermissions: jest + .fn() + .mockReturnValue({ [SnapEndowments.EthereumProvider]: {} }), + grantPermissions: jest.fn(), + }; + + const middleware = createPreinstalledSnapsMiddleware(hooks); + + const engine = new JsonRpcEngine(); + + engine.push(middleware); + + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'eth_signTypedData_v4', + params: {}, + }; + + // Since we only have one middleware, this will return an error on a successful test. + expect(await engine.handle(request)).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + data: { + cause: null, + request: { + id: 1, + jsonrpc: '2.0', + method: 'eth_signTypedData_v4', + params: {}, + }, + }, + message: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + stack: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + }, + }); + + expect(hooks.grantPermissions).toHaveBeenCalledWith({ + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + isMultichainOrigin: false, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x1234567890123456789012345678901234567890', + ], + }, + }, + requiredScopes: {}, + sessionProperties: {}, + }, + }, + ], + }, + }); + }); + + it('merges with existing scopes', async () => { + const hooks = { + getAllEvmAccounts: jest + .fn() + .mockReturnValue(['0x1234567890123456789012345678901234567890']), + getPermissions: jest.fn().mockReturnValue({ + [SnapEndowments.EthereumProvider]: {}, + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + ], + }, + }, + optionalScopes: { + 'eip155:2': { + accounts: [ + 'eip155:2:0x1234567890123456789012345678901234567890', + ], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }), + grantPermissions: jest.fn(), + }; + + const middleware = createPreinstalledSnapsMiddleware(hooks); + + const engine = new JsonRpcEngine(); + + engine.push(middleware); + + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'eth_signTypedData_v4', + params: {}, + }; + + // Since we only have one middleware, this will return an error on a successful test. + expect(await engine.handle(request)).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + data: { + cause: null, + request: { + id: 1, + jsonrpc: '2.0', + method: 'eth_signTypedData_v4', + params: {}, + }, + }, + message: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + stack: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + }, + }); + + expect(hooks.grantPermissions).toHaveBeenCalledWith({ + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + ], + }, + }, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x1234567890123456789012345678901234567890', + ], + }, + 'eip155:2': { + accounts: [ + 'eip155:2:0x1234567890123456789012345678901234567890', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }); + }); + + it('skips the middleware if the accounts are already permitted', async () => { + const hooks = { + getAllEvmAccounts: jest + .fn() + .mockReturnValue(['0x1234567890123456789012345678901234567890']), + getPermissions: jest.fn().mockReturnValue({ + [SnapEndowments.EthereumProvider]: {}, + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x1234567890123456789012345678901234567890', + ], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }), + grantPermissions: jest.fn(), + }; + + const middleware = createPreinstalledSnapsMiddleware(hooks); + + const engine = new JsonRpcEngine(); + + engine.push(middleware); + + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'eth_signTypedData_v4', + params: {}, + }; + + // Since we only have one middleware, this will return an error on a successful test. + expect(await engine.handle(request)).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + data: { + cause: null, + request: { + id: 1, + jsonrpc: '2.0', + method: 'eth_signTypedData_v4', + params: {}, + }, + }, + message: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + stack: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + }, + }); + + expect(hooks.grantPermissions).not.toHaveBeenCalled(); + }); + + it('ignores snap methods', async () => { + const hooks = { + getAllEvmAccounts: jest.fn(), + getPermissions: jest.fn(), + grantPermissions: jest.fn(), + }; + + const middleware = createPreinstalledSnapsMiddleware(hooks); + + const engine = new JsonRpcEngine(); + + engine.push(middleware); + + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'snap_dialog', + params: {}, + }; + + // Since we only have one middleware, this will return an error on a successful test. + expect(await engine.handle(request)).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + data: { + cause: null, + request: { + id: 1, + jsonrpc: '2.0', + method: 'snap_dialog', + params: {}, + }, + }, + message: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + stack: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + }, + }); + + expect(hooks.grantPermissions).not.toHaveBeenCalled(); + }); + + it('skips the middleware if the Snap doesnt have endowment:ethereum-provider', async () => { + const hooks = { + getAllEvmAccounts: jest.fn(), + getPermissions: jest.fn(), + grantPermissions: jest.fn(), + }; + + const middleware = createPreinstalledSnapsMiddleware(hooks); + + const engine = new JsonRpcEngine(); + + engine.push(middleware); + + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'eth_signTypedData_v4', + params: {}, + }; + + // Since we only have one middleware, this will return an error on a successful test. + expect(await engine.handle(request)).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + data: { + cause: null, + request: { + id: 1, + jsonrpc: '2.0', + method: 'eth_signTypedData_v4', + params: {}, + }, + }, + message: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + stack: expect.stringContaining( + 'JsonRpcEngine: Response has no error or result for request', + ), + }, + }); + + expect(hooks.grantPermissions).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/snaps-rpc-methods/src/middleware/preinstalled-snaps.ts b/packages/snaps-rpc-methods/src/middleware/preinstalled-snaps.ts new file mode 100644 index 0000000000..5c7034c812 --- /dev/null +++ b/packages/snaps-rpc-methods/src/middleware/preinstalled-snaps.ts @@ -0,0 +1,122 @@ +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import type { + PermissionConstraint, + RequestedPermissions, +} from '@metamask/permission-controller'; +import { isEqual } from '@metamask/snaps-utils'; +import { + type CaipAccountId, + type CaipChainId, + hasProperty, + type Json, + type JsonRpcParams, +} from '@metamask/utils'; + +import { SnapEndowments } from '../endowments'; + +export type PreinstalledSnapsMiddlewareHooks = { + /** + * Get all accounts with the eip155 scope. + * + * @returns A list of all account addresses from the eip155 scope. + */ + getAllEvmAccounts: () => string[]; + /** + * Get the current permissions for the requesting origin. + * + * @returns An object containing the metadata about each permission. + */ + getPermissions: () => Record | undefined; + /** + * Grant the passed permissions to the origin. + * + * @param permissions + */ + grantPermissions: (permissions: RequestedPermissions) => void; +}; + +const WILDCARD_SCOPE = 'wallet:eip155'; + +type ScopesObject = Record; + +type AuthorizedScopeCaveat = { + requiredScopes: ScopesObject; + optionalScopes: ScopesObject; + sessionProperties: Record; + isMultichainOrigin: boolean; +}; + +/** + * Create a middleware that automatically grants account permissions to preinstalled Snaps + * that want to use the Ethereum provider endowment. + * + * @param hooks - The hooks used by the middleware. + * @param hooks.getAllEvmAccounts - Hook for retriveing all available EVM addresses. + * @param hooks.getPermissions - Hook for retrieving the permissions of the requesting origin. + * @param hooks.grantPermissions - Hook for granting permissions to the requesting origin. + * @returns The middleware. + */ +export function createPreinstalledSnapsMiddleware({ + getAllEvmAccounts, + getPermissions, + grantPermissions, +}: PreinstalledSnapsMiddlewareHooks): JsonRpcMiddleware { + return function methodMiddleware(request, _response, next, _end) { + if (request.method.startsWith('snap')) { + return next(); + } + + const permissions = getPermissions(); + + if ( + !permissions || + !hasProperty(permissions, SnapEndowments.EthereumProvider) + ) { + return next(); + } + + const existingEndowment = permissions['endowment:caip25']; + const existingCaveat = existingEndowment?.caveats?.find( + (caveat) => caveat.type === 'authorizedScopes', + )?.value as AuthorizedScopeCaveat | undefined; + + const existingRequiredScopes = existingCaveat?.requiredScopes ?? {}; + const existingOptionalScopes = existingCaveat?.optionalScopes ?? {}; + + const existingEvmAccounts = + existingOptionalScopes[WILDCARD_SCOPE]?.accounts.map((account) => + account.slice(WILDCARD_SCOPE.length + 1), + ) ?? []; + + const evmAccounts = getAllEvmAccounts(); + + if (isEqual(evmAccounts, existingEvmAccounts)) { + return next(); + } + + grantPermissions({ + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: existingRequiredScopes, + optionalScopes: { + ...existingOptionalScopes, + [WILDCARD_SCOPE]: { + accounts: evmAccounts.map( + (account) => `${WILDCARD_SCOPE}:${account}`, + ), + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + + return next(); + }; +}