diff --git a/packages/examples/packages/ethereum-provider/snap.manifest.json b/packages/examples/packages/ethereum-provider/snap.manifest.json index 07effdfc1d..76cec8609b 100644 --- a/packages/examples/packages/ethereum-provider/snap.manifest.json +++ b/packages/examples/packages/ethereum-provider/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "EBjcd8iJ6rPwGXcEmAhWGJWHh/m0RV8X+eid/HIUjEY=", + "shasum": "cKFOaKJv2NJ7UOWRzhpP1fIq40SPyjhd1sG42AceRBY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/ethereum-provider/src/index.ts b/packages/examples/packages/ethereum-provider/src/index.ts index 0f10af988a..712cfab616 100644 --- a/packages/examples/packages/ethereum-provider/src/index.ts +++ b/packages/examples/packages/ethereum-provider/src/index.ts @@ -10,7 +10,23 @@ import { hexToNumber, } from '@metamask/utils'; -import type { PersonalSignParams, SignTypedDataParams } from './types'; +import type { + BaseParams, + PersonalSignParams, + SignTypedDataParams, +} from './types'; + +/** + * Set the active Ethereum chain for the Snap. + * + * @param chainId - The chain ID to switch to. + */ +async function switchChain(chainId: Hex) { + await ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId }], + }); +} /** * Get the current gas price using the `ethereum` global. This is essentially @@ -207,6 +223,9 @@ async function signTypedData(message: string, from: string) { * @see https://docs.metamask.io/snaps/reference/rpc-api/#wallet_invokesnap */ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + const { chainId = '0x1' } = (request.params as BaseParams) ?? {}; + await switchChain(chainId); + switch (request.method) { case 'getGasPrice': return await getGasPrice(); diff --git a/packages/examples/packages/ethereum-provider/src/types.ts b/packages/examples/packages/ethereum-provider/src/types.ts index 807c7ef54c..6e580fb82d 100644 --- a/packages/examples/packages/ethereum-provider/src/types.ts +++ b/packages/examples/packages/ethereum-provider/src/types.ts @@ -1,4 +1,10 @@ -export type PersonalSignParams = { +import type { Hex } from '@metamask/utils'; + +export type BaseParams = { + chainId?: Hex; +}; + +export type PersonalSignParams = BaseParams & { message: string; }; diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index b8cc8451e6..a765a98b07 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93.64, + "branches": 93.67, "functions": 98.16, - "lines": 98.5, + "lines": 98.51, "statements": 98.34 } diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 84a2117975..89fc0d9331 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -5696,6 +5696,349 @@ describe('SnapController', () => { snapController.destroy(); }); + it('grants the `endowment:caip25` permission to a Snap with `endowment:ethereum-provider` if the `useCaip25Permission` feature flag is enabled', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({}), + ); + + rootMessenger.registerActionHandler( + 'SelectedNetworkController:getNetworkClientIdForDomain', + () => 'mainnet', + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => ({ + configuration: { + chainId: '0x1', + }, + }), + ); + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:page-home': {}, + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + detectSnapLocation: loopbackDetect({ manifest }), + featureFlags: { + useCaip25Permission: true, + }, + }), + ); + + await snapController.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }); + + const approvedPermissions = { + 'endowment:page-home': { + caveats: null, + }, + 'endowment:ethereum-provider': {}, + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + expect(messenger.call).toHaveBeenCalledWith( + 'PermissionController:grantPermissions', + { + approvedPermissions, + subject: { origin: MOCK_SNAP_ID }, + requestData: expect.any(Object), + }, + ); + + snapController.destroy(); + }); + + it('grants the `endowment:caip25` permission when updating a Snap with `endowment:ethereum-provider` if the `useCaip25Permission` feature flag is enabled', async () => { + const newVersion = '1.0.2'; + const newVersionRange = '>=1.0.1'; + + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + + rootMessenger.registerActionHandler( + 'SelectedNetworkController:getNetworkClientIdForDomain', + () => 'mainnet', + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => ({ + configuration: { + chainId: '0x1', + }, + }), + ); + + const { manifest: originalManifest } = + await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:page-home': {}, + }, + }), + }); + + const { manifest: updatedManifest } = + await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: newVersion, + initialPermissions: { + 'endowment:page-home': {}, + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const detectLocationMock = jest + .fn() + .mockImplementationOnce( + () => + new LoopbackLocation({ + manifest: originalManifest, + }), + ) + .mockImplementationOnce( + () => + new LoopbackLocation({ + manifest: updatedManifest, + }), + ); + + const controller = getSnapController( + getSnapControllerOptions({ + messenger, + detectSnapLocation: detectLocationMock, + featureFlags: { + useCaip25Permission: true, + }, + }), + ); + + await controller.installSnaps(MOCK_ORIGIN, { [MOCK_SNAP_ID]: {} }); + await controller.stopSnap(MOCK_SNAP_ID); + + const approvedPermissions = { + 'endowment:page-home': { + caveats: null, + }, + }; + + expect(messenger.call).toHaveBeenCalledWith( + 'PermissionController:grantPermissions', + { + approvedPermissions, + subject: { origin: MOCK_SNAP_ID }, + requestData: expect.any(Object), + }, + ); + + jest.mocked(messenger.call).mockClear(); + + const result = await controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: { version: newVersionRange }, + }); + + const updatedApprovedPermissions = { + 'endowment:page-home': { + caveats: null, + }, + 'endowment:ethereum-provider': {}, + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + expect(messenger.call).toHaveBeenCalledWith( + 'PermissionController:grantPermissions', + { + approvedPermissions: updatedApprovedPermissions, + subject: { origin: MOCK_SNAP_ID }, + requestData: expect.any(Object), + }, + ); + + expect(result).toStrictEqual({ + [MOCK_SNAP_ID]: getTruncatedSnap({ + version: newVersion, + initialPermissions: updatedManifest.result.initialPermissions, + }), + }); + + controller.destroy(); + }); + + it('does not grant the `endowment:caip25` permission to a Snap with `endowment:ethereum-provider` if the `useCaip25Permission` feature flag is disabled', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({}), + ); + + rootMessenger.registerActionHandler( + 'SelectedNetworkController:getNetworkClientIdForDomain', + () => 'mainnet', + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => ({ + configuration: { + chainId: '0x1', + }, + }), + ); + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:page-home': {}, + 'endowment:ethereum-provider': {}, + }, + }), + }); + + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + detectSnapLocation: loopbackDetect({ manifest }), + featureFlags: { + useCaip25Permission: false, + }, + }), + ); + + await snapController.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }); + + const approvedPermissions = { + 'endowment:page-home': { + caveats: null, + }, + 'endowment:ethereum-provider': {}, + }; + + expect(messenger.call).toHaveBeenCalledWith( + 'PermissionController:grantPermissions', + { + approvedPermissions, + subject: { origin: MOCK_SNAP_ID }, + requestData: expect.any(Object), + }, + ); + + snapController.destroy(); + }); + + it('does not grant the `endowment:caip25` permission if the Snap does not have the `endowment:ethereum-provider` permission', async () => { + const rootMessenger = getControllerMessenger(); + const messenger = getSnapControllerMessenger(rootMessenger); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + () => ({}), + ); + + rootMessenger.registerActionHandler( + 'SelectedNetworkController:getNetworkClientIdForDomain', + () => { + throw new Error('This should not be called.'); + }, + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => { + throw new Error('This should not be called.'); + }, + ); + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + initialPermissions: { + 'endowment:page-home': {}, + }, + }), + }); + + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + detectSnapLocation: loopbackDetect({ manifest }), + featureFlags: { + useCaip25Permission: true, + }, + }), + ); + + await snapController.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }); + + const approvedPermissions = { + 'endowment:page-home': { + caveats: null, + }, + }; + + expect(messenger.call).toHaveBeenCalledWith( + 'PermissionController:grantPermissions', + { + approvedPermissions, + subject: { origin: MOCK_SNAP_ID }, + requestData: expect.any(Object), + }, + ); + + snapController.destroy(); + }); + it('supports preinstalled snaps', async () => { const rootMessenger = getControllerMessenger(); jest.spyOn(rootMessenger, 'call'); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index c7739fba54..df6d75ca31 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -112,8 +112,10 @@ import type { SemVerRange, CaipAssetType, JsonRpcRequest, + Hex, } from '@metamask/utils'; import { + hexToNumber, assert, assertIsJsonRpcRequest, assertStruct, @@ -619,6 +621,20 @@ export type SnapControllerEvents = | SnapDisabled | SnapControllerStateChangeEvent; +type NetworkControllerGetNetworkClientById = { + type: `NetworkController:getNetworkClientById`; + handler: (customNetworkClientId: string) => { + configuration: { + chainId: Hex; + }; + }; +}; + +type SelectedNetworkControllerGetNetworkClientIdForDomain = { + type: `SelectedNetworkController:getNetworkClientIdForDomain`; + handler: (domain: string) => string; +}; + export type AllowedActions = | GetEndowments | GetPermissions @@ -643,7 +659,9 @@ export type AllowedActions = | Update | ResolveVersion | CreateInterface - | GetInterface; + | GetInterface + | NetworkControllerGetNetworkClientById + | SelectedNetworkControllerGetNetworkClientIdForDomain; export type AllowedEvents = | ExecutionServiceEvents @@ -664,6 +682,7 @@ type FeatureFlags = { allowLocalSnaps?: boolean; disableSnapInstallation?: boolean; rejectInvalidPlatformVersion?: boolean; + useCaip25Permission?: boolean; }; type DynamicFeatureFlags = { @@ -4195,7 +4214,62 @@ export class SnapController extends BaseController< } /** - * Updates the permissions for a snap following an install, update or rollback. + * Get the permissions to grant to a Snap following an install, update or + * rollback. + * + * @param snapId - The snap ID. + * @param newPermissions - The new permissions to be granted. + * @returns The permissions to grant to the Snap. + */ + #getPermissionsToGrant(snapId: SnapId, newPermissions: RequestedPermissions) { + if ( + this.#featureFlags.useCaip25Permission && + Object.keys(newPermissions).includes(SnapEndowments.EthereumProvider) + ) { + // This will return the globally selected network if the Snap doesn't have + // one set. + const networkClientId = this.messagingSystem.call( + 'SelectedNetworkController:getNetworkClientIdForDomain', + snapId, + ); + + const { configuration } = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + const chainId = hexToNumber(configuration.chainId); + + // This needs to be assigned to have proper type inference. + const modifiedPermissions: RequestedPermissions = { + ...newPermissions, + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + [`eip155:${chainId}`]: { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }; + + return modifiedPermissions; + } + + return newPermissions; + } + + /** + * Update the permissions for a snap following an install, update or rollback. * * Grants newly requested permissions and revokes unused/revoked permissions. * @@ -4228,8 +4302,13 @@ export class SnapController extends BaseController< } if (isNonEmptyArray(Object.keys(newPermissions))) { + const approvedPermissions = this.#getPermissionsToGrant( + snapId, + newPermissions, + ); + this.messagingSystem.call('PermissionController:grantPermissions', { - approvedPermissions: newPermissions, + approvedPermissions, subject: { origin: snapId }, requestData, }); diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 3cff5c0499..3d7588156a 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -487,6 +487,7 @@ export const getSnapControllerMessenger = ( 'ExecutionService:terminateAllSnaps', 'ExecutionService:terminateSnap', 'ExecutionService:handleRpcRequest', + 'NetworkController:getNetworkClientById', 'PermissionController:getEndowments', 'PermissionController:hasPermission', 'PermissionController:hasPermissions', @@ -498,6 +499,7 @@ export const getSnapControllerMessenger = ( 'PermissionController:updateCaveat', 'PermissionController:getSubjectNames', 'PhishingController:testOrigin', + 'SelectedNetworkController:getNetworkClientIdForDomain', 'SnapController:get', 'SnapController:handleRequest', 'SnapController:getSnapState', diff --git a/packages/snaps-execution-environments/src/common/utils.ts b/packages/snaps-execution-environments/src/common/utils.ts index 96a414bb6c..72e5ff343d 100644 --- a/packages/snaps-execution-environments/src/common/utils.ts +++ b/packages/snaps-execution-environments/src/common/utils.ts @@ -57,7 +57,6 @@ export const BLOCKED_RPC_METHODS = Object.freeze([ 'eth_decrypt', 'eth_getEncryptionPublicKey', 'wallet_addEthereumChain', - 'wallet_switchEthereumChain', 'wallet_watchAsset', 'wallet_registerOnboarding', 'wallet_scanQRCode', diff --git a/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts b/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts index 777ef497b9..6465e2a048 100644 --- a/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts +++ b/packages/snaps-simulation/src/middleware/internal-methods/middleware.ts @@ -6,6 +6,7 @@ import { getAccountsHandler } from './accounts'; import { getChainIdHandler } from './chain-id'; import { getNetworkVersionHandler } from './net-version'; import { getProviderStateHandler } from './provider-state'; +import { getSwitchEthereumChainHandler } from './switch-ethereum-chain'; export type InternalMethodsMiddlewareHooks = { /** @@ -23,6 +24,7 @@ const methodHandlers = { eth_accounts: getAccountsHandler, eth_chainId: getChainIdHandler, net_version: getNetworkVersionHandler, + wallet_switchEthereumChain: getSwitchEthereumChainHandler, /* eslint-enable @typescript-eslint/naming-convention */ }; diff --git a/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.test.ts b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.test.ts new file mode 100644 index 0000000000..4732d53dcb --- /dev/null +++ b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.test.ts @@ -0,0 +1,28 @@ +import type { PendingJsonRpcResponse } from '@metamask/utils'; + +import { getSwitchEthereumChainHandler } from './switch-ethereum-chain'; + +describe('getSwitchEthereumChainHandler', () => { + it('returns `null`', async () => { + const end = jest.fn(); + const result: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 1, + }; + + await getSwitchEthereumChainHandler( + { + jsonrpc: '2.0', + id: 1, + method: 'wallet_switchEthereumChain', + params: [], + }, + result, + jest.fn(), + end, + ); + + expect(end).toHaveBeenCalled(); + expect(result.result).toBeNull(); + }); +}); diff --git a/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.ts b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.ts new file mode 100644 index 0000000000..3681955df2 --- /dev/null +++ b/packages/snaps-simulation/src/middleware/internal-methods/switch-ethereum-chain.ts @@ -0,0 +1,27 @@ +import type { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +/** + * A mock handler for the `wallet_switchEthereumChain` method that always + * returns `null`. + * + * @param _request - Incoming JSON-RPC request. This is ignored for this + * specific handler. + * @param response - The outgoing JSON-RPC response, modified to return the + * result. + * @param _next - The `json-rpc-engine` middleware next handler. + * @param end - The `json-rpc-engine` middleware end handler. + * @returns The response. + */ +export async function getSwitchEthereumChainHandler( + _request: JsonRpcRequest, + response: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, +) { + response.result = null; + return end(); +} diff --git a/packages/test-snaps/src/features/snaps/ethereum-provider/EthereumProvider.tsx b/packages/test-snaps/src/features/snaps/ethereum-provider/EthereumProvider.tsx index 6817e2b0e6..11cb30be59 100644 --- a/packages/test-snaps/src/features/snaps/ethereum-provider/EthereumProvider.tsx +++ b/packages/test-snaps/src/features/snaps/ethereum-provider/EthereumProvider.tsx @@ -1,8 +1,10 @@ import { logError } from '@metamask/snaps-utils'; +import { numberToHex } from '@metamask/utils'; import type { FunctionComponent } from 'react'; +import { useState } from 'react'; import { Button, ButtonGroup } from 'react-bootstrap'; -import { SignMessage, SignTypedData } from './components'; +import { SignMessage, SignTypedData, SwitchChain } from './components'; import { ETHEREUM_PROVIDER_SNAP_ID, ETHEREUM_PROVIDER_SNAP_PORT, @@ -14,11 +16,15 @@ import { getSnapId } from '../../../utils'; export const EthereumProvider: FunctionComponent = () => { const [invokeSnap, { isLoading, data, error }] = useInvokeMutation(); + const [chainId, setChainId] = useState(1); const handleSubmit = (method: string) => { invokeSnap({ snapId: getSnapId(ETHEREUM_PROVIDER_SNAP_ID, ETHEREUM_PROVIDER_SNAP_PORT), method, + params: { + chainId: numberToHex(chainId), + }, }).catch(logError); }; @@ -33,6 +39,7 @@ export const EthereumProvider: FunctionComponent = () => { version={ETHEREUM_PROVIDER_VERSION} testId="ethereum-provider" > +