diff --git a/.circleci/config.yml b/.circleci/config.yml index b2c5ab712973..3c566782e703 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -215,6 +215,9 @@ workflows: - test-api-specs: requires: - prep-build-test + - test-api-specs-multichain: + requires: + - prep-build-test - test-e2e-chrome-multiple-providers: requires: - prep-build-test @@ -870,7 +873,7 @@ jobs: at: . - run: name: Build extension for testing - command: yarn build:test + command: CHAIN_PERMISSIONS=1 BARAD_DUR=1 yarn build:test - run: name: Move test build to 'dist-test' to avoid conflict with production build command: mv ./dist ./dist-test @@ -1091,6 +1094,37 @@ jobs: - store_test_results: path: test/test-results/e2e + test-api-specs-multichain: + executor: node-browsers-medium-plus + steps: + - run: *shallow-git-clone-and-enable-vnc + - run: sudo corepack enable + - attach_workspace: + at: . + - run: + name: Move test build to dist + command: mv ./dist-test ./dist + - run: + name: Move test zips to builds + command: mv ./builds-test ./builds + - gh/install + - run: + name: test:api-specs-multichain + command: .circleci/scripts/test-run-e2e.sh yarn test:api-specs-multichain + no_output_timeout: 5m + - run: + name: Comment on PR + command: | + if [ -f html-report-multichain/index.html ]; then + gh pr comment "${CIRCLE_PR_NUMBER}" --body ":x: Multichain API Spec Test Failed. View the report [here](https://output.circle-artifacts.com/output/job/${CIRCLE_WORKFLOW_JOB_ID}/artifacts/${CIRCLE_NODE_INDEX}/html-report-multichain/index.html)." + else + echo "Multichain API Spec Report not found!" + fi + when: on_fail + - store_artifacts: + path: html-report-multichain + destination: html-report-multichain + test-api-specs: executor: node-browsers-medium-plus steps: diff --git a/.gitignore b/.gitignore index 1671e69527e0..855ae66df29d 100644 --- a/.gitignore +++ b/.gitignore @@ -75,8 +75,11 @@ lavamoat/**/policy-debug.json # Attributions licenseInfos.json +# Branding +/app/images/branding + # API Spec tests html-report/ +html-report-multichain/ -/app/images/branding /changed-files diff --git a/.storybook/test-data.js b/.storybook/test-data.js index cbcebb6347ed..c9e79ca510a3 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -1398,17 +1398,29 @@ 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': { + methods: [], + notifications: [], + 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 7d9d0f5684a6..d741fe245fbb 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -637,13 +637,8 @@ function emitDappViewedMetricEvent(origin) { return; } - const permissions = controller.controllerMessenger.call( - 'PermissionController:getPermissions', - origin, - ); - const numberOfConnectedAccounts = - permissions?.eth_accounts?.caveats[0]?.value.length; - if (!numberOfConnectedAccounts) { + const numberOfConnectedAccounts = controller.getPermittedAccounts(origin); + if (numberOfConnectedAccounts.length === 0) { return; } diff --git a/app/scripts/controllers/permissions/background-api.js b/app/scripts/controllers/permissions/background-api.js index b778ff42385d..2cc89705f41c 100644 --- a/app/scripts/controllers/permissions/background-api.js +++ b/app/scripts/controllers/permissions/background-api.js @@ -1,45 +1,182 @@ import nanoid from 'nanoid'; +import { MethodNames } from '@metamask/permission-controller'; import { - CaveatTypes, - RestrictedMethods, -} from '../../../../shared/constants/permissions'; -import { CaveatFactories, PermissionNames } from './specifications'; + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../../lib/multichain-api/caip25permissions'; +import { + getEthAccounts, + setEthAccounts, +} from '../../lib/multichain-api/adapters/caip-permission-adapter-eth-accounts'; +import { + getPermittedEthChainIds, + setPermittedEthChainIds, +} from '../../lib/multichain-api/adapters/caip-permission-adapter-permittedChains'; +import { RestrictedMethods } from '../../../../shared/constants/permissions'; +import { PermissionNames } from './specifications'; -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); - - permissionController.grantPermissionsIncremental({ - subject: { origin }, - approvedPermissions: { - [RestrictedMethods.eth_accounts]: { caveats: [caveat] }, - }, - }); + let caip25Caveat; + try { + caip25Caveat = permissionController.getCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (err) { + // noop + } + + if (!caip25Caveat) { + throw new Error('tried to add accounts when none have been permissioned'); // TODO: better error + } + + 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); - - permissionController.grantPermissionsIncremental({ - subject: { origin }, - approvedPermissions: { - [PermissionNames.permittedChains]: { caveats: [caveat] }, - }, - }); + let caip25Caveat; + try { + caip25Caveat = permissionController.getCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (err) { + // noop + } + + if (!caip25Caveat) { + throw new Error('tried to add chains when none have been permissioned'); // TODO: better error + } + + // get the list of permitted eth accounts before we modify the permitted chains and potentially lose some + const ethAccounts = getEthAccounts(caip25Caveat.value); + + const ethChainIds = getPermittedEthChainIds(caip25Caveat.value); + + const updatedEthChainIds = Array.from( + new Set([...ethChainIds, ...chainIds]), + ); + + let updatedCaveatValue = setPermittedEthChainIds( + caip25Caveat.value, + updatedEthChainIds, + ); + + // ensure that the list of permitted eth accounts is intact after permitted chain updates + updatedCaveatValue = setEthAccounts(updatedCaveatValue, ethAccounts); + + permissionController.updateCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + updatedCaveatValue, + ); + }; + + const requestAccountsAndChainPermissionsWithId = (origin) => { + const id = nanoid(); + // NOTE: the eth_accounts/permittedChains approvals will be combined in the future. + // Until they are actually combined, when testing, you must request both + // eth_accounts and permittedChains together. + approvalController + .addAndShowApprovalRequest({ + id, + origin, + requestData: { + metadata: { + id, + origin, + }, + permissions: { + [RestrictedMethods.eth_accounts]: {}, + [PermissionNames.permittedChains]: {}, + }, + }, + type: MethodNames.requestPermissions, + }) + .then((legacyApproval) => { + let caveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + caveatValue = setPermittedEthChainIds( + caveatValue, + legacyApproval.approvedChainIds, + ); + + caveatValue = setEthAccounts( + caveatValue, + legacyApproval.approvedAccounts, + ); + + permissionController.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValue, + }, + ], + }, + }, + }); + }); + + return id; }; 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) { + // noop + } + + if (!caip25Caveat) { + throw new Error( + 'tried to remove accounts when none have been permissioned', + ); // TODO: better error + } + + const existingAccounts = getEthAccounts(caip25Caveat.value); const remainingAccounts = existingAccounts.filter( (existingAccount) => existingAccount !== account, @@ -52,74 +189,78 @@ 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) { + // noop + } - const remainingChains = existingChains.filter( - (existingChain) => existingChain !== chainId, + if (!caip25Caveat) { + throw new Error( + 'tried to remove chains when none have been permissioned', + ); // TODO: better error + } + + 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) { + // TODO: Is this right? Do we want to revoke the entire + // CAIP-25 permission if no eip-155 chains are left? + if (remainingChainIds.length === 0) { 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) => { - const id = nanoid(); - permissionController.requestPermissions( - { origin }, - { - [PermissionNames.eth_accounts]: {}, - [PermissionNames.permittedChains]: {}, - }, - { id }, - ); - return id; - }, + requestAccountsAndChainPermissionsWithId, - requestAccountsPermissionWithId: async (origin) => { - const id = nanoid(); - permissionController.requestPermissions( - { origin }, - { - eth_accounts: {}, - }, - { id }, - ); - return id; - }, + // TODO: Remove this / DRY with requestAccountsAndChainPermissionsWithId + requestAccountsPermissionWithId: requestAccountsAndChainPermissionsWithId, }; } diff --git a/app/scripts/controllers/permissions/background-api.test.js b/app/scripts/controllers/permissions/background-api.test.js index 2a050b29a00e..babfee85ce49 100644 --- a/app/scripts/controllers/permissions/background-api.test.js +++ b/app/scripts/controllers/permissions/background-api.test.js @@ -1,390 +1,1052 @@ +import { MethodNames } from '@metamask/permission-controller'; +import { RestrictedMethods } from '../../../../shared/constants/permissions'; import { - CaveatTypes, - RestrictedMethods, -} from '../../../../shared/constants/permissions'; + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../../lib/multichain-api/caip25permissions'; +import { flushPromises } from '../../../../test/lib/timer-helpers'; +import { + KnownNotifications, + KnownRpcMethods, +} from '../../lib/multichain-api/scope'; 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(), }; - 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('tried to add accounts when none have been permissioned'), + ); }); - it('calls grantPermissionsIncremental with expected parameters with multiple accounts', () => { + it('calls updateCaveat with the account added', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x2', 'eip155:1:0x3'], + }, + }, + isMultichainOrigin: true, + }, + }), + updateCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).addPermittedAccounts('foo.com', ['0x1', '0x2']); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getEthAccountsPermissions(['0x1', '0x2']), - }); + }).addPermittedAccount('foo.com', '0x4'); + + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( + 'foo.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x1', + 'eip155:1:0x4', + ], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: [ + 'eip155:10:0x2', + 'eip155:10:0x3', + 'eip155:10:0x1', + 'eip155:10:0x4', + ], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x1', + 'eip155:1:0x4', + ], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + 'wallet:eip155:0x1', + 'wallet:eip155:0x4', + ], + }, + wallet: { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + 'wallet:eip155:0x1', + 'wallet:eip155:0x4', + ], + }, + }, + isMultichainOrigin: true, + }, + ); }); }); - describe('removePermittedAccount', () => { - it('removes a permitted account', () => { + describe('addPermittedAccounts', () => { + it('gets the CAIP-25 caveat', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1', '0x2'], - }; - }), - revokePermission: jest.fn(), - updateCaveat: jest.fn(), + getCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( - permissionController, - ).removePermittedAccount('foo.com', '0x2'); + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccounts('foo.com', ['0x1']); + } catch (err) { + // noop + } - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); expect(permissionController.getCaveat).toHaveBeenCalledWith( 'foo.com', - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, + Caip25EndowmentPermissionName, + Caip25CaveatType, ); + }); - expect(permissionController.revokePermission).not.toHaveBeenCalled(); + it('throws an error if there is no existing CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn(), + }; - expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.updateCaveat).toHaveBeenCalledWith( - 'foo.com', - RestrictedMethods.eth_accounts, - CaveatTypes.restrictReturnedAccounts, - ['0x1'], + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedAccounts('foo.com', ['0x1']), + ).toThrow( + new Error('tried to add accounts when none have been permissioned'), ); }); - it('revokes the accounts permission if the removed account is the only permitted account', () => { + it('calls updateCaveat with the accounts added to only eip155 scopes and all accounts for eip155 scopes synced', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictReturnedAccounts, - value: ['0x1'], - }; + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x1', + 'eip155:1:0x4', + 'eip155:1:0x5', + ], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: [ + 'eip155:10:0x2', + 'eip155:10:0x3', + 'eip155:10:0x1', + 'eip155:10:0x4', + 'eip155:10:0x5', + ], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x2', + 'eip155:1:0x3', + 'eip155:1:0x1', + 'eip155:1:0x4', + 'eip155:1:0x5', + ], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + 'wallet:eip155:0x1', + 'wallet:eip155:0x4', + 'wallet:eip155:0x5', + ], + }, + wallet: { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + 'wallet:eip155:0x1', + 'wallet:eip155:0x4', + 'wallet:eip155: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(), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedAccount('foo.com', '0x1'), + ).toThrow( + new Error('tried to remove accounts when none have been permissioned'), + ); }); - it('does not call permissionController.updateCaveat if the specified account is not permitted', () => { + it('does nothing if the account being removed does not exist', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { type: CaveatTypes.restrictReturnedAccounts, value: ['0x1'] }; + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x3', 'eip155:1:0x1'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x3', 'eip155:10:0x1'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x3', 'eip155:1:0x1'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x3', 'wallet:eip155:0x1'], + }, + wallet: { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x3', 'wallet:eip155:0x1'], + }, + }, + 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'); + + const { id } = + approvalController.addAndShowApprovalRequest.mock.calls[0][0]; - expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1); - expect(permissionController.requestPermissions).toHaveBeenCalledWith( - { origin: 'foo.com' }, + 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': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:5:0xdeadbeef'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0xdeadbeef'], + }, + wallet: { + methods: [], + notifications: [], + accounts: ['wallet:eip155: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(), }; - 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('tried to add chains when none have been permissioned'), + ); }); - it('calls grantPermissionsIncremental with expected parameters with multiple chains', () => { + it('calls updateCaveat with the chain added and all eip155 accounts synced', () => { const permissionController = { - grantPermissionsIncremental: jest.fn(), + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + isMultichainOrigin: true, + }, + }), + updateCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( + getPermissionBackgroundApiMethods({ permissionController, - ).addPermittedChains('foo.com', ['0x1', '0x2']); - - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledTimes(1); - expect( - permissionController.grantPermissionsIncremental, - ).toHaveBeenCalledWith({ - subject: { origin: 'foo.com' }, - approvedPermissions: getPermittedChainsPermissions(['0x1', '0x2']), - }); + }).addPermittedChain('foo.com', '0x539'); // 1337 + + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( + 'foo.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:1337': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:1337:0x1', 'eip155:1337:0x2'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + wallet: { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + }, + isMultichainOrigin: true, + }, + ); }); }); - describe('removePermittedChain', () => { - it('removes a permitted chain', () => { + describe('addPermittedChains', () => { + it('gets the CAIP-25 caveat', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1', '0x2'], - }; - }), - revokePermission: jest.fn(), - updateCaveat: jest.fn(), + getCaveat: jest.fn(), }; - getPermissionBackgroundApiMethods( - permissionController, - ).removePermittedChain('foo.com', '0x2'); + try { + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChains('foo.com', ['0x1']); + } catch (err) { + // noop + } - expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); expect(permissionController.getCaveat).toHaveBeenCalledWith( 'foo.com', - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, + Caip25EndowmentPermissionName, + Caip25CaveatType, ); + }); - expect(permissionController.revokePermission).not.toHaveBeenCalled(); + it('throws an error if there is no existing CAIP-25 caveat', () => { + const permissionController = { + getCaveat: jest.fn(), + }; - expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); - expect(permissionController.updateCaveat).toHaveBeenCalledWith( - 'foo.com', - PermissionNames.permittedChains, - CaveatTypes.restrictNetworkSwitching, - ['0x1'], + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).addPermittedChains('foo.com', ['0x1']), + ).toThrow( + new Error('tried to add chains when none have been permissioned'), ); }); - it('revokes the permittedChains permission if the removed chain is the only permitted chain', () => { + it('calls updateCaveat with the chains added and all eip155 accounts synced', () => { const permissionController = { - getCaveat: jest.fn().mockImplementationOnce(() => { - return { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1'], - }; + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + 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': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:4': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:4:0x1', 'eip155:4:0x2'], + }, + 'eip155:5': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + wallet: { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155: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(), + }; + + expect(() => + getPermissionBackgroundApiMethods({ + permissionController, + }).removePermittedChain('foo.com', '0x1'), + ).toThrow( + new Error('tried to remove chains when none have been permissioned'), + ); + }); + + it('does nothing if the chain being removed does not exist', () => { + const permissionController = { + getCaveat: jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + isMultichainOrigin: true, + }, + ); }); }); }); diff --git a/app/scripts/controllers/permissions/enums.ts b/app/scripts/controllers/permissions/enums.ts index c170bd78aa67..9210d6751bdc 100644 --- a/app/scripts/controllers/permissions/enums.ts +++ b/app/scripts/controllers/permissions/enums.ts @@ -2,4 +2,5 @@ export enum NOTIFICATION_NAMES { accountsChanged = 'metamask_accountsChanged', unlockStateChanged = 'metamask_unlockStateChanged', chainChanged = 'metamask_chainChanged', + sessionChanged = 'wallet_sessionChanged', } diff --git a/app/scripts/controllers/permissions/selectors.js b/app/scripts/controllers/permissions/selectors.js index 76e638d25b54..ecba7e88ee71 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, +} from '../../lib/multichain-api/caip25permissions'; +import { getEthAccounts } from '../../lib/multichain-api/adapters/caip-permission-adapter-eth-accounts'; +import { getPermittedEthChainIds } from '../../lib/multichain-api/adapters/caip-permission-adapter-permittedChains'; /** * This file contains selectors for PermissionController selector event @@ -26,20 +30,47 @@ 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()); }, ); +/** + * Get the authorized CAIP-25 scopes for each subject, keyed by origin. + * The values of the returned map are immutable values from the + * PermissionController state. + * + * @returns {Map} The current origin:authorization map. + */ +export const getAuthorizedScopesByOrigin = createSelector( + getSubjects, + (subjects) => { + return Object.values(subjects).reduce( + (originToAuthorizationsMap, subject) => { + const caveats = + subject.permissions?.[Caip25EndowmentPermissionName]?.caveats || []; + + const caveat = caveats.find(({ type }) => type === Caip25CaveatType); + + if (caveat) { + originToAuthorizationsMap.set(subject.origin, caveat.value); + } + return originToAuthorizationsMap; + }, + new Map(), + ); + }, +); + /** * Get the permitted chains for each subject, keyed by origin. * The values of the returned map are immutable values from the @@ -52,14 +83,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()); @@ -109,3 +139,89 @@ export const diffMap = (currentMap, previousMap) => { } return changedMap; }; + +/** + * Given the current and previous exposed CAIP-25 authorization for each PermissionController + * subject, returns a new map containing all authorizations that have changed. + * The values of each map must be immutable values directly from the + * PermissionController state, or an empty object instantiated in this + * function. + * + * @param {Map} newAuthorizationsMap - The new origin:authorization map. + * @param {Map} [previousAuthorizationsMap] - The previous origin:authorization map. + * @returns {Map} The origin:authorization map of changed authorizations. + */ +export const getChangedAuthorizations = ( + newAuthorizationsMap, + previousAuthorizationsMap, +) => { + if (previousAuthorizationsMap === undefined) { + return newAuthorizationsMap; + } + + const changedAuthorizations = new Map(); + if (newAuthorizationsMap === previousAuthorizationsMap) { + return changedAuthorizations; + } + + const newOrigins = new Set([...newAuthorizationsMap.keys()]); + + for (const origin of previousAuthorizationsMap.keys()) { + const newAuthorizations = newAuthorizationsMap.get(origin) ?? { + requiredScopes: {}, + optionalScopes: {}, + }; + + // The values of these maps are references to immutable values, which is why + // a strict equality check is enough for diffing. The values are either from + // PermissionController state, or an empty object initialized in the previous + // call to this function. `newAuthorizationsMap` will never contain any empty + // objects. + if (previousAuthorizationsMap.get(origin) !== newAuthorizations) { + changedAuthorizations.set(origin, newAuthorizations); + } + + newOrigins.delete(origin); + } + + // By now, newOrigins is either empty or contains some number of previously + // unencountered origins, and all of their authorizations have "changed". + for (const origin of newOrigins.keys()) { + changedAuthorizations.set(origin, newAuthorizationsMap.get(origin)); + } + return changedAuthorizations; +}; + +/** + * + * @param {Map} newAuthorizationsMap - The new origin:authorization map. + * @param {Map} [previousAuthorizationsMap] - The previous origin:authorization map. + * @returns {Map} The origin:authorization map of changed authorizations. + */ +export const getRemovedAuthorizations = ( + newAuthorizationsMap, + previousAuthorizationsMap, +) => { + const removedAuthorizations = new Map(); + + // If there are no previous authorizations, there are no removed authorizations. + // OR If the new authorizations map is the same as the previous authorizations map, + // there are no removed authorizations + if ( + previousAuthorizationsMap === undefined || + newAuthorizationsMap === previousAuthorizationsMap + ) { + return removedAuthorizations; + } + + const previousOrigins = new Set([...previousAuthorizationsMap.keys()]); + for (const origin of newAuthorizationsMap.keys()) { + previousOrigins.delete(origin); + } + + for (const origin of previousOrigins.keys()) { + removedAuthorizations.set(origin, previousAuthorizationsMap.get(origin)); + } + + return removedAuthorizations; +}; diff --git a/app/scripts/controllers/permissions/selectors.test.js b/app/scripts/controllers/permissions/selectors.test.js index 41264d405ab2..f927c47fa4d5 100644 --- a/app/scripts/controllers/permissions/selectors.test.js +++ b/app/scripts/controllers/permissions/selectors.test.js @@ -1,11 +1,14 @@ import { cloneDeep } from 'lodash'; -import { CaveatTypes } from '../../../../shared/constants/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../../lib/multichain-api/caip25permissions'; import { diffMap, getPermittedAccountsByOrigin, getPermittedChainsByOrigin, + getRemovedAuthorizations, } from './selectors'; -import { PermissionNames } from './specifications'; describe('PermissionController selectors', () => { describe('diffMap', () => { @@ -53,25 +56,82 @@ describe('PermissionController selectors', () => { 'foo.bar': { origin: 'foo.bar', permissions: { - eth_accounts: { - caveats: [{ type: 'restrictReturnedAccounts', value: ['0x1'] }], + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x2'], + }, + }, + isMultichainOrigin: false, + }, + }, ], }, }, @@ -118,6 +178,37 @@ describe('PermissionController selectors', () => { }); }); + describe('getRemovedAuthorizations', () => { + it('returns an empty map if the new and previous values are the same', () => { + const newAuthorizations = new Map(); + expect( + getRemovedAuthorizations(newAuthorizations, newAuthorizations), + ).toStrictEqual(new Map()); + }); + + it('returns a new map of the removed authorizations if the new and previous values differ', () => { + const mockAuthorization = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction'], + notifications: [], + }, + }, + optionalScopes: {}, + }; + const previousAuthorizations = new Map([ + ['foo.bar', mockAuthorization], + ['bar.baz', mockAuthorization], + ]); + + const newAuthorizations = new Map([['foo.bar', mockAuthorization]]); + + expect( + getRemovedAuthorizations(newAuthorizations, previousAuthorizations), + ).toStrictEqual(new Map([['bar.baz', mockAuthorization]])); + }); + }); + describe('getPermittedChainsByOrigin', () => { it('memoizes and gets permitted chains by origin', () => { const state1 = { @@ -125,11 +216,27 @@ describe('PermissionController selectors', () => { 'foo.bar': { origin: 'foo.bar', permissions: { - [PermissionNames.permittedChains]: { + [Caip25EndowmentPermissionName]: { caveats: [ { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x1'], + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: true, + }, }, ], }, @@ -138,11 +245,21 @@ describe('PermissionController selectors', () => { 'bar.baz': { origin: 'bar.baz', permissions: { - [PermissionNames.permittedChains]: { + [Caip25EndowmentPermissionName]: { caveats: [ { - type: CaveatTypes.restrictNetworkSwitching, - value: ['0x2'], + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:2': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }, }, ], }, @@ -151,17 +268,33 @@ 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': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:2': { + methods: [], + notifications: [], + 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 8a40082d4d80..2e5efac1aa3b 100644 --- a/app/scripts/controllers/permissions/specifications.js +++ b/app/scripts/controllers/permissions/specifications.js @@ -1,17 +1,16 @@ -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, EndowmentTypes, RestrictedMethods, } from '../../../../shared/constants/permissions'; +import { + Caip25CaveatFactoryFn, + Caip25CaveatType, + caip25EndowmentBuilder, +} from '../../lib/multichain-api/caip25permissions'; /** * This file contains the specifications of the permissions and caveats @@ -33,58 +32,18 @@ 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]: Caip25CaveatFactoryFn, }); /** * Gets the specifications for all caveats that will be recognized by the * PermissionController. - * - * @param {{ - * getInternalAccounts: () => Record, - * }} options - Options bag. */ -export const getCaveatSpecifications = ({ - getInternalAccounts, - findNetworkClientIdByChainId, -}) => { +export const getCaveatSpecifications = () => { 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]; - }, + [Caip25CaveatType]: { + type: Caip25CaveatType, }, - [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]; - }, - }, - ...snapsCaveatsSpecifications, ...snapsEndowmentCaveatSpecifications, }; @@ -108,213 +67,18 @@ export const getCaveatSpecifications = ({ * current MetaMask instance. */ export const getPermissionSpecifications = ({ - getAllAccounts, getInternalAccounts, - captureKeyringTypesWithMissingIdentities, + findNetworkClientIdByChainId, }) => { 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({ + findNetworkClientIdByChainId, + getInternalAccounts, + }), }; }; -/** - * 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..8fe9e29493f8 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 '../../lib/multichain-api/caip25permissions'; 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/multichain-api/MultichainMiddlewareManager.test.ts b/app/scripts/lib/multichain-api/MultichainMiddlewareManager.test.ts new file mode 100644 index 000000000000..099e09537e4f --- /dev/null +++ b/app/scripts/lib/multichain-api/MultichainMiddlewareManager.test.ts @@ -0,0 +1,173 @@ +import { JsonRpcRequest } from 'json-rpc-engine'; +import MultichainMiddlewareManager, { + ExtendedJsonRpcMiddleware, +} from './MultichainMiddlewareManager'; + +const scope = 'eip155:1'; +const origin = 'example.com'; +const tabId = 123; + +describe('MultichainMiddlewareManager', () => { + it('should add middleware and get called for the scope, origin, and tabId', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { scope } as unknown as JsonRpcRequest, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).toHaveBeenCalledWith( + { scope } as unknown as JsonRpcRequest, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(nextSpy).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId when the multiplexing middleware is destroyed', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + middleware.destroy?.(); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { scope } as unknown as JsonRpcRequest, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by scope', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByScope(scope); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { scope } as unknown as JsonRpcRequest, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by scope and origin', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByScopeAndOrigin(scope, origin); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { scope } as unknown as JsonRpcRequest, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); + + it('should remove middleware by origin and tabId', () => { + const multichainMiddlewareManager = new MultichainMiddlewareManager(); + const middlewareSpy = jest.fn() as unknown as ExtendedJsonRpcMiddleware; + multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: middlewareSpy, + }); + + multichainMiddlewareManager.removeMiddlewareByOriginAndTabId(origin, tabId); + + const middleware = + multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + 123, + ); + + const nextSpy = jest.fn(); + const endSpy = jest.fn(); + + middleware( + { scope } as unknown as JsonRpcRequest, + { jsonrpc: '2.0', id: 0 }, + nextSpy, + endSpy, + ); + expect(middlewareSpy).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/app/scripts/lib/multichain-api/MultichainMiddlewareManager.ts b/app/scripts/lib/multichain-api/MultichainMiddlewareManager.ts new file mode 100644 index 000000000000..f0b52b655ef0 --- /dev/null +++ b/app/scripts/lib/multichain-api/MultichainMiddlewareManager.ts @@ -0,0 +1,123 @@ +import { JsonRpcMiddleware } from 'json-rpc-engine'; +import { ExternalScopeString } from './scope'; + +// Extend JsonRpcMiddleware to include the destroy method +// this was introduced in 7.0.0 of json-rpc-engine: https://github.com/MetaMask/json-rpc-engine/blob/v7.0.0/src/JsonRpcEngine.ts#L29-L40 +export type ExtendedJsonRpcMiddleware = JsonRpcMiddleware & { + destroy?: () => void; +}; + +type MiddlewareKey = { + scope: ExternalScopeString; + origin: string; + tabId?: number; +}; +type MiddlewareEntry = MiddlewareKey & { + middleware: ExtendedJsonRpcMiddleware; +}; + +export default class MultichainMiddlewareManager { + #middlewares: MiddlewareEntry[] = []; + + #getMiddlewareEntry({ + scope, + origin, + tabId, + }: MiddlewareKey): MiddlewareEntry | undefined { + return this.#middlewares.find((middlewareEntry) => { + return ( + middlewareEntry.scope === scope && + middlewareEntry.origin === origin && + middlewareEntry.tabId === tabId + ); + }); + } + + #removeMiddlewareEntry({ scope, origin, tabId }: MiddlewareKey) { + this.#middlewares = this.#middlewares.filter((middlewareEntry) => { + return ( + middlewareEntry.scope !== scope || + middlewareEntry.origin !== origin || + middlewareEntry.tabId !== tabId + ); + }); + } + + addMiddleware(middlewareEntry: MiddlewareEntry) { + const { scope, origin, tabId } = middlewareEntry; + if (!this.#getMiddlewareEntry({ scope, origin, tabId })) { + this.#middlewares.push(middlewareEntry); + } + } + + #removeMiddleware(middlewareKey: MiddlewareKey) { + const existingMiddlewareEntry = this.#getMiddlewareEntry(middlewareKey); + if (!existingMiddlewareEntry) { + return; + } + + existingMiddlewareEntry.middleware.destroy?.(); + + this.#removeMiddlewareEntry(middlewareKey); + } + + removeMiddlewareByScope(scope: ExternalScopeString) { + this.#middlewares.forEach((middlewareEntry) => { + if (middlewareEntry.scope === scope) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + removeMiddlewareByScopeAndOrigin(scope: ExternalScopeString, origin: string) { + this.#middlewares.forEach((middlewareEntry) => { + if ( + middlewareEntry.scope === scope && + middlewareEntry.origin === origin + ) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + removeMiddlewareByOriginAndTabId(origin: string, tabId?: number) { + this.#middlewares.forEach((middlewareEntry) => { + if ( + middlewareEntry.origin === origin && + middlewareEntry.tabId === tabId + ) { + this.#removeMiddleware(middlewareEntry); + } + }); + } + + generateMultichainMiddlewareForOriginAndTabId( + origin: string, + tabId?: number, + ) { + const middleware: ExtendedJsonRpcMiddleware = (req, res, next, end) => { + const r = req as unknown as { + scope: string; + }; + const { scope } = r; + const middlewareEntry = this.#getMiddlewareEntry({ + scope, + origin, + tabId, + }); + + if (middlewareEntry) { + middlewareEntry.middleware(req, res, next, end); + } else { + next(); + } + }; + middleware.destroy = this.removeMiddlewareByOriginAndTabId.bind( + this, + origin, + tabId, + ); + + return middleware; + } +} diff --git a/app/scripts/lib/multichain-api/MultichainSubscriptionManager.test.ts b/app/scripts/lib/multichain-api/MultichainSubscriptionManager.test.ts new file mode 100644 index 000000000000..f5e3c0147cd1 --- /dev/null +++ b/app/scripts/lib/multichain-api/MultichainSubscriptionManager.test.ts @@ -0,0 +1,124 @@ +import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; +import MultichainSubscriptionManager from './MultichainSubscriptionManager'; + +jest.mock('@metamask/eth-json-rpc-filters/subscriptionManager', () => + jest.fn(), +); +const MockCreateSubscriptionManager = jest.mocked(createSubscriptionManager); + +const newHeadsNotificationMock = { + method: 'eth_subscription', + params: { + result: { + difficulty: '0x15d9223a23aa', + extraData: '0xd983010305844765746887676f312e342e328777696e646f7773', + gasLimit: '0x47e7c4', + gasUsed: '0x38658', + logsBloom: + '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + miner: '0xf8b483dba2c3b7176a3da549ad41a48bb3121069', + nonce: '0x084149998194cc5f', + number: '0x1348c9', + parentHash: + '0x7736fab79e05dc611604d22470dadad26f56fe494421b5b333de816ce1f25701', + receiptRoot: + '0x2fab35823ad00c7bb388595cb46652fe7886e00660a01e867824d3dceb1c8d36', + sha3Uncles: + '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', + stateRoot: + '0xb3346685172db67de536d8765c43c31009d0eb3bd9c501c9be3229203f15f378', + timestamp: '0x56ffeff8', + }, + }, +}; + +const scope = 'eip155:1'; +const origin = 'example.com'; +const tabId = 123; + +const createMultichainSubscriptionManager = () => { + const mockFindNetworkClientIdByChainId = jest.fn(); + const mockGetNetworkClientById = jest.fn().mockImplementation(() => ({ + blockTracker: {}, + provider: {}, + })); + const multichainSubscriptionManager = new MultichainSubscriptionManager({ + findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, + getNetworkClientById: mockGetNetworkClientById, + }); + const onNotificationSpy = jest.fn(); + + multichainSubscriptionManager.on('notification', onNotificationSpy); + + return { multichainSubscriptionManager, onNotificationSpy }; +}; + +describe('MultichainSubscriptionManager', () => { + const mockSubscriptionManager = { + events: { + on: jest.fn(), + }, + destroy: jest.fn(), + }; + + beforeEach(() => { + MockCreateSubscriptionManager.mockReturnValue(mockSubscriptionManager); + }); + + it('should subscribe to a scope, origin, and tabId', () => { + const { multichainSubscriptionManager, onNotificationSpy } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + expect(onNotificationSpy).toHaveBeenCalledWith(origin, tabId, { + method: 'wallet_notify', + params: { + scope, + notification: newHeadsNotificationMock, + }, + }); + }); + + it('should unsubscribe from a scope', () => { + const { multichainSubscriptionManager, onNotificationSpy } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScope(scope); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + expect(onNotificationSpy).not.toHaveBeenCalled(); + }); + + it('should unsubscribe from a scope and origin', () => { + const { multichainSubscriptionManager, onNotificationSpy } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByScopeAndOrigin(scope, origin); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + expect(onNotificationSpy).not.toHaveBeenCalled(); + }); + + it('should unsubscribe from a origin and tabId', () => { + const { multichainSubscriptionManager, onNotificationSpy } = + createMultichainSubscriptionManager(); + multichainSubscriptionManager.subscribe({ scope, origin, tabId }); + multichainSubscriptionManager.unsubscribeByOriginAndTabId(origin, tabId); + + mockSubscriptionManager.events.on.mock.calls[0][1]( + newHeadsNotificationMock, + ); + + expect(onNotificationSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/app/scripts/lib/multichain-api/MultichainSubscriptionManager.ts b/app/scripts/lib/multichain-api/MultichainSubscriptionManager.ts new file mode 100644 index 000000000000..5cf94f059795 --- /dev/null +++ b/app/scripts/lib/multichain-api/MultichainSubscriptionManager.ts @@ -0,0 +1,160 @@ +import EventEmitter from 'events'; +import { NetworkController } from '@metamask/network-controller'; +import SafeEventEmitter from '@metamask/safe-event-emitter'; +import { Hex, parseCaipChainId } from '@metamask/utils'; +import { toHex } from '@metamask/controller-utils'; +import { ExternalScopeString, ScopeString } from './scope'; + +export type SubscriptionManager = { + events: EventEmitter; + destroy?: () => void; +}; + +type SubscriptionNotificationEvent = { + jsonrpc: '2.0'; + method: 'eth_subscription'; + params: { + subscription: Hex; + result: unknown; + }; +}; + +type SubscriptionKey = { + scope: ExternalScopeString; + origin: string; + tabId?: number; +}; +type SubscriptionEntry = SubscriptionKey & { + subscriptionManager: SubscriptionManager; +}; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +const createSubscriptionManager = require('@metamask/eth-json-rpc-filters/subscriptionManager'); + +type MultichainSubscriptionManagerOptions = { + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + getNetworkClientById: NetworkController['getNetworkClientById']; +}; + +export default class MultichainSubscriptionManager extends SafeEventEmitter { + #findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + + #getNetworkClientById: NetworkController['getNetworkClientById']; + + #subscriptions: SubscriptionEntry[] = []; + + constructor(options: MultichainSubscriptionManagerOptions) { + super(); + this.#findNetworkClientIdByChainId = options.findNetworkClientIdByChainId; + this.#getNetworkClientById = options.getNetworkClientById; + } + + onNotification( + { scope, origin, tabId }: SubscriptionKey, + { method, params }: SubscriptionNotificationEvent, + ) { + this.emit('notification', origin, tabId, { + method: 'wallet_notify', + params: { + scope, + notification: { method, params }, + }, + }); + } + + #getSubscriptionEntry({ + scope, + origin, + tabId, + }: SubscriptionKey): SubscriptionEntry | undefined { + return this.#subscriptions.find((subscriptionEntry) => { + return ( + subscriptionEntry.scope === scope && + subscriptionEntry.origin === origin && + subscriptionEntry.tabId === tabId + ); + }); + } + + #removeSubscriptionEntry({ scope, origin, tabId }: SubscriptionKey) { + this.#subscriptions = this.#subscriptions.filter((subscriptionEntry) => { + return ( + subscriptionEntry.scope !== scope || + subscriptionEntry.origin !== origin || + subscriptionEntry.tabId !== tabId + ); + }); + } + + subscribe(subscriptionKey: SubscriptionKey) { + const subscriptionEntry = this.#getSubscriptionEntry(subscriptionKey); + if (subscriptionEntry) { + return subscriptionEntry.subscriptionManager; + } + + const networkClientId = this.#findNetworkClientIdByChainId( + toHex(parseCaipChainId(subscriptionKey.scope).reference), + ); + const networkClient = this.#getNetworkClientById(networkClientId); + const subscriptionManager = createSubscriptionManager({ + blockTracker: networkClient.blockTracker, + provider: networkClient.provider, + }); + + subscriptionManager.events.on( + 'notification', + (message: SubscriptionNotificationEvent) => { + this.onNotification(subscriptionKey, message); + }, + ); + + this.#subscriptions.push({ + ...subscriptionKey, + subscriptionManager, + }); + + return subscriptionManager; + } + + #unsubscribe(subscriptionKey: SubscriptionKey) { + const existingSubscriptionEntry = + this.#getSubscriptionEntry(subscriptionKey); + if (!existingSubscriptionEntry) { + return; + } + + existingSubscriptionEntry.subscriptionManager.destroy?.(); + + this.#removeSubscriptionEntry(subscriptionKey); + } + + unsubscribeByScope(scope: ScopeString) { + this.#subscriptions.forEach((subscriptionEntry) => { + if (subscriptionEntry.scope === scope) { + this.#unsubscribe(subscriptionEntry); + } + }); + } + + unsubscribeByScopeAndOrigin(scope: ScopeString, origin: string) { + this.#subscriptions.forEach((subscriptionEntry) => { + if ( + subscriptionEntry.scope === scope && + subscriptionEntry.origin === origin + ) { + this.#unsubscribe(subscriptionEntry); + } + }); + } + + unsubscribeByOriginAndTabId(origin: string, tabId?: number) { + this.#subscriptions.forEach((subscriptionEntry) => { + if ( + subscriptionEntry.origin === origin && + subscriptionEntry.tabId === tabId + ) { + this.#unsubscribe(subscriptionEntry); + } + }); + } +} diff --git a/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-eth-accounts.test.ts b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-eth-accounts.test.ts new file mode 100644 index 000000000000..3ef6d6b361d3 --- /dev/null +++ b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-eth-accounts.test.ts @@ -0,0 +1,242 @@ +import { Caip25CaveatValue } from '../caip25permissions'; +import { + getEthAccounts, + setEthAccounts, +} from './caip-permission-adapter-eth-accounts'; + +describe('CAIP-25 eth_accounts adapters', () => { + describe('getEthAccounts', () => { + it('returns the unique set of EIP155 accounts from the CAIP-25 caveat value', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + methods: [], + notifications: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x5'], + }, + wallet: { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x6'], + }, + }, + isMultichainOrigin: false, + }); + + expect(ethAccounts).toStrictEqual([ + '0x1', + '0x2', + '0x4', + '0x3', + '0x100', + '0x5', + '0x6', + ]); + }); + }); + + describe('setEthAccounts', () => { + it('returns a CAIP-25 caveat value with all EIP-155 scopeObject.accounts set to CAIP-10 account addresses formed from the accounts param', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + methods: [], + notifications: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + }, + wallet: { + methods: [], + notifications: [], + }, + }, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2', 'eip155:1:0x3'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x1', 'eip155:5:0x2', 'eip155:5:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2', 'eip155:1:0x3'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2', 'eip155:10:0x3'], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x1', 'eip155:100:0x2', 'eip155:100:0x3'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + wallet: { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('returns a CAIP-25 caveat value with "wallet" and "wallet:eip155" scopes with CAIP-10 account addresses formed from the accounts param when the "wallet" or "wallet:eip155" are not defined in optional scopes', () => { + const input: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(result).toStrictEqual({ + requiredScopes: {}, + optionalScopes: { + wallet: { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object in place', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-eth-accounts.ts b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-eth-accounts.ts new file mode 100644 index 000000000000..84eab594957c --- /dev/null +++ b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-eth-accounts.ts @@ -0,0 +1,107 @@ +import { + CaipAccountId, + Hex, + KnownCaipNamespace, + parseCaipAccountId, +} from '@metamask/utils'; +import { Caip25CaveatValue } from '../caip25permissions'; +import { + mergeScopes, + parseScopeString, + ScopesObject, + ScopeString, +} from '../scope'; + +const isEip155ScopeString = (scopeString: ScopeString) => { + const { namespace, reference } = parseScopeString(scopeString); + + return ( + namespace === KnownCaipNamespace.Eip155 || + (namespace === KnownCaipNamespace.Wallet && + reference === KnownCaipNamespace.Eip155) + ); +}; + +export const getEthAccounts = (caip25CaveatValue: Caip25CaveatValue) => { + const ethAccounts: string[] = []; + const sessionScopes = mergeScopes( + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ); + + Object.entries(sessionScopes).forEach(([_, { accounts }]) => { + accounts?.forEach((account) => { + const { address, chainId } = parseCaipAccountId(account); + + if (isEip155ScopeString(chainId)) { + ethAccounts.push(address); + } + }); + }); + + return Array.from(new Set(ethAccounts)); +}; + +const setEthAccountsForScopesObject = ( + scopesObject: ScopesObject, + accounts: Hex[], +) => { + const updatedScopesObject: ScopesObject = {}; + + Object.entries(scopesObject).forEach(([scopeString, scopeObject]) => { + const { namespace, reference } = parseScopeString(scopeString); + + const isWalletNamespace = + namespace === KnownCaipNamespace.Wallet && reference === undefined; + + if ( + !isEip155ScopeString(scopeString as ScopeString) && + !isWalletNamespace + ) { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + return; + } + + const caipAccounts = accounts.map( + (account) => + (isWalletNamespace + ? `wallet:eip155:${account}` + : `${scopeString}:${account}`) as CaipAccountId, + ); + + updatedScopesObject[scopeString as ScopeString] = { + ...scopeObject, + accounts: caipAccounts, + }; + }); + + return updatedScopesObject; +}; + +// This helper must be called with existing eip155 scopes +export const setEthAccounts = ( + caip25CaveatValue: Caip25CaveatValue, + accounts: Hex[], +) => { + return { + ...caip25CaveatValue, + requiredScopes: setEthAccountsForScopesObject( + caip25CaveatValue.requiredScopes, + accounts, + ), + optionalScopes: setEthAccountsForScopesObject( + { + wallet: { + methods: [], + notifications: [], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + }, + ...caip25CaveatValue.optionalScopes, + }, + accounts, + ), + }; +}; diff --git a/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-middleware.js b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-middleware.js new file mode 100644 index 000000000000..867288eb95a3 --- /dev/null +++ b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-middleware.js @@ -0,0 +1,50 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25permissions'; +import { mergeScopes } from '../scope'; + +export async function CaipPermissionAdapterMiddleware( + request, + _response, + next, + end, + hooks, +) { + const { networkClientId, method } = request; + + let caveat; + try { + caveat = hooks.getCaveat( + request.origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (err) { + // noop + } + if (!caveat?.value?.isMultichainOrigin) { + return next(); + } + + const { chainId } = + hooks.getNetworkConfigurationByNetworkClientId(networkClientId); + + const scope = `eip155:${parseInt(chainId, 16)}`; + + const scopesObject = mergeScopes( + caveat.value.requiredScopes, + caveat.value.optionalScopes, + ); + + if ( + !scopesObject[scope]?.methods?.includes(method) && + !scopesObject['wallet:eip155']?.methods?.includes(method) && + !scopesObject.wallet?.methods?.includes(method) + ) { + return end(providerErrors.unauthorized()); + } + + return next(); +} diff --git a/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-middleware.test.js b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-middleware.test.js new file mode 100644 index 000000000000..f8c0f9813718 --- /dev/null +++ b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-middleware.test.js @@ -0,0 +1,134 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25permissions'; +import { CaipPermissionAdapterMiddleware } from './caip-permission-adapter-middleware'; + +const baseRequest = { + origin: 'http://test.com', + networkClientId: 'mainnet', + method: 'eth_call', + params: { + foo: 'bar', + }, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveat = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['net_version'], + notifications: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + }, + unhandled: { + methods: ['foobar'], + notifications: [], + }, + }, + isMultichainOrigin: true, + }, + }); + const getNetworkConfigurationByNetworkClientId = jest + .fn() + .mockImplementation((networkClientId) => { + const chainId = + { + mainnet: '0x1', + goerli: '0x5', + }[networkClientId] || '0x999'; + return { + chainId, + }; + }); + const handler = (request) => + CaipPermissionAdapterMiddleware(request, {}, next, end, { + getCaveat, + getNetworkConfigurationByNetworkClientId, + }); + + return { + next, + end, + getCaveat, + getNetworkConfigurationByNetworkClientId, + handler, + }; +}; + +describe('CaipPermissionAdapterMiddleware', () => { + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const { handler, getCaveat } = createMockedHandler(); + await handler(baseRequest); + expect(getCaveat).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('allows the request when there is no CAIP-25 endowment permission', async () => { + const { handler, getCaveat, next } = createMockedHandler(); + getCaveat.mockImplementation(() => { + throw new Error('permission not found'); + }); + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + + it('allows the request when the CAIP-25 endowment permission was not granted from the multichain API', async () => { + const { handler, getCaveat, next } = createMockedHandler(); + getCaveat.mockReturnValue({ + value: { + isMultichainOrigin: false, + }, + }); + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + + it('gets the chainId for the request networkClientId', async () => { + const { handler, getNetworkConfigurationByNetworkClientId } = + createMockedHandler(); + await handler(baseRequest); + expect(getNetworkConfigurationByNetworkClientId).toHaveBeenCalledWith( + 'mainnet', + ); + }); + + describe('when the CAIP-25 endowment permission was granted over the multichain API', () => { + it('throws an error if the requested method is not authorized for the scope specified in the request', async () => { + const { handler, end } = createMockedHandler(); + + await handler({ + ...baseRequest, + method: 'unauthorized_method', + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('allows the request if the requested scope method is authorized in the current scope', async () => { + const { handler, next } = createMockedHandler(); + + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-permittedChains.test.ts b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-permittedChains.test.ts new file mode 100644 index 000000000000..8fe89acacc2f --- /dev/null +++ b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-permittedChains.test.ts @@ -0,0 +1,376 @@ +import { Caip25CaveatValue } from '../caip25permissions'; +import { KnownNotifications, KnownRpcMethods } from '../scope'; +import { + addPermittedEthChainId, + getPermittedEthChainIds, + setPermittedEthChainIds, +} from './caip-permission-adapter-permittedChains'; + +describe('CAIP-25 permittedChains adapters', () => { + describe('getPermittedEthChainIds', () => { + it('returns the unique set of EIP155 chainIds in hexadecimal format from the CAIP-25 caveat value', () => { + const ethChainIds = getPermittedEthChainIds({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + methods: [], + notifications: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }); + + expect(ethChainIds).toStrictEqual(['0x1', '0x5', '0xa', '0x64']); + }); + }); + + describe('addPermittedEthChainId', () => { + it('adds an optional scope for the chainId if it does not already exist in required or optional scopes', () => { + const result = addPermittedEthChainId( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + }, + }, + isMultichainOrigin: false, + }, + '0x65', + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('adds an optional scope for "wallet:eip155" if it does not already exist in the optional scopes', () => { + const result = addPermittedEthChainId( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }, + '0x65', + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = addPermittedEthChainId(input, '0x65'); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + + it('does not add an optional scope for the chainId if already exists in the required scopes', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }; + const result = addPermittedEthChainId(input, '0x1'); + + expect(result).toStrictEqual(input); + }); + + it('does not add an optional scope for the chainId if already exists in the optional scopes', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }; + const result = addPermittedEthChainId(input, '0x64'); // 0x64 === 100 + + expect(result).toStrictEqual(input); + }); + }); + + describe('setPermittedEthChainIds', () => { + it('returns a CAIP-25 caveat value with EIP-155 scopes missing from the chainIds array removed', () => { + const result = setPermittedEthChainIds( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }, + ['0x1'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('returns a CAIP-25 caveat value with optional scopes added for missing chainIds', () => { + const result = setPermittedEthChainIds( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }, + ['0x1', '0x64', '0x65'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = setPermittedEthChainIds(input, ['0x1', '0x2', '0x3']); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-permittedChains.ts b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-permittedChains.ts new file mode 100644 index 000000000000..b08d86d2f764 --- /dev/null +++ b/app/scripts/lib/multichain-api/adapters/caip-permission-adapter-permittedChains.ts @@ -0,0 +1,107 @@ +import { Hex, KnownCaipNamespace } from '@metamask/utils'; +import { toHex } from '@metamask/controller-utils'; +import { Caip25CaveatValue } from '../caip25permissions'; +import { + KnownNotifications, + KnownRpcMethods, + mergeScopes, + parseScopeString, + ScopesObject, + ScopeString, +} from '../scope'; + +export const getPermittedEthChainIds = ( + caip25CaveatValue: Caip25CaveatValue, +) => { + const ethChainIds: Hex[] = []; + const sessionScopes = mergeScopes( + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ); + + Object.keys(sessionScopes).forEach((scopeString) => { + const { namespace, reference } = parseScopeString(scopeString); + if (namespace === KnownCaipNamespace.Eip155 && reference) { + ethChainIds.push(toHex(reference)); + } + }); + + return Array.from(new Set(ethChainIds)); +}; + +export const addPermittedEthChainId = ( + caip25CaveatValue: Caip25CaveatValue, + chainId: Hex, +) => { + const scopeString = `eip155:${parseInt(chainId, 16)}`; + if ( + Object.keys(caip25CaveatValue.requiredScopes).includes(scopeString) || + Object.keys(caip25CaveatValue.optionalScopes).includes(scopeString) + ) { + return caip25CaveatValue; + } + + return { + ...caip25CaveatValue, + optionalScopes: { + 'wallet:eip155': { + methods: [], + notifications: [], + }, + ...caip25CaveatValue.optionalScopes, + [scopeString]: { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + }, + }; +}; + +const filterEthScopesObjectByChainId = ( + scopesObject: ScopesObject, + chainIds: Hex[], +) => { + const updatedScopesObject: ScopesObject = {}; + + Object.entries(scopesObject).forEach(([scopeString, scopeObject]) => { + const { namespace, reference } = parseScopeString(scopeString); + if (!reference) { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + return; + } + if (namespace === KnownCaipNamespace.Eip155) { + const chainId = toHex(reference); + if (chainIds.includes(chainId)) { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + } + } else { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + } + }); + + return updatedScopesObject; +}; + +export const setPermittedEthChainIds = ( + caip25CaveatValue: Caip25CaveatValue, + chainIds: Hex[], +) => { + let updatedCaveatValue: Caip25CaveatValue = { + ...caip25CaveatValue, + requiredScopes: filterEthScopesObjectByChainId( + caip25CaveatValue.requiredScopes, + chainIds, + ), + optionalScopes: filterEthScopesObjectByChainId( + caip25CaveatValue.optionalScopes, + chainIds, + ), + }; + + chainIds.forEach((chainId) => { + updatedCaveatValue = addPermittedEthChainId(updatedCaveatValue, chainId); + }); + + return updatedCaveatValue; +}; diff --git a/app/scripts/lib/multichain-api/caip25permissions.test.ts b/app/scripts/lib/multichain-api/caip25permissions.test.ts new file mode 100644 index 000000000000..129003f8aefe --- /dev/null +++ b/app/scripts/lib/multichain-api/caip25permissions.test.ts @@ -0,0 +1,686 @@ +import { + CaveatConstraint, + CaveatMutatorOperation, + PermissionType, +} from '@metamask/permission-controller'; +import { NonEmptyArray } from '@metamask/controller-utils'; +import * as Scope from './scope'; +import { + Caip25CaveatType, + Caip25CaveatValue, + caip25EndowmentBuilder, + Caip25EndowmentPermissionName, + Caip25CaveatMutatorFactories, + removeScope, +} from './caip25permissions'; + +jest.mock('./scope', () => ({ + validateAndFlattenScopes: jest.fn(), + assertScopesSupported: jest.fn(), +})); +const MockScope = jest.mocked(Scope); + +const { removeAccount } = Caip25CaveatMutatorFactories[Caip25CaveatType]; + +describe('endowment:caip25', () => { + beforeEach(() => { + MockScope.validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: {}, + flattenedOptionalScopes: {}, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('builds the expected permission specification', () => { + const specification = caip25EndowmentBuilder.specificationBuilder({ + methodHooks: { + findNetworkClientIdByChainId: jest.fn(), + }, + }); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetName: Caip25EndowmentPermissionName, + endowmentGetter: expect.any(Function), + allowedCaveats: [Caip25CaveatType], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeNull(); + }); + + describe('caveat mutator removeScope', () => { + it('can remove a caveat', () => { + const ethereumGoerliCaveat = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope('eip155:5', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.updateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + }, + }, + optionalScopes: {}, + }, + }); + }); + + it('can revoke the entire permission when a requiredScope is removed', () => { + const ethereumGoerliCaveat = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope('eip155:1', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.revokePermission, + }); + }); + + it('can noop when nothing is removed', () => { + const ethereumGoerliCaveat = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope('eip155:2', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.noop, + }); + }); + }); + + describe('caveat mutator removeAccount', () => { + it('can remove an account', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }; + const result = removeAccount('0x1', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.updateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('can remove an account in multiple scopes in optional and required', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:2': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:2:0x1', 'eip155:2:0x2'], + }, + }, + optionalScopes: { + 'eip155:3': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:3:0x1', 'eip155:3:0x2'], + }, + }, + isMultichainOrigin: true, + }; + const result = removeAccount('0x1', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.updateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x2'], + }, + 'eip155:2': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:2:0x2'], + }, + }, + optionalScopes: { + 'eip155:3': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:3:0x2'], + }, + }, + isMultichainOrigin: true, + }, + }); + }); + + it('can noop when nothing is removed', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + }, + }, + isMultichainOrigin: true, + }; + const result = removeAccount('0x3', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.noop, + }); + }); + }); + + describe('permission validator', () => { + const findNetworkClientIdByChainId = jest.fn(); + const { validator } = caip25EndowmentBuilder.specificationBuilder({ + findNetworkClientIdByChainId, + }); + + it('throws an error if there is not exactly one caveat', () => { + expect(() => { + validator({ + caveats: [ + { + type: 'caveatType', + value: {}, + }, + { + type: 'caveatType', + value: {}, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + caveats: [] as unknown as NonEmptyArray, + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('throws an error if there is no CAIP-25 caveat', () => { + expect(() => { + validator({ + caveats: [ + { + type: 'NotCaip25Caveat', + value: {}, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('throws an error if the CAIP-25 caveat is malformed', () => { + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + missingRequiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + missingOptionalScopes: {}, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: 'NotABoolean', + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('validates and flattens the ScopesObjects', () => { + try { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + } catch (err) { + // noop + } + expect(MockScope.validateAndFlattenScopes).toHaveBeenCalledWith( + { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + ); + }); + + it('asserts the validated and flattened required scopes are supported', () => { + MockScope.validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: { + 'eip155:1': { + methods: ['flattened_required'], + notifications: [], + }, + }, + flattenedOptionalScopes: { + 'eip155:1': { + methods: ['flattened_optional'], + notifications: [], + }, + }, + }); + try { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + } catch (err) { + // noop + } + expect(MockScope.assertScopesSupported).toHaveBeenCalledWith( + { + 'eip155:1': { + methods: ['flattened_required'], + notifications: [], + }, + }, + expect.objectContaining({ + isChainIdSupported: expect.any(Function), + }), + ); + const isChainIdSupportedBody = + MockScope.assertScopesSupported.mock.calls[0][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + it('asserts the validated and flattened optional scopes are supported', () => { + MockScope.validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: { + 'eip155:1': { + methods: ['flattened_required'], + notifications: [], + }, + }, + flattenedOptionalScopes: { + 'eip155:1': { + methods: ['flattened_optional'], + notifications: [], + }, + }, + }); + try { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + } catch (err) { + // noop + } + expect(MockScope.assertScopesSupported).toHaveBeenCalledWith( + { + 'eip155:1': { + methods: ['flattened_optional'], + notifications: [], + }, + }, + expect.objectContaining({ + isChainIdSupported: expect.any(Function), + }), + ); + const isChainIdSupportedBody = + MockScope.assertScopesSupported.mock.calls[1][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + it('throws if the input requiredScopes does not match the output of validateAndFlattenScopes', () => { + MockScope.validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: {}, + flattenedOptionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + }); + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow(/Expected values to be strictly deep-equal/u); + }); + + it('throws if the input optionalScopes does not match the output of validateAndFlattenScopes', () => { + MockScope.validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + flattenedOptionalScopes: {}, + }); + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow(/Expected values to be strictly deep-equal/u); + }); + + it('does not throw if the input requiredScopes and optionalScopes ScopesObject are already validated and flattened', () => { + MockScope.validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + flattenedOptionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + }); + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/caip25permissions.ts b/app/scripts/lib/multichain-api/caip25permissions.ts new file mode 100644 index 000000000000..4b0d4ded1859 --- /dev/null +++ b/app/scripts/lib/multichain-api/caip25permissions.ts @@ -0,0 +1,249 @@ +import { strict as assert } from 'assert'; +import type { + PermissionSpecificationBuilder, + EndowmentGetterParams, + ValidPermissionSpecification, + PermissionValidatorConstraint, + PermissionConstraint, +} from '@metamask/permission-controller'; +import { + CaveatMutatorOperation, + PermissionType, +} from '@metamask/permission-controller'; +import { + CaipAccountId, + Json, + parseCaipAccountId, + type Hex, + type NonEmptyArray, +} from '@metamask/utils'; +import { NetworkClientId } from '@metamask/network-controller'; +import { cloneDeep, isEqual } from 'lodash'; +import { + ExternalScopeString, + validateAndFlattenScopes, + ScopesObject, + ScopeObject, + assertScopesSupported, +} from './scope'; + +export type Caip25CaveatValue = { + requiredScopes: ScopesObject; + optionalScopes: ScopesObject; + sessionProperties?: Record; + isMultichainOrigin: boolean; +}; + +export const Caip25CaveatType = 'authorizedScopes'; + +export const Caip25CaveatFactoryFn = (value: Caip25CaveatValue) => { + return { + type: Caip25CaveatType, + value, + }; +}; + +export const Caip25EndowmentPermissionName = 'endowment:caip25'; + +type Caip25EndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof Caip25EndowmentPermissionName; + endowmentGetter: (_options?: EndowmentGetterParams) => null; + validator: PermissionValidatorConstraint; + allowedCaveats: Readonly> | null; +}>; + +/** + * `endowment:caip25` returns nothing atm; + * + * @param builderOptions - The specification builder options. + * @param builderOptions.findNetworkClientIdByChainId + * @returns The specification for the `caip25` endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + // TODO: FIX THIS + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + Caip25EndowmentSpecification +> = ({ + findNetworkClientIdByChainId, +}: { + findNetworkClientIdByChainId: (chainId: Hex) => NetworkClientId; +}) => { + return { + permissionType: PermissionType.Endowment, + targetName: Caip25EndowmentPermissionName, + allowedCaveats: [Caip25CaveatType], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, + validator: (permission: PermissionConstraint) => { + const caip25Caveat = permission.caveats?.[0]; + if ( + permission.caveats?.length !== 1 || + caip25Caveat?.type !== Caip25CaveatType + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ); + } + + // TODO: FIX THIS TYPE + const { requiredScopes, optionalScopes, isMultichainOrigin } = ( + caip25Caveat as unknown as { value: Caip25CaveatValue } + ).value; + + if ( + !requiredScopes || + !optionalScopes || + typeof isMultichainOrigin !== 'boolean' + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ); + } + + const { flattenedRequiredScopes, flattenedOptionalScopes } = + validateAndFlattenScopes(requiredScopes, optionalScopes); + + const isChainIdSupported = (chainId: Hex) => { + try { + findNetworkClientIdByChainId(chainId); + return true; + } catch (err) { + return false; + } + }; + + assertScopesSupported(flattenedRequiredScopes, { + isChainIdSupported, + }); + assertScopesSupported(flattenedOptionalScopes, { + isChainIdSupported, + }); + + assert.deepEqual(requiredScopes, flattenedRequiredScopes); + assert.deepEqual(optionalScopes, flattenedOptionalScopes); + }, + }; +}; + +export const caip25EndowmentBuilder = Object.freeze({ + targetName: Caip25EndowmentPermissionName, + specificationBuilder, +} as const); + +/** + * Factories that construct caveat mutator functions that are passed to + * PermissionController.updatePermissionsByCaveat. + */ +export const Caip25CaveatMutatorFactories = { + [Caip25CaveatType]: { + removeScope, + removeAccount, + }, +}; + +const reduceKeysHelper = ( + acc: Record, + [key, value]: [K, V], +) => { + return { + ...acc, + [key]: value, + }; +}; + +function removeAccountFilterFn(targetAddress: string) { + return (account: CaipAccountId) => { + const parsed = parseCaipAccountId(account); + return parsed.address !== targetAddress; + }; +} + +function removeAccountOnScope(targetAddress: string, scopeObject: ScopeObject) { + if (scopeObject.accounts) { + scopeObject.accounts = scopeObject.accounts.filter( + removeAccountFilterFn(targetAddress), + ); + } +} + +function removeAccount( + targetAddress: string, // non caip-10 formatted address + existingScopes: Caip25CaveatValue, +) { + // copy existing scopes + const copyOfExistingScopes = cloneDeep(existingScopes); + + [ + copyOfExistingScopes.requiredScopes, + copyOfExistingScopes.optionalScopes, + ].forEach((scopes) => { + Object.entries(scopes).forEach(([, scopeObject]) => { + removeAccountOnScope(targetAddress, scopeObject); + }); + }); + + // deep equal check for changes + const noChange = isEqual(copyOfExistingScopes, existingScopes); + + if (noChange) { + return { + operation: CaveatMutatorOperation.noop, + }; + } + + return { + operation: CaveatMutatorOperation.updateValue, + value: copyOfExistingScopes, + }; +} + +/** + * Removes the target account from the value arrays of all + * `endowment:caip25` caveats. No-ops if the target scopeString is not in + * the existing scopes,. + * + * @param targetScopeString - The scope that is being removed. + * @param caip25CaveatValue - The CAIP-25 permission caveat value to remove the scope from. + */ +export function removeScope( + targetScopeString: ExternalScopeString, + caip25CaveatValue: Caip25CaveatValue, +) { + const newRequiredScopes = Object.entries( + caip25CaveatValue.requiredScopes, + ).filter(([scope]) => scope !== targetScopeString); + const newOptionalScopes = Object.entries( + caip25CaveatValue.optionalScopes, + ).filter(([scope]) => { + return scope !== targetScopeString; + }); + + const requiredScopesRemoved = + newRequiredScopes.length !== + Object.keys(caip25CaveatValue.requiredScopes).length; + const optionalScopesRemoved = + newOptionalScopes.length !== + Object.keys(caip25CaveatValue.optionalScopes).length; + + if (requiredScopesRemoved) { + return { + operation: CaveatMutatorOperation.revokePermission, + }; + } + + if (optionalScopesRemoved) { + return { + operation: CaveatMutatorOperation.updateValue, + value: { + requiredScopes: newRequiredScopes.reduce(reduceKeysHelper, {}), + optionalScopes: newOptionalScopes.reduce(reduceKeysHelper, {}), + }, + }; + } + + return { + operation: CaveatMutatorOperation.noop, + }; +} diff --git a/app/scripts/lib/multichain-api/multichainMethodCallValidator.ts b/app/scripts/lib/multichain-api/multichainMethodCallValidator.ts new file mode 100644 index 000000000000..cff2841ecf98 --- /dev/null +++ b/app/scripts/lib/multichain-api/multichainMethodCallValidator.ts @@ -0,0 +1,98 @@ +import { MultiChainOpenRPCDocument } from '@metamask/api-specs'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { + JsonRpcError, + JsonRpcParams, + JsonRpcRequest, + isObject, +} from '@metamask/utils'; +import { + ContentDescriptorObject, + MethodObject, + OpenrpcDocument, +} from '@open-rpc/meta-schema'; +import dereferenceDocument from '@open-rpc/schema-utils-js/build/dereference-document'; +import { makeCustomResolver } from '@open-rpc/schema-utils-js/build/parse-open-rpc-document'; +import { Json, JsonRpcMiddleware } from 'json-rpc-engine'; +import { Schema, ValidationError, Validator } from 'jsonschema'; + +const transformError = ( + error: ValidationError, + param: ContentDescriptorObject, + got: unknown, +) => { + // if there is a path, add it to the message + const message = `${ + param.name + (error.path.length > 0 ? `.${error.path.join('.')}` : '') + } ${error.message}`; + + return { + code: -32602, // TODO: could be a different error code or not wrapped in json-rpc error, since this will also be wrapped in a -32602 invalid params error + message, + data: { + param: param.name, + path: error.path, + schema: error.schema, + got, + }, + }; +}; + +const v = new Validator(); + +const dereffedPromise = dereferenceDocument( + MultiChainOpenRPCDocument as unknown as OpenrpcDocument, + makeCustomResolver({}), +); +export const multichainMethodCallValidator = async ( + method: string, + params: JsonRpcParams | undefined, +) => { + const dereffed = await dereffedPromise; + const methodToCheck = dereffed.methods.find( + (m) => (m as unknown as ContentDescriptorObject).name === method, + ); + const errors: JsonRpcError[] = []; + // check each param and aggregate errors + (methodToCheck as unknown as MethodObject).params.forEach((param, i) => { + let paramToCheck: Json | undefined; + const p = param as ContentDescriptorObject; + if (isObject(params)) { + paramToCheck = params[p.name]; + } else if (params && Array.isArray(params)) { + paramToCheck = params[i]; + } else { + paramToCheck = undefined; + } + const result = v.validate(paramToCheck, p.schema as unknown as Schema, { + required: p.required, + }); + if (result.errors) { + errors.push( + ...result.errors.map((e) => { + return transformError(e, p, paramToCheck) as JsonRpcError; + }), + ); + } + }); + if (errors.length > 0) { + return errors; + } + // feels like this should return true to indicate that its valid but i'd rather check the falsy value since errors + // would be an array and return true if it's empty + return false; +}; + +export const multichainMethodCallValidatorMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + void +> = function (request, _response, next, end) { + multichainMethodCallValidator(request.method, request.params).then( + (errors) => { + if (errors) { + return end(rpcErrors.invalidParams({ data: errors })); + } + return next(); + }, + ); +}; diff --git a/app/scripts/lib/multichain-api/scope/assert.test.ts b/app/scripts/lib/multichain-api/scope/assert.test.ts new file mode 100644 index 000000000000..6190da65b5a9 --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/assert.test.ts @@ -0,0 +1,209 @@ +import { EthereumRpcError } from 'eth-rpc-errors'; +import { assertScopeSupported, assertScopesSupported } from './assert'; +import { ScopeObject } from './scope'; +import * as Supported from './supported'; + +jest.mock('./supported', () => ({ + isSupportedScopeString: jest.fn(), + isSupportedNotification: jest.fn(), + isSupportedMethod: jest.fn(), +})); +const MockSupported = jest.mocked(Supported); + +const validScopeObject: ScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Assert', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('assertScopeSupported', () => { + const isChainIdSupported = jest.fn(); + + describe('scopeString', () => { + it('checks if the scopeString is supported', () => { + try { + assertScopeSupported('scopeString', validScopeObject, { + isChainIdSupported, + }); + } catch (err) { + // noop + } + expect(MockSupported.isSupportedScopeString).toHaveBeenCalledWith( + 'scopeString', + isChainIdSupported, + ); + }); + + it('throws an error if the scopeString is not supported', () => { + MockSupported.isSupportedScopeString.mockReturnValue(false); + expect(() => { + assertScopeSupported('scopeString', validScopeObject, { + isChainIdSupported, + }); + }).toThrow( + new EthereumRpcError(5100, 'Requested chains are not supported'), + ); + }); + }); + + describe('scopeObject', () => { + beforeEach(() => { + MockSupported.isSupportedScopeString.mockReturnValue(true); + }); + + it('checks if the methods are supported', () => { + try { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + }, + { + isChainIdSupported, + }, + ); + } catch (err) { + // noop + } + + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'scopeString', + 'eth_chainId', + ); + }); + + it('throws an error if there are unsupported methods', () => { + MockSupported.isSupportedMethod.mockReturnValue(false); + expect(() => { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + }, + { + isChainIdSupported, + }, + ); + }).toThrow( + new EthereumRpcError(5101, 'Requested methods are not supported'), + ); + }); + + it('checks if the notifications are supported', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + try { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + notifications: ['chainChanged'], + }, + { + isChainIdSupported, + }, + ); + } catch (err) { + // noop + } + + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'scopeString', + 'chainChanged', + ); + }); + + it('throws an error if there are unsupported notifications', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + MockSupported.isSupportedNotification.mockReturnValue(false); + expect(() => { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + notifications: ['chainChanged'], + }, + { + isChainIdSupported, + }, + ); + }).toThrow( + new EthereumRpcError( + 5102, + 'Requested notifications are not supported', + ), + ); + }); + + it('does not throw if the scopeObject is valid', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + MockSupported.isSupportedNotification.mockReturnValue(true); + expect( + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0xdeadbeef'], + }, + { + isChainIdSupported, + }, + ), + ).toBeUndefined(); + }); + }); + }); + + describe('assertScopesSupported', () => { + const isChainIdSupported = jest.fn(); + + it('does not throw an error if no scopes are defined', () => { + assertScopesSupported( + {}, + { + isChainIdSupported, + }, + ); + }); + + it('throws an error if any scope is invalid', () => { + MockSupported.isSupportedScopeString.mockReturnValue(false); + + expect(() => { + assertScopesSupported( + { + 'eip155:1': validScopeObject, + }, + { + isChainIdSupported, + }, + ); + }).toThrow( + new EthereumRpcError(5100, 'Requested chains are not supported'), + ); + }); + + it('does not throw an error if all scopes are valid', () => { + MockSupported.isSupportedScopeString.mockReturnValue(true); + + expect( + assertScopesSupported( + { + 'eip155:1': validScopeObject, + 'eip155:2': validScopeObject, + }, + { + isChainIdSupported, + }, + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/scope/assert.ts b/app/scripts/lib/multichain-api/scope/assert.ts new file mode 100644 index 000000000000..0908a1a9d1ba --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/assert.ts @@ -0,0 +1,73 @@ +import { Hex } from '@metamask/utils'; +import { EthereumRpcError } from 'eth-rpc-errors'; +import { + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, +} from './supported'; +import { ScopeObject, ScopesObject } from './scope'; + +export const assertScopeSupported = ( + scopeString: string, + scopeObject: ScopeObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const { methods, notifications } = scopeObject; + if (!isSupportedScopeString(scopeString, isChainIdSupported)) { + throw new EthereumRpcError(5100, 'Requested chains are not supported'); + } + + const allMethodsSupported = methods.every((method) => + isSupportedMethod(scopeString, method), + ); + + if (!allMethodsSupported) { + // not sure which one of these to use + // When provider evaluates requested methods to not be supported + // code = 5101 + // message = "Requested methods are not supported" + // When provider does not recognize one or more requested method(s) + // code = 5201 + // message = "Unknown method(s) requested" + + throw new EthereumRpcError(5101, 'Requested methods are not supported'); + } + + if ( + notifications && + !notifications.every((notification) => + isSupportedNotification(scopeString, notification), + ) + ) { + // not sure which one of these to use + // When provider evaluates requested notifications to not be supported + // code = 5102 + // message = "Requested notifications are not supported" + // When provider does not recognize one or more requested notification(s) + // code = 5202 + // message = "Unknown notification(s) requested" + throw new EthereumRpcError( + 5102, + 'Requested notifications are not supported', + ); + } +}; + +export const assertScopesSupported = ( + scopes: ScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertScopeSupported(scopeString, scopeObject, { + isChainIdSupported, + }); + } +}; diff --git a/app/scripts/lib/multichain-api/scope/authorization.test.ts b/app/scripts/lib/multichain-api/scope/authorization.test.ts new file mode 100644 index 000000000000..2dee1f48cabd --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/authorization.test.ts @@ -0,0 +1,328 @@ +import * as Validation from './validation'; +import * as Transform from './transform'; +import * as Filter from './filter'; +import { + bucketScopes, + processScopedProperties, + validateAndFlattenScopes, +} from './authorization'; +import { ExternalScopeObject } from './scope'; + +jest.mock('./validation', () => ({ + validateScopedPropertyEip3085: jest.fn(), + validateScopes: jest.fn(), +})); +const MockValidation = jest.mocked(Validation); + +jest.mock('./transform', () => ({ + flattenMergeScopes: jest.fn(), +})); +const MockTransform = jest.mocked(Transform); + +jest.mock('./filter', () => ({ + bucketScopesBySupport: jest.fn(), +})); +const MockFilter = jest.mocked(Filter); + +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Authorization', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('validateAndFlattenScopes', () => { + it('validates the scopes', () => { + try { + validateAndFlattenScopes( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + } catch (err) { + // noop + } + expect(MockValidation.validateScopes).toHaveBeenCalledWith( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + }); + + it('flatten and merges the validated scopes', () => { + MockValidation.validateScopes.mockReturnValue({ + validRequiredScopes: { + 'eip155:1': validScopeObject, + }, + validOptionalScopes: { + 'eip155:5': validScopeObject, + }, + }); + + validateAndFlattenScopes({}, {}); + expect(MockTransform.flattenMergeScopes).toHaveBeenCalledWith({ + 'eip155:1': validScopeObject, + }); + expect(MockTransform.flattenMergeScopes).toHaveBeenCalledWith({ + 'eip155:5': validScopeObject, + }); + }); + + it('returns the flattened and merged scopes', () => { + MockValidation.validateScopes.mockReturnValue({ + validRequiredScopes: { + 'eip155:1': validScopeObject, + }, + validOptionalScopes: { + 'eip155:5': validScopeObject, + }, + }); + MockTransform.flattenMergeScopes.mockImplementation((value) => ({ + ...value, + transformed: true, + })); + + expect(validateAndFlattenScopes({}, {})).toStrictEqual({ + flattenedRequiredScopes: { + 'eip155:1': validScopeObject, + transformed: true, + }, + flattenedOptionalScopes: { + 'eip155:5': validScopeObject, + transformed: true, + }, + }); + }); + }); + + describe('bucketScopes', () => { + beforeEach(() => { + let callCount = 0; + MockFilter.bucketScopesBySupport.mockImplementation(() => { + callCount += 1; + return { + supportedScopes: { + 'mock:A': { + methods: [`mock_method_${callCount}`], + notifications: [], + }, + }, + unsupportedScopes: { + 'mock:B': { + methods: [`mock_method_${callCount}`], + notifications: [], + }, + }, + }; + }); + }); + + it('buckets the scopes by supported', () => { + const isChainIdSupported = jest.fn(); + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + }, + }, + { + isChainIdSupported, + isChainIdSupportable: jest.fn(), + }, + ); + + expect(MockFilter.bucketScopesBySupport).toHaveBeenCalledWith( + { + wallet: { + methods: [], + notifications: [], + }, + }, + { + isChainIdSupported, + }, + ); + }); + + it('buckets the mayble supportable scopes', () => { + const isChainIdSupportable = jest.fn(); + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + }, + }, + { + isChainIdSupported: jest.fn(), + isChainIdSupportable, + }, + ); + + expect(MockFilter.bucketScopesBySupport).toHaveBeenCalledWith( + { + 'mock:B': { + methods: [`mock_method_1`], + notifications: [], + }, + }, + { + isChainIdSupported: isChainIdSupportable, + }, + ); + }); + + it('returns the bucketed scopes', () => { + expect( + bucketScopes( + { + wallet: { + methods: [], + notifications: [], + }, + }, + { + isChainIdSupported: jest.fn(), + isChainIdSupportable: jest.fn(), + }, + ), + ).toStrictEqual({ + supportedScopes: { + 'mock:A': { + methods: [`mock_method_1`], + notifications: [], + }, + }, + supportableScopes: { + 'mock:A': { + methods: [`mock_method_2`], + notifications: [], + }, + }, + unsupportableScopes: { + 'mock:B': { + methods: [`mock_method_2`], + notifications: [], + }, + }, + }); + }); + }); + + describe('processScopedProperties', () => { + it('excludes scopeStrings that are not defined in either required or optional scopes', () => { + expect( + processScopedProperties( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + { + 'eip155:10': {}, + }, + ), + ).toStrictEqual({}); + }); + + it('includes scopeStrings that are defined in either required or optional scopes', () => { + expect( + processScopedProperties( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + { + 'eip155:1': {}, + 'eip155:5': {}, + }, + ), + ).toStrictEqual({ + 'eip155:1': {}, + 'eip155:5': {}, + }); + }); + + it('validates eip3085 properties', () => { + processScopedProperties( + { + 'eip155:1': validScopeObject, + }, + {}, + { + 'eip155:1': { + eip3085: { + foo: 'bar', + }, + }, + }, + ); + expect(MockValidation.validateScopedPropertyEip3085).toHaveBeenCalledWith( + 'eip155:1', + { + foo: 'bar', + }, + ); + }); + + it('excludes invalid eip3085 properties', () => { + MockValidation.validateScopedPropertyEip3085.mockImplementation(() => { + throw new Error('invalid eip3085 params'); + }); + expect( + processScopedProperties( + { + 'eip155:1': validScopeObject, + }, + {}, + { + 'eip155:1': { + eip3085: { + foo: 'bar', + }, + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': {}, + }); + }); + + it('includes valid eip3085 properties', () => { + expect( + processScopedProperties( + { + 'eip155:1': validScopeObject, + }, + {}, + { + 'eip155:1': { + eip3085: { + foo: 'bar', + }, + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': { + eip3085: { + foo: 'bar', + }, + }, + }); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/scope/authorization.ts b/app/scripts/lib/multichain-api/scope/authorization.ts new file mode 100644 index 000000000000..ebde0b69ab0d --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/authorization.ts @@ -0,0 +1,102 @@ +import { CaipChainId, Hex } from '@metamask/utils'; +import { validateScopedPropertyEip3085, validateScopes } from './validation'; +import { ExternalScopesObject, ScopesObject, ScopedProperties } from './scope'; +import { flattenMergeScopes } from './transform'; +import { bucketScopesBySupport } from './filter'; + +export type Caip25Authorization = + | { + requiredScopes: ExternalScopesObject; + optionalScopes?: ExternalScopesObject; + sessionProperties?: Record; + } + | ({ + requiredScopes?: ExternalScopesObject; + optionalScopes: ExternalScopesObject; + } & { + sessionProperties?: Record; + }); + +export const validateAndFlattenScopes = ( + requiredScopes: ExternalScopesObject, + optionalScopes: ExternalScopesObject, +): { + flattenedRequiredScopes: ScopesObject; + flattenedOptionalScopes: ScopesObject; +} => { + const { validRequiredScopes, validOptionalScopes } = validateScopes( + requiredScopes, + optionalScopes, + ); + + const flattenedRequiredScopes = flattenMergeScopes(validRequiredScopes); + const flattenedOptionalScopes = flattenMergeScopes(validOptionalScopes); + + return { + flattenedRequiredScopes, + flattenedOptionalScopes, + }; +}; + +export const bucketScopes = ( + scopes: ScopesObject, + { + isChainIdSupported, + isChainIdSupportable, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + isChainIdSupportable: (chainId: Hex) => boolean; + }, +): { + supportedScopes: ScopesObject; + supportableScopes: ScopesObject; + unsupportableScopes: ScopesObject; +} => { + const { supportedScopes, unsupportedScopes: maybeSupportableScopes } = + bucketScopesBySupport(scopes, { + isChainIdSupported, + }); + + const { + supportedScopes: supportableScopes, + unsupportedScopes: unsupportableScopes, + } = bucketScopesBySupport(maybeSupportableScopes, { + isChainIdSupported: isChainIdSupportable, + }); + + return { supportedScopes, supportableScopes, unsupportableScopes }; +}; + +export const processScopedProperties = ( + requiredScopes: ScopesObject, + optionalScopes: ScopesObject, + scopedProperties?: ScopedProperties, +): ScopedProperties => { + if (!scopedProperties) { + return {}; + } + const validScopedProperties: ScopedProperties = {}; + + for (const [scopeString, scopedProperty] of Object.entries( + scopedProperties, + )) { + const scope = + requiredScopes[scopeString as CaipChainId] || + optionalScopes[scopeString as CaipChainId]; + if (!scope) { + continue; + } + validScopedProperties[scopeString] = {}; + + if (scopedProperty.eip3085) { + try { + validateScopedPropertyEip3085(scopeString, scopedProperty.eip3085); + validScopedProperties[scopeString].eip3085 = scopedProperty.eip3085; + } catch (err) { + // noop + } + } + } + + return validScopedProperties; +}; diff --git a/app/scripts/lib/multichain-api/scope/filter.test.ts b/app/scripts/lib/multichain-api/scope/filter.test.ts new file mode 100644 index 000000000000..cf7c49258341 --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/filter.test.ts @@ -0,0 +1,153 @@ +import * as Assert from './assert'; +import { filterScopesSupported, bucketScopesBySupport } from './filter'; + +jest.mock('./assert', () => ({ + assertScopeSupported: jest.fn(), +})); +const MockAssert = jest.mocked(Assert); + +describe('filter', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('filterScopesSupported', () => { + const isChainIdSupported = jest.fn(); + + it('checks if each scope is supported', () => { + filterScopesSupported( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + }, + }, + { isChainIdSupported }, + ); + + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:1', + { + methods: ['a'], + notifications: [], + }, + { isChainIdSupported }, + ); + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:5', + { + methods: ['b'], + notifications: [], + }, + { isChainIdSupported }, + ); + }); + + it('returns only supported scopes', () => { + MockAssert.assertScopeSupported.mockImplementation((scopeString) => { + if (scopeString === 'eip155:1') { + throw new Error('scope not supported'); + } + }); + + expect( + filterScopesSupported( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + }, + }, + { isChainIdSupported }, + ), + ).toStrictEqual({ + 'eip155:5': { + methods: ['b'], + notifications: [], + }, + }); + }); + }); + + describe('bucketScopesBySupport', () => { + const isChainIdSupported = jest.fn(); + + it('checks if each scope is supported', () => { + bucketScopesBySupport( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + }, + }, + { isChainIdSupported }, + ); + + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:1', + { + methods: ['a'], + notifications: [], + }, + { isChainIdSupported }, + ); + expect(MockAssert.assertScopeSupported).toHaveBeenCalledWith( + 'eip155:5', + { + methods: ['b'], + notifications: [], + }, + { isChainIdSupported }, + ); + }); + + it('returns supported and unsupported scopes', () => { + MockAssert.assertScopeSupported.mockImplementation((scopeString) => { + if (scopeString === 'eip155:1') { + throw new Error('scope not supported'); + } + }); + + expect( + bucketScopesBySupport( + { + 'eip155:1': { + methods: ['a'], + notifications: [], + }, + 'eip155:5': { + methods: ['b'], + notifications: [], + }, + }, + { isChainIdSupported }, + ), + ).toStrictEqual({ + supportedScopes: { + 'eip155:5': { + methods: ['b'], + notifications: [], + }, + }, + unsupportedScopes: { + 'eip155:1': { + methods: ['a'], + notifications: [], + }, + }, + }); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/scope/filter.ts b/app/scripts/lib/multichain-api/scope/filter.ts new file mode 100644 index 000000000000..06b9795c4971 --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/filter.ts @@ -0,0 +1,43 @@ +import { CaipChainId, Hex } from '@metamask/utils'; +import { ScopesObject } from './scope'; +import { assertScopeSupported } from './assert'; + +export const bucketScopesBySupport = ( + scopes: ScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const supportedScopes: ScopesObject = {}; + const unsupportedScopes: ScopesObject = {}; + + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + try { + assertScopeSupported(scopeString, scopeObject, { + isChainIdSupported, + }); + supportedScopes[scopeString as CaipChainId] = scopeObject; + } catch (err) { + unsupportedScopes[scopeString as CaipChainId] = scopeObject; + } + } + + return { supportedScopes, unsupportedScopes }; +}; + +export const filterScopesSupported = ( + scopes: ScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const { supportedScopes } = bucketScopesBySupport(scopes, { + isChainIdSupported, + }); + + return supportedScopes; +}; diff --git a/app/scripts/lib/multichain-api/scope/index.ts b/app/scripts/lib/multichain-api/scope/index.ts new file mode 100644 index 000000000000..c1b804efecbf --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/index.ts @@ -0,0 +1,7 @@ +export * from './assert'; +export * from './authorization'; +export * from './filter'; +export * from './scope'; +export * from './supported'; +export * from './transform'; +export * from './validation'; diff --git a/app/scripts/lib/multichain-api/scope/scope.test.ts b/app/scripts/lib/multichain-api/scope/scope.test.ts new file mode 100644 index 000000000000..2441c41c3482 --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/scope.test.ts @@ -0,0 +1,23 @@ +import { parseScopeString } from './scope'; + +describe('Scope', () => { + describe('parseScopeString', () => { + it('returns only the namespace if scopeString is namespace', () => { + expect(parseScopeString('abc')).toStrictEqual({ namespace: 'abc' }); + }); + + it('returns the namespace and reference if scopeString is a CAIP chain ID ', () => { + expect(parseScopeString('abc:foo')).toStrictEqual({ + namespace: 'abc', + reference: 'foo', + }); + }); + + it('returns empty object if scopeString is invalid', () => { + expect(parseScopeString('')).toStrictEqual({}); + expect(parseScopeString('a:')).toStrictEqual({}); + expect(parseScopeString(':b')).toStrictEqual({}); + expect(parseScopeString('a:b:c')).toStrictEqual({}); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/scope/scope.ts b/app/scripts/lib/multichain-api/scope/scope.ts new file mode 100644 index 000000000000..2f93289d7b8b --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/scope.ts @@ -0,0 +1,96 @@ +import MetaMaskOpenRPCDocument from '@metamask/api-specs'; +import { + CaipChainId, + CaipReference, + CaipAccountId, + isCaipNamespace, + isCaipChainId, + parseCaipChainId, + KnownCaipNamespace, + CaipNamespace, +} from '@metamask/utils'; + +export type NonWalletKnownCaipNamespace = Exclude< + KnownCaipNamespace, + KnownCaipNamespace.Wallet +>; + +export const KnownWalletRpcMethods: string[] = [ + 'wallet_registerOnboarding', + 'wallet_scanQRCode', +]; +const WalletEip155Methods = ['wallet_addEthereumChain']; + +const Eip155Methods = MetaMaskOpenRPCDocument.methods + .map(({ name }) => name) + .filter((method) => !WalletEip155Methods.includes(method)) + .filter((method) => !KnownWalletRpcMethods.includes(method)); + +export const KnownRpcMethods: Record = { + eip155: Eip155Methods, +}; + +export const KnownWalletNamespaceRpcMethods: Record< + NonWalletKnownCaipNamespace, + string[] +> = { + eip155: WalletEip155Methods, +}; + +export const KnownNotifications: Record = + { + eip155: ['accountsChanged', 'chainChanged', 'eth_subscription'], + }; + +// These External prefixed types represent the CAIP-217 +// Scope and ScopeObject as defined in the spec. +export type ExternalScopeString = CaipChainId | CaipNamespace; +export type ExternalScopeObject = ScopeObject & { + references?: CaipReference[]; +}; +export type ExternalScopesObject = Record< + ExternalScopeString, + ExternalScopeObject +>; + +// These non-prefixed types represent CAIP-217 Scope and +// ScopeObject as defined by the spec but without +// namespace-only Scopes (except for "wallet") and without +// the `references` array of CAIP References on the ScopeObject. +// These deviations from the spec are necessary as MetaMask +// does not support wildcarded Scopes, i.e. Scopes that only +// specify a namespace but no specific reference. +export type ScopeString = CaipChainId | KnownCaipNamespace.Wallet; +export type ScopeObject = { + methods: string[]; + notifications: string[]; + accounts?: CaipAccountId[]; + rpcDocuments?: string[]; + rpcEndpoints?: string[]; +}; +export type ScopesObject = Record & { + [KnownCaipNamespace.Wallet]?: ScopeObject; +}; + +export const parseScopeString = ( + scopeString: string, +): { + namespace?: string; + reference?: string; +} => { + if (isCaipNamespace(scopeString)) { + return { + namespace: scopeString, + }; + } + if (isCaipChainId(scopeString)) { + return parseCaipChainId(scopeString); + } + + return {}; +}; + +export type ScopedProperties = Record< + ExternalScopeString, + Record +>; diff --git a/app/scripts/lib/multichain-api/scope/supported.test.ts b/app/scripts/lib/multichain-api/scope/supported.test.ts new file mode 100644 index 000000000000..50ccc844c20b --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/supported.test.ts @@ -0,0 +1,100 @@ +import { + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, +} from './supported'; +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, + ScopeString, +} from './scope'; + +describe('Scope Support', () => { + describe('isSupportedNotification', () => { + // @ts-expect-error This is missing from the Mocha type definitions + it.each(Object.entries(KnownNotifications))( + 'returns true for each %s scope method', + (scopeString: ScopeString, notifications: string[]) => { + notifications.forEach((notification) => { + expect( + isSupportedNotification(scopeString, notification), + ).toStrictEqual(true); + }); + }, + ); + + it('returns false otherwise', () => { + expect(isSupportedNotification('eip155', 'anything else')).toStrictEqual( + false, + ); + expect(isSupportedNotification('', '')).toStrictEqual(false); + }); + }); + + describe('isSupportedMethod', () => { + // @ts-expect-error This is missing from the Mocha type definitions + it.each(Object.entries(KnownRpcMethods))( + 'returns true for each %s scoped method', + (scopeString: ScopeString, methods: string[]) => { + methods.forEach((method) => { + expect(isSupportedMethod(scopeString, method)).toStrictEqual(true); + }); + }, + ); + + it('returns true for each wallet scoped method', () => { + KnownWalletRpcMethods.forEach((method) => { + expect(isSupportedMethod('wallet', method)).toStrictEqual(true); + }); + }); + + // @ts-expect-error This is missing from the Mocha type definitions + it.each(Object.entries(KnownWalletNamespaceRpcMethods))( + 'returns true for each wallet:%s scoped method', + (scopeString: ScopeString, methods: string[]) => { + methods.forEach((method) => { + expect( + isSupportedMethod(`wallet:${scopeString}`, method), + ).toStrictEqual(true); + }); + }, + ); + + it('returns false otherwise', () => { + expect(isSupportedMethod('eip155', 'anything else')).toStrictEqual(false); + expect(isSupportedMethod('', '')).toStrictEqual(false); + }); + }); + + describe('isSupportedScopeString', () => { + it('returns true for the wallet namespace', () => { + expect(isSupportedScopeString('wallet', jest.fn())).toStrictEqual(true); + }); + + it('returns false for the wallet namespace when a reference is included', () => { + expect(isSupportedScopeString('wallet:someref', jest.fn())).toStrictEqual( + false, + ); + }); + + it('returns true for the ethereum namespace', () => { + expect(isSupportedScopeString('eip155', jest.fn())).toStrictEqual(true); + }); + + it('returns true for the ethereum namespace when a network client exists for the reference', () => { + const isChainIdSupportedMock = jest.fn().mockReturnValue(true); + expect( + isSupportedScopeString('eip155:1', isChainIdSupportedMock), + ).toStrictEqual(true); + }); + + it('returns false for the ethereum namespace when a network client does not exist for the reference', () => { + const isChainIdSupportedMock = jest.fn().mockReturnValue(false); + expect( + isSupportedScopeString('eip155:1', isChainIdSupportedMock), + ).toStrictEqual(false); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/scope/supported.ts b/app/scripts/lib/multichain-api/scope/supported.ts new file mode 100644 index 000000000000..dbdc9d7760af --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/supported.ts @@ -0,0 +1,116 @@ +import { + CaipAccountId, + Hex, + isCaipChainId, + isCaipNamespace, + KnownCaipNamespace, + parseCaipAccountId, + parseCaipChainId, +} from '@metamask/utils'; +import { toHex } from '@metamask/controller-utils'; +import { InternalAccount } from '@metamask/keyring-api'; +import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, + NonWalletKnownCaipNamespace, + parseScopeString, + ExternalScopeString, +} from './scope'; + +export const isSupportedScopeString = ( + scopeString: string, + isChainIdSupported: (chainId: Hex) => boolean, +) => { + const isNamespaceScoped = isCaipNamespace(scopeString); + const isChainScoped = isCaipChainId(scopeString); + + if (isNamespaceScoped) { + switch (scopeString) { + case KnownCaipNamespace.Wallet: + return true; + case KnownCaipNamespace.Eip155: + return true; + default: + return false; + } + } + + if (isChainScoped) { + const { namespace, reference } = parseCaipChainId(scopeString); + switch (namespace) { + case KnownCaipNamespace.Wallet: + if (reference === KnownCaipNamespace.Eip155) { + return true; + } + return false; + case KnownCaipNamespace.Eip155: + return isChainIdSupported(toHex(reference)); + default: + return false; + } + } + + return false; +}; + +export const isSupportedAccount = ( + account: CaipAccountId, + getInternalAccounts: () => InternalAccount[], +) => { + const { + address, + chain: { namespace }, + } = parseCaipAccountId(account); + switch (namespace) { + case KnownCaipNamespace.Eip155: + try { + return getInternalAccounts().some( + (internalAccount) => + ['eip155:eoa', 'eip155:erc4337'].includes(internalAccount.type) && + isEqualCaseInsensitive(address, internalAccount.address), + ); + } catch (err) { + console.log('failed to check if account is supported by wallet', err); + } + return false; + default: + return false; + } +}; + +export const isSupportedMethod = ( + scopeString: ExternalScopeString, + method: string, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + if (namespace === KnownCaipNamespace.Wallet) { + if (reference) { + return ( + KnownWalletNamespaceRpcMethods[ + reference as NonWalletKnownCaipNamespace + ] || [] + ).includes(method); + } + + return KnownWalletRpcMethods.includes(method); + } + + return ( + KnownRpcMethods[namespace as NonWalletKnownCaipNamespace] || [] + ).includes(method); +}; + +export const isSupportedNotification = ( + scopeString: ExternalScopeString, + notification: string, +): boolean => { + const { namespace } = parseScopeString(scopeString); + + return ( + KnownNotifications[namespace as NonWalletKnownCaipNamespace] || [] + ).includes(notification); +}; diff --git a/app/scripts/lib/multichain-api/scope/transform.test.ts b/app/scripts/lib/multichain-api/scope/transform.test.ts new file mode 100644 index 000000000000..df0b529822ff --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/transform.test.ts @@ -0,0 +1,283 @@ +import { ExternalScopeObject } from './scope'; +import { + flattenScope, + mergeScopes, + mergeScopeObject, + flattenMergeScopes, +} from './transform'; + +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Transform', () => { + describe('flattenScope', () => { + it('returns the scope as is when the scopeString is chain scoped', () => { + expect(flattenScope('eip155:1', validScopeObject)).toStrictEqual({ + 'eip155:1': validScopeObject, + }); + }); + + describe('scopeString is namespace scoped', () => { + it('returns the scope as is when `references` is not defined', () => { + expect(flattenScope('eip155', validScopeObject)).toStrictEqual({ + eip155: validScopeObject, + }); + }); + + it('returns one scope per `references` element with `references` excluded from the scopeObject', () => { + expect( + flattenScope('eip155', { + ...validScopeObject, + references: ['1', '5', '64'], + }), + ).toStrictEqual({ + 'eip155:1': validScopeObject, + 'eip155:5': validScopeObject, + 'eip155:64': validScopeObject, + }); + }); + + it('returns one deep cloned scope per `references` element', () => { + const flattenedScopes = flattenScope('eip155', { + ...validScopeObject, + references: ['1', '5'], + }); + + expect(flattenedScopes['eip155:1']).not.toBe( + flattenedScopes['eip155:5'], + ); + expect(flattenedScopes['eip155:1'].methods).not.toBe( + flattenedScopes['eip155:5'].methods, + ); + }); + }); + }); + + describe('mergeScopeObject', () => { + it('returns an object with the unique set of methods', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + methods: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + methods: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + methods: ['a', 'b', 'c', 'd'], + }); + }); + + it('returns an object with the unique set of notifications', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + notifications: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + notifications: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + notifications: ['a', 'b', 'c', 'd'], + }); + }); + + it('returns an object with the unique set of accounts', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }, + { + ...validScopeObject, + accounts: ['eip155:1:b', 'eip155:1:c', 'eip155:1:d'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c', 'eip155:1:d'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }); + }); + + it('returns an object with the unique set of rpcDocuments', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + rpcDocuments: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c', 'd'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }); + }); + + it('returns an object with the unique set of rpcEndpoints', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + rpcEndpoints: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c', 'd'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }); + }); + }); + + describe('mergeScopes', () => { + it('merges the scopeObjects with matching scopeString', () => { + expect( + mergeScopes( + { + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + }, + }, + { + 'eip155:1': { + methods: ['c', 'd'], + notifications: ['bar'], + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': { + methods: ['a', 'b', 'c', 'd'], + notifications: ['foo', 'bar'], + }, + }); + }); + + it('preserves the scopeObjects with no matching scopeString', () => { + expect( + mergeScopes( + { + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + }, + }, + { + 'eip155:2': { + methods: ['c', 'd'], + notifications: ['bar'], + }, + 'eip155:3': { + methods: [], + notifications: [], + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + }, + 'eip155:2': { + methods: ['c', 'd'], + notifications: ['bar'], + }, + 'eip155:3': { + methods: [], + notifications: [], + }, + }); + }); + }); + + describe('flattenMergeScopes', () => { + it('flattens scopes and merges any overlapping scopeStrings', () => { + expect( + flattenMergeScopes({ + eip155: { + ...validScopeObject, + methods: ['a', 'b'], + references: ['1', '5'], + }, + 'eip155:1': { + ...validScopeObject, + methods: ['b', 'c', 'd'], + }, + }), + ).toStrictEqual({ + 'eip155:1': { + ...validScopeObject, + methods: ['a', 'b', 'c', 'd'], + }, + 'eip155:5': { + ...validScopeObject, + methods: ['a', 'b'], + }, + }); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/scope/transform.ts b/app/scripts/lib/multichain-api/scope/transform.ts new file mode 100644 index 000000000000..a31faf2d34c8 --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/transform.ts @@ -0,0 +1,119 @@ +import { CaipReference } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; +import { + ExternalScopeObject, + ExternalScopesObject, + ScopeString, + ScopeObject, + ScopesObject, + parseScopeString, +} from './scope'; + +// DRY THIS +function unique(list: T[]): T[] { + return Array.from(new Set(list)); +} + +/** + * Flattens a ScopeString and ScopeObject into a separate + * ScopeString and ScopeObject for each reference in the `references` + * value if defined. Returns the ScopeString and ScopeObject + * unmodified if it cannot be flattened + * + * @param scopeString - The string representing the scopeObject + * @param scopeObject - The object that defines the scope + * @returns a map of caipChainId to ScopeObjects + */ +export const flattenScope = ( + scopeString: string, + scopeObject: ExternalScopeObject, +): ScopesObject => { + const { references, ...restScopeObject } = scopeObject; + const { namespace, reference } = parseScopeString(scopeString); + + // Scope is already a CAIP-2 ID and has no references to flatten + if (reference || !references) { + return { [scopeString]: scopeObject }; + } + + const scopeMap: ScopesObject = {}; + references.forEach((nestedReference: CaipReference) => { + scopeMap[`${namespace}:${nestedReference}`] = cloneDeep(restScopeObject); + }); + return scopeMap; +}; + +export const mergeScopeObject = ( + scopeObjectA: ScopeObject, + scopeObjectB: ScopeObject, +) => { + const mergedScopeObject: ScopeObject = { + methods: unique([...scopeObjectA.methods, ...scopeObjectB.methods]), + notifications: unique([ + ...scopeObjectA.notifications, + ...scopeObjectB.notifications, + ]), + }; + + if (scopeObjectA.accounts || scopeObjectB.accounts) { + mergedScopeObject.accounts = unique([ + ...(scopeObjectA.accounts ?? []), + ...(scopeObjectB.accounts ?? []), + ]); + } + + if (scopeObjectA.rpcDocuments || scopeObjectB.rpcDocuments) { + mergedScopeObject.rpcDocuments = unique([ + ...(scopeObjectA.rpcDocuments ?? []), + ...(scopeObjectB.rpcDocuments ?? []), + ]); + } + + if (scopeObjectA.rpcEndpoints || scopeObjectB.rpcEndpoints) { + mergedScopeObject.rpcEndpoints = unique([ + ...(scopeObjectA.rpcEndpoints ?? []), + ...(scopeObjectB.rpcEndpoints ?? []), + ]); + } + + return mergedScopeObject; +}; + +export const mergeScopes = ( + scopeA: ScopesObject, + scopeB: ScopesObject, +): ScopesObject => { + const scope: ScopesObject = {}; + + Object.entries(scopeA).forEach(([_scopeString, scopeObjectA]) => { + const scopeString = _scopeString as ScopeString; + const scopeObjectB = scopeB[scopeString]; + + scope[scopeString] = scopeObjectB + ? mergeScopeObject(scopeObjectA, scopeObjectB) + : scopeObjectA; + }); + + Object.entries(scopeB).forEach(([_scopeString, scopeObjectB]) => { + const scopeString = _scopeString as ScopeString; + const scopeObjectA = scopeA[scopeString]; + + if (!scopeObjectA) { + scope[scopeString] = scopeObjectB; + } + }); + + return scope; +}; + +export const flattenMergeScopes = ( + scopes: ExternalScopesObject, +): ScopesObject => { + let flattenedScopes: ScopesObject = {}; + Object.keys(scopes).forEach((scopeString) => { + const flattenedScopeMap = flattenScope(scopeString, scopes[scopeString]); + flattenedScopes = mergeScopes(flattenedScopes, flattenedScopeMap); + }); + + return flattenedScopes; +}; diff --git a/app/scripts/lib/multichain-api/scope/validation.test.ts b/app/scripts/lib/multichain-api/scope/validation.test.ts new file mode 100644 index 000000000000..e2b3fa4d7f7d --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/validation.test.ts @@ -0,0 +1,258 @@ +import * as EthereumChainUtils from '../../rpc-method-middleware/handlers/ethereum-chain-utils'; +import { ExternalScopeObject } from './scope'; +import { + isValidScope, + validateScopedPropertyEip3085, + validateScopes, +} from './validation'; + +jest.mock('../../rpc-method-middleware/handlers/ethereum-chain-utils', () => ({ + validateAddEthereumChainParams: jest.fn(), +})); +const MockEthereumChainUtils = jest.mocked(EthereumChainUtils); + +const validScopeString = 'eip155:1'; +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Validation', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isValidScope', () => { + // @ts-expect-error This is missing from the Mocha type definitions + it.each([ + [ + false, + 'the scopeString is neither a CAIP namespace or CAIP chainId', + 'not a namespace or a caip chain id', + validScopeObject, + ], + [ + true, + 'the scopeString is a valid CAIP namespace and the scopeObject is valid', + 'eip155', + validScopeObject, + ], + [ + true, + 'the scopeString is a valid CAIP chainId and the scopeObject is valid', + 'eip155:1', + validScopeObject, + ], + [ + false, + 'the scopeString is a CAIP chainId but references is nonempty', + 'eip155:1', + { + ...validScopeObject, + references: ['5'], + }, + ], + [ + false, + 'methods contains empty string', + validScopeString, + { + ...validScopeObject, + methods: [''], + }, + ], + [ + false, + 'methods contains non-string', + validScopeString, + { + ...validScopeObject, + methods: [{ foo: 'bar' }], + }, + ], + [ + true, + 'methods contains only strings', + validScopeString, + { + ...validScopeObject, + methods: ['method1', 'method2'], + }, + ], + [ + false, + 'notifications contains empty string', + validScopeString, + { + ...validScopeObject, + notifications: [''], + }, + ], + [ + false, + 'notifications contains non-string', + validScopeString, + { + ...validScopeObject, + notifications: [{ foo: 'bar' }], + }, + ], + [ + false, + 'notifications contains non-string', + 'eip155:1', + { + ...validScopeObject, + notifications: [{ foo: 'bar' }], + }, + ], + [ + false, + 'unexpected properties are defined', + validScopeString, + { + ...validScopeObject, + unexpectedParam: 'foobar', + }, + ], + [ + true, + 'only expected properties are defined', + validScopeString, + { + references: [], + methods: [], + notifications: [], + accounts: [], + rpcDocuments: [], + rpcEndpoints: [], + }, + ], + ])( + 'returns %s when %s', + ( + expected: boolean, + _scenario: string, + scopeString: string, + scopeObject: ExternalScopeObject, + ) => { + expect(isValidScope(scopeString, scopeObject)).toStrictEqual(expected); + }, + ); + }); + + describe('validateScopes', () => { + const validScopeObjectWithAccounts = { + ...validScopeObject, + accounts: [], + }; + + it('does not throw an error if required scopes are defined but none are valid', () => { + validateScopes( + { 'eip155:1': {} as unknown as ExternalScopeObject }, + undefined, + ); + }); + + it('does not throw an error if optional scopes are defined but none are valid', () => { + validateScopes(undefined, { + 'eip155:1': {} as unknown as ExternalScopeObject, + }); + }); + + it('returns the valid required and optional scopes', () => { + expect( + validateScopes( + { + 'eip155:1': validScopeObjectWithAccounts, + 'eip155:64': {} as unknown as ExternalScopeObject, + }, + { + 'eip155:2': {} as unknown as ExternalScopeObject, + 'eip155:5': validScopeObjectWithAccounts, + }, + ), + ).toStrictEqual({ + validRequiredScopes: { + 'eip155:1': validScopeObjectWithAccounts, + }, + validOptionalScopes: { + 'eip155:5': validScopeObjectWithAccounts, + }, + }); + }); + }); + + describe('validateScopedPropertyEip3085', () => { + it('throws an error if eip3085 params are not provided', () => { + expect(() => validateScopedPropertyEip3085('', undefined)).toThrow( + new Error('eip3085 params are missing'), + ); + }); + + it('throws an error if the scopeString is not a CAIP chain ID', () => { + expect(() => validateScopedPropertyEip3085('eip155', {})).toThrow( + new Error('scopeString is malformed'), + ); + }); + + it('throws an error if the namespace is not eip155', () => { + expect(() => validateScopedPropertyEip3085('wallet:1', {})).toThrow( + new Error('namespace is not eip155'), + ); + }); + + it('validates the 3085 params', () => { + try { + validateScopedPropertyEip3085('eip155:1', { foo: 'bar' }); + } catch (err) { + // noop + } + expect( + MockEthereumChainUtils.validateAddEthereumChainParams, + ).toHaveBeenCalledWith({ foo: 'bar' }); + }); + + it('throws an error if the 3085 params are invalid', () => { + MockEthereumChainUtils.validateAddEthereumChainParams.mockImplementation( + () => { + throw new Error('invalid eth chain params'); + }, + ); + expect(() => + validateScopedPropertyEip3085('eip155:1', { foo: 'bar' }), + ).toThrow(new Error('invalid eth chain params')); + }); + + it('throws an error if the 3085 params chainId does not match the reference', () => { + MockEthereumChainUtils.validateAddEthereumChainParams.mockReturnValue({ + chainId: '0x5', + chainName: 'test', + firstValidBlockExplorerUrl: 'http://explorer.test.com', + firstValidRPCUrl: 'http://rpc.test.com', + ticker: 'TST', + }); + expect(() => + validateScopedPropertyEip3085('eip155:1', { foo: 'bar' }), + ).toThrow(new Error('eip3085 chainId does not match reference')); + }); + it('returns the validated 3085 params when valid', () => { + MockEthereumChainUtils.validateAddEthereumChainParams.mockReturnValue({ + chainId: '0x1', + chainName: 'test', + firstValidBlockExplorerUrl: 'http://explorer.test.com', + firstValidRPCUrl: 'http://rpc.test.com', + ticker: 'TST', + }); + expect( + validateScopedPropertyEip3085('eip155:1', { foo: 'bar' }), + ).toStrictEqual({ + chainId: '0x1', + chainName: 'test', + firstValidBlockExplorerUrl: 'http://explorer.test.com', + firstValidRPCUrl: 'http://rpc.test.com', + ticker: 'TST', + }); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/scope/validation.ts b/app/scripts/lib/multichain-api/scope/validation.ts new file mode 100644 index 000000000000..95d6dd3a5a62 --- /dev/null +++ b/app/scripts/lib/multichain-api/scope/validation.ts @@ -0,0 +1,130 @@ +import { isCaipReference, KnownCaipNamespace } from '@metamask/utils'; +import { toHex } from '@metamask/controller-utils'; +import { validateAddEthereumChainParams } from '../../rpc-method-middleware/handlers/ethereum-chain-utils'; +import { + ExternalScopeString, + parseScopeString, + ExternalScopeObject, + ExternalScopesObject, +} from './scope'; + +export const isValidScope = ( + scopeString: ExternalScopeString, + scopeObject: ExternalScopeObject, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + if (!namespace && !reference) { + return false; + } + + const { + references, + methods, + notifications, + accounts, + rpcDocuments, + rpcEndpoints, + ...restScopeObject + } = scopeObject; + + if (!methods || !notifications) { + return false; + } + + // These assume that the namespace has a notion of chainIds + if (reference && references && references.length > 0) { + return false; + } + if (namespace && references) { + const areReferencesValid = references.every((nestedReference) => { + return isCaipReference(nestedReference); + }); + + if (!areReferencesValid) { + return false; + } + } + + const areMethodsValid = methods.every( + (method) => typeof method === 'string' && method !== '', + ); + if (!areMethodsValid) { + return false; + } + + const areNotificationsValid = notifications.every( + (notification) => typeof notification === 'string' && notification !== '', + ); + if (!areNotificationsValid) { + return false; + } + + // unexpected properties found on scopeObject + if (Object.keys(restScopeObject).length !== 0) { + return false; + } + + return true; +}; + +export const validateScopes = ( + requiredScopes?: ExternalScopesObject, + optionalScopes?: ExternalScopesObject, +) => { + const validRequiredScopes: ExternalScopesObject = {}; + for (const [scopeString, scopeObject] of Object.entries( + requiredScopes || {}, + )) { + if (isValidScope(scopeString, scopeObject)) { + validRequiredScopes[scopeString] = { + accounts: [], + ...scopeObject, + }; + } + } + + const validOptionalScopes: ExternalScopesObject = {}; + for (const [scopeString, scopeObject] of Object.entries( + optionalScopes || {}, + )) { + if (isValidScope(scopeString, scopeObject)) { + validOptionalScopes[scopeString] = { + accounts: [], + ...scopeObject, + }; + } + } + + return { + validRequiredScopes, + validOptionalScopes, + }; +}; + +export const validateScopedPropertyEip3085 = ( + scopeString: string, + eip3085Params: unknown, +) => { + if (!eip3085Params) { + throw new Error('eip3085 params are missing'); + } + + const { namespace, reference } = parseScopeString(scopeString); + + if (!namespace || !reference) { + throw new Error('scopeString is malformed'); + } + + if (namespace !== KnownCaipNamespace.Eip155) { + throw new Error('namespace is not eip155'); + } + + const validParams = validateAddEthereumChainParams(eip3085Params); + + if (validParams.chainId !== toHex(reference)) { + throw new Error('eip3085 chainId does not match reference'); + } + + return validParams; +}; diff --git a/app/scripts/lib/multichain-api/wallet-createSession/handler.js b/app/scripts/lib/multichain-api/wallet-createSession/handler.js new file mode 100644 index 000000000000..aa5a2e95e54d --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-createSession/handler.js @@ -0,0 +1,227 @@ +import { EthereumRpcError } from 'eth-rpc-errors'; +import { CaveatTypes } from '../../../../../shared/constants/permissions'; +import { + mergeScopes, + validateAndFlattenScopes, + processScopedProperties, + bucketScopes, +} from '../scope'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25permissions'; +import { shouldEmitDappViewedEvent } from '../../util'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../../shared/constants/metametrics'; +import { PermissionNames } from '../../../controllers/permissions'; +import { + getEthAccounts, + setEthAccounts, +} from '../adapters/caip-permission-adapter-eth-accounts'; +import { + getPermittedEthChainIds, + setPermittedEthChainIds, +} from '../adapters/caip-permission-adapter-permittedChains'; +import { validateAndAddEip3085 } from './helpers'; + +export async function walletCreateSessionHandler(req, res, _next, end, hooks) { + // TODO: Does this handler need a rate limiter/lock like the one in eth_requestAccounts? + + const { + origin, + params: { + requiredScopes, + optionalScopes, + sessionProperties, + scopedProperties, + }, + } = req; + + if (sessionProperties && Object.keys(sessionProperties).length === 0) { + return end( + new EthereumRpcError(5302, 'Invalid sessionProperties requested'), + ); + } + + const chainIdsForNetworksAdded = []; + + try { + const { flattenedRequiredScopes, flattenedOptionalScopes } = + validateAndFlattenScopes(requiredScopes, optionalScopes); + + const validScopedProperties = processScopedProperties( + flattenedRequiredScopes, + flattenedOptionalScopes, + scopedProperties, + ); + + const existsNetworkClientForChainId = (chainId) => { + try { + hooks.findNetworkClientIdByChainId(chainId); + return true; + } catch (err) { + return false; + } + }; + + const existsEip3085ForChainId = (chainId) => { + const scopeString = `eip155:${parseInt(chainId, 16)}`; + return validScopedProperties?.[scopeString]?.eip3085; + }; + + const { + supportedScopes: supportedRequiredScopes, + supportableScopes: supportableRequiredScopes, + unsupportableScopes: unsupportableRequiredScopes, + } = bucketScopes(flattenedRequiredScopes, { + isChainIdSupported: existsNetworkClientForChainId, + isChainIdSupportable: existsEip3085ForChainId, + }); + + const { + supportedScopes: supportedOptionalScopes, + supportableScopes: supportableOptionalScopes, + unsupportableScopes: unsupportableOptionalScopes, + } = bucketScopes(flattenedOptionalScopes, { + isChainIdSupported: existsNetworkClientForChainId, + isChainIdSupportable: existsEip3085ForChainId, + }); + + // TODO: placeholder for future CAIP-25 permission confirmation call + JSON.stringify({ + supportedRequiredScopes, + supportableRequiredScopes, + unsupportableRequiredScopes, + supportedOptionalScopes, + supportableOptionalScopes, + unsupportableOptionalScopes, + }); + + // These should be EVM accounts already although the name does not necessary imply that + // These addresses are lowercased already + const existingEvmAddresses = hooks + .listAccounts() + .map((account) => account.address); + const supportedEthAccounts = getEthAccounts({ + requiredScopes: supportedRequiredScopes, + optionalScopes: supportedOptionalScopes, + }) + .map((address) => address.toLowerCase()) + .filter((address) => existingEvmAddresses.includes(address)); + const supportedEthChainIds = getPermittedEthChainIds({ + requiredScopes: supportedRequiredScopes, + optionalScopes: supportedOptionalScopes, + }); + + const legacyApproval = await hooks.requestPermissionApprovalForOrigin({ + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: supportedEthAccounts, + }, + ], + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: supportedEthChainIds, + }, + ], + }, + }); + + let caip25CaveatValue = { + requiredScopes: supportedRequiredScopes, + optionalScopes: supportedOptionalScopes, + isMultichainOrigin: true, + // TODO: preserve sessionProperties? + }; + + caip25CaveatValue = setPermittedEthChainIds( + caip25CaveatValue, + legacyApproval.approvedChainIds, + ); + caip25CaveatValue = setEthAccounts( + caip25CaveatValue, + legacyApproval.approvedAccounts, + ); + + const sessionScopes = mergeScopes( + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ); + + await Promise.all( + Object.keys(scopedProperties || {}).map(async (scopeString) => { + const scope = sessionScopes[scopeString]; + if (!scope) { + return; + } + + const chainId = await validateAndAddEip3085({ + eip3085Params: scopedProperties[scopeString].eip3085, + addNetwork: hooks.addNetwork, + findNetworkClientIdByChainId: hooks.findNetworkClientIdByChainId, + }); + + if (chainId) { + chainIdsForNetworksAdded.push(chainId); + } + }), + ); + + hooks.grantPermissions({ + subject: { + origin, + }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caip25CaveatValue, + }, + ], + }, + }, + }); + + // TODO: Contact analytics team for how they would prefer to track this + // 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(hooks.metamaskState.metaMetricsId)) { + const isFirstVisit = !Object.keys( + hooks.metamaskState.permissionHistory, + ).includes(origin); + + hooks.sendMetrics({ + event: MetaMetricsEventName.DappViewed, + category: MetaMetricsEventCategory.InpageProvider, + referrer: { + url: origin, + }, + properties: { + is_first_visit: isFirstVisit, + number_of_accounts: Object.keys(hooks.metamaskState.accounts).length, + number_of_accounts_connected: legacyApproval.approvedAccounts.length, + }, + }); + } + + res.result = { + sessionScopes, + sessionProperties, + }; + return end(); + } catch (err) { + chainIdsForNetworksAdded.forEach((chainId) => { + hooks.removeNetwork(chainId); + }); + return end(err); + } +} diff --git a/app/scripts/lib/multichain-api/wallet-createSession/handler.test.js b/app/scripts/lib/multichain-api/wallet-createSession/handler.test.js new file mode 100644 index 000000000000..ce2870378aa7 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-createSession/handler.test.js @@ -0,0 +1,681 @@ +import { EthereumRpcError } from 'eth-rpc-errors'; +import { CaveatTypes } from '../../../../../shared/constants/permissions'; +import { + validateAndFlattenScopes, + processScopedProperties, + bucketScopes, + KnownRpcMethods, + KnownNotifications, +} from '../scope'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25permissions'; +import { shouldEmitDappViewedEvent } from '../../util'; +import { PermissionNames } from '../../../controllers/permissions'; +import { walletCreateSessionHandler } from './handler'; +import { validateAndAddEip3085 } from './helpers'; + +jest.mock('../../util', () => ({ + ...jest.requireActual('../../util'), + shouldEmitDappViewedEvent: jest.fn(), +})); + +jest.mock('../scope', () => ({ + ...jest.requireActual('../scope/assert'), + ...jest.requireActual('../scope/authorization'), + ...jest.requireActual('../scope/filter'), + ...jest.requireActual('../scope/scope'), + ...jest.requireActual('../scope/supported'), + ...jest.requireActual('../scope/transform'), + ...jest.requireActual('../scope/validation'), + validateAndFlattenScopes: jest.fn(), + processScopedProperties: jest.fn(), + bucketScopes: jest.fn(), +})); + +jest.mock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + validateAndAddEip3085: jest.fn(), +})); + +const baseRequest = { + origin: 'http://test.com', + params: { + requiredScopes: { + eip155: { + references: ['1', '137'], + methods: [ + 'eth_sendTransaction', + 'eth_signTransaction', + 'eth_sign', + 'get_balance', + 'personal_sign', + ], + notifications: ['accountsChanged', 'chainChanged'], + }, + }, + sessionProperties: { + expiry: 'date', + foo: 'bar', + }, + }, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const requestPermissionApprovalForOrigin = jest.fn().mockResolvedValue({ + approvedAccounts: ['0x1', '0x2', '0x3', '0x4'], + approvedChainIds: ['0x1', '0x5'], + }); + const grantPermissions = jest.fn().mockResolvedValue(undefined); + const findNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); + const addNetwork = jest.fn().mockResolvedValue(); + const removeNetwork = jest.fn(); + const sendMetrics = jest.fn(); + const metamaskState = { + permissionHistory: {}, + metaMetricsId: 'metaMetricsId', + accounts: { + '0x1': {}, + '0x2': {}, + '0x3': {}, + }, + }; + const listAccounts = jest.fn().mockReturnValue([]); + const response = {}; + const handler = (request) => + walletCreateSessionHandler(request, response, next, end, { + findNetworkClientIdByChainId, + requestPermissionApprovalForOrigin, + grantPermissions, + addNetwork, + removeNetwork, + metamaskState, + sendMetrics, + listAccounts, + }); + + return { + response, + next, + end, + findNetworkClientIdByChainId, + requestPermissionApprovalForOrigin, + grantPermissions, + addNetwork, + removeNetwork, + metamaskState, + sendMetrics, + listAccounts, + handler, + }; +}; + +describe('wallet_createSession', () => { + beforeEach(() => { + validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: {}, + flattenedOptionalScopes: {}, + }); + bucketScopes.mockReturnValue({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('throws an error when session properties is defined but empty', async () => { + const { handler, end } = createMockedHandler(); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + sessionProperties: {}, + }, + }); + expect(end).toHaveBeenCalledWith( + new EthereumRpcError(5302, 'Invalid sessionProperties requested'), + ); + }); + + it('processes the scopes', async () => { + const { handler } = createMockedHandler(); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + optionalScopes: { + foo: 'bar', + }, + }, + }); + + expect(validateAndFlattenScopes).toHaveBeenCalledWith( + baseRequest.params.requiredScopes, + { foo: 'bar' }, + ); + }); + + it('throws an error when processing scopes fails', async () => { + const { handler, end } = createMockedHandler(); + validateAndFlattenScopes.mockImplementation(() => { + throw new Error('failed to process scopes'); + }); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(new Error('failed to process scopes')); + }); + + it('processes the scopedProperties', async () => { + const { handler } = createMockedHandler(); + validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + flattenedOptionalScopes: { + 'eip155:100': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:100:0x4'], + }, + }, + }); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + scopedProperties: { + foo: 'bar', + }, + }, + }); + + expect(processScopedProperties).toHaveBeenCalledWith( + { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + { + 'eip155:100': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:100:0x4'], + }, + }, + { foo: 'bar' }, + ); + }); + + it('throws an error when processing scopedProperties fails', async () => { + const { handler, end } = createMockedHandler(); + processScopedProperties.mockImplementation(() => { + throw new Error('failed to process scoped properties'); + }); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith( + new Error('failed to process scoped properties'), + ); + }); + + it('buckets the required scopes', async () => { + const { handler } = createMockedHandler(); + validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + flattenedOptionalScopes: {}, + }); + await handler(baseRequest); + + expect(bucketScopes).toHaveBeenNthCalledWith( + 1, + { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + expect.objectContaining({ + isChainIdSupported: expect.any(Function), + isChainIdSupportable: expect.any(Function), + }), + ); + + const isChainIdSupportedBody = + bucketScopes.mock.calls[0][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + const isChainIdSupportableBody = + bucketScopes.mock.calls[0][1].isChainIdSupportable.toString(); + expect(isChainIdSupportableBody).toContain('validScopedProperties'); + }); + + it('buckets the optional scopes', async () => { + const { handler } = createMockedHandler(); + validateAndFlattenScopes.mockReturnValue({ + flattenedRequiredScopes: {}, + flattenedOptionalScopes: { + 'eip155:100': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:100:0x4'], + }, + }, + }); + await handler(baseRequest); + + expect(bucketScopes).toHaveBeenNthCalledWith( + 2, + { + 'eip155:100': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:100:0x4'], + }, + }, + expect.objectContaining({ + isChainIdSupported: expect.any(Function), + isChainIdSupportable: expect.any(Function), + }), + ); + + const isChainIdSupportedBody = + bucketScopes.mock.calls[1][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + const isChainIdSupportableBody = + bucketScopes.mock.calls[1][1].isChainIdSupportable.toString(); + expect(isChainIdSupportableBody).toContain('validScopedProperties'); + }); + + it('gets a list of evm accounts in the wallet', async () => { + const { handler, listAccounts } = createMockedHandler(); + await handler(baseRequest); + + expect(listAccounts).toHaveBeenCalled(); + }); + + it('requests approval for account and permitted chains permission based on the supported eth accounts and eth chains from the supported scopes in the request', async () => { + const { handler, listAccounts, requestPermissionApprovalForOrigin } = + createMockedHandler(); + listAccounts.mockReturnValue([ + { address: '0x1' }, + { address: '0x3' }, + { address: '0x4' }, + ]); + bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1337': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:2:0x1', 'eip155:2:0x3', 'eip155:2:0xdeadbeef'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + await handler(baseRequest); + + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [PermissionNames.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x1', '0x3'], + }, + ], + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x539', '0x64'], + }, + ], + }, + }); + }); + + it('throws an error when requesting account permission approval fails', async () => { + const { handler, requestPermissionApprovalForOrigin, end } = + createMockedHandler(); + requestPermissionApprovalForOrigin.mockImplementation(() => { + throw new Error('failed to request account permission approval'); + }); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith( + new Error('failed to request account permission approval'), + ); + }); + + it('validates and upserts EIP 3085 scoped properties when matching sessionScope is defined', async () => { + const { handler, findNetworkClientIdByChainId, addNetwork } = + createMockedHandler(); + bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + scopedProperties: { + 'eip155:1': { + eip3085: { + foo: 'bar', + }, + }, + }, + }, + }); + + expect(validateAndAddEip3085).toHaveBeenCalledWith({ + eip3085Params: { foo: 'bar' }, + addNetwork, + findNetworkClientIdByChainId, + }); + }); + + it('does not validate and upsert EIP 3085 scoped properties when there is no matching sessionScope', async () => { + const { handler } = createMockedHandler(); + bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + scopedProperties: { + 'eip155:99999': { + eip3085: { + foo: 'bar', + }, + }, + }, + }, + }); + + expect(validateAndAddEip3085).not.toHaveBeenCalled(); + }); + + it('grants the CAIP-25 permission for the supported scopes and accounts that were approved', async () => { + const { handler, grantPermissions, requestPermissionApprovalForOrigin } = + createMockedHandler(); + bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:5': { + methods: ['eth_chainId'], + notifications: ['accountsChanged'], + accounts: [], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:100': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x3'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + requestPermissionApprovalForOrigin.mockResolvedValue({ + approvedAccounts: ['0x1', '0x2'], + approvedChainIds: ['0x5', '0x64', '0x539'], // 5, 100, 1337 + }); + await handler(baseRequest); + + expect(grantPermissions).toHaveBeenCalledWith({ + subject: { origin: 'http://test.com' }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:5': { + methods: ['eth_chainId'], + notifications: ['accountsChanged'], + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:100:0x1', 'eip155:100:0x2'], + }, + 'eip155:1337': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: ['eip155:1337:0x1', 'eip155:1337:0x2'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + wallet: { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + }, + }, + }); + }); + + it('throws an error when granting the CAIP-25 permission fails', async () => { + const { handler, grantPermissions, end } = createMockedHandler(); + grantPermissions.mockImplementation(() => { + throw new Error('failed to grant CAIP-25 permissions'); + }); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith( + new Error('failed to grant CAIP-25 permissions'), + ); + }); + + it('emits the dapp viewed metrics event', async () => { + shouldEmitDappViewedEvent.mockReturnValue(true); + const { handler, sendMetrics } = createMockedHandler(); + bucketScopes.mockReturnValue({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + 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: 4, + }, + referrer: { + url: 'http://test.com', + }, + }); + }); + + it('returns the session ID, properties, and merged scopes', async () => { + const { handler, requestPermissionApprovalForOrigin, response } = + createMockedHandler(); + bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:5': { + methods: ['eth_chainId'], + notifications: ['accountsChanged'], + accounts: ['eip155:5:0x1'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:5': { + methods: ['net_version'], + notifications: ['chainChanged', 'accountsChanged'], + accounts: [], + }, + 'eip155:100': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x3'], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }); + requestPermissionApprovalForOrigin.mockResolvedValue({ + approvedAccounts: ['0x1', '0x2'], + approvedChainIds: ['0x5', '0x64'], // 5, 100 + }); + await handler(baseRequest); + + expect(response.result).toStrictEqual({ + sessionProperties: { + expiry: 'date', + foo: 'bar', + }, + sessionScopes: { + 'eip155:5': { + methods: ['eth_chainId', 'net_version'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + 'eip155:100': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:100:0x1', 'eip155:100:0x2'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + wallet: { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x1', 'wallet:eip155:0x2'], + }, + }, + }); + }); + + it('reverts any upserted network clients if the request fails', async () => { + const { handler, removeNetwork, grantPermissions } = createMockedHandler(); + bucketScopes + .mockReturnValueOnce({ + supportedScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + }, + supportableScopes: {}, + unsupportableScopes: {}, + }) + .mockReturnValueOnce({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + processScopedProperties.mockReturnValue({ + 'eip155:1': { + eip3085: { + foo: 'bar', + }, + }, + }); + validateAndAddEip3085.mockReturnValue('0xdeadbeef'); + grantPermissions.mockImplementation(() => { + throw new Error('failed to grant permission'); + }); + + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + scopedProperties: { + 'eip155:1': { + eip3085: { + foo: 'bar', + }, + }, + }, + }, + }); + + expect(removeNetwork).toHaveBeenCalledWith('0xdeadbeef'); + }); +}); diff --git a/app/scripts/lib/multichain-api/wallet-createSession/helpers.test.ts b/app/scripts/lib/multichain-api/wallet-createSession/helpers.test.ts new file mode 100644 index 000000000000..118f98d569ff --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-createSession/helpers.test.ts @@ -0,0 +1,99 @@ +import { RpcEndpointType } from '@metamask/network-controller'; +import * as EthereumChainUtils from '../../rpc-method-middleware/handlers/ethereum-chain-utils'; +import { validateAndAddEip3085 } from './helpers'; + +jest.mock('../../rpc-method-middleware/handlers/ethereum-chain-utils', () => ({ + validateAddEthereumChainParams: jest.fn(), +})); +const MockEthereumChainUtils = jest.mocked(EthereumChainUtils); + +describe('wallet_createSession helpers', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('validateAndAddEip3085', () => { + const addNetwork = jest.fn(); + const findNetworkClientIdByChainId = jest.fn(); + + beforeEach(() => { + findNetworkClientIdByChainId.mockImplementation(() => { + throw new Error('cannot find network client for chainId'); + }); + + MockEthereumChainUtils.validateAddEthereumChainParams.mockReturnValue({ + chainId: '0x5', + chainName: 'test', + firstValidBlockExplorerUrl: 'http://explorer.test.com', + firstValidRPCUrl: 'http://rpc.test.com', + ticker: 'TST', + }); + }); + + it('validates the eip3085 params', async () => { + try { + await validateAndAddEip3085({ + eip3085Params: { foo: 'bar' }, + addNetwork, + findNetworkClientIdByChainId, + }); + } catch (err) { + // noop + } + expect( + MockEthereumChainUtils.validateAddEthereumChainParams, + ).toHaveBeenCalledWith({ foo: 'bar' }); + }); + + it('checks if the chainId can already be served', async () => { + try { + await validateAndAddEip3085({ + eip3085Params: { foo: 'bar' }, + addNetwork, + findNetworkClientIdByChainId, + }); + } catch (err) { + // noop + } + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x5'); + }); + + it('returns undefined if a network client already exists for the chainId', async () => { + findNetworkClientIdByChainId.mockReturnValue('existingNetworkClientId'); + const result = await validateAndAddEip3085({ + eip3085Params: {}, + addNetwork, + findNetworkClientIdByChainId, + }); + + expect(addNetwork).not.toHaveBeenCalled(); + expect(result).toStrictEqual(undefined); + }); + + it('adds a new network returns the chainId if a network client does not already exist for the chainId', async () => { + addNetwork.mockResolvedValue({ chainId: '0x5' }); + const result = await validateAndAddEip3085({ + eip3085Params: {}, + addNetwork, + findNetworkClientIdByChainId, + }); + + expect(addNetwork).toHaveBeenCalledWith({ + blockExplorerUrls: ['http://explorer.test.com'], + defaultBlockExplorerUrlIndex: 0, + chainId: '0x5', + defaultRpcEndpointIndex: 0, + name: 'test', + nativeCurrency: 'TST', + rpcEndpoints: [ + { + url: 'http://rpc.test.com', + name: 'test', + type: RpcEndpointType.Custom, + }, + ], + }); + expect(result).toStrictEqual('0x5'); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/wallet-createSession/helpers.ts b/app/scripts/lib/multichain-api/wallet-createSession/helpers.ts new file mode 100644 index 000000000000..2470feeaf25d --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-createSession/helpers.ts @@ -0,0 +1,53 @@ +import { Hex } from '@metamask/utils'; +import { + NetworkController, + RpcEndpointType, +} from '@metamask/network-controller'; +import { validateAddEthereumChainParams } from '../../rpc-method-middleware/handlers/ethereum-chain-utils'; + +export const validateAndAddEip3085 = async ({ + eip3085Params, + addNetwork, + findNetworkClientIdByChainId, +}: { + eip3085Params: unknown; + addNetwork: NetworkController['addNetwork']; + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; +}): Promise => { + const validParams = validateAddEthereumChainParams(eip3085Params); + + const { + chainId, + chainName, + firstValidBlockExplorerUrl, + firstValidRPCUrl, + ticker, + } = validParams; + + try { + findNetworkClientIdByChainId(chainId as Hex); + return undefined; + } catch (err) { + // noop + } + + const networkConfiguration = await addNetwork({ + blockExplorerUrls: firstValidBlockExplorerUrl + ? [firstValidBlockExplorerUrl] + : [], + defaultBlockExplorerUrlIndex: firstValidBlockExplorerUrl ? 0 : undefined, + chainId: chainId as Hex, + defaultRpcEndpointIndex: 0, + name: chainName, + nativeCurrency: ticker, + rpcEndpoints: [ + { + url: firstValidRPCUrl, + name: chainName, + type: RpcEndpointType.Custom, + }, + ], + }); + + return networkConfiguration.chainId; +}; diff --git a/app/scripts/lib/multichain-api/wallet-createSession/index.js b/app/scripts/lib/multichain-api/wallet-createSession/index.js new file mode 100644 index 000000000000..68ae53f6c3d8 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-createSession/index.js @@ -0,0 +1 @@ +export * from './handler'; diff --git a/app/scripts/lib/multichain-api/wallet-getPermissions.js b/app/scripts/lib/multichain-api/wallet-getPermissions.js new file mode 100644 index 000000000000..700fe2182674 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-getPermissions.js @@ -0,0 +1,85 @@ +import { MethodNames } from '@metamask/permission-controller'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; +import { PermissionNames } from '../../controllers/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { getPermittedEthChainIds } from './adapters/caip-permission-adapter-permittedChains'; + +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, + res, + _next, + end, + { getPermissionsForOrigin, getAccounts }, +) { + // permissions are frozen and must be cloned before modified + const permissions = { ...getPermissionsForOrigin() } || {}; + const caip25Endowment = permissions[Caip25EndowmentPermissionName]; + const caip25Caveat = caip25Endowment?.caveats?.find( + ({ type }) => type === Caip25CaveatType, + ); + delete permissions[Caip25EndowmentPermissionName]; + + if (caip25Caveat) { + // We cannot derive ethAccounts directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + const ethAccounts = await getAccounts(); + + if (ethAccounts.length > 0) { + permissions[RestrictedMethods.eth_accounts] = { + ...caip25Endowment, + parentCapability: RestrictedMethods.eth_accounts, + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ethAccounts, + }, + ], + }; + } + + const ethChainIds = getPermittedEthChainIds(caip25Caveat.value); + + 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/multichain-api/wallet-getPermissions.test.js b/app/scripts/lib/multichain-api/wallet-getPermissions.test.js new file mode 100644 index 000000000000..00bee72a01d1 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-getPermissions.test.js @@ -0,0 +1,304 @@ +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; +import { PermissionNames } from '../../controllers/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { getPermissionsHandler } from './wallet-getPermissions'; +import PermittedChainsAdapters from './adapters/caip-permission-adapter-permittedChains'; + +jest.mock('./adapters/caip-permission-adapter-permittedChains', () => ({ + ...jest.requireActual('./adapters/caip-permission-adapter-permittedChains'), + getPermittedEthChainIds: jest.fn(), +})); +const MockPermittedChainsAdapters = jest.mocked(PermittedChainsAdapters); + +const baseRequest = { + origin: 'http://test.com', +}; + +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': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x1', 'eip155:5:0x3'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0xdeadbeef'], + }, + }, + }, + }, + ], + }, + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ); + const getAccounts = jest + .fn() + .mockResolvedValue(['0x1', '0x2', '0x3', '0xdeadbeef']); + const response = {}; + const handler = (request) => + getPermissionsHandler.implementation(request, response, next, end, { + getPermissionsForOrigin, + getAccounts, + }); + + return { + response, + next, + end, + getPermissionsForOrigin, + getAccounts, + handler, + }; +}; + +describe('getPermissionsHandler', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + beforeEach(() => { + MockPermittedChainsAdapters.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, response } = createMockedHandler(); + getAccounts.mockResolvedValue([]); + 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).toHaveBeenCalled(); + }); + + it('returns the permissions with an eth_accounts permission if some eth accounts are permissioned', async () => { + const { handler, response } = createMockedHandler(); + + 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 } = createMockedHandler(); + await handler(baseRequest); + expect( + MockPermittedChainsAdapters.getPermittedEthChainIds, + ).toHaveBeenCalledWith({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x1', 'eip155:5:0x3'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0xdeadbeef'], + }, + }, + }); + }); + + it('returns the permissions with a permittedChains permission if some eip155 chainIds are permissioned', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts.mockResolvedValue([]); + MockPermittedChainsAdapters.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.mockResolvedValue(['0x1', '0x2', '0xdeadbeef']); + MockPermittedChainsAdapters.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/multichain-api/wallet-getSession.js b/app/scripts/lib/multichain-api/wallet-getSession.js new file mode 100644 index 000000000000..19e10a31ee9b --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-getSession.js @@ -0,0 +1,37 @@ +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { mergeScopes } from './scope'; + +export async function walletGetSessionHandler( + request, + response, + _next, + end, + hooks, +) { + let caveat; + try { + caveat = hooks.getCaveat( + request.origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (e) { + // noop + } + + if (!caveat) { + response.result = { sessionScopes: {} }; + return end(); + } + + response.result = { + sessionScopes: mergeScopes( + caveat.value.requiredScopes, + caveat.value.optionalScopes, + ), + }; + return end(); +} diff --git a/app/scripts/lib/multichain-api/wallet-getSession.test.js b/app/scripts/lib/multichain-api/wallet-getSession.test.js new file mode 100644 index 000000000000..f749fb9940e0 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-getSession.test.js @@ -0,0 +1,99 @@ +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { walletGetSessionHandler } from './wallet-getSession'; + +const baseRequest = { + origin: 'http://test.com', + params: {}, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveat = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['net_version'], + notifications: ['chainChanged'], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + }, + }, + }, + }); + const response = {}; + const handler = (request) => + walletGetSessionHandler(request, response, next, end, { + getCaveat, + }); + + return { + next, + response, + end, + getCaveat, + handler, + }; +}; + +describe('wallet_getSession', () => { + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const { handler, getCaveat } = createMockedHandler(); + + await handler(baseRequest); + expect(getCaveat).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('returns empty scopes if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, getCaveat } = createMockedHandler(); + getCaveat.mockImplementation(() => { + throw new Error('permission not found'); + }); + + await handler(baseRequest); + expect(response.result).toStrictEqual({ + sessionScopes: {}, + }); + }); + + it('returns the merged scopes', async () => { + const { handler, response } = createMockedHandler(); + + await handler(baseRequest); + expect(response.result).toStrictEqual({ + sessionScopes: { + 'eip155:1': { + methods: ['eth_call', 'net_version'], + notifications: ['chainChanged'], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + }, + }, + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/wallet-invokeMethod.js b/app/scripts/lib/multichain-api/wallet-invokeMethod.js new file mode 100644 index 000000000000..14b204372643 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-invokeMethod.js @@ -0,0 +1,78 @@ +import { numberToHex } from '@metamask/utils'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { mergeScopes, parseScopeString } from './scope'; + +export async function walletInvokeMethodHandler( + request, + _response, + next, + end, + hooks, +) { + const { scope, request: wrappedRequest } = request.params; + + let caveat; + try { + caveat = hooks.getCaveat( + request.origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (e) { + // noop + } + if (!caveat?.value?.isMultichainOrigin) { + return end(providerErrors.unauthorized()); + } + + const scopeObject = mergeScopes( + caveat.value.requiredScopes, + caveat.value.optionalScopes, + )[scope]; + + if (!scopeObject?.methods?.includes(wrappedRequest.method)) { + return end(providerErrors.unauthorized()); + } + + const { namespace, reference } = parseScopeString(scope); + + let networkClientId; + switch (namespace) { + case 'wallet': + networkClientId = hooks.getSelectedNetworkClientId(); + break; + case 'eip155': + if (reference) { + networkClientId = hooks.findNetworkClientIdByChainId( + numberToHex(parseInt(reference, 10)), + ); + } + break; + default: + console.error( + 'failed to resolve namespace for wallet_invokeMethod', + request, + ); + return end(rpcErrors.internal()); + } + + if (!networkClientId) { + console.error( + 'failed to resolve network client for wallet_invokeMethod', + request, + ); + return end(rpcErrors.internal()); + } + + Object.assign(request, { + scope, + networkClientId, + method: wrappedRequest.method, + params: wrappedRequest.params, + }); + return next(); +} diff --git a/app/scripts/lib/multichain-api/wallet-invokeMethod.test.js b/app/scripts/lib/multichain-api/wallet-invokeMethod.test.js new file mode 100644 index 000000000000..dcf0d5f4ac87 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-invokeMethod.test.js @@ -0,0 +1,262 @@ +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { walletInvokeMethodHandler } from './wallet-invokeMethod'; + +const createMockedRequest = () => ({ + origin: 'http://test.com', + params: { + scope: 'eip155:1', + request: { + method: 'eth_call', + params: { + foo: 'bar', + }, + }, + }, +}); + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveat = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['net_version'], + notifications: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + }, + unhandled: { + methods: ['foobar'], + notifications: [], + }, + }, + isMultichainOrigin: true, + }, + }); + const findNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); + const getSelectedNetworkClientId = jest + .fn() + .mockReturnValue('selectedNetworkClientId'); + const handler = (request) => + walletInvokeMethodHandler(request, {}, next, end, { + getCaveat, + findNetworkClientIdByChainId, + getSelectedNetworkClientId, + }); + + return { + next, + end, + getCaveat, + findNetworkClientIdByChainId, + getSelectedNetworkClientId, + handler, + }; +}; + +describe('wallet_invokeMethod', () => { + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const request = createMockedRequest(); + const { handler, getCaveat } = createMockedHandler(); + await handler(request); + expect(getCaveat).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('throws an unauthorized error when there is no CAIP-25 endowment permission', async () => { + const request = createMockedRequest(); + const { handler, getCaveat, end } = createMockedHandler(); + getCaveat.mockImplementation(() => { + throw new Error('permission not found'); + }); + await handler(request); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error when the CAIP-25 endowment permission was not granted from the multichain flow', async () => { + const request = createMockedRequest(); + const { handler, getCaveat, end } = createMockedHandler(); + getCaveat.mockReturnValue({ + value: { + isMultichainOrigin: false, + }, + }); + await handler(request); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error if the requested scope is not authorized', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'eip155:999', + }, + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an unauthorized error if the requested scope method is not authorized', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + request: { + ...request.params.request, + method: 'unauthorized_method', + }, + }, + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('throws an internal error for authorized but unhandled scopes', async () => { + const request = createMockedRequest(); + const { handler, end } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'unhandled', + request: { + ...request.params.request, + method: 'foobar', + }, + }, + }); + + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + describe('ethereum scope', () => { + it('gets the networkClientId for the chainId', async () => { + const request = createMockedRequest(); + const { handler, findNetworkClientIdByChainId } = createMockedHandler(); + + await handler(request); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + it('throws an internal error if a networkClientId does not exist for the chainId', async () => { + const request = createMockedRequest(); + const { handler, findNetworkClientIdByChainId, end } = + createMockedHandler(); + findNetworkClientIdByChainId.mockReturnValue(undefined); + + await handler(request); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('sets the networkClientId and unwraps the CAIP-27 request', async () => { + const request = createMockedRequest(); + const { handler, next } = createMockedHandler(); + + await handler(request); + expect(request).toStrictEqual({ + scope: 'eip155:1', + origin: 'http://test.com', + networkClientId: 'mainnet', + method: 'eth_call', + params: { + foo: 'bar', + }, + }); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('wallet scope', () => { + it('gets the networkClientId for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId } = createMockedHandler(); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(getSelectedNetworkClientId).toHaveBeenCalled(); + }); + + it('throws an internal error if a networkClientId cannot be retrieved for the globally selected network', async () => { + const request = createMockedRequest(); + const { handler, getSelectedNetworkClientId, end } = + createMockedHandler(); + getSelectedNetworkClientId.mockReturnValue(undefined); + + await handler({ + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('sets the networkClientId and unwraps the CAIP-27 request', async () => { + const request = createMockedRequest(); + const { handler, next } = createMockedHandler(); + + const walletRequest = { + ...request, + params: { + ...request.params, + scope: 'wallet', + request: { + ...request.params.request, + method: 'wallet_watchAsset', + }, + }, + }; + await handler(walletRequest); + expect(walletRequest).toStrictEqual({ + scope: 'wallet', + origin: 'http://test.com', + networkClientId: 'selectedNetworkClientId', + method: 'wallet_watchAsset', + params: { + foo: 'bar', + }, + }); + expect(next).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/wallet-requestPermissions.js b/app/scripts/lib/multichain-api/wallet-requestPermissions.js new file mode 100644 index 000000000000..e19a4349075f --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-requestPermissions.js @@ -0,0 +1,205 @@ +import { pick } from 'lodash'; +import { isPlainObject } from '@metamask/controller-utils'; +import { invalidParams, MethodNames } from '@metamask/permission-controller'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; +import { PermissionNames } from '../../controllers/permissions'; +// eslint-disable-next-line import/no-restricted-paths +import { isSnapId } from '../../../../ui/helpers/utils/snaps'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { setEthAccounts } from './adapters/caip-permission-adapter-eth-accounts'; +import { setPermittedEthChainIds } from './adapters/caip-permission-adapter-permittedChains'; + +export const requestPermissionsHandler = { + methodNames: [MethodNames.requestPermissions], + implementation: requestPermissionsImplementation, + hookNames: { + requestPermissionsForOrigin: true, + getPermissionsForOrigin: true, + updateCaveat: true, + grantPermissions: true, + requestPermissionApprovalForOrigin: true, + getAccounts: true, + }, +}; + +/** + * 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.requestPermissionsForOrigin - The specific method hook needed for this method implementation + * @param options.getPermissionsForOrigin + * @param options.updateCaveat + * @param options.grantPermissions + * @param options.requestPermissionApprovalForOrigin + * @param options.getAccounts + * @returns A promise that resolves to nothing + */ +async function requestPermissionsImplementation( + req, + res, + _next, + end, + { + requestPermissionsForOrigin, + getPermissionsForOrigin, + updateCaveat, + grantPermissions, + requestPermissionApprovalForOrigin, + getAccounts, + }, +) { + const { origin, params } = req; + + if (!Array.isArray(params) || !isPlainObject(params[0])) { + return end(invalidParams({ data: { request: req } })); + } + + const [requestedPermissions] = params; + delete requestedPermissions[Caip25EndowmentPermissionName]; + + const legacyRequestedPermissions = pick(requestedPermissions, [ + RestrictedMethods.eth_accounts, + PermissionNames.permittedChains, + ]); + delete requestedPermissions[RestrictedMethods.eth_accounts]; + delete requestedPermissions[PermissionNames.permittedChains]; + + // We manually handle eth_accounts and permittedChains permissions + // by calling the ApprovalController rather than the PermissionController + // because these two permissions do not actually exist in the Permssion + // Specifications. Calling the PermissionController with them will + // cause an error to be thrown. Instead, we will use the approval result + // from the ApprovalController to form a CAIP-25 permission later. + let legacyApproval; + const haveLegacyPermissions = + Object.keys(legacyRequestedPermissions).length > 0; + if (haveLegacyPermissions) { + if (!legacyRequestedPermissions[RestrictedMethods.eth_accounts]) { + legacyRequestedPermissions[RestrictedMethods.eth_accounts] = {}; + } + + if (!legacyRequestedPermissions[PermissionNames.permittedChains]) { + legacyRequestedPermissions[PermissionNames.permittedChains] = {}; + } + + if (isSnapId(origin)) { + delete legacyRequestedPermissions[PermissionNames.permittedChains]; + } + + legacyApproval = await requestPermissionApprovalForOrigin( + legacyRequestedPermissions, + ); + } + + let grantedPermissions = {}; + // Request permissions from the PermissionController for any permissions other + // than eth_accounts and permittedChains in the params. If no permissions + // are in the params, then request empty permissions from the PermissionController + // to get an appropriate error to be returned to the dapp. + if ( + (Object.keys(requestedPermissions).length === 0 && + !haveLegacyPermissions) || + Object.keys(requestedPermissions).length > 0 + ) { + const [_grantedPermissions] = await requestPermissionsForOrigin( + requestedPermissions, + ); + // permissions are frozen and must be cloned before modified + grantedPermissions = { ..._grantedPermissions }; + } + + if (legacyApproval) { + // NOTE: the eth_accounts/permittedChains approvals will be combined in the future. + // We assume that approvedAccounts and permittedChains are both defined here. + // Until they are actually combined, when testing, you must request both + // eth_accounts and permittedChains together. + let caveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + if (!isSnapId(origin)) { + caveatValue = setPermittedEthChainIds( + caveatValue, + legacyApproval.approvedChainIds, + ); + } + + caveatValue = setEthAccounts(caveatValue, legacyApproval.approvedAccounts); + + const permissions = getPermissionsForOrigin(origin) || {}; + let caip25Endowment = permissions[Caip25EndowmentPermissionName]; + const existingCaveat = caip25Endowment?.caveats?.find( + ({ type }) => type === Caip25CaveatType, + ); + if (existingCaveat) { + if (existingCaveat.value.isMultichainOrigin) { + return end( + new Error('cannot modify permission granted from multichain flow'), + ); // TODO: better error + } + + updateCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + caveatValue, + ); + } else { + caip25Endowment = grantPermissions({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValue, + }, + ], + }, + }, + })[Caip25EndowmentPermissionName]; + } + + // We cannot derive ethAccounts directly from the CAIP-25 permission + // because the accounts will not be in order of lastSelected + const ethAccounts = await getAccounts(); + + grantedPermissions[RestrictedMethods.eth_accounts] = { + ...caip25Endowment, + parentCapability: RestrictedMethods.eth_accounts, + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ethAccounts, + }, + ], + }; + + if (!isSnapId(origin)) { + grantedPermissions[PermissionNames.permittedChains] = { + ...caip25Endowment, + parentCapability: PermissionNames.permittedChains, + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: legacyApproval.approvedChainIds, + }, + ], + }; + } + } + + res.result = Object.values(grantedPermissions); + return end(); +} diff --git a/app/scripts/lib/multichain-api/wallet-requestPermissions.test.js b/app/scripts/lib/multichain-api/wallet-requestPermissions.test.js new file mode 100644 index 000000000000..76160fd5b10e --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-requestPermissions.test.js @@ -0,0 +1,729 @@ +import { invalidParams } from '@metamask/permission-controller'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; +import { PermissionNames } from '../../controllers/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { requestPermissionsHandler } from './wallet-requestPermissions'; +import PermittedChainsAdapters from './adapters/caip-permission-adapter-permittedChains'; +import EthAccountsAdapters from './adapters/caip-permission-adapter-eth-accounts'; + +jest.mock('./adapters/caip-permission-adapter-permittedChains', () => ({ + ...jest.requireActual('./adapters/caip-permission-adapter-permittedChains'), + setPermittedEthChainIds: jest.fn(), +})); +const MockPermittedChainsAdapters = jest.mocked(PermittedChainsAdapters); + +jest.mock('./adapters/caip-permission-adapter-eth-accounts', () => ({ + ...jest.requireActual('./adapters/caip-permission-adapter-eth-accounts'), + setEthAccounts: jest.fn(), +})); +const MockEthAccountsAdapters = jest.mocked(EthAccountsAdapters); + +const getBaseRequest = () => ({ + networkClientId: 'mainnet', + origin: 'http://test.com', + params: [ + { + eth_accounts: {}, + [Caip25EndowmentPermissionName]: {}, + otherPermission: {}, + }, + ], +}); + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const requestPermissionsForOrigin = jest.fn().mockResolvedValue([ + Object.freeze({ + otherPermission: { + id: '2', + parentCapability: 'otherPermission', + caveats: [ + { + value: { + foo: 'bar', + }, + }, + ], + }, + }), + ]); + const getPermissionsForOrigin = jest.fn().mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x1', 'eip155:5:0x3'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x4'], + }, + 'other:1': { + methods: [], + notifications: [], + accounts: ['other:1:0x4'], + }, + }, + }, + isMultichainOrigin: false, + }, + ], + }, + }), + ); + const updateCaveat = jest.fn(); + const grantPermissions = jest.fn().mockReturnValue( + Object.freeze({ + [Caip25EndowmentPermissionName]: { + id: 'new', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + }, + }, + ], + }, + }), + ); + const requestPermissionApprovalForOrigin = jest.fn().mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }); + const getAccounts = jest.fn().mockResolvedValue([]); + const response = {}; + const handler = (request) => + requestPermissionsHandler.implementation(request, response, next, end, { + requestPermissionsForOrigin, + getPermissionsForOrigin, + updateCaveat, + grantPermissions, + requestPermissionApprovalForOrigin, + getAccounts, + request, + }); + + return { + response, + next, + end, + requestPermissionsForOrigin, + getPermissionsForOrigin, + updateCaveat, + grantPermissions, + requestPermissionApprovalForOrigin, + getAccounts, + handler, + }; +}; + +describe('requestPermissionsHandler', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + beforeEach(() => { + MockEthAccountsAdapters.setEthAccounts.mockImplementation( + (caveatValue) => caveatValue, + ); + MockPermittedChainsAdapters.setPermittedEthChainIds.mockImplementation( + (caveatValue) => caveatValue, + ); + }); + + 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 } }), + ); + }); + + 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 () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }, + ], + }); + + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: {}, + }); + }); + + it('requests approval from the ApprovalController for eth_accounts and permittedChains when only permittedChains is specified in params and origin is not snapId', async () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }); + + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: {}, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + + it('requests approval from the ApprovalController for eth_accounts and permittedChains when both are specified in params and origin is not snapId', async () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }); + + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + }); + + it('requests approval from the ApprovalController for only eth_accounts when only eth_accounts is specified in params and origin is snapId', async () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler({ + ...getBaseRequest(), + origin: 'npm:snap', + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }, + ], + }); + + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }); + }); + + it('requests approval from the ApprovalController for only eth_accounts when only permittedChains is specified in params and origin is snapId', async () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler({ + ...getBaseRequest(), + origin: 'npm:snap', + params: [ + { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }); + + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: {}, + }); + }); + + 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 () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler({ + ...getBaseRequest(), + origin: 'npm:snap', + params: [ + { + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }); + + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: { + foo: 'bar', + }, + }); + }); + + it('requests other permissions in params from the PermissionController, but ignores CAIP-25 if specified', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [Caip25EndowmentPermissionName]: {}, + otherPermission: {}, + }, + ], + }); + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ + otherPermission: {}, + }); + }); + + it('requests other permissions in params from the PermissionController, but ignores eth_accounts if specified', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [RestrictedMethods.eth_accounts]: {}, + otherPermission: {}, + }, + ], + }); + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ + otherPermission: {}, + }); + }); + + it('requests other permissions in params from the PermissionController, but ignores permittedChains if specified', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [PermissionNames.permittedChains]: {}, + otherPermission: {}, + }, + ], + }); + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({ + otherPermission: {}, + }); + }); + + it('does not request permissions from the PermissionController when only eth_accounts is provided in params', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [RestrictedMethods.eth_accounts]: {}, + }, + ], + }); + expect(requestPermissionsForOrigin).not.toHaveBeenCalled(); + }); + + it('does not request permissions from the PermissionController when only permittedChains is provided in params', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [PermissionNames.permittedChains]: {}, + }, + ], + }); + expect(requestPermissionsForOrigin).not.toHaveBeenCalled(); + }); + + it('does not request permissions from the PermissionController when both eth_accounts and permittedChains are provided in params', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [RestrictedMethods.eth_accounts]: {}, + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }, + ], + }); + expect(requestPermissionsForOrigin).not.toHaveBeenCalled(); + }); + + it('requests empty permissions from the PermissionController when only CAIP-25 permission is provided in params', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + [Caip25EndowmentPermissionName]: {}, + }, + ], + }); + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({}); + }); + + it('requests empty permissions from the PermissionController when no permissions are provided in params', async () => { + const { handler, requestPermissionsForOrigin } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [{}], + }); + expect(requestPermissionsForOrigin).toHaveBeenCalledWith({}); + }); + + it('does not update or grant a CAIP-25 endowment permission if eth_accounts and permittedChains approvals were not requested', async () => { + const { handler, updateCaveat, grantPermissions, getPermissionsForOrigin } = + createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + otherPermission: {}, + }, + ], + }); + expect(getPermissionsForOrigin).not.toHaveBeenCalled(); + expect(updateCaveat).not.toHaveBeenCalled(); + expect(grantPermissions).not.toHaveBeenCalled(); + }); + + it('returns the granted permissions if eth_accounts and permittedChains approvals were not requested', async () => { + const { handler, response } = createMockedHandler(); + + await handler({ + ...getBaseRequest(), + params: [ + { + otherPermission: {}, + }, + ], + }); + expect(response.result).toStrictEqual([ + { + caveats: [{ value: { foo: 'bar' } }], + id: '2', + parentCapability: 'otherPermission', + }, + ]); + }); + + it('does not update or grant a CAIP-25 endowment type permission if eth_accounts and permittedChains approvals were denied', async () => { + const { + handler, + updateCaveat, + grantPermissions, + getPermissionsForOrigin, + requestPermissionApprovalForOrigin, + } = createMockedHandler(); + requestPermissionApprovalForOrigin.mockRejectedValue( + new Error('user denied approval'), + ); + + try { + await handler({ + ...getBaseRequest(), + params: [ + { + [RestrictedMethods.eth_accounts]: {}, + }, + ], + }); + } catch (err) { + // noop + } + expect(getPermissionsForOrigin).not.toHaveBeenCalled(); + expect(updateCaveat).not.toHaveBeenCalled(); + expect(grantPermissions).not.toHaveBeenCalled(); + }); + + describe('eth_accounts and permittedChains approvals were accepted', () => { + it('sets the approved chainIds on an empty CAIP-25 caveat with isMultichainOrigin: false if origin is not snapId', async () => { + const { handler } = createMockedHandler(); + + await handler(getBaseRequest()); + expect( + MockPermittedChainsAdapters.setPermittedEthChainIds, + ).toHaveBeenCalledWith( + { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + ['0x1', '0x5'], + ); + }); + + it('sets the approved accounts on the CAIP-25 caveat after the approved chainIds if origin is not snapId', async () => { + const { handler } = createMockedHandler(); + MockPermittedChainsAdapters.setPermittedEthChainIds.mockReturnValue( + 'caveatValueWithEthChainIdsSet', + ); + + await handler(getBaseRequest()); + expect(MockEthAccountsAdapters.setEthAccounts).toHaveBeenCalledWith( + 'caveatValueWithEthChainIdsSet', + ['0xdeadbeef'], + ); + }); + + it('does not set the approved chainIds on an empty CAIP-25 caveat if origin is snapId', async () => { + const { handler } = createMockedHandler(); + + await handler({ ...getBaseRequest(), origin: 'npm:snapm' }); + expect( + MockPermittedChainsAdapters.setPermittedEthChainIds, + ).not.toHaveBeenCalled(); + }); + + it('sets the approved accounts on an empty CAIP-25 caveat with isMultichainOrigin: false if origin is snapId', async () => { + const { handler } = createMockedHandler(); + + await handler({ ...getBaseRequest(), origin: 'npm:snapm' }); + expect(MockEthAccountsAdapters.setEthAccounts).toHaveBeenCalledWith( + { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + ['0xdeadbeef'], + ); + }); + + it('gets permission for the origin', async () => { + const { handler, getPermissionsForOrigin } = createMockedHandler(); + + await handler(getBaseRequest()); + expect(getPermissionsForOrigin).toHaveBeenCalledWith('http://test.com'); + }); + + it('throws an error when a CAIP-25 already exists that was granted from the multichain flow (isMultichainOrigin: true)', async () => { + const { handler, getPermissionsForOrigin, end } = createMockedHandler(); + getPermissionsForOrigin.mockReturnValue({ + [Caip25EndowmentPermissionName]: { + id: '1', + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }, + ], + }, + }); + + await handler(getBaseRequest()); + expect(end).toHaveBeenCalledWith( + new Error('cannot modify permission granted from multichain flow'), + ); + }); + + it('updates the caveat when a CAIP-25 already exists that was granted from the legacy flow (isMultichainOrigin: false)', async () => { + const { handler, updateCaveat } = createMockedHandler(); + MockEthAccountsAdapters.setEthAccounts.mockReturnValue( + 'updatedCaveatValue', + ); + + await handler(getBaseRequest()); + expect(updateCaveat).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + 'updatedCaveatValue', + ); + }); + + it('grants a CAIP-25 permission if one does not already exist', async () => { + const { handler, getPermissionsForOrigin, grantPermissions } = + createMockedHandler(); + getPermissionsForOrigin.mockReturnValue({}); + MockEthAccountsAdapters.setEthAccounts.mockReturnValue( + 'updatedCaveatValue', + ); + + await handler(getBaseRequest()); + expect(grantPermissions).toHaveBeenCalledWith({ + subject: { + origin: 'http://test.com', + }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: 'updatedCaveatValue', + }, + ], + }, + }, + }); + }); + + it('gets the ordered eth accounts', async () => { + const { handler, getAccounts } = createMockedHandler(); + + await handler(getBaseRequest()); + expect(getAccounts).toHaveBeenCalled(); + }); + + it('returns both eth_accounts and permittedChains permissions in addition to other permissions that were granted if origin is not snapId', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts.mockResolvedValue(['0xdeadbeef']); + + await handler(getBaseRequest()); + expect(response.result).toStrictEqual([ + { + caveats: [{ value: { foo: 'bar' } }], + id: '2', + parentCapability: 'otherPermission', + }, + { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0xdeadbeef'], + }, + ], + id: '1', + parentCapability: RestrictedMethods.eth_accounts, + }, + { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x5'], + }, + ], + id: '1', + parentCapability: PermissionNames.permittedChains, + }, + ]); + }); + + it('returns only eth_accounts permissions in addition to other permissions that were granted if origin is snapId', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts.mockResolvedValue(['0xdeadbeef']); + + await handler({ ...getBaseRequest(), origin: 'npm:snap' }); + expect(response.result).toStrictEqual([ + { + caveats: [{ value: { foo: 'bar' } }], + id: '2', + parentCapability: 'otherPermission', + }, + { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0xdeadbeef'], + }, + ], + id: '1', + parentCapability: RestrictedMethods.eth_accounts, + }, + ]); + }); + }); +}); diff --git a/app/scripts/lib/multichain-api/wallet-revokePermissions.js b/app/scripts/lib/multichain-api/wallet-revokePermissions.js new file mode 100644 index 000000000000..97eda7216d06 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-revokePermissions.js @@ -0,0 +1,88 @@ +import { invalidParams, MethodNames } from '@metamask/permission-controller'; +import { isNonEmptyArray } from '@metamask/utils'; +import { RestrictedMethods } from '../../../../shared/constants/permissions'; +import { PermissionNames } from '../../controllers/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; + +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, + res, + _next, + end, + { revokePermissionsForOrigin, getPermissionsForOrigin }, +) { + const { params, origin } = 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) => + ![ + RestrictedMethods.eth_accounts, + PermissionNames.permittedChains, + ].includes(name), + ); + + const shouldRevokeLegacyPermission = + relevantPermissionKeys.length !== permissionKeys.length; + + if (shouldRevokeLegacyPermission) { + const permissions = getPermissionsForOrigin(origin) || {}; + const caip25Endowment = permissions?.[Caip25EndowmentPermissionName]; + const caip25Caveat = caip25Endowment?.caveats?.find( + ({ type }) => type === Caip25CaveatType, + ); + + if (caip25Caveat && caip25Caveat.value.isMultichainOrigin) { + return end( + new Error('cannot modify permission granted from multichain flow'), + ); // TODO: better error + } + relevantPermissionKeys.push(Caip25EndowmentPermissionName); + } + + revokePermissionsForOrigin(relevantPermissionKeys); + + res.result = null; + + return end(); +} diff --git a/app/scripts/lib/multichain-api/wallet-revokePermissions.test.js b/app/scripts/lib/multichain-api/wallet-revokePermissions.test.js new file mode 100644 index 000000000000..1c1235d83900 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-revokePermissions.test.js @@ -0,0 +1,204 @@ +import { invalidParams } from '@metamask/permission-controller'; +import { PermissionNames } from '../../controllers/permissions'; +import { RestrictedMethods } from '../../../../shared/constants/permissions'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './caip25permissions'; +import { revokePermissionsHandler } from './wallet-revokePermissions'; + +const baseRequest = { + origin: 'http://test.com', + 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 = {}; + const handler = (request) => + 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 the CAIP-25 permission is specified', () => { + const { handler, end } = createMockedHandler(); + + const emptyRequest = { + ...baseRequest, + params: [ + { + [Caip25EndowmentPermissionName]: {}, + }, + ], + }; + handler(emptyRequest); + expect(end).toHaveBeenCalledWith( + invalidParams({ data: { request: emptyRequest } }), + ); + }); + + describe.each([ + [RestrictedMethods.eth_accounts], + [PermissionNames.permittedChains], + ])('%s permission is specified', (permission) => { + 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 from multichain flow'), + ); + }); + }); + + it('revokes permissions other than eth_accounts, permittedChains, CAIP-25 if specified', () => { + const { handler, revokePermissionsForOrigin } = createMockedHandler(); + + handler(baseRequest); + expect(revokePermissionsForOrigin).toHaveBeenCalledWith([ + 'otherPermission', + ]); + }); + + it('returns null', () => { + const { handler, response } = createMockedHandler(); + + handler(baseRequest); + expect(response.result).toStrictEqual(null); + }); +}); diff --git a/app/scripts/lib/multichain-api/wallet-revokeSession.js b/app/scripts/lib/multichain-api/wallet-revokeSession.js new file mode 100644 index 000000000000..6771e72b18f8 --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-revokeSession.js @@ -0,0 +1,29 @@ +import { + PermissionDoesNotExistError, + UnrecognizedSubjectError, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { Caip25EndowmentPermissionName } from './caip25permissions'; + +export async function walletRevokeSessionHandler( + request, + response, + _next, + end, + hooks, +) { + try { + hooks.revokePermission(request.origin, Caip25EndowmentPermissionName); + } catch (err) { + if ( + !(err instanceof UnrecognizedSubjectError) && + !(err instanceof PermissionDoesNotExistError) + ) { + console.error(err); + return end(rpcErrors.internal()); + } + } + + response.result = true; + return end(); +} diff --git a/app/scripts/lib/multichain-api/wallet-revokeSession.test.js b/app/scripts/lib/multichain-api/wallet-revokeSession.test.js new file mode 100644 index 000000000000..11d9b751967f --- /dev/null +++ b/app/scripts/lib/multichain-api/wallet-revokeSession.test.js @@ -0,0 +1,80 @@ +import { + PermissionDoesNotExistError, + UnrecognizedSubjectError, +} from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { Caip25EndowmentPermissionName } from './caip25permissions'; +import { walletRevokeSessionHandler } from './wallet-revokeSession'; + +const baseRequest = { + origin: 'http://test.com', + params: {}, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const revokePermission = jest.fn(); + const response = {}; + const handler = (request) => + walletRevokeSessionHandler(request, response, next, end, { + revokePermission, + }); + + return { + next, + response, + end, + revokePermission, + handler, + }; +}; + +describe('wallet_revokeSession', () => { + it('revokes the the CAIP-25 endowment permission', async () => { + const { handler, revokePermission } = createMockedHandler(); + + await handler(baseRequest); + expect(revokePermission).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + ); + }); + + it('returns true if the CAIP-25 endowment permission does not exist', async () => { + const { handler, response, revokePermission } = createMockedHandler(); + revokePermission.mockImplementation(() => { + throw new PermissionDoesNotExistError(); + }); + + await handler(baseRequest); + expect(response.result).toStrictEqual(true); + }); + + it('returns true if the subject does not exist', async () => { + const { handler, response, revokePermission } = createMockedHandler(); + revokePermission.mockImplementation(() => { + throw new UnrecognizedSubjectError(); + }); + + await handler(baseRequest); + expect(response.result).toStrictEqual(true); + }); + + it('throws an internal RPC error if something unexpected goes wrong with revoking the permission', async () => { + const { handler, revokePermission, end } = createMockedHandler(); + revokePermission.mockImplementation(() => { + throw new Error('revoke failed'); + }); + + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(rpcErrors.internal()); + }); + + it('returns true if the permission was revoked', async () => { + const { handler, response } = createMockedHandler(); + + await handler(baseRequest); + expect(response.result).toStrictEqual(true); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js index e4b436163fc6..ed8abbb5d4c5 100644 --- a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js +++ b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js @@ -1,19 +1,35 @@ -import { permissionRpcMethods } from '@metamask/permission-controller'; import { selectHooks } from '@metamask/snaps-rpc-methods'; import { hasProperty } from '@metamask/utils'; import { ethErrors } from 'eth-rpc-errors'; -import { handlers as localHandlers, legacyHandlers } from './handlers'; -const allHandlers = [...localHandlers, ...permissionRpcMethods.handlers]; +import { getPermissionsHandler } from '../multichain-api/wallet-getPermissions'; +import { requestPermissionsHandler } from '../multichain-api/wallet-requestPermissions'; +import { revokePermissionsHandler } from '../multichain-api/wallet-revokePermissions'; +import { + handlers as localHandlers, + eip1193OnlyHandlers, + ethAccountsHandler, +} from './handlers'; -// 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, + 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 on the in the EIP-1193 JSON-RPC middleware pipeline. +export const createEthAccountsMethodMiddleware = makeMethodMiddlewareMaker([ + ethAccountsHandler, +]); + +// The primary home of RPC method implementations for the MultiChain API. +export const createMultichainMethodMiddleware = + makeMethodMiddlewareMaker(localHandlers); /** * 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 46aba9abe746..fdcff0b459a6 100644 --- a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js +++ b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js @@ -3,49 +3,65 @@ import { assertIsJsonRpcFailure, assertIsJsonRpcSuccess, } from '@metamask/utils'; -import { createMethodMiddleware, createLegacyMethodMiddleware } from '.'; +import { + createEip1193MethodMiddleware, + createEthAccountsMethodMiddleware, + createMultichainMethodMiddleware, +} 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('../multichain-api/wallet-getPermissions', () => ({ + getPermissionsHandler: getHandler(), +})); - return { - handlers: [getHandler()], - legacyHandlers: [getHandler()], - }; -}); +jest.mock('../multichain-api/wallet-requestPermissions', () => ({ + requestPermissionsHandler: getHandler(), +})); + +jest.mock('../multichain-api/wallet-revokePermissions', () => ({ + revokePermissionsHandler: getHandler(), +})); + +jest.mock('./handlers', () => ({ + handlers: [getHandler()], + eip1193OnlyHandlers: [getHandler()], + ethAccountsHandler: getHandler(), +})); describe.each([ - ['createMethodMiddleware', createMethodMiddleware], - ['createLegacyMethodMiddleware', createLegacyMethodMiddleware], + ['createEip1193MethodMiddleware', createEip1193MethodMiddleware], + ['createEthAccountsMethodMiddleware', createEthAccountsMethodMiddleware], + ['createMultichainMethodMiddleware', createMultichainMethodMiddleware], ])('%s', (_name, createMiddleware) => { const method1 = 'method1'; diff --git a/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.test.ts b/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.test.ts index e50c86d13268..418d22c1821d 100644 --- a/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.test.ts +++ b/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.test.ts @@ -10,8 +10,8 @@ describe('createUnsupportedMethodMiddleware', () => { }); const getMockResponse = () => ({ jsonrpc: jsonrpc2, id: 'foo' }); - it('forwards requests whose methods are not on the list of unsupported methods', () => { - const middleware = createUnsupportedMethodMiddleware(); + it('forwards requests whose methods are not in the list of unsupported methods', () => { + const middleware = createUnsupportedMethodMiddleware([]); const nextMock = jest.fn(); const endMock = jest.fn(); @@ -22,10 +22,12 @@ describe('createUnsupportedMethodMiddleware', () => { }); // @ts-expect-error This function is missing from the Mocha type definitions - it.each([...UNSUPPORTED_RPC_METHODS.keys()])( - 'ends requests for methods that are on the list of unsupported methods: %s', + it.each(UNSUPPORTED_RPC_METHODS)( + 'ends requests for methods that are in the list of unsupported methods: %s', (method: string) => { - const middleware = createUnsupportedMethodMiddleware(); + const middleware = createUnsupportedMethodMiddleware( + UNSUPPORTED_RPC_METHODS, + ); const nextMock = jest.fn(); const endMock = jest.fn(); diff --git a/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts b/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts index 193cc54b5a38..cebfe13c441e 100644 --- a/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts +++ b/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts @@ -1,17 +1,17 @@ import { ethErrors } from 'eth-rpc-errors'; import type { JsonRpcMiddleware } from 'json-rpc-engine'; -import { UNSUPPORTED_RPC_METHODS } from '../../../../shared/constants/network'; /** * Creates a middleware that rejects explicitly unsupported RPC methods with the * appropriate error. + * + * @param methods */ -export function createUnsupportedMethodMiddleware(): JsonRpcMiddleware< - unknown, - void -> { +export function createUnsupportedMethodMiddleware( + methods: string[], +): JsonRpcMiddleware { return async function unsupportedMethodMiddleware(req, _res, next, end) { - if ((UNSUPPORTED_RPC_METHODS as Set).has(req.method)) { + if (methods.includes(req.method)) { return end(ethErrors.rpc.methodNotSupported()); } return next(); 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 2f4727fdab36..38dad2e66592 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,9 @@ const addEthereumChain = { endApprovalFlow: true, getCurrentChainIdForDomain: true, getCaveat: true, - requestPermittedChainsPermission: true, - grantPermittedChainsPermissionIncremental: true, + requestPermissionApprovalForOrigin: true, + updateCaveat: true, + grantPermissions: true, }, }; @@ -44,13 +45,14 @@ async function addEthereumChainHandler( endApprovalFlow, getCurrentChainIdForDomain, getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + requestPermissionApprovalForOrigin, + updateCaveat, + grantPermissions, }, ) { let validParams; try { - validParams = validateAddEthereumChainParams(req.params[0], end); + validParams = validateAddEthereumChainParams(req.params[0]); } catch (error) { return end(error); } @@ -193,14 +195,23 @@ async function addEthereumChainHandler( const { networkClientId } = updatedNetwork.rpcEndpoints[updatedNetwork.defaultRpcEndpointIndex]; - return switchChain(res, end, chainId, networkClientId, approvalFlowId, { - isAddFlow: true, - setActiveNetwork, - endApprovalFlow, - getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, - }); + return switchChain( + res, + end, + origin, + chainId, + networkClientId, + approvalFlowId, + { + isAddFlow: true, + setActiveNetwork, + getCaveat, + requestPermissionApprovalForOrigin, + updateCaveat, + endApprovalFlow, + grantPermissions, + }, + ); } 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 945953cff562..9a0249953ec4 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,12 @@ import { ethErrors } from 'eth-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'), + switchChain: jest.fn(), +})); const NON_INFURA_CHAIN_ID = '0x123456789'; @@ -52,611 +58,236 @@ 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), + requestPermissionApprovalForOrigin: jest.fn(), + 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 }], + }), + updateCaveat: jest.fn(), + grantPermissions: jest.fn(), }; + const response = {}; + const handler = (request) => + addEthereumChain.implementation(request, response, next, end, mocks); + + return { + mocks, + response, + next, + end, + handler, + }; +}; +describe('addEthereumChainHandler', () => { 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( - { - 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'], - }, - ], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); + 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(); - 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', + 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, }, - ], - }); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith(123); + blockExplorerUrls: nonInfuraConfiguration.blockExplorerUrls, + }, + ], }); - it('creates a new networkConfiguration when called without "blockExplorerUrls" property', async () => { - const mocks = makeMocks(); - await addEthereumChainHandler( - { + expect(mocks.addNetwork).toHaveBeenCalledWith(nonInfuraConfiguration); + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + 'example.com', + NON_INFURA_CHAIN_ID, + 123, + 'approvalFlowId', + { + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + requestPermissionApprovalForOrigin: + mocks.requestPermissionApprovalForOrigin, + setActiveNetwork: mocks.setActiveNetwork, + updateCaveat: mocks.updateCaveat, + grantPermissions: mocks.grantPermissions, + }, + ); + }); + + 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', async () => { + const { mocks, end, handler } = createMockedHandler(); + mocks.getCurrentChainIdForDomain.mockReturnValue(CHAIN_IDS.SEPOLIA); + + 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'], - }, - ], - }, - {}, - 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', - { - chainId: '0x1', - name: 'Ethereum Mainnet', - // Expect both endpoints - rpcEndpoints: [ - { - networkClientId: 'mainnet', - url: 'https://mainnet.infura.io/v3/', - type: 'infura', - }, - { - name: 'Ethereum Mainnet', - url: 'https://eth.llamarpc.com', - type: 'custom', - }, - ], - // 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', + blockExplorerUrls: ['https://etherscan.io'], }, ], - // 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, + 'example.com', '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'], - }, - ], + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + requestPermissionApprovalForOrigin: + mocks.requestPermissionApprovalForOrigin, + setActiveNetwork: mocks.setActiveNetwork, + updateCaveat: mocks.updateCaveat, + grantPermissions: mocks.grantPermissions, }, - {}, - 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( + it('should 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: [ { - origin: 'example.com', - params: [{ chainId: 'invalid_chain_id' }], + chainId: createMockOptimismConfiguration().chainId, + chainName: createMockOptimismConfiguration().name, + rpcUrls: createMockOptimismConfiguration().rpcEndpoints.map( + (rpc) => rpc.url, + ), + nativeCurrency: { + symbol: createMockOptimismConfiguration().nativeCurrency, + decimals: 18, + }, + blockExplorerUrls: + createMockOptimismConfiguration().blockExplorerUrls, }, - {}, - jest.fn(), - mockEnd, - mocks, - ); - - expect(mockEnd).toHaveBeenCalledWith( - ethErrors.rpc.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( + expect(EthChainUtils.switchChain).toHaveBeenCalledTimes(1); + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + 'example.com', + '0xa', + createMockOptimismConfiguration().rpcEndpoints[0].networkClientId, + undefined, { - origin: 'example.com', - params: [ - { - chainId: nonInfuraConfiguration.chainId, - chainName: nonInfuraConfiguration.name, - rpcUrls: nonInfuraConfiguration.rpcEndpoints.map( - (rpc) => rpc.url, - ), - nativeCurrency: { - symbol: nonInfuraConfiguration.nativeCurrency, - decimals: 18, - }, - blockExplorerUrls: nonInfuraConfiguration.blockExplorerUrls, - }, - ], + isAddFlow: true, + endApprovalFlow: mocks.endApprovalFlow, + getCaveat: mocks.getCaveat, + requestPermissionApprovalForOrigin: + mocks.requestPermissionApprovalForOrigin, + setActiveNetwork: mocks.setActiveNetwork, + updateCaveat: mocks.updateCaveat, + grantPermissions: mocks.grantPermissions, }, - {}, - 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, - }, - ], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - - expect(mocks.requestPermittedChainsPermission).not.toHaveBeenCalled(); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockOptimismConfiguration().rpcEndpoints[0].networkClientId, - ); - }); }); }); it('should return an error if an unexpected parameter is provided', async () => { - const mocks = makeMocks(); - const mockEnd = jest.fn(); + const { end, handler } = createMockedHandler(); 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', + await handler({ + origin: 'example.com', + params: [ + { + chainId: createMockNonInfuraConfiguration().chainId, + chainName: createMockNonInfuraConfiguration().nickname, + rpcUrls: [createMockNonInfuraConfiguration().rpcUrl], + nativeCurrency: { + symbol: createMockNonInfuraConfiguration().ticker, + decimals: 18, }, - ], - }, - {}, - jest.fn(), - mockEnd, - mocks, - ); + blockExplorerUrls: [ + createMockNonInfuraConfiguration().blockExplorerUrls[0], + ], + [unexpectedParam]: 'parameter', + }, + ], + }); - expect(mockEnd).toHaveBeenCalledWith( + expect(end).toHaveBeenCalledWith( ethErrors.rpc.invalidParams({ message: `Received unexpected keys on object parameter. Unsupported keys:\n${unexpectedParam}`, }), ); }); - it('should handle errors during the switch network permission request', async () => { - const mockError = new Error('Permission request failed'); - const mocks = makeMocks({ - permissionedChainIds: [], - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.SEPOLIA), - grantPermittedChainsPermissionIncremental: jest - .fn() - .mockRejectedValue(mockError), - }, - }); - const mockEnd = jest.fn(); - - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: CHAIN_IDS.MAINNET, - chainName: 'Ethereum Mainnet', - rpcUrls: ['https://mainnet.infura.io/v3/'], - nativeCurrency: { - symbol: 'ETH', - decimals: 18, - }, - blockExplorerUrls: ['https://etherscan.io'], - }, - ], - }, - {}, - jest.fn(), - mockEnd, - mocks, - ); - - expect( - mocks.grantPermittedChainsPermissionIncremental, - ).toHaveBeenCalledTimes(1); - expect(mockEnd).toHaveBeenCalledWith(mockError); - expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); - }); - it('should return an error if nativeCurrency.symbol does not match an existing network with the same chainId', async () => { - const mocks = makeMocks({ - permissionedChainIds: [CHAIN_IDS.MAINNET], - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(createMockMainnetConfiguration()), - }, - }); - const mockEnd = jest.fn(); - - await addEthereumChainHandler( - { - origin: 'example.com', - params: [ - { - chainId: CHAIN_IDS.MAINNET, - chainName: 'Ethereum Mainnet', - rpcUrls: ['https://mainnet.infura.io/v3/'], - nativeCurrency: { - symbol: 'WRONG', - decimals: 18, - }, - blockExplorerUrls: ['https://etherscan.io'], - }, - ], - }, - {}, - jest.fn(), - mockEnd, - mocks, + const { mocks, end, handler } = createMockedHandler(); + mocks.getNetworkConfigurationByChainId.mockReturnValue( + createMockMainnetConfiguration(), ); + await handler({ + origin: 'example.com', + params: [ + { + chainId: CHAIN_IDS.MAINNET, + chainName: 'Ethereum Mainnet', + rpcUrls: ['https://mainnet.infura.io/v3/'], + nativeCurrency: { + symbol: 'WRONG', + decimals: 18, + }, + blockExplorerUrls: ['https://etherscan.io'], + }, + ], + }); - expect(mockEnd).toHaveBeenCalledWith( + expect(end).toHaveBeenCalledWith( ethErrors.rpc.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 +297,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.js b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.test.js new file mode 100644 index 000000000000..bb8ba36142b0 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/eth-accounts.test.js @@ -0,0 +1,40 @@ +import ethereumAccounts from './eth-accounts'; + +const baseRequest = { + origin: 'http://test.com', +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getAccounts = jest.fn().mockResolvedValue(['0xdead', '0xbeef']); + const response = {}; + const handler = (request) => + 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/ethereum-chain-utils.js b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js index 080fef549564..1775941968e7 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 @@ -3,13 +3,21 @@ 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'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../../multichain-api/caip25permissions'; +import { CaveatTypes } from '../../../../../shared/constants/permissions'; +import { PermissionNames } from '../../../controllers/permissions'; +import { + getPermittedEthChainIds, + addPermittedEthChainId, +} from '../../multichain-api/adapters/caip-permission-adapter-permittedChains'; export function validateChainId(chainId) { - const _chainId = typeof chainId === 'string' && chainId.toLowerCase(); + const _chainId = typeof chainId === 'string' ? chainId.toLowerCase() : ''; if (!isPrefixedFormattedHexString(_chainId)) { throw ethErrors.rpc.invalidParams({ message: `Expected 0x-prefixed, unpadded, non-zero hexadecimal string 'chainId'. Received:\n${chainId}`, @@ -25,7 +33,7 @@ export function validateChainId(chainId) { return _chainId; } -export function validateSwitchEthereumChainParams(req, end) { +export function validateSwitchEthereumChainParams(req) { if (!req.params?.[0] || typeof req.params[0] !== 'object') { throw ethErrors.rpc.invalidParams({ message: `Expected single, object parameter. Received:\n${JSON.stringify( @@ -43,10 +51,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 ethErrors.rpc.invalidParams({ message: `Expected single, object parameter. Received:\n${JSON.stringify( @@ -75,7 +83,7 @@ export function validateAddEthereumChainParams(params, end) { }); } - const _chainId = validateChainId(chainId, end); + const _chainId = validateChainId(chainId); if (!rpcUrls || !Array.isArray(rpcUrls) || rpcUrls.length === 0) { throw ethErrors.rpc.invalidParams({ message: `Expected an array with at least one valid string HTTPS url 'rpcUrls', Received:\n${rpcUrls}`, @@ -155,6 +163,7 @@ export function validateAddEthereumChainParams(params, end) { export async function switchChain( res, end, + origin, chainId, networkClientId, approvalFlowId, @@ -163,26 +172,90 @@ export async function switchChain( setActiveNetwork, endApprovalFlow, getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, + requestPermissionApprovalForOrigin, + updateCaveat, + grantPermissions, }, ) { 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)) { + if (caip25Caveat.value.isMultichainOrigin) { + return end( + new Error( + 'cannot switch to chain that was not permissioned in the multichain flow', + ), + ); // TODO: better error + } + + // TODO: This behavior may have deviated from the original permittedChains add chain behavior + // Verify that this helper behaves as expected + if (!isAddFlow) { + await requestPermissionApprovalForOrigin({ + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: [chainId], + }, + ], + }, + }); + } + + const updatedCaveatValue = addPermittedEthChainId( + caip25Caveat.value, + chainId, + ); + + updateCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + updatedCaveatValue, + ); } + } else { + if (!isAddFlow) { + await requestPermissionApprovalForOrigin({ + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: [chainId], + }, + ], + }, + }); + } + + let caveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + caveatValue = addPermittedEthChainId(caveatValue, chainId); + + grantPermissions({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValue, + }, + ], + }, + }, + }); } await setActiveNetwork(networkClientId); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.test.js b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.test.js new file mode 100644 index 000000000000..d473bf86ca1c --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.test.js @@ -0,0 +1,357 @@ +import { errorCodes } from 'eth-rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../../multichain-api/caip25permissions'; +import { CaveatTypes } from '../../../../../shared/constants/permissions'; +import { PermissionNames } from '../../../controllers/permissions'; +import { + KnownNotifications, + KnownRpcMethods, +} from '../../multichain-api/scope'; +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(), + requestPermissionApprovalForOrigin: jest.fn(), + updateCaveat: jest.fn(), + grantPermissions: jest.fn(), + }; + const response = {}; + const switchChain = (origin, chainId, networkClientId, approvalFlowId) => + EthChainUtils.switchChain( + response, + end, + origin, + chainId, + networkClientId, + approvalFlowId, + mocks, + ); + + return { + mocks, + response, + end, + switchChain, + }; + }; + + describe('switchChain', () => { + it('gets the CAIP-25 caveat', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + await switchChain('example.com', '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.requestPermissionApprovalForOrigin.mockRejectedValueOnce( + new Error('unexpected error'), + ); + + await switchChain('example.com', '0x1', 'mainnet', null); + + expect(end).toHaveBeenCalledWith(new Error('unexpected error')); + }); + + it('passes through unexpected errors if approvalFlowId is provided', async () => { + const { mocks, end, switchChain } = createMockedSwitchChain(); + mocks.requestPermissionApprovalForOrigin.mockRejectedValueOnce( + new Error('unexpected error'), + ); + + await switchChain('example.com', '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.requestPermissionApprovalForOrigin.mockRejectedValueOnce({ + code: errorCodes.provider.userRejectedRequest, + }); + + await switchChain('example.com', '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('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.endApprovalFlow).toHaveBeenCalledWith({ + id: 'approvalFlowId', + }); + }); + + describe('with no existing CAIP-25 permission', () => { + it('requests a switch chain approval', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1'], + }, + ], + }, + }); + }); + + it('grants a new CAIP-25 permission with the chain', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.grantPermissions).toHaveBeenCalledWith({ + subject: { origin: 'example.com' }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }); + }); + + it('switches to the chain', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + await switchChain('example.com', '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.requestPermissionApprovalForOrigin.mockRejectedValueOnce({ + code: errorCodes.provider.userRejectedRequest, + }); + + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.requestPermissionApprovalForOrigin).toHaveBeenCalled(); + expect(mocks.grantPermissions).not.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('skips permittedChains approval 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('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.requestPermissionApprovalForOrigin).not.toHaveBeenCalled(); + expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); + }); + + it('requests permittedChains approval 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('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.requestPermissionApprovalForOrigin).toHaveBeenCalled(); + expect(mocks.requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1'], + }, + ], + }, + }); + expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); + }); + + it('updates the CAIP-25 caveat with the chain added', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.updateCaveat).toHaveBeenCalledWith( + 'example.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + }, + isMultichainOrigin: false, + }, + ); + }); + + it('should handle errors if the permittedChains approval is rejected', async () => { + const { mocks, end, switchChain } = createMockedSwitchChain(); + mocks.requestPermissionApprovalForOrigin.mockRejectedValueOnce({ + code: errorCodes.provider.userRejectedRequest, + }); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + }); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.requestPermissionApprovalForOrigin).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('does not request permittedChains approval', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.requestPermissionApprovalForOrigin).not.toHaveBeenCalled(); + }); + + it('does not switch the active network', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + await switchChain('example.com', '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, + }, + }); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(end).toHaveBeenCalledWith( + new Error( + 'cannot switch to chain that was not permissioned in the multichain flow', + ), + ); + }); + }); + + 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', + (_, isMultichainOrigin) => { + it('does not request permittedChains approval', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin, + }, + }); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect( + mocks.requestPermissionApprovalForOrigin, + ).not.toHaveBeenCalled(); + }); + + it('switches the active network', async () => { + const { mocks, switchChain } = createMockedSwitchChain(); + mocks.getCaveat.mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin, + }, + }); + await switchChain('example.com', '0x1', 'mainnet', 'approvalFlowId'); + + expect(mocks.setActiveNetwork).toHaveBeenCalledWith('mainnet'); + }); + }, + ); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/index.ts b/app/scripts/lib/rpc-method-middleware/handlers/index.ts index 09bca12b5b67..521cb32bec64 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/index.ts +++ b/app/scripts/lib/rpc-method-middleware/handlers/index.ts @@ -20,9 +20,7 @@ export const handlers = [ addEthereumChain, getProviderState, logWeb3ShimUsage, - requestAccounts, sendMetadata, - switchEthereumChain, watchAsset, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) mmiAuthenticate, @@ -34,4 +32,10 @@ export const handlers = [ ///: END:ONLY_INCLUDE_IF ]; -export const legacyHandlers = [ethAccounts]; +export const eip1193OnlyHandlers = [ + switchEthereumChain, + ethAccounts, + requestAccounts, +]; + +export const ethAccountsHandler = ethAccounts; diff --git a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js index f90fb5bd0d42..1009c86109b9 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.js @@ -5,6 +5,16 @@ import { MetaMetricsEventCategory, } from '../../../../../shared/constants/metametrics'; import { shouldEmitDappViewedEvent } from '../../util'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../../multichain-api/caip25permissions'; +import { RestrictedMethods } from '../../../../../shared/constants/permissions'; +import { setEthAccounts } from '../../multichain-api/adapters/caip-permission-adapter-eth-accounts'; +import { PermissionNames } from '../../../controllers/permissions'; +import { setPermittedEthChainIds } from '../../multichain-api/adapters/caip-permission-adapter-permittedChains'; +// eslint-disable-next-line import/no-restricted-paths +import { isSnapId } from '../../../../../ui/helpers/utils/snaps'; /** * This method attempts to retrieve the Ethereum accounts available to the @@ -18,14 +28,12 @@ const requestEthereumAccounts = { methodNames: [MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS], implementation: requestEthereumAccountsHandler, hookNames: { - origin: true, getAccounts: true, getUnlockPromise: true, - hasPermission: true, - requestAccountsPermission: true, + requestPermissionApprovalForOrigin: true, sendMetrics: true, - getPermissionsForOrigin: true, metamaskState: true, + grantPermissions: true, }, }; export default requestEthereumAccounts; @@ -35,7 +43,6 @@ 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 @@ -48,28 +55,27 @@ const locks = new Set(); /** * - * @param {import('json-rpc-engine').JsonRpcRequest} _req - The JSON-RPC request object. + * @param {import('json-rpc-engine').JsonRpcRequest} req - The JSON-RPC request object. * @param {import('json-rpc-engine').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, + req, res, _next, end, { - origin, getAccounts, getUnlockPromise, - hasPermission, - requestAccountsPermission, + requestPermissionApprovalForOrigin, sendMetrics, - getPermissionsForOrigin, metamaskState, + grantPermissions, }, ) { + const { origin } = req; if (locks.has(origin)) { res.error = ethErrors.rpc.resourceUnavailable( `Already processing ${MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS}. Please wait.`, @@ -77,14 +83,15 @@ async function requestEthereumAccountsHandler( return end(); } - if (hasPermission(MESSAGE_TYPE.ETH_ACCOUNTS)) { + let ethAccounts = await getAccounts(); + if (ethAccounts.length > 0) { // We wait for the extension to unlock in this case only, because permission // requests are handled when the extension is unlocked, regardless of the // lock state when they were received. try { locks.add(origin); await getUnlockPromise(true); - res.result = await getAccounts(); + res.result = ethAccounts; end(); } catch (error) { end(error); @@ -94,48 +101,77 @@ async function requestEthereumAccountsHandler( return undefined; } - // If no accounts, request the accounts permission + let legacyApproval; try { - await requestAccountsPermission(); + legacyApproval = await requestPermissionApprovalForOrigin({ + [RestrictedMethods.eth_accounts]: {}, + ...(!isSnapId(origin) && { + [PermissionNames.permittedChains]: {}, + }), + }); } 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 + // NOTE: the eth_accounts/permittedChains approvals will be combined in the future. + // We assume that approvedAccounts and permittedChains are both defined here. + // Until they are actually combined, when testing, you must request both + // eth_accounts and permittedChains together. + let caveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + if (!isSnapId(origin)) { + caveatValue = setPermittedEthChainIds( + caveatValue, + legacyApproval.approvedChainIds, + ); + } + + caveatValue = setEthAccounts(caveatValue, legacyApproval.approvedAccounts); + + grantPermissions({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValue, + }, + ], + }, + }, + }); + + ethAccounts = await getAccounts(); + // first time connection to dapp will lead to no log in the permissionHistory + // and if user has connected to dapp before, the dapp origin will be included in the permissionHistory state + // we will leverage that to identify `is_first_visit` for metrics + if (shouldEmitDappViewedEvent(metamaskState.metaMetricsId)) { const isFirstVisit = !Object.keys(metamaskState.permissionHistory).includes( origin, ); - if (shouldEmitDappViewedEvent(metamaskState.metaMetricsId)) { - sendMetrics({ - event: MetaMetricsEventName.DappViewed, - category: MetaMetricsEventCategory.InpageProvider, - referrer: { - url: origin, - }, - properties: { - is_first_visit: isFirstVisit, - number_of_accounts: Object.keys(metamaskState.accounts).length, - number_of_accounts_connected: numberOfConnectedAccounts, - }, - }); - } - } else { - // This should never happen, because it should be caught in the - // above catch clause - res.error = ethErrors.rpc.internal( - 'Accounts unexpectedly unavailable. Please report this bug.', - ); + sendMetrics({ + event: MetaMetricsEventName.DappViewed, + category: MetaMetricsEventCategory.InpageProvider, + referrer: { + url: origin, + }, + properties: { + is_first_visit: isFirstVisit, + number_of_accounts: Object.keys(metamaskState.accounts).length, + number_of_accounts_connected: ethAccounts.length, + }, + }); } + // 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/request-accounts.test.js b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.js new file mode 100644 index 000000000000..a741d722ade9 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/request-accounts.test.js @@ -0,0 +1,297 @@ +import { ethErrors } from 'eth-rpc-errors'; +import { deferredPromise, shouldEmitDappViewedEvent } from '../../util'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../../multichain-api/caip25permissions'; +import { RestrictedMethods } from '../../../../../shared/constants/permissions'; +import { PermissionNames } from '../../../controllers/permissions'; +import PermittedChainsAdapters from '../../multichain-api/adapters/caip-permission-adapter-permittedChains'; +import EthAccountsAdapters from '../../multichain-api/adapters/caip-permission-adapter-eth-accounts'; +import { flushPromises } from '../../../../../test/lib/timer-helpers'; +import requestEthereumAccounts from './request-accounts'; + +jest.mock( + '../../multichain-api/adapters/caip-permission-adapter-permittedChains', + () => ({ + ...jest.requireActual( + '../../multichain-api/adapters/caip-permission-adapter-permittedChains', + ), + setPermittedEthChainIds: jest.fn(), + }), +); +const MockPermittedChainsAdapters = jest.mocked(PermittedChainsAdapters); + +jest.mock( + '../../multichain-api/adapters/caip-permission-adapter-eth-accounts', + () => ({ + ...jest.requireActual( + '../../multichain-api/adapters/caip-permission-adapter-eth-accounts', + ), + setEthAccounts: jest.fn(), + }), +); +const MockEthAccountsAdapters = jest.mocked(EthAccountsAdapters); + +jest.mock('../../util', () => ({ + ...jest.requireActual('../../util'), + shouldEmitDappViewedEvent: jest.fn(), +})); + +const baseRequest = { + networkClientId: 'mainnet', + origin: 'http://test.com', +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getAccounts = jest.fn().mockResolvedValue([]); + const getUnlockPromise = jest.fn(); + const requestPermissionApprovalForOrigin = jest.fn().mockResolvedValue({ + approvedChainIds: ['0x1', '0x5'], + approvedAccounts: ['0xdeadbeef'], + }); + const sendMetrics = jest.fn(); + const metamaskState = { + permissionHistory: {}, + metaMetricsId: 'metaMetricsId', + accounts: { + '0x1': {}, + '0x2': {}, + '0x3': {}, + }, + }; + const grantPermissions = jest.fn(); + const response = {}; + const handler = (request) => + requestEthereumAccounts.implementation(request, response, next, end, { + getAccounts, + getUnlockPromise, + requestPermissionApprovalForOrigin, + sendMetrics, + metamaskState, + grantPermissions, + }); + + return { + response, + next, + end, + getAccounts, + getUnlockPromise, + requestPermissionApprovalForOrigin, + sendMetrics, + grantPermissions, + handler, + }; +}; + +describe('requestEthereumAccountsHandler', () => { + beforeEach(() => { + shouldEmitDappViewedEvent.mockReturnValue(true); + MockEthAccountsAdapters.setEthAccounts.mockImplementation( + (caveatValue) => caveatValue, + ); + MockPermittedChainsAdapters.setPermittedEthChainIds.mockImplementation( + (caveatValue) => caveatValue, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('checks if there are any eip155 accounts permissioned', async () => { + const { handler, getAccounts } = createMockedHandler(); + + await handler(baseRequest); + expect(getAccounts).toHaveBeenCalled(); + }); + + describe('eip155 account permissions exist', () => { + it('waits for the wallet to unlock', async () => { + const { handler, getUnlockPromise, getAccounts } = createMockedHandler(); + getAccounts.mockResolvedValue(['0xdead', '0xbeef']); + + await handler(baseRequest); + expect(getUnlockPromise).toHaveBeenCalledWith(true); + }); + + it('returns the accounts', async () => { + const { handler, response, getAccounts } = createMockedHandler(); + getAccounts.mockResolvedValue(['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.mockResolvedValue(['0xdead', '0xbeef']); + + handler(baseRequest); + expect(response).toStrictEqual({}); + expect(end).not.toHaveBeenCalled(); + + await flushPromises(); + + await handler(baseRequest); + expect(response.error).toStrictEqual( + ethErrors.rpc.resourceUnavailable( + `Already processing eth_requestAccounts. Please wait.`, + ), + ); + expect(end).toHaveBeenCalledTimes(1); + resolve(); + }); + }); + + describe('eip155 account permissions do not exist', () => { + it('requests eth_accounts and permittedChains approval if origin is not snapId', async () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler(baseRequest); + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: {}, + [PermissionNames.permittedChains]: {}, + }); + }); + + it('requests eth_accounts approval if origin is snapId', async () => { + const { handler, requestPermissionApprovalForOrigin } = + createMockedHandler(); + + await handler({ ...baseRequest, origin: 'npm:snap' }); + expect(requestPermissionApprovalForOrigin).toHaveBeenCalledWith({ + [RestrictedMethods.eth_accounts]: {}, + }); + }); + + it('throws an error if the eth_accounts and permittedChains approval is rejected', async () => { + const { handler, requestPermissionApprovalForOrigin, response, end } = + createMockedHandler(); + requestPermissionApprovalForOrigin.mockRejectedValue( + new Error('approval rejected'), + ); + + await handler(baseRequest); + expect(response.error).toStrictEqual(new Error('approval rejected')); + expect(end).toHaveBeenCalled(); + }); + + it('sets the approved chainIds on an empty CAIP-25 caveat with isMultichainOrigin: false if origin is not snapId', async () => { + const { handler } = createMockedHandler(); + + await handler(baseRequest); + expect( + MockPermittedChainsAdapters.setPermittedEthChainIds, + ).toHaveBeenCalledWith( + { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + ['0x1', '0x5'], + ); + }); + + it('sets the approved accounts on the CAIP-25 caveat after the approved chainIds if origin is not snapId', async () => { + const { handler } = createMockedHandler(); + + MockPermittedChainsAdapters.setPermittedEthChainIds.mockReturnValue( + 'caveatValueWithEthChainIdsSet', + ); + + await handler(baseRequest); + expect(MockEthAccountsAdapters.setEthAccounts).toHaveBeenCalledWith( + 'caveatValueWithEthChainIdsSet', + ['0xdeadbeef'], + ); + }); + + it('does not set the approved chainIds on an empty CAIP-25 caveat if origin is snapId', async () => { + const { handler } = createMockedHandler(); + + await handler({ baseRequest, origin: 'npm:snap' }); + expect( + MockPermittedChainsAdapters.setPermittedEthChainIds, + ).not.toHaveBeenCalled(); + }); + + it('sets the approved accounts on an empty CAIP-25 caveat with isMultichainOrigin: false if origin is snapId', async () => { + const { handler } = createMockedHandler(); + + await handler({ baseRequest, origin: 'npm:snap' }); + expect(MockEthAccountsAdapters.setEthAccounts).toHaveBeenCalledWith( + { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }, + ['0xdeadbeef'], + ); + }); + + it('grants a CAIP-25 permission', async () => { + const { handler, grantPermissions } = createMockedHandler(); + + MockEthAccountsAdapters.setEthAccounts.mockReturnValue( + 'updatedCaveatValue', + ); + + await handler(baseRequest); + expect(grantPermissions).toHaveBeenCalledWith({ + subject: { + origin: 'http://test.com', + }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: 'updatedCaveatValue', + }, + ], + }, + }, + }); + }); + + it('returns the newly granted and properly ordered eth accounts', async () => { + const { handler, getAccounts, response } = createMockedHandler(); + getAccounts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(['0xdead', '0xbeef']); + + await handler(baseRequest); + expect(response.result).toStrictEqual(['0xdead', '0xbeef']); + expect(getAccounts).toHaveBeenCalledTimes(2); + }); + + it('emits the dapp viewed metrics event', async () => { + const { handler, getAccounts, sendMetrics } = createMockedHandler(); + getAccounts + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(['0xdead', '0xbeef']); + + 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', + }, + }); + }); + }); +}); 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 f43973e4ba57..57c4378989aa 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,10 @@ const switchEthereumChain = { getNetworkConfigurationByChainId: true, setActiveNetwork: true, getCaveat: true, - requestPermittedChainsPermission: true, getCurrentChainIdForDomain: true, - grantPermittedChainsPermissionIncremental: true, + requestPermissionApprovalForOrigin: true, + updateCaveat: true, + grantPermissions: true, }, }; @@ -28,15 +29,16 @@ async function switchEthereumChainHandler( { getNetworkConfigurationByChainId, setActiveNetwork, - requestPermittedChainsPermission, getCaveat, getCurrentChainIdForDomain, - grantPermittedChainsPermissionIncremental, + requestPermissionApprovalForOrigin, + updateCaveat, + grantPermissions, }, ) { let chainId; try { - chainId = validateSwitchEthereumChainParams(req, end); + chainId = validateSwitchEthereumChainParams(req); } catch (error) { return end(error); } @@ -64,10 +66,19 @@ async function switchEthereumChainHandler( ); } - return switchChain(res, end, chainId, networkClientIdToSwitchTo, null, { - setActiveNetwork, - getCaveat, - requestPermittedChainsPermission, - grantPermittedChainsPermissionIncremental, - }); + return switchChain( + res, + end, + origin, + chainId, + networkClientIdToSwitchTo, + null, + { + setActiveNetwork, + getCaveat, + updateCaveat, + requestPermissionApprovalForOrigin, + grantPermissions, + }, + ); } 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..c17d8dc6cc94 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,15 @@ +import { ethErrors } from 'eth-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'), + switchChain: jest.fn(), +})); const NON_INFURA_CHAIN_ID = '0x123456789'; @@ -26,257 +33,143 @@ 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), + requestPermissionApprovalForOrigin: jest.fn(), + updateCaveat: jest.fn(), + grantPermissions: jest.fn(), + }; + const response = {}; + const handler = (request) => + switchEthereumChain.implementation(request, response, next, end, mocks); + + return { + mocks, + response, + next, + end, + handler, }; +}; +describe('switchEthereumChainHandler', () => { 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( + it('returns null 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(), - jest.fn(), - mocks, - ); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); + ], }); - 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( - { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.LINEA_MAINNET.toLowerCase() }], - }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockLineaMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); - }); + expect(response.result).toStrictEqual(null); + expect(end).toHaveBeenCalled(); + expect(EthChainUtils.switchChain).not.toHaveBeenCalled(); + }); - 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, - ); - }); + it('throws an error 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 when switching to a custom network', async () => { - const mocks = makeMocks({ - overrides: { - getCurrentChainIdForDomain: jest - .fn() - .mockReturnValue(CHAIN_IDS.MAINNET), - }, - }); - const switchEthereumChainHandler = switchEthereumChain.implementation; - await switchEthereumChainHandler( + await handler({ + origin: 'example.com', + params: [ { - origin: 'example.com', - params: [{ chainId: NON_INFURA_CHAIN_ID }], + chainId: NON_INFURA_CHAIN_ID, }, - {}, - jest.fn(), - jest.fn(), - mocks, - ); - expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); - expect(mocks.setActiveNetwork).toHaveBeenCalledWith( - createMockMainnetConfiguration().rpcEndpoints[0].networkClientId, - ); + ], }); - it('should handle missing networkConfiguration', async () => { - // Mock a network configuration that has an undefined or missing rpcEndpoints - const mockNetworkConfiguration = undefined; - - const mocks = makeMocks({ - overrides: { - getNetworkConfigurationByChainId: jest - .fn() - .mockReturnValue(mockNetworkConfiguration), - }, - }); - - const switchEthereumChainHandler = switchEthereumChain.implementation; + expect(end).toHaveBeenCalledWith( + ethErrors.provider.custom({ + code: 4902, + message: `Unrecognized chain ID "${NON_INFURA_CHAIN_ID}". Try adding the chain using wallet_addEthereumChain first.`, + }), + ); + expect(EthChainUtils.switchChain).not.toHaveBeenCalled(); + }); - const mockEnd = jest.fn(); - 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, - ); - - // 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(); + ], }); + + expect(EthChainUtils.switchChain).toHaveBeenCalledWith( + {}, + end, + 'example.com', + '0xdeadbeef', + 'mainnet', + null, + { + setActiveNetwork: mocks.setActiveNetwork, + getCaveat: mocks.getCaveat, + updateCaveat: mocks.updateCaveat, + requestPermissionApprovalForOrigin: + mocks.requestPermissionApprovalForOrigin, + grantPermissions: mocks.grantPermissions, + }, + ); }); - 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, - ); + it('should return an error if an unexpected parameter is provided', async () => { + const { end, handler } = createMockedHandler(); - 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, - ); - }); + const unexpectedParam = 'unexpected'; - 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: createMockMainnetConfiguration().chainId, + [unexpectedParam]: 'parameter', }, - {}, - 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( + ethErrors.rpc.invalidParams({ + message: `Received unexpected keys on object parameter. Unsupported keys:\n${unexpectedParam}`, + }), + ); + }); - await switchEthereumChainHandler( - { - origin: 'example.com', - params: [{ chainId: CHAIN_IDS.MAINNET }], - }, - {}, - jest.fn(), - mockEnd, - mocks, - ); + it('should return error for invalid chainId', async () => { + const { handler, end } = createMockedHandler(); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledTimes(1); - expect(mockEnd).toHaveBeenCalledWith(mockError); - expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); + await handler({ + origin: 'example.com', + params: [{ chainId: 'invalid_chain_id' }], }); + + expect(end).toHaveBeenCalledWith( + ethErrors.rpc.invalidParams({ + message: `Expected 0x-prefixed, unpadded, non-zero hexadecimal string 'chainId'. Received:\ninvalid_chain_id`, + }), + ); }); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index b19c91a232ab..79efaec3a45e 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -15,7 +15,7 @@ import { } from '@metamask/assets-controllers'; import { ObservableStore } from '@metamask/obs-store'; import { storeAsStream } from '@metamask/obs-store/dist/asStream'; -import { JsonRpcEngine } from 'json-rpc-engine'; +import { JsonRpcEngine, createScaffoldMiddleware } from 'json-rpc-engine'; import { createEngineStream } from 'json-rpc-middleware-stream'; import { providerAsMiddleware } from '@metamask/eth-json-rpc-middleware'; import { debounce, throttle, memoize, wrap } from 'lodash'; @@ -25,11 +25,7 @@ import { } 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, - EthereumRpcError, - ethErrors, -} from 'eth-rpc-errors'; +import { EthereumRpcError, ethErrors } from 'eth-rpc-errors'; import { Mutex } from 'await-semaphore'; import log from 'loglevel'; @@ -62,6 +58,7 @@ import { } from '@metamask/network-controller'; import { GasFeeController } from '@metamask/gas-fee-controller'; import { + MethodNames, PermissionController, PermissionDoesNotExistError, PermissionsRequestNotFoundError, @@ -147,6 +144,7 @@ import { import { Interface } from '@ethersproject/abi'; import { abiERC1155, abiERC721 } from '@metamask/metamask-eth-abis'; import { isEvmAccountType } from '@metamask/keyring-api'; +import { isValidHexAddress, toCaipChainId } from '@metamask/utils'; import { AuthenticationController, UserStorageController, @@ -171,6 +169,7 @@ import { CHAIN_IDS, NETWORK_TYPES, NetworkStatus, + UNSUPPORTED_RPC_METHODS, MAINNET_DISPLAY_NAME, } from '../../shared/constants/network'; import { getAllowedSmartTransactionsChainIds } from '../../shared/constants/smartTransactions'; @@ -192,6 +191,7 @@ import { MILLISECOND, SECOND } from '../../shared/constants/time'; import { ORIGIN_METAMASK, POLLING_TOKEN_ENVIRONMENT_TYPES, + MESSAGE_TYPE, } from '../../shared/constants/app'; import { MetaMetricsEventCategory, @@ -280,8 +280,9 @@ import AccountTrackerController from './controllers/account-tracker-controller'; import createDupeReqFilterStream from './lib/createDupeReqFilterStream'; import createLoggerMiddleware from './lib/createLoggerMiddleware'; import { - createLegacyMethodMiddleware, - createMethodMiddleware, + createEthAccountsMethodMiddleware, + createEip1193MethodMiddleware, + createMultichainMethodMiddleware, createUnsupportedMethodMiddleware, } from './lib/rpc-method-middleware'; import createOriginMiddleware from './lib/createOriginMiddleware'; @@ -311,17 +312,19 @@ import EncryptionPublicKeyController from './controllers/encryption-public-key'; import AppMetadataController from './controllers/app-metadata'; import { - CaveatFactories, CaveatMutatorFactories, + getAuthorizedScopesByOrigin, getCaveatSpecifications, + getChangedAuthorizations, diffMap, getPermissionBackgroundApiMethods, getPermissionSpecifications, getPermittedAccountsByOrigin, + getRemovedAuthorizations, 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'; @@ -348,7 +351,23 @@ import { createTxVerificationMiddleware } from './lib/tx-verification/tx-verific import { updateSecurityAlertResponse } from './lib/ppom/ppom-util'; import createEvmMethodsToNonEvmAccountReqFilterMiddleware from './lib/createEvmMethodsToNonEvmAccountReqFilterMiddleware'; import { isEthAddress } from './lib/multichain/address'; +import { walletCreateSessionHandler } from './lib/multichain-api/wallet-createSession'; +import { walletInvokeMethodHandler } from './lib/multichain-api/wallet-invokeMethod'; +import { + Caip25CaveatMutatorFactories, + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from './lib/multichain-api/caip25permissions'; +import { multichainMethodCallValidatorMiddleware } from './lib/multichain-api/multichainMethodCallValidator'; + import { decodeTransactionData } from './lib/transaction/decode/util'; +import MultichainSubscriptionManager from './lib/multichain-api/MultichainSubscriptionManager'; +import MultichainMiddlewareManager from './lib/multichain-api/MultichainMiddlewareManager'; +import { walletRevokeSessionHandler } from './lib/multichain-api/wallet-revokeSession'; +import { walletGetSessionHandler } from './lib/multichain-api/wallet-getSession'; +import { mergeScopes } from './lib/multichain-api/scope'; +import { getEthAccounts } from './lib/multichain-api/adapters/caip-permission-adapter-eth-accounts'; +import { CaipPermissionAdapterMiddleware } from './lib/multichain-api/adapters/caip-permission-adapter-middleware'; import { BridgeUserAction, BridgeBackgroundAction, @@ -570,7 +589,19 @@ export default class MetamaskController extends EventEmitter { state: initialNetworkControllerState, infuraProjectId: opts.infuraProjectId, }); + this.networkController.initializeProvider(); + this.multichainSubscriptionManager = new MultichainSubscriptionManager({ + getNetworkClientById: this.networkController.getNetworkClientById.bind( + this.networkController, + ), + findNetworkClientIdByChainId: + this.networkController.findNetworkClientIdByChainId.bind( + this.networkController, + ), + }); + + this.multichainMiddlewareManager = new MultichainMiddlewareManager(); this.provider = this.networkController.getProviderAndBlockTracker().provider; this.blockTracker = @@ -1200,51 +1231,16 @@ export default class MetamaskController extends EventEmitter { ], }), state: initState.PermissionController, - caveatSpecifications: getCaveatSpecifications({ - getInternalAccounts: this.accountsController.listAccounts.bind( - this.accountsController, - ), - findNetworkClientIdByChainId: - this.networkController.findNetworkClientIdByChainId.bind( - this.networkController, - ), - }), + caveatSpecifications: getCaveatSpecifications(), 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.`, - ), - ); - }, + findNetworkClientIdByChainId: + this.networkController.findNetworkClientIdByChainId.bind( + this.networkController, + ), }), ...this.getSnapPermissionSpecifications(), }, @@ -1552,7 +1548,7 @@ export default class MetamaskController extends EventEmitter { }, }, env: { - isAccountSyncingEnabled: isManifestV3, + isAccountSyncingEnabled: false, // TODO: undo this once fixed }, messenger: this.controllerMessenger.getRestricted({ name: 'UserStorageController', @@ -1861,7 +1857,7 @@ export default class MetamaskController extends EventEmitter { this.networkController, ), getNetworkState: () => this.networkController.state, - getPermittedAccounts: this.getPermittedAccounts.bind(this), + getPermittedAccounts: this.getPermittedAccountsSorted.bind(this), getSavedGasFees: () => this.preferencesController.state.advancedGasFee[ getCurrentChainId({ metamask: this.networkController.state }) @@ -2249,18 +2245,13 @@ export default class MetamaskController extends EventEmitter { }, version, // account mgmt - getAccounts: async ( - { origin: innerOrigin }, - { suppressUnauthorizedError = true } = {}, - ) => { + getAccounts: async ({ 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 await this.getPermittedAccounts(innerOrigin); } return []; // changing this is a breaking change }, @@ -2891,6 +2882,86 @@ export default class MetamaskController extends EventEmitter { getPermittedAccountsByOrigin, ); + // This handles CAIP-25 authorization changes every time relevant permission state + // changes, for any reason. + this.controllerMessenger.subscribe( + `${this.permissionController.name}:stateChange`, + async (currentValue, previousValue) => { + const changedAuthorizations = getChangedAuthorizations( + currentValue, + previousValue, + ); + + const removedAuthorizations = getRemovedAuthorizations( + currentValue, + previousValue, + ); + + // remove any existing notification subscriptions for removed authorizations + for (const [origin, authorization] of removedAuthorizations.entries()) { + const mergedScopes = mergeScopes( + authorization.requiredScopes, + authorization.optionalScopes, + ); + // if the eth_subscription notification is in the scope and eth_subscribe is in the methods + // then remove middleware and unsubscribe + Object.entries(mergedScopes).forEach(([scope, scopeObject]) => { + if ( + scopeObject.notifications.includes('eth_subscription') && + scopeObject.methods.includes('eth_subscribe') + ) { + this.multichainMiddlewareManager.removeMiddlewareByScopeAndOrigin( + scope, + origin, + ); + this.multichainSubscriptionManager.unsubscribeByScopeAndOrigin( + scope, + origin, + ); + } + }); + } + + // add new notification subscriptions for changed authorizations + for (const [origin, authorization] of changedAuthorizations.entries()) { + const mergedScopes = mergeScopes( + authorization.requiredScopes, + authorization.optionalScopes, + ); + + // if the eth_subscription notification is in the scope and eth_subscribe is in the methods + // then get the subscriptionManager going for that scope + Object.entries(mergedScopes).forEach(([scope, scopeObject]) => { + if ( + scopeObject.notifications.includes('eth_subscription') && + scopeObject.methods.includes('eth_subscribe') + ) { + // for each tabId + Object.entries(this.connections[origin]).forEach( + ([_, { tabId }]) => { + const subscriptionManager = + this.multichainSubscriptionManager.subscribe({ + scope, + origin, + tabId, + }); + this.multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: subscriptionManager.middleware, + }); + }, + ); + } + }); + + this._notifyAuthorizationChange(origin, authorization); + } + }, + getAuthorizedScopesByOrigin, + ); + this.controllerMessenger.subscribe( `${this.permissionController.name}:stateChange`, async (currentValue, previousValue) => { @@ -3433,9 +3504,7 @@ export default class MetamaskController extends EventEmitter { updateNetwork: this.networkController.updateNetwork.bind( this.networkController, ), - removeNetwork: this.networkController.removeNetwork.bind( - this.networkController, - ), + removeNetwork: this.removeNetwork.bind(this), getCurrentNetworkEIP1559Compatibility: this.networkController.getEIP1559Compatibility.bind( this.networkController, @@ -3712,7 +3781,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( @@ -4855,56 +4927,142 @@ 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.`, + ), + ); + } + + async getAllEvmAccountsSorted() { + // We only consider EVM addresses here, hence the filtering: + const accounts = (await this.keyringController.getAccounts()).filter( + isValidHexAddress, + ); + 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. * * @param {string} origin - The origin whose exposed accounts to retrieve. - * @param {boolean} [suppressUnauthorizedError] - Suppresses the unauthorized error. * @returns {Promise} The origin's permitted accounts, or an empty * array. */ - async getPermittedAccounts( - origin, - { suppressUnauthorizedError = true } = {}, - ) { + getPermittedAccounts(origin) { + 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 []; - } - throw error; + } catch (err) { + // noop + } + if (!caveat) { + return []; } + + return getEthAccounts(caveat.value); + } + + async getPermittedAccountsSorted(origin) { + const permittedAccounts = this.getPermittedAccounts(origin); + const allEvmAccounts = await this.getAllEvmAccountsSorted(); + return allEvmAccounts.filter((account) => + permittedAccounts.includes(account), + ); } /** * 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) => + Caip25CaveatMutatorFactories[Caip25CaveatType].removeScope( + toCaipChainId('eip155', parseInt(targetChainId, 16)), + existingScopes, + ), ); } + // Figure out what needs to be done with the middleware/subscription logic + removeNetwork(chainId) { + const scope = `eip155:${parseInt(chainId, 16)}`; + this.multichainSubscriptionManager.unsubscribeByScope(scope); + this.multichainMiddlewareManager.removeMiddlewareByScope(scope); + + this.removeAllChainIdPermissions(chainId); + + this.networkController.removeNetwork(chainId); + } + /** * Stops exposing the account with the specified address to all third parties. * Exposed accounts are stored in caveats of the eth_accounts permission. This @@ -4923,6 +5081,14 @@ export default class MetamaskController extends EventEmitter { CaveatTypes.restrictReturnedAccounts ].removeAccount(targetAccount, existingAccounts), ); + this.permissionController.updatePermissionsByCaveat( + Caip25CaveatType, + (existingScopes) => + Caip25CaveatMutatorFactories[Caip25CaveatType].removeAccount( + targetAccount, + existingScopes, + ), + ); } /** @@ -4964,6 +5130,28 @@ export default class MetamaskController extends EventEmitter { this.preferencesController.setSelectedAddress(importedAccountAddress); } + /** + * Requests approval for permissions for the specified origin + * + * @param origin - The origin to request approval for. + * @param permissions - The permissions to request approval for. + */ + async requestPermissionApprovalForOrigin(origin, permissions) { + const id = nanoid(); + return this.approvalController.addAndShowApprovalRequest({ + id, + origin, + requestData: { + metadata: { + id, + origin, + }, + permissions, + }, + type: MethodNames.requestPermissions, + }); + } + // --------------------------------------------------------------------------- // Identity Management (signature operations) @@ -5471,7 +5659,7 @@ export default class MetamaskController extends EventEmitter { // setup connection const providerStream = createEngineStream({ engine }); - const connectionId = this.addConnection(origin, { engine }); + const connectionId = this.addConnection(origin, { tabId, engine }); pipeline( outStream, @@ -5539,7 +5727,7 @@ export default class MetamaskController extends EventEmitter { // setup connection const providerStream = createEngineStream({ engine }); - const connectionId = this.addConnection(origin, { engine }); + const connectionId = this.addConnection(origin, { tabId, engine }); pipeline( outStream, @@ -5547,6 +5735,15 @@ export default class MetamaskController extends EventEmitter { providerStream, outStream, (err) => { + this.multichainMiddlewareManager.removeMiddlewareByOriginAndTabId( + origin, + tabId, + ); + this.multichainSubscriptionManager.unsubscribeByOriginAndTabId( + origin, + tabId, + ); + // handle any middleware cleanup engine._middleware.forEach((mid) => { if (mid.destroy && typeof mid.destroy === 'function') { @@ -5606,6 +5803,8 @@ export default class MetamaskController extends EventEmitter { useRequestQueue: this.preferencesController.getUseRequestQueue.bind( this.preferencesController, ), + // TODO: Should this be made async in queued-request-controller package? + // Doing so allows us to DRY up getPermittedAcounts and getPermittedAccountsSorted shouldEnqueueRequest: (request) => { return methodsRequiringNetworkSwitch.includes(request.method); }, @@ -5679,13 +5878,25 @@ export default class MetamaskController extends EventEmitter { }), ); - engine.push(createUnsupportedMethodMiddleware()); + engine.push(createUnsupportedMethodMiddleware(UNSUPPORTED_RPC_METHODS)); + + engine.push((req, res, next, end) => + CaipPermissionAdapterMiddleware(req, res, next, end, { + getCaveat: this.permissionController.getCaveat.bind( + this.permissionController, + ), + getNetworkConfigurationByNetworkClientId: + this.networkController.getNetworkConfigurationByNetworkClientId.bind( + this.networkController, + ), + }), + ); - // Legacy RPC methods that need to be implemented _ahead of_ the permission + // Legacy RPC method that needs to be implemented _ahead of_ the permission // middleware. engine.push( - createLegacyMethodMiddleware({ - getAccounts: this.getPermittedAccounts.bind(this, origin), + createEthAccountsMethodMiddleware({ + getAccounts: this.getPermittedAccountsSorted.bind(this, origin), }), ); @@ -5720,9 +5931,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 @@ -5750,59 +5959,21 @@ export default class MetamaskController extends EventEmitter { this.metaMetricsController, ), // Permission-related - getAccounts: this.getPermittedAccounts.bind(this, origin), + getAccounts: this.getPermittedAccountsSorted.bind(this, origin), getPermissionsForOrigin: this.permissionController.getPermissions.bind( this.permissionController, origin, ), - hasPermission: this.permissionController.hasPermission.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, - ), - ], - }, - }, - }), + requestPermissionApprovalForOrigin: + this.requestPermissionApprovalForOrigin.bind(this, origin), requestPermissionsForOrigin: (requestedPermissions) => this.permissionController.requestPermissions( { origin }, { - ...(requestedPermissions[PermissionNames.eth_accounts] && { - [PermissionNames.permittedChains]: {}, - }), + ...(requestedPermissions[PermissionNames.eth_accounts] && + !isSnapId(origin) && { + [PermissionNames.permittedChains]: {}, + }), ...(requestedPermissions[PermissionNames.permittedChains] && { [PermissionNames.eth_accounts]: {}, }), @@ -5844,12 +6015,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( @@ -5887,6 +6058,13 @@ export default class MetamaskController extends EventEmitter { this.alertController, ), + grantPermissions: this.permissionController.grantPermissions.bind( + this.permissionController, + ), + updateCaveat: this.permissionController.updateCaveat.bind( + this.permissionController, + ), + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) handleMmiAuthenticate: this.institutionalFeaturesController.handleMmiAuthenticate.bind( @@ -6001,7 +6179,7 @@ export default class MetamaskController extends EventEmitter { } /** - * A method for creating a CAIP provider that is safely restricted for the requesting subject. + * A method for creating a provider that is safely restricted for the requesting subject. * * @param {object} options - Provider engine options * @param {string} options.origin - The origin of the sender @@ -6010,9 +6188,290 @@ export default class MetamaskController extends EventEmitter { setupProviderEngineCaip({ origin, tabId }) { const engine = new JsonRpcEngine(); - engine.push((request, _res, _next, end) => { - console.log('CAIP request received', { origin, tabId, request }); - return end(new Error('CAIP RPC Pipeline not yet implemented.')); + // Append origin to each request + engine.push(createOriginMiddleware({ origin })); + + // Append tabId to each request if it exists + if (tabId) { + engine.push(createTabIdMiddleware({ tabId })); + } + + engine.push(createLoggerMiddleware({ origin })); + + engine.push((req, _res, next, end) => { + if ( + ![ + MESSAGE_TYPE.WALLET_CREATE_SESSION, + MESSAGE_TYPE.WALLET_INVOKE_METHOD, + MESSAGE_TYPE.WALLET_GET_SESSION, + MESSAGE_TYPE.WALLET_REVOKE_SESSION, + ].includes(req.method) + ) { + return end(new Error('Invalid method')); // TODO: Use a proper error + } + return next(); + }); + + // TODO: Uncomment this when wallet lifecycle methods are added to api-specs + engine.push(multichainMethodCallValidatorMiddleware); + + engine.push( + createScaffoldMiddleware({ + [MESSAGE_TYPE.WALLET_CREATE_SESSION]: ( + request, + response, + next, + end, + ) => { + return walletCreateSessionHandler(request, response, next, end, { + grantPermissions: this.permissionController.grantPermissions.bind( + this.permissionController, + ), + findNetworkClientIdByChainId: + this.networkController.findNetworkClientIdByChainId.bind( + this.networkController, + ), + listAccounts: this.accountsController.listAccounts.bind( + this.accountsController, + ), + addNetwork: this.networkController.addNetwork.bind( + this.networkController, + ), + removeNetwork: this.removeNetwork.bind(this), + requestPermissionApprovalForOrigin: + this.requestPermissionApprovalForOrigin.bind(this, origin), + sendMetrics: this.metaMetricsController.trackEvent.bind( + this.metaMetricsController, + ), + metamaskState: this.getState(), + }); + }, + [MESSAGE_TYPE.WALLET_INVOKE_METHOD]: (request, response, next, end) => { + return walletInvokeMethodHandler(request, response, next, end, { + findNetworkClientIdByChainId: + this.networkController.findNetworkClientIdByChainId.bind( + this.networkController, + ), + getCaveat: this.permissionController.getCaveat.bind( + this.permissionController, + ), + getSelectedNetworkClientId: () => + this.networkController.state.selectedNetworkClientId, + }); + }, + [MESSAGE_TYPE.WALLET_REVOKE_SESSION]: ( + request, + response, + next, + end, + ) => { + return walletRevokeSessionHandler(request, response, next, end, { + revokePermission: this.permissionController.revokePermission.bind( + this.permissionController, + ), + }); + }, + [MESSAGE_TYPE.WALLET_GET_SESSION]: (request, response, next, end) => { + return walletGetSessionHandler(request, response, next, end, { + getCaveat: this.permissionController.getCaveat.bind( + this.permissionController, + ), + }); + }, + }), + ); + + // TODO: Does this need to go before the wallet_createSession middleware? + // Add a middleware that will switch chain on each request (as needed) + const requestQueueMiddleware = createQueuedRequestMiddleware({ + enqueueRequest: this.queuedRequestController.enqueueRequest.bind( + this.queuedRequestController, + ), + useRequestQueue: this.preferencesController.getUseRequestQueue.bind( + this.preferencesController, + ), + shouldEnqueueRequest: (request) => { + return methodsRequiringNetworkSwitch.includes(request.method); + }, + }); + engine.push(requestQueueMiddleware); + + engine.push( + createUnsupportedMethodMiddleware([ + ...UNSUPPORTED_RPC_METHODS, + 'eth_requestAccounts', + 'eth_accounts', + ]), + ); + + engine.push( + createMultichainMethodMiddleware({ + subjectType: SubjectType.Website, // TODO: this should probably be passed in + + // Miscellaneous + addSubjectMetadata: + this.subjectMetadataController.addSubjectMetadata.bind( + this.subjectMetadataController, + ), + getProviderState: this.getProviderState.bind(this), + handleWatchAssetRequest: this.handleWatchAssetRequest.bind(this), + requestUserApproval: + this.approvalController.addAndShowApprovalRequest.bind( + this.approvalController, + ), + startApprovalFlow: this.approvalController.startFlow.bind( + this.approvalController, + ), + endApprovalFlow: this.approvalController.endFlow.bind( + this.approvalController, + ), + getCaveat: ({ target, caveatType }) => { + try { + return this.permissionController.getCaveat( + origin, + target, + caveatType, + ); + } catch (e) { + if (e instanceof PermissionDoesNotExistError) { + // suppress expected error in case that the origin + // does not have the target permission yet + } else { + throw e; + } + } + + return undefined; + }, + addNetwork: this.networkController.addNetwork.bind( + this.networkController, + ), + updateNetwork: this.networkController.updateNetwork.bind( + this.networkController, + ), + setActiveNetwork: async (networkClientId) => { + await this.networkController.setActiveNetwork(networkClientId); + // if the origin has the CAIP-25 permission + // we set per dapp network selection state + if ( + this.permissionController.hasPermission( + origin, + Caip25EndowmentPermissionName, + ) + ) { + this.selectedNetworkController.setNetworkClientIdForDomain( + origin, + networkClientId, + ); + } + }, + getNetworkConfigurationByChainId: + this.networkController.getNetworkConfigurationByChainId.bind( + this.networkController, + ), + // TODO refactor `add-ethereum-chain` handler so that this hook can be removed from multichain middleware + getCurrentChainIdForDomain: (domain) => { + const networkClientId = + this.selectedNetworkController.getNetworkClientIdForDomain(domain); + const { chainId } = + this.networkController.getNetworkConfigurationByNetworkClientId( + networkClientId, + ); + return chainId; + }, + + // Web3 shim-related + getWeb3ShimUsageState: this.alertController.getWeb3ShimUsageState.bind( + this.alertController, + ), + setWeb3ShimUsageRecorded: + this.alertController.setWeb3ShimUsageRecorded.bind( + this.alertController, + ), + + requestPermissionApprovalForOrigin: + this.requestPermissionApprovalForOrigin.bind(this, origin), + updateCaveat: this.permissionController.updateCaveat.bind( + this.permissionController, + ), + grantPermissions: this.permissionController.grantPermissions.bind( + this.permissionController, + ), + }), + ); + + engine.push(this.metamaskMiddleware); + + // TODO: Might be able to DRY this with the stateChange event + try { + const caip25Caveat = this.permissionController.getCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + + // add new notification subscriptions for changed authorizations + const mergedScopes = mergeScopes( + caip25Caveat.value.requiredScopes, + caip25Caveat.value.optionalScopes, + ); + + // if the eth_subscription notification is in the scope and eth_subscribe is in the methods + // then get the subscriptionManager going for that scope + Object.entries(mergedScopes).forEach(([scope, scopeObject]) => { + if ( + scopeObject.notifications.includes('eth_subscription') && + scopeObject.methods.includes('eth_subscribe') + ) { + const subscriptionManager = + this.multichainSubscriptionManager.subscribe({ + scope, + origin, + tabId, + }); + this.multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: subscriptionManager.middleware, + }); + } + }); + } catch (err) { + // noop + } + + this.multichainSubscriptionManager.on( + 'notification', + (targetOrigin, targetTabId, message) => { + if (origin === targetOrigin && tabId === targetTabId) { + engine.emit('notification', message); + } + }, + ); + + engine.push( + this.multichainMiddlewareManager.generateMultichainMiddlewareForOriginAndTabId( + origin, + tabId, + ), + ); + + engine.push((req, res, _next, end) => { + const { provider } = this.networkController.getNetworkClientById( + req.networkClientId, + ); + + // send request to provider + provider.sendAsync(req, (err, providerRes) => { + // forward any error + if (err instanceof Error) { + return end(err); + } + // copy provider response onto original response + Object.assign(res, providerRes); + return end(); + }); }); return engine; @@ -6049,9 +6508,10 @@ export default class MetamaskController extends EventEmitter { * @param {string} origin - The connection's origin string. * @param {object} options - Data associated with the connection * @param {object} options.engine - The connection's JSON Rpc Engine + * @param options.tabId * @returns {string} The connection's id (so that it can be deleted later) */ - addConnection(origin, { engine }) { + addConnection(origin, { tabId, engine }) { if (origin === ORIGIN_METAMASK) { return null; } @@ -6062,6 +6522,7 @@ export default class MetamaskController extends EventEmitter { const id = nanoid(); this.connections[origin][id] = { + tabId, engine, }; @@ -6217,7 +6678,7 @@ export default class MetamaskController extends EventEmitter { method: NOTIFICATION_NAMES.unlockStateChanged, params: { isUnlocked: true, - accounts: await this.getPermittedAccounts(origin), + accounts: await this.getPermittedAccountsSorted(origin), }, }; }); @@ -6258,7 +6719,7 @@ export default class MetamaskController extends EventEmitter { */ _onStateUpdate(newState) { this.isClientOpenAndUnlocked = newState.isUnlocked && this._isClientOpen; - this._notifyChainChange(); + // this._notifyChainChange(); } // misc @@ -6748,13 +7209,27 @@ 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), + await this.getPermittedAccountsSorted(origin), }); } this.permissionLogController.updateAccountsHistory(origin, newAccounts); } + async _notifyAuthorizationChange(origin, newAuthorization) { + if (this.isUnlocked()) { + this.notifyConnections(origin, { + method: NOTIFICATION_NAMES.sessionChanged, + params: { + sessionScopes: mergeScopes( + newAuthorization.requiredScopes ?? {}, + newAuthorization.optionalScopes ?? {}, + ), + }, + }); + } + } + async _notifyChainChange() { if (this.preferencesController.getUseRequestQueue()) { this.notifyAllConnections(async (origin) => ({ diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 77b062bcfdc7..09022da677c0 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -102,10 +102,13 @@ const createLoggerMiddlewareMock = () => (req, res, next) => { jest.mock('./lib/createLoggerMiddleware', () => createLoggerMiddlewareMock); const rpcMethodMiddlewareMock = { - createMethodMiddleware: () => (_req, _res, next, _end) => { + createEip1193MethodMiddleware: () => (_req, _res, next, _end) => { next(); }, - createLegacyMethodMiddleware: () => (_req, _res, next, _end) => { + createEthAccountsMethodMiddleware: () => (_req, _res, next, _end) => { + next(); + }, + createMultichainMethodMiddleware: () => (_req, _res, next, _end) => { next(); }, createUnsupportedMethodMiddleware: () => (_req, _res, next, _end) => { @@ -1397,6 +1400,10 @@ describe('MetaMaskController', () => { }); describe('#setupUntrustedCommunicationCaip', () => { + it.todo('adds a tabId, origin and networkClient to requests'); + + it.todo('should add only origin to request if tabId not provided'); + it.todo('should only process `caip-x` CAIP formatted messages'); }); diff --git a/app/scripts/migrations/131.test.ts b/app/scripts/migrations/131.test.ts new file mode 100644 index 000000000000..f65dfe5996b2 --- /dev/null +++ b/app/scripts/migrations/131.test.ts @@ -0,0 +1,1176 @@ +import { migrate, version } from './131'; + +const PermissionNames = { + eth_accounts: 'eth_accounts', + permittedChains: 'endowment:permitted-chains', +}; + +const sentryCaptureExceptionMock = jest.fn(); + +global.sentry = { + captureException: sentryCaptureExceptionMock, +}; + +const oldVersion = 130; + +describe('migration #131', () => { + 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}: Invalid chainId for selectedNetworkClientId "nonExistentNetworkClientId" of type undefined`, + ), + ); + 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`, + ], + methods: [], + notifications: [], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + methods: [], + notifications: [], + }, + }, + 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(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`, + ], + methods: [], + notifications: [], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + methods: [], + notifications: [], + }, + }, + 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', + ], + methods: [], + notifications: [], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + methods: [], + notifications: [], + }, + }, + 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', + ], + methods: [], + notifications: [], + }, + }, + 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', + ], + methods: [], + notifications: [], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + methods: [], + notifications: [], + }, + }, + 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', + ], + methods: [], + notifications: [], + }, + 'eip155:100': { + accounts: [ + 'eip155:100:0xdeadbeef', + 'eip155:100:0x999', + ], + methods: [], + notifications: [], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0xdeadbeef', + 'wallet:eip155:0x999', + ], + methods: [], + notifications: [], + }, + }, + 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`], + methods: [], + notifications: [], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + methods: [], + notifications: [], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + 'test2.com': { + permissions: { + 'endowment:caip25': { + parentCapability: 'endowment:caip25', + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + [currentScope]: { + accounts: [`${currentScope}:0xdeadbeef`], + methods: [], + notifications: [], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + methods: [], + notifications: [], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }, + }, + }, + }, + }); + }); + }, + ); +}); diff --git a/app/scripts/migrations/131.ts b/app/scripts/migrations/131.ts new file mode 100644 index 000000000000..fdb0f618f9cd --- /dev/null +++ b/app/scripts/migrations/131.ts @@ -0,0 +1,291 @@ +import { hasProperty, isObject, NonEmptyArray, Json } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +type CaveatConstraint = { + type: string; + value: Json; +}; + +type PermissionConstraint = { + parentCapability: string; + caveats: null | NonEmptyArray; +}; + +const PermissionNames = { + eth_accounts: 'eth_accounts', + permittedChains: 'endowment:permitted-chains', +}; + +const BUILT_IN_NETWORKS = { + goerli: '0x5', + sepolia: '0xaa36a7', + mainnet: '0x1', + 'linea-goerli': '0xe704', + 'linea-sepolia': '0xe705', + 'linea-mainnet': '0xe708', +}; + +const Caip25CaveatType = 'authorizedScopes'; +const Caip25EndowmentPermissionName = 'endowment:caip25'; + +const snapsPrefixes = ['npm:', 'local:'] as const; + +type VersionedData = { + meta: { version: number }; + data: Record; +}; + +export const version = 131; + +/** + * 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) => { + 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 null; + } + if (!Array.isArray(networkConfiguration.rpcEndpoints)) { + global.sentry?.captureException( + new Error( + `Migration ${version}: typeof state.NetworkController.networkConfigurationsByChainId["${chainId}"].rpcEndpoints is ${typeof networkConfiguration.rpcEndpoints}`, + ), + ); + return null; + } + 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 null; + } + if (rpcEndpoint.networkClientId === networkClientId) { + return chainId; + } + } + } + + return BUILT_IN_NETWORKS[ + networkClientId as unknown as keyof typeof BUILT_IN_NETWORKS + ]; + }; + + const currentChainId = getChainIdForNetworkClientId(selectedNetworkClientId); + if (!currentChainId || typeof currentChainId !== 'string') { + global.sentry?.captureException( + new Error( + `Migration ${version}: Invalid chainId for selectedNetworkClientId "${selectedNetworkClientId}" of type ${typeof 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; + + 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) { + continue; + } + + if (chainIds.length === 0) { + chainIds = [currentChainId]; + + const networkClientIdForOrigin = domains[origin]; + if (networkClientIdForOrigin) { + const chainIdForOrigin = getChainIdForNetworkClientId( + networkClientIdForOrigin as string, + ); + if (chainIdForOrigin && typeof chainIdForOrigin === 'string') { + chainIds = [chainIdForOrigin]; + } + } + } + + const isSnap = snapsPrefixes.some((prefix) => origin.startsWith(prefix)); + const scopes: Record = {}; + const scopeStrings = isSnap + ? [] + : chainIds.map((chainId) => `eip155:${parseInt(chainId, 16)}`); + scopeStrings.push('wallet:eip155'); + + scopeStrings.forEach((scopeString) => { + const caipAccounts = ethAccounts.map( + (account) => `${scopeString}:${account}`, + ); + scopes[scopeString] = { + methods: [], + notifications: [], + accounts: caipAccounts, + }; + }); + + permissions[Caip25EndowmentPermissionName] = { + ...basePermission, + parentCapability: Caip25EndowmentPermissionName, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: scopes, + isMultichainOrigin: false, + }, + }, + ], + }; + } + + return state; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index a72fd34c3c28..d2c63eb2e35c 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -151,6 +151,7 @@ const migrations = [ require('./128'), require('./129'), require('./130'), + require('./131'), ]; export default migrations; diff --git a/html-report-caip27/index.html b/html-report-caip27/index.html new file mode 100644 index 000000000000..e3a023d1158d --- /dev/null +++ b/html-report-caip27/index.html @@ -0,0 +1,132 @@ + + + + + + + + OpenRPC API Test HTML Reporter + + + + +
+ + diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index d7522783f9fc..520857d06572 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1421,18 +1421,13 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-sig-util": true, - "@metamask/message-manager>jsonschema": true, "@metamask/utils": true, "browserify>buffer": true, + "jsonschema": true, "uuid": true, "webpack>events": true } }, - "@metamask/message-manager>jsonschema": { - "packages": { - "browserify>url": true - } - }, "@metamask/message-signing-snap>@noble/ciphers": { "globals": { "TextDecoder": true, @@ -2095,34 +2090,11 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/base-controller": true, "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true, - "@metamask/snaps-controllers>@metamask/json-rpc-engine": true - } - }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true + "@metamask/snaps-controllers>@metamask/json-rpc-engine": true, + "@metamask/utils": true } }, "@metamask/rate-limit-controller": { @@ -2906,6 +2878,62 @@ "crypto": true } }, + "@open-rpc/schema-utils-js": { + "packages": { + "@open-rpc/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, + "@open-rpc/schema-utils-js>@json-schema-tools/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "@open-rpc/schema-utils-js>ajv": true, + "@open-rpc/schema-utils-js>is-url": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver>@json-schema-spec/json-pointer": true, + "@open-rpc/test-coverage>isomorphic-fetch": true + } + }, + "@open-rpc/schema-utils-js>ajv": { + "globals": { + "console": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@open-rpc/schema-utils-js>ajv>json-schema-traverse": true, + "eslint>fast-deep-equal": true, + "uri-js": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch": { + "globals": { + "fetch.bind": true + }, + "packages": { + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": { + "globals": { + "AbortController": true, + "Blob": true, + "FileReader": true, + "FormData": true, + "URLSearchParams.prototype.isPrototypeOf": true, + "XMLHttpRequest": true, + "console.warn": true, + "define": true, + "setTimeout": true + } + }, "@popperjs/core": { "globals": { "Element": true, @@ -4547,6 +4575,11 @@ "readable-stream": true } }, + "jsonschema": { + "packages": { + "browserify>url": true + } + }, "koa>content-disposition>safe-buffer": { "packages": { "browserify>buffer": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index d7522783f9fc..520857d06572 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1421,18 +1421,13 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-sig-util": true, - "@metamask/message-manager>jsonschema": true, "@metamask/utils": true, "browserify>buffer": true, + "jsonschema": true, "uuid": true, "webpack>events": true } }, - "@metamask/message-manager>jsonschema": { - "packages": { - "browserify>url": true - } - }, "@metamask/message-signing-snap>@noble/ciphers": { "globals": { "TextDecoder": true, @@ -2095,34 +2090,11 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/base-controller": true, "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true, - "@metamask/snaps-controllers>@metamask/json-rpc-engine": true - } - }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true + "@metamask/snaps-controllers>@metamask/json-rpc-engine": true, + "@metamask/utils": true } }, "@metamask/rate-limit-controller": { @@ -2906,6 +2878,62 @@ "crypto": true } }, + "@open-rpc/schema-utils-js": { + "packages": { + "@open-rpc/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, + "@open-rpc/schema-utils-js>@json-schema-tools/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "@open-rpc/schema-utils-js>ajv": true, + "@open-rpc/schema-utils-js>is-url": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver>@json-schema-spec/json-pointer": true, + "@open-rpc/test-coverage>isomorphic-fetch": true + } + }, + "@open-rpc/schema-utils-js>ajv": { + "globals": { + "console": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@open-rpc/schema-utils-js>ajv>json-schema-traverse": true, + "eslint>fast-deep-equal": true, + "uri-js": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch": { + "globals": { + "fetch.bind": true + }, + "packages": { + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": { + "globals": { + "AbortController": true, + "Blob": true, + "FileReader": true, + "FormData": true, + "URLSearchParams.prototype.isPrototypeOf": true, + "XMLHttpRequest": true, + "console.warn": true, + "define": true, + "setTimeout": true + } + }, "@popperjs/core": { "globals": { "Element": true, @@ -4547,6 +4575,11 @@ "readable-stream": true } }, + "jsonschema": { + "packages": { + "browserify>url": true + } + }, "koa>content-disposition>safe-buffer": { "packages": { "browserify>buffer": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index d7522783f9fc..520857d06572 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1421,18 +1421,13 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-sig-util": true, - "@metamask/message-manager>jsonschema": true, "@metamask/utils": true, "browserify>buffer": true, + "jsonschema": true, "uuid": true, "webpack>events": true } }, - "@metamask/message-manager>jsonschema": { - "packages": { - "browserify>url": true - } - }, "@metamask/message-signing-snap>@noble/ciphers": { "globals": { "TextDecoder": true, @@ -2095,34 +2090,11 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/base-controller": true, "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true, - "@metamask/snaps-controllers>@metamask/json-rpc-engine": true - } - }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true + "@metamask/snaps-controllers>@metamask/json-rpc-engine": true, + "@metamask/utils": true } }, "@metamask/rate-limit-controller": { @@ -2906,6 +2878,62 @@ "crypto": true } }, + "@open-rpc/schema-utils-js": { + "packages": { + "@open-rpc/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, + "@open-rpc/schema-utils-js>@json-schema-tools/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "@open-rpc/schema-utils-js>ajv": true, + "@open-rpc/schema-utils-js>is-url": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver>@json-schema-spec/json-pointer": true, + "@open-rpc/test-coverage>isomorphic-fetch": true + } + }, + "@open-rpc/schema-utils-js>ajv": { + "globals": { + "console": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@open-rpc/schema-utils-js>ajv>json-schema-traverse": true, + "eslint>fast-deep-equal": true, + "uri-js": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch": { + "globals": { + "fetch.bind": true + }, + "packages": { + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": { + "globals": { + "AbortController": true, + "Blob": true, + "FileReader": true, + "FormData": true, + "URLSearchParams.prototype.isPrototypeOf": true, + "XMLHttpRequest": true, + "console.warn": true, + "define": true, + "setTimeout": true + } + }, "@popperjs/core": { "globals": { "Element": true, @@ -4547,6 +4575,11 @@ "readable-stream": true } }, + "jsonschema": { + "packages": { + "browserify>url": true + } + }, "koa>content-disposition>safe-buffer": { "packages": { "browserify>buffer": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 3df824f29c78..ce9a613816bb 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -1513,18 +1513,13 @@ "@metamask/base-controller": true, "@metamask/controller-utils": true, "@metamask/eth-sig-util": true, - "@metamask/message-manager>jsonschema": true, "@metamask/utils": true, "browserify>buffer": true, + "jsonschema": true, "uuid": true, "webpack>events": true } }, - "@metamask/message-manager>jsonschema": { - "packages": { - "browserify>url": true - } - }, "@metamask/message-signing-snap>@noble/ciphers": { "globals": { "TextDecoder": true, @@ -2187,34 +2182,11 @@ }, "@metamask/queued-request-controller": { "packages": { - "@metamask/queued-request-controller>@metamask/base-controller": true, - "@metamask/queued-request-controller>@metamask/utils": true, + "@metamask/base-controller": true, "@metamask/rpc-errors": true, "@metamask/selected-network-controller": true, - "@metamask/snaps-controllers>@metamask/json-rpc-engine": true - } - }, - "@metamask/queued-request-controller>@metamask/base-controller": { - "globals": { - "setTimeout": true - }, - "packages": { - "immer": true - } - }, - "@metamask/queued-request-controller>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true + "@metamask/snaps-controllers>@metamask/json-rpc-engine": true, + "@metamask/utils": true } }, "@metamask/rate-limit-controller": { @@ -2998,6 +2970,62 @@ "crypto": true } }, + "@open-rpc/schema-utils-js": { + "packages": { + "@open-rpc/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, + "@open-rpc/schema-utils-js>@json-schema-tools/meta-schema": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "@open-rpc/schema-utils-js>ajv": true, + "@open-rpc/schema-utils-js>is-url": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, + "eth-rpc-errors>fast-safe-stringify": true + } + }, + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": { + "packages": { + "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver>@json-schema-spec/json-pointer": true, + "@open-rpc/test-coverage>isomorphic-fetch": true + } + }, + "@open-rpc/schema-utils-js>ajv": { + "globals": { + "console": true + }, + "packages": { + "@metamask/snaps-utils>fast-json-stable-stringify": true, + "@open-rpc/schema-utils-js>ajv>json-schema-traverse": true, + "eslint>fast-deep-equal": true, + "uri-js": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch": { + "globals": { + "fetch.bind": true + }, + "packages": { + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": true + } + }, + "@open-rpc/test-coverage>isomorphic-fetch>whatwg-fetch": { + "globals": { + "AbortController": true, + "Blob": true, + "FileReader": true, + "FormData": true, + "URLSearchParams.prototype.isPrototypeOf": true, + "XMLHttpRequest": true, + "console.warn": true, + "define": true, + "setTimeout": true + } + }, "@popperjs/core": { "globals": { "Element": true, @@ -4639,6 +4667,11 @@ "readable-stream": true } }, + "jsonschema": { + "packages": { + "browserify>url": true + } + }, "koa>content-disposition>safe-buffer": { "packages": { "browserify>buffer": true diff --git a/package.json b/package.json index 4973fa0da559..0931ff1ca630 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "test:e2e:chrome:flask": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js --build-type flask", "test:e2e:chrome:webpack": "ENABLE_MV3=false SELENIUM_BROWSER=chrome node test/e2e/run-all.js", "test:api-specs": "SELENIUM_BROWSER=chrome ts-node test/e2e/run-openrpc-api-test-coverage.ts", + "test:api-specs-multichain": "SELENIUM_BROWSER=chrome ts-node test/e2e/run-api-specs-multichain.ts", "test:e2e:mmi:ci": "yarn playwright test --project=mmi --project=mmi.visual", "test:e2e:mmi:all": "yarn playwright test --project=mmi && yarn test:e2e:mmi:visual", "test:e2e:mmi:regular": "yarn playwright test --project=mmi", @@ -261,6 +262,11 @@ "@metamask/message-manager": "^10.1.0", "@metamask/gas-fee-controller@npm:^15.1.1": "patch:@metamask/gas-fee-controller@npm%3A15.1.2#~/.yarn/patches/@metamask-gas-fee-controller-npm-15.1.2-db4d2976aa.patch", "@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:^17.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", "@metamask/network-controller@npm:^19.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", "@metamask/network-controller@npm:^20.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", @@ -300,6 +306,7 @@ "@metamask/accounts-controller": "^18.2.2", "@metamask/address-book-controller": "^6.0.0", "@metamask/announcement-controller": "^7.0.0", + "@metamask/api-specs": "^0.10.12", "@metamask/approval-controller": "^7.0.0", "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A38.2.0#~/.yarn/patches/@metamask-assets-controllers-npm-38.2.0-40af2afaa7.patch", "@metamask/base-controller": "^7.0.0", @@ -345,7 +352,7 @@ "@metamask/preinstalled-example-snap": "^0.1.0", "@metamask/profile-sync-controller": "^0.9.7", "@metamask/providers": "^14.0.2", - "@metamask/queued-request-controller": "^2.0.0", + "@metamask/queued-request-controller": "^5.1.0", "@metamask/rate-limit-controller": "^6.0.0", "@metamask/rpc-errors": "^6.2.1", "@metamask/safe-event-emitter": "^3.1.1", @@ -404,6 +411,7 @@ "jest-junit": "^14.0.1", "json-rpc-engine": "^6.1.0", "json-rpc-middleware-stream": "^5.0.1", + "jsonschema": "^1.4.1", "labeled-stream-splicer": "^2.0.2", "localforage": "^1.9.0", "lodash": "^4.17.21", @@ -466,7 +474,6 @@ "@lavamoat/lavapack": "^6.1.0", "@lgbot/madge": "^6.2.0", "@lydell/node-pty": "^1.0.1", - "@metamask/api-specs": "^0.9.3", "@metamask/auto-changelog": "^2.1.0", "@metamask/build-utils": "^3.0.0", "@metamask/eslint-config": "^9.0.0", @@ -483,8 +490,8 @@ "@octokit/core": "^3.6.0", "@open-rpc/meta-schema": "^1.14.6", "@open-rpc/mock-server": "^1.7.5", - "@open-rpc/schema-utils-js": "^1.16.2", - "@open-rpc/test-coverage": "^2.2.2", + "@open-rpc/schema-utils-js": "^2.0.5", + "@open-rpc/test-coverage": "^2.2.4", "@playwright/test": "^1.39.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@sentry/cli": "^2.19.4", diff --git a/shared/constants/app.ts b/shared/constants/app.ts index 12b340d35fa5..64030e5da4a4 100644 --- a/shared/constants/app.ts +++ b/shared/constants/app.ts @@ -47,6 +47,11 @@ export const MESSAGE_TYPE = { TRANSACTION: 'transaction', WALLET_REQUEST_PERMISSIONS: 'wallet_requestPermissions', WATCH_ASSET: 'wallet_watchAsset', + WALLET_CREATE_SESSION: 'wallet_createSession', + WALLET_GET_SESSION: 'wallet_getSession', + WALLET_INVOKE_METHOD: 'wallet_invokeMethod', + WALLET_REVOKE_SESSION: 'wallet_revokeSession', + WALLET_SESSION_CHANGED: 'wallet_sessionChanged', WATCH_ASSET_LEGACY: 'metamask_watchAsset', SNAP_DIALOG_ALERT: DIALOG_APPROVAL_TYPES.alert, SNAP_DIALOG_CONFIRMATION: DIALOG_APPROVAL_TYPES.confirmation, diff --git a/shared/constants/network.ts b/shared/constants/network.ts index a98417794d81..a716fc5d5d0e 100644 --- a/shared/constants/network.ts +++ b/shared/constants/network.ts @@ -919,11 +919,11 @@ export const CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP = { * Ethereum JSON-RPC methods that are known to exist but that we intentionally * do not support. */ -export const UNSUPPORTED_RPC_METHODS = new Set([ +export const UNSUPPORTED_RPC_METHODS = [ // This is implemented later in our middleware stack – specifically, in // eth-json-rpc-middleware – but our UI does not support it. 'eth_signTransaction' as const, -]); +]; export const IPFS_DEFAULT_GATEWAY_URL = 'dweb.link'; diff --git a/shared/modules/caip-stream.test.ts b/shared/modules/caip-stream.test.ts index d97a18bda992..756a60e95bc3 100644 --- a/shared/modules/caip-stream.test.ts +++ b/shared/modules/caip-stream.test.ts @@ -1,5 +1,8 @@ import { Duplex, PassThrough } from 'readable-stream'; import { createDeferredPromise } from '@metamask/utils'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { deferredPromise } from '../../app/scripts/lib/util'; import { createCaipStream } from './caip-stream'; const writeToStream = async (stream: Duplex, message: unknown) => { @@ -77,5 +80,20 @@ describe('CAIP Stream', () => { { type: 'caip-x', data: { foo: 'bar' } }, ]); }); + + it('ends the substream when the source stream ends', async () => { + // using a fake stream here instead of PassThrough to prevent a loop + // when sourceStream gets written back to at the end of the CAIP pipeline + const sourceStream = new MockStream(); + + const providerStream = createCaipStream(sourceStream); + + const { promise, resolve } = deferredPromise(); + providerStream.on('close', () => resolve?.()); + + sourceStream.destroy(); + + await expect(promise).resolves.toBe(undefined); + }); }); }); diff --git a/shared/modules/caip-stream.ts b/shared/modules/caip-stream.ts index 3f13927efc27..09e0891bc3d6 100644 --- a/shared/modules/caip-stream.ts +++ b/shared/modules/caip-stream.ts @@ -65,9 +65,10 @@ export class CaipStream extends Duplex { export const createCaipStream = (portStream: Duplex): Duplex => { const caipStream = new CaipStream(); - pipeline(portStream, caipStream, portStream, (err: Error) => - console.log('MetaMask CAIP stream', err), - ); + pipeline(portStream, caipStream, portStream, (err: Error) => { + caipStream.substream.destroy(); + console.log('MetaMask CAIP stream', err); + }); return caipStream.substream; }; diff --git a/test/e2e/api-specs/ConfirmationRejectionRule.ts b/test/e2e/api-specs/ConfirmationRejectionRule.ts index 3e37dcd07fd7..7e8085fb97b2 100644 --- a/test/e2e/api-specs/ConfirmationRejectionRule.ts +++ b/test/e2e/api-specs/ConfirmationRejectionRule.ts @@ -113,8 +113,6 @@ export class ConfirmationsRejectRule implements Rule { await this.driver.executeScript( `window.ethereum.request(${switchEthereumChainRequest})`, ); - - await switchToOrOpenDapp(this.driver); } } catch (e) { console.log(e); diff --git a/test/e2e/api-specs/MultichainAuthorizationConfirmation.ts b/test/e2e/api-specs/MultichainAuthorizationConfirmation.ts new file mode 100644 index 000000000000..c7286257fad2 --- /dev/null +++ b/test/e2e/api-specs/MultichainAuthorizationConfirmation.ts @@ -0,0 +1,125 @@ +import Rule from '@open-rpc/test-coverage/build/rules/rule'; +import { Call } from '@open-rpc/test-coverage/build/coverage'; +import { + ContentDescriptorObject, + ExampleObject, + ExamplePairingObject, + MethodObject, +} from '@open-rpc/meta-schema'; +import paramsToObj from '@open-rpc/test-coverage/build/utils/params-to-obj'; +import _ from 'lodash'; +import { Driver } from '../webdriver/driver'; +import { WINDOW_TITLES, switchToOrOpenDapp } from '../helpers'; +import { addToQueue } from './helpers'; + +type MultichainAuthorizationConfirmationOptions = { + driver: Driver; + only?: string[]; +}; +// this rule makes sure that a multichain authorization confirmation dialog is shown and confirmed +export class MultichainAuthorizationConfirmation implements Rule { + private driver: Driver; + + private only: string[]; + + constructor(options: MultichainAuthorizationConfirmationOptions) { + this.driver = options.driver; + this.only = options.only || ['wallet_createSession']; + } + + getTitle() { + return 'Multichain Authorization Confirmation Rule'; + } + + async afterRequest(__: unknown, call: Call) { + await new Promise((resolve, reject) => { + addToQueue({ + name: 'afterRequest', + resolve, + reject, + task: async () => { + try { + await this.driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + const text = 'Connect'; + + await this.driver.findClickableElements({ + text, + tag: 'button', + }); + + const screenshot = await this.driver.driver.takeScreenshot(); + call.attachments = call.attachments || []; + call.attachments.push({ + type: 'image', + data: `data:image/png;base64,${screenshot}`, + }); + await this.driver.clickElement({ text, tag: 'button' }); + + // make sure to switch back to the dapp or else the next test will fail on the wrong window + await switchToOrOpenDapp(this.driver); + } catch (e) { + console.log(e); + } + }, + }); + }); + } + + // get all the confirmation calls to make and expect to pass + getCalls(__: unknown, method: MethodObject) { + const calls: Call[] = []; + const isMethodAllowed = this.only ? this.only.includes(method.name) : true; + if (isMethodAllowed) { + if (method.examples) { + // pull the first example + const e = method.examples[0]; + const ex = e as ExamplePairingObject; + + if (!ex.result) { + return calls; + } + const p = ex.params.map((_e) => (_e as ExampleObject).value); + const params = + method.paramStructure === 'by-name' + ? paramsToObj(p, method.params as ContentDescriptorObject[]) + : p; + calls.push({ + title: `${this.getTitle()} - with example ${ex.name}`, + methodName: method.name, + params, + url: '', + resultSchema: (method.result as ContentDescriptorObject).schema, + expectedResult: (ex.result as ExampleObject).value, + }); + } else { + // naively call the method with no params + calls.push({ + title: `${method.name} > multichain authorization confirmation`, + methodName: method.name, + params: [], + url: '', + resultSchema: (method.result as ContentDescriptorObject).schema, + }); + } + } + return calls; + } + + validateCall(call: Call) { + if (call.error) { + call.valid = false; + call.reason = `Expected a result but got error \ncode: ${call.error.code}\n message: ${call.error.message}`; + } else { + call.valid = _.isEqual(call.result, call.expectedResult); + if (!call.valid) { + call.reason = `Expected:\n${JSON.stringify( + call.expectedResult, + null, + 4, + )} but got\n${JSON.stringify(call.result, null, 4)}`; + } + } + return call; + } +} diff --git a/test/e2e/api-specs/MultichainAuthorizationConfirmationErrors.ts b/test/e2e/api-specs/MultichainAuthorizationConfirmationErrors.ts new file mode 100644 index 000000000000..5df26137125d --- /dev/null +++ b/test/e2e/api-specs/MultichainAuthorizationConfirmationErrors.ts @@ -0,0 +1,140 @@ +import Rule from '@open-rpc/test-coverage/build/rules/rule'; +import { Call } from '@open-rpc/test-coverage/build/coverage'; +import { + ContentDescriptorObject, + ErrorObject, + MethodObject, +} from '@open-rpc/meta-schema'; +import _ from 'lodash'; +import { Driver } from '../webdriver/driver'; +import { WINDOW_TITLES, switchToOrOpenDapp } from '../helpers'; +import { addToQueue } from './helpers'; + +type MultichainAuthorizationConfirmationOptions = { + driver: Driver; + only?: string[]; +}; +// this rule makes sure that a multichain authorization error codes are returned +export class MultichainAuthorizationConfirmationErrors implements Rule { + private driver: Driver; + + private only: string[]; + + private errorCodesToHitCancel: number[]; + + constructor(options: MultichainAuthorizationConfirmationOptions) { + this.driver = options.driver; + this.only = options.only || ['wallet_createSession']; + this.errorCodesToHitCancel = [5001, 5002]; + } + + getTitle() { + return 'Multichain Authorization Confirmation Rule'; + } + + async afterRequest(__: unknown, call: Call) { + await new Promise((resolve, reject) => { + addToQueue({ + name: 'afterRequest', + resolve, + reject, + task: async () => { + if (this.errorCodesToHitCancel.includes(call.expectedResult?.code)) { + try { + await this.driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + const text = 'Cancel'; + + await this.driver.findClickableElements({ + text: 'Cancel', + tag: 'button', + }); + + const screenshot = await this.driver.driver.takeScreenshot(); + call.attachments = call.attachments || []; + call.attachments.push({ + type: 'image', + data: `data:image/png;base64,${screenshot}`, + }); + await this.driver.clickElement({ text, tag: 'button' }); + // make sure to switch back to the dapp or else the next test will fail on the wrong window + await switchToOrOpenDapp(this.driver); + } catch (e) { + console.log(e); + } + } + }, + }); + }); + } + + getCalls(__: unknown, method: MethodObject) { + const calls: Call[] = []; + const isMethodAllowed = this.only ? this.only.includes(method.name) : true; + if (isMethodAllowed) { + if (method.errors) { + method.errors.forEach((err) => { + const unsupportedErrorCodes = [5000, 5100, 5101, 5102, 5300, 5301]; + const error = err as ErrorObject; + if (unsupportedErrorCodes.includes(error.code)) { + return; + } + let params: Record = {}; + switch (error.code) { + case 5100: + params = { + requiredScopes: { + 'eip155:10124': { + methods: ['eth_signTypedData_v4'], + notifications: [], + }, + }, + }; + break; + case 5302: + params = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_signTypedData_v4'], + notifications: [], + }, + }, + sessionProperties: {}, + }; + break; + default: + break; + } + + // params should make error happen (or lifecycle hooks will make it happen) + calls.push({ + title: `${this.getTitle()} - with error ${error.code} ${ + error.message + } `, + methodName: method.name, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: params as any, + url: '', + resultSchema: (method.result as ContentDescriptorObject).schema, + expectedResult: error, + }); + }); + } + } + return calls; + } + + validateCall(call: Call) { + if (call.error) { + call.valid = _.isEqual(call.error.code, call.expectedResult.code); + if (!call.valid) { + call.reason = `Expected:\n${JSON.stringify( + call.expectedResult, + null, + 4, + )} but got\n${JSON.stringify(call.error, null, 4)}`; + } + } + return call; + } +} diff --git a/test/e2e/api-specs/helpers.ts b/test/e2e/api-specs/helpers.ts index 51cdbbe47951..86b6850abc4c 100644 --- a/test/e2e/api-specs/helpers.ts +++ b/test/e2e/api-specs/helpers.ts @@ -1,6 +1,9 @@ import { v4 as uuid } from 'uuid'; import { ErrorObject } from '@open-rpc/meta-schema'; +import { JsonRpcResponse } from 'json-rpc-engine'; +import { JsonRpcFailure } from '@metamask/utils'; import { Driver } from '../webdriver/driver'; +import { ScopeString } from '../../../app/scripts/lib/multichain-api/scope'; // eslint-disable-next-line @typescript-eslint/no-shadow, @typescript-eslint/no-explicit-any declare let window: any; @@ -47,7 +50,6 @@ export const pollForResult = async ( generatedKey: string, ): Promise => { let result; - // eslint-disable-next-line no-loop-func await new Promise((resolve, reject) => { addToQueue({ name: 'pollResult', @@ -58,7 +60,7 @@ export const pollForResult = async ( `return window['${generatedKey}'];`, ); - if (result) { + if (result !== undefined && result !== null) { // clear the result await driver.executeScript(`delete window['${generatedKey}'];`); } else { @@ -75,9 +77,172 @@ export const pollForResult = async ( return pollForResult(driver, generatedKey); }; +export const createCaip27DriverTransport = ( + driver: Driver, + scopeMap: Record, + extensionId: string, +) => { + // use externally_connectable to communicate with the extension + // https://developer.chrome.com/docs/extensions/mv3/messaging/ + return async ( + __: string, + method: string, + params: unknown[] | Record, + ) => { + const generatedKey = uuid(); + addToQueue({ + name: 'transport', + resolve: () => { + // noop + }, + reject: () => { + // noop + }, + task: async () => { + // don't wait for executeScript to finish window.ethereum promise + // we need this because if we wait for the promise to resolve it + // will hang in selenium since it can only do one thing at a time. + // the workaround is to put the response on window.asyncResult and poll for it. + driver.executeScript( + ([m, p, g, s, e]: [ + string, + unknown[] | Record, + string, + ScopeString, + string, + ]) => { + const extensionPort = chrome.runtime.connect(e); + + const listener = ({ + type, + data, + }: { + type: string; + data: JsonRpcResponse; + }) => { + if (type !== 'caip-x') { + return; + } + if (data?.id !== g) { + return; + } + + if (data.id || (data as JsonRpcFailure).error) { + window[g] = data; + extensionPort.onMessage.removeListener(listener); + } + }; + + extensionPort.onMessage.addListener(listener); + const msg = { + type: 'caip-x', + data: { + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + request: { + method: m, + params: p, + }, + scope: s, + }, + id: g, + }, + }; + extensionPort.postMessage(msg); + }, + method, + params, + generatedKey, + scopeMap[method], + extensionId, + ); + }, + }); + return pollForResult(driver, generatedKey); + }; +}; + +export const createMultichainDriverTransport = ( + driver: Driver, + extensionId: string, +) => { + // use externally_connectable to communicate with the extension + // https://developer.chrome.com/docs/extensions/mv3/messaging/ + return async ( + __: string, + method: string, + params: unknown[] | Record, + ) => { + const generatedKey = uuid(); + addToQueue({ + name: 'transport', + resolve: () => { + // noop + }, + reject: () => { + // noop + }, + task: async () => { + // don't wait for executeScript to finish window.ethereum promise + // we need this because if we wait for the promise to resolve it + // will hang in selenium since it can only do one thing at a time. + // the workaround is to put the response on window.asyncResult and poll for it. + driver.executeScript( + ([m, p, g, e]: [ + string, + unknown[] | Record, + string, + string, + ]) => { + const extensionPort = chrome.runtime.connect(e); + + const listener = ({ + type, + data, + }: { + type: string; + data: JsonRpcResponse; + }) => { + if (type !== 'caip-x') { + return; + } + if (data?.id !== g) { + return; + } + + if (data.id || (data as JsonRpcFailure).error) { + window[g] = data; + extensionPort.onMessage.removeListener(listener); + } + }; + + extensionPort.onMessage.addListener(listener); + const msg = { + type: 'caip-x', + data: { + jsonrpc: '2.0', + method: m, + params: p, + id: g, + }, + }; + extensionPort.postMessage(msg); + }, + method, + params, + generatedKey, + extensionId, + ); + }, + }); + return pollForResult(driver, generatedKey); + }; +}; + export const createDriverTransport = (driver: Driver) => { return async ( - _: string, + __: string, method: string, params: unknown[] | Record, ) => { @@ -109,6 +274,7 @@ export const createDriverTransport = (driver: Driver) => { }) .catch((e: ErrorObject) => { window[g] = { + id: g, error: { code: e.code, message: e.message, diff --git a/test/e2e/api-specs/transform.ts b/test/e2e/api-specs/transform.ts new file mode 100644 index 000000000000..ccbd696d407c --- /dev/null +++ b/test/e2e/api-specs/transform.ts @@ -0,0 +1,334 @@ +import { + ExampleObject, + ExamplePairingObject, + MethodObject, + OpenrpcDocument, +} from '@open-rpc/meta-schema'; + +const transformOpenRPCDocument = ( + openrpcDocument: OpenrpcDocument, + chainId: number, + account: string, +): [OpenrpcDocument, string[], string[]] => { + // transform the document here + + const transaction = + openrpcDocument.components?.schemas?.TransactionInfo?.allOf?.[0]; + + if (transaction) { + delete transaction.unevaluatedProperties; + } + + const chainIdMethod = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'eth_chainId', + ); + (chainIdMethod as MethodObject).examples = [ + { + name: 'chainIdExample', + description: 'Example of a chainId request', + params: [], + result: { + name: 'chainIdResult', + value: `0x${chainId.toString(16)}`, + }, + }, + ]; + + const getBalanceMethod = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'eth_getBalance', + ); + + (getBalanceMethod as MethodObject).examples = [ + { + name: 'getBalanceExample', + description: 'Example of a getBalance request', + params: [ + { + name: 'address', + value: account, + }, + { + name: 'tag', + value: 'latest', + }, + ], + result: { + name: 'getBalanceResult', + value: '0x1a8819e0c9bab700', // can we get this from a variable too + }, + }, + ]; + + const blockNumber = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'eth_blockNumber', + ); + + (blockNumber as MethodObject).examples = [ + { + name: 'blockNumberExample', + description: 'Example of a blockNumber request', + params: [], + result: { + name: 'blockNumberResult', + value: '0x1', + }, + }, + ]; + + const personalSign = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'personal_sign', + ); + + (personalSign as MethodObject).examples = [ + { + name: 'personalSignExample', + description: 'Example of a personalSign request', + params: [ + { + name: 'data', + value: '0xdeadbeef', + }, + { + name: 'address', + value: account, + }, + ], + result: { + name: 'personalSignResult', + value: '0x1a8819e0c9bab700', + }, + }, + ]; + + const switchEthereumChain = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'wallet_switchEthereumChain', + ); + (switchEthereumChain as MethodObject).examples = [ + { + name: 'wallet_switchEthereumChain', + description: 'Example of a wallet_switchEthereumChain request to sepolia', + params: [ + { + name: 'SwitchEthereumChainParameter', + value: { + chainId: '0xaa36a7', + }, + }, + ], + result: { + name: 'wallet_switchEthereumChain', + value: null, + }, + }, + ]; + + const getProof = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'eth_getProof', + ); + + // delete invalid example until its fixed here: https://github.com/ethereum/execution-apis/pull/588 + ( + ((getProof as MethodObject).examples?.[0] as ExamplePairingObject) + ?.params[1] as ExampleObject + ).value.pop(); + + const signTypedData4 = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'eth_signTypedData_v4', + ); + + const signTypedData4Example = (signTypedData4 as MethodObject) + .examples?.[0] as ExamplePairingObject; + + // just update address for signTypedData + (signTypedData4Example.params[0] as ExampleObject).value = account; + + // update chainId for signTypedData + (signTypedData4Example.params[1] as ExampleObject).value.domain.chainId = + chainId; + + // net_version missing from execution-apis. see here: https://github.com/ethereum/execution-apis/issues/540 + const netVersion: MethodObject = { + name: 'net_version', + summary: 'Returns the current network ID.', + params: [], + result: { + description: 'Returns the current network ID.', + name: 'net_version', + schema: { + type: 'string', + }, + }, + description: 'Returns the current network ID.', + examples: [ + { + name: 'net_version', + description: 'Example of a net_version request', + params: [], + result: { + name: 'net_version', + description: 'The current network ID', + value: '0x1', + }, + }, + ], + }; + // add net_version + (openrpcDocument.methods as MethodObject[]).push( + netVersion as unknown as MethodObject, + ); + + const getEncryptionPublicKey = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'eth_getEncryptionPublicKey', + ); + + (getEncryptionPublicKey as MethodObject).examples = [ + { + name: 'getEncryptionPublicKeyExample', + description: 'Example of a getEncryptionPublicKey request', + params: [ + { + name: 'address', + value: account, + }, + ], + result: { + name: 'getEncryptionPublicKeyResult', + value: '0x1a8819e0c9bab700', + }, + }, + ]; + + const getTransactionCount = openrpcDocument.methods.find( + (m) => (m as MethodObject).name === 'eth_getTransactionCount', + ); + (getTransactionCount as MethodObject).examples = [ + { + name: 'getTransactionCountExampleEarliest', + description: 'Example of a pending getTransactionCount request', + params: [ + { + name: 'address', + value: account, + }, + { + name: 'tag', + value: 'earliest', + }, + ], + result: { + name: 'getTransactionCountResult', + value: '0x0', + }, + }, + { + name: 'getTransactionCountExampleFinalized', + description: 'Example of a pending getTransactionCount request', + params: [ + { + name: 'address', + value: account, + }, + { + name: 'tag', + value: 'finalized', + }, + ], + result: { + name: 'getTransactionCountResult', + value: '0x0', + }, + }, + { + name: 'getTransactionCountExampleSafe', + description: 'Example of a pending getTransactionCount request', + params: [ + { + name: 'address', + value: account, + }, + { + name: 'tag', + value: 'safe', + }, + ], + result: { + name: 'getTransactionCountResult', + value: '0x0', + }, + }, + { + name: 'getTransactionCountExample', + description: 'Example of a getTransactionCount request', + params: [ + { + name: 'address', + value: account, + }, + { + name: 'tag', + value: 'latest', + }, + ], + result: { + name: 'getTransactionCountResult', + value: '0x0', + }, + }, + // returns a number right now. see here: https://github.com/MetaMask/metamask-extension/pull/14822 + // { + // name: 'getTransactionCountExamplePending', + // description: 'Example of a pending getTransactionCount request', + // params: [ + // { + // name: 'address', + // value: account, + // }, + // { + // name: 'tag', + // value: 'pending', + // }, + // ], + // result: { + // name: 'getTransactionCountResult', + // value: '0x0', + // }, + // }, + ]; + // TODO: move these to a "Confirmation" tag in api-specs + const methodsWithConfirmations = [ + 'wallet_requestPermissions', + 'eth_requestAccounts', + 'wallet_watchAsset', + 'personal_sign', // requires permissions for eth_accounts + 'wallet_addEthereumChain', + 'eth_signTypedData_v4', // requires permissions for eth_accounts + 'wallet_switchEthereumChain', + + // commented out because its not returning 4001 error. + // see here https://github.com/MetaMask/metamask-extension/issues/24227 + // 'eth_getEncryptionPublicKey', // requires permissions for eth_accounts + ]; + const filteredMethods = openrpcDocument.methods + .filter((_m: unknown) => { + const m = _m as MethodObject; + return ( + m.name.includes('snap') || + m.name.includes('Snap') || + m.name.toLowerCase().includes('account') || + m.name.includes('crypt') || + m.name.includes('blob') || + m.name.includes('sendTransaction') || + m.name.startsWith('wallet_scanQRCode') || + methodsWithConfirmations.includes(m.name) || + // filters are currently 0 prefixed for odd length on + // extension which doesn't pass spec + // see here: https://github.com/MetaMask/eth-json-rpc-filters/issues/152 + m.name.includes('filter') || + m.name.includes('Filter') + ); + }) + .map((m) => (m as MethodObject).name); + return [openrpcDocument, filteredMethods, methodsWithConfirmations]; +}; + +export default transformOpenRPCDocument; diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 4c802e13bfa0..4fb4a2de4996 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -447,106 +447,156 @@ class FixtureBuilder { account = '', } = {}) { const selectedAccount = account || DEFAULT_FIXTURE_ACCOUNT; - return this.withPermissionController({ - subjects: { + let subjects = {}; + if (restrictReturnedAccounts) { + subjects = { [DAPP_URL]: { origin: DAPP_URL, permissions: { - eth_accounts: { - id: 'ZaqPEWxyhNCJYACFw93jE', - parentCapability: 'eth_accounts', - invoker: DAPP_URL, - caveats: restrictReturnedAccounts && [ + 'endowment:caip25': { + caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - selectedAccount.toLowerCase(), - '0x09781764c08de8ca82e156bbf156a3ca217c7950', - ERC_4337_ACCOUNT.toLowerCase(), - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + `eip155:1:${selectedAccount.toLowerCase()}`, + 'eip155:1:0x09781764c08de8ca82e156bbf156a3ca217c7950', + `eip155:1:${ERC_4337_ACCOUNT.toLowerCase()}`, + ], + }, + }, + isMultichainOrigin: false, + }, }, ], + id: 'ZaqPEWxyhNCJYACFw93jE', date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', }, }, }, - }, + }; + } + return this.withPermissionController({ + subjects, }); } withPermissionControllerSnapAccountConnectedToTestDapp( restrictReturnedAccounts = true, ) { - return this.withPermissionController({ - subjects: { + let subjects = {}; + if (restrictReturnedAccounts) { + subjects = { [DAPP_URL]: { origin: DAPP_URL, permissions: { - eth_accounts: { - id: 'ZaqPEWxyhNCJYACFw93jE', - parentCapability: 'eth_accounts', - invoker: DAPP_URL, - caveats: restrictReturnedAccounts && [ + 'endowment:caip25': { + caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x09781764c08de8ca82e156bbf156a3ca217c7950'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x09781764c08de8ca82e156bbf156a3ca217c7950', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], + id: 'ZaqPEWxyhNCJYACFw93jE', date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', }, }, }, - }, - }); + }; + } + return this.withPermissionController({ subjects }); } withPermissionControllerConnectedToTwoTestDapps( restrictReturnedAccounts = true, ) { - return this.withPermissionController({ - subjects: { + let subjects = {}; + if (restrictReturnedAccounts) { + subjects = { [DAPP_URL]: { origin: DAPP_URL, permissions: { - eth_accounts: { - id: 'ZaqPEWxyhNCJYACFw93jE', - parentCapability: 'eth_accounts', - invoker: DAPP_URL, - caveats: restrictReturnedAccounts && [ + 'endowment:caip25': { + caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', - '0x09781764c08de8ca82e156bbf156a3ca217c7950', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + 'eip155:1:0x09781764c08de8ca82e156bbf156a3ca217c7950', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], + 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 && [ + 'endowment:caip25': { + caveats: [ { - type: 'restrictReturnedAccounts', - value: [ - '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', - '0x09781764c08de8ca82e156bbf156a3ca217c7950', - ], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x5cfe73b6021e818b776b421b1c4db2474086a7e1', + 'eip155:1:0x09781764c08de8ca82e156bbf156a3ca217c7950', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], + id: 'ZaqPEWxyhNCJYACFw93jE', date: 1664388714636, + invoker: DAPP_ONE_URL, + parentCapability: 'endowment:caip25', }, }, }, - }, - }); + }; + } + return this.withPermissionController({ subjects }); } withPermissionControllerConnectedToSnapDapp() { @@ -1183,78 +1233,120 @@ 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:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + 'eip155:1: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:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + 'eip155:1: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:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + 'eip155:1:0xa5c5293e124d04e2f85e8553851001fd2f192647', + 'eip155:1: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:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0xbee150bdc171c7d4190891e78234f791a3ac7b24', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], date: 1708030049641, + id: 'AiblK84K1Cic-Y0FDSzMD', + invoker: 'https://coinmarketcap.com', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 926b152e899b..ada748e3f55b 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -50,6 +50,7 @@ const convertETHToHexGwei = (eth) => convertToHexValue(eth * 10 ** 18); * @property {Bundler} bundlerServer - The bundler server. * @property {mockttp.Mockttp} mockServer - The mock server. * @property {object} manifestFlags - Flags to add to the manifest in order to change things at runtime. + * @property {string} extensionId - the ID that the extension can be found at via externally_connectable. */ /** @@ -97,9 +98,11 @@ async function withFixtures(options, testSuite) { getServerMochaToBackground(); } - let webDriver; let driver; + let webDriver; + let extensionId; let failed = false; + try { if (!disableGanache) { await ganacheServer.start(ganacheOptions); @@ -184,7 +187,9 @@ async function withFixtures(options, testSuite) { setManifestFlags(manifestFlags); - driver = (await buildWebDriver(driverOptions)).driver; + const wd = await buildWebDriver(driverOptions); + driver = wd.driver; + extensionId = wd.extensionId; webDriver = driver.driver; if (process.env.SELENIUM_BROWSER === 'chrome') { @@ -222,6 +227,7 @@ async function withFixtures(options, testSuite) { mockedEndpoint, bundlerServer, mockServer, + extensionId, }); const errorsAndExceptions = driver.summarizeErrorsAndExceptions(); diff --git a/test/e2e/run-api-specs-multichain.ts b/test/e2e/run-api-specs-multichain.ts new file mode 100644 index 000000000000..181be598374b --- /dev/null +++ b/test/e2e/run-api-specs-multichain.ts @@ -0,0 +1,279 @@ +import testCoverage from '@open-rpc/test-coverage'; +import { parseOpenRPCDocument } from '@open-rpc/schema-utils-js'; +import HtmlReporter from '@open-rpc/test-coverage/build/reporters/html-reporter'; +import { + MultiChainOpenRPCDocument, + MetaMaskOpenRPCDocument, +} from '@metamask/api-specs'; + +import { MethodObject, OpenrpcDocument } from '@open-rpc/meta-schema'; +import JsonSchemaFakerRule from '@open-rpc/test-coverage/build/rules/json-schema-faker-rule'; +import ExamplesRule from '@open-rpc/test-coverage/build/rules/examples-rule'; +import { IOptions } from '@open-rpc/test-coverage/build/coverage'; +import { ScopeString } from '../../app/scripts/lib/multichain-api/scope'; +import { Driver, PAGES } from './webdriver/driver'; + +import { + createCaip27DriverTransport, + createMultichainDriverTransport, +} from './api-specs/helpers'; + +import FixtureBuilder from './fixture-builder'; +import { + withFixtures, + openDapp, + unlockWallet, + DAPP_URL, + ACCOUNT_1, +} from './helpers'; +import { MultichainAuthorizationConfirmation } from './api-specs/MultichainAuthorizationConfirmation'; +import transformOpenRPCDocument from './api-specs/transform'; +import { MultichainAuthorizationConfirmationErrors } from './api-specs/MultichainAuthorizationConfirmationErrors'; +import { ConfirmationsRejectRule } from './api-specs/ConfirmationRejectionRule'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +const mockServer = require('@open-rpc/mock-server/build/index').default; + +async function main() { + const port = 8545; + const chainId = 1337; + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder().build(), + disableGanache: true, + title: 'api-specs coverage', + }, + async ({ + driver, + extensionId, + }: { + driver: Driver; + extensionId: string; + }) => { + await unlockWallet(driver); + + // Navigate to extension home screen + await driver.navigate(PAGES.HOME); + + // Open Dapp + await openDapp(driver, undefined, DAPP_URL); + + const doc = await parseOpenRPCDocument( + MultiChainOpenRPCDocument as OpenrpcDocument, + ); + const providerAuthorize = doc.methods.find( + (m) => (m as MethodObject).name === 'wallet_createSession', + ); + + const walletRpcMethods: string[] = [ + 'wallet_registerOnboarding', + 'wallet_scanQRCode', + ]; + const walletEip155Methods = ['wallet_addEthereumChain']; + + const ignoreMethods = [ + 'wallet_switchEthereumChain', + 'wallet_getPermissions', + 'wallet_requestPermissions', + 'wallet_revokePermissions', + 'eth_requestAccounts', + 'eth_accounts', + 'eth_coinbase', + 'net_version', + ]; + + const transport = createMultichainDriverTransport(driver, extensionId); + const [transformedDoc, filteredMethods, methodsWithConfirmations] = + transformOpenRPCDocument( + MetaMaskOpenRPCDocument as OpenrpcDocument, + chainId, + ACCOUNT_1, + ); + const ethereumMethods = transformedDoc.methods + .map((m) => (m as MethodObject).name) + .filter((m) => { + const match = + walletRpcMethods.includes(m) || + walletEip155Methods.includes(m) || + ignoreMethods.includes(m); + return !match; + }); + const confirmationMethods = methodsWithConfirmations.filter( + (m) => !ignoreMethods.includes(m), + ); + const scopeMap: Record = { + [`eip155:${chainId}`]: ethereumMethods, + 'wallet:eip155': walletEip155Methods, + wallet: walletRpcMethods, + }; + + const reverseScopeMap = Object.entries(scopeMap).reduce( + (acc, [scope, methods]: [string, string[]]) => { + methods.forEach((method) => { + acc[method] = scope; + }); + return acc; + }, + {} as { [method: string]: string }, + ); + + // fix the example for wallet_createSession + (providerAuthorize as MethodObject).examples = [ + { + name: 'wallet_createSessionExample', + description: 'Example of a provider authorization request.', + params: [ + { + name: 'requiredScopes', + value: { + eip155: { + references: ['1337'], + methods: ethereumMethods, + notifications: ['eth_subscription'], + }, + 'wallet:eip155': { + methods: walletEip155Methods, + notifications: [], + }, + wallet: { + methods: walletRpcMethods, + notifications: [], + }, + }, + }, + ], + result: { + name: 'wallet_createSessionResultExample', + value: { + sessionScopes: { + [`eip155:${chainId}`]: { + accounts: [`eip155:${chainId}:${ACCOUNT_1}`], + methods: ethereumMethods, + notifications: ['eth_subscription'], + }, + 'wallet:eip155': { + accounts: [`wallet:eip155:${ACCOUNT_1}`], + methods: walletEip155Methods, + notifications: [], + }, + wallet: { + accounts: [`wallet:eip155:${ACCOUNT_1}`], + methods: walletRpcMethods, + notifications: [], + }, + }, + }, + }, + }, + ]; + + const server = mockServer( + port, + await parseOpenRPCDocument(transformedDoc), + ); + server.start(); + + const getSession = doc.methods.find( + (m) => (m as MethodObject).name === 'wallet_getSession', + ); + (getSession as MethodObject).examples = [ + { + name: 'wallet_getSessionExample', + description: 'Example of a provider authorization request.', + params: [], + result: { + name: 'wallet_getSessionResultExample', + value: { + sessionScopes: {}, + }, + }, + }, + ]; + + const testCoverageResults = await testCoverage({ + openrpcDocument: doc, + transport, + reporters: ['console-streaming'], + skip: ['wallet_invokeMethod'], + rules: [ + new ExamplesRule({ + skip: [], + only: ['wallet_getSession', 'wallet_revokeSession'], + }), + new MultichainAuthorizationConfirmation({ + driver, + }), + new MultichainAuthorizationConfirmationErrors({ + driver, + }), + ], + }); + + const testCoverageResultsCaip27 = await testCoverage({ + openrpcDocument: MetaMaskOpenRPCDocument as OpenrpcDocument, + transport: createCaip27DriverTransport( + driver, + reverseScopeMap, + extensionId, + ), + reporters: ['console-streaming'], + skip: [ + 'eth_coinbase', + 'wallet_revokePermissions', + 'wallet_requestPermissions', + 'wallet_getPermissions', + 'eth_accounts', + 'eth_requestAccounts', + 'net_version', // not in the spec yet for some reason + // these 2 methods below are not supported by MetaMask extension yet and + // don't get passed through. See here: https://github.com/MetaMask/metamask-extension/issues/24225 + 'eth_getBlockReceipts', + 'eth_maxPriorityFeePerGas', + ], + rules: [ + new JsonSchemaFakerRule({ + only: [], + skip: filteredMethods, + numCalls: 2, + }), + new ExamplesRule({ + only: [], + skip: filteredMethods, + }), + new ConfirmationsRejectRule({ + driver, + only: confirmationMethods, + }), + ], + }); + + const joinedResults = testCoverageResults.concat( + testCoverageResultsCaip27, + ); + + // fix ids for html reporter + joinedResults.forEach((r, index) => { + r.id = index; + }); + + const htmlReporter = new HtmlReporter({ + autoOpen: !process.env.CI, + destination: `${process.cwd()}/html-report-multichain`, + }); + + await htmlReporter.onEnd({} as IOptions, joinedResults); + + await driver.quit(); + + // if any of the tests failed, exit with a non-zero code + if (joinedResults.every((r) => r.valid)) { + process.exit(0); + } else { + process.exit(1); + } + }, + ); +} + +main(); diff --git a/test/e2e/run-openrpc-api-test-coverage.ts b/test/e2e/run-openrpc-api-test-coverage.ts index f192f6088954..a48ac4237e68 100644 --- a/test/e2e/run-openrpc-api-test-coverage.ts +++ b/test/e2e/run-openrpc-api-test-coverage.ts @@ -4,12 +4,8 @@ import HtmlReporter from '@open-rpc/test-coverage/build/reporters/html-reporter' import ExamplesRule from '@open-rpc/test-coverage/build/rules/examples-rule'; import JsonSchemaFakerRule from '@open-rpc/test-coverage/build/rules/json-schema-faker-rule'; -import { - ExampleObject, - ExamplePairingObject, - MethodObject, -} from '@open-rpc/meta-schema'; -import openrpcDocument from '@metamask/api-specs'; +import { OpenrpcDocument } from '@open-rpc/meta-schema'; +import { MetaMaskOpenRPCDocument } from '@metamask/api-specs'; import { ConfirmationsRejectRule } from './api-specs/ConfirmationRejectionRule'; import { Driver, PAGES } from './webdriver/driver'; @@ -24,6 +20,7 @@ import { DAPP_URL, ACCOUNT_1, } from './helpers'; +import transformOpenRPCDocument from './api-specs/transform'; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const mockServer = require('@open-rpc/mock-server/build/index').default; @@ -48,324 +45,19 @@ async function main() { await openDapp(driver, undefined, DAPP_URL); const transport = createDriverTransport(driver); - - const transaction = - openrpcDocument.components?.schemas?.TransactionInfo?.allOf?.[0]; - - if (transaction) { - delete transaction.unevaluatedProperties; - } - - const chainIdMethod = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'eth_chainId', - ); - (chainIdMethod as MethodObject).examples = [ - { - name: 'chainIdExample', - description: 'Example of a chainId request', - params: [], - result: { - name: 'chainIdResult', - value: `0x${chainId.toString(16)}`, - }, - }, - ]; - - const getBalanceMethod = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'eth_getBalance', - ); - - (getBalanceMethod as MethodObject).examples = [ - { - name: 'getBalanceExample', - description: 'Example of a getBalance request', - params: [ - { - name: 'address', - value: ACCOUNT_1, - }, - { - name: 'tag', - value: 'latest', - }, - ], - result: { - name: 'getBalanceResult', - value: '0x1a8819e0c9bab700', // can we get this from a variable too - }, - }, - ]; - - const blockNumber = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'eth_blockNumber', - ); - - (blockNumber as MethodObject).examples = [ - { - name: 'blockNumberExample', - description: 'Example of a blockNumber request', - params: [], - result: { - name: 'blockNumberResult', - value: '0x1', - }, - }, - ]; - - const personalSign = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'personal_sign', - ); - - (personalSign as MethodObject).examples = [ - { - name: 'personalSignExample', - description: 'Example of a personalSign request', - params: [ - { - name: 'data', - value: '0xdeadbeef', - }, - { - name: 'address', - value: ACCOUNT_1, - }, - ], - result: { - name: 'personalSignResult', - value: '0x1a8819e0c9bab700', - }, - }, - ]; - - const switchEthereumChain = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'wallet_switchEthereumChain', - ); - (switchEthereumChain as MethodObject).examples = [ - { - name: 'wallet_switchEthereumChain', - description: - 'Example of a wallet_switchEthereumChain request to sepolia', - params: [ - { - name: 'SwitchEthereumChainParameter', - value: { - chainId: '0xaa36a7', - }, - }, - ], - result: { - name: 'wallet_switchEthereumChain', - value: null, - }, - }, - ]; - - const signTypedData4 = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'eth_signTypedData_v4', - ); - - const signTypedData4Example = (signTypedData4 as MethodObject) - .examples?.[0] as ExamplePairingObject; - - // just update address for signTypedData - (signTypedData4Example.params[0] as ExampleObject).value = ACCOUNT_1; - - // update chainId for signTypedData - ( - signTypedData4Example.params[1] as ExampleObject - ).value.domain.chainId = 1337; - - // net_version missing from execution-apis. see here: https://github.com/ethereum/execution-apis/issues/540 - const netVersion: MethodObject = { - name: 'net_version', - summary: 'Returns the current network ID.', - params: [], - result: { - description: 'Returns the current network ID.', - name: 'net_version', - schema: { - type: 'string', - }, - }, - description: 'Returns the current network ID.', - examples: [ - { - name: 'net_version', - description: 'Example of a net_version request', - params: [], - result: { - name: 'net_version', - description: 'The current network ID', - value: '0x1', - }, - }, - ], - }; - // add net_version - (openrpcDocument.methods as MethodObject[]).push( - netVersion as unknown as MethodObject, - ); - - const getEncryptionPublicKey = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'eth_getEncryptionPublicKey', - ); - - (getEncryptionPublicKey as MethodObject).examples = [ - { - name: 'getEncryptionPublicKeyExample', - description: 'Example of a getEncryptionPublicKey request', - params: [ - { - name: 'address', - value: ACCOUNT_1, - }, - ], - result: { - name: 'getEncryptionPublicKeyResult', - value: '0x1a8819e0c9bab700', - }, - }, - ]; - - const getTransactionCount = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'eth_getTransactionCount', - ); - (getTransactionCount as MethodObject).examples = [ - { - name: 'getTransactionCountExampleEarliest', - description: 'Example of a pending getTransactionCount request', - params: [ - { - name: 'address', - value: ACCOUNT_1, - }, - { - name: 'tag', - value: 'earliest', - }, - ], - result: { - name: 'getTransactionCountResult', - value: '0x0', - }, - }, - { - name: 'getTransactionCountExampleFinalized', - description: 'Example of a pending getTransactionCount request', - params: [ - { - name: 'address', - value: ACCOUNT_1, - }, - { - name: 'tag', - value: 'finalized', - }, - ], - result: { - name: 'getTransactionCountResult', - value: '0x0', - }, - }, - { - name: 'getTransactionCountExampleSafe', - description: 'Example of a pending getTransactionCount request', - params: [ - { - name: 'address', - value: ACCOUNT_1, - }, - { - name: 'tag', - value: 'safe', - }, - ], - result: { - name: 'getTransactionCountResult', - value: '0x0', - }, - }, - { - name: 'getTransactionCountExample', - description: 'Example of a getTransactionCount request', - params: [ - { - name: 'address', - value: ACCOUNT_1, - }, - { - name: 'tag', - value: 'latest', - }, - ], - result: { - name: 'getTransactionCountResult', - value: '0x0', - }, - }, - // returns a number right now. see here: https://github.com/MetaMask/metamask-extension/pull/14822 - // { - // name: 'getTransactionCountExamplePending', - // description: 'Example of a pending getTransactionCount request', - // params: [ - // { - // name: 'address', - // value: ACCOUNT_1, - // }, - // { - // name: 'tag', - // value: 'pending', - // }, - // ], - // result: { - // name: 'getTransactionCountResult', - // value: '0x0', - // }, - // }, - ]; - - const server = mockServer(port, openrpcDocument); + const [doc, filteredMethods, methodsWithConfirmations] = + transformOpenRPCDocument( + MetaMaskOpenRPCDocument as unknown as OpenrpcDocument, + chainId, + ACCOUNT_1, + ); + const parsedDoc = await parseOpenRPCDocument(doc); + + const server = mockServer(port, parsedDoc); server.start(); - // TODO: move these to a "Confirmation" tag in api-specs - const methodsWithConfirmations = [ - 'wallet_requestPermissions', - 'eth_requestAccounts', - 'wallet_watchAsset', - 'personal_sign', // requires permissions for eth_accounts - 'wallet_addEthereumChain', - 'eth_signTypedData_v4', // requires permissions for eth_accounts - 'wallet_switchEthereumChain', - - // commented out because its not returning 4001 error. - // see here https://github.com/MetaMask/metamask-extension/issues/24227 - // 'eth_getEncryptionPublicKey', // requires permissions for eth_accounts - ]; - const filteredMethods = openrpcDocument.methods - .filter((_m: unknown) => { - const m = _m as MethodObject; - return ( - m.name.includes('snap') || - m.name.includes('Snap') || - m.name.toLowerCase().includes('account') || - m.name.includes('crypt') || - m.name.includes('blob') || - m.name.includes('sendTransaction') || - m.name.startsWith('wallet_scanQRCode') || - methodsWithConfirmations.includes(m.name) || - // filters are currently 0 prefixed for odd length on - // extension which doesn't pass spec - // see here: https://github.com/MetaMask/eth-json-rpc-filters/issues/152 - m.name.includes('filter') || - m.name.includes('Filter') - ); - }) - .map((m) => (m as MethodObject).name); - const testCoverageResults = await testCoverage({ - openrpcDocument: (await parseOpenRPCDocument( - openrpcDocument as never, - )) as never, + openrpcDocument: parsedDoc, transport, reporters: [ 'console-streaming', diff --git a/test/e2e/tests/request-queuing/ui.spec.js b/test/e2e/tests/request-queuing/ui.spec.js index b857d4307d5b..f940c35d0e69 100644 --- a/test/e2e/tests/request-queuing/ui.spec.js +++ b/test/e2e/tests/request-queuing/ui.spec.js @@ -60,7 +60,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/webdriver/chrome.js b/test/e2e/webdriver/chrome.js index fa56c107439e..891828ddefeb 100644 --- a/test/e2e/webdriver/chrome.js +++ b/test/e2e/webdriver/chrome.js @@ -110,6 +110,7 @@ class ChromeDriver { return { driver, extensionUrl: `chrome-extension://${extensionId}`, + extensionId, }; } 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..7b6e2595c63d 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,27 @@ 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': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, 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..aac1d6731464 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 @@ -148,7 +148,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; 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 7a849577dfa4..36d7c9a363f1 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 @@ -69,15 +69,27 @@ const render = ( subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -200,15 +212,31 @@ 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': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + invoker: 'https://test.dapp', + parentCapability: 'endowment:caip25', }, - ], - invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + }, }, }, }, @@ -327,15 +355,27 @@ describe('AccountListMenu', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, @@ -438,15 +478,27 @@ describe('AccountListMenu', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + 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..c7d0e91644db 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,30 @@ 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': { + methods: [], + notifications: [], + 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 ba842efc6a11..47a9b43071f9 100644 --- a/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx +++ b/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx @@ -30,6 +30,7 @@ import { } from '../../../helpers/constants/design-system'; import { getURLHost } from '../../../helpers/utils/util'; import { MergedInternalAccount } from '../../../selectors/selectors.types'; +import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; import { MetaMetricsEventCategory, MetaMetricsEventName, @@ -141,8 +142,12 @@ export const EditAccountsModal: React.FC = ({ isPinned={Boolean(account.pinned)} startAccessory={ + isEqualCaseInsensitive( + selectedAccountAddress, + account.address, + ), )} /> } diff --git a/ui/components/multichain/pages/connections/connections.test.tsx b/ui/components/multichain/pages/connections/connections.test.tsx index 0840e3a2f9ed..ae351be699a7 100644 --- a/ui/components/multichain/pages/connections/connections.test.tsx +++ b/ui/components/multichain/pages/connections/connections.test.tsx @@ -40,17 +40,29 @@ 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': { + methods: [], + notifications: [], + 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 +79,27 @@ 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': { + methods: [], + notifications: [], + 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..3dbb77f3a2e3 100644 --- a/ui/components/multichain/pages/connections/connections.tsx +++ b/ui/components/multichain/pages/connections/connections.tsx @@ -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/permissions-page.test.js b/ui/components/multichain/pages/permissions-page/permissions-page.test.js index 026cddeff34d..1adbdd5febe5 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,29 @@ 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': { + methods: [], + notifications: [], + 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 bb3a14a8f5e8..43d6efdd598e 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,6 +14,7 @@ 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, @@ -59,7 +60,9 @@ export const SiteCell: React.FC = ({ const [showEditNetworksModal, setShowEditNetworksModal] = useState(false); const selectedAccounts = accounts.filter(({ address }) => - selectedAccountAddresses.includes(address), + selectedAccountAddresses.some((selectedAccountAddress) => + isEqualCaseInsensitive(selectedAccountAddress, address), + ), ); const selectedNetworks = allNetworks.filter(({ chainId }) => selectedChainIds.includes(chainId), 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..0905db72ecce 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,27 @@ const render = ( subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + 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..1aff89ae8015 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,30 @@ 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': { + methods: [], + notifications: [], + 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/pages/permissions-connect/connect-page/connect-page.tsx b/ui/pages/permissions-connect/connect-page/connect-page.tsx index a30047fbd38a..1bac3ceafbc2 100644 --- a/ui/pages/permissions-connect/connect-page/connect-page.tsx +++ b/ui/pages/permissions-connect/connect-page/connect-page.tsx @@ -33,11 +33,20 @@ 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 { + CaveatTypes, + EndowmentTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; import PermissionsConnectFooter from '../../../components/app/permissions-connect-footer'; export type ConnectPageRequest = { id: string; origin: string; + permissions?: Record< + string, + { caveats: { type: string; value: string[] }[] } + >; }; type ConnectPageProps = { @@ -57,6 +66,20 @@ export const ConnectPage: React.FC = ({ }) => { const t = useI18nContext(); + const ethAccountsPermission = + request?.permissions?.[RestrictedMethods.eth_accounts]; + const requestedAccounts = + ethAccountsPermission?.caveats?.find( + (caveat) => caveat.type === CaveatTypes.restrictReturnedAccounts, + )?.value || []; + + const permittedChainsPermission = + request?.permissions?.[EndowmentTypes.permittedChains]; + const requestedChainIds = + permittedChainsPermission?.caveats?.find( + (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, + )?.value || []; + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); const [nonTestNetworks, testNetworks] = useMemo( () => @@ -70,7 +93,10 @@ export const ConnectPage: React.FC = ({ ), [networkConfigurations], ); - const defaultSelectedChainIds = nonTestNetworks.map(({ chainId }) => chainId); + const defaultSelectedChainIds = + requestedChainIds.length > 0 + ? requestedChainIds + : nonTestNetworks.map(({ chainId }) => chainId); const [selectedChainIds, setSelectedChainIds] = useState( defaultSelectedChainIds, ); @@ -84,7 +110,10 @@ export const ConnectPage: React.FC = ({ }, [accounts, internalAccounts]); const currentAccount = useSelector(getSelectedInternalAccount); - const defaultAccountsAddresses = [currentAccount?.address]; + const defaultAccountsAddresses = + requestedAccounts.length > 0 + ? requestedAccounts + : [currentAccount?.address]; const [selectedAccountAddresses, setSelectedAccountAddresses] = useState( defaultAccountsAddresses, ); diff --git a/ui/pages/routes/routes.component.test.js b/ui/pages/routes/routes.component.test.js index 6151fedc687b..23cedb96cbd4 100644 --- a/ui/pages/routes/routes.component.test.js +++ b/ui/pages/routes/routes.component.test.js @@ -200,16 +200,26 @@ describe('toast display', () => { subjects: { [mockOrigin]: { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: [mockAccount.address], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + 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 fb32d41c9b17..1d0ecb14208b 100644 --- a/ui/selectors/permissions.js +++ b/ui/selectors/permissions.js @@ -1,9 +1,18 @@ 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'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + // TODO: move these into shared path + // eslint-disable-next-line import/no-restricted-paths +} from '../../app/scripts/lib/multichain-api/caip25permissions'; +// TODO: move these into shared path +// eslint-disable-next-line import/no-restricted-paths +import { getEthAccounts } from '../../app/scripts/lib/multichain-api/adapters/caip-permission-adapter-eth-accounts'; +// TODO: move these into shared path // eslint-disable-next-line import/no-restricted-paths -import { PermissionNames } from '../../app/scripts/controllers/permissions'; +import { getPermittedEthChainIds } from '../../app/scripts/lib/multichain-api/adapters/caip-permission-adapter-permittedChains'; import { getApprovalRequestsByType } from './approvals'; import { createDeepEqualSelector } from './util'; import { @@ -58,13 +67,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 +283,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..f07b5422bf1e 100644 --- a/ui/selectors/permissions.test.js +++ b/ui/selectors/permissions.test.js @@ -46,33 +46,57 @@ describe('selectors', () => { subjects: { 'peepeth.com': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + 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 +171,58 @@ describe('selectors', () => { subjects: { 'peepeth.com': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + 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 +348,61 @@ 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': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + 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 +621,86 @@ 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': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + 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': { + methods: [], + notifications: [], + 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 +728,31 @@ 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': { + methods: [], + notifications: [], + 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 8d71048e0924..f150dc498ed0 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -1637,15 +1637,27 @@ describe('Selectors', () => { subjects: { 'https://test.dapp': { permissions: { - eth_accounts: { + 'endowment:caip25': { caveats: [ { - type: 'restrictReturnedAccounts', - value: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [ + 'eip155:1:0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ], + }, + }, + isMultichainOrigin: false, + }, }, ], invoker: 'https://test.dapp', - parentCapability: 'eth_accounts', + parentCapability: 'endowment:caip25', }, }, }, diff --git a/yarn.lock b/yarn.lock index 733c94112452..5969d72c5ab6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4085,13 +4085,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" @@ -4125,6 +4132,17 @@ __metadata: languageName: node linkType: hard +"@json-schema-tools/dereferencer@npm:^1.6.3": + version: 1.6.3 + resolution: "@json-schema-tools/dereferencer@npm:1.6.3" + dependencies: + "@json-schema-tools/reference-resolver": "npm:^1.2.6" + "@json-schema-tools/traverse": "npm:^1.10.4" + fast-safe-stringify: "npm:^2.1.1" + checksum: 10/da6ef5b82a8a9c3a7e62ffcab5c04c581f1e0f8165c0debdb272bb1e08ccd726107ee194487b8fa736cac00fb390b8df74bc1ad1b200eddbe25c98ee0d3d000b + languageName: node + linkType: hard + "@json-schema-tools/meta-schema@npm:1.6.19": version: 1.6.19 resolution: "@json-schema-tools/meta-schema@npm:1.6.19" @@ -4132,37 +4150,37 @@ __metadata: languageName: node 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 +"@json-schema-tools/meta-schema@npm:^1.6.10, @json-schema-tools/meta-schema@npm:^1.7.5": + 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 -"@json-schema-tools/traverse@npm:^1.7.5, @json-schema-tools/traverse@npm:^1.7.8": - version: 1.10.3 - resolution: "@json-schema-tools/traverse@npm:1.10.3" - checksum: 10/690623740d223ea373d8e561dad5c70bf86461bcedc5fc45da01c87bcdf3284bbdbad3006d4a423f8d82e4b2d4580e45f92c0b272f006024fb597d7f01876215 +"@json-schema-tools/traverse@npm:^1.10.4, @json-schema-tools/traverse@npm:^1.7.5, @json-schema-tools/traverse@npm:^1.7.8": + version: 1.10.4 + resolution: "@json-schema-tools/traverse@npm:1.10.4" + checksum: 10/0027bc90df01c5eeee0833e722b7320b53be8b5ce3f4e0e4a6e45713a38e6f88f21aba31e3dd973093ef75cd21a40c07fe8f112da8f49a7919b1c0e44c904d20 languageName: node linkType: hard @@ -4472,29 +4490,29 @@ __metadata: linkType: hard "@mantine/core@npm:^7.8.0": - version: 7.10.2 - resolution: "@mantine/core@npm:7.10.2" + version: 7.8.0 + resolution: "@mantine/core@npm:7.8.0" dependencies: "@floating-ui/react": "npm:^0.26.9" - clsx: "npm:^2.1.1" + clsx: "npm:2.1.0" react-number-format: "npm:^5.3.1" react-remove-scroll: "npm:^2.5.7" react-textarea-autosize: "npm:8.5.3" type-fest: "npm:^4.12.0" peerDependencies: - "@mantine/hooks": 7.10.2 + "@mantine/hooks": 7.8.0 react: ^18.2.0 react-dom: ^18.2.0 - checksum: 10/312c3d8777c18b30ca04a2ebf43b110b4f888fb3cfeb4f1dce6dd6b37ca5656ffa160a707fe2933bef87bcfda9e3249f21ff7dab673d3a01f660044ae8bf31e7 + checksum: 10/9d3ba53cd41a7b725579dec3723ee92444b45c51a0ed7a283caad047c1a9d3d4c8edf2cf143c4abf8959f3ade62cc7cdcb552fe1f4bfc1ef0f6a6d6136ce958f languageName: node linkType: hard "@mantine/hooks@npm:^7.8.0": - version: 7.10.2 - resolution: "@mantine/hooks@npm:7.10.2" + version: 7.8.0 + resolution: "@mantine/hooks@npm:7.8.0" peerDependencies: react: ^18.2.0 - checksum: 10/cdf16046e42fbe266ed4ac5a1e053fa541a30de21485fe0d5a964f5261f8e40df4b028768059f11326a5ff702b5da07c8672fb3df2bcb747234ec84e14cec4a6 + checksum: 10/723d963995076574842ed3296a9acc63fd4a279d265a80edfe7f32c0359f70821e86dc5da5c37c13ce4ce8edfcbbf2c0c32a3024c60a7167172034cf6ddac220 languageName: node linkType: hard @@ -4842,10 +4860,10 @@ __metadata: languageName: node linkType: hard -"@metamask/api-specs@npm:^0.9.3": - version: 0.9.3 - resolution: "@metamask/api-specs@npm:0.9.3" - checksum: 10/803852ba43a0fbabb43aeba2ca63e43d22a99d35710700aa04c92cc85184c93024b052b2ee43831762341848de42d172c99485fa7b659249e75255ff8d29d0b2 +"@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 @@ -6121,8 +6139,8 @@ __metadata: linkType: hard "@metamask/providers@npm:^17.1.2": - version: 17.2.0 - resolution: "@metamask/providers@npm:17.2.0" + version: 17.1.2 + resolution: "@metamask/providers@npm:17.1.2" dependencies: "@metamask/json-rpc-engine": "npm:^9.0.1" "@metamask/json-rpc-middleware-stream": "npm:^8.0.1" @@ -6137,24 +6155,24 @@ __metadata: readable-stream: "npm:^3.6.2" peerDependencies: webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/b2fc93cdc059528bfeb14a61d6153f9a5f2679e5c6640648c16cd4e5067f758a67c2c6abab962615e878e6b9d7f1bbcd3632584ad7e57ec9df8c16f47b13e608 + checksum: 10/bf555f9774e340d4497c09c980094e759a198f11c5a78b403e639cf01904b9ec3b19a5e9f53567465dd8739da4138e2021ac9a404a99b1a6022add12a4b19a31 languageName: node linkType: hard -"@metamask/queued-request-controller@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/queued-request-controller@npm:2.0.0" +"@metamask/queued-request-controller@npm:^5.1.0": + version: 5.1.0 + resolution: "@metamask/queued-request-controller@npm:5.1.0" dependencies: - "@metamask/base-controller": "npm:^6.0.0" - "@metamask/controller-utils": "npm:^11.0.0" - "@metamask/json-rpc-engine": "npm:^9.0.0" - "@metamask/rpc-errors": "npm:^6.2.1" + "@metamask/base-controller": "npm:^7.0.1" + "@metamask/controller-utils": "npm:^11.3.0" + "@metamask/json-rpc-engine": "npm:^9.0.3" + "@metamask/rpc-errors": "npm:^6.3.1" "@metamask/swappable-obj-proxy": "npm:^2.2.0" - "@metamask/utils": "npm:^8.3.0" + "@metamask/utils": "npm:^9.1.0" peerDependencies: - "@metamask/network-controller": ^19.0.0 - "@metamask/selected-network-controller": ^15.0.0 - checksum: 10/b618fa05465a52e5b689d932d99b47552b5987a9141d58260966611f1057190132f14b1a2123c48399f218fc57c577e1c86375e8ee2b43871cdc597fbaeedb7a + "@metamask/network-controller": ^21.0.0 + "@metamask/selected-network-controller": ^18.0.0 + checksum: 10/71bfc03a1b4de2e611c4a744edf9b0159b9ed7245f62ffd040cf700b717820dcb78844c503bf73d4bac0ad377e0b91d4e20afd18381999fd5fd16c9c2d80b966 languageName: node linkType: hard @@ -6690,9 +6708,9 @@ __metadata: linkType: hard "@noble/ciphers@npm:^0.5.1, @noble/ciphers@npm:^0.5.2": - version: 0.5.3 - resolution: "@noble/ciphers@npm:0.5.3" - checksum: 10/af0ad96b5807feace93e63549e05de6f5e305b36e2e95f02d90532893fbc3af3f19b9621b6de4caa98303659e5df2e7aa082064e5d4a82e6f38c728d48dfae5d + version: 0.5.2 + resolution: "@noble/ciphers@npm:0.5.2" + checksum: 10/47a5958954249d5edb49aff48ae6fbfff4d4e5a6bb221c010ebc8e7470c410e9208a2f3f6bf8b7eca83057277478f4ccbdbdcf1bfd324608b334b9f9d28a9fbb languageName: node linkType: hard @@ -7011,10 +7029,10 @@ __metadata: languageName: node 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 +"@open-rpc/meta-schema@npm:^1.14.6, @open-rpc/meta-schema@npm:^1.14.9": + version: 1.14.9 + resolution: "@open-rpc/meta-schema@npm:1.14.9" + checksum: 10/51505dcf7aa1a2285c78953c9b33711cede5f2765aa37dcb9ee7756d689e2ff2a89cfc6039504f0569c52a805fb9aa18f30a7c02ad7a06e793c801e43b419104 languageName: node linkType: hard @@ -7087,6 +7105,24 @@ __metadata: languageName: node linkType: hard +"@open-rpc/schema-utils-js@npm:^2.0.5": + version: 2.0.5 + resolution: "@open-rpc/schema-utils-js@npm:2.0.5" + dependencies: + "@json-schema-tools/dereferencer": "npm:^1.6.3" + "@json-schema-tools/meta-schema": "npm:^1.7.5" + "@json-schema-tools/reference-resolver": "npm:^1.2.6" + "@open-rpc/meta-schema": "npm:^1.14.9" + ajv: "npm:^6.10.0" + detect-node: "npm:^2.0.4" + fast-safe-stringify: "npm:^2.0.7" + fs-extra: "npm:^10.1.0" + is-url: "npm:^1.2.4" + isomorphic-fetch: "npm:^3.0.0" + checksum: 10/9e10215606e9a00a47b082c9cfd70d05bf0d38de6cf1c147246c545c6997375d94cd3caafe919b71178df58b5facadfd0dcc8b6857bf5e79c40e5e33683dd3d5 + languageName: node + linkType: hard + "@open-rpc/server-js@npm:1.9.3": version: 1.9.3 resolution: "@open-rpc/server-js@npm:1.9.3" @@ -7103,11 +7139,12 @@ __metadata: languageName: node linkType: hard -"@open-rpc/test-coverage@npm:^2.2.2": - version: 2.2.2 - resolution: "@open-rpc/test-coverage@npm:2.2.2" +"@open-rpc/test-coverage@npm:^2.2.4": + version: 2.2.4 + resolution: "@open-rpc/test-coverage@npm:2.2.4" dependencies: "@open-rpc/html-reporter-react": "npm:^0.0.4" + "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/schema-utils-js": "npm:^1.16.2" "@types/isomorphic-fetch": "npm:0.0.35" "@types/lodash": "npm:^4.14.162" @@ -7119,7 +7156,7 @@ __metadata: lodash: "npm:^4.17.20" bin: open-rpc-test-coverage: bin/cli.js - checksum: 10/fc764031d8395dca73187684143f07cd2f6be854bedbd943b086e46f94e5c4207942bf87f1d4ac66f4220f209d6d4a7d50b0eb70d4586e2d07a4e086f0e344b1 + checksum: 10/4bde5b40404a2bdd9f5c2f37b8bdeb1afb21cf0c9a192b508dbf3efd2cf3d2334ed3a149b18bd6546c5754c6f3a78b26832be3677caf2fff9a87f722c7b721f1 languageName: node linkType: hard @@ -11200,11 +11237,11 @@ __metadata: linkType: hard "@types/ws@npm:*, @types/ws@npm:^8.5.10": - version: 8.5.10 - resolution: "@types/ws@npm:8.5.10" + version: 8.5.11 + resolution: "@types/ws@npm:8.5.11" dependencies: "@types/node": "npm:*" - checksum: 10/9b414dc5e0b6c6f1ea4b1635b3568c58707357f68076df9e7cd33194747b7d1716d5189c0dbdd68c8d2521b148e88184cf881bac7429eb0e5c989b001539ed31 + checksum: 10/950d13b762fc7c092a0fc1450c41229a1d41abb93cb72251068885bd46fa4bbcf461c00df2e77de3f7a547371998b650a720ed90417562af0772b14a8a009dec languageName: node linkType: hard @@ -14950,6 +14987,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:2.1.0": + version: 2.1.0 + resolution: "clsx@npm:2.1.0" + checksum: 10/2e0ce7c3b6803d74fc8147c408f88e79245583202ac14abd9691e2aebb9f312de44270b79154320d10bb7804a9197869635d1291741084826cff20820f31542b + languageName: node + linkType: hard + "clsx@npm:^1.0.4": version: 1.1.1 resolution: "clsx@npm:1.1.1" @@ -14957,13 +15001,6 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^2.1.1": - version: 2.1.1 - resolution: "clsx@npm:2.1.1" - checksum: 10/cdfb57fa6c7649bbff98d9028c2f0de2f91c86f551179541cf784b1cfdc1562dcb951955f46d54d930a3879931a980e32a46b598acaea274728dbe068deca919 - languageName: node - linkType: hard - "cmd-shim@npm:^6.0.0": version: 6.0.1 resolution: "cmd-shim@npm:6.0.1" @@ -19202,7 +19239,7 @@ __metadata: languageName: node linkType: hard -"fast-safe-stringify@npm:^2.0.6, fast-safe-stringify@npm:^2.0.7": +"fast-safe-stringify@npm:^2.0.6, fast-safe-stringify@npm:^2.0.7, fast-safe-stringify@npm:^2.1.1": version: 2.1.1 resolution: "fast-safe-stringify@npm:2.1.1" checksum: 10/dc1f063c2c6ac9533aee14d406441f86783a8984b2ca09b19c2fe281f9ff59d315298bc7bc22fd1f83d26fe19ef2f20e2ddb68e96b15040292e555c5ced0c1e4 @@ -24408,10 +24445,10 @@ __metadata: languageName: node linkType: hard -"jsonschema@npm:^1.2.4": - version: 1.2.4 - resolution: "jsonschema@npm:1.2.4" - checksum: 10/7b959737416a5716f2df3142e30c8685bc5449974d56d1cd5acbbd61c0f71041af38fa315327c8577fcdbe30907fd9b633c4d3484baf2cc8563609afac5b4e14 +"jsonschema@npm:^1.2.4, jsonschema@npm:^1.4.1": + version: 1.4.1 + resolution: "jsonschema@npm:1.4.1" + checksum: 10/d7a188da7a3100a2caa362b80e98666d46607b7a7153aac405b8e758132961911c6df02d444d4700691330874e21a62639f550e856b21ddd28423690751ca9c6 languageName: node linkType: hard @@ -26124,7 +26161,7 @@ __metadata: "@metamask/accounts-controller": "npm:^18.2.2" "@metamask/address-book-controller": "npm:^6.0.0" "@metamask/announcement-controller": "npm:^7.0.0" - "@metamask/api-specs": "npm:^0.9.3" + "@metamask/api-specs": "npm:^0.10.12" "@metamask/approval-controller": "npm:^7.0.0" "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A38.2.0#~/.yarn/patches/@metamask-assets-controllers-npm-38.2.0-40af2afaa7.patch" "@metamask/auto-changelog": "npm:^2.1.0" @@ -26181,7 +26218,7 @@ __metadata: "@metamask/preinstalled-example-snap": "npm:^0.1.0" "@metamask/profile-sync-controller": "npm:^0.9.7" "@metamask/providers": "npm:^14.0.2" - "@metamask/queued-request-controller": "npm:^2.0.0" + "@metamask/queued-request-controller": "npm:^5.1.0" "@metamask/rate-limit-controller": "npm:^6.0.0" "@metamask/rpc-errors": "npm:^6.2.1" "@metamask/safe-event-emitter": "npm:^3.1.1" @@ -26204,8 +26241,8 @@ __metadata: "@octokit/core": "npm:^3.6.0" "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/mock-server": "npm:^1.7.5" - "@open-rpc/schema-utils-js": "npm:^1.16.2" - "@open-rpc/test-coverage": "npm:^2.2.2" + "@open-rpc/schema-utils-js": "npm:^2.0.5" + "@open-rpc/test-coverage": "npm:^2.2.4" "@playwright/test": "npm:^1.39.0" "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.11" "@popperjs/core": "npm:^2.4.0" @@ -26378,6 +26415,7 @@ __metadata: json-rpc-engine: "npm:^6.1.0" json-rpc-middleware-stream: "npm:^5.0.1" json-schema-to-ts: "npm:^3.0.1" + jsonschema: "npm:^1.4.1" koa: "npm:^2.7.0" labeled-stream-splicer: "npm:^2.0.2" lavamoat: "npm:^8.0.2"