diff --git a/.storybook/test-data.js b/.storybook/test-data.js index 6e4eb2265b98..471d5b2206c6 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -1429,17 +1429,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 c6a3bccd46aa..115314fbed02 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..628431f570ce 100644 --- a/app/scripts/controllers/permissions/background-api.js +++ b/app/scripts/controllers/permissions/background-api.js @@ -1,29 +1,170 @@ 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 { RestrictedMethods } from '../../../../shared/constants/permissions'; +import { PermissionNames } from './specifications'; + +const snapsPrefixes = ['npm:', 'local:']; +const isSnap = (origin) => + snapsPrefixes.some((prefix) => origin.startsWith(prefix)); -export function getPermissionBackgroundApiMethods(permissionController) { +export function getPermissionBackgroundApiMethods({ + permissionController, + approvalController, +}) { + // To add more than one account when already connected to the dapp const addMoreAccounts = (origin, accounts) => { - const caveat = CaveatFactories.restrictReturnedAccounts(accounts); + 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; + } + } - permissionController.grantPermissionsIncremental({ - subject: { origin }, - approvedPermissions: { - [RestrictedMethods.eth_accounts]: { caveats: [caveat] }, - }, - }); + if (!caip25Caveat) { + throw new Error( + `Cannot add account permissions for origin "${origin}": no permission currently exists for this origin.`, + ); + } + + 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); + 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; + } + } + + 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, + ); - permissionController.grantPermissionsIncremental({ + const caveatValueWithAccounts = setEthAccounts( + caveatValueWithChains, + legacyApproval.approvedAccounts, + ); + + permissionController.grantPermissions({ subject: { origin }, approvedPermissions: { - [PermissionNames.permittedChains]: { caveats: [caveat] }, + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccounts, + }, + ], + }, }, }); }; @@ -31,15 +172,34 @@ 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, - ); + 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; + } + } + + 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 +212,81 @@ 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, - ); + 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; + } + } - const remainingChains = existingChains.filter( - (existingChain) => existingChain !== chainId, + if (!caip25Caveat) { + throw new Error( + `Cannot remove permission for chainId "${chainId}": No permissions exist for origin "${origin}".`, + ); + } + + 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 && !isSnap(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 38a2bb002523..85d6c3728a0b 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 afcc2e167043..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,13 +44,13 @@ async function addEthereumChainHandler( endApprovalFlow, getCurrentChainIdForDomain, getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin, }, ) { let validParams; try { - validParams = validateAddEthereumChainParams(req.params[0], end); + validParams = validateAddEthereumChainParams(req.params[0]); } catch (error) { return end(error); } @@ -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 ee0c9d3f732b..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,611 +59,288 @@ 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(); }); - describe('with `endowment:permitted-chains` permissioning inactive', () => { - it('creates a new network configuration for the given chainid and switches to it if none exists', async () => { - const mocks = makeMocks(); - await addEthereumChainHandler( + it('should validate the request params', async () => { + const { handler } = createMockedHandler(); + + const request = { + origin: 'example.com', + params: [ { - origin: 'example.com', - params: [ - { - chainId: CHAIN_IDS.OPTIMISM, - chainName: 'Optimism Mainnet', - rpcUrls: ['https://optimism.llamarpc.com'], - nativeCurrency: { - symbol: 'ETH', - decimals: 18, - }, - blockExplorerUrls: ['https://optimistic.etherscan.io'], - iconUrls: ['https://optimism.icon.com'], - }, - ], + foo: true, }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); + ], + }; - expect(mocks.requestUserApproval).toHaveBeenCalledTimes(1); - expect(mocks.addNetwork).toHaveBeenCalledTimes(1); - expect(mocks.addNetwork).toHaveBeenCalledWith({ - blockExplorerUrls: ['https://optimistic.etherscan.io'], - defaultBlockExplorerUrlIndex: 0, - chainId: '0xa', - defaultRpcEndpointIndex: 0, - name: 'Optimism Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - name: 'Optimism Mainnet', - url: 'https://optimism.llamarpc.com', - type: 'custom', - }, - ], - }); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith(123); + 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'); }); - it('creates a new networkConfiguration when called without "blockExplorerUrls" property', async () => { - const mocks = makeMocks(); - await addEthereumChainHandler( + 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, 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, + }, + ], + }); + + expect(mocks.addNetwork).toHaveBeenCalledWith(nonInfuraConfiguration); + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + NON_INFURA_CHAIN_ID, + 123, + 'approvalFlowId', + { + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + setActiveNetwork: mocks.setActiveNetwork, + requestPermittedChainsPermissionForOrigin: + mocks.requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin: + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + }, + ); + }); + + 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('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(), + ); + + await handler({ origin: 'example.com', params: [ { - chainId: CHAIN_IDS.OPTIMISM, - chainName: 'Optimism Mainnet', - rpcUrls: ['https://optimism.llamarpc.com'], + chainId: CHAIN_IDS.MAINNET, + chainName: 'Ethereum Mainnet', + rpcUrls: ['https://eth.llamarpc.com'], nativeCurrency: { symbol: 'ETH', decimals: 18, }, - iconUrls: ['https://optimism.icon.com'], + blockExplorerUrls: ['https://etherscan.io'], }, ], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.addNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - }); - - describe('if a networkConfiguration for the given chainId already exists', () => { - it('updates the existing networkConfiguration with the new rpc url if it doesnt already exist', async () => { - const mocks = makeMocks({ - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - // Start with just infura endpoint - .mockReturnValue(createMockMainnetConfiguration()), - }, }); - // Add a custom endpoint - 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, - ); - - expect(mocks.updateNetwork).toHaveBeenCalledTimes(1); expect(mocks.updateNetwork).toHaveBeenCalledWith( '0x1', { + blockExplorerUrls: ['https://etherscan.io'], chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 1, name: 'Ethereum Mainnet', - // Expect both endpoints + nativeCurrency: 'ETH', rpcEndpoints: [ { networkClientId: 'mainnet', - url: 'https://mainnet.infura.io/v3/', type: 'infura', + url: 'https://mainnet.infura.io/v3/', }, { name: 'Ethereum Mainnet', - url: 'https://eth.llamarpc.com', type: 'custom', + url: 'https://eth.llamarpc.com', }, ], - // and the new one is the default - defaultRpcEndpointIndex: 1, - nativeCurrency: 'ETH', - blockExplorerUrls: ['https://etherscan.io'], - defaultBlockExplorerUrlIndex: 0, }, undefined, ); - }); - - it('makes the rpc url the default if it already exists', async () => { - const existingNetwork = { - chainId: '0x1', - name: 'Ethereum Mainnet', - // Start with infura + custom endpoint - rpcEndpoints: [ - { - networkClientId: 'mainnet', - url: 'https://mainnet.infura.io/v3/', - type: 'infura', - }, - { - name: 'Ethereum Mainnet', - url: 'https://eth.llamarpc.com', - type: 'custom', - }, - ], - // Infura is the default - defaultRpcEndpointIndex: 0, - nativeCurrency: 'ETH', - blockExplorerUrls: ['https://etherscan.io'], - defaultBlockExplorerUrlIndex: 0, - }; - - const mocks = makeMocks({ - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(existingNetwork), - }, - }); - - // Add the same custom endpoint - 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'], - }, - ], - }, + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( {}, - jest.fn(), - jest.fn(), - mocks, - ); - - expect(mocks.updateNetwork).toHaveBeenCalledTimes(1); - expect(mocks.updateNetwork).toHaveBeenCalledWith( + end, '0x1', + 123, + 'approvalFlowId', { - ...existingNetwork, - // Verify the custom endpoint becomes the default - defaultRpcEndpointIndex: 1, - }, - undefined, - ); - }); - - it('switches to the network if its not already the currently selected chain id', async () => { - const existingNetwork = createMockMainnetConfiguration(); - - const mocks = makeMocks({ - overrides: { - // Start on sepolia - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.SEPOLIA), - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(existingNetwork), - }, - }); - - // Add with rpc + block explorers that already exist - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: CHAIN_IDS.MAINNET, - chainName: 'Ethereum Mainnet', - rpcUrls: [existingNetwork.rpcEndpoints[0].url], - nativeCurrency: { - symbol: 'ETH', - decimals: 18, - }, - blockExplorerUrls: ['https://etherscan.io'], - }, - ], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - - // No updates, network already had all the info - expect(mocks.updateNetwork).toHaveBeenCalledTimes(0); - - // User should be prompted to switch chains - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); - }); - - it('should return error for invalid chainId', async () => { - const mocks = makeMocks(); - const mockEnd = jest.fn(); - - await addEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: 'invalid_chain_id' }], + 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: `Expected 0x-prefixed, unpadded, non-zero hexadecimal string 'chainId'. Received:\ninvalid_chain_id`, - }), ); }); }); - }); - - describe('with `endowment:permitted-chains` permissioning active', () => { - 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 () => { - const nonInfuraConfiguration = createMockNonInfuraConfiguration(); - const mocks = makeMocks({ - permissionedChainIds: [], - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.MAINNET), - }, - }); - 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: [ { - chainId: nonInfuraConfiguration.chainId, - chainName: nonInfuraConfiguration.name, - rpcUrls: nonInfuraConfiguration.rpcEndpoints.map( + chainId: createMockOptimismConfiguration().chainId, + chainName: createMockOptimismConfiguration().name, + rpcUrls: createMockOptimismConfiguration().rpcEndpoints.map( (rpc) => rpc.url, ), nativeCurrency: { - symbol: nonInfuraConfiguration.nativeCurrency, + symbol: createMockOptimismConfiguration().nativeCurrency, decimals: 18, }, - blockExplorerUrls: nonInfuraConfiguration.blockExplorerUrls, + blockExplorerUrls: + createMockOptimismConfiguration().blockExplorerUrls, }, ], - }, - {}, - 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, - ); - - 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 addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: NON_INFURA_CHAIN_ID, - chainName: 'Custom Network', - rpcUrls: ['https://new-custom.network'], - nativeCurrency: { - symbol: 'CUST', - decimals: 18, - }, - blockExplorerUrls: ['https://custom.blockexplorer'], - }, - ], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - - 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( - { - origin: 'example.com', - params: [ - { - chainId: createMockOptimismConfiguration().chainId, - chainName: createMockOptimismConfiguration().name, - rpcUrls: createMockOptimismConfiguration().rpcEndpoints.map( - (rpc) => rpc.url, - ), - nativeCurrency: { - symbol: createMockOptimismConfiguration().nativeCurrency, - decimals: 18, - }, - blockExplorerUrls: - createMockOptimismConfiguration().blockExplorerUrls, - }, - ], - }, + expect(mocks.addNetwork).not.toHaveBeenCalled(); + expect(mocks.updateNetwork).not.toHaveBeenCalled(); + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( {}, - jest.fn(), - jest.fn(), - mocks, - ); - - expect(mocks.requestPermittedChainsPermission).not.toHaveBeenCalled(); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( + end, + '0xa', createMockOptimismConfiguration().rpcEndpoints[0].networkClientId, + undefined, + { + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + setActiveNetwork: mocks.setActiveNetwork, + requestPermittedChainsPermissionForOrigin: + mocks.requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin: + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + }, ); }); }); }); - 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: [ - { - chainId: createMockNonInfuraConfiguration().chainId, - chainName: createMockNonInfuraConfiguration().nickname, - rpcUrls: [createMockNonInfuraConfiguration().rpcUrl], - nativeCurrency: { - symbol: createMockNonInfuraConfiguration().ticker, - decimals: 18, - }, - blockExplorerUrls: [ - createMockNonInfuraConfiguration().blockExplorerUrls[0], - ], - [unexpectedParam]: 'parameter', - }, - ], - }, - {}, - jest.fn(), - mockEnd, - mocks, - ); - - expect(mockEnd).toHaveBeenCalledWith( - rpcErrors.invalidParams({ - message: `Received unexpected keys on object parameter. Unsupported keys:\n${unexpectedParam}`, - }), + it('should return an error if nativeCurrency.symbol does not match an existing network with the same chainId', async () => { + const { mocks, end, handler } = createMockedHandler(); + mocks.getNetworkConfigurationByChainId.mockReturnValue( + createMockMainnetConfiguration(), ); - }); - - 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'], + await handler({ + origin: 'example.com', + params: [ + { + chainId: CHAIN_IDS.MAINNET, + chainName: 'Ethereum Mainnet', + rpcUrls: ['https://mainnet.infura.io/v3/'], + nativeCurrency: { + symbol: 'WRONG', + decimals: 18, }, - ], - }, - {}, - 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()), - }, + blockExplorerUrls: ['https://etherscan.io'], + }, + ], }); - 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, - ); - 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`, }), @@ -666,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..75d17fb2997b 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, @@ -49,11 +48,11 @@ export default ethAccounts; */ 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 10973e052715..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,31 +1,35 @@ 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, end) { +export function validateSwitchEthereumChainParams(req) { if (!req.params?.[0] || typeof req.params[0] !== 'object') { throw rpcErrors.invalidParams({ message: `Expected single, object parameter. Received:\n${JSON.stringify( @@ -43,10 +47,10 @@ export function validateSwitchEthereumChainParams(req, end) { }); } - return validateChainId(chainId, end); + return validateChainId(chainId); } -export function validateAddEthereumChainParams(params, end) { +export function validateAddEthereumChainParams(params) { if (!params || typeof params !== 'object') { throw rpcErrors.invalidParams({ message: `Expected single, object parameter. Received:\n${JSON.stringify( @@ -75,7 +79,7 @@ export function validateAddEthereumChainParams(params, end) { }); } - const _chainId = validateChainId(chainId, end); + const _chainId = validateChainId(chainId); if (!rpcUrls || !Array.isArray(rpcUrls) || rpcUrls.length === 0) { throw rpcErrors.invalidParams({ message: `Expected an array with at least one valid string HTTPS url 'rpcUrls', Received:\n${rpcUrls}`, @@ -152,8 +156,26 @@ export function validateAddEthereumChainParams(params, end) { }; } +/** + * 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 deleted file mode 100644 index 68b52ea75549..000000000000 --- a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js +++ /dev/null @@ -1,141 +0,0 @@ -import { rpcErrors } from '@metamask/rpc-errors'; -import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; -import { - MetaMetricsEventName, - MetaMetricsEventCategory, -} 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, - }, -}; -export default requestEthereumAccounts; - -// Used to rate-limit pending requests to one per origin -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. - */ - -/** - * - * @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. - */ -async function requestEthereumAccountsHandler( - _req, - res, - _next, - end, - { - origin, - getAccounts, - getUnlockPromise, - hasPermission, - requestAccountsPermission, - sendMetrics, - getPermissionsForOrigin, - metamaskState, - }, -) { - if (locks.has(origin)) { - res.error = rpcErrors.resourceUnavailable( - `Already processing ${MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS}. Please wait.`, - ); - return end(); - } - - if (hasPermission(MESSAGE_TYPE.ETH_ACCOUNTS)) { - // 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(); - end(); - } catch (error) { - end(error); - } finally { - locks.delete(origin); - } - return undefined; - } - - // If no accounts, request the accounts permission - try { - await requestAccountsPermission(); - } catch (err) { - res.error = err; - return end(); - } - - // 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 - 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.', - ); - } - - 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..d3a52aad230b --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.ts @@ -0,0 +1,193 @@ +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 requestCaip25PermissionForOrigin = jest.fn().mockResolvedValue({}); + 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, + requestCaip25PermissionForOrigin, + }); + + return { + response, + next, + end, + getAccounts, + getUnlockPromise, + sendMetrics, + metamaskState, + requestCaip25PermissionForOrigin, + 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(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 permission', async () => { + const { handler, requestCaip25PermissionForOrigin } = + createMockedHandler(); + + await handler({ ...baseRequest, origin: 'http://test.com' }); + expect(requestCaip25PermissionForOrigin).toHaveBeenCalledWith(); + }); + + it('throws an error if the CAIP-25 permission approval is rejected', async () => { + const { handler, requestCaip25PermissionForOrigin, end } = + createMockedHandler(); + requestCaip25PermissionForOrigin.mockRejectedValue( + new Error('approval rejected'), + ); + + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(new Error('approval rejected')); + }); + + 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/request-accounts.ts b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.ts new file mode 100644 index 000000000000..5efded2fe007 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.ts @@ -0,0 +1,160 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25CaveatValue, + Caip25EndowmentPermissionName, +} from '@metamask/multichain'; +import { + Caveat, + RequestedPermissions, + ValidPermission, +} from '@metamask/permission-controller'; +import { + JsonRpcParams, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import { MESSAGE_TYPE } from '../../../../../shared/constants/app'; +import { + MetaMetricsEventName, + MetaMetricsEventCategory, + MetaMetricsEventPayload, + MetaMetricsEventOptions, +} from '../../../../../shared/constants/metametrics'; +import { shouldEmitDappViewedEvent } from '../../util'; + +const requestEthereumAccounts = { + methodNames: [MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS], + implementation: requestEthereumAccountsHandler, + hookNames: { + getAccounts: true, + getUnlockPromise: true, + sendMetrics: true, + metamaskState: true, + requestCaip25PermissionForOrigin: true, + }, +}; +export default requestEthereumAccounts; + +// Used to rate-limit pending requests to one per origin +const locks = new Set(); + +/** + * 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 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.requestCaip25PermissionForOrigin - A hook that requests the CAIP-25 permission for the origin. + * @param options.metamaskState.metaMetricsId + * @param options.metamaskState.permissionHistory + * @param options.metamaskState.accounts + * @returns A promise that resolves to nothing + */ +async function requestEthereumAccountsHandler( + req: JsonRpcRequest & { origin: string }, + res: PendingJsonRpcResponse, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + getAccounts, + getUnlockPromise, + sendMetrics, + metamaskState, + requestCaip25PermissionForOrigin, + }: { + getAccounts: (ignoreLock?: boolean) => string[]; + getUnlockPromise: (shouldShowUnlockRequest: true) => Promise; + sendMetrics: ( + payload: MetaMetricsEventPayload, + options?: MetaMetricsEventOptions, + ) => void; + metamaskState: { + metaMetricsId: string; + permissionHistory: Record; + accounts: Record; + }; + requestCaip25PermissionForOrigin: ( + requestedPermissions?: RequestedPermissions, + ) => Promise< + ValidPermission< + typeof Caip25EndowmentPermissionName, + Caveat + > + >; + }, +) { + const { origin } = req; + if (locks.has(origin)) { + res.error = rpcErrors.resourceUnavailable( + `Already processing ${MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS}. Please wait.`, + ); + return end(); + } + + let ethAccounts = getAccounts(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 = ethAccounts; + end(); + } catch (error) { + end(error as unknown as Error); + } finally { + locks.delete(origin); + } + return undefined; + } + + try { + await requestCaip25PermissionForOrigin(); + } catch (error) { + return end(error as unknown as Error); + } + + ethAccounts = getAccounts(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, + ); + 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, + }, + }); + } + + // We cannot derive ethAccounts directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + res.result = ethAccounts; + + return end(); +} 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 5f907bef4d4b..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,15 +28,15 @@ async function switchEthereumChainHandler( { getNetworkConfigurationByChainId, setActiveNetwork, - requestPermittedChainsPermission, getCaveat, getCurrentChainIdForDomain, - grantPermittedChainsPermissionIncremental, + requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin, }, ) { let chainId; try { - chainId = validateSwitchEthereumChainParams(req, end); + chainId = validateSwitchEthereumChainParams(req); } catch (error) { return end(error); } @@ -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 be612fbc7d8e..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'; @@ -26,257 +34,145 @@ const createMockLineaMainnetConfiguration = () => ({ ], }); -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 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(); }); - describe('with permittedChains permissioning inactive', () => { - it('should call setActiveNetwork when switching to a built-in infura network', async () => { - const mocks = makeMocks({ - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(createMockMainnetConfiguration()), - }, - }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); - }); + it('should validate the request params', async () => { + const { handler } = createMockedHandler(); - it('should call setActiveNetwork when switching to a built-in infura network, when chainId from request is lower case', async () => { - const mocks = makeMocks({ - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(createMockLineaMainnetConfiguration()), - }, - }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( + const request = { + origin: 'example.com', + params: [ { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.LINEA_MAINNET.toLowerCase() }], + foo: true, }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockLineaMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); - }); + ], + }; - it('should call setActiveNetwork when switching to a built-in infura network, when chainId from request is upper case', async () => { - const mocks = makeMocks({ - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(createMockLineaMainnetConfiguration()), - }, - }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.LINEA_MAINNET.toUpperCase() }], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockLineaMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); - }); + await handler(request); - it('should call setActiveNetwork when switching to a custom network', async () => { - const mocks = makeMocks({ - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.MAINNET), - }, - }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: NON_INFURA_CHAIN_ID }], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); - }); + expect( + EthChainUtils.validateSwitchEthereumChainParams, + ).toHaveBeenCalledWith(request); + }); - it('should handle missing networkConfiguration', async () => { - // Mock a network configuration that has an undefined or missing rpcEndpoints - const mockNetworkConfiguration = undefined; + 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 mocks = makeMocks({ - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(mockNetworkConfiguration), - }, - }); + await handler({ + origin: 'example.com', + params: [{}], + }); - const switchEthereumChainHandler = switchEthereumChain.implementation; + expect(end).toHaveBeenCalledWith(new Error('failed to validate params')); + }); - const mockEnd = jest.fn(); - await switchEthereumChainHandler( + 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: [ { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], + chainId: NON_INFURA_CHAIN_ID, }, - {}, - jest.fn(), - mockEnd, - mocks, - ); - - // Check that the function handled the missing rpcEndpoints and did not attempt to call setActiveNetwork - expect(mockEnd).toHaveBeenCalledWith( - expect.objectContaining({ - code: 4902, - message: expect.stringContaining('Unrecognized chain ID'), - }), - ); - expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); + ], }); - }); - describe('with permittedChains permissioning active', () => { - 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, - }, - }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); + expect(response.result).toStrictEqual(null); + expect(end).toHaveBeenCalled(); + expect(EthChainUtils.switchChain).not.toHaveBeenCalled(); + }); - 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, - ); - }); + 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); - it('should call setActiveNetwork without calling requestPermittedChainsPermission when requested chainId is in `endowment:permitted-chains`', async () => { - const mocks = makeMocks({ - permissionedChainIds: [CHAIN_IDS.MAINNET], - }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( + await handler({ + origin: 'example.com', + params: [ { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], + chainId: NON_INFURA_CHAIN_ID, }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - - expect(mocks.requestPermittedChainsPermission).not.toHaveBeenCalled(); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); + ], }); - 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, - }, - }); - const mockEnd = jest.fn(); - const switchEthereumChainHandler = switchEthereumChain.implementation; + 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(); + }); - await switchEthereumChainHandler( + it('tries to switch the network', async () => { + const { mocks, end, handler } = createMockedHandler(); + mocks.getNetworkConfigurationByChainId + .mockReturnValueOnce(createMockMainnetConfiguration()) + .mockReturnValueOnce(createMockLineaMainnetConfiguration()); + await handler({ + origin: 'example.com', + params: [ { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], + chainId: '0xdeadbeef', }, - {}, - jest.fn(), - mockEnd, - mocks, - ); - - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledTimes(1); - expect(mockEnd).toHaveBeenCalledWith(mockError); - expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); + ], }); + + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + '0xdeadbeef', + 'mainnet', + null, + { + setActiveNetwork: mocks.setActiveNetwork, + getCaveat: mocks.getCaveat, + requestPermittedChainsPermissionForOrigin: + mocks.requestPermittedChainsPermissionForOrigin, + requestPermittedChainsPermissionIncrementalForOrigin: + mocks.requestPermittedChainsPermissionIncrementalForOrigin, + }, + ); }); }); 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..c3f13c91468d --- /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(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..bdd2332a857a --- /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 + * @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: (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(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..622cf33b38f3 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.test.ts @@ -0,0 +1,638 @@ +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 requestCaip25PermissionForOrigin = jest.fn().mockResolvedValue({}); + 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, + requestCaip25PermissionForOrigin, + }, + ); + + return { + response, + next, + end, + getAccounts, + requestPermissionsForOrigin, + requestCaip25PermissionForOrigin, + 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, + requestCaip25PermissionForOrigin, + } = createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [Caip25EndowmentPermissionName]: {}, + }, + ], + }), + ); + + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ + [Caip25EndowmentPermissionName]: {}, + }); + expect(requestCaip25PermissionForOrigin).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, requestCaip25PermissionForOrigin } = + createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }, + ], + }), + ); + + expect(requestCaip25PermissionForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }); + }); + + it('requests the CAIP-25 permission for permittedChains when only permittedChains is specified in params', async () => { + const { handler, requestCaip25PermissionForOrigin } = + createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect(requestCaip25PermissionForOrigin).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, requestCaip25PermissionForOrigin } = + createMockedHandler(); + + await handler( + getBaseRequest({ + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }), + ); + + expect(requestCaip25PermissionForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + + it('returns an error if requesting the CAIP-25 permission fails', async () => { + const { handler, requestCaip25PermissionForOrigin, end } = + createMockedHandler(); + requestCaip25PermissionForOrigin.mockRejectedValue( + new Error('failed to request caip25 permission'), + ); + + 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 permission'), + ); + }); + + it('returns both eth_accounts and permittedChains permissions that were granted if there are permitted chains', async () => { + const { + handler, + getAccounts, + requestCaip25PermissionForOrigin, + response, + } = createMockedHandler(); + getAccounts.mockReturnValue(['0xdeadbeef']); + requestCaip25PermissionForOrigin.mockResolvedValue({ + 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, + requestCaip25PermissionForOrigin, + response, + } = createMockedHandler(); + getAccounts.mockReturnValue(['0xdeadbeef']); + requestCaip25PermissionForOrigin.mockResolvedValue({ + 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 granted', () => { + it('returns eth_accounts, permittedChains, and other permissions that were granted', async () => { + const { + handler, + getAccounts, + requestPermissionsForOrigin, + requestCaip25PermissionForOrigin, + response, + } = createMockedHandler(); + requestPermissionsForOrigin.mockResolvedValue([ + { + otherPermissionA: { foo: 'bar' }, + otherPermissionB: { hello: true }, + }, + ]); + getAccounts.mockReturnValue(['0xdeadbeef']); + requestCaip25PermissionForOrigin.mockResolvedValue({ + 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 granted, but other permissions are not granted', () => { + it('returns both eth_accounts and permittedChains permissions that were granted', async () => { + const { + handler, + getAccounts, + requestPermissionsForOrigin, + requestCaip25PermissionForOrigin, + response, + } = createMockedHandler(); + requestPermissionsForOrigin.mockRejectedValue( + new Error('other permissions rejected'), + ); + getAccounts.mockReturnValue(['0xdeadbeef']); + requestCaip25PermissionForOrigin.mockResolvedValue({ + 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([ + { + 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 not granted, but other permissions are granted', () => { + it('returns the other permissions that are granted', async () => { + const { + handler, + requestPermissionsForOrigin, + requestCaip25PermissionForOrigin, + response, + } = createMockedHandler(); + requestPermissionsForOrigin.mockResolvedValue([ + { + otherPermissionA: { foo: 'bar' }, + otherPermissionB: { hello: true }, + }, + ]); + requestCaip25PermissionForOrigin.mockRejectedValue( + new Error('caip25 permission rejected'), + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(response.result).toStrictEqual([ + { foo: 'bar' }, + { hello: true }, + ]); + }); + }); + + describe('both CAIP-25 equivalent permissions and other permissions are not granted', () => { + it('returns the error from the rejected CAIP-25 permission grant', async () => { + const { + handler, + requestPermissionsForOrigin, + requestCaip25PermissionForOrigin, + end, + } = createMockedHandler(); + requestPermissionsForOrigin.mockRejectedValue( + new Error('other permissions rejected'), + ); + requestCaip25PermissionForOrigin.mockRejectedValue( + new Error('caip25 permission rejected'), + ); + + await handler( + getBaseRequest({ + params: [ + { + eth_accounts: {}, + 'endowment:permitted-chains': {}, + otherPermissionA: {}, + otherPermissionB: {}, + }, + ], + }), + ); + + expect(end).toHaveBeenCalledWith( + new Error('caip25 permission 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..0ee80669758a --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-requestPermissions.ts @@ -0,0 +1,181 @@ +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, + requestCaip25PermissionForOrigin: 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 + * @param options.requestCaip25PermissionForOrigin + * @param options.requestPermissionsForOrigin + * @returns A promise that resolves to nothing + */ +async function requestPermissionsImplementation( + req: JsonRpcRequest<[RequestedPermissions]> & { origin: string }, + res: PendingJsonRpcResponse, + _next: AsyncJsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + getAccounts, + requestPermissionsForOrigin, + requestCaip25PermissionForOrigin, + }: { + getAccounts: () => string[]; + requestPermissionsForOrigin: ( + requestedPermissions: RequestedPermissions, + ) => Promise<[GrantedPermissions]>; + requestCaip25PermissionForOrigin: ( + requestedPermissions?: RequestedPermissions, + ) => Promise< + 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 didGrantOtherPermissions; + + if (hasOtherRequestedPermissions || !hasCaip25EquivalentPermissions) { + try { + const [frozenGrantedPermissions] = await requestPermissionsForOrigin( + requestedPermissions, + ); + // permissions are frozen and must be cloned before modified + grantedPermissions = { ...frozenGrantedPermissions }; + didGrantOtherPermissions = true; + } catch (error) { + if (!hasCaip25EquivalentPermissions) { + return end(error as unknown as Error); + } + } + } + + let caip25Endowment; + let caip25CaveatValue; + if (hasCaip25EquivalentPermissions) { + try { + caip25Endowment = await requestCaip25PermissionForOrigin( + caip25EquivalentPermissions, + ); + 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, + }, + ], + }; + } + } catch (error) { + if (!didGrantOtherPermissions) { + return end(error as unknown as Error); + } + } + } + + res.result = Object.values(grantedPermissions).filter( + (value) => value !== undefined, + ) as Json; + 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..b637784bbd73 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.test.ts @@ -0,0 +1,221 @@ +import { invalidParams } from '@metamask/permission-controller'; +import { + Caip25CaveatType, + 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 getPermissionsForOrigin = jest.fn().mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }, + ], + }, + }), + ); + const response: PendingJsonRpcResponse = { + jsonrpc: '2.0' as const, + id: 0, + }; + const handler = (request: JsonRpcRequest) => + revokePermissionsHandler.implementation(request, response, next, end, { + revokePermissionsForOrigin, + getPermissionsForOrigin, + }); + + return { + response, + next, + end, + revokePermissionsForOrigin, + getPermissionsForOrigin, + 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('gets permissions for the origin', () => { + const { handler, getPermissionsForOrigin } = createMockedHandler(); + + handler({ + ...baseRequest, + params: [ + { + [permission]: {}, + }, + ], + }); + expect(getPermissionsForOrigin).toHaveBeenCalled(); + }); + + 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('throws an error when a CAIP-25 permission exists from the multichain flow (isMultichainOrigin: true)', () => { + const { handler, getPermissionsForOrigin, end } = createMockedHandler(); + getPermissionsForOrigin.mockReturnValue({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }, + ], + }, + }); + + handler({ + ...baseRequest, + params: [ + { + [permission]: {}, + otherPermission: {}, + }, + ], + }); + expect(end).toHaveBeenCalledWith( + new Error( + 'Cannot modify permission granted via the Multichain API. Either modify the permission using the Multichain API or revoke permissions and request again.', + ), + ); + }); + }); + + 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..80d9591a36b1 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-revokePermissions.ts @@ -0,0 +1,117 @@ +import { + CaveatSpecificationConstraint, + invalidParams, + MethodNames, + PermissionController, + PermissionSpecificationConstraint, +} from '@metamask/permission-controller'; +import { + isNonEmptyArray, + Json, + JsonRpcRequest, + PendingJsonRpcResponse, +} from '@metamask/utils'; +import { + Caip25CaveatType, + Caip25CaveatValue, + 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, + getPermissionsForOrigin: 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 + * @param options.getPermissionsForOrigin + * @returns A promise that resolves to nothing + */ +function revokePermissionsImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: AsyncJsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + { + revokePermissionsForOrigin, + getPermissionsForOrigin, + }: { + revokePermissionsForOrigin: (permissionKeys: string[]) => void; + getPermissionsForOrigin: () => ReturnType< + PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + >['getPermissions'] + >; + }, +) { + 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 relevantPermissionKeys = permissionKeys.filter( + (name: string) => + ![ + RestrictedMethods.eth_accounts as string, + PermissionNames.permittedChains as string, + ].includes(name), + ); + + const shouldRevokeLegacyPermission = + relevantPermissionKeys.length !== permissionKeys.length; + + if (shouldRevokeLegacyPermission) { + const permissions = getPermissionsForOrigin() || {}; + const caip25Endowment = permissions?.[Caip25EndowmentPermissionName]; + const caip25CaveatValue = caip25Endowment?.caveats?.find( + ({ type }) => type === Caip25CaveatType, + )?.value as Caip25CaveatValue | undefined; + + if (caip25CaveatValue?.isMultichainOrigin) { + return end( + new Error( + 'Cannot modify permission granted via the Multichain API. Either modify the permission using the Multichain API or revoke permissions and request again.', + ), + ); + } + 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 62fe3c942589..db6aa7e4e0e6 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'; @@ -64,6 +60,7 @@ import { } from '@metamask/network-controller'; import { GasFeeController } from '@metamask/gas-fee-controller'; import { + MethodNames, PermissionController, PermissionDoesNotExistError, PermissionsRequestNotFoundError, @@ -156,6 +153,7 @@ import { import { Interface } from '@ethersproject/abi'; import { abiERC1155, abiERC721 } from '@metamask/metamask-eth-abis'; import { isEvmAccountType } from '@metamask/keyring-api'; +import { toCaipChainId } from '@metamask/utils'; import { AuthenticationController, UserStorageController, @@ -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'; @@ -300,8 +307,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'; @@ -332,8 +339,6 @@ import EncryptionPublicKeyController from './controllers/encryption-public-key'; import AppMetadataController from './controllers/app-metadata'; import { - CaveatFactories, - CaveatMutatorFactories, getCaveatSpecifications, diffMap, getPermissionBackgroundApiMethods, @@ -341,8 +346,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'; @@ -1269,7 +1274,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: @@ -1278,42 +1283,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, @@ -1430,6 +1400,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, @@ -2451,18 +2422,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 }, @@ -3364,7 +3330,7 @@ export default class MetamaskController extends EventEmitter { return { isUnlocked: this.isUnlocked(), - accounts: await this.getPermittedAccounts(origin), + accounts: this.getPermittedAccounts(origin), ...providerNetworkState, }; } @@ -3952,7 +3918,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( @@ -5202,53 +5171,131 @@ export default class MetamaskController extends EventEmitter { return selectedAddress; } + 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.`, + ), + ); + } + + 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 permitted accounts for the specified origin. Returns an empty - * array if no accounts are permitted. + * 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 {boolean} 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', parseInt(targetChainId, 16).toString()), + ), ); } @@ -5264,11 +5311,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, + ), ); } @@ -5311,6 +5359,225 @@ 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}"`, + ); + } + + const caip25Caveat = this.permissionController.getCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + + if (caip25Caveat.value.isMultichainOrigin) { + throw providerErrors.unauthorized( + `Cannot switch to or add permissions for chainId '${chainId}' because permissions were granted over the Multichain API.`, + ); + } + + if (!autoApprove) { + await this.requestApprovalPermittedChainsPermission(origin, chainId); + } + + const caveatValueWithChainsAdded = addPermittedEthChainId( + caip25Caveat.value, + chainId, + ); + + const ethAccounts = getEthAccounts(caip25Caveat.value); + const caveatValueWithAccountsSynced = setEthAccounts( + caveatValueWithChainsAdded, + ethAccounts, + ); + + this.permissionController.updateCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + caveatValueWithAccountsSynced, + ); + } + + /** + * Requests CAIP-25 for permissions for the specified origin + * and replaces any existing CAIP-25 permission with a new one. + * + * @param {string} origin - The origin to request approval for. + * @param requestedPermissions - The legacy permissions to request approval for. + * @returns the granted CAIP-25 Permission. + */ + async requestCaip25Permission(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: {}, + isMultichainOrigin: false, + }; + + const caveatValueWithChains = isSnapId(origin) + ? { + ...newCaveatValue, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + } + : setPermittedEthChainIds( + newCaveatValue, + legacyApproval.approvedChainIds, + ); + + const caveatValueWithAccounts = setEthAccounts( + caveatValueWithChains, + legacyApproval.approvedAccounts, + ); + + const grantedPermissions = this.permissionController.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccounts, + }, + ], + }, + }, + }); + + return grantedPermissions[Caip25EndowmentPermissionName]; + } + // --------------------------------------------------------------------------- // Identity Management (signature operations) @@ -6040,7 +6307,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), }), ); @@ -6076,9 +6343,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 @@ -6107,63 +6372,28 @@ export default class MetamaskController extends EventEmitter { ), // Permission-related getAccounts: this.getPermittedAccounts.bind(this, origin), - getPermissionsForOrigin: this.permissionController.getPermissions.bind( - this.permissionController, + requestCaip25PermissionForOrigin: this.requestCaip25Permission.bind( + this, origin, ), - hasPermission: this.permissionController.hasPermission.bind( + 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 { @@ -6200,12 +6430,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( @@ -6242,6 +6472,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: @@ -6587,12 +6821,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), }, }; }); @@ -7180,7 +7414,7 @@ export default class MetamaskController extends EventEmitter { ]); } - async _notifyAccountsChange(origin, newAccounts) { + _notifyAccountsChange(origin, newAccounts) { if (this.isUnlocked()) { this.notifyConnections(origin, { method: NOTIFICATION_NAMES.accountsChanged, @@ -7193,7 +7427,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 47a9050232fd..5255622956c3 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -32,6 +32,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'; @@ -44,6 +49,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, @@ -54,6 +63,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'); @@ -110,10 +120,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,1285 @@ 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', 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', 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('#requestCaip25Permission', () => { + 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.requestCaip25Permission('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.requestCaip25Permission('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.requestCaip25Permission('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.requestCaip25Permission('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.requestCaip25Permission('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.requestCaip25Permission('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.requestCaip25Permission('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.requestCaip25Permission('test.com', { + eth_accounts: {}, + }), + ).rejects.toThrow(new Error('approval rejected')); + }); + + it('grants the CAIP-25 permission with eth accounts, chainIds, and isMultichainOrigin: false if origin is not snapId', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + await metamaskController.requestCaip25Permission('test.com', {}); + + expect( + metamaskController.permissionController.grantPermissions, + ).toHaveBeenCalledWith({ + subject: { origin: 'test.com' }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }); + }); + + it('grants the CAIP-25 permission 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', + }, + }); + + await metamaskController.requestCaip25Permission('npm:snap', {}); + + expect( + metamaskController.permissionController.grantPermissions, + ).toHaveBeenCalledWith({ + subject: { origin: 'npm:snap' }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }); + }); + + it('returns the result from the ApprovalController', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }); + jest + .spyOn(metamaskController.permissionController, 'grantPermissions') + .mockReturnValue({ + [Caip25EndowmentPermissionName]: { + foo: 'bar', + }, + }); + + expect( + await metamaskController.requestCaip25Permission('test.com', {}), + ).toStrictEqual({ foo: 'bar' }); + }); + }); + + 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/135.test.ts b/app/scripts/migrations/135.test.ts new file mode 100644 index 000000000000..c5fde9fedfc0 --- /dev/null +++ b/app/scripts/migrations/135.test.ts @@ -0,0 +1,1151 @@ +import { migrate, version } from './135'; + +const PermissionNames = { + eth_accounts: 'eth_accounts', + permittedChains: 'endowment:permitted-chains', +}; + +const sentryCaptureExceptionMock = jest.fn(); + +global.sentry = { + captureException: sentryCaptureExceptionMock, +}; + +const oldVersion = 134; + +describe('migration #135', () => { + 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(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.PermissionController is undefined`, + ), + ); + 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 missing', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PermissionController: {}, + NetworkController: {}, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + new Error( + `Migration ${version}: typeof state.SelectedNetworkController is undefined`, + ), + ); + 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 currentScope = `eip155:${chainId}`; + + 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]: { + 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': { + 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 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]: { + 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': { + 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]: { + 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': { + 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]: { + 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': { + 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]: { + 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': { + 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]: { + 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]: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef', '0x999'], + }, + ], + }, + [PermissionNames.permittedChains]: { + 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': { + 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]: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + }, + }, + 'test2.com': { + permissions: { + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + expect(newStorage.data).toStrictEqual({ + ...baseData(), + PermissionController: { + subjects: { + 'test.com': { + permissions: { + 'endowment:caip25': { + 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': { + 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/135.ts b/app/scripts/migrations/135.ts new file mode 100644 index 000000000000..b48dba122cb4 --- /dev/null +++ b/app/scripts/migrations/135.ts @@ -0,0 +1,318 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import type { CaipChainId, CaipAccountId, Json, Hex } 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 = 135; + +// 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; + +const BUILT_IN_NETWORKS: ReadonlyMap = new Map([ + ['sepolia', '0xaa36a7'], + ['mainnet', '0x1'], + ['linea-sepolia', '0xe705'], + ['linea-mainnet', '0xe708'], +]); + +const snapsPrefixes = ['npm:', 'local:'] as const; + +/** + * 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; + transformState(versionedData.data); + return versionedData; +} + +function transformState(state: Record) { + if ( + !hasProperty(state, 'PermissionController') || + !isObject(state.PermissionController) + ) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.PermissionController is ${typeof state.PermissionController}`, + ), + ); + return state; + } + + if ( + !hasProperty(state, 'NetworkController') || + !isObject(state.NetworkController) + ) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.NetworkController is ${typeof state.NetworkController}`, + ), + ); + return state; + } + + if ( + !hasProperty(state, 'SelectedNetworkController') || + !isObject(state.SelectedNetworkController) + ) { + global.sentry?.captureException?.( + new Error( + `Migration ${version}: typeof state.SelectedNetworkController is ${typeof state.SelectedNetworkController}`, + ), + ); + return state; + } + + const { + PermissionController: { subjects }, + NetworkController: { + selectedNetworkClientId, + networkConfigurationsByChainId, + }, + SelectedNetworkController: { domains }, + } = state; + + if (!isObject(subjects)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.PermissionController.subjects is ${typeof subjects}`, + ), + ); + return state; + } + if (!selectedNetworkClientId || typeof selectedNetworkClientId !== 'string') { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.NetworkController.selectedNetworkClientId is ${typeof selectedNetworkClientId}`, + ), + ); + return state; + } + if (!isObject(networkConfigurationsByChainId)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId is ${typeof networkConfigurationsByChainId}`, + ), + ); + return state; + } + if (!isObject(domains)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.SelectedNetworkController.domains is ${typeof domains}`, + ), + ); + return state; + } + + const getChainIdForNetworkClientId = ( + networkClientId: string, + propertyName: string, + ): 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}`, + ), + ); + return undefined; + } + if (!Array.isArray(networkConfiguration.rpcEndpoints)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["${chainId}"].rpcEndpoints is ${typeof networkConfiguration.rpcEndpoints}`, + ), + ); + return undefined; + } + 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}`, + ), + ); + return undefined; + } + if (rpcEndpoint.networkClientId === networkClientId) { + return chainId; + } + } + } + + 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 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 state; + } + + const { permissions } = subject as { + permissions: Record; + }; + if (!isObject(permissions)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: Invalid permissions for origin "${origin}" of type ${typeof permissions}`, + ), + ); + return state; + } + + let basePermission: PermissionConstraint | undefined; + + let ethAccounts: string[] = []; + if ( + isObject(permissions[PermissionNames.eth_accounts]) && + Array.isArray(permissions[PermissionNames.eth_accounts].caveats) + ) { + ethAccounts = + (permissions[PermissionNames.eth_accounts].caveats?.[0] + ?.value as string[]) ?? []; + basePermission = permissions[PermissionNames.eth_accounts]; + } + delete permissions[PermissionNames.eth_accounts]; + + let chainIds: string[] = []; + if ( + isObject(permissions[PermissionNames.permittedChains]) && + Array.isArray(permissions[PermissionNames.permittedChains].caveats) + ) { + chainIds = + (permissions[PermissionNames.permittedChains].caveats?.[0] + ?.value as string[]) ?? []; + basePermission ??= permissions[PermissionNames.permittedChains]; + } + delete permissions[PermissionNames.permittedChains]; + + if (ethAccounts.length === 0 || !basePermission) { + continue; + } + + 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:${parseInt(chainId, 16)}`, + ); + 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 state; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 8700b833d8de..35b72816b388 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -158,6 +158,7 @@ const migrations = [ require('./133.1'), require('./133.2'), require('./134'), + require('./135'), ]; export default migrations; diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index e55d57f5ec0f..88f0c695b713 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1406,6 +1406,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 e55d57f5ec0f..88f0c695b713 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1406,6 +1406,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 e55d57f5ec0f..88f0c695b713 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1406,6 +1406,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 5658498ad3a7..a71f3106f748 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -1498,6 +1498,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 da93c6c75761..4bb941544c24 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,11 @@ "@expo/config-plugins/glob": "^10.3.10", "@solana/web3.js/rpc-websockets": "^8.0.1", "@metamask/nonce-tracker@npm:^5.0.0": "patch:@metamask/nonce-tracker@npm%3A5.0.0#~/.yarn/patches/@metamask-nonce-tracker-npm-5.0.0-d81478218e.patch", + "@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", @@ -320,6 +325,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/fixture-builder.js b/test/e2e/fixture-builder.js index 020c4db1c64b..c82fb2ba69f5 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -467,111 +467,171 @@ class FixtureBuilder { } withPermissionControllerConnectedToTestDapp({ - restrictReturnedAccounts = true, account = '', useLocalhostHostname = false, } = {}) { const selectedAccount = account || DEFAULT_FIXTURE_ACCOUNT; - return this.withPermissionController({ - subjects: { - [useLocalhostHostname ? DAPP_URL_LOCALHOST : DAPP_URL]: { - origin: useLocalhostHostname ? DAPP_URL_LOCALHOST : DAPP_URL, - permissions: { - eth_accounts: { - id: 'ZaqPEWxyhNCJYACFw93jE', - parentCapability: 'eth_accounts', - invoker: DAPP_URL, - caveats: restrictReturnedAccounts && [ - { - type: 'restrictReturnedAccounts', - value: [ - selectedAccount.toLowerCase(), - '0x09781764c08de8ca82e156bbf156a3ca217c7950', - ERC_4337_ACCOUNT.toLowerCase(), - ], + const subjects = { + [useLocalhostHostname ? DAPP_URL_LOCALHOST : DAPP_URL]: { + origin: useLocalhostHostname ? DAPP_URL_LOCALHOST : DAPP_URL, + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + `eip155:1337:${selectedAccount.toLowerCase()}`, + ], + }, + }, + isMultichainOrigin: false, }, - ], - date: 1664388714636, - }, + }, + ], + id: 'ZaqPEWxyhNCJYACFw93jE', + date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', }, }, }, + }; + return this.withPermissionController({ + subjects, }); } - withPermissionControllerSnapAccountConnectedToTestDapp( - restrictReturnedAccounts = true, - ) { - return this.withPermissionController({ - subjects: { - [DAPP_URL]: { - origin: DAPP_URL, - permissions: { - eth_accounts: { - id: 'ZaqPEWxyhNCJYACFw93jE', - parentCapability: 'eth_accounts', - invoker: DAPP_URL, - caveats: restrictReturnedAccounts && [ - { - type: 'restrictReturnedAccounts', - value: ['0x09781764c08de8ca82e156bbf156a3ca217c7950'], + withPermissionControllerConnectedToTestDappWithTwoAccounts() { + const subjects = { + [DAPP_URL]: { + origin: DAPP_URL, + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + 'eip155:1337:0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + 'eip155:1337:0x09781764c08de8ca82e156bbf156a3ca217c7950', + ], + }, + }, + isMultichainOrigin: false, }, - ], - date: 1664388714636, - }, + }, + ], + id: 'ZaqPEWxyhNCJYACFw93jE', + date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', }, }, }, + }; + return this.withPermissionController({ + subjects, }); } - withPermissionControllerConnectedToTwoTestDapps( - restrictReturnedAccounts = true, - ) { - return this.withPermissionController({ - subjects: { - [DAPP_URL]: { - origin: DAPP_URL, - permissions: { - eth_accounts: { - id: 'ZaqPEWxyhNCJYACFw93jE', - parentCapability: 'eth_accounts', - invoker: DAPP_URL, - caveats: restrictReturnedAccounts && [ - { - type: 'restrictReturnedAccounts', - value: [ - '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', - '0x09781764c08de8ca82e156bbf156a3ca217c7950', - ], + withPermissionControllerSnapAccountConnectedToTestDapp() { + const subjects = { + [DAPP_URL]: { + origin: DAPP_URL, + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + 'eip155:1337:0x09781764c08de8ca82e156bbf156a3ca217c7950', + ], + }, + }, + isMultichainOrigin: false, }, - ], - date: 1664388714636, - }, + }, + ], + id: 'ZaqPEWxyhNCJYACFw93jE', + date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', }, }, - [DAPP_ONE_URL]: { - origin: DAPP_ONE_URL, - permissions: { - eth_accounts: { - id: 'AqPEWxyhNCJYACFw93jE4', - parentCapability: 'eth_accounts', - invoker: DAPP_ONE_URL, - caveats: restrictReturnedAccounts && [ - { - type: 'restrictReturnedAccounts', - value: [ - '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', - '0x09781764c08de8ca82e156bbf156a3ca217c7950', - ], + }, + }; + return this.withPermissionController({ subjects }); + } + + withPermissionControllerConnectedToTwoTestDapps() { + const subjects = { + [DAPP_URL]: { + origin: DAPP_URL, + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + 'eip155:1337:0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + ], + }, + }, + isMultichainOrigin: false, }, - ], - date: 1664388714636, - }, + }, + ], + id: 'ZaqPEWxyhNCJYACFw93jE', + date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', }, }, }, - }); + [DAPP_ONE_URL]: { + origin: DAPP_ONE_URL, + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1338': { + accounts: [ + 'eip155:1338:0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + id: 'ZaqPEWxyhNCJYACFw93jE', + date: 1664388714636, + invoker: DAPP_ONE_URL, + parentCapability: 'endowment:caip25', + }, + }, + }, + }; + return this.withPermissionController({ subjects }); } withPermissionControllerConnectedToSnapDapp() { @@ -622,8 +682,8 @@ class FixtureBuilder { withPreferencesControllerAdditionalAccountIdentities() { return this.withPreferencesController({ identities: { - '0x5cfe73b6021e818b776b421b1c4db2474086a7e1': { - address: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + [DEFAULT_FIXTURE_ACCOUNT]: { + address: DEFAULT_FIXTURE_ACCOUNT, lastSelected: 1665507600000, name: 'Account 1', }, @@ -632,6 +692,11 @@ class FixtureBuilder { lastSelected: 1665507800000, name: 'Account 2', }, + [ERC_4337_ACCOUNT]: { + address: ERC_4337_ACCOUNT, + lastSelected: 1665507600000, + name: 'Account 4', + }, }, }); } @@ -819,6 +884,13 @@ class FixtureBuilder { }, }, }, + '74c55111-be4f-48aa-a49c-55995c8a1b26': { + id: '74c55111-be4f-48aa-a49c-55995c8a1b26', + address: ERC_4337_ACCOUNT, + options: {}, + methods: [], + type: 'eip155:erc4337', + }, }, }, selectedAccount: 'd5e45e4a-3b04-4a09-a5e1-39762e5c6be4', @@ -1226,78 +1298,112 @@ class FixtureBuilder { 'https://app.ens.domains': { origin: 'https://app.ens.domains', permissions: { - eth_accounts: { - id: 'oKXoF_MNlffiR2u1Y3mDE', - parentCapability: 'eth_accounts', - invoker: 'https://app.ens.domains', + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0xbee150bdc171c7d4190891e78234f791a3ac7b24', - '0xb9504634e5788208933b51ae7440b478bfadf865', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + 'eip155:1337:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + 'eip155:1337:0xb9504634e5788208933b51ae7440b478bfadf865', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1708029792962, + id: 'oKXoF_MNlffiR2u1Y3mDE', + invoker: 'https://app.ens.domains', + parentCapability: 'endowment:caip25', }, }, }, 'https://app.uniswap.org': { origin: 'https://app.uniswap.org', permissions: { - eth_accounts: { - id: 'vaa88u5Iv3VmsJwG3bDKW', - parentCapability: 'eth_accounts', - invoker: 'https://app.uniswap.org', + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0xbee150bdc171c7d4190891e78234f791a3ac7b24', - '0xd1ca923697a701cba1364d803d72b4740fc39bc9', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + 'eip155:1337:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + 'eip155:1337:0xd1ca923697a701cba1364d803d72b4740fc39bc9', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1708029870079, + id: 'vaa88u5Iv3VmsJwG3bDKW', + invoker: 'https://app.uniswap.org', + parentCapability: 'endowment:caip25', }, }, }, 'https://www.dextools.io': { origin: 'https://www.dextools.io', permissions: { - eth_accounts: { - id: 'bvvPcFtIhkFyHyW0Tmwi4', - parentCapability: 'eth_accounts', - invoker: 'https://www.dextools.io', + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0xbee150bdc171c7d4190891e78234f791a3ac7b24', - '0xa5c5293e124d04e2f85e8553851001fd2f192647', - '0xb9504634e5788208933b51ae7440b478bfadf865', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + 'eip155:1337:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + 'eip155:1337:0xa5c5293e124d04e2f85e8553851001fd2f192647', + 'eip155:1337:0xb9504634e5788208933b51ae7440b478bfadf865', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1708029948170, + id: 'bvvPcFtIhkFyHyW0Tmwi4', + invoker: 'https://www.dextools.io', + parentCapability: 'endowment:caip25', }, }, }, 'https://coinmarketcap.com': { origin: 'https://coinmarketcap.com', permissions: { - eth_accounts: { - id: 'AiblK84K1Cic-Y0FDSzMD', - parentCapability: 'eth_accounts', - invoker: 'https://coinmarketcap.com', + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0xbee150bdc171c7d4190891e78234f791a3ac7b24'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + 'eip155:1337:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1708030049641, + id: 'AiblK84K1Cic-Y0FDSzMD', + invoker: 'https://coinmarketcap.com', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/test/e2e/flask/user-operations.spec.ts b/test/e2e/flask/user-operations.spec.ts index 7512e0b563c9..4e8b51043583 100644 --- a/test/e2e/flask/user-operations.spec.ts +++ b/test/e2e/flask/user-operations.spec.ts @@ -24,6 +24,7 @@ import { Driver } from '../webdriver/driver'; import { Bundler } from '../bundler'; import { SWAP_TEST_ETH_USDC_TRADES_MOCK } from '../../data/mock-data'; import { Mockttp } from '../mock-e2e'; +import TestDapp from '../page-objects/pages/test-dapp'; enum TransactionDetailRowIndex { Nonce = 0, @@ -204,9 +205,7 @@ async function withAccountSnap( ) { await withFixtures( { - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp() - .build(), + fixtures: new FixtureBuilder().build(), title, useBundler: true, usePaymaster: Boolean(paymaster), @@ -239,7 +238,10 @@ async function withAccountSnap( ERC_4337_ACCOUNT_SALT, ); - await driver.closeWindow(); + const testDapp = new TestDapp(driver); + await testDapp.openTestDappPage(); + await testDapp.connectAccount({ publicAddress: ERC_4337_ACCOUNT }); + await driver.switchToWindowWithTitle( WINDOW_TITLES.ExtensionInFullScreenView, ); diff --git a/test/e2e/json-rpc/eth_accounts.spec.ts b/test/e2e/json-rpc/eth_accounts.spec.ts index 149021d40a57..c4928dc04c3b 100644 --- a/test/e2e/json-rpc/eth_accounts.spec.ts +++ b/test/e2e/json-rpc/eth_accounts.spec.ts @@ -14,7 +14,7 @@ describe('eth_accounts', function () { .withKeyringControllerAdditionalAccountVault() .withPreferencesControllerAdditionalAccountIdentities() .withAccountsControllerAdditionalAccountIdentities() - .withPermissionControllerConnectedToTestDapp() + .withPermissionControllerConnectedToTestDappWithTwoAccounts() .build(), ganacheOptions: defaultGanacheOptions, title: this.test?.fullTitle(), 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/page-objects/flows/sign.flow.ts b/test/e2e/page-objects/flows/sign.flow.ts index c7d03bb4f96e..ec267ac23887 100644 --- a/test/e2e/page-objects/flows/sign.flow.ts +++ b/test/e2e/page-objects/flows/sign.flow.ts @@ -1,5 +1,5 @@ import { Driver } from '../../webdriver/driver'; -import { WINDOW_TITLES } from '../../helpers'; +import { veryLargeDelayMs, WINDOW_TITLES } from '../../helpers'; import SnapSimpleKeyringPage from '../pages/snap-simple-keyring-page'; import TestDapp from '../pages/test-dapp'; @@ -20,6 +20,7 @@ export const personalSignWithSnapAccount = async ( const testDapp = new TestDapp(driver); await testDapp.check_pageIsLoaded(); await testDapp.personalSign(); + await driver.delay(veryLargeDelayMs); if (!isSyncFlow) { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await new SnapSimpleKeyringPage(driver).approveRejectSnapAccountTransaction( @@ -53,6 +54,7 @@ export const signTypedDataWithSnapAccount = async ( const testDapp = new TestDapp(driver); await testDapp.check_pageIsLoaded(); await testDapp.signTypedData(); + await driver.delay(veryLargeDelayMs); if (!isSyncFlow) { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await new SnapSimpleKeyringPage(driver).approveRejectSnapAccountTransaction( @@ -86,6 +88,7 @@ export const signTypedDataV3WithSnapAccount = async ( const testDapp = new TestDapp(driver); await testDapp.check_pageIsLoaded(); await testDapp.signTypedDataV3(); + await driver.delay(veryLargeDelayMs); if (!isSyncFlow) { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await new SnapSimpleKeyringPage(driver).approveRejectSnapAccountTransaction( @@ -119,6 +122,7 @@ export const signTypedDataV4WithSnapAccount = async ( const testDapp = new TestDapp(driver); await testDapp.check_pageIsLoaded(); await testDapp.signTypedDataV4(); + await driver.delay(veryLargeDelayMs); if (!isSyncFlow) { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await new SnapSimpleKeyringPage(driver).approveRejectSnapAccountTransaction( @@ -152,6 +156,7 @@ export const signPermitWithSnapAccount = async ( const testDapp = new TestDapp(driver); await testDapp.check_pageIsLoaded(); await testDapp.signPermit(); + await driver.delay(veryLargeDelayMs); if (!isSyncFlow) { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await new SnapSimpleKeyringPage(driver).approveRejectSnapAccountTransaction( 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/account/snap-account-signatures-and-disconnects.spec.ts b/test/e2e/tests/account/snap-account-signatures-and-disconnects.spec.ts index d3c177ff7fdd..7a97130a3ef5 100644 --- a/test/e2e/tests/account/snap-account-signatures-and-disconnects.spec.ts +++ b/test/e2e/tests/account/snap-account-signatures-and-disconnects.spec.ts @@ -19,11 +19,7 @@ describe('Snap Account Signatures and Disconnects @no-mmi', function (this: Suit await withFixtures( { dapp: true, - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp({ - restrictReturnedAccounts: false, - }) - .build(), + fixtures: new FixtureBuilder().build(), title: this.test?.fullTitle(), }, async ({ driver }: { driver: Driver }) => { @@ -49,9 +45,12 @@ describe('Snap Account Signatures and Disconnects @no-mmi', function (this: Suit await experimentalSettings.check_pageIsLoaded(); await experimentalSettings.toggleRedesignedSignature(); - // Open the Test Dapp and signTypedDataV3 + // Open the Test Dapp and connect const testDapp = new TestDapp(driver); await testDapp.openTestDappPage(); + await testDapp.connectAccount({ publicAddress: newPublicKey }); + + // SignedTypedDataV3 with Test Dapp await signTypedDataV3WithSnapAccount(driver, newPublicKey, false, true); // Disconnect from Test Dapp and reconnect to Test Dapp diff --git a/test/e2e/tests/account/snap-account-signatures.spec.ts b/test/e2e/tests/account/snap-account-signatures.spec.ts index e04987561fc5..78940624abca 100644 --- a/test/e2e/tests/account/snap-account-signatures.spec.ts +++ b/test/e2e/tests/account/snap-account-signatures.spec.ts @@ -16,6 +16,7 @@ import { signTypedDataV4WithSnapAccount, signTypedDataWithSnapAccount, } from '../../page-objects/flows/sign.flow'; +import { DAPP_URL } from '../../constants'; describe('Snap Account Signatures @no-mmi', function (this: Suite) { this.timeout(200000); // This test is very long, so we need an unusually high timeout @@ -30,11 +31,7 @@ describe('Snap Account Signatures @no-mmi', function (this: Suite) { await withFixtures( { dapp: true, - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp({ - restrictReturnedAccounts: false, - }) - .build(), + fixtures: new FixtureBuilder().build(), title, }, async ({ driver }: { driver: Driver }) => { @@ -62,8 +59,22 @@ describe('Snap Account Signatures @no-mmi', function (this: Suite) { await experimentalSettings.check_pageIsLoaded(); await experimentalSettings.toggleRedesignedSignature(); - // Run all 5 signature types + // Connect the SSK account await new TestDapp(driver).openTestDappPage(); + + await driver.findClickableElement({ text: 'Connect', tag: 'button' }); + await driver.clickElement('#connectButton'); + + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.clickElementAndWaitForWindowToClose({ + text: 'Connect', + tag: 'button', + }); + + await driver.switchToWindowWithUrl(DAPP_URL); + + // Run all 5 signature types await personalSignWithSnapAccount( driver, newPublicKey, 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/test/e2e/tests/network/deprecated-networks.spec.js b/test/e2e/tests/network/deprecated-networks.spec.js index 26c2388e4b51..80a11226a4df 100644 --- a/test/e2e/tests/network/deprecated-networks.spec.js +++ b/test/e2e/tests/network/deprecated-networks.spec.js @@ -51,6 +51,9 @@ describe('Deprecated networks', function () { { dapp: true, fixtures: new FixtureBuilder() + .withKeyringControllerAdditionalAccountVault() + .withPreferencesControllerAdditionalAccountIdentities() + .withAccountsControllerAdditionalAccountIdentities() .withPermissionControllerConnectedToTestDapp() .withPreferencesController({ useSafeChainsListValidation: false }) .build(), @@ -130,6 +133,9 @@ describe('Deprecated networks', function () { { dapp: true, fixtures: new FixtureBuilder() + .withKeyringControllerAdditionalAccountVault() + .withPreferencesControllerAdditionalAccountIdentities() + .withAccountsControllerAdditionalAccountIdentities() .withPermissionControllerConnectedToTestDapp() .withPreferencesController({ useSafeChainsListValidation: false }) .build(), @@ -209,6 +215,9 @@ describe('Deprecated networks', function () { { dapp: true, fixtures: new FixtureBuilder() + .withKeyringControllerAdditionalAccountVault() + .withPreferencesControllerAdditionalAccountIdentities() + .withAccountsControllerAdditionalAccountIdentities() .withPermissionControllerConnectedToTestDapp() .withPreferencesController({ useSafeChainsListValidation: false }) .build(), diff --git a/test/e2e/tests/network/switch-custom-network.spec.js b/test/e2e/tests/network/switch-custom-network.spec.js index 09dedc3a62da..c5eb780a29db 100644 --- a/test/e2e/tests/network/switch-custom-network.spec.js +++ b/test/e2e/tests/network/switch-custom-network.spec.js @@ -13,6 +13,9 @@ describe('Switch ethereum chain', function () { { dapp: true, fixtures: new FixtureBuilder() + .withKeyringControllerAdditionalAccountVault() + .withPreferencesControllerAdditionalAccountIdentities() + .withAccountsControllerAdditionalAccountIdentities() .withPermissionControllerConnectedToTestDapp() .build(), ganacheOptions: generateGanacheOptions({ diff --git a/test/e2e/tests/request-queuing/ui.spec.js b/test/e2e/tests/request-queuing/ui.spec.js index 707c252396b7..90a33891f802 100644 --- a/test/e2e/tests/request-queuing/ui.spec.js +++ b/test/e2e/tests/request-queuing/ui.spec.js @@ -61,7 +61,7 @@ async function openDappAndSwitchChain(driver, dappUrl, chainId) { (permission) => permission.parentCapability === PermissionNames.permittedChains, ) - ?.caveats.find( + ?.caveats?.find( (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, )?.value || []; diff --git a/test/e2e/tests/tokens/increase-token-allowance.spec.js b/test/e2e/tests/tokens/increase-token-allowance.spec.js index 9ce8db2cc065..e0bd0f894897 100644 --- a/test/e2e/tests/tokens/increase-token-allowance.spec.js +++ b/test/e2e/tests/tokens/increase-token-allowance.spec.js @@ -24,7 +24,10 @@ describe('Increase Token Allowance', function () { { dapp: true, fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp() + .withKeyringControllerAdditionalAccountVault() + .withPreferencesControllerAdditionalAccountIdentities() + .withAccountsControllerAdditionalAccountIdentities() + .withPermissionControllerConnectedToTestDappWithTwoAccounts() .build(), ganacheOptions: defaultGanacheOptions, smartContract, @@ -32,7 +35,7 @@ describe('Increase Token Allowance', function () { }, async ({ driver, contractRegistry }) => { const ACCOUNT_1_NAME = 'Account 1'; - const ACCOUNT_2_NAME = '2nd Account'; + const ACCOUNT_2_NAME = 'Account 2'; const initialSpendingCap = '1'; const additionalSpendingCap = '1'; @@ -41,6 +44,8 @@ describe('Increase Token Allowance', function () { await tempToggleSettingRedesignedTransactionConfirmations(driver); + await switchToAccountWithName(driver, ACCOUNT_1_NAME); + const contractAddress = await contractRegistry.getContractAddress( smartContract, ); @@ -50,7 +55,6 @@ describe('Increase Token Allowance', function () { await approveTokenSpendingCapTo(driver, ACCOUNT_2, initialSpendingCap); await sendTransaction(driver, ACCOUNT_2, '1'); - await addAccount(driver, ACCOUNT_2_NAME); await triggerTransferFromTokens(driver, ACCOUNT_1, ACCOUNT_2); // 'Transfer From Tokens' on the test dApp attempts to transfer 1.5 TST. @@ -126,23 +130,6 @@ describe('Increase Token Allowance', function () { }); } - async function addAccount(driver, newAccountName) { - await driver.clickElement('[data-testid="account-menu-icon"]'); - await driver.clickElement( - '[data-testid="multichain-account-menu-popover-action-button"]', - ); - await driver.clickElement( - '[data-testid="multichain-account-menu-popover-add-account"]', - ); - - await driver.fill('[placeholder="Account 2"]', newAccountName); - await driver.clickElement({ text: 'Add account', tag: 'button' }); - await driver.findElement({ - css: '[data-testid="account-menu-icon"]', - text: newAccountName, - }); - } - async function triggerTransferFromTokens( driver, senderAccount, 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 539899d0eb09..8be034f8559c 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/edit-accounts-modal/edit-accounts-modal.tsx b/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx index ddc2749e2ee7..54d5adadf0f3 100644 --- a/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx +++ b/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx @@ -30,12 +30,12 @@ import { BlockSize, } from '../../../helpers/constants/design-system'; import { MergedInternalAccount } from '../../../selectors/selectors.types'; +import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../shared/constants/metametrics'; import { MetaMetricsContext } from '../../../contexts/metametrics'; -import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; type EditAccountsModalProps = { accounts: MergedInternalAccount[]; @@ -60,7 +60,7 @@ export const EditAccountsModal: React.FC = ({ useEffect(() => { setSelectedAccountAddresses(defaultSelectedAccountAddresses); - }, [defaultSelectedAccountAddresses]); + }, [JSON.stringify(defaultSelectedAccountAddresses)]); const selectAll = () => { const allNetworksAccountAddresses = accounts.map(({ address }) => address); diff --git a/ui/components/multichain/edit-networks-modal/edit-networks-modal.js b/ui/components/multichain/edit-networks-modal/edit-networks-modal.js index 0b86716af50a..704f3eb3ced4 100644 --- a/ui/components/multichain/edit-networks-modal/edit-networks-modal.js +++ b/ui/components/multichain/edit-networks-modal/edit-networks-modal.js @@ -52,7 +52,7 @@ export const EditNetworksModal = ({ useEffect(() => { setSelectedChainIds(defaultSelectedChainIds); - }, [defaultSelectedChainIds]); + }, [JSON.stringify(defaultSelectedChainIds)]); const selectAll = () => { const allNetworksChainIds = allNetworks.map(({ chainId }) => chainId); 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 481ddfe5142f..bb55704a1ef5 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'; @@ -301,7 +301,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 4a8b188b86b6..8bf383d4f5fe 100644 --- a/ui/components/multichain/pages/connections/connections.tsx +++ b/ui/components/multichain/pages/connections/connections.tsx @@ -53,7 +53,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 { @@ -133,7 +133,7 @@ export const Connections = () => { } const requestAccountsPermission = async () => { const requestId = await dispatch( - requestAccountsPermissionWithId(tabToConnect.origin), + requestAccountsAndChainPermissionsWithId(tabToConnect.origin), ); history.push(`${CONNECT_ROUTE}/${requestId}`); }; @@ -396,7 +396,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/review-permissions-page/site-cell/site-cell.tsx b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx index 1c91489d0d1f..b89a569f0710 100644 --- a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx @@ -14,12 +14,12 @@ import { } from '../../../../component-library'; import { EditAccountsModal, EditNetworksModal } from '../../..'; import { MergedInternalAccount } from '../../../../../selectors/selectors.types'; +import { isEqualCaseInsensitive } from '../../../../../../shared/modules/string-utils'; import { MetaMetricsContext } from '../../../../../contexts/metametrics'; import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../../../shared/constants/metametrics'; -import { isEqualCaseInsensitive } from '../../../../../../shared/modules/string-utils'; import { SiteCellTooltip } from './site-cell-tooltip'; import { SiteCellConnectionListItem } from './site-cell-connection-list-item'; 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/connect-page/connect-page.tsx b/ui/pages/permissions-connect/connect-page/connect-page.tsx index 741629b3d4d7..cfbd80823678 100644 --- a/ui/pages/permissions-connect/connect-page/connect-page.tsx +++ b/ui/pages/permissions-connect/connect-page/connect-page.tsx @@ -34,12 +34,12 @@ import { import { MergedInternalAccount } from '../../../selectors/selectors.types'; import { mergeAccounts } from '../../../components/multichain/account-list-menu/account-list-menu'; import { TEST_CHAINS } from '../../../../shared/constants/network'; -import PermissionsConnectFooter from '../../../components/app/permissions-connect-footer'; import { CaveatTypes, EndowmentTypes, RestrictedMethods, } from '../../../../shared/constants/permissions'; +import PermissionsConnectFooter from '../../../components/app/permissions-connect-footer'; import { getMultichainNetwork } from '../../../selectors/multichain'; export type ConnectPageRequest = { diff --git a/ui/pages/permissions-connect/permissions-connect.component.js b/ui/pages/permissions-connect/permissions-connect.component.js index 17f73d633953..c39750c54666 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, @@ -151,14 +156,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': @@ -396,6 +394,7 @@ export default class PermissionConnect extends Component { selectedAccounts={accounts.filter((account) => selectedAccountAddresses.has(account.address), )} + requestedChainIds={getRequestedChainIds(permissionsRequest)} targetSubjectMetadata={targetSubjectMetadata} history={this.props.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 aa6ae125bd4b..f319c3c7daa5 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -1386,15 +1386,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 c16df9838916..3bf2b50a2942 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'; @@ -2665,73 +2661,4 @@ describe('Actions', () => { expect(store.getActions()).toStrictEqual([]); }); }); - - 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([]); - }); - }); }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 75de201c76b7..05612c5b9a4d 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) @@ -3986,19 +3982,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> { @@ -5980,48 +5963,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 f45eb5cf233e..f068be182e58 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 @@ -4935,6 +4942,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" @@ -5822,6 +5836,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" @@ -6954,9 +6985,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 @@ -13285,13 +13316,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 @@ -26650,6 +26681,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"