From 0c7eb2ecc22a58e840d7fca7b036042a1eee6cb8 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 31 Jan 2025 14:26:48 +0100 Subject: [PATCH] feat: Implement `MultichainRouter` and `onProtocolRequest` (SIP-26) (#2875) This PR adds a `MultichainRouter` that can handle routing of non-EVM requests received via the multichain API. The multichain API should integrate this by calling `MultichainRouter.handleRequest` for any requests that are not understood by our existing JSON-RPC stack. Additionally this PR implements `onProtocolRequest`, a new handler that Snaps can choose to register if they want to service protocol (non signing) requests for a given set of methods for one or more chains. The endowment is registered as follows: ```json5 "initialPermissions": { "endowment:protocol": { "scopes": { "": { "methods": [ // List of supported methods ], "notifications": [ // List of supported notifications ] } } } } ``` This implementation follows https://metamask.github.io/SIPs/SIPS/sip-26 Closes https://github.com/MetaMask/snaps/issues/2898 --------- Co-authored-by: Maarten Zuidhoorn --- .../browserify-plugin/snap.manifest.json | 2 +- .../packages/browserify/snap.manifest.json | 2 +- packages/snaps-controllers/coverage.json | 8 +- packages/snaps-controllers/src/index.ts | 1 + .../src/multichain/MultichainRouter.test.ts | 433 ++++++++++++++++++ .../src/multichain/MultichainRouter.ts | 404 ++++++++++++++++ .../snaps-controllers/src/multichain/index.ts | 1 + .../src/snaps/SnapController.test.tsx | 2 +- .../src/test-utils/controller.ts | 40 ++ .../snaps-controllers/src/test-utils/index.ts | 1 + .../src/test-utils/multichain.ts | 111 +++++ .../coverage.json | 8 +- .../common/BaseSnapExecutor.test.browser.ts | 42 ++ .../src/common/commands.ts | 9 + .../src/common/validation.test.ts | 66 +++ .../src/common/validation.ts | 39 +- packages/snaps-rpc-methods/jest.config.js | 8 +- .../snaps-rpc-methods/src/endowments/enum.ts | 1 + .../snaps-rpc-methods/src/endowments/index.ts | 12 + .../src/endowments/keyring.test.ts | 2 + .../src/endowments/protocol.test.ts | 153 +++++++ .../src/endowments/protocol.ts | 146 ++++++ .../snaps-rpc-methods/src/permissions.test.ts | 13 + .../snaps-sdk/src/types/handlers/index.ts | 1 + .../snaps-sdk/src/types/handlers/protocol.ts | 30 ++ .../src/methods/specifications.test.ts | 13 + packages/snaps-utils/coverage.json | 6 +- packages/snaps-utils/src/caveats.ts | 5 + packages/snaps-utils/src/handler-types.ts | 1 + packages/snaps-utils/src/handlers.ts | 18 +- .../snaps-utils/src/manifest/validation.ts | 12 + 31 files changed, 1568 insertions(+), 22 deletions(-) create mode 100644 packages/snaps-controllers/src/multichain/MultichainRouter.test.ts create mode 100644 packages/snaps-controllers/src/multichain/MultichainRouter.ts create mode 100644 packages/snaps-controllers/src/multichain/index.ts create mode 100644 packages/snaps-controllers/src/test-utils/multichain.ts create mode 100644 packages/snaps-rpc-methods/src/endowments/protocol.test.ts create mode 100644 packages/snaps-rpc-methods/src/endowments/protocol.ts create mode 100644 packages/snaps-sdk/src/types/handlers/protocol.ts diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index fc111c2e52..70799b3553 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "R4WjwqkDLNMUtU07n8AGq0WZKjsqjTjQXlASF++J4ws=", + "shasum": "Yzt/aRJTbRwAn3zbbK7W3MjgVVt2f6jLMGSc6pe2oyg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index d42908c907..5cde77ba66 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "06xWu+ehUlNMpbJQxTZi2mlnAJId3cLHEK6fWD2Z9rc=", + "shasum": "J+fHvBGBrcoSZ5R1dEiIO3PegqNGFElb1i4h9Zw4zxg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 813ee1e36b..486cbc8f4d 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93.06, - "functions": 96.61, - "lines": 98.08, - "statements": 97.8 + "branches": 93.28, + "functions": 96.8, + "lines": 98.15, + "statements": 97.88 } diff --git a/packages/snaps-controllers/src/index.ts b/packages/snaps-controllers/src/index.ts index 46f68a7381..ea8a94de5d 100644 --- a/packages/snaps-controllers/src/index.ts +++ b/packages/snaps-controllers/src/index.ts @@ -5,3 +5,4 @@ export * from './utils'; export * from './cronjob'; export * from './interface'; export * from './insights'; +export * from './multichain'; diff --git a/packages/snaps-controllers/src/multichain/MultichainRouter.test.ts b/packages/snaps-controllers/src/multichain/MultichainRouter.test.ts new file mode 100644 index 0000000000..a17b6d0fc7 --- /dev/null +++ b/packages/snaps-controllers/src/multichain/MultichainRouter.test.ts @@ -0,0 +1,433 @@ +import { HandlerType } from '@metamask/snaps-utils'; +import { getTruncatedSnap } from '@metamask/snaps-utils/test-utils'; + +import { + getRootMultichainRouterMessenger, + getRestrictedMultichainRouterMessenger, + BTC_CAIP2, + BTC_CONNECTED_ACCOUNTS, + MOCK_SOLANA_SNAP_PERMISSIONS, + SOLANA_CONNECTED_ACCOUNTS, + SOLANA_CAIP2, + MOCK_SOLANA_ACCOUNTS, + MOCK_BTC_ACCOUNTS, + getMockWithSnapKeyring, +} from '../test-utils'; +import { MultichainRouter } from './MultichainRouter'; + +describe('MultichainRouter', () => { + describe('handleRequest', () => { + it('can route signing requests to account Snaps without address resolution', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring({ + submitRequest: jest.fn().mockResolvedValue({ + txid: '53de51e2fa75c3cfa51132865f7d430138b1cd92a8f5267ec836ec565b422969', + }), + }); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_BTC_ACCOUNTS, + ); + + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + async ({ handler }) => { + if (handler === HandlerType.OnKeyringRequest) { + return null; + } + throw new Error('Unmocked request'); + }, + ); + + const result = await messenger.call('MultichainRouter:handleRequest', { + connectedAddresses: BTC_CONNECTED_ACCOUNTS, + scope: BTC_CAIP2, + request: { + method: 'sendBitcoin', + params: { + message: 'foo', + }, + }, + }); + + expect(result).toStrictEqual({ + txid: '53de51e2fa75c3cfa51132865f7d430138b1cd92a8f5267ec836ec565b422969', + }); + }); + + it('can route signing requests to account Snaps using address resolution', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring({ + submitRequest: jest.fn().mockResolvedValue({ + signature: '0x', + }), + }); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_SOLANA_ACCOUNTS, + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => MOCK_SOLANA_SNAP_PERMISSIONS, + ); + + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + async ({ handler }) => { + if (handler === HandlerType.OnKeyringRequest) { + return { address: SOLANA_CONNECTED_ACCOUNTS[0] }; + } + throw new Error('Unmocked request'); + }, + ); + + const result = await messenger.call('MultichainRouter:handleRequest', { + connectedAddresses: SOLANA_CONNECTED_ACCOUNTS, + scope: SOLANA_CAIP2, + request: { + method: 'signAndSendTransaction', + params: { + message: 'foo', + }, + }, + }); + + expect(result).toStrictEqual({ signature: '0x' }); + }); + + it('can route protocol requests to protocol Snaps', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [], + ); + + rootMessenger.registerActionHandler('SnapController:getAll', () => { + return [getTruncatedSnap()]; + }); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => MOCK_SOLANA_SNAP_PERMISSIONS, + ); + + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + async () => ({ + 'feature-set': 2891131721, + 'solana-core': '1.16.7', + }), + ); + + const result = await messenger.call('MultichainRouter:handleRequest', { + connectedAddresses: [], + scope: SOLANA_CAIP2, + request: { + method: 'getVersion', + }, + }); + + expect(result).toStrictEqual({ + 'feature-set': 2891131721, + 'solana-core': '1.16.7', + }); + }); + + it('throws if no suitable Snaps are found', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [], + ); + + rootMessenger.registerActionHandler('SnapController:getAll', () => { + return []; + }); + + await expect( + messenger.call('MultichainRouter:handleRequest', { + connectedAddresses: [], + scope: SOLANA_CAIP2, + request: { + method: 'getVersion', + }, + }), + ).rejects.toThrow('The method does not exist / is not available'); + }); + + it('throws if address resolution fails', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring({ + // Simulate the Snap returning a bogus address + resolveAccountAddress: jest.fn().mockResolvedValue({ address: 'foo' }), + }); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_SOLANA_ACCOUNTS, + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => MOCK_SOLANA_SNAP_PERMISSIONS, + ); + + await expect( + messenger.call('MultichainRouter:handleRequest', { + connectedAddresses: SOLANA_CONNECTED_ACCOUNTS, + scope: SOLANA_CAIP2, + request: { + method: 'signAndSendTransaction', + params: { + message: 'foo', + }, + }, + }), + ).rejects.toThrow('Internal JSON-RPC error'); + }); + + it('throws if address resolution returns an address that isnt available', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring({ + // Simulate the Snap returning an unconnected address + resolveAccountAddress: jest.fn().mockResolvedValue({ + address: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKa', + }), + }); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_SOLANA_ACCOUNTS, + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => MOCK_SOLANA_SNAP_PERMISSIONS, + ); + + await expect( + messenger.call('MultichainRouter:handleRequest', { + connectedAddresses: SOLANA_CONNECTED_ACCOUNTS, + scope: SOLANA_CAIP2, + request: { + method: 'signAndSendTransaction', + params: { + message: 'foo', + }, + }, + }), + ).rejects.toThrow('No available account found for request.'); + }); + }); + + describe('getSupportedMethods', () => { + it('returns a set of both protocol and account Snap methods', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler('SnapController:getAll', () => { + return [getTruncatedSnap()]; + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_SOLANA_ACCOUNTS, + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => MOCK_SOLANA_SNAP_PERMISSIONS, + ); + + expect( + messenger.call('MultichainRouter:getSupportedMethods', SOLANA_CAIP2), + ).toStrictEqual(['signAndSendTransaction', 'getVersion']); + }); + + it('handles lack of protocol Snaps', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler('SnapController:getAll', () => { + return [getTruncatedSnap()]; + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_SOLANA_ACCOUNTS, + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({}), + ); + + expect( + messenger.call('MultichainRouter:getSupportedMethods', SOLANA_CAIP2), + ).toStrictEqual(['signAndSendTransaction']); + }); + + it('handles lack of account Snaps', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler('SnapController:getAll', () => { + return [getTruncatedSnap()]; + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [], + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => MOCK_SOLANA_SNAP_PERMISSIONS, + ); + + expect( + messenger.call('MultichainRouter:getSupportedMethods', SOLANA_CAIP2), + ).toStrictEqual(['getVersion']); + }); + }); + + describe('getSupportedAccounts', () => { + it('returns a set of accounts for the requested scope', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_SOLANA_ACCOUNTS, + ); + + expect( + messenger.call('MultichainRouter:getSupportedAccounts', SOLANA_CAIP2), + ).toStrictEqual([ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ]); + }); + }); + + describe('isSupportedScope', () => { + it('returns true if an account Snap exists', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => MOCK_SOLANA_ACCOUNTS, + ); + + expect( + messenger.call('MultichainRouter:isSupportedScope', SOLANA_CAIP2), + ).toBe(true); + }); + + it('returns false if no account Snap is found', async () => { + const rootMessenger = getRootMultichainRouterMessenger(); + const messenger = getRestrictedMultichainRouterMessenger(rootMessenger); + const withSnapKeyring = getMockWithSnapKeyring(); + + /* eslint-disable-next-line no-new */ + new MultichainRouter({ + messenger, + withSnapKeyring, + }); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [], + ); + + expect( + messenger.call('MultichainRouter:isSupportedScope', SOLANA_CAIP2), + ).toBe(false); + }); + }); +}); diff --git a/packages/snaps-controllers/src/multichain/MultichainRouter.ts b/packages/snaps-controllers/src/multichain/MultichainRouter.ts new file mode 100644 index 0000000000..4d23f8ef1b --- /dev/null +++ b/packages/snaps-controllers/src/multichain/MultichainRouter.ts @@ -0,0 +1,404 @@ +import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { GetPermissions } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { + getProtocolCaveatScopes, + SnapEndowments, +} from '@metamask/snaps-rpc-methods'; +import type { Json, JsonRpcRequest, SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { + CaipAccountId, + CaipChainId, + JsonRpcParams, +} from '@metamask/utils'; +import { + assert, + hasProperty, + KnownCaipNamespace, + parseCaipAccountId, +} from '@metamask/utils'; + +import { getRunnableSnaps } from '../snaps'; +import type { GetAllSnaps, HandleSnapRequest } from '../snaps'; + +export type MultichainRouterHandleRequestAction = { + type: `${typeof name}:handleRequest`; + handler: MultichainRouter['handleRequest']; +}; + +export type MultichainRouterGetSupportedMethodsAction = { + type: `${typeof name}:getSupportedMethods`; + handler: MultichainRouter['getSupportedMethods']; +}; + +export type MultichainRouterGetSupportedAccountsAction = { + type: `${typeof name}:getSupportedAccounts`; + handler: MultichainRouter['getSupportedAccounts']; +}; + +export type MultichainRouterIsSupportedScopeAction = { + type: `${typeof name}:isSupportedScope`; + handler: MultichainRouter['isSupportedScope']; +}; + +// Since the AccountsController depends on snaps-controllers we manually type this +type InternalAccount = { + id: string; + type: string; + address: string; + options: Record; + methods: string[]; + metadata: { + name: string; + snap?: { id: SnapId; enabled: boolean; name: string }; + }; +}; + +type SnapKeyring = { + submitRequest: (request: { + account: string; + method: string; + params?: Json[] | Record; + scope: CaipChainId; + }) => Promise; + resolveAccountAddress: ( + snapId: SnapId, + scope: CaipChainId, + request: Json, + ) => Promise<{ address: CaipAccountId } | null>; +}; + +// Expecting a bound function that calls KeyringController.withKeyring selecting the Snap keyring +type WithSnapKeyringFunction = ( + operation: (keyring: SnapKeyring) => Promise, +) => Promise; + +export type AccountsControllerListMultichainAccountsAction = { + type: `AccountsController:listMultichainAccounts`; + handler: (chainId?: CaipChainId) => InternalAccount[]; +}; + +export type MultichainRouterActions = + | MultichainRouterHandleRequestAction + | MultichainRouterGetSupportedMethodsAction + | MultichainRouterGetSupportedAccountsAction + | MultichainRouterIsSupportedScopeAction; + +export type MultichainRouterAllowedActions = + | GetAllSnaps + | HandleSnapRequest + | GetPermissions + | AccountsControllerListMultichainAccountsAction; + +export type MultichainRouterEvents = never; + +export type MultichainRouterMessenger = RestrictedControllerMessenger< + typeof name, + MultichainRouterActions | MultichainRouterAllowedActions, + never, + MultichainRouterAllowedActions['type'], + MultichainRouterEvents['type'] +>; + +export type MultichainRouterArgs = { + messenger: MultichainRouterMessenger; + withSnapKeyring: WithSnapKeyringFunction; +}; + +type ProtocolSnap = { + snapId: SnapId; + methods: string[]; +}; + +const name = 'MultichainRouter'; + +export class MultichainRouter { + #messenger: MultichainRouterMessenger; + + #withSnapKeyring: WithSnapKeyringFunction; + + constructor({ messenger, withSnapKeyring }: MultichainRouterArgs) { + this.#messenger = messenger; + this.#withSnapKeyring = withSnapKeyring; + + this.#messenger.registerActionHandler( + `${name}:handleRequest`, + async (...args) => this.handleRequest(...args), + ); + + this.#messenger.registerActionHandler( + `${name}:getSupportedMethods`, + (...args) => this.getSupportedMethods(...args), + ); + + this.#messenger.registerActionHandler( + `${name}:getSupportedAccounts`, + (...args) => this.getSupportedAccounts(...args), + ); + + this.#messenger.registerActionHandler( + `${name}:isSupportedScope`, + (...args) => this.isSupportedScope(...args), + ); + } + + /** + * Attempts to resolve the account address to use for a given request by inspecting the request itself. + * + * The request is sent to to an account Snap via the SnapKeyring that will attempt this resolution. + * + * @param snapId - The ID of the Snap to send the request to. + * @param scope - The CAIP-2 scope for the request. + * @param request - The JSON-RPC request. + * @returns The resolved address if found, otherwise null. + * @throws If the invocation of the SnapKeyring fails. + */ + async #resolveRequestAddress( + snapId: SnapId, + scope: CaipChainId, + request: JsonRpcRequest, + ) { + try { + const result = await this.#withSnapKeyring(async (keyring) => + keyring.resolveAccountAddress(snapId, scope, request), + ); + const address = result?.address; + return address ? parseCaipAccountId(address).address : null; + } catch { + throw rpcErrors.internal(); + } + } + + /** + * Get the account ID of the account that should service the RPC request via an account Snap. + * + * This function checks whether any accounts exist that can service a given request by + * using a combination of the resolveAccountAddress functionality and the connected accounts. + * + * If an account is expected to service this request but none is found, the function will throw. + * + * @param connectedAddresses - The CAIP-10 addresses connected to the requesting origin. + * @param scope - The CAIP-2 scope for the request. + * @param request - The JSON-RPC request. + * @returns An account ID if found, otherwise null. + * @throws If no account is found, but the accounts exist that could service the request. + */ + async #getSnapAccountId( + connectedAddresses: CaipAccountId[], + scope: CaipChainId, + request: JsonRpcRequest, + ) { + const accounts = this.#messenger + .call('AccountsController:listMultichainAccounts', scope) + .filter( + ( + account: InternalAccount, + ): account is InternalAccount & { + metadata: Required; + } => + Boolean(account.metadata.snap?.enabled) && + account.methods.includes(request.method), + ); + + // If no accounts can service the request, return null. + if (accounts.length === 0) { + return null; + } + + const resolutionSnapId = accounts[0].metadata.snap.id; + + // Attempt to resolve the address that should be used for signing. + const address = await this.#resolveRequestAddress( + resolutionSnapId, + scope, + request, + ); + + const parsedConnectedAddresses = connectedAddresses.map( + (connectedAddress) => parseCaipAccountId(connectedAddress).address, + ); + + // If we have a resolved address, try to find the selected account based on that + // otherwise, default to one of the connected accounts. + // TODO: Eventually let the user choose if we have more than one option for the account. + const selectedAccount = accounts.find( + (account) => + parsedConnectedAddresses.includes(account.address) && + (!address || account.address.toLowerCase() === address.toLowerCase()), + ); + + if (!selectedAccount) { + throw rpcErrors.invalidParams({ + message: 'No available account found for request.', + }); + } + + return selectedAccount.id; + } + + /** + * Get all protocol Snaps that can service a given CAIP-2 scope. + * + * Protocol Snaps are deemed fit to service a scope if they are runnable + * and have the proper permissions set for the scope. + * + * @param scope - A CAIP-2 scope. + * @returns A list of all the protocol Snaps available and their RPC methods. + */ + #getProtocolSnaps(scope: CaipChainId) { + const allSnaps = this.#messenger.call('SnapController:getAll'); + const filteredSnaps = getRunnableSnaps(allSnaps); + + return filteredSnaps.reduce((accumulator, snap) => { + const permissions = this.#messenger.call( + 'PermissionController:getPermissions', + snap.id, + ); + + if (permissions && hasProperty(permissions, SnapEndowments.Protocol)) { + const permission = permissions[SnapEndowments.Protocol]; + const scopes = getProtocolCaveatScopes(permission); + if (scopes && hasProperty(scopes, scope)) { + accumulator.push({ + snapId: snap.id, + methods: scopes[scope].methods, + }); + } + } + + return accumulator; + }, []); + } + + /** + * Handle an incoming JSON-RPC request tied to a specific scope by routing + * to either a procotol Snap or an account Snap. + * + * @param options - An options bag. + * @param options.connectedAddresses - Addresses currently connected to the origin. + * @param options.origin - The origin of the RPC request. + * @param options.request - The JSON-RPC request. + * @param options.scope - The CAIP-2 scope for the request. + * @returns The response from the chosen Snap. + * @throws If no handler was found. + */ + async handleRequest({ + connectedAddresses, + origin, + scope, + request, + }: { + connectedAddresses: CaipAccountId[]; + origin: string; + scope: CaipChainId; + request: JsonRpcRequest; + }): Promise { + // Explicitly block EVM scopes, just in case. + assert( + !scope.startsWith(KnownCaipNamespace.Eip155) && + !scope.startsWith('wallet:eip155'), + ); + + const { method, params } = request; + + // If the RPC request can be serviced by an account Snap, route it there. + const accountId = await this.#getSnapAccountId( + connectedAddresses, + scope, + request, + ); + + if (accountId) { + return this.#withSnapKeyring(async (keyring) => + keyring.submitRequest({ + account: accountId, + scope, + method, + params: params as JsonRpcParams, + }), + ); + } + + // If the RPC request cannot be serviced by an account Snap, + // but has a protocol Snap available, route it there. + const protocolSnaps = this.#getProtocolSnaps(scope); + const protocolSnap = protocolSnaps.find((snap) => + snap.methods.includes(method), + ); + + if (protocolSnap) { + return this.#messenger.call('SnapController:handleRequest', { + snapId: protocolSnap.snapId, + origin, + request: { + method: '', + params: { + request, + scope, + }, + }, + handler: HandlerType.OnProtocolRequest, + }) as Promise; + } + + // If no compatible account or protocol Snaps were found, throw. + throw rpcErrors.methodNotFound(); + } + + /** + * Get a list of metadata for supported accounts for a given scope from the client. + * + * @param scope - The CAIP-2 scope. + * @returns A list of metadata for the supported accounts. + */ + #getSupportedAccountsMetadata(scope: CaipChainId): InternalAccount[] { + return this.#messenger + .call('AccountsController:listMultichainAccounts', scope) + .filter((account: InternalAccount) => account.metadata.snap?.enabled); + } + + /** + * Get a list of supported methods for a given scope. + * This combines both protocol and account Snaps supported methods. + * + * @param scope - The CAIP-2 scope. + * @returns A list of supported methods. + */ + getSupportedMethods(scope: CaipChainId): string[] { + const accountMethods = this.#getSupportedAccountsMetadata(scope).flatMap( + (account) => account.methods, + ); + + const protocolMethods = this.#getProtocolSnaps(scope).flatMap( + (snap) => snap.methods, + ); + + return Array.from(new Set([...accountMethods, ...protocolMethods])); + } + + /** + * Get a list of supported accounts for a given scope. + * + * @param scope - The CAIP-2 scope. + * @returns A list of CAIP-10 addresses. + */ + getSupportedAccounts(scope: CaipChainId): string[] { + return this.#getSupportedAccountsMetadata(scope).map( + (account) => `${scope}:${account.address}`, + ); + } + + /** + * Determine whether a given CAIP-2 scope is supported by the router. + * + * @param scope - The CAIP-2 scope. + * @returns True if the router can service the scope, otherwise false. + */ + isSupportedScope(scope: CaipChainId): boolean { + // We currently assume here that if one Snap exists that service the scope, we can service the scope generally. + return this.#messenger + .call('AccountsController:listMultichainAccounts', scope) + .some((account: InternalAccount) => account.metadata.snap?.enabled); + } +} diff --git a/packages/snaps-controllers/src/multichain/index.ts b/packages/snaps-controllers/src/multichain/index.ts new file mode 100644 index 0000000000..8c696c379e --- /dev/null +++ b/packages/snaps-controllers/src/multichain/index.ts @@ -0,0 +1 @@ +export * from './MultichainRouter'; diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 7c31cf10f1..09b698954a 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -6108,7 +6108,7 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: {}, }), ).rejects.toThrow( - 'A snap must request at least one of the following permissions: endowment:rpc, endowment:transaction-insight, endowment:cronjob, endowment:name-lookup, endowment:lifecycle-hooks, endowment:keyring, endowment:page-home, endowment:page-settings, endowment:signature-insight, endowment:assets.', + 'A snap must request at least one of the following permissions: endowment:rpc, endowment:transaction-insight, endowment:cronjob, endowment:name-lookup, endowment:lifecycle-hooks, endowment:keyring, endowment:page-home, endowment:page-settings, endowment:signature-insight, endowment:assets, endowment:protocol.', ); controller.destroy(); diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index f71b8b072a..fab6d0c937 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -52,6 +52,11 @@ import type { SnapInterfaceControllerEvents, StoredInterface, } from '../interface/SnapInterfaceController'; +import type { + MultichainRouterActions, + MultichainRouterAllowedActions, + MultichainRouterEvents, +} from '../multichain'; import { SnapController } from '../snaps'; import type { AllowedActions, @@ -861,3 +866,38 @@ export async function waitForStateChange( }); }); } + +// Mock controller messenger for Multichain Router +export const getRootMultichainRouterMessenger = () => { + const messenger = new MockControllerMessenger< + MultichainRouterActions | MultichainRouterAllowedActions, + MultichainRouterEvents + >(); + + jest.spyOn(messenger, 'call'); + + return messenger; +}; + +export const getRestrictedMultichainRouterMessenger = ( + messenger: ReturnType< + typeof getRootMultichainRouterMessenger + > = getRootMultichainRouterMessenger(), +) => { + const controllerMessenger = messenger.getRestricted< + 'MultichainRouter', + MultichainRouterAllowedActions['type'], + never + >({ + name: 'MultichainRouter', + allowedEvents: [], + allowedActions: [ + 'PermissionController:getPermissions', + 'SnapController:getAll', + 'SnapController:handleRequest', + 'AccountsController:listMultichainAccounts', + ], + }); + + return controllerMessenger; +}; diff --git a/packages/snaps-controllers/src/test-utils/index.ts b/packages/snaps-controllers/src/test-utils/index.ts index 91b9d1585a..eb5301eacf 100644 --- a/packages/snaps-controllers/src/test-utils/index.ts +++ b/packages/snaps-controllers/src/test-utils/index.ts @@ -4,4 +4,5 @@ export * from './execution-environment'; export * from './service'; export * from './sleep'; export * from './location'; +export * from './multichain'; export * from './registry'; diff --git a/packages/snaps-controllers/src/test-utils/multichain.ts b/packages/snaps-controllers/src/test-utils/multichain.ts new file mode 100644 index 0000000000..361190786a --- /dev/null +++ b/packages/snaps-controllers/src/test-utils/multichain.ts @@ -0,0 +1,111 @@ +import type { PermissionConstraint } from '@metamask/permission-controller'; +import { SnapEndowments } from '@metamask/snaps-rpc-methods'; +import { SnapCaveatType } from '@metamask/snaps-utils'; +import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; +import type { CaipAccountId, CaipChainId, Json } from '@metamask/utils'; + +export const BTC_CAIP2 = + 'bip122:000000000019d6689c085ae165831e93' as CaipChainId; +export const BTC_CONNECTED_ACCOUNTS = [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', +] as CaipAccountId[]; + +export const MOCK_BTC_ACCOUNTS = [ + { + address: '128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + id: '408eb023-8678-4b53-8885-f1e50b8b5bc3', + metadata: { + importTime: 1729154128900, + keyring: { type: 'Snap Keyring' }, + lastSelected: 1729154128902, + name: 'Bitcoin Account', + snap: { + enabled: true, + id: MOCK_SNAP_ID, + name: 'Bitcoin', + }, + }, + methods: ['sendBitcoin'], + options: { index: 0, scope: BTC_CAIP2 }, + type: 'bip122:p2wpkh', + }, +]; + +export const SOLANA_CAIP2 = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId; +export const SOLANA_CONNECTED_ACCOUNTS = [ + `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv`, +] as CaipAccountId[]; + +export const MOCK_SOLANA_ACCOUNTS = [ + { + address: '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + id: '408eb023-8678-4b53-8885-f1e50b8b5bc3', + metadata: { + importTime: 1729154128900, + keyring: { type: 'Snap Keyring' }, + lastSelected: 1729154128902, + name: 'Solana Account', + snap: { + enabled: true, + id: MOCK_SNAP_ID, + name: 'Solana', + }, + }, + methods: ['signAndSendTransaction'], + options: { index: 0, scope: SOLANA_CAIP2 }, + type: 'solana:data-account', + }, +]; + +export const MOCK_SOLANA_SNAP_PERMISSIONS: Record< + string, + PermissionConstraint +> = { + [SnapEndowments.Keyring]: { + caveats: [ + { + type: SnapCaveatType.KeyringOrigin, + value: { allowedOrigins: [] }, + }, + ], + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_SNAP_ID, + parentCapability: SnapEndowments.Keyring, + }, + [SnapEndowments.Protocol]: { + caveats: [ + { + type: SnapCaveatType.ProtocolSnapScopes, + value: { [SOLANA_CAIP2]: { methods: ['getVersion'] } }, + }, + ], + date: 1664187844588, + id: 'izn0WGUO8cvq_jqvLQuQP', + invoker: MOCK_SNAP_ID, + parentCapability: SnapEndowments.Protocol, + }, +}; + +type MockSnapKeyring = { + submitRequest: (request: unknown) => Promise; + resolveAccountAddress: ( + options: unknown, + ) => Promise<{ address: CaipAccountId } | null>; +}; + +type MockOperationCallback = (keyring: MockSnapKeyring) => Promise; + +export const getMockWithSnapKeyring = ( + { submitRequest = jest.fn(), resolveAccountAddress = jest.fn() } = { + submitRequest: jest.fn(), + resolveAccountAddress: jest.fn(), + }, +) => { + return async (callback: MockOperationCallback) => + callback({ + submitRequest, + resolveAccountAddress, + }); +}; diff --git a/packages/snaps-execution-environments/coverage.json b/packages/snaps-execution-environments/coverage.json index f8b47f1792..9625ffb66a 100644 --- a/packages/snaps-execution-environments/coverage.json +++ b/packages/snaps-execution-environments/coverage.json @@ -1,6 +1,6 @@ { - "branches": 80.95, - "functions": 89.47, - "lines": 90.84, - "statements": 90 + "branches": 81.08, + "functions": 89.54, + "lines": 90.92, + "statements": 89.95 } diff --git a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts index d803c3ce66..1fb0576804 100644 --- a/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts +++ b/packages/snaps-execution-environments/src/common/BaseSnapExecutor.test.browser.ts @@ -1683,6 +1683,48 @@ describe('BaseSnapExecutor', () => { }); }); + it('supports onProtocolRequest export', async () => { + const CODE = ` + module.exports.onProtocolRequest = ({ origin, scope, request }) => ({ origin, scope, request }) + `; + + const executor = new TestSnapExecutor(); + await executor.executeSnap(1, MOCK_SNAP_ID, CODE, []); + + expect(await executor.readCommand()).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: 'OK', + }); + + const params = { + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + request: { + jsonrpc: '2.0', + id: 'foo', + method: 'getVersion', + }, + }; + + await executor.writeCommand({ + jsonrpc: '2.0', + id: 2, + method: 'snapRpc', + params: [ + MOCK_SNAP_ID, + HandlerType.OnProtocolRequest, + MOCK_ORIGIN, + { jsonrpc: '2.0', method: '', params }, + ], + }); + + expect(await executor.readCommand()).toStrictEqual({ + id: 2, + jsonrpc: '2.0', + result: { origin: MOCK_ORIGIN, ...params }, + }); + }); + describe('lifecycle hooks', () => { const LIFECYCLE_HOOKS = [HandlerType.OnInstall, HandlerType.OnUpdate]; diff --git a/packages/snaps-execution-environments/src/common/commands.ts b/packages/snaps-execution-environments/src/common/commands.ts index 8dbfac295f..db7b2f34d0 100644 --- a/packages/snaps-execution-environments/src/common/commands.ts +++ b/packages/snaps-execution-environments/src/common/commands.ts @@ -17,6 +17,7 @@ import { assertIsOnUserInputRequestArguments, assertIsOnAssetsLookupRequestArguments, assertIsOnAssetsConversionRequestArguments, + assertIsOnProtocolRequestArguments, } from './validation'; export type CommandMethodsMapping = { @@ -86,6 +87,14 @@ export function getHandlerArguments( address, }; } + + case HandlerType.OnProtocolRequest: { + assertIsOnProtocolRequestArguments(request.params); + + const { request: nestedRequest, scope } = request.params; + return { origin, request: nestedRequest, scope }; + } + case HandlerType.OnRpcRequest: case HandlerType.OnKeyringRequest: return { origin, request }; diff --git a/packages/snaps-execution-environments/src/common/validation.test.ts b/packages/snaps-execution-environments/src/common/validation.test.ts index eef0294d2a..c3cec65b34 100644 --- a/packages/snaps-execution-environments/src/common/validation.test.ts +++ b/packages/snaps-execution-environments/src/common/validation.test.ts @@ -4,6 +4,7 @@ import { assertIsOnAssetsConversionRequestArguments, assertIsOnAssetsLookupRequestArguments, assertIsOnNameLookupRequestArguments, + assertIsOnProtocolRequestArguments, assertIsOnSignatureRequestArguments, assertIsOnTransactionRequestArguments, assertIsOnUserInputRequestArguments, @@ -312,3 +313,68 @@ describe('assertIsOnAssetsConversionRequestArguments', () => { }, ); }); + +describe('assertIsOnProtocolRequestArguments', () => { + it.each([ + { + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + request: { + jsonrpc: '2.0', + id: 'foo', + method: 'getVersion', + }, + }, + { + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + request: { + jsonrpc: '2.0', + id: 'foo', + method: 'getVersion', + params: { + foo: 'bar', + }, + }, + }, + ])('does not throw for a valid protocol request param object', (value) => { + expect(() => assertIsOnProtocolRequestArguments(value)).not.toThrow(); + }); + + it.each([ + true, + false, + null, + undefined, + 0, + 1, + '', + 'foo', + [], + {}, + { request: { foo: 'bar' } }, + { + request: { + jsonrpc: '2.0', + id: 'foo', + method: 'getVersion', + params: { + foo: 'bar', + }, + }, + }, + { + scope: 'foo', + request: { + jsonrpc: '2.0', + id: 'foo', + method: 'getVersion', + }, + }, + ])( + 'throws if the value is not a valid protocol request params object', + (value) => { + expect(() => assertIsOnProtocolRequestArguments(value as any)).toThrow( + 'Invalid request params:', + ); + }, + ); +}); diff --git a/packages/snaps-execution-environments/src/common/validation.ts b/packages/snaps-execution-environments/src/common/validation.ts index 22a5e68033..806e78dedf 100644 --- a/packages/snaps-execution-environments/src/common/validation.ts +++ b/packages/snaps-execution-environments/src/common/validation.ts @@ -4,7 +4,7 @@ import { UserInputEventStruct, } from '@metamask/snaps-sdk'; import { ChainIdStruct, HandlerType } from '@metamask/snaps-utils'; -import type { Infer } from '@metamask/superstruct'; +import type { Infer, Struct } from '@metamask/superstruct'; import { any, array, @@ -21,12 +21,19 @@ import { tuple, union, } from '@metamask/superstruct'; -import type { Json, JsonRpcSuccess } from '@metamask/utils'; +import type { + CaipChainId, + Json, + JsonRpcRequest, + JsonRpcSuccess, +} from '@metamask/utils'; import { assertStruct, CaipAssetTypeStruct, + CaipChainIdStruct, JsonRpcIdStruct, JsonRpcParamsStruct, + JsonRpcRequestStruct, JsonRpcSuccessStruct, JsonRpcVersionStruct, JsonStruct, @@ -308,6 +315,34 @@ export function assertIsOnUserInputRequestArguments( ); } +export const OnProtocolRequestArgumentsStruct = object({ + scope: CaipChainIdStruct, + request: JsonRpcRequestStruct, +}) as unknown as Struct<{ scope: CaipChainId; request: JsonRpcRequest }, null>; + +export type OnProtocolRequestArguments = Infer< + typeof OnProtocolRequestArgumentsStruct +>; + +/** + * Asserts that the given value is a valid {@link OnProtocolRequestArguments} + * object. + * + * @param value - The value to validate. + * @throws If the value is not a valid {@link OnProtocolRequestArguments} + * object. + */ +export function assertIsOnProtocolRequestArguments( + value: unknown, +): asserts value is OnProtocolRequestArguments { + assertStruct( + value, + OnProtocolRequestArgumentsStruct, + 'Invalid request params', + rpcErrors.invalidParams, + ); +} + const OkResponseStruct = object({ id: JsonRpcIdStruct, jsonrpc: JsonRpcVersionStruct, diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index d7bfe3cbd4..95c324bc1f 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: 94.93, - functions: 98.08, - lines: 98.69, - statements: 98.36, + branches: 95.09, + functions: 98.61, + lines: 98.8, + statements: 98.47, }, }, }); diff --git a/packages/snaps-rpc-methods/src/endowments/enum.ts b/packages/snaps-rpc-methods/src/endowments/enum.ts index 96f4179fc6..e3f24b4de5 100644 --- a/packages/snaps-rpc-methods/src/endowments/enum.ts +++ b/packages/snaps-rpc-methods/src/endowments/enum.ts @@ -12,4 +12,5 @@ export enum SnapEndowments { HomePage = 'endowment:page-home', SettingsPage = 'endowment:page-settings', Assets = 'endowment:assets', + Protocol = 'endowment:protocol', } diff --git a/packages/snaps-rpc-methods/src/endowments/index.ts b/packages/snaps-rpc-methods/src/endowments/index.ts index e0767cedd7..c4d435d31e 100644 --- a/packages/snaps-rpc-methods/src/endowments/index.ts +++ b/packages/snaps-rpc-methods/src/endowments/index.ts @@ -27,6 +27,11 @@ import { nameLookupEndowmentBuilder, } from './name-lookup'; import { networkAccessEndowmentBuilder } from './network-access'; +import { + getProtocolCaveatMapper, + protocolCaveatSpecifications, + protocolEndowmentBuilder, +} from './protocol'; import { getRpcCaveatMapper, rpcCaveatSpecifications, @@ -58,6 +63,7 @@ export const endowmentPermissionBuilders = { [lifecycleHooksEndowmentBuilder.targetName]: lifecycleHooksEndowmentBuilder, [keyringEndowmentBuilder.targetName]: keyringEndowmentBuilder, [settingsPageEndowmentBuilder.targetName]: settingsPageEndowmentBuilder, + [protocolEndowmentBuilder.targetName]: protocolEndowmentBuilder, [homePageEndowmentBuilder.targetName]: homePageEndowmentBuilder, [signatureInsightEndowmentBuilder.targetName]: signatureInsightEndowmentBuilder, @@ -72,6 +78,7 @@ export const endowmentCaveatSpecifications = { ...keyringCaveatSpecifications, ...signatureInsightCaveatSpecifications, ...maxRequestTimeCaveatSpecifications, + ...protocolCaveatSpecifications, }; export const endowmentCaveatMappers: Record< @@ -92,6 +99,9 @@ export const endowmentCaveatMappers: Record< [keyringEndowmentBuilder.targetName]: createMaxRequestTimeMapper( getKeyringCaveatMapper, ), + [protocolEndowmentBuilder.targetName]: createMaxRequestTimeMapper( + getProtocolCaveatMapper, + ), [signatureInsightEndowmentBuilder.targetName]: createMaxRequestTimeMapper( getSignatureInsightCaveatMapper, ), @@ -118,6 +128,7 @@ export const handlerEndowments: Record = { [HandlerType.OnUserInput]: null, [HandlerType.OnAssetsLookup]: assetsEndowmentBuilder.targetName, [HandlerType.OnAssetsConversion]: assetsEndowmentBuilder.targetName, + [HandlerType.OnProtocolRequest]: protocolEndowmentBuilder.targetName, }; export * from './enum'; @@ -128,3 +139,4 @@ export { getChainIdsCaveat, getLookupMatchersCaveat } from './name-lookup'; export { getKeyringCaveatOrigins } from './keyring'; export { getMaxRequestTimeCaveat } from './caveats'; export { getCronjobCaveatJobs } from './cronjob'; +export { getProtocolCaveatScopes } from './protocol'; diff --git a/packages/snaps-rpc-methods/src/endowments/keyring.test.ts b/packages/snaps-rpc-methods/src/endowments/keyring.test.ts index e591189cfb..569b221778 100644 --- a/packages/snaps-rpc-methods/src/endowments/keyring.test.ts +++ b/packages/snaps-rpc-methods/src/endowments/keyring.test.ts @@ -23,6 +23,8 @@ describe('endowment:keyring', () => { subjectTypes: [SubjectType.Snap], validator: expect.any(Function), }); + + expect(specification.endowmentGetter()).toBeNull(); }); describe('validator', () => { diff --git a/packages/snaps-rpc-methods/src/endowments/protocol.test.ts b/packages/snaps-rpc-methods/src/endowments/protocol.test.ts new file mode 100644 index 0000000000..8d3862fa4b --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/protocol.test.ts @@ -0,0 +1,153 @@ +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { SnapCaveatType } from '@metamask/snaps-utils'; + +import { SnapEndowments } from './enum'; +import { + getProtocolCaveatMapper, + getProtocolCaveatScopes, + protocolCaveatSpecifications, + protocolEndowmentBuilder, +} from './protocol'; + +describe('endowment:protocol', () => { + it('builds the expected permission specification', () => { + const specification = protocolEndowmentBuilder.specificationBuilder({}); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetName: SnapEndowments.Protocol, + endowmentGetter: expect.any(Function), + allowedCaveats: [ + SnapCaveatType.ProtocolSnapScopes, + SnapCaveatType.MaxRequestTime, + ], + subjectTypes: [SubjectType.Snap], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeNull(); + }); + + describe('validator', () => { + it('throws if the caveat is not a single "protocolSnapScopes"', () => { + const specification = protocolEndowmentBuilder.specificationBuilder({}); + + expect(() => + specification.validator({ + // @ts-expect-error Missing other required permission types. + caveats: undefined, + }), + ).toThrow('Expected the following caveats: "protocolSnapScopes".'); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [{ type: 'foo', value: 'bar' }], + }), + ).toThrow( + 'Expected the following caveats: "protocolSnapScopes", "maxRequestTime", received "foo".', + ); + + expect(() => + // @ts-expect-error Missing other required permission types. + specification.validator({ + caveats: [ + { type: SnapCaveatType.ProtocolSnapScopes, value: 'bar' }, + { type: SnapCaveatType.ProtocolSnapScopes, value: 'bar' }, + ], + }), + ).toThrow('Duplicate caveats are not allowed.'); + }); + }); +}); + +describe('getProtocolCaveatMapper', () => { + it('maps a value to a caveat', () => { + expect( + getProtocolCaveatMapper({ + scopes: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + methods: ['getVersion'], + }, + }, + }), + ).toStrictEqual({ + caveats: [ + { + type: SnapCaveatType.ProtocolSnapScopes, + value: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + methods: ['getVersion'], + }, + }, + }, + ], + }); + }); + + it('returns null if value is empty', () => { + expect(getProtocolCaveatMapper({})).toStrictEqual({ caveats: null }); + }); +}); + +describe('getProtocolCaveatScopes', () => { + it('returns the scopes from the caveat', () => { + expect( + // @ts-expect-error Missing other required permission types. + getProtocolCaveatScopes({ + caveats: [ + { + type: SnapCaveatType.ProtocolSnapScopes, + value: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + methods: ['getVersion'], + }, + }, + }, + ], + }), + ).toStrictEqual({ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + methods: ['getVersion'], + }, + }); + }); + + it('returns null if no caveat found', () => { + expect( + // @ts-expect-error Missing other required permission types. + getProtocolCaveatScopes({ + caveats: null, + }), + ).toBeNull(); + }); +}); + +describe('protocolCaveatSpecifications', () => { + describe('validator', () => { + it('throws if the caveat values are invalid', () => { + expect(() => + protocolCaveatSpecifications[ + SnapCaveatType.ProtocolSnapScopes + ].validator?.( + // @ts-expect-error Missing value type. + { + type: SnapCaveatType.ProtocolSnapScopes, + }, + ), + ).toThrow('Expected a plain object.'); + + expect(() => + protocolCaveatSpecifications[ + SnapCaveatType.ProtocolSnapScopes + ].validator?.({ + type: SnapCaveatType.ProtocolSnapScopes, + value: { + foo: 'bar', + }, + }), + ).toThrow( + 'Invalid scopes specified: At path: foo -- Expected a string matching `/^(?[-a-z0-9]{3,8}):(?[-_a-zA-Z0-9]{1,32})$/` but received "foo".', + ); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/endowments/protocol.ts b/packages/snaps-rpc-methods/src/endowments/protocol.ts new file mode 100644 index 0000000000..9c4a529c91 --- /dev/null +++ b/packages/snaps-rpc-methods/src/endowments/protocol.ts @@ -0,0 +1,146 @@ +import type { + Caveat, + CaveatConstraint, + CaveatSpecificationConstraint, + EndowmentGetterParams, + PermissionConstraint, + PermissionSpecificationBuilder, + PermissionValidatorConstraint, + ValidPermissionSpecification, +} from '@metamask/permission-controller'; +import { PermissionType, SubjectType } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { ProtocolScopesStruct, SnapCaveatType } from '@metamask/snaps-utils'; +import type { Infer } from '@metamask/superstruct'; +import type { Json, NonEmptyArray } from '@metamask/utils'; +import { + assertStruct, + hasProperty, + isObject, + isPlainObject, +} from '@metamask/utils'; + +import { createGenericPermissionValidator } from './caveats'; +import { SnapEndowments } from './enum'; + +const permissionName = SnapEndowments.Protocol; + +type ProtocolEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof permissionName; + endowmentGetter: (_options?: EndowmentGetterParams) => null; + allowedCaveats: Readonly> | null; + validator: PermissionValidatorConstraint; + subjectTypes: readonly SubjectType[]; +}>; + +/** + * `endowment:protocol` returns nothing; it is intended to be used as a flag + * by the client to detect whether the Snap supports the Protocol API. + * + * @param _builderOptions - Optional specification builder options. + * @returns The specification for the accounts chain endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + any, + ProtocolEndowmentSpecification +> = (_builderOptions?: unknown) => { + return { + permissionType: PermissionType.Endowment, + targetName: permissionName, + allowedCaveats: [ + SnapCaveatType.ProtocolSnapScopes, + SnapCaveatType.MaxRequestTime, + ], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, + validator: createGenericPermissionValidator([ + { type: SnapCaveatType.ProtocolSnapScopes }, + { type: SnapCaveatType.MaxRequestTime, optional: true }, + ]), + subjectTypes: [SubjectType.Snap], + }; +}; + +export const protocolEndowmentBuilder = Object.freeze({ + targetName: permissionName, + specificationBuilder, +} as const); + +/** + * Map a raw value from the `initialPermissions` to a caveat specification. + * Note that this function does not do any validation, that's handled by the + * PermissionsController when the permission is requested. + * + * @param value - The raw value from the `initialPermissions`. + * @returns The caveat specification. + */ +export function getProtocolCaveatMapper( + value: Json, +): Pick { + if (!value || !isObject(value) || Object.keys(value).length === 0) { + return { caveats: null }; + } + + const caveats = []; + + if (value.scopes) { + caveats.push({ + type: SnapCaveatType.ProtocolSnapScopes, + value: value.scopes, + }); + } + + return { caveats: caveats as NonEmptyArray }; +} + +export type ProtocolScopes = Infer; + +/** + * Getter function to get the {@link ProtocolSnapScopes} caveat value from a + * permission. + * + * @param permission - The permission to get the caveat value from. + * @returns The caveat value. + */ +export function getProtocolCaveatScopes( + permission?: PermissionConstraint, +): ProtocolScopes | null { + const caveat = permission?.caveats?.find( + (permCaveat) => permCaveat.type === SnapCaveatType.ProtocolSnapScopes, + ) as Caveat | undefined; + + return caveat ? caveat.value : null; +} + +/** + * Validates the type of the caveat value. + * + * @param caveat - The caveat to validate. + * @throws If the caveat value is invalid. + */ +function validateCaveat(caveat: Caveat): void { + if (!hasProperty(caveat, 'value') || !isPlainObject(caveat)) { + throw rpcErrors.invalidParams({ + message: 'Expected a plain object.', + }); + } + + const { value } = caveat; + assertStruct( + value, + ProtocolScopesStruct, + 'Invalid scopes specified', + rpcErrors.invalidParams, + ); +} + +export const protocolCaveatSpecifications: Record< + SnapCaveatType.ProtocolSnapScopes, + CaveatSpecificationConstraint +> = { + [SnapCaveatType.ProtocolSnapScopes]: Object.freeze({ + type: SnapCaveatType.ProtocolSnapScopes, + validator: (caveat: Caveat) => validateCaveat(caveat), + }), +}; diff --git a/packages/snaps-rpc-methods/src/permissions.test.ts b/packages/snaps-rpc-methods/src/permissions.test.ts index 9b9486d2e4..ba39525e4d 100644 --- a/packages/snaps-rpc-methods/src/permissions.test.ts +++ b/packages/snaps-rpc-methods/src/permissions.test.ts @@ -104,6 +104,19 @@ describe('buildSnapEndowmentSpecifications', () => { ], "targetName": "endowment:page-settings", }, + "endowment:protocol": { + "allowedCaveats": [ + "protocolSnapScopes", + "maxRequestTime", + ], + "endowmentGetter": [Function], + "permissionType": "Endowment", + "subjectTypes": [ + "snap", + ], + "targetName": "endowment:protocol", + "validator": [Function], + }, "endowment:rpc": { "allowedCaveats": [ "rpcOrigin", diff --git a/packages/snaps-sdk/src/types/handlers/index.ts b/packages/snaps-sdk/src/types/handlers/index.ts index 33fd11ac75..5961304c08 100644 --- a/packages/snaps-sdk/src/types/handlers/index.ts +++ b/packages/snaps-sdk/src/types/handlers/index.ts @@ -5,6 +5,7 @@ export * from './home-page'; export * from './keyring'; export * from './lifecycle'; export * from './name-lookup'; +export * from './protocol'; export * from './rpc-request'; export * from './transaction'; export * from './signature'; diff --git a/packages/snaps-sdk/src/types/handlers/protocol.ts b/packages/snaps-sdk/src/types/handlers/protocol.ts new file mode 100644 index 0000000000..085d6c1b2c --- /dev/null +++ b/packages/snaps-sdk/src/types/handlers/protocol.ts @@ -0,0 +1,30 @@ +import type { + CaipChainId, + Json, + JsonRpcParams, + JsonRpcRequest, +} from '@metamask/utils'; + +/** + * The `onProtocolRequest` handler, which is called when a Snap receives a + * protocol request. + * + * Note that using this handler requires the `endowment:protocol` permission. + * + * @param args - The request arguments. + * @param args.origin - The origin of the request. This can be the ID of another + * Snap, or the URL of a website. + * @param args.scope - The scope of the request. + * @param args.request - The protocol request sent to the Snap. This includes + * the method name and parameters. + * @returns The response to the protocol request. This must be a + * JSON-serializable value. In order to return an error, throw a `SnapError` + * instead. + */ +export type OnProtocolRequestHandler< + Params extends JsonRpcParams = JsonRpcParams, +> = (args: { + origin: string; + scope: CaipChainId; + request: JsonRpcRequest; +}) => Promise; diff --git a/packages/snaps-simulation/src/methods/specifications.test.ts b/packages/snaps-simulation/src/methods/specifications.test.ts index a1be97a8d5..55007e456c 100644 --- a/packages/snaps-simulation/src/methods/specifications.test.ts +++ b/packages/snaps-simulation/src/methods/specifications.test.ts @@ -145,6 +145,19 @@ describe('getPermissionSpecifications', () => { ], "targetName": "endowment:page-settings", }, + "endowment:protocol": { + "allowedCaveats": [ + "protocolSnapScopes", + "maxRequestTime", + ], + "endowmentGetter": [Function], + "permissionType": "Endowment", + "subjectTypes": [ + "snap", + ], + "targetName": "endowment:protocol", + "validator": [Function], + }, "endowment:rpc": { "allowedCaveats": [ "rpcOrigin", diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index 701516c7b8..16cb70ee5a 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.74, - "functions": 98.94, - "lines": 99.46, - "statements": 96.32 + "functions": 98.95, + "lines": 99.47, + "statements": 96.27 } diff --git a/packages/snaps-utils/src/caveats.ts b/packages/snaps-utils/src/caveats.ts index 61bd80910e..8272be9bc8 100644 --- a/packages/snaps-utils/src/caveats.ts +++ b/packages/snaps-utils/src/caveats.ts @@ -53,4 +53,9 @@ export enum SnapCaveatType { * Caveat specifying the max request time for a handler endowment. */ MaxRequestTime = 'maxRequestTime', + + /** + * Caveat specifying a list of scopes serviced by an endowment. + */ + ProtocolSnapScopes = 'protocolSnapScopes', } diff --git a/packages/snaps-utils/src/handler-types.ts b/packages/snaps-utils/src/handler-types.ts index 392969898b..210ac70115 100644 --- a/packages/snaps-utils/src/handler-types.ts +++ b/packages/snaps-utils/src/handler-types.ts @@ -12,6 +12,7 @@ export enum HandlerType { OnUserInput = 'onUserInput', OnAssetsLookup = 'onAssetsLookup', OnAssetsConversion = 'onAssetsConversion', + OnProtocolRequest = 'onProtocolRequest', } export type SnapHandler = { diff --git a/packages/snaps-utils/src/handlers.ts b/packages/snaps-utils/src/handlers.ts index f2de2b6e2a..ef24aa4da7 100644 --- a/packages/snaps-utils/src/handlers.ts +++ b/packages/snaps-utils/src/handlers.ts @@ -1,9 +1,12 @@ import type { + OnAssetsConversionHandler, + OnAssetsLookupHandler, OnCronjobHandler, OnHomePageHandler, OnInstallHandler, OnKeyringRequestHandler, OnNameLookupHandler, + OnProtocolRequestHandler, OnRpcRequestHandler, OnSettingsPageHandler, OnSignatureHandler, @@ -114,14 +117,25 @@ export const SNAP_EXPORTS = { [HandlerType.OnAssetsLookup]: { type: HandlerType.OnAssetsLookup, required: true, - validator: (snapExport: unknown): snapExport is OnUserInputHandler => { + validator: (snapExport: unknown): snapExport is OnAssetsLookupHandler => { return typeof snapExport === 'function'; }, }, [HandlerType.OnAssetsConversion]: { type: HandlerType.OnAssetsConversion, required: true, - validator: (snapExport: unknown): snapExport is OnUserInputHandler => { + validator: ( + snapExport: unknown, + ): snapExport is OnAssetsConversionHandler => { + return typeof snapExport === 'function'; + }, + }, + [HandlerType.OnProtocolRequest]: { + type: HandlerType.OnProtocolRequest, + required: true, + validator: ( + snapExport: unknown, + ): snapExport is OnProtocolRequestHandler => { return typeof snapExport === 'function'; }, }, diff --git a/packages/snaps-utils/src/manifest/validation.ts b/packages/snaps-utils/src/manifest/validation.ts index 2721bb2cc8..2eabc2a769 100644 --- a/packages/snaps-utils/src/manifest/validation.ts +++ b/packages/snaps-utils/src/manifest/validation.ts @@ -27,6 +27,7 @@ import { isValidSemVerRange, inMilliseconds, Duration, + CaipChainIdStruct, } from '@metamask/utils'; import { isEqual } from '../array'; @@ -174,6 +175,11 @@ export const MaxRequestTimeStruct = size( MAXIMUM_REQUEST_TIMEOUT, ); +export const ProtocolScopesStruct = record( + CaipChainIdStruct, + object({ methods: array(string()) }), +); + // Utility type to union with for all handler structs export const HandlerCaveatsStruct = object({ maxRequestTime: optional(MaxRequestTimeStruct), @@ -201,6 +207,12 @@ export const PermissionsStruct: Describe = type({ 'endowment:keyring': optional( mergeStructs(HandlerCaveatsStruct, KeyringOriginsStruct), ), + 'endowment:protocol': optional( + mergeStructs( + HandlerCaveatsStruct, + object({ scopes: ProtocolScopesStruct }), + ), + ), 'endowment:lifecycle-hooks': optional(HandlerCaveatsStruct), 'endowment:name-lookup': optional( mergeStructs(