diff --git a/.storybook/test-data.js b/.storybook/test-data.js index 8ff5c0bea3db..0b920470526f 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -1424,17 +1424,27 @@ const state = { subjects: { 'https://app.uniswap.org': { permissions: { - eth_accounts: { - invoker: 'https://app.uniswap.org', - parentCapability: 'eth_accounts', - id: 'a7342e4b-beae-4525-a36c-c0635fd03359', - date: 1620710693178, + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x64a845a5b02460acf8a3d84503b0d68d028b4bb4'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x64a845a5b02460acf8a3d84503b0d68d028b4bb4', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], + invoker: 'https://app.uniswap.org', + id: 'a7342e4b-beae-4525-a36c-c0635fd03359', + date: 1620710693178, + parentCapability: 'endowment:caip25', }, }, }, diff --git a/.yarn/patches/@json-schema-spec-json-pointer-npm-0.1.2-3d06119887.patch b/.yarn/patches/@json-schema-spec-json-pointer-npm-0.1.2-3d06119887.patch new file mode 100644 index 000000000000..4eddae30359d --- /dev/null +++ b/.yarn/patches/@json-schema-spec-json-pointer-npm-0.1.2-3d06119887.patch @@ -0,0 +1,13 @@ +diff --git a/lib/index.js b/lib/index.js +index f5795884311124b221d91f488ed45750eb6e9c80..e030d6f8d8e85e6d1350c565d36ad48bc49af881 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -25,7 +25,7 @@ class Ptr { + }); + return `/${tokens.join("/")}`; + } +- eval(instance) { ++ shmeval(instance) { + for (const token of this.tokens) { + if (instance.hasOwnProperty(token)) { + instance = instance[token]; diff --git a/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch b/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch new file mode 100644 index 000000000000..2ff663fa18e4 --- /dev/null +++ b/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch @@ -0,0 +1,13 @@ +diff --git a/build/resolve-pointer.js b/build/resolve-pointer.js +index d5a8ec7486250cd17572eb0e0449725643fc9842..044e74bb51a46e9bf3547f6d7a84763b93260613 100644 +--- a/build/resolve-pointer.js ++++ b/build/resolve-pointer.js +@@ -27,7 +27,7 @@ exports.default = (function (ref, root) { + try { + var withoutHash = ref.replace("#", ""); + var pointer = json_pointer_1.default.parse(withoutHash); +- return pointer.eval(root); ++ return pointer.shmeval(root); + } + catch (e) { + throw new InvalidJsonPointerRefError(ref, e.message); diff --git a/app/scripts/background.js b/app/scripts/background.js index 029adf026841..700db8127a97 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -677,13 +677,9 @@ function emitDappViewedMetricEvent(origin) { return; } - const permissions = controller.controllerMessenger.call( - 'PermissionController:getPermissions', - origin, - ); const numberOfConnectedAccounts = - permissions?.eth_accounts?.caveats[0]?.value.length; - if (!numberOfConnectedAccounts) { + controller.getPermittedAccounts(origin).length; + if (numberOfConnectedAccounts === 0) { return; } diff --git a/app/scripts/controllers/permissions/background-api.js b/app/scripts/controllers/permissions/background-api.js index 8a0942667f17..62b32a072e68 100644 --- a/app/scripts/controllers/permissions/background-api.js +++ b/app/scripts/controllers/permissions/background-api.js @@ -1,29 +1,157 @@ import { nanoid } from 'nanoid'; import { - CaveatTypes, - RestrictedMethods, -} from '../../../../shared/constants/permissions'; -import { CaveatFactories, PermissionNames } from './specifications'; + MethodNames, + PermissionDoesNotExistError, +} from '@metamask/permission-controller'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getEthAccounts, + setEthAccounts, + getPermittedEthChainIds, + setPermittedEthChainIds, +} from '@metamask/multichain'; +import { isSnapId } from '@metamask/snaps-utils'; +import { RestrictedMethods } from '../../../../shared/constants/permissions'; +import { PermissionNames } from './specifications'; + +export function getPermissionBackgroundApiMethods({ + permissionController, + approvalController, +}) { + // Returns the CAIP-25 caveat or undefined if it does not exist + const getCaip25Caveat = (origin) => { + let caip25Caveat; + try { + caip25Caveat = permissionController.getCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (err) { + if (err instanceof PermissionDoesNotExistError) { + // suppress expected error in case that the origin + // does not have the target permission yet + } else { + throw err; + } + } + return caip25Caveat; + }; -export function getPermissionBackgroundApiMethods(permissionController) { + // To add more than one account when already connected to the dapp const addMoreAccounts = (origin, accounts) => { - const caveat = CaveatFactories.restrictReturnedAccounts(accounts); + const caip25Caveat = getCaip25Caveat(origin); + if (!caip25Caveat) { + throw new Error( + `Cannot add account permissions for origin "${origin}": no permission currently exists for this origin.`, + ); + } - permissionController.grantPermissionsIncremental({ - subject: { origin }, - approvedPermissions: { - [RestrictedMethods.eth_accounts]: { caveats: [caveat] }, - }, - }); + const ethAccounts = getEthAccounts(caip25Caveat.value); + + const updatedEthAccounts = Array.from( + new Set([...ethAccounts, ...accounts]), + ); + + const updatedCaveatValue = setEthAccounts( + caip25Caveat.value, + updatedEthAccounts, + ); + + permissionController.updateCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + updatedCaveatValue, + ); }; const addMoreChains = (origin, chainIds) => { - const caveat = CaveatFactories.restrictNetworkSwitching(chainIds); + const caip25Caveat = getCaip25Caveat(origin); + if (!caip25Caveat) { + throw new Error( + `Cannot add chain permissions for origin "${origin}": no permission currently exists for this origin.`, + ); + } + + const ethChainIds = getPermittedEthChainIds(caip25Caveat.value); + + const updatedEthChainIds = Array.from( + new Set([...ethChainIds, ...chainIds]), + ); + + const caveatValueWithChains = setPermittedEthChainIds( + caip25Caveat.value, + updatedEthChainIds, + ); + + // ensure that the list of permitted eth accounts is set for the newly added eth scopes + const ethAccounts = getEthAccounts(caveatValueWithChains); + const caveatValueWithAccountsSynced = setEthAccounts( + caveatValueWithChains, + ethAccounts, + ); + + permissionController.updateCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + caveatValueWithAccountsSynced, + ); + }; + + const requestAccountsAndChainPermissions = async (origin, id) => { + // Note that we are purposely requesting an approval from the ApprovalController + // and then manually forming the permission that is then granted via the + // PermissionController rather than calling the PermissionController.requestPermissions() + // directly because the Approval UI is still dependent on the notion of there + // being separate "eth_accounts" and "endowment:permitted-chains" permissions. + // After that depedency is refactored, we can move to requesting "endowment:caip25" + // directly from the PermissionController instead. + const legacyApproval = await approvalController.addAndShowApprovalRequest({ + id, + origin, + requestData: { + metadata: { + id, + origin, + }, + permissions: { + [RestrictedMethods.eth_accounts]: {}, + [PermissionNames.permittedChains]: {}, + }, + }, + type: MethodNames.RequestPermissions, + }); + + const newCaveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const caveatValueWithChains = setPermittedEthChainIds( + newCaveatValue, + legacyApproval.approvedChainIds, + ); + + const caveatValueWithAccounts = setEthAccounts( + caveatValueWithChains, + legacyApproval.approvedAccounts, + ); - permissionController.grantPermissionsIncremental({ + permissionController.grantPermissions({ subject: { origin }, approvedPermissions: { - [PermissionNames.permittedChains]: { caveats: [caveat] }, + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccounts, + }, + ], + }, }, }); }; @@ -31,15 +159,19 @@ export function getPermissionBackgroundApiMethods(permissionController) { return { addPermittedAccount: (origin, account) => addMoreAccounts(origin, [account]), + addPermittedAccounts: (origin, accounts) => addMoreAccounts(origin, accounts), removePermittedAccount: (origin, account) => { - const { value: existingAccounts } = permissionController.getCaveat( - origin, - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, - ); + const caip25Caveat = getCaip25Caveat(origin); + if (!caip25Caveat) { + throw new Error( + `Cannot remove account "${account}": No permissions exist for origin "${origin}".`, + ); + } + + const existingAccounts = getEthAccounts(caip25Caveat.value); const remainingAccounts = existingAccounts.filter( (existingAccount) => existingAccount !== account, @@ -52,73 +184,66 @@ export function getPermissionBackgroundApiMethods(permissionController) { if (remainingAccounts.length === 0) { permissionController.revokePermission( origin, - RestrictedMethods.eth_accounts, + Caip25EndowmentPermissionName, ); } else { + const updatedCaveatValue = setEthAccounts( + caip25Caveat.value, + remainingAccounts, + ); permissionController.updateCaveat( origin, - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, - remainingAccounts, + Caip25EndowmentPermissionName, + Caip25CaveatType, + updatedCaveatValue, ); } }, addPermittedChain: (origin, chainId) => addMoreChains(origin, [chainId]), + addPermittedChains: (origin, chainIds) => addMoreChains(origin, chainIds), removePermittedChain: (origin, chainId) => { - const { value: existingChains } = permissionController.getCaveat( - origin, - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, - ); + const caip25Caveat = getCaip25Caveat(origin); + if (!caip25Caveat) { + throw new Error( + `Cannot remove permission for chainId "${chainId}": No permissions exist for origin "${origin}".`, + ); + } - const remainingChains = existingChains.filter( - (existingChain) => existingChain !== chainId, + const existingEthChainIds = getPermittedEthChainIds(caip25Caveat.value); + + const remainingChainIds = existingEthChainIds.filter( + (existingChainId) => existingChainId !== chainId, ); - if (remainingChains.length === existingChains.length) { + if (remainingChainIds.length === existingEthChainIds.length) { return; } - if (remainingChains.length === 0) { + if (remainingChainIds.length === 0 && !isSnapId(origin)) { permissionController.revokePermission( origin, - PermissionNames.permittedChains, + Caip25EndowmentPermissionName, ); } else { + const updatedCaveatValue = setPermittedEthChainIds( + caip25Caveat.value, + remainingChainIds, + ); permissionController.updateCaveat( origin, - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, - remainingChains, + Caip25EndowmentPermissionName, + Caip25CaveatType, + updatedCaveatValue, ); } }, - requestAccountsAndChainPermissionsWithId: async (origin) => { + requestAccountsAndChainPermissionsWithId: (origin) => { const id = nanoid(); - permissionController.requestPermissions( - { origin }, - { - [PermissionNames.eth_accounts]: {}, - [PermissionNames.permittedChains]: {}, - }, - { id }, - ); - return id; - }, - - requestAccountsPermissionWithId: async (origin) => { - const id = nanoid(); - permissionController.requestPermissions( - { origin }, - { - eth_accounts: {}, - }, - { id }, - ); + requestAccountsAndChainPermissions(origin, id); return id; }, }; diff --git a/app/scripts/controllers/permissions/background-api.test.js b/app/scripts/controllers/permissions/background-api.test.js index 2a050b29a00e..74a357f35f52 100644 --- a/app/scripts/controllers/permissions/background-api.test.js +++ b/app/scripts/controllers/permissions/background-api.test.js @@ -1,390 +1,962 @@ import { - CaveatTypes, - RestrictedMethods, -} from '../../../../shared/constants/permissions'; + MethodNames, + PermissionDoesNotExistError, +} from '@metamask/permission-controller'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; +import { RestrictedMethods } from '../../../../shared/constants/permissions'; +import { flushPromises } from '../../../../test/lib/timer-helpers'; import { getPermissionBackgroundApiMethods } from './background-api'; -import { CaveatFactories, PermissionNames } from './specifications'; +import { PermissionNames } from './specifications'; describe('permission background API methods', () => { - const getEthAccountsPermissions = (accounts) => ({ - [RestrictedMethods.eth_accounts]: { - caveats: [CaveatFactories.restrictReturnedAccounts(accounts)], - }, - }); - - const getPermittedChainsPermissions = (chainIds) => ({ - [PermissionNames.permittedChains]: { - caveats: [CaveatFactories.restrictNetworkSwitching(chainIds)], - }, + afterEach(() => { + jest.resetAllMocks(); }); describe('addPermittedAccount', () => { - it('calls grantPermissionsIncremental with expected parameters', () => { + it('gets the CAIP-25 caveat', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( - permissionController, - ).addPermittedAccount('foo.com', '0x1'); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getEthAccountsPermissions(['0x1']), - }); + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccount('foo.com', '0x1'); + } catch (err) { + // noop + } + + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); }); - }); - describe('addPermittedAccounts', () => { - it('calls grantPermissionsIncremental with expected parameters for single account', () => { + it('throws an error if there is no existing CAIP-25 caveat', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn().mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }), }; - getPermissionBackgroundApiMethods( - permissionController, - ).addPermittedAccounts('foo.com', ['0x1']); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getEthAccountsPermissions(['0x1']), - }); + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccount('foo.com', '0x1'), + ).toThrow( + new Error( + `Cannot add account permissions for origin "foo.com": no permission currently exists for this origin.`, + ), + ); }); - it('calls grantPermissionsIncremental with expected parameters with multiple accounts', () => { + it('throws an error if getCaveat fails unexpectedly', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn().mockImplementation(() => { + throw new Error('unexpected getCaveat error'); + }), }; - getPermissionBackgroundApiMethods( - permissionController, - ).addPermittedAccounts('foo.com', ['0x1', '0x2']); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getEthAccountsPermissions(['0x1', '0x2']), - }); + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccount('foo.com', '0x1'), + ).toThrow(new Error(`unexpected getCaveat error`)); }); - }); - describe('removePermittedAccount', () => { - it('removes a permitted account', () => { + it('calls updateCaveat with the account added', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1', '0x2'], - }; + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x2', 'eip155:1:0x3'], + }, + }, + isMultichainOrigin: true, + }, }), - revokePermission: jest.fn(), updateCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).removePermittedAccount('foo.com', '0x2'); + }).addPermittedAccount('foo.com', '0x4'); - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.getCaveat).toHaveBeenCalledWith( + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( 'foo.com', - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x1', + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x4', + ], + }, + 'eip155:10': { + accounts: [ + 'eip155:10:0x1', + 'eip155:10:0x2', + 'eip155:10:0x3', + 'eip155:10:0x4', + ], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: [ + 'eip155:1:0x1', + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x4', + ], + }, + }, + isMultichainOrigin: true, + }, ); + }); + }); - expect(permissionController.revokePermission).not.toHaveBeenCalled(); + describe('addPermittedAccounts', () => { + it('gets the CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn(), + }; - expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.updateCaveat).toHaveBeenCalledWith( + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccounts('foo.com', ['0x1']); + } catch (err) { + // noop + } + + expect(permissionController.getCaveat).toHaveBeenCalledWith( 'foo.com', - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, - ['0x1'], + Caip25EndowmentPermissionName, + Caip25CaveatType, ); }); - it('revokes the accounts permission if the removed account is the only permitted account', () => { + it('throws an error if there is no existing CAIP-25 caveat', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1'], - }; + getCaveat: jest.fn().mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccounts('foo.com', ['0x1']), + ).toThrow( + new Error( + `Cannot add account permissions for origin "foo.com": no permission currently exists for this origin.`, + ), + ); + }); + + it('throws an error if getCaveat fails unexpectedly', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementation(() => { + throw new Error('unexpected getCaveat error'); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccounts('foo.com', ['0x1']), + ).toThrow(new Error(`unexpected getCaveat error`)); + }); + + it('calls updateCaveat with the accounts added to only eip155 scopes and all accounts for eip155 scopes synced', () => { + const permissionController = { + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x2', 'eip155:1:0x3'], + }, + }, + isMultichainOrigin: true, + }, }), - revokePermission: jest.fn(), updateCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).removePermittedAccount('foo.com', '0x1'); + }).addPermittedAccounts('foo.com', ['0x4', '0x5']); - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.getCaveat).toHaveBeenCalledWith( + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( 'foo.com', - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x1', + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x4', + 'eip155:1:0x5', + ], + }, + 'eip155:10': { + accounts: [ + 'eip155:10:0x1', + 'eip155:10:0x2', + 'eip155:10:0x3', + 'eip155:10:0x4', + 'eip155:10:0x5', + ], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: [ + 'eip155:1:0x1', + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x4', + 'eip155:1:0x5', + ], + }, + }, + isMultichainOrigin: true, + }, ); + }); + }); - expect(permissionController.revokePermission).toHaveBeenCalledTimes(1); - expect(permissionController.revokePermission).toHaveBeenCalledWith( + describe('removePermittedAccount', () => { + it('gets the CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn(), + }; + + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedAccount('foo.com', '0x1'); + } catch (err) { + // noop + } + + expect(permissionController.getCaveat).toHaveBeenCalledWith( 'foo.com', - RestrictedMethods.eth_accounts, + Caip25EndowmentPermissionName, + Caip25CaveatType, ); + }); - expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + it('throws an error if there is no existing CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedAccount('foo.com', '0x1'), + ).toThrow( + new Error( + `Cannot remove account "0x1": No permissions exist for origin "foo.com".`, + ), + ); }); - it('does not call permissionController.updateCaveat if the specified account is not permitted', () => { + it('throws an error if getCaveat fails unexpectedly', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; + getCaveat: jest.fn().mockImplementation(() => { + throw new Error('unexpected getCaveat error'); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedAccount('foo.com', '0x1'), + ).toThrow(new Error(`unexpected getCaveat error`)); + }); + + it('does nothing if the account being removed does not exist', () => { + const permissionController = { + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x2', 'eip155:1:0x3'], + }, + }, + isMultichainOrigin: true, + }, }), - revokePermission: jest.fn(), updateCaveat: jest.fn(), + revokePermission: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).removePermittedAccount('foo.com', '0x2'); - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.getCaveat).toHaveBeenCalledWith( - 'foo.com', - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, - ); + }).removePermittedAccount('foo.com', '0xdeadbeef'); - expect(permissionController.revokePermission).not.toHaveBeenCalled(); expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + expect(permissionController.revokePermission).not.toHaveBeenCalled(); }); - }); - describe('requestAccountsPermissionWithId', () => { - it('request an accounts permission and returns the request id', async () => { + it('revokes the entire permission if the removed account is the only eip:155 scoped account', () => { const permissionController = { - requestPermissions: jest - .fn() - .mockImplementationOnce(async (_, __, { id }) => { - return [null, { id }]; - }), + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + isMultichainOrigin: true, + }, + }), + revokePermission: jest.fn(), }; - const id = await getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).requestAccountsPermissionWithId('foo.com'); + }).removePermittedAccount('foo.com', '0x1'); - expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1); - expect(permissionController.requestPermissions).toHaveBeenCalledWith( - { origin: 'foo.com' }, - { eth_accounts: {} }, - { id: expect.any(String) }, + expect(permissionController.revokePermission).toHaveBeenCalledWith( + 'foo.com', + Caip25EndowmentPermissionName, ); + }); - expect(id.length > 0).toBe(true); - expect(id).toStrictEqual( - permissionController.requestPermissions.mock.calls[0][2].id, + it('updates the caveat with the account removed and all eip155 accounts synced', () => { + const permissionController = { + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x2', 'eip155:1:0x3'], + }, + }, + isMultichainOrigin: true, + }, + }), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedAccount('foo.com', '0x2'); + + expect(permissionController.updateCaveat).toHaveBeenCalledWith( + 'foo.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x3'], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x3'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x3'], + }, + }, + isMultichainOrigin: true, + }, ); }); }); describe('requestAccountsAndChainPermissionsWithId', () => { - it('request eth_accounts and permittedChains permissions and returns the request id', async () => { + it('requests eth_accounts and permittedChains approval and returns the request id', async () => { + const approvalController = { + addAndShowApprovalRequest: jest.fn().mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }), + }; const permissionController = { - requestPermissions: jest - .fn() - .mockImplementationOnce(async (_, __, { id }) => { - return [null, { id }]; - }), + grantPermissions: jest.fn(), }; - const id = await getPermissionBackgroundApiMethods( + const result = getPermissionBackgroundApiMethods({ + approvalController, permissionController, - ).requestAccountsAndChainPermissionsWithId('foo.com'); + }).requestAccountsAndChainPermissionsWithId('foo.com'); - expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1); - expect(permissionController.requestPermissions).toHaveBeenCalledWith( - { origin: 'foo.com' }, + const { id } = + approvalController.addAndShowApprovalRequest.mock.calls[0][0]; + + expect(result).toStrictEqual(id); + expect(approvalController.addAndShowApprovalRequest).toHaveBeenCalledWith( { - [PermissionNames.eth_accounts]: {}, - [PermissionNames.permittedChains]: {}, + id, + origin: 'foo.com', + requestData: { + metadata: { + id, + origin: 'foo.com', + }, + permissions: { + [RestrictedMethods.eth_accounts]: {}, + [PermissionNames.permittedChains]: {}, + }, + }, + type: MethodNames.RequestPermissions, }, - { id: expect.any(String) }, ); + }); - expect(id.length > 0).toBe(true); - expect(id).toStrictEqual( - permissionController.requestPermissions.mock.calls[0][2].id, - ); + it('grants a legacy CAIP-25 permission (isMultichainOrigin: false) with the approved eip155 chainIds and accounts', async () => { + const approvalController = { + addAndShowApprovalRequest: jest.fn().mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }), + }; + const permissionController = { + grantPermissions: jest.fn(), + }; + + getPermissionBackgroundApiMethods({ + approvalController, + permissionController, + }).requestAccountsAndChainPermissionsWithId('foo.com'); + + await flushPromises(); + + expect(permissionController.grantPermissions).toHaveBeenCalledWith({ + subject: { + origin: 'foo.com', + }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }); }); }); describe('addPermittedChain', () => { - it('calls grantPermissionsIncremental with expected parameters', () => { + it('gets the CAIP-25 caveat', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods(permissionController).addPermittedChain( + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChain('foo.com', '0x1'); + } catch (err) { + // noop + } + + expect(permissionController.getCaveat).toHaveBeenCalledWith( 'foo.com', - '0x1', + Caip25EndowmentPermissionName, + Caip25CaveatType, ); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getPermittedChainsPermissions(['0x1']), - }); }); - }); - describe('addPermittedChains', () => { - it('calls grantPermissionsIncremental with expected parameters for single chain', () => { + it('throws an error if there is no existing CAIP-25 caveat', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn().mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }), }; - getPermissionBackgroundApiMethods( - permissionController, - ).addPermittedChains('foo.com', ['0x1']); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getPermittedChainsPermissions(['0x1']), - }); + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChain('foo.com', '0x1'), + ).toThrow( + new Error( + `Cannot add chain permissions for origin "foo.com": no permission currently exists for this origin.`, + ), + ); }); - it('calls grantPermissionsIncremental with expected parameters with multiple chains', () => { + it('throws an error if getCaveat fails unexpectedly', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn().mockImplementation(() => { + throw new Error('unexpected getCaveat error'); + }), }; - getPermissionBackgroundApiMethods( - permissionController, - ).addPermittedChains('foo.com', ['0x1', '0x2']); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getPermittedChainsPermissions(['0x1', '0x2']), - }); + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChain('foo.com', '0x1'), + ).toThrow(new Error(`unexpected getCaveat error`)); }); - }); - describe('removePermittedChain', () => { - it('removes a permitted chain', () => { + it('calls updateCaveat with the chain added and all eip155 accounts synced', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1', '0x2'], - }; + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x2'], + }, + }, + isMultichainOrigin: true, + }, }), - revokePermission: jest.fn(), updateCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).removePermittedChain('foo.com', '0x2'); + }).addPermittedChain('foo.com', '0x539'); // 1337 - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.getCaveat).toHaveBeenCalledWith( + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( 'foo.com', - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337:0x2'], + }, + }, + isMultichainOrigin: true, + }, ); + }); + }); - expect(permissionController.revokePermission).not.toHaveBeenCalled(); + describe('addPermittedChains', () => { + it('gets the CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn(), + }; - expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.updateCaveat).toHaveBeenCalledWith( + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChains('foo.com', ['0x1']); + } catch (err) { + // noop + } + + expect(permissionController.getCaveat).toHaveBeenCalledWith( 'foo.com', - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, - ['0x1'], + Caip25EndowmentPermissionName, + Caip25CaveatType, ); }); - it('revokes the permittedChains permission if the removed chain is the only permitted chain', () => { + it('throws an error if there is no existing CAIP-25 caveat', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1'], - }; + getCaveat: jest.fn().mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChains('foo.com', ['0x1']), + ).toThrow( + new Error( + `Cannot add chain permissions for origin "foo.com": no permission currently exists for this origin.`, + ), + ); + }); + + it('throws an error if getCaveat fails unexpectedly', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementation(() => { + throw new Error('unexpected getCaveat error'); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChains('foo.com', ['0x1']), + ).toThrow(new Error(`unexpected getCaveat error`)); + }); + + it('calls updateCaveat with the chains added and all eip155 accounts synced', () => { + const permissionController = { + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x2'], + }, + }, + isMultichainOrigin: true, + }, }), - revokePermission: jest.fn(), updateCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).removePermittedChain('foo.com', '0x1'); + }).addPermittedChains('foo.com', ['0x4', '0x5']); - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.getCaveat).toHaveBeenCalledWith( + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( 'foo.com', - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:4': { + accounts: ['eip155:4:0x1', 'eip155:4:0x2'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + }, + isMultichainOrigin: true, + }, ); + }); + }); - expect(permissionController.revokePermission).toHaveBeenCalledTimes(1); - expect(permissionController.revokePermission).toHaveBeenCalledWith( + describe('removePermittedChain', () => { + it('gets the CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn(), + }; + + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedChain('foo.com', '0x1'); + } catch (err) { + // noop + } + + expect(permissionController.getCaveat).toHaveBeenCalledWith( 'foo.com', - PermissionNames.permittedChains, + Caip25EndowmentPermissionName, + Caip25CaveatType, ); + }); + + it('throws an error if there is no existing CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedChain('foo.com', '0x1'), + ).toThrow( + new Error( + `Cannot remove permission for chainId "0x1": No permissions exist for origin "foo.com".`, + ), + ); + }); + + it('throws an error if getCaveat fails unexpectedly', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementation(() => { + throw new Error('unexpected getCaveat error'); + }), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedChain('foo.com', '0x1'), + ).toThrow(new Error(`unexpected getCaveat error`)); + }); + + it('does nothing if the chain being removed does not exist', () => { + const permissionController = { + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + isMultichainOrigin: true, + }, + }), + updateCaveat: jest.fn(), + revokePermission: jest.fn(), + }; + + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedChain('foo.com', '0xdeadbeef'); expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + expect(permissionController.revokePermission).not.toHaveBeenCalled(); }); - it('does not call permissionController.updateCaveat if the specified chain is not permitted', () => { + it('revokes the entire permission if the removed chain is the only eip:155 scope', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { type: CaveatTypes.restrictNetworkSwitching, value: ['0x1'] }; + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': {}, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + isMultichainOrigin: true, + }, }), revokePermission: jest.fn(), - updateCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).removePermittedChain('foo.com', '0x2'); - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.getCaveat).toHaveBeenCalledWith( + }).removePermittedChain('foo.com', '0x1'); + + expect(permissionController.revokePermission).toHaveBeenCalledWith( 'foo.com', - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, + Caip25EndowmentPermissionName, ); + }); - expect(permissionController.revokePermission).not.toHaveBeenCalled(); - expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + it('updates the caveat with the chain removed', () => { + const permissionController = { + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:10': { + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + isMultichainOrigin: true, + }, + }), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedChain('foo.com', '0xa'); // 10 + + expect(permissionController.updateCaveat).toHaveBeenCalledWith( + 'foo.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + isMultichainOrigin: true, + }, + ); }); }); }); diff --git a/app/scripts/controllers/permissions/caveat-mutators.js b/app/scripts/controllers/permissions/caveat-mutators.js deleted file mode 100644 index 047341e34770..000000000000 --- a/app/scripts/controllers/permissions/caveat-mutators.js +++ /dev/null @@ -1,71 +0,0 @@ -import { CaveatMutatorOperation } from '@metamask/permission-controller'; -import { CaveatTypes } from '../../../../shared/constants/permissions'; -import { normalizeSafeAddress } from '../../lib/multichain/address'; - -/** - * Factories that construct caveat mutator functions that are passed to - * PermissionController.updatePermissionsByCaveat. - */ -export const CaveatMutatorFactories = { - [CaveatTypes.restrictReturnedAccounts]: { - removeAccount, - }, - [CaveatTypes.restrictNetworkSwitching]: { - removeChainId, - }, -}; - -/** - * Removes the target account from the value arrays of all - * `restrictReturnedAccounts` caveats. No-ops if the target account is not in - * the array, and revokes the parent permission if it's the only account in - * the array. - * - * @param {string} targetAccount - The address of the account to remove from - * all accounts permissions. - * @param {string[]} existingAccounts - The account address array from the - * account permissions. - */ -function removeAccount(targetAccount, existingAccounts) { - const checkSumTargetAccount = normalizeSafeAddress(targetAccount); - const newAccounts = existingAccounts.filter( - (address) => normalizeSafeAddress(address) !== checkSumTargetAccount, - ); - - if (newAccounts.length === existingAccounts.length) { - return { operation: CaveatMutatorOperation.Noop }; - } else if (newAccounts.length > 0) { - return { - operation: CaveatMutatorOperation.UpdateValue, - value: newAccounts, - }; - } - return { operation: CaveatMutatorOperation.RevokePermission }; -} - -/** - * Removes the target chain ID from the value arrays of all - * `restrictNetworkSwitching` caveats. No-ops if the target chain ID is not in - * the array, and revokes the parent permission if it's the only chain ID in - * the array. - * - * @param {string} targetChainId - The chain ID to remove from - * all network switching permissions. - * @param {string[]} existingChainIds - The chain ID array from the - * network switching permissions. - */ -function removeChainId(targetChainId, existingChainIds) { - const newChainIds = existingChainIds.filter( - (chainId) => chainId !== targetChainId, - ); - - if (newChainIds.length === existingChainIds.length) { - return { operation: CaveatMutatorOperation.Noop }; - } else if (newChainIds.length > 0) { - return { - operation: CaveatMutatorOperation.UpdateValue, - value: newChainIds, - }; - } - return { operation: CaveatMutatorOperation.RevokePermission }; -} diff --git a/app/scripts/controllers/permissions/caveat-mutators.test.js b/app/scripts/controllers/permissions/caveat-mutators.test.js deleted file mode 100644 index 8c16924514f4..000000000000 --- a/app/scripts/controllers/permissions/caveat-mutators.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import { CaveatMutatorOperation } from '@metamask/permission-controller'; -import { CaveatTypes } from '../../../../shared/constants/permissions'; -import { CaveatMutatorFactories } from './caveat-mutators'; - -const address1 = '0xbf16f7f5db8ae6af2512399bace3101debbde7fc'; -const address2 = '0xb6d5abeca51bfc3d53d00afed06b17eeea32ecdf'; -const nonEvmAddress = 'bc1qdkwac3em6mvlur4fatn2g4q050f4kkqadrsmnp'; - -describe('caveat mutators', () => { - describe('restrictReturnedAccounts', () => { - const { removeAccount } = - CaveatMutatorFactories[CaveatTypes.restrictReturnedAccounts]; - - describe('removeAccount', () => { - it('returns the no-op operation if the target account is not permitted', () => { - expect(removeAccount(address2, [address1])).toStrictEqual({ - operation: CaveatMutatorOperation.Noop, - }); - }); - - it('returns the update operation and a new value if the target account is permitted', () => { - expect(removeAccount(address2, [address1, address2])).toStrictEqual({ - operation: CaveatMutatorOperation.UpdateValue, - value: [address1], - }); - }); - - it('returns the revoke permission operation the target account is the only permitted account', () => { - expect(removeAccount(address1, [address1])).toStrictEqual({ - operation: CaveatMutatorOperation.RevokePermission, - }); - }); - - it('returns the revoke permission operation even if the target account is a checksummed address', () => { - const address3 = '0x95222290dd7278aa3ddd389cc1e1d165cc4baee5'; - const checksummedAddress3 = - '0x95222290dd7278AA3DDd389cc1E1d165Cc4BaeE5'; - expect(removeAccount(checksummedAddress3, [address3])).toStrictEqual({ - operation: CaveatMutatorOperation.RevokePermission, - }); - }); - - describe('Multichain behaviour', () => { - it('returns the no-op operation if the target account is not permitted', () => { - expect(removeAccount(address2, [nonEvmAddress])).toStrictEqual({ - operation: CaveatMutatorOperation.Noop, - }); - }); - - it('can revoke permission for non-EVM addresses', () => { - expect(removeAccount(nonEvmAddress, [nonEvmAddress])).toStrictEqual({ - operation: CaveatMutatorOperation.RevokePermission, - }); - }); - - it('returns the update operation and a new value if the target non-EVM account is permitted', () => { - expect( - removeAccount(nonEvmAddress, [address1, nonEvmAddress]), - ).toStrictEqual({ - operation: CaveatMutatorOperation.UpdateValue, - value: [address1], - }); - }); - }); - }); - }); -}); diff --git a/app/scripts/controllers/permissions/index.js b/app/scripts/controllers/permissions/index.js index b0ec94b175f1..76a460487dfe 100644 --- a/app/scripts/controllers/permissions/index.js +++ b/app/scripts/controllers/permissions/index.js @@ -1,4 +1,3 @@ -export * from './caveat-mutators'; export * from './background-api'; export * from './enums'; export * from './specifications'; diff --git a/app/scripts/controllers/permissions/selectors.js b/app/scripts/controllers/permissions/selectors.js index 76e638d25b54..97464885b7a6 100644 --- a/app/scripts/controllers/permissions/selectors.js +++ b/app/scripts/controllers/permissions/selectors.js @@ -1,6 +1,10 @@ import { createSelector } from 'reselect'; -import { CaveatTypes } from '../../../../shared/constants/permissions'; -import { PermissionNames } from './specifications'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getEthAccounts, + getPermittedEthChainIds, +} from '@metamask/multichain'; /** * This file contains selectors for PermissionController selector event @@ -26,14 +30,14 @@ export const getPermittedAccountsByOrigin = createSelector( getSubjects, (subjects) => { return Object.values(subjects).reduce((originToAccountsMap, subject) => { - const caveats = subject.permissions?.eth_accounts?.caveats || []; + const caveats = + subject.permissions?.[Caip25EndowmentPermissionName]?.caveats || []; - const caveat = caveats.find( - ({ type }) => type === CaveatTypes.restrictReturnedAccounts, - ); + const caveat = caveats.find(({ type }) => type === Caip25CaveatType); if (caveat) { - originToAccountsMap.set(subject.origin, caveat.value); + const ethAccounts = getEthAccounts(caveat.value); + originToAccountsMap.set(subject.origin, ethAccounts); } return originToAccountsMap; }, new Map()); @@ -52,14 +56,13 @@ export const getPermittedChainsByOrigin = createSelector( (subjects) => { return Object.values(subjects).reduce((originToChainsMap, subject) => { const caveats = - subject.permissions?.[PermissionNames.permittedChains]?.caveats || []; + subject.permissions?.[Caip25EndowmentPermissionName]?.caveats || []; - const caveat = caveats.find( - ({ type }) => type === CaveatTypes.restrictNetworkSwitching, - ); + const caveat = caveats.find(({ type }) => type === Caip25CaveatType); if (caveat) { - originToChainsMap.set(subject.origin, caveat.value); + const ethChainIds = getPermittedEthChainIds(caveat.value); + originToChainsMap.set(subject.origin, ethChainIds); } return originToChainsMap; }, new Map()); diff --git a/app/scripts/controllers/permissions/selectors.test.js b/app/scripts/controllers/permissions/selectors.test.js index 41264d405ab2..9a6cc10a9a07 100644 --- a/app/scripts/controllers/permissions/selectors.test.js +++ b/app/scripts/controllers/permissions/selectors.test.js @@ -1,11 +1,13 @@ import { cloneDeep } from 'lodash'; -import { CaveatTypes } from '../../../../shared/constants/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; import { diffMap, getPermittedAccountsByOrigin, getPermittedChainsByOrigin, } from './selectors'; -import { PermissionNames } from './specifications'; describe('PermissionController selectors', () => { describe('diffMap', () => { @@ -53,25 +55,72 @@ describe('PermissionController selectors', () => { 'foo.bar': { origin: 'foo.bar', permissions: { - eth_accounts: { - caveats: [{ type: 'restrictReturnedAccounts', value: ['0x1'] }], + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + isMultichainOrigin: true, + }, + }, + ], }, }, }, 'bar.baz': { origin: 'bar.baz', permissions: { - eth_accounts: { - caveats: [{ type: 'restrictReturnedAccounts', value: ['0x2'] }], + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x2'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], }, }, }, 'baz.bizz': { origin: 'baz.fizz', permissions: { - eth_accounts: { + [Caip25EndowmentPermissionName]: { caveats: [ - { type: 'restrictReturnedAccounts', value: ['0x1', '0x2'] }, + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x2'], + }, + }, + isMultichainOrigin: false, + }, + }, ], }, }, @@ -125,11 +174,23 @@ describe('PermissionController selectors', () => { 'foo.bar': { origin: 'foo.bar', permissions: { - [PermissionNames.permittedChains]: { + [Caip25EndowmentPermissionName]: { caveats: [ { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1'], + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, }, ], }, @@ -138,11 +199,19 @@ describe('PermissionController selectors', () => { 'bar.baz': { origin: 'bar.baz', permissions: { - [PermissionNames.permittedChains]: { + [Caip25EndowmentPermissionName]: { caveats: [ { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x2'], + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:2': { + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }, }, ], }, @@ -151,17 +220,29 @@ describe('PermissionController selectors', () => { 'baz.bizz': { origin: 'baz.fizz', permissions: { - [PermissionNames.permittedChains]: { + [Caip25EndowmentPermissionName]: { caveats: [ { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1', '0x2'], + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:2': { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, }, ], }, }, }, - 'no.accounts': { + 'no.chains': { // we shouldn't see this in the result permissions: { foobar: {}, diff --git a/app/scripts/controllers/permissions/specifications.js b/app/scripts/controllers/permissions/specifications.js index c56b8f06e9f0..f4a1e3320172 100644 --- a/app/scripts/controllers/permissions/specifications.js +++ b/app/scripts/controllers/permissions/specifications.js @@ -1,14 +1,14 @@ -import { - constructPermission, - PermissionType, -} from '@metamask/permission-controller'; import { caveatSpecifications as snapsCaveatsSpecifications, endowmentCaveatSpecifications as snapsEndowmentCaveatSpecifications, } from '@metamask/snaps-rpc-methods'; -import { isValidHexAddress } from '@metamask/utils'; import { - CaveatTypes, + createCaip25Caveat, + Caip25CaveatType, + caip25EndowmentBuilder, + caip25CaveatBuilder, +} from '@metamask/multichain'; +import { EndowmentTypes, RestrictedMethods, } from '../../../../shared/constants/permissions'; @@ -33,58 +33,29 @@ export const PermissionNames = Object.freeze({ * PermissionController. */ export const CaveatFactories = Object.freeze({ - [CaveatTypes.restrictReturnedAccounts]: (accounts) => { - return { type: CaveatTypes.restrictReturnedAccounts, value: accounts }; - }, - - [CaveatTypes.restrictNetworkSwitching]: (chainIds) => { - return { type: CaveatTypes.restrictNetworkSwitching, value: chainIds }; - }, + [Caip25CaveatType]: createCaip25Caveat, }); /** * Gets the specifications for all caveats that will be recognized by the * PermissionController. * - * @param {{ - * getInternalAccounts: () => Record, - * }} options - Options bag. + * @param options - The options object. + * @param options.listAccounts - A function that returns the + * `AccountsController` internalAccount objects for all evm accounts. + * @param options.findNetworkClientIdByChainId - A function that + * returns the networkClientId given a chainId. + * @returns the caveat specifications to construct the PermissionController. */ export const getCaveatSpecifications = ({ - getInternalAccounts, + listAccounts, findNetworkClientIdByChainId, }) => { return { - [CaveatTypes.restrictReturnedAccounts]: { - type: CaveatTypes.restrictReturnedAccounts, - - decorator: (method, caveat) => { - return async (args) => { - const result = await method(args); - return result.filter((account) => caveat.value.includes(account)); - }; - }, - - validator: (caveat, _origin, _target) => - validateCaveatAccounts(caveat.value, getInternalAccounts), - - merger: (leftValue, rightValue) => { - const newValue = Array.from(new Set([...leftValue, ...rightValue])); - const diff = newValue.filter((value) => !leftValue.includes(value)); - return [newValue, diff]; - }, - }, - [CaveatTypes.restrictNetworkSwitching]: { - type: CaveatTypes.restrictNetworkSwitching, - validator: (caveat, _origin, _target) => - validateCaveatNetworks(caveat.value, findNetworkClientIdByChainId), - merger: (leftValue, rightValue) => { - const newValue = Array.from(new Set([...leftValue, ...rightValue])); - const diff = newValue.filter((value) => !leftValue.includes(value)); - return [newValue, diff]; - }, - }, - + [Caip25CaveatType]: caip25CaveatBuilder({ + listAccounts, + findNetworkClientIdByChainId, + }), ...snapsCaveatsSpecifications, ...snapsEndowmentCaveatSpecifications, }; @@ -94,227 +65,15 @@ export const getCaveatSpecifications = ({ * Gets the specifications for all permissions that will be recognized by the * PermissionController. * - * @param {{ - * getAllAccounts: () => Promise, - * getInternalAccounts: () => Record, - * }} options - Options bag. - * @param options.getAllAccounts - A function that returns all Ethereum accounts - * in the current MetaMask instance. - * @param options.getInternalAccounts - A function that returns the - * `AccountsController` internalAccount objects for all accounts in the - * @param options.captureKeyringTypesWithMissingIdentities - A function that - * captures extra error information about the "Missing identity for address" - * error. - * current MetaMask instance. + * @returns the permission specifications to construct the PermissionController. */ -export const getPermissionSpecifications = ({ - getAllAccounts, - getInternalAccounts, - captureKeyringTypesWithMissingIdentities, -}) => { +export const getPermissionSpecifications = () => { return { - [PermissionNames.eth_accounts]: { - permissionType: PermissionType.RestrictedMethod, - targetName: PermissionNames.eth_accounts, - allowedCaveats: [CaveatTypes.restrictReturnedAccounts], - - factory: (permissionOptions, requestData) => { - // This occurs when we use PermissionController.grantPermissions(). - if (requestData === undefined) { - return constructPermission({ - ...permissionOptions, - }); - } - - // The approved accounts will be further validated as part of the caveat. - if (!requestData.approvedAccounts) { - throw new Error( - `${PermissionNames.eth_accounts} error: No approved accounts specified.`, - ); - } - - return constructPermission({ - ...permissionOptions, - caveats: [ - CaveatFactories[CaveatTypes.restrictReturnedAccounts]( - requestData.approvedAccounts, - ), - ], - }); - }, - methodImplementation: async (_args) => { - // We only consider EVM addresses here, hence the filtering: - const accounts = (await getAllAccounts()).filter(isValidHexAddress); - const internalAccounts = getInternalAccounts(); - - return accounts.sort((firstAddress, secondAddress) => { - const firstAccount = internalAccounts.find( - (internalAccount) => - internalAccount.address.toLowerCase() === - firstAddress.toLowerCase(), - ); - - const secondAccount = internalAccounts.find( - (internalAccount) => - internalAccount.address.toLowerCase() === - secondAddress.toLowerCase(), - ); - - if (!firstAccount) { - captureKeyringTypesWithMissingIdentities( - internalAccounts, - accounts, - ); - throw new Error(`Missing identity for address: "${firstAddress}".`); - } else if (!secondAccount) { - captureKeyringTypesWithMissingIdentities( - internalAccounts, - accounts, - ); - throw new Error( - `Missing identity for address: "${secondAddress}".`, - ); - } else if ( - firstAccount.metadata.lastSelected === - secondAccount.metadata.lastSelected - ) { - return 0; - } else if (firstAccount.metadata.lastSelected === undefined) { - return 1; - } else if (secondAccount.metadata.lastSelected === undefined) { - return -1; - } - - return ( - secondAccount.metadata.lastSelected - - firstAccount.metadata.lastSelected - ); - }); - }, - validator: (permission, _origin, _target) => { - const { caveats } = permission; - if ( - !caveats || - caveats.length !== 1 || - caveats[0].type !== CaveatTypes.restrictReturnedAccounts - ) { - throw new Error( - `${PermissionNames.eth_accounts} error: Invalid caveats. There must be a single caveat of type "${CaveatTypes.restrictReturnedAccounts}".`, - ); - } - }, - }, - - [PermissionNames.permittedChains]: { - permissionType: PermissionType.Endowment, - targetName: PermissionNames.permittedChains, - allowedCaveats: [CaveatTypes.restrictNetworkSwitching], - - factory: (permissionOptions, requestData) => { - if (requestData === undefined) { - return constructPermission({ - ...permissionOptions, - }); - } - if (!requestData.approvedChainIds) { - throw new Error( - `${PermissionNames.permittedChains}: No approved networks specified.`, - ); - } - - return constructPermission({ - ...permissionOptions, - caveats: [ - CaveatFactories[CaveatTypes.restrictNetworkSwitching]( - requestData.approvedChainIds, - ), - ], - }); - }, - endowmentGetter: async (_getterOptions) => undefined, - validator: (permission, _origin, _target) => { - const { caveats } = permission; - if ( - !caveats || - caveats.length !== 1 || - caveats[0].type !== CaveatTypes.restrictNetworkSwitching - ) { - throw new Error( - `${PermissionNames.permittedChains} error: Invalid caveats. There must be a single caveat of type "${CaveatTypes.restrictNetworkSwitching}".`, - ); - } - }, - }, + [caip25EndowmentBuilder.targetName]: + caip25EndowmentBuilder.specificationBuilder({}), }; }; -/** - * Validates the accounts associated with a caveat. In essence, ensures that - * the accounts value is an array of non-empty strings, and that each string - * corresponds to a PreferencesController identity. - * - * @param {string[]} accounts - The accounts associated with the caveat. - * @param {() => Record} getInternalAccounts - - * Gets all AccountsController InternalAccounts. - */ -function validateCaveatAccounts(accounts, getInternalAccounts) { - if (!Array.isArray(accounts) || accounts.length === 0) { - throw new Error( - `${PermissionNames.eth_accounts} error: Expected non-empty array of Ethereum addresses.`, - ); - } - - const internalAccounts = getInternalAccounts(); - accounts.forEach((address) => { - if (!address || typeof address !== 'string') { - throw new Error( - `${PermissionNames.eth_accounts} error: Expected an array of Ethereum addresses. Received: "${address}".`, - ); - } - - if ( - !internalAccounts.some( - (internalAccount) => - internalAccount.address.toLowerCase() === address.toLowerCase(), - ) - ) { - throw new Error( - `${PermissionNames.eth_accounts} error: Received unrecognized address: "${address}".`, - ); - } - }); -} - -/** - * Validates the networks associated with a caveat. Ensures that - * the networks value is an array of valid chain IDs. - * - * @param {string[]} chainIdsForCaveat - The list of chain IDs to validate. - * @param {function(string): string} findNetworkClientIdByChainId - Function to find network client ID by chain ID. - * @throws {Error} If the chainIdsForCaveat is not a non-empty array of valid chain IDs. - */ -function validateCaveatNetworks( - chainIdsForCaveat, - findNetworkClientIdByChainId, -) { - if (!Array.isArray(chainIdsForCaveat) || chainIdsForCaveat.length === 0) { - throw new Error( - `${PermissionNames.permittedChains} error: Expected non-empty array of chainIds.`, - ); - } - - chainIdsForCaveat.forEach((chainId) => { - try { - findNetworkClientIdByChainId(chainId); - } catch (e) { - console.error(e); - throw new Error( - `${PermissionNames.permittedChains} error: Received unrecognized chainId: "${chainId}". Please try adding the network first via wallet_addEthereumChain.`, - ); - } - }); -} - /** * Unrestricted methods for Ethereum, see {@link unrestrictedMethods} for more details. */ diff --git a/app/scripts/controllers/permissions/specifications.test.js b/app/scripts/controllers/permissions/specifications.test.js index b27ec07a45b1..e0b3f1623ccd 100644 --- a/app/scripts/controllers/permissions/specifications.test.js +++ b/app/scripts/controllers/permissions/specifications.test.js @@ -1,15 +1,11 @@ -import { EthAccountType } from '@metamask/keyring-api'; import { SnapCaveatType } from '@metamask/snaps-rpc-methods'; import { - CaveatTypes, - RestrictedMethods, -} from '../../../../shared/constants/permissions'; -import { ETH_EOA_METHODS } from '../../../../shared/constants/eth-methods'; + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; import { - CaveatFactories, getCaveatSpecifications, getPermissionSpecifications, - PermissionNames, unrestrictedMethods, } from './specifications'; @@ -20,13 +16,10 @@ describe('PermissionController specifications', () => { describe('caveat specifications', () => { it('getCaveatSpecifications returns the expected specifications object', () => { const caveatSpecifications = getCaveatSpecifications({}); - expect(Object.keys(caveatSpecifications)).toHaveLength(13); - expect( - caveatSpecifications[CaveatTypes.restrictReturnedAccounts].type, - ).toStrictEqual(CaveatTypes.restrictReturnedAccounts); - expect( - caveatSpecifications[CaveatTypes.restrictNetworkSwitching].type, - ).toStrictEqual(CaveatTypes.restrictNetworkSwitching); + expect(Object.keys(caveatSpecifications)).toHaveLength(12); + expect(caveatSpecifications[Caip25CaveatType].type).toStrictEqual( + Caip25CaveatType, + ); expect(caveatSpecifications.permittedDerivationPaths.type).toStrictEqual( SnapCaveatType.PermittedDerivationPaths, @@ -62,537 +55,15 @@ describe('PermissionController specifications', () => { SnapCaveatType.LookupMatchers, ); }); - - describe('restrictReturnedAccounts', () => { - describe('decorator', () => { - it('only returns array members included in the caveat value', async () => { - const getInternalAccounts = jest.fn(); - const { decorator } = getCaveatSpecifications({ - getInternalAccounts, - })[CaveatTypes.restrictReturnedAccounts]; - - const method = async () => ['0x1', '0x2', '0x3']; - const caveat = { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1', '0x3'], - }; - const decorated = decorator(method, caveat); - expect(await decorated()).toStrictEqual(['0x1', '0x3']); - }); - - it('returns an empty array if no array members are included in the caveat value', async () => { - const getInternalAccounts = jest.fn(); - const { decorator } = getCaveatSpecifications({ - getInternalAccounts, - })[CaveatTypes.restrictReturnedAccounts]; - - const method = async () => ['0x1', '0x2', '0x3']; - const caveat = { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x5'], - }; - const decorated = decorator(method, caveat); - expect(await decorated()).toStrictEqual([]); - }); - - it('returns an empty array if the method result is an empty array', async () => { - const getInternalAccounts = jest.fn(); - const { decorator } = getCaveatSpecifications({ - getInternalAccounts, - })[CaveatTypes.restrictReturnedAccounts]; - - const method = async () => []; - const caveat = { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1', '0x2'], - }; - const decorated = decorator(method, caveat); - expect(await decorated()).toStrictEqual([]); - }); - }); - - describe('validator', () => { - it('rejects invalid array values', () => { - const getInternalAccounts = jest.fn(); - const { validator } = getCaveatSpecifications({ - getInternalAccounts, - })[CaveatTypes.restrictReturnedAccounts]; - - [null, 'foo', {}, []].forEach((invalidValue) => { - expect(() => validator({ value: invalidValue })).toThrow( - /Expected non-empty array of Ethereum addresses\.$/u, - ); - }); - }); - - it('rejects falsy or non-string addresses', () => { - const getInternalAccounts = jest.fn(); - const { validator } = getCaveatSpecifications({ - getInternalAccounts, - })[CaveatTypes.restrictReturnedAccounts]; - - [[{}], [[]], [null], ['']].forEach((invalidValue) => { - expect(() => validator({ value: invalidValue })).toThrow( - /Expected an array of Ethereum addresses. Received:/u, - ); - }); - }); - - it('rejects addresses that have no corresponding identity', () => { - const getInternalAccounts = jest.fn().mockImplementationOnce(() => { - return [ - { - address: '0x1', - id: '21066553-d8c8-4cdc-af33-efc921cd3ca9', - metadata: { - name: 'Test Account 1', - lastSelected: 1, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - { - address: '0x3', - id: 'ff8fda69-d416-4d25-80a2-efb77bc7d4ad', - metadata: { - name: 'Test Account 3', - lastSelected: 3, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - ]; - }); - - const { validator } = getCaveatSpecifications({ - getInternalAccounts, - })[CaveatTypes.restrictReturnedAccounts]; - - expect(() => validator({ value: ['0x1', '0x2', '0x3'] })).toThrow( - /Received unrecognized address:/u, - ); - }); - }); - - describe('merger', () => { - it.each([ - { - left: [], - right: [], - expected: [[], []], - }, - { - left: ['0x1'], - right: [], - expected: [['0x1'], []], - }, - { - left: [], - right: ['0x1'], - expected: [['0x1'], ['0x1']], - }, - { - left: ['0x1', '0x2'], - right: ['0x1', '0x2'], - expected: [['0x1', '0x2'], []], - }, - { - left: ['0x1', '0x2'], - right: ['0x2', '0x3'], - expected: [['0x1', '0x2', '0x3'], ['0x3']], - }, - { - left: ['0x1', '0x2'], - right: ['0x3', '0x4'], - expected: [ - ['0x1', '0x2', '0x3', '0x4'], - ['0x3', '0x4'], - ], - }, - { - left: [{ a: 1 }, { b: 2 }], - right: [{ a: 1 }], - expected: [[{ a: 1 }, { b: 2 }, { a: 1 }], [{ a: 1 }]], - }, - ])('merges arrays as expected', ({ left, right, expected }) => { - const { merger } = getCaveatSpecifications({})[ - CaveatTypes.restrictReturnedAccounts - ]; - - expect(merger(left, right)).toStrictEqual(expected); - }); - }); - }); }); describe('permission specifications', () => { it('getPermissionSpecifications returns the expected specifications object', () => { const permissionSpecifications = getPermissionSpecifications({}); - expect(Object.keys(permissionSpecifications)).toHaveLength(2); + expect(Object.keys(permissionSpecifications)).toHaveLength(1); expect( - permissionSpecifications[RestrictedMethods.eth_accounts].targetName, - ).toStrictEqual(RestrictedMethods.eth_accounts); - expect( - permissionSpecifications[PermissionNames.permittedChains].targetName, - ).toStrictEqual('endowment:permitted-chains'); - }); - - describe('eth_accounts', () => { - describe('factory', () => { - it('constructs a valid eth_accounts permission, using permissionOptions', () => { - const getInternalAccounts = jest.fn(); - const getAllAccounts = jest.fn(); - const { factory } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - })[RestrictedMethods.eth_accounts]; - - expect( - factory({ - invoker: 'foo.bar', - target: 'eth_accounts', - caveats: [ - CaveatFactories[CaveatTypes.restrictReturnedAccounts](['0x1']), - ], - }), - ).toStrictEqual({ - caveats: [ - { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1'], - }, - ], - date: 1, - id: expect.any(String), - invoker: 'foo.bar', - parentCapability: 'eth_accounts', - }); - }); - - it('constructs a valid eth_accounts permission, using requestData.approvedAccounts', () => { - const getInternalAccounts = jest.fn(); - const getAllAccounts = jest.fn(); - const { factory } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - })[RestrictedMethods.eth_accounts]; - - expect( - factory( - { invoker: 'foo.bar', target: 'eth_accounts' }, - { approvedAccounts: ['0x1'] }, - ), - ).toStrictEqual({ - caveats: [ - { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1'], - }, - ], - date: 1, - id: expect.any(String), - invoker: 'foo.bar', - parentCapability: 'eth_accounts', - }); - }); - - it('throws if requestData is defined but approvedAccounts is not specified', () => { - const getInternalAccounts = jest.fn(); - const getAllAccounts = jest.fn(); - const { factory } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - })[RestrictedMethods.eth_accounts]; - - expect(() => - factory( - { invoker: 'foo.bar', target: 'eth_accounts' }, - {}, // no approvedAccounts - ), - ).toThrow(/No approved accounts specified\.$/u); - }); - - it('prefers requestData.approvedAccounts over a specified caveat', () => { - const getInternalAccounts = jest.fn(); - const getAllAccounts = jest.fn(); - const { factory } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - })[RestrictedMethods.eth_accounts]; - - expect( - factory( - { - caveats: [ - CaveatFactories[CaveatTypes.restrictReturnedAccounts]([ - '0x1', - '0x2', - ]), - ], - invoker: 'foo.bar', - target: 'eth_accounts', - }, - { approvedAccounts: ['0x1', '0x3'] }, - ), - ).toStrictEqual({ - caveats: [ - { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1', '0x3'], - }, - ], - date: 1, - id: expect.any(String), - invoker: 'foo.bar', - parentCapability: 'eth_accounts', - }); - }); - }); - - describe('methodImplementation', () => { - it('returns the keyring accounts in lastSelected order', async () => { - const getInternalAccounts = jest.fn().mockImplementationOnce(() => { - return [ - { - address: '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', - id: '21066553-d8c8-4cdc-af33-efc921cd3ca9', - metadata: { - name: 'Test Account', - lastSelected: 1, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - { - address: '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', - id: '0bd7348e-bdfe-4f67-875c-de831a583857', - metadata: { - name: 'Test Account', - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - { - address: '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', - id: 'ff8fda69-d416-4d25-80a2-efb77bc7d4ad', - metadata: { - name: 'Test Account', - keyring: { - type: 'HD Key Tree', - }, - lastSelected: 3, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - { - address: '0x04eBa9B766477d8eCA77F5f0e67AE1863C95a7E3', - id: '0bd7348e-bdfe-4f67-875c-de831a583857', - metadata: { - name: 'Test Account', - lastSelected: 3, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - ]; - }); - const getAllAccounts = jest - .fn() - .mockImplementationOnce(() => [ - '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', - '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', - '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', - '0x04eBa9B766477d8eCA77F5f0e67AE1863C95a7E3', - ]); - - const { methodImplementation } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - })[RestrictedMethods.eth_accounts]; - - expect(await methodImplementation()).toStrictEqual([ - '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', - '0x04eBa9B766477d8eCA77F5f0e67AE1863C95a7E3', - '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', - '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', - ]); - }); - - it('throws if a keyring account is missing an address (case 1)', async () => { - const getInternalAccounts = jest.fn().mockImplementationOnce(() => { - return [ - { - address: '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', - id: '0bd7348e-bdfe-4f67-875c-de831a583857', - metadata: { - name: 'Test Account', - lastSelected: 2, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - { - address: '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', - id: 'ff8fda69-d416-4d25-80a2-efb77bc7d4ad', - metadata: { - name: 'Test Account', - lastSelected: 3, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - ]; - }); - const getAllAccounts = jest - .fn() - .mockImplementationOnce(() => [ - '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', - '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', - '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', - ]); - - const { methodImplementation } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - captureKeyringTypesWithMissingIdentities: jest.fn(), - })[RestrictedMethods.eth_accounts]; - - await expect(() => methodImplementation()).rejects.toThrow( - 'Missing identity for address: "0x7A2Bd22810088523516737b4Dc238A4bC37c23F2".', - ); - }); - - it('throws if a keyring account is missing an address (case 2)', async () => { - const getInternalAccounts = jest.fn().mockImplementationOnce(() => { - return [ - { - address: '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Test Account', - lastSelected: 1, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - { - address: '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', - id: 'ff8fda69-d416-4d25-80a2-efb77bc7d4ad', - metadata: { - name: 'Test Account', - lastSelected: 3, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - ]; - }); - const getAllAccounts = jest - .fn() - .mockImplementationOnce(() => [ - '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', - '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', - '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', - ]); - - const { methodImplementation } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - captureKeyringTypesWithMissingIdentities: jest.fn(), - })[RestrictedMethods.eth_accounts]; - - await expect(() => methodImplementation()).rejects.toThrow( - 'Missing identity for address: "0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3".', - ); - }); - }); - - describe('validator', () => { - it('accepts valid permissions', () => { - const getInternalAccounts = jest.fn(); - const getAllAccounts = jest.fn(); - const { validator } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - })[RestrictedMethods.eth_accounts]; - - expect(() => - validator({ - caveats: [ - { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1', '0x2'], - }, - ], - date: 1, - id: expect.any(String), - invoker: 'foo.bar', - parentCapability: 'eth_accounts', - }), - ).not.toThrow(); - }); - - it('rejects invalid caveats', () => { - const getInternalAccounts = jest.fn(); - const getAllAccounts = jest.fn(); - const { validator } = getPermissionSpecifications({ - getInternalAccounts, - getAllAccounts, - })[RestrictedMethods.eth_accounts]; - - [null, [], [1, 2], [{ type: 'foobar' }]].forEach( - (invalidCaveatsValue) => { - expect(() => - validator({ - caveats: invalidCaveatsValue, - date: 1, - id: expect.any(String), - invoker: 'foo.bar', - parentCapability: 'eth_accounts', - }), - ).toThrow(/Invalid caveats./u); - }, - ); - }); - }); + permissionSpecifications[Caip25EndowmentPermissionName].targetName, + ).toStrictEqual('endowment:caip25'); }); }); diff --git a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js index bbc06e7033f5..9be36a3dbced 100644 --- a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js +++ b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js @@ -1,19 +1,32 @@ -import { permissionRpcMethods } from '@metamask/permission-controller'; import { rpcErrors } from '@metamask/rpc-errors'; import { selectHooks } from '@metamask/snaps-rpc-methods'; import { hasProperty } from '@metamask/utils'; -import { handlers as localHandlers, legacyHandlers } from './handlers'; -const allHandlers = [...localHandlers, ...permissionRpcMethods.handlers]; +import { + handlers as localHandlers, + eip1193OnlyHandlers, + ethAccountsHandler, +} from './handlers'; +import { getPermissionsHandler } from './handlers/wallet-getPermissions'; +import { requestPermissionsHandler } from './handlers/wallet-requestPermissions'; +import { revokePermissionsHandler } from './handlers/wallet-revokePermissions'; -// The primary home of RPC method implementations in MetaMask. MUST be subsequent -// to our permissioning logic in the JSON-RPC middleware pipeline. -export const createMethodMiddleware = makeMethodMiddlewareMaker(allHandlers); +// The primary home of RPC method implementations for the injected 1193 provider API. MUST be subsequent +// to our permissioning logic in the EIP-1193 JSON-RPC middleware pipeline. +export const createEip1193MethodMiddleware = makeMethodMiddlewareMaker([ + ...localHandlers, + ...eip1193OnlyHandlers, + // EIP-2255 Permission handlers + getPermissionsHandler, + requestPermissionsHandler, + revokePermissionsHandler, +]); // A collection of RPC method implementations that, for legacy reasons, MAY precede -// our permissioning logic in the JSON-RPC middleware pipeline. -export const createLegacyMethodMiddleware = - makeMethodMiddlewareMaker(legacyHandlers); +// our permissioning logic in the EIP-1193 JSON-RPC middleware pipeline. +export const createEthAccountsMethodMiddleware = makeMethodMiddlewareMaker([ + ethAccountsHandler, +]); /** * Creates a method middleware factory function given a set of method handlers. diff --git a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js index 48ea5ae90d58..4a3b9f958a16 100644 --- a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js +++ b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js @@ -3,49 +3,63 @@ import { assertIsJsonRpcFailure, assertIsJsonRpcSuccess, } from '@metamask/utils'; -import { createMethodMiddleware, createLegacyMethodMiddleware } from '.'; +import { + createEip1193MethodMiddleware, + createEthAccountsMethodMiddleware, +} from '.'; + +const getHandler = () => ({ + implementation: (req, res, _next, end, hooks) => { + if (Array.isArray(req.params)) { + switch (req.params[0]) { + case 1: + res.result = hooks.hook1(); + break; + case 2: + res.result = hooks.hook2(); + break; + case 3: + return end(new Error('test error')); + case 4: + throw new Error('test error'); + case 5: + // eslint-disable-next-line no-throw-literal + throw 'foo'; + default: + throw new Error(`unexpected param "${req.params[0]}"`); + } + } + return end(); + }, + hookNames: { hook1: true, hook2: true }, + methodNames: ['method1', 'method2'], +}); jest.mock('@metamask/permission-controller', () => ({ - permissionRpcMethods: { handlers: [] }, + ...jest.requireActual('@metamask/permission-controller'), })); -jest.mock('./handlers', () => { - const getHandler = () => ({ - implementation: (req, res, _next, end, hooks) => { - if (Array.isArray(req.params)) { - switch (req.params[0]) { - case 1: - res.result = hooks.hook1(); - break; - case 2: - res.result = hooks.hook2(); - break; - case 3: - return end(new Error('test error')); - case 4: - throw new Error('test error'); - case 5: - // eslint-disable-next-line no-throw-literal - throw 'foo'; - default: - throw new Error(`unexpected param "${req.params[0]}"`); - } - } - return end(); - }, - hookNames: { hook1: true, hook2: true }, - methodNames: ['method1', 'method2'], - }); +jest.mock('./handlers/wallet-getPermissions', () => ({ + getPermissionsHandler: getHandler(), +})); - return { - handlers: [getHandler()], - legacyHandlers: [getHandler()], - }; -}); +jest.mock('./handlers/wallet-requestPermissions', () => ({ + requestPermissionsHandler: getHandler(), +})); + +jest.mock('./handlers/wallet-revokePermissions', () => ({ + revokePermissionsHandler: getHandler(), +})); + +jest.mock('./handlers', () => ({ + handlers: [getHandler()], + eip1193OnlyHandlers: [getHandler()], + ethAccountsHandler: getHandler(), +})); describe.each([ - ['createMethodMiddleware', createMethodMiddleware], - ['createLegacyMethodMiddleware', createLegacyMethodMiddleware], + ['createEip1193MethodMiddleware', createEip1193MethodMiddleware], + ['createEthAccountsMethodMiddleware', createEthAccountsMethodMiddleware], ])('%s', (_name, createMiddleware) => { const method1 = 'method1'; diff --git a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js index 66d57dd8786b..721fe6e82107 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js @@ -22,8 +22,8 @@ const addEthereumChain = { endApprovalFlow: true, getCurrentChainIdForDomain: true, getCaveat: true, - requestPermittedChainsPermission: true, - grantPermittedChainsPermissionIncremental: true, + requestPermittedChainsPermissionForOrigin: true, + requestPermittedChainsPermissionIncrementalForOrigin: true, }, }; @@ -44,8 +44,8 @@ async function addEthereumChainHandler( endApprovalFlow, getCurrentChainIdForDomain, getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin, }, ) { let validParams; @@ -196,10 +196,10 @@ async function addEthereumChainHandler( return switchChain(res, end, chainId, networkClientId, approvalFlowId, { isAddFlow: true, setActiveNetwork, - endApprovalFlow, getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + endApprovalFlow, + requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin, }); } else if (approvalFlowId) { endApprovalFlow({ id: approvalFlowId }); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js index eb35e27a1c2b..517921570540 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js @@ -1,6 +1,13 @@ import { rpcErrors } from '@metamask/rpc-errors'; import { CHAIN_IDS } from '../../../../../shared/constants/network'; import addEthereumChain from './add-ethereum-chain'; +import EthChainUtils from './ethereum-chain-utils'; + +jest.mock('./ethereum-chain-utils', () => ({ + ...jest.requireActual('./ethereum-chain-utils'), + validateAddEthereumChainParams: jest.fn(), + switchChain: jest.fn(), +})); const NON_INFURA_CHAIN_ID = '0x123456789'; @@ -52,186 +59,224 @@ const createMockNonInfuraConfiguration = () => ({ defaultBlockExplorerUrlIndex: 0, }); -describe('addEthereumChainHandler', () => { - const addEthereumChainHandler = addEthereumChain.implementation; - const makeMocks = ({ permissionedChainIds = [], overrides = {} } = {}) => { - return { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(NON_INFURA_CHAIN_ID), - setNetworkClientIdForDomain: jest.fn(), - getNetworkConfigurationByChainId: jest.fn(), - setActiveNetwork: jest.fn(), - requestUserApproval: jest.fn().mockResolvedValue(123), - requestPermittedChainsPermission: jest.fn(), - grantPermittedChainsPermissionIncremental: jest.fn(), - getCaveat: jest.fn().mockReturnValue({ value: permissionedChainIds }), - startApprovalFlow: () => ({ id: 'approvalFlowId' }), - endApprovalFlow: jest.fn(), - addNetwork: jest.fn().mockResolvedValue({ - defaultRpcEndpointIndex: 0, - rpcEndpoints: [{ networkClientId: 123 }], - }), - updateNetwork: jest.fn().mockResolvedValue({ - defaultRpcEndpointIndex: 0, - rpcEndpoints: [{ networkClientId: 123 }], - }), - ...overrides, - }; +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const mocks = { + getCurrentChainIdForDomain: jest.fn().mockReturnValue(NON_INFURA_CHAIN_ID), + setNetworkClientIdForDomain: jest.fn(), + getNetworkConfigurationByChainId: jest.fn(), + setActiveNetwork: jest.fn(), + requestUserApproval: jest.fn().mockResolvedValue(123), + getCaveat: jest.fn(), + startApprovalFlow: () => ({ id: 'approvalFlowId' }), + endApprovalFlow: jest.fn(), + addNetwork: jest.fn().mockResolvedValue({ + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 123 }], + }), + updateNetwork: jest.fn().mockResolvedValue({ + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 123 }], + }), + requestPermittedChainsPermissionForOrigin: jest.fn(), + requestPermittedChainsPermissionIncrementalForOrigin: jest.fn(), }; + const response = {}; + const handler = (request) => + addEthereumChain.implementation(request, response, next, end, mocks); + + return { + mocks, + response, + next, + end, + handler, + }; +}; + +describe('addEthereumChainHandler', () => { + beforeEach(() => { + EthChainUtils.validateAddEthereumChainParams.mockImplementation( + (params) => { + const { + chainId, + chainName, + blockExplorerUrls, + rpcUrls, + nativeCurrency, + } = params; + return { + chainId, + chainName, + firstValidBlockExplorerUrl: blockExplorerUrls[0] ?? null, + firstValidRPCUrl: rpcUrls[0], + ticker: nativeCurrency.symbol, + }; + }, + ); + }); afterEach(() => { jest.clearAllMocks(); }); - it('creates a new network configuration for the given chainid, requests `endowment:permitted-chains` permission and switches to it if no networkConfigurations with the same chainId exist', async () => { + it('should validate the request params', async () => { + const { handler } = createMockedHandler(); + + const request = { + origin: 'example.com', + params: [ + { + foo: true, + }, + ], + }; + + await handler(request); + + expect(EthChainUtils.validateAddEthereumChainParams).toHaveBeenCalledWith( + request.params[0], + ); + }); + + it('should return an error if request params validation fails', async () => { + const { end, handler } = createMockedHandler(); + EthChainUtils.validateAddEthereumChainParams.mockImplementation(() => { + throw new Error('failed to validate params'); + }); + + await handler({ + origin: 'example.com', + params: [{}], + }); + + expect(end).toHaveBeenCalledWith( + rpcErrors.invalidParams(new Error('failed to validate params')), + ); + }); + + it('creates a new network configuration for the given chainid and switches to it if no networkConfigurations with the same chainId exist', async () => { const nonInfuraConfiguration = createMockNonInfuraConfiguration(); - const mocks = makeMocks({ - permissionedChainIds: [], - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.MAINNET), - }, + const { mocks, end, handler } = createMockedHandler(); + mocks.getCurrentChainIdForDomain.mockReturnValue(CHAIN_IDS.MAINNET); + + await handler({ + origin: 'example.com', + params: [ + { + chainId: nonInfuraConfiguration.chainId, + chainName: nonInfuraConfiguration.name, + rpcUrls: nonInfuraConfiguration.rpcEndpoints.map((rpc) => rpc.url), + nativeCurrency: { + symbol: nonInfuraConfiguration.nativeCurrency, + decimals: 18, + }, + blockExplorerUrls: nonInfuraConfiguration.blockExplorerUrls, + }, + ], }); - await addEthereumChainHandler( + + expect(mocks.addNetwork).toHaveBeenCalledWith(nonInfuraConfiguration); + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + NON_INFURA_CHAIN_ID, + 123, + 'approvalFlowId', { - origin: 'example.com', - params: [ - { - chainId: nonInfuraConfiguration.chainId, - chainName: nonInfuraConfiguration.name, - rpcUrls: nonInfuraConfiguration.rpcEndpoints.map((rpc) => rpc.url), - nativeCurrency: { - symbol: nonInfuraConfiguration.nativeCurrency, - decimals: 18, - }, - blockExplorerUrls: nonInfuraConfiguration.blockExplorerUrls, - }, - ], + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + setActiveNetwork: mocks.setActiveNetwork, + requestPermittedChainsPermissionForOrigin: + mocks.requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin: + mocks.requestPermittedChainsPermissionIncrementalForOrigin, }, - {}, - jest.fn(), - jest.fn(), - mocks, ); - - expect(mocks.addNetwork).toHaveBeenCalledWith(nonInfuraConfiguration); - expect( - mocks.grantPermittedChainsPermissionIncremental, - ).toHaveBeenCalledTimes(1); - expect( - mocks.grantPermittedChainsPermissionIncremental, - ).toHaveBeenCalledWith([createMockNonInfuraConfiguration().chainId]); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith(123); }); describe('if a networkConfiguration for the given chainId already exists', () => { describe('if the proposed networkConfiguration has a different rpcUrl from the one already in state', () => { - it('create a new networkConfiguration and switches to it without requesting permissions, if the requested chainId has `endowment:permitted-chains` permission granted for requesting origin', async () => { - const mocks = makeMocks({ - permissionedChainIds: [CHAIN_IDS.MAINNET], - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.SEPOLIA), - }, - }); - - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: CHAIN_IDS.MAINNET, - chainName: 'Ethereum Mainnet', - rpcUrls: ['https://eth.llamarpc.com'], - nativeCurrency: { - symbol: 'ETH', - decimals: 18, - }, - blockExplorerUrls: ['https://etherscan.io'], - }, - ], - }, - {}, - jest.fn(), - jest.fn(), - mocks, + it('updates the network with a new networkConfiguration and switches to it', async () => { + const { mocks, end, handler } = createMockedHandler(); + mocks.getCurrentChainIdForDomain.mockReturnValue(CHAIN_IDS.SEPOLIA); + mocks.getNetworkConfigurationByChainId.mockReturnValue( + createMockMainnetConfiguration(), ); - expect(mocks.requestUserApproval).toHaveBeenCalledTimes(1); - expect(mocks.requestPermittedChainsPermission).not.toHaveBeenCalled(); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith(123); - }); - - it('create a new networkConfiguration, requests permissions and switches to it, if the requested chainId does not have permittedChains permission granted for requesting origin', async () => { - const mocks = makeMocks({ - permissionedChainIds: [], - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(createMockNonInfuraConfiguration()), - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.MAINNET), - }, + await handler({ + origin: 'example.com', + params: [ + { + chainId: CHAIN_IDS.MAINNET, + chainName: 'Ethereum Mainnet', + rpcUrls: ['https://eth.llamarpc.com'], + nativeCurrency: { + symbol: 'ETH', + decimals: 18, + }, + blockExplorerUrls: ['https://etherscan.io'], + }, + ], }); - await addEthereumChainHandler( + expect(mocks.updateNetwork).toHaveBeenCalledWith( + '0x1', { - origin: 'example.com', - params: [ + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 1, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ { - chainId: NON_INFURA_CHAIN_ID, - chainName: 'Custom Network', - rpcUrls: ['https://new-custom.network'], - nativeCurrency: { - symbol: 'CUST', - decimals: 18, - }, - blockExplorerUrls: ['https://custom.blockexplorer'], + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/', + }, + { + name: 'Ethereum Mainnet', + type: 'custom', + url: 'https://eth.llamarpc.com', }, ], }, + undefined, + ); + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( {}, - jest.fn(), - jest.fn(), - mocks, + end, + '0x1', + 123, + 'approvalFlowId', + { + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + setActiveNetwork: mocks.setActiveNetwork, + requestPermittedChainsPermissionForOrigin: + mocks.requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin: + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + }, ); - - expect(mocks.updateNetwork).toHaveBeenCalledTimes(1); - expect( - mocks.grantPermittedChainsPermissionIncremental, - ).toHaveBeenCalledTimes(1); - expect( - mocks.grantPermittedChainsPermissionIncremental, - ).toHaveBeenCalledWith([NON_INFURA_CHAIN_ID]); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); }); }); - it('should switch to the existing networkConfiguration if one already exsits for the given chain id', async () => { - const mocks = makeMocks({ - permissionedChainIds: [ - createMockOptimismConfiguration().chainId, - CHAIN_IDS.MAINNET, - ], - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.MAINNET), - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(createMockOptimismConfiguration()), - }, - }); - - await addEthereumChainHandler( - { + describe('if the proposed networkConfiguration does not have a different rpcUrl from the one already in state', () => { + it('should only switch to the existing networkConfiguration if one already exists for the given chain id', async () => { + const { mocks, end, handler } = createMockedHandler(); + mocks.getCurrentChainIdForDomain.mockReturnValue(CHAIN_IDS.MAINNET); + mocks.getNetworkConfigurationByChainId.mockReturnValue( + createMockOptimismConfiguration(), + ); + await handler({ origin: 'example.com', params: [ { @@ -248,137 +293,54 @@ describe('addEthereumChainHandler', () => { createMockOptimismConfiguration().blockExplorerUrls, }, ], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - - expect(mocks.requestPermittedChainsPermission).not.toHaveBeenCalled(); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockOptimismConfiguration().rpcEndpoints[0].networkClientId, - ); - }); - }); - - it('should return an error if an unexpected parameter is provided', async () => { - const mocks = makeMocks(); - const mockEnd = jest.fn(); - - const unexpectedParam = 'unexpected'; + }); - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ + expect(mocks.addNetwork).not.toHaveBeenCalled(); + expect(mocks.updateNetwork).not.toHaveBeenCalled(); + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + '0xa', + createMockOptimismConfiguration().rpcEndpoints[0].networkClientId, + undefined, { - chainId: createMockNonInfuraConfiguration().chainId, - chainName: createMockNonInfuraConfiguration().nickname, - rpcUrls: [createMockNonInfuraConfiguration().rpcUrl], - nativeCurrency: { - symbol: createMockNonInfuraConfiguration().ticker, - decimals: 18, - }, - blockExplorerUrls: [ - createMockNonInfuraConfiguration().blockExplorerUrls[0], - ], - [unexpectedParam]: 'parameter', + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + setActiveNetwork: mocks.setActiveNetwork, + requestPermittedChainsPermissionForOrigin: + mocks.requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin: + mocks.requestPermittedChainsPermissionIncrementalForOrigin, }, - ], - }, - {}, - jest.fn(), - mockEnd, - mocks, - ); - - expect(mockEnd).toHaveBeenCalledWith( - rpcErrors.invalidParams({ - message: `Received unexpected keys on object parameter. Unsupported keys:\n${unexpectedParam}`, - }), - ); - }); - - it('should handle errors during the switch network permission request', async () => { - const mockError = new Error('Permission request failed'); - const mocks = makeMocks({ - permissionedChainIds: [], - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.SEPOLIA), - grantPermittedChainsPermissionIncremental: jest - .fn() - .mockRejectedValue(mockError), - }, + ); + }); }); - const mockEnd = jest.fn(); - - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: CHAIN_IDS.MAINNET, - chainName: 'Ethereum Mainnet', - rpcUrls: ['https://mainnet.infura.io/v3/'], - nativeCurrency: { - symbol: 'ETH', - decimals: 18, - }, - blockExplorerUrls: ['https://etherscan.io'], - }, - ], - }, - {}, - jest.fn(), - mockEnd, - mocks, - ); - - expect( - mocks.grantPermittedChainsPermissionIncremental, - ).toHaveBeenCalledTimes(1); - expect(mockEnd).toHaveBeenCalledWith(mockError); - expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); }); it('should return an error if nativeCurrency.symbol does not match an existing network with the same chainId', async () => { - const mocks = makeMocks({ - permissionedChainIds: [CHAIN_IDS.MAINNET], - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(createMockMainnetConfiguration()), - }, - }); - const mockEnd = jest.fn(); - - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: CHAIN_IDS.MAINNET, - chainName: 'Ethereum Mainnet', - rpcUrls: ['https://mainnet.infura.io/v3/'], - nativeCurrency: { - symbol: 'WRONG', - decimals: 18, - }, - blockExplorerUrls: ['https://etherscan.io'], - }, - ], - }, - {}, - jest.fn(), - mockEnd, - mocks, + const { mocks, end, handler } = createMockedHandler(); + mocks.getNetworkConfigurationByChainId.mockReturnValue( + createMockMainnetConfiguration(), ); + await handler({ + origin: 'example.com', + params: [ + { + chainId: CHAIN_IDS.MAINNET, + chainName: 'Ethereum Mainnet', + rpcUrls: ['https://mainnet.infura.io/v3/'], + nativeCurrency: { + symbol: 'WRONG', + decimals: 18, + }, + blockExplorerUrls: ['https://etherscan.io'], + }, + ], + }); - expect(mockEnd).toHaveBeenCalledWith( + expect(end).toHaveBeenCalledWith( rpcErrors.invalidParams({ message: `nativeCurrency.symbol does not match currency symbol for a network the user already has added with the same chainId. Received:\nWRONG`, }), @@ -388,39 +350,26 @@ describe('addEthereumChainHandler', () => { it('should add result set to null to response object if the requested rpcUrl (and chainId) is currently selected', async () => { const CURRENT_RPC_CONFIG = createMockNonInfuraConfiguration(); - const mocks = makeMocks({ - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CURRENT_RPC_CONFIG.chainId), - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(CURRENT_RPC_CONFIG), - }, - }); - const res = {}; - - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: CURRENT_RPC_CONFIG.chainId, - chainName: 'Custom Network', - rpcUrls: [CURRENT_RPC_CONFIG.rpcEndpoints[0].url], - nativeCurrency: { - symbol: CURRENT_RPC_CONFIG.nativeCurrency, - decimals: 18, - }, - blockExplorerUrls: ['https://custom.blockexplorer'], - }, - ], - }, - res, - jest.fn(), - jest.fn(), - mocks, + const { mocks, response, handler } = createMockedHandler(); + mocks.getCurrentChainIdForDomain.mockReturnValue( + CURRENT_RPC_CONFIG.chainId, ); - expect(res.result).toBeNull(); + mocks.getNetworkConfigurationByChainId.mockReturnValue(CURRENT_RPC_CONFIG); + await handler({ + origin: 'example.com', + params: [ + { + chainId: CURRENT_RPC_CONFIG.chainId, + chainName: 'Custom Network', + rpcUrls: [CURRENT_RPC_CONFIG.rpcEndpoints[0].url], + nativeCurrency: { + symbol: CURRENT_RPC_CONFIG.nativeCurrency, + decimals: 18, + }, + blockExplorerUrls: ['https://custom.blockexplorer'], + }, + ], + }); + expect(response.result).toBeNull(); }); }); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.test.ts new file mode 100644 index 000000000000..7aa367ec6873 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.test.ts @@ -0,0 +1,51 @@ +import { + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import ethereumAccounts from './eth-accounts'; + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'eth_accounts', + origin: 'http://test.com', +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getAccounts = jest.fn().mockReturnValue(['0xdead', '0xbeef']); + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: JsonRpcRequest) => + ethereumAccounts.implementation(request, response, next, end, { + getAccounts, + }); + + return { + response, + next, + end, + getAccounts, + handler, + }; +}; + +describe('ethAccountsHandler', () => { + it('gets sorted eth accounts from the CAIP-25 permission via the getAccounts hook', async () => { + const { handler, getAccounts } = createMockedHandler(); + + await handler(baseRequest); + expect(getAccounts).toHaveBeenCalled(); + }); + + it('returns the accounts', async () => { + const { handler, response } = createMockedHandler(); + + await handler(baseRequest); + expect(response.result).toStrictEqual(['0xdead', '0xbeef']); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.ts b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.ts index 47c2f0c2e318..1efa92121076 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.ts @@ -8,17 +8,16 @@ import type { PendingJsonRpcResponse, } from '@metamask/utils'; import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; -import { AccountAddress } from '../../../controllers/account-order'; import { HandlerWrapper } from './types'; type EthAccountsHandlerOptions = { - getAccounts: () => Promise; + getAccounts: () => string[]; }; type EthAccountsConstraint = { implementation: ( _req: JsonRpcRequest, - res: PendingJsonRpcResponse, + res: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, { getAccounts }: EthAccountsHandlerOptions, @@ -44,16 +43,15 @@ export default ethAccounts; * @param _next - The json-rpc-engine 'next' callback. * @param end - The json-rpc-engine 'end' callback. * @param options - The RPC method hooks. - * @param options.getAccounts - Gets the accounts for the requesting - * origin. + * @param options.getAccounts - A hook that returns the permitted eth accounts for the origin sorted by lastSelected. */ async function ethAccountsHandler( _req: JsonRpcRequest, - res: PendingJsonRpcResponse, + res: PendingJsonRpcResponse, _next: JsonRpcEngineNextCallback, end: JsonRpcEngineEndCallback, { getAccounts }: EthAccountsHandlerOptions, ): Promise { - res.result = await getAccounts(); + res.result = getAccounts(); return end(); } diff --git a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js index 2f86b30885e5..d4b62576df63 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js @@ -1,28 +1,32 @@ import { errorCodes, rpcErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getPermittedEthChainIds, +} from '@metamask/multichain'; import { isPrefixedFormattedHexString, isSafeChainId, } from '../../../../../shared/modules/network.utils'; -import { CaveatTypes } from '../../../../../shared/constants/permissions'; import { UNKNOWN_TICKER_SYMBOL } from '../../../../../shared/constants/app'; -import { PermissionNames } from '../../../controllers/permissions'; import { getValidUrl } from '../../util'; export function validateChainId(chainId) { - const _chainId = typeof chainId === 'string' && chainId.toLowerCase(); - if (!isPrefixedFormattedHexString(_chainId)) { + const lowercasedChainId = + typeof chainId === 'string' ? chainId.toLowerCase() : null; + if (!isPrefixedFormattedHexString(lowercasedChainId)) { throw rpcErrors.invalidParams({ message: `Expected 0x-prefixed, unpadded, non-zero hexadecimal string 'chainId'. Received:\n${chainId}`, }); } - if (!isSafeChainId(parseInt(_chainId, 16))) { + if (!isSafeChainId(parseInt(chainId, 16))) { throw rpcErrors.invalidParams({ - message: `Invalid chain ID "${_chainId}": numerical value greater than max safe value. Received:\n${chainId}`, + message: `Invalid chain ID "${lowercasedChainId}": numerical value greater than max safe value. Received:\n${chainId}`, }); } - return _chainId; + return lowercasedChainId; } export function validateSwitchEthereumChainParams(req) { @@ -152,8 +156,26 @@ export function validateAddEthereumChainParams(params) { }; } +/** + * Switches the active network for the origin if already permitted + * otherwise requests approval to update permission first. + * + * @param response - The JSON RPC request's response object. + * @param end - The JSON RPC request's end callback. + * @param {string} chainId - The chainId being switched to. + * @param {string} networkClientId - The network client being switched to. + * @param {string} [approvalFlowId] - The optional approval flow ID to handle. + * @param {object} hooks - The hooks object. + * @param {boolean} hooks.isAddFlow - The boolean determining if this call originates from wallet_addEthereumChain. + * @param {Function} hooks.setActiveNetwork - The callback to change the current network for the origin. + * @param {Function} hooks.endApprovalFlow - The optional callback to end the approval flow when approvalFlowId is provided. + * @param {Function} hooks.getCaveat - The callback to get the CAIP-25 caveat for the origin. + * @param {Function} hooks.requestPermittedChainsPermissionForOrigin - The callback to request a new permittedChains-equivalent CAIP-25 permission. + * @param {Function} hooks.requestPermittedChainsPermissionIncrementalForOrigin - The callback to add a new chain to the permittedChains-equivalent CAIP-25 permission. + * @returns a null response on success or an error if user rejects an approval when isAddFlow is false or on unexpected errors. + */ export async function switchChain( - res, + response, end, chainId, networkClientId, @@ -163,30 +185,34 @@ export async function switchChain( setActiveNetwork, endApprovalFlow, getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin, }, ) { try { - const { value: permissionedChainIds } = - getCaveat({ - target: PermissionNames.permittedChains, - caveatType: CaveatTypes.restrictNetworkSwitching, - }) ?? {}; + const caip25Caveat = getCaveat({ + target: Caip25EndowmentPermissionName, + caveatType: Caip25CaveatType, + }); - if ( - permissionedChainIds === undefined || - !permissionedChainIds.includes(chainId) - ) { - if (isAddFlow) { - await grantPermittedChainsPermissionIncremental([chainId]); - } else { - await requestPermittedChainsPermission([chainId]); + if (caip25Caveat) { + const ethChainIds = getPermittedEthChainIds(caip25Caveat.value); + + if (!ethChainIds.includes(chainId)) { + await requestPermittedChainsPermissionIncrementalForOrigin({ + chainId, + autoApprove: isAddFlow, + }); } + } else { + await requestPermittedChainsPermissionForOrigin({ + chainId, + autoApprove: isAddFlow, + }); } await setActiveNetwork(networkClientId); - res.result = null; + response.result = null; } catch (error) { // We don't want to return an error if user rejects the request // and this is a chained switch request after wallet_addEthereumChain. @@ -197,7 +223,7 @@ export async function switchChain( error.code === errorCodes.provider.userRejectedRequest && approvalFlowId ) { - res.result = null; + response.result = null; return end(); } return end(error); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.test.ts new file mode 100644 index 000000000000..51b77372dd09 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.test.ts @@ -0,0 +1,401 @@ +import { errorCodes, rpcErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; +import { Hex } from '@metamask/utils'; +import * as EthChainUtils from './ethereum-chain-utils'; + +describe('Ethereum Chain Utils', () => { + const createMockedSwitchChain = () => { + const end = jest.fn(); + const mocks = { + isAddFlow: false, + setActiveNetwork: jest.fn(), + endApprovalFlow: jest.fn(), + getCaveat: jest.fn(), + requestPermittedChainsPermissionForOrigin: jest.fn(), + requestPermittedChainsPermissionIncrementalForOrigin: jest.fn(), + }; + const response: { result?: true } = {}; + const switchChain = ( + chainId: Hex, + networkClientId: string, + approvalFlowId?: string, + ) => + EthChainUtils.switchChain( + response, + end, + chainId, + networkClientId, + approvalFlowId, + mocks, + ); + + return { + mocks, + response, + end, + switchChain, + }; + }; + + describe('switchChain', () => { + it('gets the CAIP-25 caveat', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.getCaveat).toHaveBeenCalledWith({ + target: Caip25EndowmentPermissionName, + caveatType: Caip25CaveatType, + }); + }); + + it('passes through unexpected errors if approvalFlowId is not provided', async () => { + const { mocks, end, switchChain } = createMockedSwitchChain(); + mocks.requestPermittedChainsPermissionForOrigin.mockRejectedValueOnce( + new Error('unexpected error'), + ); + + await switchChain('0x1', 'mainnet', undefined); + + expect(end).toHaveBeenCalledWith(new Error('unexpected error')); + }); + + it('passes through unexpected errors if approvalFlowId is provided', async () => { + const { mocks, end, switchChain } = createMockedSwitchChain(); + mocks.requestPermittedChainsPermissionForOrigin.mockRejectedValueOnce( + new Error('unexpected error'), + ); + + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(end).toHaveBeenCalledWith(new Error('unexpected error')); + }); + + it('ignores userRejectedRequest errors when approvalFlowId is provided', async () => { + const { mocks, end, response, switchChain } = createMockedSwitchChain(); + mocks.requestPermittedChainsPermissionForOrigin.mockRejectedValueOnce({ + code: errorCodes.provider.userRejectedRequest, + }); + + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(response.result).toStrictEqual(null); + expect(end).toHaveBeenCalledWith(); + }); + + it('ends the approval flow when approvalFlowId is provided', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.endApprovalFlow).toHaveBeenCalledWith({ + id: 'approvalFlowId', + }); + }); + + describe('with no existing CAIP-25 permission', () => { + it('requests a switch chain approval without autoApprove if isAddFlow: false', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.isAddFlow = false; + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermittedChainsPermissionForOrigin, + ).toHaveBeenCalledWith({ chainId: '0x1', autoApprove: false }); + }); + + it('switches to the chain', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); + }); + + it('should handle errors if the switch chain approval is rejected', async () => { + const { mocks, end, switchChain } = createMockedSwitchChain(); + mocks.requestPermittedChainsPermissionForOrigin.mockRejectedValueOnce({ + code: errorCodes.provider.userRejectedRequest, + }); + + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermittedChainsPermissionForOrigin, + ).toHaveBeenCalled(); + expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledWith(); + }); + }); + + describe('with an existing CAIP-25 permission granted from the legacy flow (isMultichainOrigin: false) and the chainId is not already permissioned', () => { + it('requests a switch chain approval with autoApprove and switches to it if isAddFlow: true', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.isAddFlow = true; + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + ).toHaveBeenCalledWith({ chainId: '0x1', autoApprove: true }); + expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); + }); + + it('requests permittedChains approval without autoApprove then switches to it if isAddFlow: false', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.isAddFlow = false; + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + ).toHaveBeenCalledWith({ chainId: '0x1', autoApprove: false }); + expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); + }); + + it('should handle errors if the permittedChains approval is rejected', async () => { + const { mocks, end, switchChain } = createMockedSwitchChain(); + mocks.requestPermittedChainsPermissionIncrementalForOrigin.mockRejectedValueOnce( + { + code: errorCodes.provider.userRejectedRequest, + }, + ); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + ).toHaveBeenCalled(); + expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledWith(); + }); + }); + + describe('with an existing CAIP-25 permission granted from the multichain flow (isMultichainOrigin: true) and the chainId is not already permissioned', () => { + it('requests permittedChains approval', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.requestPermittedChainsPermissionIncrementalForOrigin.mockRejectedValue( + new Error( + "Cannot switch to or add permissions for chainId '0x1' because permissions were granted over the Multichain API.", + ), + ); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + ).toHaveBeenCalledWith({ chainId: '0x1', autoApprove: false }); + }); + + it('does not switch the active network', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + mocks.requestPermittedChainsPermissionIncrementalForOrigin.mockRejectedValue( + new Error( + "Cannot switch to or add permissions for chainId '0x1' because permissions were granted over the Multichain API.", + ), + ); + + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); + }); + + it('return error about not being able to switch chain', async () => { + const { mocks, end, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + mocks.requestPermittedChainsPermissionIncrementalForOrigin.mockRejectedValue( + new Error( + "Cannot switch to or add permissions for chainId '0x1' because permissions were granted over the Multichain API.", + ), + ); + + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(end).toHaveBeenCalledWith( + new Error( + "Cannot switch to or add permissions for chainId '0x1' because permissions were granted over the Multichain API.", + ), + ); + }); + }); + + // @ts-expect-error This function is missing from the Mocha type definitions + describe.each([ + ['legacy', false], + ['multichain', true], + ])( + 'with an existing CAIP-25 permission granted from the %s flow (isMultichainOrigin: %s) and the chainId is already permissioned', + (_type: string, isMultichainOrigin: boolean) => { + it('does not request permittedChains approval', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin, + }, + }); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + ).not.toHaveBeenCalled(); + }); + + it('switches the active network', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin, + }, + }); + await switchChain('0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); + }); + }, + ); + }); + + describe('validateAddEthereumChainParams', () => { + it('throws an error if an unexpected parameter is provided', () => { + const unexpectedParam = 'unexpected'; + + expect(() => { + EthChainUtils.validateAddEthereumChainParams({ + chainId: '0x1', + chainName: 'Mainnet', + rpcUrls: ['https://test.com/rpc'], + nativeCurrency: { + symbol: 'ETH', + decimals: 18, + }, + blockExplorerUrls: ['https://explorer.test.com/'], + [unexpectedParam]: 'parameter', + }); + }).toThrow( + rpcErrors.invalidParams({ + message: `Received unexpected keys on object parameter. Unsupported keys:\n${unexpectedParam}`, + }), + ); + }); + + it('returns a flattened version of params if it is valid', () => { + expect( + EthChainUtils.validateAddEthereumChainParams({ + chainId: '0x1', + chainName: 'Mainnet', + rpcUrls: ['https://test.com/rpc'], + nativeCurrency: { + symbol: 'ETH', + decimals: 18, + }, + blockExplorerUrls: ['https://explorer.test.com/'], + }), + ).toStrictEqual({ + chainId: '0x1', + chainName: 'Mainnet', + firstValidBlockExplorerUrl: 'https://explorer.test.com/', + firstValidRPCUrl: 'https://test.com/rpc', + ticker: 'ETH', + }); + }); + }); + + describe('validateSwitchEthereumChainParams', () => { + it('throws an error if an unexpected parameter is provided', () => { + const unexpectedParam = 'unexpected'; + + expect(() => { + EthChainUtils.validateSwitchEthereumChainParams({ + params: [ + { + chainId: '0x1', + [unexpectedParam]: 'parameter', + }, + ], + }); + }).toThrow( + rpcErrors.invalidParams({ + message: `Received unexpected keys on object parameter. Unsupported keys:\n${unexpectedParam}`, + }), + ); + }); + + it('throws an error for invalid chainId', async () => { + expect(() => { + EthChainUtils.validateSwitchEthereumChainParams({ + params: [ + { + chainId: 'invalid_chain_id', + }, + ], + }); + }).toThrow( + rpcErrors.invalidParams({ + message: `Expected 0x-prefixed, unpadded, non-zero hexadecimal string 'chainId'. Received:\ninvalid_chain_id`, + }), + ); + }); + + it('returns the chainId if it is valid', () => { + expect( + EthChainUtils.validateSwitchEthereumChainParams({ + params: [ + { + chainId: '0x1', + }, + ], + }), + ).toStrictEqual('0x1'); + }); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/index.ts b/app/scripts/lib/rpc-method-middleware/handlers/index.ts index 09bca12b5b67..521cb32bec64 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/index.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/index.ts @@ -20,9 +20,7 @@ export const handlers = [ addEthereumChain, getProviderState, logWeb3ShimUsage, - requestAccounts, sendMetadata, - switchEthereumChain, watchAsset, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) mmiAuthenticate, @@ -34,4 +32,10 @@ export const handlers = [ ///: END:ONLY_INCLUDE_IF ]; -export const legacyHandlers = [ethAccounts]; +export const eip1193OnlyHandlers = [ + switchEthereumChain, + ethAccounts, + requestAccounts, +]; + +export const ethAccountsHandler = ethAccounts; diff --git a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js index 68b52ea75549..0217ec104558 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js @@ -6,26 +6,16 @@ import { } from '../../../../../shared/constants/metametrics'; import { shouldEmitDappViewedEvent } from '../../util'; -/** - * This method attempts to retrieve the Ethereum accounts available to the - * requester, or initiate a request for account access if none are currently - * available. It is essentially a wrapper of wallet_requestPermissions that - * only errors if the user rejects the request. We maintain the method for - * backwards compatibility reasons. - */ - const requestEthereumAccounts = { methodNames: [MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS], implementation: requestEthereumAccountsHandler, hookNames: { - origin: true, getAccounts: true, getUnlockPromise: true, - hasPermission: true, - requestAccountsPermission: true, sendMetrics: true, - getPermissionsForOrigin: true, metamaskState: true, + requestCaip25ApprovalForOrigin: true, + grantPermissionsForOrigin: true, }, }; export default requestEthereumAccounts; @@ -34,42 +24,40 @@ export default requestEthereumAccounts; const locks = new Set(); /** - * @typedef {Record} RequestEthereumAccountsOptions - * @property {string} origin - The requesting origin. - * @property {Function} getAccounts - Gets the accounts for the requesting - * origin. - * @property {Function} getUnlockPromise - Gets a promise that resolves when - * the extension unlocks. - * @property {Function} hasPermission - Returns whether the requesting origin - * has the specified permission. - * @property {Function} requestAccountsPermission - Requests the `eth_accounts` - * permission for the requesting origin. - */ - -/** + * This method attempts to retrieve the Ethereum accounts available to the + * requester, or initiate a request for account access if none are currently + * available. It is essentially a wrapper of wallet_requestPermissions that + * only errors if the user rejects the request. We maintain the method for + * backwards compatibility reasons. * - * @param {import('@metamask/utils').JsonRpcRequest} _req - The JSON-RPC request object. - * @param {import('@metamask/utils').JsonRpcResponse} res - The JSON-RPC response object. - * @param {Function} _next - The json-rpc-engine 'next' callback. - * @param {Function} end - The json-rpc-engine 'end' callback. - * @param {RequestEthereumAccountsOptions} options - The RPC method hooks. + * @param req - The JsonRpcEngine request + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.getAccounts - A hook that returns the permitted eth accounts for the origin sorted by lastSelected. + * @param options.getUnlockPromise - A hook that resolves when the wallet is unlocked. + * @param options.sendMetrics - A hook that helps track metric events. + * @param options.metamaskState - The MetaMask app state. + * @param options.requestCaip25ApprovalForOrigin - A hook that requests approval for the CAIP-25 permission for the origin. + * @param options.grantPermissionsForOrigin - A hook that grants permission for the approved permissions for the origin. + * @returns A promise that resolves to nothing */ async function requestEthereumAccountsHandler( - _req, + req, res, _next, end, { - origin, getAccounts, getUnlockPromise, - hasPermission, - requestAccountsPermission, sendMetrics, - getPermissionsForOrigin, metamaskState, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, }, ) { + const { origin } = req; if (locks.has(origin)) { res.error = rpcErrors.resourceUnavailable( `Already processing ${MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS}. Please wait.`, @@ -77,14 +65,15 @@ async function requestEthereumAccountsHandler( return end(); } - if (hasPermission(MESSAGE_TYPE.ETH_ACCOUNTS)) { + let ethAccounts = getAccounts({ ignoreLock: true }); + if (ethAccounts.length > 0) { // We wait for the extension to unlock in this case only, because permission // requests are handled when the extension is unlocked, regardless of the // lock state when they were received. try { locks.add(origin); await getUnlockPromise(true); - res.result = await getAccounts(); + res.result = ethAccounts; end(); } catch (error) { end(error); @@ -94,48 +83,38 @@ async function requestEthereumAccountsHandler( return undefined; } - // If no accounts, request the accounts permission try { - await requestAccountsPermission(); - } catch (err) { - res.error = err; - return end(); + const caip25Approval = await requestCaip25ApprovalForOrigin(); + await grantPermissionsForOrigin(caip25Approval); + } catch (error) { + return end(error); } - // Get the approved accounts - const accounts = await getAccounts(); - /* istanbul ignore else: too hard to induce, see below comment */ - if (accounts.length > 0) { - res.result = accounts; - const numberOfConnectedAccounts = - getPermissionsForOrigin(origin).eth_accounts.caveats[0].value.length; - // first time connection to dapp will lead to no log in the permissionHistory - // and if user has connected to dapp before, the dapp origin will be included in the permissionHistory state - // we will leverage that to identify `is_first_visit` for metrics + // We cannot derive ethAccounts directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + ethAccounts = getAccounts({ ignoreLock: true }); + + // first time connection to dapp will lead to no log in the permissionHistory + // and if user has connected to dapp before, the dapp origin will be included in the permissionHistory state + // we will leverage that to identify `is_first_visit` for metrics + if (shouldEmitDappViewedEvent(metamaskState.metaMetricsId)) { const isFirstVisit = !Object.keys(metamaskState.permissionHistory).includes( origin, ); - if (shouldEmitDappViewedEvent(metamaskState.metaMetricsId)) { - sendMetrics({ - event: MetaMetricsEventName.DappViewed, - category: MetaMetricsEventCategory.InpageProvider, - referrer: { - url: origin, - }, - properties: { - is_first_visit: isFirstVisit, - number_of_accounts: Object.keys(metamaskState.accounts).length, - number_of_accounts_connected: numberOfConnectedAccounts, - }, - }); - } - } else { - // This should never happen, because it should be caught in the - // above catch clause - res.error = rpcErrors.internal( - 'Accounts unexpectedly unavailable. Please report this bug.', - ); + sendMetrics({ + event: MetaMetricsEventName.DappViewed, + category: MetaMetricsEventCategory.InpageProvider, + referrer: { + url: origin, + }, + properties: { + is_first_visit: isFirstVisit, + number_of_accounts: Object.keys(metamaskState.accounts).length, + number_of_accounts_connected: ethAccounts.length, + }, + }); } + res.result = ethAccounts; return end(); } diff --git a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts new file mode 100644 index 000000000000..72cce380ab96 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts @@ -0,0 +1,208 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import { + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { deferredPromise } from '../../util'; +import * as Util from '../../util'; +import { flushPromises } from '../../../../../test/lib/timer-helpers'; +import requestEthereumAccounts from './request-accounts'; + +jest.mock('../../util', () => ({ + ...jest.requireActual('../../util'), + shouldEmitDappViewedEvent: jest.fn(), +})); +const MockUtil = jest.mocked(Util); + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'eth_requestAccounts', + networkClientId: 'mainnet', + origin: 'http://test.com', + params: [], +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getAccounts = jest.fn().mockReturnValue([]); + const getUnlockPromise = jest.fn(); + const sendMetrics = jest.fn(); + const metamaskState = { + permissionHistory: {}, + metaMetricsId: 'metaMetricsId', + accounts: { + '0x1': {}, + '0x2': {}, + '0x3': {}, + }, + }; + const requestCaip25ApprovalForOrigin = jest.fn().mockResolvedValue({}); + const grantPermissionsForOrigin = jest.fn().mockReturnValue({}); + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + result: undefined, + }; + const handler = ( + request: JsonRpcRequest & { origin: string }, + ) => + requestEthereumAccounts.implementation(request, response, next, end, { + getAccounts, + getUnlockPromise, + sendMetrics, + metamaskState, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + }); + + return { + response, + next, + end, + getAccounts, + getUnlockPromise, + sendMetrics, + metamaskState, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + handler, + }; +}; + +describe('requestEthereumAccountsHandler', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('checks if there are any eip155 accounts permissioned', async () => { + const { handler, getAccounts } = createMockedHandler(); + + await handler(baseRequest); + expect(getAccounts).toHaveBeenCalledWith({ ignoreLock: true }); + }); + + describe('eip155 account permissions exist', () => { + it('waits for the wallet to unlock', async () => { + const { handler, getUnlockPromise, getAccounts } = createMockedHandler(); + getAccounts.mockReturnValue(['0xdead', '0xbeef']); + + await handler(baseRequest); + expect(getUnlockPromise).toHaveBeenCalledWith(true); + }); + + it('returns the accounts', async () => { + const { handler, response, getAccounts } = createMockedHandler(); + getAccounts.mockReturnValue(['0xdead', '0xbeef']); + + await handler(baseRequest); + expect(response.result).toStrictEqual(['0xdead', '0xbeef']); + }); + + it('blocks subsequent requests if there is currently a request waiting for the wallet to be unlocked', async () => { + const { handler, getUnlockPromise, getAccounts, end, response } = + createMockedHandler(); + const { promise, resolve } = deferredPromise(); + getUnlockPromise.mockReturnValue(promise); + getAccounts.mockReturnValue(['0xdead', '0xbeef']); + + handler(baseRequest); + expect(response).toStrictEqual({ + id: 0, + jsonrpc: '2.0', + result: undefined, + }); + expect(end).not.toHaveBeenCalled(); + + await flushPromises(); + + await handler(baseRequest); + expect(response.error).toStrictEqual( + rpcErrors.resourceUnavailable( + `Already processing eth_requestAccounts. Please wait.`, + ), + ); + expect(end).toHaveBeenCalledTimes(1); + resolve?.(); + }); + }); + + describe('eip155 account permissions do not exist', () => { + it('requests the CAIP-25 approval', async () => { + const { handler, requestCaip25ApprovalForOrigin } = createMockedHandler(); + + await handler({ ...baseRequest, origin: 'http://test.com' }); + expect(requestCaip25ApprovalForOrigin).toHaveBeenCalledWith(); + }); + + it('throws an error if the CAIP-25 approval is rejected', async () => { + const { handler, requestCaip25ApprovalForOrigin, end } = + createMockedHandler(); + requestCaip25ApprovalForOrigin.mockRejectedValue( + new Error('approval rejected'), + ); + + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(new Error('approval rejected')); + }); + + it('grants the CAIP-25 approval', async () => { + const { + handler, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + } = createMockedHandler(); + + requestCaip25ApprovalForOrigin.mockResolvedValue({ foo: 'bar' }); + + await handler({ ...baseRequest, origin: 'http://test.com' }); + expect(grantPermissionsForOrigin).toHaveBeenCalledWith({ foo: 'bar' }); + }); + + it('returns the newly granted and properly ordered eth accounts', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts + .mockReturnValueOnce([]) + .mockReturnValueOnce(['0xdead', '0xbeef']); + + await handler(baseRequest); + expect(response.result).toStrictEqual(['0xdead', '0xbeef']); + expect(getAccounts).toHaveBeenCalledTimes(2); + }); + + it('emits the dapp viewed metrics event when shouldEmitDappViewedEvent returns true', async () => { + const { handler, getAccounts, sendMetrics } = createMockedHandler(); + getAccounts + .mockReturnValueOnce([]) + .mockReturnValueOnce(['0xdead', '0xbeef']); + MockUtil.shouldEmitDappViewedEvent.mockReturnValue(true); + + await handler(baseRequest); + expect(sendMetrics).toHaveBeenCalledWith({ + category: 'inpage_provider', + event: 'Dapp Viewed', + properties: { + is_first_visit: true, + number_of_accounts: 3, + number_of_accounts_connected: 2, + }, + referrer: { + url: 'http://test.com', + }, + }); + }); + + it('does not emit the dapp viewed metrics event when shouldEmitDappViewedEvent returns false', async () => { + const { handler, getAccounts, sendMetrics } = createMockedHandler(); + getAccounts + .mockReturnValueOnce([]) + .mockReturnValueOnce(['0xdead', '0xbeef']); + MockUtil.shouldEmitDappViewedEvent.mockReturnValue(false); + + await handler(baseRequest); + expect(sendMetrics).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js b/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js index 1fbeedbef3f5..4a5e0ef6a4f8 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.js @@ -12,9 +12,9 @@ const switchEthereumChain = { getNetworkConfigurationByChainId: true, setActiveNetwork: true, getCaveat: true, - requestPermittedChainsPermission: true, getCurrentChainIdForDomain: true, - grantPermittedChainsPermissionIncremental: true, + requestPermittedChainsPermissionForOrigin: true, + requestPermittedChainsPermissionIncrementalForOrigin: true, }, }; @@ -28,10 +28,10 @@ async function switchEthereumChainHandler( { getNetworkConfigurationByChainId, setActiveNetwork, - requestPermittedChainsPermission, getCaveat, getCurrentChainIdForDomain, - grantPermittedChainsPermissionIncremental, + requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin, }, ) { let chainId; @@ -67,7 +67,7 @@ async function switchEthereumChainHandler( return switchChain(res, end, chainId, networkClientIdToSwitchTo, null, { setActiveNetwork, getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin, }); } diff --git a/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.test.js b/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.test.js index abd0c0eb9ff7..694839b4562b 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.test.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/switch-ethereum-chain.test.js @@ -1,8 +1,16 @@ +import { providerErrors } from '@metamask/rpc-errors'; import { CHAIN_IDS, NETWORK_TYPES, } from '../../../../../shared/constants/network'; import switchEthereumChain from './switch-ethereum-chain'; +import EthChainUtils from './ethereum-chain-utils'; + +jest.mock('./ethereum-chain-utils', () => ({ + ...jest.requireActual('./ethereum-chain-utils'), + validateSwitchEthereumChainParams: jest.fn(), + switchChain: jest.fn(), +})); const NON_INFURA_CHAIN_ID = '0x123456789'; @@ -16,113 +24,155 @@ const createMockMainnetConfiguration = () => ({ ], }); -describe('switchEthereumChainHandler', () => { - const makeMocks = ({ - permissionedChainIds = [], - overrides = {}, - mockedGetNetworkConfigurationByChainIdReturnValue = createMockMainnetConfiguration(), - mockedGetCurrentChainIdForDomainReturnValue = NON_INFURA_CHAIN_ID, - } = {}) => { - const mockGetCaveat = jest.fn(); - mockGetCaveat.mockReturnValue({ value: permissionedChainIds }); - - return { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(mockedGetCurrentChainIdForDomainReturnValue), - setNetworkClientIdForDomain: jest.fn(), - setActiveNetwork: jest.fn(), - requestPermittedChainsPermission: jest.fn(), - getCaveat: mockGetCaveat, - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(mockedGetNetworkConfigurationByChainIdReturnValue), - ...overrides, - }; +const createMockLineaMainnetConfiguration = () => ({ + chainId: CHAIN_IDS.LINEA_MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: NETWORK_TYPES.LINEA_MAINNET, + }, + ], +}); + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const mocks = { + getNetworkConfigurationByChainId: jest + .fn() + .mockReturnValue(createMockMainnetConfiguration()), + setActiveNetwork: jest.fn(), + getCaveat: jest.fn(), + getCurrentChainIdForDomain: jest.fn().mockReturnValue(NON_INFURA_CHAIN_ID), + requestPermittedChainsPermissionForOrigin: jest.fn(), + requestPermittedChainsPermissionIncrementalForOrigin: jest.fn(), }; + const response = {}; + const handler = (request) => + switchEthereumChain.implementation(request, response, next, end, mocks); + + return { + mocks, + response, + next, + end, + handler, + }; +}; + +describe('switchEthereumChainHandler', () => { + beforeEach(() => { + EthChainUtils.validateSwitchEthereumChainParams.mockImplementation( + (request) => { + return request.params[0].chainId; + }, + ); + }); afterEach(() => { jest.clearAllMocks(); }); - it('should call requestPermittedChainsPermission and setActiveNetwork when chainId is not in `endowment:permitted-chains`', async () => { - const mockrequestPermittedChainsPermission = jest.fn().mockResolvedValue(); - const mocks = makeMocks({ - overrides: { - requestPermittedChainsPermission: mockrequestPermittedChainsPermission, - }, + it('should validate the request params', async () => { + const { handler } = createMockedHandler(); + + const request = { + origin: 'example.com', + params: [ + { + foo: true, + }, + ], + }; + + await handler(request); + + expect( + EthChainUtils.validateSwitchEthereumChainParams, + ).toHaveBeenCalledWith(request); + }); + + it('should return an error if request params validation fails', async () => { + const { end, handler } = createMockedHandler(); + EthChainUtils.validateSwitchEthereumChainParams.mockImplementation(() => { + throw new Error('failed to validate params'); }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledTimes(1); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledWith([ - CHAIN_IDS.MAINNET, - ]); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); + await handler({ + origin: 'example.com', + params: [{}], + }); + + expect(end).toHaveBeenCalledWith(new Error('failed to validate params')); }); - it('should call setActiveNetwork without calling requestPermittedChainsPermission when requested chainId is in `endowment:permitted-chains`', async () => { - const mocks = makeMocks({ - permissionedChainIds: [CHAIN_IDS.MAINNET], + it('returns null and does not try to switch the network if the current chain id for the domain matches the chainId in the params', async () => { + const { end, response, handler } = createMockedHandler(); + await handler({ + origin: 'example.com', + params: [ + { + chainId: NON_INFURA_CHAIN_ID, + }, + ], + }); + + expect(response.result).toStrictEqual(null); + expect(end).toHaveBeenCalled(); + expect(EthChainUtils.switchChain).not.toHaveBeenCalled(); + }); + + it('throws an error and does not try to switch the network if unable to find a network matching the chainId in the params', async () => { + const { mocks, end, handler } = createMockedHandler(); + mocks.getCurrentChainIdForDomain.mockReturnValue('0x1'); + mocks.getNetworkConfigurationByChainId.mockReturnValue(undefined); + + await handler({ + origin: 'example.com', + params: [ + { + chainId: NON_INFURA_CHAIN_ID, + }, + ], }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.requestPermittedChainsPermission).not.toHaveBeenCalled(); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockMainnetConfiguration().rpcEndpoints[0].networkClientId, + expect(end).toHaveBeenCalledWith( + providerErrors.custom({ + code: 4902, + message: `Unrecognized chain ID "${NON_INFURA_CHAIN_ID}". Try adding the chain using wallet_addEthereumChain first.`, + }), ); + expect(EthChainUtils.switchChain).not.toHaveBeenCalled(); }); - it('should handle errors during the switch network permission request', async () => { - const mockError = new Error('Permission request failed'); - const mockrequestPermittedChainsPermission = jest - .fn() - .mockRejectedValue(mockError); - const mocks = makeMocks({ - overrides: { - requestPermittedChainsPermission: mockrequestPermittedChainsPermission, - }, + it('tries to switch the network', async () => { + const { mocks, end, handler } = createMockedHandler(); + mocks.getNetworkConfigurationByChainId + .mockReturnValueOnce(createMockMainnetConfiguration()) + .mockReturnValueOnce(createMockLineaMainnetConfiguration()); + await handler({ + origin: 'example.com', + params: [ + { + chainId: '0xdeadbeef', + }, + ], }); - const mockEnd = jest.fn(); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + '0xdeadbeef', + 'mainnet', + null, { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], + setActiveNetwork: mocks.setActiveNetwork, + getCaveat: mocks.getCaveat, + requestPermittedChainsPermissionForOrigin: + mocks.requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin: + mocks.requestPermittedChainsPermissionIncrementalForOrigin, }, - {}, - jest.fn(), - mockEnd, - mocks, ); - - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledTimes(1); - expect(mockEnd).toHaveBeenCalledWith(mockError); - expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); }); }); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-getPermissions.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-getPermissions.test.ts new file mode 100644 index 000000000000..2cc8f19fb3ca --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-getPermissions.test.ts @@ -0,0 +1,357 @@ +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; +import * as Multichain from '@metamask/multichain'; +import { Json, JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../../shared/constants/permissions'; +import { PermissionNames } from '../../../controllers/permissions'; +import { getPermissionsHandler } from './wallet-getPermissions'; + +jest.mock('@metamask/multichain', () => ({ + ...jest.requireActual('@metamask/multichain'), + getPermittedEthChainIds: jest.fn(), +})); +const MockMultichain = jest.mocked(Multichain); + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_getPermissions', +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getPermissionsForOrigin = jest.fn().mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x3'], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + }, + }, + }, + ], + }, + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + const getAccounts = jest.fn().mockReturnValue([]); + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: JsonRpcRequest) => + getPermissionsHandler.implementation(request, response, next, end, { + getPermissionsForOrigin, + getAccounts, + }); + + return { + response, + next, + end, + getPermissionsForOrigin, + getAccounts, + handler, + }; +}; + +describe('getPermissionsHandler', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + beforeEach(() => { + MockMultichain.getPermittedEthChainIds.mockReturnValue([]); + }); + + it('gets the permissions for the origin', async () => { + const { handler, getPermissionsForOrigin } = createMockedHandler(); + + await handler(baseRequest); + expect(getPermissionsForOrigin).toHaveBeenCalled(); + }); + + it('returns permissions unmodified if no CAIP-25 endowment permission has been granted', async () => { + const { handler, getPermissionsForOrigin, response } = + createMockedHandler(); + + getPermissionsForOrigin.mockReturnValue( + Object.freeze({ + otherPermission: { + id: '1', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '1', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + ]); + }); + + describe('CAIP-25 endowment permissions has been granted', () => { + it('returns the permissions with the CAIP-25 permission removed', async () => { + const { handler, getAccounts, getPermissionsForOrigin, response } = + createMockedHandler(); + getPermissionsForOrigin.mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + }, + }, + ], + }, + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + getAccounts.mockReturnValue([]); + MockMultichain.getPermittedEthChainIds.mockReturnValue([]); + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + ]); + }); + + it('gets the lastSelected sorted permissioned eth accounts for the origin', async () => { + const { handler, getAccounts } = createMockedHandler(); + await handler(baseRequest); + expect(getAccounts).toHaveBeenCalledWith({ ignoreLock: true }); + }); + + it('returns the permissions with an eth_accounts permission if some eth accounts are permissioned', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts.mockReturnValue(['0x1', '0x2', '0x3', '0xdeadbeef']); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + { + id: '1', + parentCapability: RestrictedMethods.eth_accounts, + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x2', '0x3', '0xdeadbeef'], + }, + ], + }, + ]); + }); + + it('gets the permitted eip155 chainIds from the CAIP-25 caveat value', async () => { + const { handler, getPermissionsForOrigin } = createMockedHandler(); + getPermissionsForOrigin.mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + }, + }, + ], + }, + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + await handler(baseRequest); + expect(MockMultichain.getPermittedEthChainIds).toHaveBeenCalledWith({ + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + 'eip155:5': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + }); + }); + + it('returns the permissions with a permittedChains permission if some eip155 chainIds are permissioned', async () => { + const { handler, response } = createMockedHandler(); + MockMultichain.getPermittedEthChainIds.mockReturnValue(['0x1', '0x64']); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + { + id: '1', + parentCapability: PermissionNames.permittedChains, + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x64'], + }, + ], + }, + ]); + }); + + it('returns the permissions with a eth_accounts and permittedChains permission if some eip155 accounts and chainIds are permissioned', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts.mockReturnValue(['0x1', '0x2', '0xdeadbeef']); + MockMultichain.getPermittedEthChainIds.mockReturnValue(['0x1', '0x64']); + + await handler(baseRequest); + expect(response.result).toStrictEqual([ + { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + { + id: '1', + parentCapability: RestrictedMethods.eth_accounts, + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x2', '0xdeadbeef'], + }, + ], + }, + { + id: '1', + parentCapability: PermissionNames.permittedChains, + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x64'], + }, + ], + }, + ]); + }); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-getPermissions.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-getPermissions.ts new file mode 100644 index 000000000000..0ff95c327a59 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-getPermissions.ts @@ -0,0 +1,106 @@ +import { + CaveatSpecificationConstraint, + MethodNames, + PermissionController, + PermissionSpecificationConstraint, +} from '@metamask/permission-controller'; +import { + Caip25CaveatType, + Caip25CaveatValue, + Caip25EndowmentPermissionName, + getPermittedEthChainIds, +} from '@metamask/multichain'; +import { + AsyncJsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, +} from '@metamask/json-rpc-engine'; +import { Json, JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { PermissionNames } from '../../../controllers/permissions'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../../shared/constants/permissions'; + +export const getPermissionsHandler = { + methodNames: [MethodNames.GetPermissions], + implementation: getPermissionsImplementation, + hookNames: { + getPermissionsForOrigin: true, + getAccounts: true, + }, +}; + +/** + * Get Permissions implementation to be used in JsonRpcEngine middleware. + * + * @param _req - The JsonRpcEngine request - unused + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.getPermissionsForOrigin - The specific method hook needed for this method implementation + * @param options.getAccounts - A hook that returns the permitted eth accounts for the origin sorted by lastSelected. + * @returns A promise that resolves to nothing + */ +async function getPermissionsImplementation( + _req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: AsyncJsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + getPermissionsForOrigin, + getAccounts, + }: { + getPermissionsForOrigin: () => ReturnType< + PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + >['getPermissions'] + >; + getAccounts: (options?: { ignoreLock?: boolean }) => string[]; + }, +) { + const permissions = { ...getPermissionsForOrigin() }; + const caip25Endowment = permissions[Caip25EndowmentPermissionName]; + const caip25CaveatValue = caip25Endowment?.caveats?.find( + ({ type }) => type === Caip25CaveatType, + )?.value as Caip25CaveatValue | undefined; + delete permissions[Caip25EndowmentPermissionName]; + + if (caip25CaveatValue) { + // We cannot derive ethAccounts directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + const ethAccounts = getAccounts({ ignoreLock: true }); + + if (ethAccounts.length > 0) { + permissions[RestrictedMethods.eth_accounts] = { + ...caip25Endowment, + parentCapability: RestrictedMethods.eth_accounts, + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ethAccounts, + }, + ], + }; + } + + const ethChainIds = getPermittedEthChainIds(caip25CaveatValue); + + if (ethChainIds.length > 0) { + permissions[PermissionNames.permittedChains] = { + ...caip25Endowment, + parentCapability: PermissionNames.permittedChains, + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ethChainIds, + }, + ], + }; + } + } + + res.result = Object.values(permissions); + return end(); +} diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.test.ts new file mode 100644 index 000000000000..6ce5c9a7264c --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.test.ts @@ -0,0 +1,651 @@ +import { + invalidParams, + RequestedPermissions, +} from '@metamask/permission-controller'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; +import { Json, JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../../shared/constants/permissions'; +import { PermissionNames } from '../../../controllers/permissions'; +import { requestPermissionsHandler } from './wallet-requestPermissions'; + +const getBaseRequest = (overrides = {}) => ({ + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_requestPermissions', + networkClientId: 'mainnet', + origin: 'http://test.com', + params: [ + { + eth_accounts: {}, + }, + ], + ...overrides, +}); + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const requestPermissionsForOrigin = jest.fn().mockResolvedValue({}); + const getAccounts = jest.fn().mockReturnValue([]); + const requestCaip25ApprovalForOrigin = jest.fn().mockResolvedValue({}); + const grantPermissionsForOrigin = jest.fn().mockReturnValue({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + }, + }, + ], + }, + }); + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: unknown) => + requestPermissionsHandler.implementation( + request as JsonRpcRequest<[RequestedPermissions]> & { origin: string }, + response, + next, + end, + { + getAccounts, + requestPermissionsForOrigin, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + }, + ); + + return { + response, + next, + end, + getAccounts, + requestPermissionsForOrigin, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + handler, + }; +}; + +describe('requestPermissionsHandler', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('returns an error if params is malformed', async () => { + const { handler, end } = createMockedHandler(); + + const malformedRequest = getBaseRequest({ params: [] }); + await handler(malformedRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: malformedRequest } }), + ); + }); + + describe('only other permissions (non CAIP-25 equivalent) requested', () => { + it('it treats "endowment:caip25" as an other permission', async () => { + const { + handler, + requestPermissionsForOrigin, + requestCaip25ApprovalForOrigin, + } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [Caip25EndowmentPermissionName]: {}, + }, + ], + }), + ); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ + [Caip25EndowmentPermissionName]: {}, + }); + expect(requestCaip25ApprovalForOrigin).not.toHaveBeenCalled(); + }); + + it('requests the permission for the other permissions', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ + otherPermissionA: {}, + otherPermissionB: {}, + }); + }); + + it('returns an error if requesting other permissions fails', async () => { + const { handler, requestPermissionsForOrigin, end } = + createMockedHandler(); + + requestPermissionsForOrigin.mockRejectedValue( + new Error('failed to request other permissions'), + ); + + await handler( + getBaseRequest({ + params: [ + { + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(end).toHaveBeenCalledWith( + new Error('failed to request other permissions'), + ); + }); + + it('returns the other permissions that are granted', async () => { + const { handler, requestPermissionsForOrigin, response } = + createMockedHandler(); + + requestPermissionsForOrigin.mockResolvedValue([ + { + otherPermissionA: { foo: 'bar' }, + otherPermissionB: { hello: true }, + }, + ]); + + await handler( + getBaseRequest({ + params: [ + { + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(response.result).toStrictEqual([{ foo: 'bar' }, { hello: true }]); + }); + }); + + describe('only CAIP-25 equivalent permissions ("eth_accounts" and/or "endowment:permittedChains") requested', () => { + it('requests the CAIP-25 permission using eth_accounts when only eth_accounts is specified in params', async () => { + const { handler, requestCaip25ApprovalForOrigin } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }, + ], + }), + ); + + expect(requestCaip25ApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }); + }); + + it('requests the CAIP-25 permission for permittedChains when only permittedChains is specified in params', async () => { + const { handler, requestCaip25ApprovalForOrigin } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect(requestCaip25ApprovalForOrigin).toHaveBeenCalledWith({ + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + + it('requests the CAIP-25 permission for eth_accounts and permittedChains when both are specified in params', async () => { + const { handler, requestCaip25ApprovalForOrigin } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect(requestCaip25ApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + + it('returns an error if requesting the CAIP-25 approval fails', async () => { + const { handler, requestCaip25ApprovalForOrigin, end } = + createMockedHandler(); + requestCaip25ApprovalForOrigin.mockRejectedValue( + new Error('failed to request caip25 approval'), + ); + + await handler( + getBaseRequest({ + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect(end).toHaveBeenCalledWith( + new Error('failed to request caip25 approval'), + ); + }); + + it('grants the CAIP-25 approval', async () => { + const { + handler, + getAccounts, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + } = createMockedHandler(); + getAccounts.mockReturnValue(['0xdeadbeef']); + requestCaip25ApprovalForOrigin.mockResolvedValue({ + foo: 'bar', + }); + + await handler(getBaseRequest()); + expect(grantPermissionsForOrigin).toHaveBeenCalledWith({ foo: 'bar' }); + }); + + it('returns both eth_accounts and permittedChains permissions that were granted if there are permitted chains', async () => { + const { handler, getAccounts, grantPermissionsForOrigin, response } = + createMockedHandler(); + getAccounts.mockReturnValue(['0xdeadbeef']); + grantPermissionsForOrigin.mockReturnValue({ + [Caip25EndowmentPermissionName]: { + id: 'new', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['0xdeadbeef'], + }, + }, + }, + }, + ], + }, + }); + + await handler(getBaseRequest()); + expect(response.result).toStrictEqual([ + { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0xdeadbeef'], + }, + ], + id: 'new', + parentCapability: RestrictedMethods.eth_accounts, + }, + { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x5'], + }, + ], + id: 'new', + parentCapability: PermissionNames.permittedChains, + }, + ]); + }); + + it('returns only eth_accounts permission that was granted if there are no permitted chains', async () => { + const { handler, getAccounts, grantPermissionsForOrigin, response } = + createMockedHandler(); + getAccounts.mockReturnValue(['0xdeadbeef']); + grantPermissionsForOrigin.mockReturnValue({ + [Caip25EndowmentPermissionName]: { + id: 'new', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['0xdeadbeef'], + }, + }, + }, + }, + ], + }, + }); + + await handler(getBaseRequest()); + expect(response.result).toStrictEqual([ + { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0xdeadbeef'], + }, + ], + id: 'new', + parentCapability: RestrictedMethods.eth_accounts, + }, + ]); + }); + }); + + describe('both CAIP-25 equivalent and other permissions requested', () => { + describe('both CAIP-25 equivalent permissions and other permissions are approved', () => { + it('returns eth_accounts, permittedChains, and other permissions that were granted', async () => { + const { + handler, + getAccounts, + requestPermissionsForOrigin, + grantPermissionsForOrigin, + response, + } = createMockedHandler(); + requestPermissionsForOrigin.mockResolvedValue([ + { + otherPermissionA: { foo: 'bar' }, + otherPermissionB: { hello: true }, + }, + ]); + getAccounts.mockReturnValue(['0xdeadbeef']); + grantPermissionsForOrigin.mockReturnValue({ + [Caip25EndowmentPermissionName]: { + id: 'new', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['0xdeadbeef'], + }, + }, + }, + }, + ], + }, + }); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + expect(response.result).toStrictEqual([ + { foo: 'bar' }, + { hello: true }, + { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0xdeadbeef'], + }, + ], + id: 'new', + parentCapability: RestrictedMethods.eth_accounts, + }, + { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x5'], + }, + ], + id: 'new', + parentCapability: PermissionNames.permittedChains, + }, + ]); + }); + }); + + describe('CAIP-25 equivalent permissions are approved, but other permissions are not approved', () => { + it('does not grant the CAIP-25 permission', async () => { + const { + handler, + requestPermissionsForOrigin, + grantPermissionsForOrigin, + } = createMockedHandler(); + requestPermissionsForOrigin.mockRejectedValue( + new Error('other permissions rejected'), + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(grantPermissionsForOrigin).not.toHaveBeenCalled(); + }); + + it('returns an error that the other permissions were not approved', async () => { + const { handler, requestPermissionsForOrigin, end } = + createMockedHandler(); + requestPermissionsForOrigin.mockRejectedValue( + new Error('other permissions rejected'), + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(end).toHaveBeenCalledWith( + new Error('other permissions rejected'), + ); + }); + }); + + describe('CAIP-25 equivalent permissions are not approved', () => { + it('does not grant the CAIP-25 permission', async () => { + const { + handler, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + } = createMockedHandler(); + requestCaip25ApprovalForOrigin.mockRejectedValue( + new Error('caip25 approval rejected'), + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(grantPermissionsForOrigin).not.toHaveBeenCalled(); + }); + + it('does not request approval for the other permissions', async () => { + const { + handler, + requestCaip25ApprovalForOrigin, + requestPermissionsForOrigin, + } = createMockedHandler(); + requestCaip25ApprovalForOrigin.mockRejectedValue( + new Error('caip25 approval rejected'), + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(requestPermissionsForOrigin).not.toHaveBeenCalled(); + }); + + it('returns an error that the CAIP-25 permissions were not approved', async () => { + const { handler, requestCaip25ApprovalForOrigin, end } = + createMockedHandler(); + requestCaip25ApprovalForOrigin.mockRejectedValue( + new Error('caip25 approval rejected'), + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(end).toHaveBeenCalledWith(new Error('caip25 approval rejected')); + }); + }); + }); + + describe('no permissions requested', () => { + it('returns an error by requesting empty permissions in params from the PermissionController if no permissions specified', async () => { + const { handler, requestPermissionsForOrigin, end } = + createMockedHandler(); + requestPermissionsForOrigin.mockRejectedValue( + new Error('failed to request unexpected permission'), + ); + + await handler( + getBaseRequest({ + params: [{}], + }), + ); + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({}); + expect(end).toHaveBeenCalledWith( + new Error('failed to request unexpected permission'), + ); + }); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.ts new file mode 100644 index 000000000000..ab0e1870c972 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.ts @@ -0,0 +1,187 @@ +import { pick } from 'lodash'; +import { isPlainObject } from '@metamask/controller-utils'; +import { + Caveat, + CaveatSpecificationConstraint, + invalidParams, + MethodNames, + PermissionController, + PermissionSpecificationConstraint, + RequestedPermissions, + ValidPermission, +} from '@metamask/permission-controller'; +import { + Caip25CaveatType, + Caip25CaveatValue, + Caip25EndowmentPermissionName, + getPermittedEthChainIds, +} from '@metamask/multichain'; +import { Json, JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { + AsyncJsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, +} from '@metamask/json-rpc-engine'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../../shared/constants/permissions'; +import { PermissionNames } from '../../../controllers/permissions'; + +export const requestPermissionsHandler = { + methodNames: [MethodNames.RequestPermissions], + implementation: requestPermissionsImplementation, + hookNames: { + getAccounts: true, + requestPermissionsForOrigin: true, + requestCaip25ApprovalForOrigin: true, + grantPermissionsForOrigin: true, + }, +}; + +type AbstractPermissionController = PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint +>; + +type GrantedPermissions = Awaited< + ReturnType +>[0]; + +/** + * Request Permissions implementation to be used in JsonRpcEngine middleware. + * + * @param req - The JsonRpcEngine request + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.getAccounts - A hook that returns the permitted eth accounts for the origin sorted by lastSelected. + * @param options.requestCaip25ApprovalForOrigin - A hook that requests approval for the CAIP-25 permission for the origin. + * @param options.grantPermissionsForOrigin - A hook that grants permission for the approved permissions for the origin. + * @param options.requestPermissionsForOrigin - A hook that requests permissions for the origin. + * @returns A promise that resolves to nothing + */ +async function requestPermissionsImplementation( + req: JsonRpcRequest<[RequestedPermissions]> & { origin: string }, + res: PendingJsonRpcResponse, + _next: AsyncJsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + getAccounts, + requestPermissionsForOrigin, + requestCaip25ApprovalForOrigin, + grantPermissionsForOrigin, + }: { + getAccounts: () => string[]; + requestPermissionsForOrigin: ( + requestedPermissions: RequestedPermissions, + ) => Promise<[GrantedPermissions]>; + requestCaip25ApprovalForOrigin: ( + requestedPermissions?: RequestedPermissions, + ) => Promise; + grantPermissionsForOrigin: (approvedPermissions: RequestedPermissions) => { + [Caip25EndowmentPermissionName]: ValidPermission< + typeof Caip25EndowmentPermissionName, + Caveat + >; + }; + }, +) { + const { params } = req; + + if (!Array.isArray(params) || !isPlainObject(params[0])) { + return end(invalidParams({ data: { request: req } })); + } + + const [requestedPermissions] = params; + const caip25EquivalentPermissions: Partial< + Pick + > = pick(requestedPermissions, [ + RestrictedMethods.eth_accounts, + PermissionNames.permittedChains, + ]); + delete requestedPermissions[RestrictedMethods.eth_accounts]; + delete requestedPermissions[PermissionNames.permittedChains]; + + const hasCaip25EquivalentPermissions = + Object.keys(caip25EquivalentPermissions).length > 0; + const hasOtherRequestedPermissions = + Object.keys(requestedPermissions).length > 0; + + let grantedPermissions: GrantedPermissions = {}; + + let caip25Approval; + if (hasCaip25EquivalentPermissions) { + try { + caip25Approval = await requestCaip25ApprovalForOrigin( + caip25EquivalentPermissions, + ); + } catch (error) { + return end(error as unknown as Error); + } + } + + if (hasOtherRequestedPermissions || !hasCaip25EquivalentPermissions) { + try { + const [frozenGrantedPermissions] = await requestPermissionsForOrigin( + requestedPermissions, + ); + grantedPermissions = { ...frozenGrantedPermissions }; + } catch (error) { + return end(error as unknown as Error); + } + } + + if (caip25Approval) { + const grantedCaip25Permissions = grantPermissionsForOrigin(caip25Approval); + const caip25Endowment = + grantedCaip25Permissions[Caip25EndowmentPermissionName]; + + const caip25CaveatValue = caip25Endowment?.caveats?.find( + ({ type }) => type === Caip25CaveatType, + )?.value as Caip25CaveatValue | undefined; + + if (!caip25CaveatValue) { + throw new Error( + `could not find ${Caip25CaveatType} in granted ${Caip25EndowmentPermissionName} permission.`, + ); + } + + // We cannot derive correct eth_accounts value directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + const ethAccounts = getAccounts(); + + grantedPermissions[RestrictedMethods.eth_accounts] = { + ...caip25Endowment, + parentCapability: RestrictedMethods.eth_accounts, + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ethAccounts, + }, + ], + }; + + const ethChainIds = getPermittedEthChainIds(caip25CaveatValue); + if (ethChainIds.length > 0) { + grantedPermissions[PermissionNames.permittedChains] = { + ...caip25Endowment, + parentCapability: PermissionNames.permittedChains, + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ethChainIds, + }, + ], + }; + } + } + + res.result = Object.values(grantedPermissions).filter( + ( + permission: ValidPermission> | undefined, + ): permission is ValidPermission> => + permission !== undefined, + ); + return end(); +} diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.test.ts new file mode 100644 index 000000000000..380db50222de --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.test.ts @@ -0,0 +1,150 @@ +import { invalidParams } from '@metamask/permission-controller'; +import { Caip25EndowmentPermissionName } from '@metamask/multichain'; +import { Json, JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; +import { PermissionNames } from '../../../controllers/permissions'; +import { RestrictedMethods } from '../../../../../shared/constants/permissions'; +import { revokePermissionsHandler } from './wallet-revokePermissions'; + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_revokePermissions', + params: [ + { + [Caip25EndowmentPermissionName]: {}, + otherPermission: {}, + }, + ], +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const revokePermissionsForOrigin = jest.fn(); + + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: JsonRpcRequest) => + revokePermissionsHandler.implementation(request, response, next, end, { + revokePermissionsForOrigin, + }); + + return { + response, + next, + end, + revokePermissionsForOrigin, + handler, + }; +}; + +describe('revokePermissionsHandler', () => { + it('returns an error if params is malformed', () => { + const { handler, end } = createMockedHandler(); + + const malformedRequest = { + ...baseRequest, + params: [], + }; + handler(malformedRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: malformedRequest } }), + ); + }); + + it('returns an error if params are empty', () => { + const { handler, end } = createMockedHandler(); + + const emptyRequest = { + ...baseRequest, + params: [{}], + }; + handler(emptyRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: emptyRequest } }), + ); + }); + + it('returns an error if params only contains the CAIP-25 permission', () => { + const { handler, end } = createMockedHandler(); + + const emptyRequest = { + ...baseRequest, + params: [ + { + [Caip25EndowmentPermissionName]: {}, + }, + ], + }; + handler(emptyRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: emptyRequest } }), + ); + }); + + // @ts-expect-error This is missing from the Mocha type definitions + describe.each([ + [RestrictedMethods.eth_accounts], + [PermissionNames.permittedChains], + ])('%s permission is specified', (permission: string) => { + it('revokes the CAIP-25 endowment permission', () => { + const { handler, revokePermissionsForOrigin } = createMockedHandler(); + + handler({ + ...baseRequest, + params: [ + { + [permission]: {}, + }, + ], + }); + expect(revokePermissionsForOrigin).toHaveBeenCalledWith([ + Caip25EndowmentPermissionName, + ]); + }); + + it('revokes other permissions specified', () => { + const { handler, revokePermissionsForOrigin } = createMockedHandler(); + + handler({ + ...baseRequest, + params: [ + { + [permission]: {}, + otherPermission: {}, + }, + ], + }); + expect(revokePermissionsForOrigin).toHaveBeenCalledWith([ + 'otherPermission', + Caip25EndowmentPermissionName, + ]); + }); + }); + + it('revokes permissions other than eth_accounts, permittedChains, CAIP-25 if specified', () => { + const { handler, revokePermissionsForOrigin } = createMockedHandler(); + + handler({ + ...baseRequest, + params: [ + { + [Caip25EndowmentPermissionName]: {}, + otherPermission: {}, + }, + ], + }); + expect(revokePermissionsForOrigin).toHaveBeenCalledWith([ + 'otherPermission', + ]); + }); + + it('returns null', () => { + const { handler, response } = createMockedHandler(); + + handler(baseRequest); + expect(response.result).toStrictEqual(null); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.ts new file mode 100644 index 000000000000..0d30087ac24e --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.ts @@ -0,0 +1,85 @@ +import { invalidParams, MethodNames } from '@metamask/permission-controller'; +import { + isNonEmptyArray, + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { Caip25EndowmentPermissionName } from '@metamask/multichain'; +import { + AsyncJsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, +} from '@metamask/json-rpc-engine'; +import { RestrictedMethods } from '../../../../../shared/constants/permissions'; +import { PermissionNames } from '../../../controllers/permissions'; + +export const revokePermissionsHandler = { + methodNames: [MethodNames.RevokePermissions], + implementation: revokePermissionsImplementation, + hookNames: { + revokePermissionsForOrigin: true, + updateCaveat: true, + }, +}; + +/** + * Revoke Permissions implementation to be used in JsonRpcEngine middleware. + * + * @param req - The JsonRpcEngine request + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.revokePermissionsForOrigin - A hook that revokes given permission keys for an origin + * @returns A promise that resolves to nothing + */ +function revokePermissionsImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: AsyncJsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + revokePermissionsForOrigin, + }: { + revokePermissionsForOrigin: (permissionKeys: string[]) => void; + }, +) { + const { params } = req; + + const param = params?.[0]; + + if (!param) { + return end(invalidParams({ data: { request: req } })); + } + + // For now, this API revokes the entire permission key + // even if caveats are specified. + const permissionKeys = Object.keys(param).filter( + (name) => name !== Caip25EndowmentPermissionName, + ); + + if (!isNonEmptyArray(permissionKeys)) { + return end(invalidParams({ data: { request: req } })); + } + + const caip25EquivalentPermissions: string[] = [ + RestrictedMethods.eth_accounts, + PermissionNames.permittedChains, + ]; + const relevantPermissionKeys = permissionKeys.filter( + (name: string) => !caip25EquivalentPermissions.includes(name), + ); + + const shouldRevokeLegacyPermission = + relevantPermissionKeys.length !== permissionKeys.length; + + if (shouldRevokeLegacyPermission) { + relevantPermissionKeys.push(Caip25EndowmentPermissionName); + } + + revokePermissionsForOrigin(relevantPermissionKeys); + + res.result = null; + + return end(); +} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 756dd163a862..b307cda155ab 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -19,18 +19,14 @@ import { createEngineStream } from '@metamask/json-rpc-middleware-stream'; import { ObservableStore } from '@metamask/obs-store'; import { storeAsStream } from '@metamask/obs-store/dist/asStream'; import { providerAsMiddleware } from '@metamask/eth-json-rpc-middleware'; -import { debounce, throttle, memoize, wrap } from 'lodash'; +import { debounce, throttle, memoize, wrap, pick } from 'lodash'; import { KeyringController, keyringBuilderFactory, } from '@metamask/keyring-controller'; import createFilterMiddleware from '@metamask/eth-json-rpc-filters'; import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; -import { - errorCodes as rpcErrorCodes, - JsonRpcError, - providerErrors, -} from '@metamask/rpc-errors'; +import { JsonRpcError, providerErrors } from '@metamask/rpc-errors'; import { Mutex } from 'await-semaphore'; import log from 'loglevel'; @@ -62,6 +58,7 @@ import { } from '@metamask/network-controller'; import { GasFeeController } from '@metamask/gas-fee-controller'; import { + MethodNames, PermissionController, PermissionDoesNotExistError, PermissionsRequestNotFoundError, @@ -155,6 +152,7 @@ import { import { Interface } from '@ethersproject/abi'; import { abiERC1155, abiERC721 } from '@metamask/metamask-eth-abis'; import { isEvmAccountType } from '@metamask/keyring-api'; +import { hexToBigInt, toCaipChainId } from '@metamask/utils'; import { normalize } from '@metamask/eth-sig-util'; import { AuthenticationController, @@ -164,6 +162,15 @@ import { NotificationServicesPushController, NotificationServicesController, } from '@metamask/notification-services-controller'; +import { + Caip25CaveatMutators, + Caip25CaveatType, + Caip25EndowmentPermissionName, + getEthAccounts, + setPermittedEthChainIds, + setEthAccounts, + addPermittedEthChainId, +} from '@metamask/multichain'; import { methodsRequiringNetworkSwitch, methodsThatCanSwitchNetworkWithoutApproval, @@ -195,11 +202,11 @@ import { } from '../../shared/constants/hardware-wallets'; import { KeyringType } from '../../shared/constants/keyring'; import { - CaveatTypes, RestrictedMethods, EndowmentPermissions, ExcludedSnapPermissions, ExcludedSnapEndowments, + CaveatTypes, } from '../../shared/constants/permissions'; import { UI_NOTIFICATIONS } from '../../shared/notifications'; import { MILLISECOND, MINUTE, SECOND } from '../../shared/constants/time'; @@ -298,8 +305,8 @@ import AccountTrackerController from './controllers/account-tracker-controller'; import createDupeReqFilterStream from './lib/createDupeReqFilterStream'; import createLoggerMiddleware from './lib/createLoggerMiddleware'; import { - createLegacyMethodMiddleware, - createMethodMiddleware, + createEthAccountsMethodMiddleware, + createEip1193MethodMiddleware, createUnsupportedMethodMiddleware, } from './lib/rpc-method-middleware'; import createOriginMiddleware from './lib/createOriginMiddleware'; @@ -330,8 +337,6 @@ import EncryptionPublicKeyController from './controllers/encryption-public-key'; import AppMetadataController from './controllers/app-metadata'; import { - CaveatFactories, - CaveatMutatorFactories, getCaveatSpecifications, diffMap, getPermissionBackgroundApiMethods, @@ -339,8 +344,8 @@ import { getPermittedAccountsByOrigin, getPermittedChainsByOrigin, NOTIFICATION_NAMES, - PermissionNames, unrestrictedMethods, + PermissionNames, } from './controllers/permissions'; import { MetaMetricsDataDeletionController } from './controllers/metametrics-data-deletion/metametrics-data-deletion'; import { DataDeletionService } from './services/data-deletion-service'; @@ -1265,7 +1270,7 @@ export default class MetamaskController extends EventEmitter { }), state: initState.PermissionController, caveatSpecifications: getCaveatSpecifications({ - getInternalAccounts: this.accountsController.listAccounts.bind( + listAccounts: this.accountsController.listAccounts.bind( this.accountsController, ), findNetworkClientIdByChainId: @@ -1274,42 +1279,7 @@ export default class MetamaskController extends EventEmitter { ), }), permissionSpecifications: { - ...getPermissionSpecifications({ - getInternalAccounts: this.accountsController.listAccounts.bind( - this.accountsController, - ), - getAllAccounts: this.keyringController.getAccounts.bind( - this.keyringController, - ), - captureKeyringTypesWithMissingIdentities: ( - internalAccounts = [], - accounts = [], - ) => { - const accountsMissingIdentities = accounts.filter( - (address) => - !internalAccounts.some( - (account) => - account.address.toLowerCase() === address.toLowerCase(), - ), - ); - const keyringTypesWithMissingIdentities = - accountsMissingIdentities.map((address) => - this.keyringController.getAccountKeyringType(address), - ); - - const internalAccountCount = internalAccounts.length; - - const accountTrackerCount = Object.keys( - this.accountTrackerController.state.accounts || {}, - ).length; - - captureException( - new Error( - `Attempt to get permission specifications failed because their were ${accounts.length} accounts, but ${internalAccountCount} identities, and the ${keyringTypesWithMissingIdentities} keyrings included accounts with missing identities. Meanwhile, there are ${accountTrackerCount} accounts in the account tracker.`, - ), - ); - }, - }), + ...getPermissionSpecifications(), ...this.getSnapPermissionSpecifications(), }, unrestrictedMethods, @@ -1427,6 +1397,7 @@ export default class MetamaskController extends EventEmitter { process.env.REJECT_INVALID_SNAPS_PLATFORM_VERSION; this.snapController = new SnapController({ + dynamicPermissions: ['endowment:caip25'], environmentEndowmentPermissions: Object.values(EndowmentPermissions), excludedPermissions: { ...ExcludedSnapPermissions, @@ -2460,18 +2431,13 @@ export default class MetamaskController extends EventEmitter { }, version, // account mgmt - getAccounts: async ( - { origin: innerOrigin }, - { suppressUnauthorizedError = true } = {}, - ) => { + getAccounts: ({ origin: innerOrigin }) => { if (innerOrigin === ORIGIN_METAMASK) { const selectedAddress = this.accountsController.getSelectedAccount().address; return selectedAddress ? [selectedAddress] : []; } else if (this.isUnlocked()) { - return await this.getPermittedAccounts(innerOrigin, { - suppressUnauthorizedError, - }); + return this.getPermittedAccounts(innerOrigin); } return []; // changing this is a breaking change }, @@ -3383,7 +3349,7 @@ export default class MetamaskController extends EventEmitter { return { isUnlocked: this.isUnlocked(), - accounts: await this.getPermittedAccounts(origin), + accounts: this.getPermittedAccounts(origin), ...providerNetworkState, }; } @@ -3966,7 +3932,10 @@ export default class MetamaskController extends EventEmitter { removePermissionsFor: this.removePermissionsFor, approvePermissionsRequest: this.acceptPermissionsRequest, rejectPermissionsRequest: this.rejectPermissionsRequest, - ...getPermissionBackgroundApiMethods(permissionController), + ...getPermissionBackgroundApiMethods({ + permissionController, + approvalController, + }), ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) connectCustodyAddresses: this.mmiController.connectCustodyAddresses.bind( @@ -5208,52 +5177,145 @@ export default class MetamaskController extends EventEmitter { } /** - * Gets the permitted accounts for the specified origin. Returns an empty - * array if no accounts are permitted. + * Checks that all accounts referenced have a matching InternalAccount. Sends + * an error to sentry for any accounts that were expected but are missing from the wallet. + * + * @param {InternalAccount[]} [internalAccounts] - The list of evm accounts the wallet knows about. + * @param {Hex[]} [accounts] - The list of evm accounts addresses that should exist. + */ + captureKeyringTypesWithMissingIdentities( + internalAccounts = [], + accounts = [], + ) { + const accountsMissingIdentities = accounts.filter( + (address) => + !internalAccounts.some( + (account) => account.address.toLowerCase() === address.toLowerCase(), + ), + ); + const keyringTypesWithMissingIdentities = accountsMissingIdentities.map( + (address) => this.keyringController.getAccountKeyringType(address), + ); + + const internalAccountCount = internalAccounts.length; + + const accountTrackerCount = Object.keys( + this.accountTrackerController.state.accounts || {}, + ).length; + + captureException( + new Error( + `Attempt to get permission specifications failed because their were ${accounts.length} accounts, but ${internalAccountCount} identities, and the ${keyringTypesWithMissingIdentities} keyrings included accounts with missing identities. Meanwhile, there are ${accountTrackerCount} accounts in the account tracker.`, + ), + ); + } + + /** + * Sorts a list of evm account addresses by most recently selected by using + * the lastSelected value for the matching InternalAccount object stored in state. + * + * @param {Hex[]} [accounts] - The list of evm accounts addresses to sort. + * @returns {Hex[]} The sorted evm accounts addresses. + */ + sortAccountsByLastSelected(accounts) { + const internalAccounts = this.accountsController.listAccounts(); + + return accounts.sort((firstAddress, secondAddress) => { + const firstAccount = internalAccounts.find( + (internalAccount) => + internalAccount.address.toLowerCase() === firstAddress.toLowerCase(), + ); + + const secondAccount = internalAccounts.find( + (internalAccount) => + internalAccount.address.toLowerCase() === secondAddress.toLowerCase(), + ); + + if (!firstAccount) { + this.captureKeyringTypesWithMissingIdentities( + internalAccounts, + accounts, + ); + throw new Error(`Missing identity for address: "${firstAddress}".`); + } else if (!secondAccount) { + this.captureKeyringTypesWithMissingIdentities( + internalAccounts, + accounts, + ); + throw new Error(`Missing identity for address: "${secondAddress}".`); + } else if ( + firstAccount.metadata.lastSelected === + secondAccount.metadata.lastSelected + ) { + return 0; + } else if (firstAccount.metadata.lastSelected === undefined) { + return 1; + } else if (secondAccount.metadata.lastSelected === undefined) { + return -1; + } + + return ( + secondAccount.metadata.lastSelected - firstAccount.metadata.lastSelected + ); + }); + } + + /** + * Gets the sorted permitted accounts for the specified origin. Returns an empty + * array if no accounts are permitted or the wallet is locked. Returns any permitted + * accounts if the wallet is locked and `ignoreLock` is true. This lock bypass is needed + * for the `eth_requestAccounts` & `wallet_getPermission` handlers both of which + * return permissioned accounts to the dapp when the wallet is locked. * * @param {string} origin - The origin whose exposed accounts to retrieve. - * @param {boolean} [suppressUnauthorizedError] - Suppresses the unauthorized error. + * @param {object} [options] - The options object + * @param {boolean} [options.ignoreLock] - If accounts should be returned even if the wallet is locked. * @returns {Promise} The origin's permitted accounts, or an empty * array. */ - async getPermittedAccounts( - origin, - { suppressUnauthorizedError = true } = {}, - ) { + getPermittedAccounts(origin, { ignoreLock } = {}) { + let caveat; try { - return await this.permissionController.executeRestrictedMethod( + caveat = this.permissionController.getCaveat( origin, - RestrictedMethods.eth_accounts, + Caip25EndowmentPermissionName, + Caip25CaveatType, ); - } catch (error) { - if ( - suppressUnauthorizedError && - error.code === rpcErrorCodes.provider.unauthorized - ) { - return []; + } catch (err) { + if (err instanceof PermissionDoesNotExistError) { + // suppress expected error in case that the origin + // does not have the target permission yet + } else { + throw err; } - throw error; } + + if (!caveat) { + return []; + } + + if (!this.isUnlocked() && !ignoreLock) { + return []; + } + + const ethAccounts = getEthAccounts(caveat.value); + return this.sortAccountsByLastSelected(ethAccounts); } /** * Stops exposing the specified chain ID to all third parties. - * Exposed chain IDs are stored in caveats of the `endowment:permitted-chains` - * permission. This method uses `PermissionController.updatePermissionsByCaveat` - * to remove the specified chain ID from every `endowment:permitted-chains` - * permission. If a permission only included this chain ID, the permission is - * revoked entirely. * * @param {string} targetChainId - The chain ID to stop exposing * to third parties. */ removeAllChainIdPermissions(targetChainId) { this.permissionController.updatePermissionsByCaveat( - CaveatTypes.restrictNetworkSwitching, - (existingChainIds) => - CaveatMutatorFactories[ - CaveatTypes.restrictNetworkSwitching - ].removeChainId(targetChainId, existingChainIds), + Caip25CaveatType, + (existingScopes) => + Caip25CaveatMutators[Caip25CaveatType].removeScope( + existingScopes, + toCaipChainId('eip155', hexToBigInt(targetChainId).toString(10)), + ), ); } @@ -5269,11 +5331,12 @@ export default class MetamaskController extends EventEmitter { */ removeAllAccountPermissions(targetAccount) { this.permissionController.updatePermissionsByCaveat( - CaveatTypes.restrictReturnedAccounts, - (existingAccounts) => - CaveatMutatorFactories[ - CaveatTypes.restrictReturnedAccounts - ].removeAccount(targetAccount, existingAccounts), + Caip25CaveatType, + (existingScopes) => + Caip25CaveatMutators[Caip25CaveatType].removeAccount( + existingScopes, + targetAccount, + ), ); } @@ -5304,6 +5367,210 @@ export default class MetamaskController extends EventEmitter { this.preferencesController.setSelectedAddress(importedAccountAddress); } + /** + * Prompts the user with permittedChains approval for given chainId. + * + * @param {string} origin - The origin to request approval for. + * @param {Hex} chainId - The chainId to add incrementally. + */ + async requestApprovalPermittedChainsPermission(origin, chainId) { + const id = nanoid(); + await this.approvalController.addAndShowApprovalRequest({ + id, + origin, + requestData: { + metadata: { + id, + origin, + }, + permissions: { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: [chainId], + }, + ], + }, + }, + }, + type: MethodNames.RequestPermissions, + }); + } + + /** + * Requests permittedChains permission for the specified origin + * and replaces any existing CAIP-25 permission with a new one. + * Allows for granting without prompting for user approval which + * would be used as part of flows like `wallet_addEthereumChain` + * requests where the addition of the network and the permitting + * of the chain are combined into one approval. + * + * @param {object} options - The options object + * @param {string} options.origin - The origin to request approval for. + * @param {Hex} options.chainId - The chainId to permit. + * @param {boolean} options.autoApprove - If the chain should be granted without prompting for user approval. + */ + async requestPermittedChainsPermission({ origin, chainId, autoApprove }) { + if (isSnapId(origin)) { + throw new Error( + `Cannot request permittedChains permission for Snaps with origin "${origin}"`, + ); + } + + if (!autoApprove) { + await this.requestApprovalPermittedChainsPermission(origin, chainId); + } + + let caveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + caveatValue = addPermittedEthChainId(caveatValue, chainId); + + this.permissionController.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValue, + }, + ], + }, + }, + }); + } + + /** + * Requests incremental permittedChains permission for the specified origin. + * and updates the existing CAIP-25 permission. + * Allows for granting without prompting for user approval which + * would be used as part of flows like `wallet_addEthereumChain` + * requests where the addition of the network and the permitting + * of the chain are combined into one approval. + * + * @param {object} options - The options object + * @param {string} options.origin - The origin to request approval for. + * @param {Hex} options.chainId - The chainId to add to the existing permittedChains. + * @param {boolean} options.autoApprove - If the chain should be granted without prompting for user approval. + */ + async requestPermittedChainsPermissionIncremental({ + origin, + chainId, + autoApprove, + }) { + if (isSnapId(origin)) { + throw new Error( + `Cannot request permittedChains permission for Snaps with origin "${origin}"`, + ); + } + + if (!autoApprove) { + await this.requestApprovalPermittedChainsPermission(origin, chainId); + } + + const caip25Caveat = this.permissionController.getCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + + const caveatValueWithChainsAdded = addPermittedEthChainId( + caip25Caveat.value, + chainId, + ); + + const ethAccounts = getEthAccounts(caip25Caveat.value); + const caveatValueWithAccountsSynced = setEthAccounts( + caveatValueWithChainsAdded, + ethAccounts, + ); + + this.permissionController.updateCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + caveatValueWithAccountsSynced, + ); + } + + /** + * Requests user approval for the CAIP-25 permission for the specified origin + * and returns a permissions object that must be passed to + * PermissionController.grantPermissions() to complete the permission granting. + * + * @param {string} origin - The origin to request approval for. + * @param requestedPermissions - The legacy permissions to request approval for. + * @returns the approved permissions object that must then be granted by calling the PermissionController. + */ + async requestCaip25Approval(origin, requestedPermissions = {}) { + const permissions = pick(requestedPermissions, [ + RestrictedMethods.eth_accounts, + PermissionNames.permittedChains, + ]); + + if (!permissions[RestrictedMethods.eth_accounts]) { + permissions[RestrictedMethods.eth_accounts] = {}; + } + + if (!permissions[PermissionNames.permittedChains]) { + permissions[PermissionNames.permittedChains] = {}; + } + + if (isSnapId(origin)) { + delete permissions[PermissionNames.permittedChains]; + } + + const id = nanoid(); + const legacyApproval = + await this.approvalController.addAndShowApprovalRequest({ + id, + origin, + requestData: { + metadata: { + id, + origin, + }, + permissions, + }, + type: MethodNames.RequestPermissions, + }); + + const newCaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + isMultichainOrigin: false, + }; + + const caveatValueWithChains = setPermittedEthChainIds( + newCaveatValue, + isSnapId(origin) ? [] : legacyApproval.approvedChainIds, + ); + + const caveatValueWithAccounts = setEthAccounts( + caveatValueWithChains, + legacyApproval.approvedAccounts, + ); + + return { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccounts, + }, + ], + }, + }; + } + // --------------------------------------------------------------------------- // Identity Management (signature operations) @@ -6022,7 +6289,7 @@ export default class MetamaskController extends EventEmitter { // Legacy RPC methods that need to be implemented _ahead of_ the permission // middleware. engine.push( - createLegacyMethodMiddleware({ + createEthAccountsMethodMiddleware({ getAccounts: this.getPermittedAccounts.bind(this, origin), }), ); @@ -6058,9 +6325,7 @@ export default class MetamaskController extends EventEmitter { // Unrestricted/permissionless RPC method implementations. // They must nevertheless be placed _behind_ the permission middleware. engine.push( - createMethodMiddleware({ - origin, - + createEip1193MethodMiddleware({ subjectType, // Miscellaneous @@ -6089,63 +6354,34 @@ export default class MetamaskController extends EventEmitter { ), // Permission-related getAccounts: this.getPermittedAccounts.bind(this, origin), - getPermissionsForOrigin: this.permissionController.getPermissions.bind( - this.permissionController, + requestCaip25ApprovalForOrigin: this.requestCaip25Approval.bind( + this, origin, ), - hasPermission: this.permissionController.hasPermission.bind( + grantPermissionsForOrigin: (approvedPermissions) => { + return this.permissionController.grantPermissions({ + subject: { origin }, + approvedPermissions, + }); + }, + getPermissionsForOrigin: this.permissionController.getPermissions.bind( this.permissionController, origin, ), - requestAccountsPermission: - this.permissionController.requestPermissions.bind( - this.permissionController, - { origin }, - { - eth_accounts: {}, - ...(!isSnapId(origin) && { - [PermissionNames.permittedChains]: {}, - }), - }, - ), - requestPermittedChainsPermission: (chainIds) => - this.permissionController.requestPermissionsIncremental( - { origin }, - { - [PermissionNames.permittedChains]: { - caveats: [ - CaveatFactories[CaveatTypes.restrictNetworkSwitching]( - chainIds, - ), - ], - }, - }, - ), - grantPermittedChainsPermissionIncremental: (chainIds) => - this.permissionController.grantPermissionsIncremental({ - subject: { origin }, - approvedPermissions: { - [PermissionNames.permittedChains]: { - caveats: [ - CaveatFactories[CaveatTypes.restrictNetworkSwitching]( - chainIds, - ), - ], - }, - }, + requestPermittedChainsPermissionForOrigin: (options) => + this.requestPermittedChainsPermission({ + ...options, + origin, + }), + requestPermittedChainsPermissionIncrementalForOrigin: (options) => + this.requestPermittedChainsPermissionIncremental({ + ...options, + origin, }), requestPermissionsForOrigin: (requestedPermissions) => this.permissionController.requestPermissions( { origin }, - { - ...(requestedPermissions[PermissionNames.eth_accounts] && { - [PermissionNames.permittedChains]: {}, - }), - ...(requestedPermissions[PermissionNames.permittedChains] && { - [PermissionNames.eth_accounts]: {}, - }), - ...requestedPermissions, - }, + requestedPermissions, ), revokePermissionsForOrigin: (permissionKeys) => { try { @@ -6182,12 +6418,12 @@ export default class MetamaskController extends EventEmitter { // network configuration-related setActiveNetwork: async (networkClientId) => { await this.networkController.setActiveNetwork(networkClientId); - // if the origin has the eth_accounts permission + // if the origin has the CAIP-25 permission // we set per dapp network selection state if ( this.permissionController.hasPermission( origin, - PermissionNames.eth_accounts, + Caip25EndowmentPermissionName, ) ) { this.selectedNetworkController.setNetworkClientIdForDomain( @@ -6224,6 +6460,10 @@ export default class MetamaskController extends EventEmitter { this.alertController.setWeb3ShimUsageRecorded.bind( this.alertController, ), + updateCaveat: this.permissionController.updateCaveat.bind( + this.permissionController, + origin, + ), ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) handleMmiAuthenticate: @@ -6599,12 +6839,12 @@ export default class MetamaskController extends EventEmitter { * account(s) are currently accessible, if any. */ _onUnlock() { - this.notifyAllConnections(async (origin) => { + this.notifyAllConnections((origin) => { return { method: NOTIFICATION_NAMES.unlockStateChanged, params: { isUnlocked: true, - accounts: await this.getPermittedAccounts(origin), + accounts: this.getPermittedAccounts(origin), }, }; }); @@ -7205,7 +7445,7 @@ export default class MetamaskController extends EventEmitter { ]); } - async _notifyAccountsChange(origin, newAccounts) { + _notifyAccountsChange(origin, newAccounts) { if (this.isUnlocked()) { this.notifyConnections(origin, { method: NOTIFICATION_NAMES.accountsChanged, @@ -7218,7 +7458,7 @@ export default class MetamaskController extends EventEmitter { newAccounts : // If the length is 2 or greater, we have to execute // `eth_accounts` vi this method. - await this.getPermittedAccounts(origin), + this.getPermittedAccounts(origin), }); } diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index a8dfee9fe51e..fc2309b2646c 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -31,6 +31,11 @@ import { import ObjectMultiplex from '@metamask/object-multiplex'; import { TrezorKeyring } from '@metamask/eth-trezor-keyring'; import { LedgerKeyring } from '@metamask/eth-ledger-bridge-keyring'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; +import { PermissionDoesNotExistError } from '@metamask/permission-controller'; import { createTestProviderTools } from '../../test/stub/provider'; import { HardwareDeviceNames } from '../../shared/constants/hardware-wallets'; import { KeyringType } from '../../shared/constants/keyring'; @@ -43,6 +48,10 @@ import { createMockInternalAccount } from '../../test/jest/mocks'; import { mockNetworkState } from '../../test/stub/networks'; import { ENVIRONMENT } from '../../development/build/constants'; import { SECOND } from '../../shared/constants/time'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../shared/constants/permissions'; import { BalancesController as MultichainBalancesController, BTC_BALANCES_UPDATE_TIME as MULTICHAIN_BALANCES_UPDATE_TIME, @@ -53,6 +62,7 @@ import { METAMASK_COOKIE_HANDLER } from './constants/stream'; import MetaMaskController, { ONE_KEY_VIA_TREZOR_MINOR_VERSION, } from './metamask-controller'; +import { PermissionNames } from './controllers/permissions'; const { Ganache } = require('../../test/e2e/seeder/ganache'); @@ -109,10 +119,13 @@ const createLoggerMiddlewareMock = () => (req, res, next) => { jest.mock('./lib/createLoggerMiddleware', () => createLoggerMiddlewareMock); const rpcMethodMiddlewareMock = { - createMethodMiddleware: () => (_req, _res, next, _end) => { + createEip1193MethodMiddleware: () => (_req, _res, next, _end) => { + next(); + }, + createEthAccountsMethodMiddleware: () => (_req, _res, next, _end) => { next(); }, - createLegacyMethodMiddleware: () => (_req, _res, next, _end) => { + createMultichainMethodMiddleware: () => (_req, _res, next, _end) => { next(); }, createUnsupportedMethodMiddleware: () => (_req, _res, next, _end) => { @@ -800,6 +813,1258 @@ describe('MetaMaskController', () => { }); }); + describe('#getPermittedAccounts', () => { + it('gets the CAIP-25 caveat value for the origin', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue(); + + metamaskController.getPermittedAccounts('test.com'); + + expect( + metamaskController.permissionController.getCaveat, + ).toHaveBeenCalledWith( + 'test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('returns empty array if there is no CAIP-25 permission for the origin', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }); + + expect( + metamaskController.getPermittedAccounts('test.com'), + ).toStrictEqual([]); + }); + + it('throws an error if getCaveat fails unexpectedly', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockImplementation(() => { + throw new Error('unexpected getCaveat error'); + }); + + expect(() => { + metamaskController.getPermittedAccounts('test.com'); + }).toThrow(new Error(`unexpected getCaveat error`)); + }); + + describe('the wallet is locked', () => { + beforeEach(() => { + jest.spyOn(metamaskController, 'isUnlocked').mockReturnValue(false); + }); + + it('returns empty array if there is a CAIP-25 permission for the origin and ignoreLock is false', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }, + }, + }); + + expect( + metamaskController.getPermittedAccounts('test.com', { + ignoreLock: false, + }), + ).toStrictEqual([]); + }); + + it('returns accounts if there is a CAIP-25 permission for the origin and ignoreLock is true', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }, + }, + }); + jest + .spyOn(metamaskController, 'sortAccountsByLastSelected') + .mockReturnValue(['not_empty']); + + expect( + metamaskController.getPermittedAccounts('test.com', { + ignoreLock: true, + }), + ).toStrictEqual(['not_empty']); + }); + }); + + describe('the wallet is unlocked', () => { + beforeEach(() => { + jest.spyOn(metamaskController, 'isUnlocked').mockReturnValue(true); + }); + + it('sorts the eth accounts from the CAIP-25 permission', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }, + }, + }); + jest + .spyOn(metamaskController, 'sortAccountsByLastSelected') + .mockReturnValue([]); + + metamaskController.getPermittedAccounts('test.com'); + expect( + metamaskController.sortAccountsByLastSelected, + ).toHaveBeenCalledWith(['0xdead', '0xbeef']); + }); + + it('returns the sorted eth accounts from the CAIP-25 permission', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead', 'eip155:1:0xbeef'], + }, + }, + }, + }); + jest + .spyOn(metamaskController, 'sortAccountsByLastSelected') + .mockReturnValue(['0xbeef', '0xdead']); + + expect( + metamaskController.getPermittedAccounts('test.com'), + ).toStrictEqual(['0xbeef', '0xdead']); + }); + }); + }); + + describe('#requestCaip25Approval', () => { + it('requests approval with well formed id and origin', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedAccounts: [], + approvedChainIds: [], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Approval('test.com', {}); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + requestData: expect.objectContaining({ + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + }, + }), + type: 'wallet_requestPermissions', + }), + ); + + const [params] = + metamaskController.approvalController.addAndShowApprovalRequest.mock + .calls[0]; + expect(params.id).toStrictEqual(params.requestData.metadata.id); + }); + + it('requests approval from the ApprovalController for eth_accounts and permittedChains when only eth_accounts is specified in params and origin is not snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedAccounts: [], + approvedChainIds: [], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Approval('test.com', { + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + }); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + requestData: { + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + }, + permissions: { + [RestrictedMethods.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + [PermissionNames.permittedChains]: {}, + }, + }, + type: 'wallet_requestPermissions', + }), + ); + }); + + it('requests approval from the ApprovalController for eth_accounts and permittedChains when only permittedChains is specified in params and origin is not snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedAccounts: [], + approvedChainIds: [], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Approval('test.com', { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + requestData: { + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + }, + permissions: { + [RestrictedMethods.eth_accounts]: {}, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + }, + type: 'wallet_requestPermissions', + }), + ); + }); + + it('requests approval from the ApprovalController for eth_accounts and permittedChains when both are specified in params and origin is not snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedAccounts: [], + approvedChainIds: [], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Approval('test.com', { + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + requestData: { + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + }, + permissions: { + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + }, + type: 'wallet_requestPermissions', + }), + ); + }); + + it('requests approval from the ApprovalController for only eth_accounts when only eth_accounts is specified in params and origin is snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedAccounts: [], + approvedChainIds: [], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Approval('npm:snap', { + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + }); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'npm:snap', + requestData: { + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'npm:snap', + }, + permissions: { + [RestrictedMethods.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + }, + }, + type: 'wallet_requestPermissions', + }), + ); + }); + + it('requests approval from the ApprovalController for only eth_accounts when only permittedChains is specified in params and origin is snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedAccounts: [], + approvedChainIds: [], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Approval('npm:snap', { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'npm:snap', + requestData: { + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'npm:snap', + }, + permissions: { + [PermissionNames.eth_accounts]: {}, + }, + }, + type: 'wallet_requestPermissions', + }), + ); + }); + + it('requests approval from the ApprovalController for only eth_accounts when both eth_accounts and permittedChains are specified in params and origin is snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedAccounts: [], + approvedChainIds: [], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Approval('npm:snap', { + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'npm:snap', + requestData: { + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'npm:snap', + }, + permissions: { + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['foo'], + }, + ], + }, + }, + }, + type: 'wallet_requestPermissions', + }), + ); + }); + + it('throws an error if the eth_accounts and permittedChains approval is rejected', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockRejectedValue(new Error('approval rejected')); + + await expect(() => + metamaskController.requestCaip25Approval('test.com', { + eth_accounts: {}, + }), + ).rejects.toThrow(new Error('approval rejected')); + }); + + it('returns the CAIP-25 approval with eth accounts, chainIds, and isMultichainOrigin: false if origin is not snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }); + + const result = await metamaskController.requestCaip25Approval( + 'test.com', + {}, + ); + + expect(result).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + }); + + it('returns the CAIP-25 approval with approved accounts for the `wallet:eip155` scope (and no approved chainIds) with isMultichainOrigin: false if origin is snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + const result = await metamaskController.requestCaip25Approval( + 'npm:snap', + {}, + ); + + expect(result).toStrictEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + }); + }); + + describe('requestApprovalPermittedChainsPermission', () => { + it('requests approval with well formed id and origin', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue(); + + await metamaskController.requestApprovalPermittedChainsPermission( + 'test.com', + '0x1', + ); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + requestData: expect.objectContaining({ + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + }, + permissions: { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1'], + }, + ], + }, + }, + }), + type: 'wallet_requestPermissions', + }), + ); + + const [params] = + metamaskController.approvalController.addAndShowApprovalRequest.mock + .calls[0]; + expect(params.id).toStrictEqual(params.requestData.metadata.id); + }); + + it('throws if the approval is rejected', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockRejectedValue(new Error('approval rejected')); + + await expect(() => + metamaskController.requestApprovalPermittedChainsPermission( + 'test.com', + '0x1', + ), + ).rejects.toThrow(new Error('approval rejected')); + }); + }); + + describe('requestPermittedChainsPermission', () => { + it('throws if the origin is snapId', async () => { + await expect(() => + metamaskController.requestPermittedChainsPermission({ + origin: 'npm:snap', + chainId: '0x1', + }), + ).rejects.toThrow( + new Error( + 'Cannot request permittedChains permission for Snaps with origin "npm:snap"', + ), + ); + }); + + it('requests approval for permittedChains permissions from the ApprovalController if autoApprove: false', async () => { + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue(); + + await metamaskController.requestPermittedChainsPermission({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + }); + + expect( + metamaskController.requestApprovalPermittedChainsPermission, + ).toHaveBeenCalledWith('test.com', '0x1'); + }); + + it('throws if permittedChains approval is rejected', async () => { + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockRejectedValue(new Error('approval rejected')); + + await expect(() => + metamaskController.requestPermittedChainsPermission({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + }), + ).rejects.toThrow(new Error('approval rejected')); + }); + + it('does not request approval for permittedChains permissions from the ApprovalController if autoApprove: true', async () => { + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue(); + + await metamaskController.requestPermittedChainsPermission({ + origin: 'test.com', + chainId: '0x1', + autoApprove: true, + }); + + expect( + metamaskController.requestApprovalPermittedChainsPermission, + ).not.toHaveBeenCalled(); + }); + + it('grants the CAIP-25 permission', async () => { + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue(); + + await metamaskController.requestPermittedChainsPermission({ + origin: 'test.com', + chainId: '0x1', + }); + + expect( + metamaskController.permissionController.grantPermissions, + ).toHaveBeenCalledWith({ + subject: { origin: 'test.com' }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }); + }); + + it('throws if CAIP-25 permission grant fails', async () => { + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockImplementation(() => { + throw new Error('grant failed'); + }); + + await expect(() => + metamaskController.requestPermittedChainsPermission({ + origin: 'test.com', + chainId: '0x1', + }), + ).rejects.toThrow(new Error('grant failed')); + }); + }); + + describe('requestPermittedChainsPermissionIncremental', () => { + it('throws if the origin is snapId', async () => { + await expect(() => + metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'npm:snap', + chainId: '0x1', + }), + ).rejects.toThrow( + new Error( + 'Cannot request permittedChains permission for Snaps with origin "npm:snap"', + ), + ); + }); + + it('gets the CAIP-25 caveat', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'updateCaveat') + .mockReturnValue(); + + await metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + }); + + expect( + metamaskController.permissionController.getCaveat, + ).toHaveBeenCalledWith( + 'test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('throws if getting the caveat fails', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockImplementation(() => { + throw new Error('no caveat found'); + }); + + await expect(() => + metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + }), + ).rejects.toThrow(new Error('no caveat found')); + }); + + it('requests permittedChains approval if autoApprove: false', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'updateCaveat') + .mockReturnValue(); + + await metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + }); + + expect( + metamaskController.requestApprovalPermittedChainsPermission, + ).toHaveBeenCalledWith('test.com', '0x1'); + }); + + it('throws if permittedChains approval is rejected', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockRejectedValue(new Error('approval rejected')); + + await expect(() => + metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + }), + ).rejects.toThrow(new Error('approval rejected')); + }); + + it('does not request permittedChains approval if autoApprove: true', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'updateCaveat') + .mockReturnValue(); + + await metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: true, + }); + + expect( + metamaskController.requestApprovalPermittedChainsPermission, + ).not.toHaveBeenCalled(); + }); + + it('updates the CAIP-25 permission', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }); + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'updateCaveat') + .mockReturnValue(); + + await metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + }); + + expect( + metamaskController.permissionController.updateCaveat, + ).toHaveBeenCalledWith( + 'test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + ); + }); + + it('throws if CAIP-25 permission update fails', async () => { + jest + .spyOn(metamaskController.permissionController, 'getCaveat') + .mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + jest + .spyOn(metamaskController, 'requestApprovalPermittedChainsPermission') + .mockResolvedValue(); + jest + .spyOn(metamaskController.permissionController, 'updateCaveat') + .mockImplementation(() => { + throw new Error('grant failed'); + }); + + await expect(() => + metamaskController.requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + }), + ).rejects.toThrow(new Error('grant failed')); + }); + }); + + describe('#sortAccountsByLastSelected', () => { + it('returns the keyring accounts in lastSelected order', () => { + jest + .spyOn(metamaskController.accountsController, 'listAccounts') + .mockReturnValueOnce([ + { + address: '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + id: '21066553-d8c8-4cdc-af33-efc921cd3ca9', + metadata: { + name: 'Test Account', + lastSelected: 1, + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + { + address: '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + id: '0bd7348e-bdfe-4f67-875c-de831a583857', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + { + address: '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + id: 'ff8fda69-d416-4d25-80a2-efb77bc7d4ad', + metadata: { + name: 'Test Account', + keyring: { + type: 'HD Key Tree', + }, + lastSelected: 3, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + { + address: '0x04eBa9B766477d8eCA77F5f0e67AE1863C95a7E3', + id: '0bd7348e-bdfe-4f67-875c-de831a583857', + metadata: { + name: 'Test Account', + lastSelected: 3, + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + ]); + jest + .spyOn(metamaskController, 'captureKeyringTypesWithMissingIdentities') + .mockImplementation(() => { + // noop + }); + + expect( + metamaskController.sortAccountsByLastSelected([ + '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + '0x04eBa9B766477d8eCA77F5f0e67AE1863C95a7E3', + ]), + ).toStrictEqual([ + '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + '0x04eBa9B766477d8eCA77F5f0e67AE1863C95a7E3', + '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + ]); + }); + + it('throws if a keyring account is missing an address (case 1)', () => { + const internalAccounts = [ + { + address: '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + id: '0bd7348e-bdfe-4f67-875c-de831a583857', + metadata: { + name: 'Test Account', + lastSelected: 2, + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + { + address: '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + id: 'ff8fda69-d416-4d25-80a2-efb77bc7d4ad', + metadata: { + name: 'Test Account', + lastSelected: 3, + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + ]; + jest + .spyOn(metamaskController.accountsController, 'listAccounts') + .mockReturnValueOnce(internalAccounts); + jest + .spyOn(metamaskController, 'captureKeyringTypesWithMissingIdentities') + .mockImplementation(() => { + // noop + }); + + expect(() => + metamaskController.sortAccountsByLastSelected([ + '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + ]), + ).toThrow( + 'Missing identity for address: "0x7A2Bd22810088523516737b4Dc238A4bC37c23F2".', + ); + expect( + metamaskController.captureKeyringTypesWithMissingIdentities, + ).toHaveBeenCalledWith(internalAccounts, [ + '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + ]); + }); + + it('throws if a keyring account is missing an address (case 2)', () => { + const internalAccounts = [ + { + address: '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Test Account', + lastSelected: 1, + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + { + address: '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + id: 'ff8fda69-d416-4d25-80a2-efb77bc7d4ad', + metadata: { + name: 'Test Account', + lastSelected: 3, + keyring: { + type: 'HD Key Tree', + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }, + ]; + jest + .spyOn(metamaskController.accountsController, 'listAccounts') + .mockReturnValueOnce(internalAccounts); + jest + .spyOn(metamaskController, 'captureKeyringTypesWithMissingIdentities') + .mockImplementation(() => { + // noop + }); + + expect(() => + metamaskController.sortAccountsByLastSelected([ + '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + ]), + ).toThrow( + 'Missing identity for address: "0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3".', + ); + expect( + metamaskController.captureKeyringTypesWithMissingIdentities, + ).toHaveBeenCalledWith(internalAccounts, [ + '0x7A2Bd22810088523516737b4Dc238A4bC37c23F2', + '0x7152f909e5EB3EF198f17e5Cb087c5Ced88294e3', + '0xDe70d2FF1995DC03EF1a3b584e3ae14da020C616', + ]); + }); + }); + describe('#getApi', () => { it('getState', () => { const getApi = metamaskController.getApi(); diff --git a/app/scripts/migrations/139.test.ts b/app/scripts/migrations/139.test.ts new file mode 100644 index 000000000000..6291e72de241 --- /dev/null +++ b/app/scripts/migrations/139.test.ts @@ -0,0 +1,1253 @@ +import { migrate, version } from './139'; + +const PermissionNames = { + eth_accounts: 'eth_accounts', + permittedChains: 'endowment:permitted-chains', +}; + +const sentryCaptureExceptionMock = jest.fn(); + +global.sentry = { + captureException: sentryCaptureExceptionMock, +}; + +const oldVersion = 138; + +describe('migration #139', () => { + afterEach(() => jest.resetAllMocks()); + + it('updates the version metadata', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: {}, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.meta).toStrictEqual({ version }); + }); + + it('does nothing if PermissionController state is missing', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + NetworkController: {}, + SelectedNetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if PermissionController state is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: 'foo', + NetworkController: {}, + SelectedNetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.PermissionController is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if NetworkController state is missing', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: {}, + SelectedNetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.NetworkController is undefined`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if NetworkController state is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: {}, + NetworkController: 'foo', + SelectedNetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.NetworkController is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if SelectedNetworkController state is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: {}, + NetworkController: {}, + SelectedNetworkController: 'foo', + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.SelectedNetworkController is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if PermissionController.subjects is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: 'foo', + }, + NetworkController: {}, + SelectedNetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.PermissionController.subjects is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if NetworkController.selectedNetworkClientId is not a non-empty string', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: {}, + }, + NetworkController: { + selectedNetworkClientId: {}, + }, + SelectedNetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.NetworkController.selectedNetworkClientId is object`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if NetworkController.networkConfigurationsByChainId is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: {}, + }, + NetworkController: { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: 'foo', + }, + SelectedNetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if SelectedNetworkController.domains is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: {}, + }, + NetworkController: { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: {}, + }, + SelectedNetworkController: { + domains: 'foo', + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.SelectedNetworkController.domains is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if NetworkController.networkConfigurationsByChainId[] is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: {}, + }, + NetworkController: { + selectedNetworkClientId: 'nonExistentNetworkClientId', + networkConfigurationsByChainId: { + '0x1': 'foo', + }, + }, + SelectedNetworkController: { + domains: {}, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["0x1"] is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if NetworkController.networkConfigurationsByChainId[].rpcEndpoints is not an array', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: {}, + }, + NetworkController: { + selectedNetworkClientId: 'nonExistentNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + rpcEndpoints: 'foo', + }, + }, + }, + SelectedNetworkController: { + domains: {}, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["0x1"].rpcEndpoints is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if NetworkController.networkConfigurationsByChainId[].rpcEndpoints[] is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: {}, + }, + NetworkController: { + selectedNetworkClientId: 'nonExistentNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + rpcEndpoints: ['foo'], + }, + }, + }, + SelectedNetworkController: { + domains: {}, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["0x1"].rpcEndpoints[] is string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if the currently selected network client is neither built in nor exists in NetworkController.networkConfigurationsByChainId', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: { + subjects: {}, + }, + NetworkController: { + selectedNetworkClientId: 'nonExistentNetworkClientId', + networkConfigurationsByChainId: {}, + }, + SelectedNetworkController: { + domains: {}, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: No chainId found for selectedNetworkClientId "nonExistentNetworkClientId"`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if a subject is not an object', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + NetworkController: { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: {}, + }, + SelectedNetworkController: { + domains: {}, + }, + PermissionController: { + subjects: { + 'test.com': 'foo', + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: Invalid subject for origin "test.com" of type string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it("does nothing if a subject's permissions is not an object", async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + NetworkController: { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: {}, + }, + SelectedNetworkController: { + domains: {}, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: 'foo', + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: Invalid permissions for origin "test.com" of type string`, + ), + ); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing if neither eth_accounts nor permittedChains permissions have been granted', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + NetworkController: { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: {}, + }, + SelectedNetworkController: { + domains: {}, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + NetworkController: { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: {}, + }, + SelectedNetworkController: { + domains: {}, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + }, + }, + }, + }, + }); + }); + + // @ts-expect-error This function is missing from the Mocha type definitions + describe.each([ + [ + 'built-in', + { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: {}, + }, + '1', + ], + [ + 'custom', + { + selectedNetworkClientId: 'customId', + networkConfigurationsByChainId: { + '0xf': { + rpcEndpoints: [ + { + networkClientId: 'customId', + }, + ], + }, + }, + }, + '15', + ], + ])( + 'the currently selected network client is %s', + ( + _type: string, + NetworkController: { + networkConfigurationsByChainId: Record< + string, + { + rpcEndpoints: { networkClientId: string }[]; + } + >; + } & Record, + chainId: string, + ) => { + const baseData = () => ({ + PermissionController: { + subjects: {}, + }, + NetworkController, + SelectedNetworkController: { + domains: {}, + }, + }); + const baseEthAccountsPermissionMetadata = { + id: '1', + date: 2, + invoker: 'test.com', + parentCapability: PermissionNames.eth_accounts, + }; + const currentScope = `eip155:${chainId}`; + + it('does nothing when eth_accounts and permittedChains permissions are missing metadata', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + invoker: 'test.com', + parentCapability: PermissionNames.eth_accounts, + date: 2, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + [PermissionNames.permittedChains]: { + invoker: 'test.com', + parentCapability: PermissionNames.permittedChains, + date: 2, + caveats: [ + { + type: 'restrictNetworkSwitching', + value: ['0xa', '0x64'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing when there are malformed network configurations (even if there is a valid networkConfiguration that matches the selected network client)', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + NetworkController: { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + '0x1': { + rpcEndpoints: [ + { + networkClientId: 'mainnet', + }, + ], + }, + '0xInvalid': 'invalid-network-configuration', + '0xa': { + rpcEndpoints: [ + { + networkClientId: 'bar', + }, + ], + }, + }, + }, + SelectedNetworkController: { + domains: { + 'test.com': 'bar', + }, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('replaces the eth_accounts permission with a CAIP-25 permission using the eth_accounts value for the currently selected chain id when the origin does not have its own network client', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + [currentScope]: { + accounts: [ + `${currentScope}:0xdeadbeef`, + `${currentScope}:0x999`, + ], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + + it('replaces the eth_accounts permission with a CAIP-25 permission using the globally selected chain id value for the currently selected chain id when the origin does have its own network client that cannot be resolved', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + SelectedNetworkController: { + domains: { + 'test.com': 'doesNotExist', + }, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: No chainId found for networkClientIdForOrigin "doesNotExist"`, + ), + ); + + expect(newStorage.data).toStrictEqual({ + ...baseData(), + SelectedNetworkController: { + domains: { + 'test.com': 'doesNotExist', + }, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + [currentScope]: { + accounts: [ + `${currentScope}:0xdeadbeef`, + `${currentScope}:0x999`, + ], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + + it('replaces the eth_accounts permission with a CAIP-25 permission using the eth_accounts value for the origin chain id when the origin does have its own network client and it exists in the built-in networks', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + SelectedNetworkController: { + domains: { + 'test.com': 'sepolia', + }, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + SelectedNetworkController: { + domains: { + 'test.com': 'sepolia', + }, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:11155111': { + accounts: [ + 'eip155:11155111:0xdeadbeef', + 'eip155:11155111:0x999', + ], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + + it('replaces the eth_accounts permission with a CAIP-25 permission using the eth_accounts value without permitted chains when the origin is snapId', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + PermissionController: { + subjects: { + 'npm:snap': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + PermissionController: { + subjects: { + 'npm:snap': { + permissions: { + unrelated: { + foo: 'bar', + }, + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + + it('replaces the eth_accounts permission with a CAIP-25 permission using the eth_accounts value for the origin chain id when the origin does have its own network client and it exists in the custom configurations', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + NetworkController: { + ...baseData().NetworkController, + networkConfigurationsByChainId: { + ...baseData().NetworkController.networkConfigurationsByChainId, + '0xa': { + rpcEndpoints: [ + { + networkClientId: 'customNetworkClientId', + }, + ], + }, + }, + }, + SelectedNetworkController: { + domains: { + 'test.com': 'customNetworkClientId', + }, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + NetworkController: { + ...baseData().NetworkController, + networkConfigurationsByChainId: { + ...baseData().NetworkController.networkConfigurationsByChainId, + '0xa': { + rpcEndpoints: [ + { + networkClientId: 'customNetworkClientId', + }, + ], + }, + }, + }, + SelectedNetworkController: { + domains: { + 'test.com': 'customNetworkClientId', + }, + }, + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:10': { + accounts: [ + 'eip155:10:0xdeadbeef', + 'eip155:10:0x999', + ], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + + it('does not create a CAIP-25 permission when eth_accounts permission is missing', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictNetworkSwitching', + value: ['0xa', '0x64'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + }, + }, + }, + }, + }); + }); + + it('replaces both eth_accounts and permittedChains permission with a CAIP-25 permission using the values from both permissions', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + [PermissionNames.permittedChains]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictNetworkSwitching', + value: ['0xa', '0x64'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + unrelated: { + foo: 'bar', + }, + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:10': { + accounts: [ + 'eip155:10:0xdeadbeef', + 'eip155:10:0x999', + ], + }, + 'eip155:100': { + accounts: [ + 'eip155:100:0xdeadbeef', + 'eip155:100:0x999', + ], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + + it('replaces permissions for each subject', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + }, + }, + 'test2.com': { + permissions: { + [PermissionNames.eth_accounts]: { + ...baseEthAccountsPermissionMetadata, + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + [currentScope]: { + accounts: [`${currentScope}:0xdeadbeef`], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + 'test2.com': { + permissions: { + 'endowment:caip25': { + ...baseEthAccountsPermissionMetadata, + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + [currentScope]: { + accounts: [`${currentScope}:0xdeadbeef`], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + }, + ); +}); diff --git a/app/scripts/migrations/139.ts b/app/scripts/migrations/139.ts new file mode 100644 index 000000000000..4a9be59a58ce --- /dev/null +++ b/app/scripts/migrations/139.ts @@ -0,0 +1,430 @@ +import { hasProperty, hexToBigInt, isObject } from '@metamask/utils'; +import type { + CaipChainId, + CaipAccountId, + Json, + Hex, + NonEmptyArray, +} from '@metamask/utils'; +import { cloneDeep } from 'lodash'; +import type { + Caveat, + PermissionConstraint, + ValidPermission, +} from '@metamask/permission-controller'; + +type VersionedData = { + meta: { version: number }; + data: Record; +}; + +export const version = 139; + +// In-lined from @metamask/multichain +const Caip25CaveatType = 'authorizedScopes'; +const Caip25EndowmentPermissionName = 'endowment:caip25'; + +type InternalScopeObject = { + accounts: CaipAccountId[]; +}; + +type InternalScopesObject = Record; + +type Caip25CaveatValue = { + requiredScopes: InternalScopesObject; + optionalScopes: InternalScopesObject; + sessionProperties?: Record; + isMultichainOrigin: boolean; +}; + +// Locally defined types +type Caip25Caveat = Caveat; +type Caip25Permission = ValidPermission< + typeof Caip25EndowmentPermissionName, + Caip25Caveat +>; + +const PermissionNames = { + eth_accounts: 'eth_accounts', + permittedChains: 'endowment:permitted-chains', +} as const; + +// a map of the networks built into the extension at the time of this migration to their chain IDs +// copied from shared/constants/network.ts (https://github.com/MetaMask/metamask-extension/blob/5b5c04a16fb7937a6e9d59b1debe4713978ef39d/shared/constants/network.ts#L535) +const BUILT_IN_NETWORKS: ReadonlyMap = new Map([ + ['sepolia', '0xaa36a7'], + ['mainnet', '0x1'], + ['linea-sepolia', '0xe705'], + ['linea-mainnet', '0xe708'], +]); + +const snapsPrefixes = ['npm:', 'local:'] as const; + +function isPermissionConstraint(obj: unknown): obj is PermissionConstraint { + return ( + isObject(obj) && + obj !== null && + hasProperty(obj, 'caveats') && + Array.isArray(obj.caveats) && + obj.caveats.length > 0 && + hasProperty(obj, 'date') && + typeof obj.date === 'number' && + hasProperty(obj, 'id') && + typeof obj.id === 'string' && + hasProperty(obj, 'invoker') && + typeof obj.invoker === 'string' && + hasProperty(obj, 'parentCapability') && + typeof obj.parentCapability === 'string' + ); +} + +function isNonEmptyArrayOfStrings(obj: unknown): obj is NonEmptyArray { + return ( + Array.isArray(obj) && + obj.length > 0 && + obj.every((item) => typeof item === 'string') + ); +} + +/** + * This migration transforms `eth_accounts` and `permittedChains` permissions into + * an equivalent CAIP-25 permission. + * + * @param originalVersionedData - Versioned MetaMask extension state, exactly + * what we persist to dist. + * @param originalVersionedData.meta - State metadata. + * @param originalVersionedData.meta.version - The current state version. + * @param originalVersionedData.data - The persisted MetaMask state, keyed by + * controller. + * @returns Updated versioned MetaMask extension state. + */ +export async function migrate( + originalVersionedData: VersionedData, +): Promise { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + + const newState = transformState(versionedData.data); + versionedData.data = newState as Record; + return versionedData; +} + +function transformState(oldState: Record) { + const newState = cloneDeep(oldState); + if (!hasProperty(newState, 'PermissionController')) { + return oldState; + } + + if (!isObject(newState.PermissionController)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.PermissionController is ${typeof newState.PermissionController}`, + ), + ); + return oldState; + } + + if ( + !hasProperty(newState, 'NetworkController') || + !isObject(newState.NetworkController) + ) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.NetworkController is ${typeof newState.NetworkController}`, + ), + ); + return oldState; + } + + if (!hasProperty(newState, 'SelectedNetworkController')) { + console.warn( + `Migration ${version}: typeof state.SelectedNetworkController is ${typeof newState.SelectedNetworkController}`, + ); + // This matches how the `SelectedNetworkController` is initialized + // See https://github.com/MetaMask/core/blob/e692641040be470f7f4ad2d58692b0668e6443b3/packages/selected-network-controller/src/SelectedNetworkController.ts#L27 + newState.SelectedNetworkController = { + domains: {}, + }; + } + + if (!isObject(newState.SelectedNetworkController)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.SelectedNetworkController is ${typeof newState.SelectedNetworkController}`, + ), + ); + return oldState; + } + + const { + NetworkController: { + selectedNetworkClientId, + networkConfigurationsByChainId, + }, + PermissionController: { subjects }, + } = newState; + + if (!isObject(subjects)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.PermissionController.subjects is ${typeof subjects}`, + ), + ); + return oldState; + } + + if (!selectedNetworkClientId || typeof selectedNetworkClientId !== 'string') { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.NetworkController.selectedNetworkClientId is ${typeof selectedNetworkClientId}`, + ), + ); + return oldState; + } + + if (!isObject(networkConfigurationsByChainId)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId is ${typeof newState + .NetworkController.networkConfigurationsByChainId}`, + ), + ); + return oldState; + } + + if ( + !hasProperty(newState.SelectedNetworkController, 'domains') || + !isObject(newState.SelectedNetworkController.domains) + ) { + const { domains } = newState.SelectedNetworkController; + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.SelectedNetworkController.domains is ${typeof domains}`, + ), + ); + return oldState; + } + + const { domains } = newState.SelectedNetworkController; + + const getChainIdForNetworkClientId = ( + networkClientId: string, + propertyName: string, + ): string | undefined => { + let malformedDataErrorFound = false; + let matchingChainId: string | undefined; + for (const [chainId, networkConfiguration] of Object.entries( + networkConfigurationsByChainId, + )) { + if (!isObject(networkConfiguration)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["${chainId}"] is ${typeof networkConfiguration}`, + ), + ); + malformedDataErrorFound = true; + continue; + } + if (!Array.isArray(networkConfiguration.rpcEndpoints)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["${chainId}"].rpcEndpoints is ${typeof networkConfiguration.rpcEndpoints}`, + ), + ); + malformedDataErrorFound = true; + continue; + } + + for (const rpcEndpoint of networkConfiguration.rpcEndpoints) { + if (!isObject(rpcEndpoint)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["${chainId}"].rpcEndpoints[] is ${typeof rpcEndpoint}`, + ), + ); + malformedDataErrorFound = true; + continue; + } + + if (rpcEndpoint.networkClientId === networkClientId) { + matchingChainId = chainId; + } + } + } + if (malformedDataErrorFound) { + return undefined; + } + + if (matchingChainId) { + return matchingChainId; + } + + const builtInChainId = BUILT_IN_NETWORKS.get(networkClientId); + if (!builtInChainId) { + global.sentry?.captureException( + new Error( + `Migration ${version}: No chainId found for ${propertyName} "${networkClientId}"`, + ), + ); + } + return builtInChainId; + }; + + const currentChainId = getChainIdForNetworkClientId( + selectedNetworkClientId, + 'selectedNetworkClientId', + ); + if (!currentChainId) { + return oldState; + } + + // perform mutations on the cloned state + for (const [origin, subject] of Object.entries(subjects)) { + if (!isObject(subject)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: Invalid subject for origin "${origin}" of type ${typeof subject}`, + ), + ); + return oldState; + } + + if ( + !hasProperty(subject, 'permissions') || + !isObject(subject.permissions) + ) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: Invalid permissions for origin "${origin}" of type ${typeof subject.permissions}`, + ), + ); + return oldState; + } + + const { permissions } = subject; + + let basePermission: PermissionConstraint | undefined; + + let ethAccounts: string[] = []; + const ethAccountsPermission = permissions[PermissionNames.eth_accounts]; + const permittedChainsPermission = + permissions[PermissionNames.permittedChains]; + + // if there is no eth_accounts permission we can't create a valid CAIP-25 permission so we remove the permission + if (permittedChainsPermission && !ethAccountsPermission) { + delete permissions[PermissionNames.permittedChains]; + continue; + } + if (!isPermissionConstraint(ethAccountsPermission)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: Invalid state.PermissionController.subjects[${origin}].permissions[${ + PermissionNames.eth_accounts + }: ${JSON.stringify(ethAccountsPermission)}`, + ), + ); + return oldState; + } + const accountsCaveatValue = ethAccountsPermission.caveats?.[0]?.value; + if (!isNonEmptyArrayOfStrings(accountsCaveatValue)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: Invalid state.PermissionController.subjects[${origin}].permissions[${ + PermissionNames.eth_accounts + }].caveats[0].value of type ${typeof ethAccountsPermission + .caveats?.[0]?.value}`, + ), + ); + return oldState; + } + ethAccounts = accountsCaveatValue; + basePermission = ethAccountsPermission; + + delete permissions[PermissionNames.eth_accounts]; + + let chainIds: string[] = []; + // this permission is new so it may not exist + if (permittedChainsPermission) { + if (!isPermissionConstraint(permittedChainsPermission)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: Invalid state.PermissionController.subjects[${origin}].permissions[${ + PermissionNames.permittedChains + }]: ${JSON.stringify(permittedChainsPermission)}`, + ), + ); + return oldState; + } + const chainsCaveatValue = permittedChainsPermission.caveats?.[0]?.value; + if (!isNonEmptyArrayOfStrings(chainsCaveatValue)) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: Invalid state.PermissionController.subjects[${origin}].permissions[${ + PermissionNames.permittedChains + }].caveats[0].value of type ${typeof permittedChainsPermission + .caveats?.[0]?.value}`, + ), + ); + return oldState; + } + chainIds = chainsCaveatValue; + basePermission ??= permittedChainsPermission; + delete permissions[PermissionNames.permittedChains]; + } + + if (chainIds.length === 0) { + chainIds = [currentChainId]; + + const networkClientIdForOrigin = domains[origin]; + if ( + networkClientIdForOrigin && + typeof networkClientIdForOrigin === 'string' + ) { + const chainIdForOrigin = getChainIdForNetworkClientId( + networkClientIdForOrigin, + 'networkClientIdForOrigin', + ); + if (chainIdForOrigin) { + chainIds = [chainIdForOrigin]; + } + } + } + + const isSnap = snapsPrefixes.some((prefix) => origin.startsWith(prefix)); + const scopes: InternalScopesObject = {}; + const scopeStrings: CaipChainId[] = isSnap + ? [] + : chainIds.map( + (chainId) => `eip155:${hexToBigInt(chainId).toString(10)}`, + ); + scopeStrings.push('wallet:eip155'); + + scopeStrings.forEach((scopeString) => { + const caipAccounts = ethAccounts.map( + (account) => `${scopeString}:${account}`, + ); + scopes[scopeString] = { + accounts: caipAccounts, + }; + }); + + const caip25Permission: Caip25Permission = { + ...basePermission, + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: scopes, + isMultichainOrigin: false, + }, + }, + ], + }; + + permissions[Caip25EndowmentPermissionName] = caip25Permission; + } + + return newState; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 20abaecd4b3b..bb16cfcbd267 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -162,6 +162,7 @@ const migrations = [ require('./136'), require('./137'), require('./138'), + require('./139'), ]; export default migrations; diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index c4985af54a1d..f9f4e16077b5 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1425,6 +1425,16 @@ "uuid": true } }, + "@metamask/multichain": { + "packages": { + "@metamask/multichain>@metamask/api-specs": true, + "@metamask/controller-utils": true, + "@metamask/permission-controller": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, + "lodash": true + } + }, "@metamask/name-controller": { "globals": { "fetch": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index c4985af54a1d..f9f4e16077b5 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1425,6 +1425,16 @@ "uuid": true } }, + "@metamask/multichain": { + "packages": { + "@metamask/multichain>@metamask/api-specs": true, + "@metamask/controller-utils": true, + "@metamask/permission-controller": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, + "lodash": true + } + }, "@metamask/name-controller": { "globals": { "fetch": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index c4985af54a1d..f9f4e16077b5 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1425,6 +1425,16 @@ "uuid": true } }, + "@metamask/multichain": { + "packages": { + "@metamask/multichain>@metamask/api-specs": true, + "@metamask/controller-utils": true, + "@metamask/permission-controller": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, + "lodash": true + } + }, "@metamask/name-controller": { "globals": { "fetch": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index bc5be17e5a64..98a6fbda7087 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -1517,6 +1517,16 @@ "uuid": true } }, + "@metamask/multichain": { + "packages": { + "@metamask/multichain>@metamask/api-specs": true, + "@metamask/controller-utils": true, + "@metamask/permission-controller": true, + "@metamask/rpc-errors": true, + "@metamask/utils": true, + "lodash": true + } + }, "@metamask/name-controller": { "globals": { "fetch": true diff --git a/package.json b/package.json index b3bf5e3985d5..300e5dd666c1 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,11 @@ "@expo/config/glob": "^10.3.10", "@expo/config-plugins/glob": "^10.3.10", "@solana/web3.js/rpc-websockets": "^8.0.1", + "@json-schema-spec/json-pointer@npm:^0.1.2": "patch:@json-schema-spec/json-pointer@npm%3A0.1.2#~/.yarn/patches/@json-schema-spec-json-pointer-npm-0.1.2-3d06119887.patch", + "@json-schema-tools/reference-resolver@npm:^1.2.6": "patch:@json-schema-tools/reference-resolver@npm%3A1.2.6#~/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch", + "@json-schema-tools/reference-resolver@npm:1.2.4": "patch:@json-schema-tools/reference-resolver@npm%3A1.2.6#~/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch", + "@json-schema-tools/reference-resolver@npm:^1.2.4": "patch:@json-schema-tools/reference-resolver@npm%3A1.2.6#~/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch", + "@json-schema-tools/reference-resolver@npm:^1.2.1": "patch:@json-schema-tools/reference-resolver@npm%3A1.2.6#~/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch", "@metamask/network-controller@npm:^22.0.2": "patch:@metamask/network-controller@npm%3A22.1.1#~/.yarn/patches/@metamask-network-controller-npm-22.1.1-09b6510f1e.patch", "path-to-regexp": "1.9.0", "@ledgerhq/cryptoassets-evm-signatures/axios": "^0.28.0", @@ -321,6 +326,7 @@ "@metamask/message-manager": "^11.0.0", "@metamask/message-signing-snap": "^0.6.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/multichain": "^2.0.0", "@metamask/name-controller": "^8.0.0", "@metamask/network-controller": "patch:@metamask/network-controller@npm%3A22.1.1#~/.yarn/patches/@metamask-network-controller-npm-22.1.1-09b6510f1e.patch", "@metamask/notification-services-controller": "^0.15.0", diff --git a/shared/constants/snaps/permissions.ts b/shared/constants/snaps/permissions.ts index e08afdf46421..28ae5e12a061 100644 --- a/shared/constants/snaps/permissions.ts +++ b/shared/constants/snaps/permissions.ts @@ -16,11 +16,11 @@ export const EndowmentPermissions = Object.freeze({ } as const); // Methods / permissions in external packages that we are temporarily excluding. -export const ExcludedSnapPermissions = Object.freeze({ - eth_accounts: +export const ExcludedSnapPermissions = Object.freeze({}); + +export const ExcludedSnapEndowments = Object.freeze({ + 'endowment:caip25': 'eth_accounts is disabled. For more information please see https://github.com/MetaMask/snaps/issues/990.', }); -export const ExcludedSnapEndowments = Object.freeze({}); - -export const DynamicSnapPermissions = Object.freeze(['eth_accounts']); +export const DynamicSnapPermissions = Object.freeze(['endowment:caip25']); diff --git a/test/e2e/json-rpc/wallet_requestPermissions.spec.ts b/test/e2e/json-rpc/wallet_requestPermissions.spec.ts index d2a033dcf3c5..f06d28a1609d 100644 --- a/test/e2e/json-rpc/wallet_requestPermissions.spec.ts +++ b/test/e2e/json-rpc/wallet_requestPermissions.spec.ts @@ -1,4 +1,5 @@ import { strict as assert } from 'assert'; +import { PermissionConstraint } from '@metamask/permission-controller'; import { withFixtures } from '../helpers'; import FixtureBuilder from '../fixture-builder'; import { loginWithBalanceValidation } from '../page-objects/flows/login.flow'; @@ -36,7 +37,17 @@ describe('wallet_requestPermissions', function () { const getPermissions = await driver.executeScript( `return window.ethereum.request(${getPermissionsRequest})`, ); - assert.strictEqual(getPermissions[1].parentCapability, 'eth_accounts'); + + const grantedPermissionNames = getPermissions + .map( + (permission: PermissionConstraint) => permission.parentCapability, + ) + .sort(); + + assert.deepStrictEqual(grantedPermissionNames, [ + 'endowment:permitted-chains', + 'eth_accounts', + ]); }, ); }); diff --git a/test/e2e/json-rpc/wallet_revokePermissions.spec.ts b/test/e2e/json-rpc/wallet_revokePermissions.spec.ts index 8d7a06ac4ccc..d1ac0d39f580 100644 --- a/test/e2e/json-rpc/wallet_revokePermissions.spec.ts +++ b/test/e2e/json-rpc/wallet_revokePermissions.spec.ts @@ -6,7 +6,7 @@ import TestDapp from '../page-objects/pages/test-dapp'; import { loginWithBalanceValidation } from '../page-objects/flows/login.flow'; describe('Revoke Dapp Permissions', function () { - it('should revoke dapp permissions for "eth_accounts"', async function () { + it('should revoke "eth_accounts" and "endowment:permitted-chains" when the dapp revokes permissions for just "eth_accounts"', async function () { await withFixtures( { dapp: true, @@ -59,14 +59,70 @@ describe('Revoke Dapp Permissions', function () { const afterGetPermissionsNames = afterGetPermissionsResult.map( (permission: PermissionConstraint) => permission.parentCapability, ); - assert.deepEqual(afterGetPermissionsNames, [ + assert.deepEqual(afterGetPermissionsNames, []); + }, + ); + }); + + it('should revoke "eth_accounts" and "endowment:permitted-chains" when the dapp revokes permissions for just "endowment:permitted-chains"', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDappWithChain() + .build(), + title: this.test?.fullTitle(), + }, + async ({ driver }) => { + await loginWithBalanceValidation(driver); + const testDapp = new TestDapp(driver); + await testDapp.openTestDappPage(); + + const beforeGetPermissionsRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_getPermissions', + }); + const beforeGetPermissionsResult = await driver.executeScript( + `return window.ethereum.request(${beforeGetPermissionsRequest})`, + ); + const beforeGetPermissionsNames = beforeGetPermissionsResult.map( + (permission: PermissionConstraint) => permission.parentCapability, + ); + assert.deepEqual(beforeGetPermissionsNames, [ + 'eth_accounts', 'endowment:permitted-chains', ]); + + const revokePermissionsRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_revokePermissions', + params: [ + { + 'endowment:permitted-chains': {}, + }, + ], + }); + const revokePermissionsResult = await driver.executeScript( + `return window.ethereum.request(${revokePermissionsRequest})`, + ); + assert.deepEqual(revokePermissionsResult, null); + + const afterGetPermissionsRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_getPermissions', + }); + const afterGetPermissionsResult = await driver.executeScript( + `return window.ethereum.request(${afterGetPermissionsRequest})`, + ); + const afterGetPermissionsNames = afterGetPermissionsResult.map( + (permission: PermissionConstraint) => permission.parentCapability, + ); + assert.deepEqual(afterGetPermissionsNames, []); }, ); }); - it('should revoke dapp permissions for "endowment:permitted-chains"', async function () { + it('should revoke "eth_accounts" and "endowment:permitted-chains" when the dapp revokes permissions for "eth_accounts" and "endowment:permitted-chains"', async function () { await withFixtures( { dapp: true, @@ -100,6 +156,7 @@ describe('Revoke Dapp Permissions', function () { method: 'wallet_revokePermissions', params: [ { + eth_accounts: {}, 'endowment:permitted-chains': {}, }, ], @@ -119,7 +176,7 @@ describe('Revoke Dapp Permissions', function () { const afterGetPermissionsNames = afterGetPermissionsResult.map( (permission: PermissionConstraint) => permission.parentCapability, ); - assert.deepEqual(afterGetPermissionsNames, ['eth_accounts']); + assert.deepEqual(afterGetPermissionsNames, []); }, ); }); diff --git a/test/e2e/snaps/test-snap-revoke-perm.spec.js b/test/e2e/snaps/test-snap-revoke-perm.spec.js index e61c1d831862..9f5867cba7fa 100644 --- a/test/e2e/snaps/test-snap-revoke-perm.spec.js +++ b/test/e2e/snaps/test-snap-revoke-perm.spec.js @@ -150,7 +150,7 @@ describe('Test Snap revoke permission', function () { }); // try to click on options menu - await driver.clickElement('[data-testid="eth_accounts"]'); + await driver.clickElement('[data-testid="endowment:caip25"]'); // try to click on revoke permission await driver.clickElement({ diff --git a/test/e2e/tests/dapp-interactions/revoke-permissions.spec.js b/test/e2e/tests/dapp-interactions/revoke-permissions.spec.js index 696095e3fd79..ddf4d9c51f43 100644 --- a/test/e2e/tests/dapp-interactions/revoke-permissions.spec.js +++ b/test/e2e/tests/dapp-interactions/revoke-permissions.spec.js @@ -7,7 +7,7 @@ const { const FixtureBuilder = require('../../fixture-builder'); describe('Wallet Revoke Permissions', function () { - it('should revoke eth_accounts permissions via test dapp', async function () { + it('should revoke "eth_accounts" permissions via test dapp', async function () { await withFixtures( { dapp: true, @@ -43,4 +43,47 @@ describe('Wallet Revoke Permissions', function () { }, ); }); + + it('should revoke "endowment:permitted-chains" permissions', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + }, + async ({ driver }) => { + await unlockWallet(driver); + await openDapp(driver); + + // Get initial accounts permissions + await driver.clickElement('#getPermissions'); + + const revokeChainsRequest = JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_revokePermissions', + params: [ + { + 'endowment:permitted-chains': {}, + }, + ], + }); + + await driver.executeScript( + `return window.ethereum.request(${revokeChainsRequest})`, + ); + + // Get new allowed permissions + await driver.clickElement('#getPermissions'); + + // Eth_accounts permissions removed + await driver.waitForSelector({ + css: '#permissionsResult', + text: 'No permissions found.', + }); + }, + ); + }); }); diff --git a/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js b/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js index dff1cf47d4be..3767d56de8b7 100644 --- a/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js +++ b/ui/components/app/alerts/unconnected-account-alert/unconnected-account-alert.test.js @@ -123,15 +123,25 @@ describe('Unconnected Account Alert', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/components/app/permission-cell/permission-cell.js b/ui/components/app/permission-cell/permission-cell.js index 4df1dc256765..7d85f48d2a54 100644 --- a/ui/components/app/permission-cell/permission-cell.js +++ b/ui/components/app/permission-cell/permission-cell.js @@ -42,7 +42,7 @@ const PermissionCell = ({ showOptions, hideStatus, accounts, - permissionValue, + chainIds, }) => { const infoIcon = IconName.Info; let infoIconColor = IconColor.iconMuted; @@ -71,7 +71,7 @@ const PermissionCell = ({ } const networksInfo = useSelector((state) => - getRequestingNetworkInfo(state, permissionValue), + getRequestingNetworkInfo(state, chainIds), ); return ( @@ -171,7 +171,7 @@ PermissionCell.propTypes = { showOptions: PropTypes.bool, hideStatus: PropTypes.bool, accounts: PropTypes.array, - permissionValue: PropTypes.array, + chainIds: PropTypes.array, }; export default PermissionCell; diff --git a/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js b/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js index e5e8503e6c73..26a69bd864e3 100644 --- a/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js +++ b/ui/components/app/permission-page-container/permission-page-container-content/permission-page-container-content.component.js @@ -27,10 +27,12 @@ export default class PermissionPageContainerContent extends PureComponent { }), selectedPermissions: PropTypes.object.isRequired, selectedAccounts: PropTypes.array, + requestedChainIds: PropTypes.array, }; static defaultProps = { selectedAccounts: [], + requestedChainIds: [], }; static contextTypes = { @@ -40,8 +42,12 @@ export default class PermissionPageContainerContent extends PureComponent { render() { const { t } = this.context; - const { selectedPermissions, selectedAccounts, subjectMetadata } = - this.props; + const { + selectedPermissions, + selectedAccounts, + subjectMetadata, + requestedChainIds, + } = this.props; const accounts = selectedAccounts.reduce((accumulator, account) => { accumulator.push({ @@ -98,6 +104,7 @@ export default class PermissionPageContainerContent extends PureComponent { permissions={selectedPermissions} subjectName={subjectMetadata.origin} accounts={accounts} + requestedChainIds={requestedChainIds} /> diff --git a/ui/components/app/permission-page-container/permission-page-container.component.js b/ui/components/app/permission-page-container/permission-page-container.component.js index f5f69da8f947..6e0ff41cd461 100644 --- a/ui/components/app/permission-page-container/permission-page-container.component.js +++ b/ui/components/app/permission-page-container/permission-page-container.component.js @@ -31,6 +31,7 @@ export default class PermissionPageContainer extends Component { approvePermissionsRequest: PropTypes.func.isRequired, rejectPermissionsRequest: PropTypes.func.isRequired, selectedAccounts: PropTypes.array, + requestedChainIds: PropTypes.array, allAccountsSelected: PropTypes.bool, currentPermissions: PropTypes.object, snapsInstallPrivacyWarningShown: PropTypes.bool.isRequired, @@ -148,7 +149,7 @@ export default class PermissionPageContainer extends Component { const permittedChainsPermission = _request.permissions?.[PermissionNames.permittedChains]; - const approvedChainIds = permittedChainsPermission?.caveats.find( + const approvedChainIds = permittedChainsPermission?.caveats?.find( (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, )?.value; @@ -183,6 +184,7 @@ export default class PermissionPageContainer extends Component { targetSubjectMetadata, selectedAccounts, allAccountsSelected, + requestedChainIds, } = this.props; const requestedPermissions = this.getRequestedPermissions(); @@ -216,6 +218,7 @@ export default class PermissionPageContainer extends Component { requestMetadata={requestMetadata} subjectMetadata={targetSubjectMetadata} selectedPermissions={requestedPermissions} + requestedChainIds={requestedChainIds} selectedAccounts={selectedAccounts} allAccountsSelected={allAccountsSelected} /> diff --git a/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.js b/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.js index da15d384849c..a54573a3c6e9 100644 --- a/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.js +++ b/ui/components/app/permissions-connect-permission-list/permissions-connect-permission-list.js @@ -11,12 +11,19 @@ import { Box } from '../../component-library'; /** * Get one or more permission descriptions for a permission name. * - * @param permission - The permission to render. - * @param index - The index of the permission. - * @param accounts - An array representing list of accounts for which permission is used. + * @param options - The options object. + * @param options.permission - The permission to render. + * @param options.index - The index of the permission. + * @param options.accounts - An array representing list of accounts for which permission is used. + * @param options.requestedChainIds - An array representing list of chain ids for which permission is used. * @returns {JSX.Element} A permission description node. */ -function getDescriptionNode(permission, index, accounts) { +function getDescriptionNode({ + permission, + index, + accounts, + requestedChainIds, +}) { return ( ); } @@ -35,6 +42,7 @@ export default function PermissionsConnectPermissionList({ permissions, subjectName, accounts, + requestedChainIds, }) { const t = useI18nContext(); const snapsMetadata = useSelector(getSnapsMetadata); @@ -47,7 +55,12 @@ export default function PermissionsConnectPermissionList({ getSubjectName: getSnapName(snapsMetadata), subjectName, }).map((permission, index) => { - return getDescriptionNode(permission, index, accounts); + return getDescriptionNode({ + permission, + index, + accounts, + requestedChainIds, + }); })} ); @@ -56,5 +69,6 @@ export default function PermissionsConnectPermissionList({ PermissionsConnectPermissionList.propTypes = { permissions: PropTypes.object.isRequired, subjectName: PropTypes.string.isRequired, + requestedChainIds: PropTypes.array, accounts: PropTypes.arrayOf(PropTypes.object), }; diff --git a/ui/components/multichain/account-list-menu/account-list-menu.test.tsx b/ui/components/multichain/account-list-menu/account-list-menu.test.tsx index 0d7884fcd666..d52bd5ac4cdf 100644 --- a/ui/components/multichain/account-list-menu/account-list-menu.test.tsx +++ b/ui/components/multichain/account-list-menu/account-list-menu.test.tsx @@ -84,15 +84,25 @@ const render = ( subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -216,15 +226,29 @@ describe('AccountListMenu', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { - caveats: [ - { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + 'https://test.dapp': { + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + invoker: 'https://test.dapp', + parentCapability: 'endowment:caip25', }, - ], - invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + }, }, }, }, @@ -343,15 +367,25 @@ describe('AccountListMenu', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -454,15 +488,25 @@ describe('AccountListMenu', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/components/multichain/connected-accounts-menu/connected-accounts-menu.test.tsx b/ui/components/multichain/connected-accounts-menu/connected-accounts-menu.test.tsx index 91ffeb5e21a7..d6a16cd4c995 100644 --- a/ui/components/multichain/connected-accounts-menu/connected-accounts-menu.test.tsx +++ b/ui/components/multichain/connected-accounts-menu/connected-accounts-menu.test.tsx @@ -81,20 +81,28 @@ const renderComponent = (props = {}, stateChanges = {}) => { subjects: { 'https://remix.ethereum.org': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', - '0x7250739de134d33ec7ab1ee592711e15098c9d2d', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + 'eip155:1:0x7250739de134d33ec7ab1ee592711e15098c9d2d', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1586359844177, id: '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b', invoker: 'https://remix.ethereum.org', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/components/multichain/network-list-menu/network-list-menu.tsx b/ui/components/multichain/network-list-menu/network-list-menu.tsx index 491ae289b19c..a9ee7c7f25a4 100644 --- a/ui/components/multichain/network-list-menu/network-list-menu.tsx +++ b/ui/components/multichain/network-list-menu/network-list-menu.tsx @@ -24,10 +24,10 @@ import { updateNetworksList, setNetworkClientIdForDomain, setEditedNetwork, - grantPermittedChain, showPermittedNetworkToast, updateCustomNonce, setNextNonce, + addPermittedChain, setTokenNetworkFilter, detectNfts, } from '../../../store/actions'; @@ -299,7 +299,7 @@ export const NetworkListMenu = ({ onClose }: { onClose: () => void }) => { } if (permittedAccountAddresses.length > 0) { - grantPermittedChain(selectedTabOrigin, network.chainId); + dispatch(addPermittedChain(selectedTabOrigin, network.chainId)); if (!permittedChainIds.includes(network.chainId)) { dispatch(showPermittedNetworkToast()); } diff --git a/ui/components/multichain/pages/connections/connections.test.tsx b/ui/components/multichain/pages/connections/connections.test.tsx index 0840e3a2f9ed..a27e8bb36ed2 100644 --- a/ui/components/multichain/pages/connections/connections.test.tsx +++ b/ui/components/multichain/pages/connections/connections.test.tsx @@ -40,17 +40,27 @@ describe('Connections Content', () => { 'https://metamask.github.io': { origin: 'https://metamask.github.io', permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1616006369498, id: '3d0bdc27-e8e4-4fb0-a24b-340d61f6a3fa', invoker: 'https://metamask.github.io', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -67,15 +77,25 @@ describe('Connections Content', () => { subjects: { 'https://metamask.github.io': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://metamask.github.io', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/components/multichain/pages/connections/connections.tsx b/ui/components/multichain/pages/connections/connections.tsx index 0592f5de84ee..2cd2bb948232 100644 --- a/ui/components/multichain/pages/connections/connections.tsx +++ b/ui/components/multichain/pages/connections/connections.tsx @@ -51,7 +51,7 @@ import { import { Content, Footer, Header, Page } from '../page'; import { ConnectAccountsModal } from '../../connect-accounts-modal/connect-accounts-modal'; import { - requestAccountsPermissionWithId, + requestAccountsAndChainPermissionsWithId, removePermissionsFor, } from '../../../../store/actions'; import { @@ -126,7 +126,7 @@ export const Connections = () => { } const requestAccountsPermission = async () => { const requestId = await dispatch( - requestAccountsPermissionWithId(tabToConnect.origin), + requestAccountsAndChainPermissionsWithId(tabToConnect.origin), ); history.push(`${CONNECT_ROUTE}/${requestId}`); }; @@ -389,7 +389,7 @@ export const Connections = () => { size={ButtonPrimarySize.Lg} block data-test-id="no-connections-button" - onClick={() => dispatch(requestAccountsPermission())} + onClick={() => requestAccountsPermission()} > {t('connectAccounts')} diff --git a/ui/components/multichain/pages/permissions-page/__snapshots__/permissions-page.test.js.snap b/ui/components/multichain/pages/permissions-page/__snapshots__/permissions-page.test.js.snap index c49cd1ba3236..e9f5fa78b9cd 100644 --- a/ui/components/multichain/pages/permissions-page/__snapshots__/permissions-page.test.js.snap +++ b/ui/components/multichain/pages/permissions-page/__snapshots__/permissions-page.test.js.snap @@ -84,7 +84,7 @@ exports[`All Connections render renders correctly 1`] = ` accounts •  - 0 + 1 networks diff --git a/ui/components/multichain/pages/permissions-page/permissions-page.test.js b/ui/components/multichain/pages/permissions-page/permissions-page.test.js index 026cddeff34d..b257d85ae29a 100644 --- a/ui/components/multichain/pages/permissions-page/permissions-page.test.js +++ b/ui/components/multichain/pages/permissions-page/permissions-page.test.js @@ -35,17 +35,27 @@ mockState.metamask.subjects = { 'https://metamask.github.io': { origin: 'https://metamask.github.io', permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1698071087770, id: 'BIko27gpEajmo_CcNYPxD', invoker: 'https://metamask.github.io', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/components/multichain/pages/send/components/account-picker.test.tsx b/ui/components/multichain/pages/send/components/account-picker.test.tsx index 136a2986a63e..ffa37e757f0f 100644 --- a/ui/components/multichain/pages/send/components/account-picker.test.tsx +++ b/ui/components/multichain/pages/send/components/account-picker.test.tsx @@ -46,15 +46,25 @@ const render = ( subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/components/multichain/permission-details-modal/permission-details-modal.test.tsx b/ui/components/multichain/permission-details-modal/permission-details-modal.test.tsx index 3c3d5ff18bfa..1c700ad25371 100644 --- a/ui/components/multichain/permission-details-modal/permission-details-modal.test.tsx +++ b/ui/components/multichain/permission-details-modal/permission-details-modal.test.tsx @@ -69,20 +69,28 @@ describe('PermissionDetailsModal', () => { subjects: { 'https://remix.ethereum.org': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', - '0x7250739de134d33ec7ab1ee592711e15098c9d2d', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + 'eip155:1:0x7250739de134d33ec7ab1ee592711e15098c9d2d', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1586359844177, id: '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b', invoker: 'https://remix.ethereum.org', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/helpers/utils/permission.js b/ui/helpers/utils/permission.js index b1d1856c40df..7526732d5ec7 100644 --- a/ui/helpers/utils/permission.js +++ b/ui/helpers/utils/permission.js @@ -8,6 +8,7 @@ import { getSnapDerivationPathName, } from '@metamask/snaps-utils'; import { isNonEmptyArray } from '@metamask/controller-utils'; +import { Caip25EndowmentPermissionName } from '@metamask/multichain'; import { RestrictedMethods, EndowmentPermissions, @@ -50,6 +51,13 @@ function getSnapNameComponent(snapName) { } export const PERMISSION_DESCRIPTIONS = deepFreeze({ + // "endowment:caip25" entry is needed for the Snaps Permissions Review UI + [Caip25EndowmentPermissionName]: ({ t }) => ({ + label: t('permission_ethereumAccounts'), + leftIcon: IconName.Eye, + weight: PermissionWeight.eth_accounts, + }), + // "eth_accounts" entry is needed for the Snaps Permissions Grant UI [RestrictedMethods.eth_accounts]: ({ t }) => ({ label: t('permission_ethereumAccounts'), leftIcon: IconName.Eye, diff --git a/ui/pages/connected-sites/connected-sites.container.js b/ui/pages/connected-sites/connected-sites.container.js index bdbeb423dc89..8c080335f70a 100644 --- a/ui/pages/connected-sites/connected-sites.container.js +++ b/ui/pages/connected-sites/connected-sites.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { getOpenMetamaskTabsIds, - requestAccountsPermissionWithId, + requestAccountsAndChainPermissionsWithId, removePermissionsFor, removePermittedAccount, } from '../../store/actions'; @@ -61,8 +61,8 @@ const mapDispatchToProps = (dispatch) => { }), ); }, - requestAccountsPermissionWithId: (origin) => - dispatch(requestAccountsPermissionWithId(origin)), + requestAccountsAndChainPermissionsWithId: (origin) => + dispatch(requestAccountsAndChainPermissionsWithId(origin)), }; }; @@ -78,7 +78,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { disconnectAccount, disconnectAllAccounts, // eslint-disable-next-line no-shadow - requestAccountsPermissionWithId, + requestAccountsAndChainPermissionsWithId, } = dispatchProps; const { history } = ownProps; @@ -102,7 +102,9 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { } }, requestAccountsPermission: async () => { - const id = await requestAccountsPermissionWithId(tabToConnect.origin); + const id = await requestAccountsAndChainPermissionsWithId( + tabToConnect.origin, + ); history.push(`${CONNECT_ROUTE}/${id}`); }, }; diff --git a/ui/pages/permissions-connect/permissions-connect.component.js b/ui/pages/permissions-connect/permissions-connect.component.js index 6509b79249ab..ee7892fbf8d1 100644 --- a/ui/pages/permissions-connect/permissions-connect.component.js +++ b/ui/pages/permissions-connect/permissions-connect.component.js @@ -50,6 +50,11 @@ function getDefaultSelectedAccounts(currentAddress, permissionsRequest) { return new Set(isEthAddress(currentAddress) ? [currentAddress] : []); } +function getRequestedChainIds(permissionsRequest) { + return permissionsRequest?.permissions?.[PermissionNames.permittedChains] + ?.caveats[0]?.value; +} + export default class PermissionConnect extends Component { static propTypes = { approvePermissionsRequest: PropTypes.func.isRequired, @@ -148,14 +153,7 @@ export default class PermissionConnect extends Component { history.replace(DEFAULT_ROUTE); return; } - // if this is an incremental permission request for permitted chains, skip the account selection - if ( - permissionsRequest?.diff?.permissionDiffMap?.[ - PermissionNames.permittedChains - ] - ) { - history.replace(confirmPermissionPath); - } + if (history.location.pathname === connectPath && !isRequestingAccounts) { switch (requestType) { case 'wallet_installSnap': @@ -392,6 +390,7 @@ export default class PermissionConnect extends Component { selectedAccounts={accounts.filter((account) => selectedAccountAddresses.has(account.address), )} + requestedChainIds={getRequestedChainIds(permissionsRequest)} targetSubjectMetadata={targetSubjectMetadata} history={history} connectPath={connectPath} diff --git a/ui/pages/routes/routes.component.test.js b/ui/pages/routes/routes.component.test.js index b9bd116195ea..a41bb94271e0 100644 --- a/ui/pages/routes/routes.component.test.js +++ b/ui/pages/routes/routes.component.test.js @@ -279,16 +279,24 @@ describe('toast display', () => { subjects: { [mockOrigin]: { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [mockAccount.address], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [`eip155:1:${mockAccount.address}`], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1719910288437, invoker: 'https://metamask.github.io', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/selectors/permissions.js b/ui/selectors/permissions.js index a0d69b7dc75e..e1e35e4a0191 100644 --- a/ui/selectors/permissions.js +++ b/ui/selectors/permissions.js @@ -1,9 +1,12 @@ import { ApprovalType } from '@metamask/controller-utils'; import { WALLET_SNAP_PERMISSION_KEY } from '@metamask/snaps-rpc-methods'; import { isEvmAccountType } from '@metamask/keyring-api'; -import { CaveatTypes } from '../../shared/constants/permissions'; -// eslint-disable-next-line import/no-restricted-paths -import { PermissionNames } from '../../app/scripts/controllers/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getEthAccounts, + getPermittedEthChainIds, +} from '@metamask/multichain'; import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; import { getApprovalRequestsByType } from './approvals'; import { @@ -58,13 +61,13 @@ export function getPermissionSubjects(state) { */ export function getPermittedAccounts(state, origin) { return getAccountsFromPermission( - getAccountsPermissionFromSubject(subjectSelector(state, origin)), + getCaip25PermissionFromSubject(subjectSelector(state, origin)), ); } export function getPermittedChains(state, origin) { return getChainsFromPermission( - getChainsPermissionFromSubject(subjectSelector(state, origin)), + getCaip25PermissionFromSubject(subjectSelector(state, origin)), ); } @@ -274,53 +277,33 @@ export const isAccountConnectedToCurrentTab = createDeepEqualSelector( ); // selector helpers - -function getAccountsFromSubject(subject) { - return getAccountsFromPermission(getAccountsPermissionFromSubject(subject)); +function getCaip25PermissionFromSubject(subject = {}) { + return subject.permissions?.[Caip25EndowmentPermissionName] || {}; } -function getAccountsPermissionFromSubject(subject = {}) { - return subject.permissions?.eth_accounts || {}; +function getAccountsFromSubject(subject) { + return getAccountsFromPermission(getCaip25PermissionFromSubject(subject)); } function getChainsFromSubject(subject) { - return getChainsFromPermission(getChainsPermissionFromSubject(subject)); -} - -function getChainsPermissionFromSubject(subject = {}) { - return subject.permissions?.[PermissionNames.permittedChains] || {}; -} - -function getAccountsFromPermission(accountsPermission) { - const accountsCaveat = getAccountsCaveatFromPermission(accountsPermission); - return accountsCaveat && Array.isArray(accountsCaveat.value) - ? accountsCaveat.value - : []; + return getChainsFromPermission(getCaip25PermissionFromSubject(subject)); } -function getChainsFromPermission(chainsPermission) { - const chainsCaveat = getChainsCaveatFromPermission(chainsPermission); - return chainsCaveat && Array.isArray(chainsCaveat.value) - ? chainsCaveat.value - : []; -} - -function getChainsCaveatFromPermission(chainsPermission = {}) { +function getCaveatFromPermission(caip25Permission = {}) { return ( - Array.isArray(chainsPermission.caveats) && - chainsPermission.caveats.find( - (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, - ) + Array.isArray(caip25Permission.caveats) && + caip25Permission.caveats.find((caveat) => caveat.type === Caip25CaveatType) ); } -function getAccountsCaveatFromPermission(accountsPermission = {}) { - return ( - Array.isArray(accountsPermission.caveats) && - accountsPermission.caveats.find( - (caveat) => caveat.type === CaveatTypes.restrictReturnedAccounts, - ) - ); +function getAccountsFromPermission(caip25Permission) { + const caip25Caveat = getCaveatFromPermission(caip25Permission); + return caip25Caveat ? getEthAccounts(caip25Caveat.value) : []; +} + +function getChainsFromPermission(caip25Permission) { + const caip25Caveat = getCaveatFromPermission(caip25Permission); + return caip25Caveat ? getPermittedEthChainIds(caip25Caveat.value) : []; } function subjectSelector(state, origin) { diff --git a/ui/selectors/permissions.test.js b/ui/selectors/permissions.test.js index 3c55179d4a0e..a48cda996bd9 100644 --- a/ui/selectors/permissions.test.js +++ b/ui/selectors/permissions.test.js @@ -46,33 +46,53 @@ describe('selectors', () => { subjects: { 'peepeth.com': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1585676177970, id: '840d72a0-925f-449f-830a-1aa1dd5ce151', invoker: 'peepeth.com', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, 'https://remix.ethereum.org': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1585685128948, id: '6b9615cc-64e4-4317-afab-3c4f8ee0244a', invoker: 'https://remix.ethereum.org', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -147,36 +167,54 @@ describe('selectors', () => { subjects: { 'peepeth.com': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1585676177970, id: '840d72a0-925f-449f-830a-1aa1dd5ce151', invoker: 'peepeth.com', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, 'https://remix.ethereum.org': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', - '0x7250739de134d33ec7ab1ee592711e15098c9d2d', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + 'eip155:1:0x7250739de134d33ec7ab1ee592711e15098c9d2d', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1585685128948, id: '6b9615cc-64e4-4317-afab-3c4f8ee0244a', invoker: 'https://remix.ethereum.org', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -302,39 +340,57 @@ describe('selectors', () => { subjects: { 'https://remix.ethereum.org': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', - '0x7250739de134d33ec7ab1ee592711e15098c9d2d', - '0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - '0xb3958fb96c8201486ae20be1d5c9f58083df343a', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + 'eip155:1:0x7250739de134d33ec7ab1ee592711e15098c9d2d', + 'eip155:1:0x617b3f8050a0bd94b6b1da02b4384ee5b4df13f4', + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + 'eip155:1:0xb3958fb96c8201486ae20be1d5c9f58083df343a', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1586359844177, id: '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b', invoker: 'https://remix.ethereum.org', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, 'peepeth.com': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1585676177970, id: '840d72a0-925f-449f-830a-1aa1dd5ce151', invoker: 'peepeth.com', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -553,52 +609,80 @@ describe('selectors', () => { subjects: { 'https://remix.ethereum.org': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', - '0x7250739de134d33ec7ab1ee592711e15098c9d2d', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + 'eip155:1:0x7250739de134d33ec7ab1ee592711e15098c9d2d', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1586359844177, id: '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b', invoker: 'https://remix.ethereum.org', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, 'peepeth.com': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1585676177970, id: '840d72a0-925f-449f-830a-1aa1dd5ce151', invoker: 'peepeth.com', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, 'uniswap.exchange': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1585616816623, id: 'ce625215-f2e9-48e7-93ca-21ba193244ff', invoker: 'uniswap.exchange', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -626,21 +710,29 @@ describe('selectors', () => { it('should return a list of permissions keys and values', () => { expect(getPermissionsForActiveTab(mockState)).toStrictEqual([ { - key: 'eth_accounts', + key: 'endowment:caip25', value: { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', - '0x7250739de134d33ec7ab1ee592711e15098c9d2d', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5', + 'eip155:1:0x7250739de134d33ec7ab1ee592711e15098c9d2d', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1586359844177, id: '3aa65a8b-3bcb-4944-941b-1baa5fe0ed8b', invoker: 'https://remix.ethereum.org', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, ]); diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index 143f0b9e3d74..faedbefc2f55 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -1381,15 +1381,25 @@ describe('Selectors', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 769f78f50e07..f7eaba692749 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -18,10 +18,6 @@ import { MetaMetricsNetworkEventSource } from '../../shared/constants/metametric import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { mockNetworkState } from '../../test/stub/networks'; import { CHAIN_IDS } from '../../shared/constants/network'; -import { - CaveatTypes, - EndowmentTypes, -} from '../../shared/constants/permissions'; import * as actions from './actions'; import * as actionConstants from './actionConstants'; import { setBackgroundConnection } from './background-connection'; @@ -2666,75 +2662,6 @@ describe('Actions', () => { }); }); - describe('grantPermittedChain', () => { - afterEach(() => { - sinon.restore(); - }); - - it('calls grantPermissionsIncremental in the background', async () => { - const store = mockStore(); - - background.grantPermissionsIncremental.callsFake((_, cb) => cb()); - setBackgroundConnection(background); - - await actions.grantPermittedChain('test.com', '0x1'); - expect( - background.grantPermissionsIncremental.calledWith( - { - subject: { origin: 'test.com' }, - approvedPermissions: { - [EndowmentTypes.permittedChains]: { - caveats: [ - { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1'], - }, - ], - }, - }, - }, - sinon.match.func, - ), - ).toBe(true); - expect(store.getActions()).toStrictEqual([]); - }); - }); - - describe('grantPermittedChains', () => { - afterEach(() => { - sinon.restore(); - }); - - it('calls grantPermissions in the background', async () => { - const store = mockStore(); - - background.grantPermissions.callsFake((_, cb) => cb()); - setBackgroundConnection(background); - - await actions.grantPermittedChains('test.com', ['0x1', '0x2']); - expect( - background.grantPermissions.calledWith( - { - subject: { origin: 'test.com' }, - approvedPermissions: { - [EndowmentTypes.permittedChains]: { - caveats: [ - { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1', '0x2'], - }, - ], - }, - }, - }, - sinon.match.func, - ), - ).toBe(true); - - expect(store.getActions()).toStrictEqual([]); - }); - }); - describe('setSmartTransactionsRefreshInterval', () => { afterEach(() => { sinon.restore(); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 889b46e56ae4..e8af51344302 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -123,10 +123,6 @@ import { DecodedTransactionDataResponse } from '../../shared/types/transaction-d import { LastInteractedConfirmationInfo } from '../pages/confirmations/types/confirm'; import { EndTraceRequest } from '../../shared/lib/trace'; import { SortCriteria } from '../components/app/assets/util/sort'; -import { - CaveatTypes, - EndowmentTypes, -} from '../../shared/constants/permissions'; import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notifications'; import * as actionConstants from './actionConstants'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -3982,19 +3978,6 @@ export function setInitialGasEstimate( // Permissions -export function requestAccountsPermissionWithId( - origin: string, -): ThunkAction { - return async (dispatch: MetaMaskReduxDispatch) => { - const id = await submitRequestToBackground( - 'requestAccountsPermissionWithId', - [origin], - ); - await forceUpdateMetamaskState(dispatch); - return id; - }; -} - export function requestAccountsAndChainPermissionsWithId( origin: string, ): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { @@ -5971,48 +5954,6 @@ export async function getNextAvailableAccountName( ); } -export async function grantPermittedChain( - selectedTabOrigin: string, - chainId?: string, -): Promise { - return await submitRequestToBackground('grantPermissionsIncremental', [ - { - subject: { origin: selectedTabOrigin }, - approvedPermissions: { - [EndowmentTypes.permittedChains]: { - caveats: [ - { - type: CaveatTypes.restrictNetworkSwitching, - value: [chainId], - }, - ], - }, - }, - }, - ]); -} - -export async function grantPermittedChains( - selectedTabOrigin: string, - chainIds: string[], -): Promise { - return await submitRequestToBackground('grantPermissions', [ - { - subject: { origin: selectedTabOrigin }, - approvedPermissions: { - [EndowmentTypes.permittedChains]: { - caveats: [ - { - type: CaveatTypes.restrictNetworkSwitching, - value: chainIds, - }, - ], - }, - }, - }, - ]); -} - export async function decodeTransactionData({ transactionData, contractAddress, diff --git a/yarn.lock b/yarn.lock index 1ffc9f7c5c0a..63202ac21c19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4035,13 +4035,20 @@ __metadata: languageName: node linkType: hard -"@json-schema-spec/json-pointer@npm:^0.1.2": +"@json-schema-spec/json-pointer@npm:0.1.2": version: 0.1.2 resolution: "@json-schema-spec/json-pointer@npm:0.1.2" checksum: 10/2a691ffc11f1a266ca4d0c9e2c99791679d580f343ef69746fad623d1abcf4953adde987890e41f906767d7729604c0182341e9012388b73a44d5b21fb296453 languageName: node linkType: hard +"@json-schema-spec/json-pointer@patch:@json-schema-spec/json-pointer@npm%3A0.1.2#~/.yarn/patches/@json-schema-spec-json-pointer-npm-0.1.2-3d06119887.patch": + version: 0.1.2 + resolution: "@json-schema-spec/json-pointer@patch:@json-schema-spec/json-pointer@npm%3A0.1.2#~/.yarn/patches/@json-schema-spec-json-pointer-npm-0.1.2-3d06119887.patch::version=0.1.2&hash=8ff707" + checksum: 10/b957be819e3f744e8546014064f9fd8e45aa133985384bf91ae5f20688a087e12da3cb9046cd163e57ed1bea90c95ff7ed9a3e817f4c5e47377a8b330177915e + languageName: node + linkType: hard + "@json-schema-tools/dereferencer@npm:1.5.1": version: 1.5.1 resolution: "@json-schema-tools/dereferencer@npm:1.5.1" @@ -4083,29 +4090,29 @@ __metadata: linkType: hard "@json-schema-tools/meta-schema@npm:^1.6.10": - version: 1.7.4 - resolution: "@json-schema-tools/meta-schema@npm:1.7.4" - checksum: 10/6a688260eaac550d372325a39e7d4f44db7904a3fcaa3d3e0bf318b259007326592b53e511025ff35010ba0e0314dba338fd169338c5ea090328663f3e7cbd46 + version: 1.7.5 + resolution: "@json-schema-tools/meta-schema@npm:1.7.5" + checksum: 10/707dc3a285c26c37d00f418e9d0ef8a2ad1c23d4936ad5aab0ce94c9ae36a7a6125c4ca5048513af64b7e6e527b5472a1701d1f709c379acdd7ad12f6409d2cd languageName: node linkType: hard -"@json-schema-tools/reference-resolver@npm:1.2.4": - version: 1.2.4 - resolution: "@json-schema-tools/reference-resolver@npm:1.2.4" +"@json-schema-tools/reference-resolver@npm:1.2.6": + version: 1.2.6 + resolution: "@json-schema-tools/reference-resolver@npm:1.2.6" dependencies: "@json-schema-spec/json-pointer": "npm:^0.1.2" isomorphic-fetch: "npm:^3.0.0" - checksum: 10/1ad98d011e5aad72000112215615715593a0a244ca82dbf6008cc93bfcd14ef99a0796ab4e808faee083dc13182dc9ab2d01ca5db4f44ca880f45de2f5ea2437 + checksum: 10/91d6b4b2ac43f8163fd27bde6d826f29f339e9c7ce3b7e2b73b85e891fa78e3702fd487deda143a0701879cbc2fe28c53a4efce4cd2d2dd2fe6e82b64bbd9c9c languageName: node linkType: hard -"@json-schema-tools/reference-resolver@npm:^1.2.1, @json-schema-tools/reference-resolver@npm:^1.2.4": - version: 1.2.5 - resolution: "@json-schema-tools/reference-resolver@npm:1.2.5" +"@json-schema-tools/reference-resolver@patch:@json-schema-tools/reference-resolver@npm%3A1.2.6#~/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch": + version: 1.2.6 + resolution: "@json-schema-tools/reference-resolver@patch:@json-schema-tools/reference-resolver@npm%3A1.2.6#~/.yarn/patches/@json-schema-tools-reference-resolver-npm-1.2.6-4e1497c16d.patch::version=1.2.6&hash=6fefb6" dependencies: "@json-schema-spec/json-pointer": "npm:^0.1.2" isomorphic-fetch: "npm:^3.0.0" - checksum: 10/0f48098ea6df853a56fc7c758974eee4c5b7e3979123f49f52929c82a1eb263c7d0154efc6671325920d670494b05cae4d4625c6204023b4b1fed6e5f93ccb96 + checksum: 10/91534095e488dc091a6d9bf807a065697cdc2c070bbda70ebd0817569c46daa8cfb56b4e625a9d9ddaa0d08c5fdc40db3ef39cae97a16b682e8b593f1febf062 languageName: node linkType: hard @@ -4939,6 +4946,13 @@ __metadata: languageName: node linkType: hard +"@metamask/api-specs@npm:^0.10.12": + version: 0.10.12 + resolution: "@metamask/api-specs@npm:0.10.12" + checksum: 10/e592f27f350994688d3d54a8a8db16de033011ef665efe3283a77431914d8d69d1c3312fad33e4245b4984e1223b04c98da3d0a68c7f9577cf8290ba441c52ee + languageName: node + linkType: hard + "@metamask/api-specs@npm:^0.9.3": version: 0.9.3 resolution: "@metamask/api-specs@npm:0.9.3" @@ -5859,6 +5873,23 @@ __metadata: languageName: node linkType: hard +"@metamask/multichain@npm:^2.0.0": + version: 2.0.0 + resolution: "@metamask/multichain@npm:2.0.0" + dependencies: + "@metamask/api-specs": "npm:^0.10.12" + "@metamask/controller-utils": "npm:^11.4.4" + "@metamask/eth-json-rpc-filters": "npm:^9.0.0" + "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/utils": "npm:^10.0.0" + lodash: "npm:^4.17.21" + peerDependencies: + "@metamask/network-controller": ^22.0.0 + "@metamask/permission-controller": ^11.0.0 + checksum: 10/3ae5a1b76070f06b952c1781d36b075a11cc7e94cb3dec35f93e20ed29c5e356cec320079e583e92e3cce52613c125c740bdd020fae0da30a2503b2eb3a2dca0 + languageName: node + linkType: hard + "@metamask/name-controller@npm:^8.0.0": version: 8.0.0 resolution: "@metamask/name-controller@npm:8.0.0" @@ -7005,9 +7036,9 @@ __metadata: linkType: hard "@open-rpc/meta-schema@npm:^1.14.6": - version: 1.14.6 - resolution: "@open-rpc/meta-schema@npm:1.14.6" - checksum: 10/7cb672ea42c143c3fcb177ad04b935d56c38cb28fc7ede0a0bb50293e0e49dee81046c2d43bc57c8bbf9efbbb76356d60b4a8e408a03ecc8fa5952ef3e342316 + version: 1.14.9 + resolution: "@open-rpc/meta-schema@npm:1.14.9" + checksum: 10/51505dcf7aa1a2285c78953c9b33711cede5f2765aa37dcb9ee7756d689e2ff2a89cfc6039504f0569c52a805fb9aa18f30a7c02ad7a06e793c801e43b419104 languageName: node linkType: hard @@ -13336,13 +13367,13 @@ __metadata: linkType: hard "axios@npm:^1.1.3": - version: 1.7.7 - resolution: "axios@npm:1.7.7" + version: 1.7.4 + resolution: "axios@npm:1.7.4" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/7f875ea13b9298cd7b40fd09985209f7a38d38321f1118c701520939de2f113c4ba137832fe8e3f811f99a38e12c8225481011023209a77b0c0641270e20cde1 + checksum: 10/7a1429be1e3d0c2e1b96d4bba4d113efbfabc7c724bed107beb535c782c7bea447ff634886b0c7c43395a264d085450d009eb1154b5f38a8bae49d469fdcbc61 languageName: node linkType: hard @@ -26706,6 +26737,7 @@ __metadata: "@metamask/message-manager": "npm:^11.0.0" "@metamask/message-signing-snap": "npm:^0.6.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/multichain": "npm:^2.0.0" "@metamask/name-controller": "npm:^8.0.0" "@metamask/network-controller": "patch:@metamask/network-controller@npm%3A22.1.1#~/.yarn/patches/@metamask-network-controller-npm-22.1.1-09b6510f1e.patch" "@metamask/notification-services-controller": "npm:^0.15.0"