diff --git a/.circleci/config.yml b/.circleci/config.yml index c46b88be39d9..51a9917a0df9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -159,6 +159,10 @@ workflows: requires: - prep-build-test - get-changed-files-with-git-diff + - test-api-specs-multichain: + requires: + - prep-build-test-flask + - get-changed-files-with-git-diff - test-e2e-chrome-multiple-providers: requires: - prep-build-test @@ -703,6 +707,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-flask ./dist + - run: + name: Move test zips to builds + command: mv ./builds-test-flask ./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/.depcheckrc.yml b/.depcheckrc.yml index 0ce708a73a0c..b199d6967c8d 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -23,6 +23,7 @@ ignores: - '@metamask/forwarder' - '@metamask/phishing-warning' # statically hosted as part of some e2e tests - '@metamask/test-dapp' + - '@metamask/test-dapp-multichain' - '@metamask/design-tokens' # Only imported in index.css - '@tsconfig/node22' # required dynamically by TS, used in tsconfig.json - '@sentry/cli' # invoked as `sentry-cli` diff --git a/.gitignore b/.gitignore index 5a3d141c990d..85ab55d3d548 100644 --- a/.gitignore +++ b/.gitignore @@ -77,10 +77,13 @@ lavamoat/**/policy-debug.json # Attributions licenseInfos.json +# Branding +/app/images/branding + # API Spec tests html-report/ +html-report-multichain/ -/app/images/branding /changed-files # UI Integration tests diff --git a/app/build-types/flask/manifest/_base.json b/app/build-types/flask/manifest/_base.json index bc43d646a9bc..2d1366b53c7a 100644 --- a/app/build-types/flask/manifest/_base.json +++ b/app/build-types/flask/manifest/_base.json @@ -11,6 +11,10 @@ }, "default_title": "MetaMask Flask" }, + "externally_connectable": { + "matches": ["http://*/*", "https://*/*"], + "ids": ["*"] + }, "icons": { "16": "images/icon-16.png", "19": "images/icon-19.png", diff --git a/app/manifest/v2/_barad_dur.json b/app/manifest/v2/_barad_dur.json deleted file mode 100644 index 304ebf8c4a24..000000000000 --- a/app/manifest/v2/_barad_dur.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "externally_connectable": { - "matches": ["http://*/*", "https://*/*"], - "ids": ["*"] - } -} diff --git a/app/manifest/v3/_barad_dur.json b/app/manifest/v3/_barad_dur.json deleted file mode 100644 index 304ebf8c4a24..000000000000 --- a/app/manifest/v3/_barad_dur.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "externally_connectable": { - "matches": ["http://*/*", "https://*/*"], - "ids": ["*"] - } -} diff --git a/app/scripts/background.js b/app/scripts/background.js index 8fe1f52c89d0..90ab46b67ce0 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -387,12 +387,13 @@ browser.runtime.onConnectExternal.addListener(async (...args) => { // Queue up connection attempts here, waiting until after initialization await isInitialized; // This is set in `setupController`, which is called as part of initialization - const port = args[0]; - if (port.sender.tab?.id && process.env.BARAD_DUR) { - connectExternalCaip(...args); - } else { + const port = args[0]; + const isDappConnecting = port.sender.tab?.id; + if (!process.env.MULTICHAIN_API || !isDappConnecting) { connectExternalExtension(...args); + } else { + connectExternalCaip(...args); } }); @@ -1012,6 +1013,10 @@ export function setupController( }; connectExternalCaip = async (remotePort) => { + if (!process.env.MULTICHAIN_API) { + return; + } + if (metamaskBlockedPorts.includes(remotePort.name)) { return; } diff --git a/app/scripts/controllers/permissions/differs.test.ts b/app/scripts/controllers/permissions/differs.test.ts new file mode 100644 index 000000000000..1f17ce42bc6a --- /dev/null +++ b/app/scripts/controllers/permissions/differs.test.ts @@ -0,0 +1,263 @@ +import { + diffMap, + getChangedAuthorizations, + getRemovedAuthorizations, +} from './differs'; + +describe('PermissionController selectors', () => { + describe('diffMap', () => { + it('returns the new value if the previous value is undefined', () => { + const newAccounts = new Map([['foo.bar', ['0x1']]]); + expect(diffMap(newAccounts, undefined)).toBe(newAccounts); + }); + + it('returns an empty map if the new and previous values are the same', () => { + const newAccounts = new Map([['foo.bar', ['0x1']]]); + expect(diffMap(newAccounts, newAccounts)).toStrictEqual(new Map()); + }); + + it('returns a new map of the changed key/value pairs if the new and previous maps differ', () => { + // We set this on the new and previous value under the key 'foo.bar' to + // check that identical values are excluded. + const identicalValue = ['0x1']; + + const previousAccounts = new Map([ + ['bar.baz', ['0x1']], // included: different accounts + ['fizz.buzz', ['0x1']], // included: removed in new value + ]); + previousAccounts.set('foo.bar', identicalValue); + + const newAccounts = new Map([ + ['bar.baz', ['0x1', '0x2']], // included: different accounts + ['baz.fizz', ['0x3']], // included: brand new + ]); + newAccounts.set('foo.bar', identicalValue); + + expect(diffMap(newAccounts, previousAccounts)).toStrictEqual( + new Map([ + ['bar.baz', ['0x1', '0x2']], + ['fizz.buzz', []], + ['baz.fizz', ['0x3']], + ]), + ); + }); + }); + + describe('getChangedAuthorizations', () => { + it('returns an empty map if the previous value is undefined', () => { + expect(getChangedAuthorizations(new Map(), undefined)).toStrictEqual( + new Map(), + ); + }); + + it('returns an empty map if the new and previous values are the same', () => { + const newAuthorizations = new Map(); + expect( + getChangedAuthorizations(newAuthorizations, newAuthorizations), + ).toStrictEqual(new Map()); + }); + + it('returns a new map of the current values of changed scopes but excluding removed scopes in authorizations', () => { + const previousAuthorizations = new Map([ + [ + 'foo.bar', + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead' as const], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + 'eip155:10': { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + ], + ]); + + const newAuthorizations = new Map([ + [ + 'foo.bar', + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef' as const], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x123' as const], + }, + }, + isMultichainOrigin: true, + }, + ], + ]); + + expect( + getChangedAuthorizations(newAuthorizations, previousAuthorizations), + ).toStrictEqual( + new Map([ + [ + 'foo.bar', + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xbeef'], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x123'], + }, + }, + }, + ], + ]), + ); + }); + + it('returns a new map with empty requiredScopes and optionalScopes for revoked authorizations', () => { + const previousAuthorizations = new Map([ + [ + 'foo.bar', + { + requiredScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdead' as const], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + 'eip155:10': { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + ], + ]); + + const newAuthorizations = new Map(); + + expect( + getChangedAuthorizations(newAuthorizations, previousAuthorizations), + ).toStrictEqual( + new Map([ + [ + 'foo.bar', + { + requiredScopes: {}, + optionalScopes: {}, + }, + ], + ]), + ); + }); + }); + + describe('getRemovedAuthorizations', () => { + it('returns an empty map if the previous value is undefined', () => { + expect(getRemovedAuthorizations(new Map(), undefined)).toStrictEqual( + new Map(), + ); + }); + + 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 scopes in authorizations', () => { + const previousAuthorizations = new Map([ + [ + 'foo.bar', + { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + 'eip155:10': { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + ], + ]); + + const newAuthorizations = new Map([ + [ + 'foo.bar', + { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: { + 'eip155:10': { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + ], + ]); + + expect( + getRemovedAuthorizations(newAuthorizations, previousAuthorizations), + ).toStrictEqual( + new Map([ + [ + 'foo.bar', + { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { + accounts: [], + }, + }, + }, + ], + ]), + ); + }); + + it('returns a new map of the revoked authorizations', () => { + const mockAuthorization = { + requiredScopes: { + 'eip155:1': { + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }; + 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]])); + }); + }); +}); diff --git a/app/scripts/controllers/permissions/differs.ts b/app/scripts/controllers/permissions/differs.ts new file mode 100644 index 000000000000..f770d5131544 --- /dev/null +++ b/app/scripts/controllers/permissions/differs.ts @@ -0,0 +1,183 @@ +import { + Caip25CaveatValue, + InternalScopesObject, + InternalScopeString, +} from '@metamask/multichain'; + +/** + * Returns a map containing key/value pairs for those that have been + * added, changed, or removed between two string:string[] maps + * + * @param currentMap - The new string:string[] map. + * @param previousMap - The previous string:string[] map. + * @returns The string:string[] map of changed key/values. + */ +export const diffMap = ( + currentMap: Map, + previousMap?: Map, +): Map => { + if (previousMap === undefined) { + return currentMap; + } + + const changedMap = new Map(); + if (currentMap === previousMap) { + return changedMap; + } + + const newKeys = new Set([...currentMap.keys()]); + + for (const key of previousMap.keys()) { + const currentValue = currentMap.get(key) ?? []; + const previousValue = previousMap.get(key); + + // 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 array initialized in the previous + // call to this function. `currentMap` will never contain any empty + // arrays. + if (currentValue !== previousValue) { + changedMap.set(key, currentValue); + } + + newKeys.delete(key); + } + + // By now, newKeys is either empty or contains some number of previously + // unencountered origins, and all of their origins have "changed". + for (const origin of newKeys.keys()) { + changedMap.set(origin, currentMap.get(origin)); + } + return changedMap; +}; + +/** + * Given the current and previous exposed CAIP-25 authorization for each PermissionController + * subject, returns a new map containing the current value of scopes added/changed in an authorization. + * The values of each map must be immutable values directly from the + * PermissionController state, or an empty object instantiated in this + * function. + * + * @param newAuthorizationsMap - The new origin:authorization map. + * @param [previousAuthorizationsMap] - The previous origin:authorization map. + * @returns The origin:authorization map of changed authorizations. + */ +export const getChangedAuthorizations = ( + newAuthorizationsMap: Map, + previousAuthorizationsMap?: Map, +): Map< + string, + Pick +> => { + 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, { + requiredScopes: newAuthorizations.requiredScopes, + optionalScopes: newAuthorizations.optionalScopes, + }); + } + + 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; +}; + +/** + * Given the current and previous exposed CAIP-25 authorization for each PermissionController + * subject, returns a new map containing the only the scopes removed entirely from an authorization. + * + * @param newAuthorizationsMap - The new origin:authorization map. + * @param [previousAuthorizationsMap] - The previous origin:authorization map. + * @returns The origin:authorization map of scopes removed from authorizations. + */ +export const getRemovedAuthorizations = ( + newAuthorizationsMap: Map, + previousAuthorizationsMap?: Map, +): Map< + string, + Pick +> => { + 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; + } + + for (const [ + origin, + previousAuthorization, + ] of previousAuthorizationsMap.entries()) { + const newAuthorization = newAuthorizationsMap.get(origin); + if (!newAuthorization) { + removedAuthorizations.set(origin, previousAuthorization); + continue; + } + + const removedRequiredScopes: InternalScopesObject = {}; + Object.entries(previousAuthorization.requiredScopes).forEach( + ([scope, prevScopeObject]) => { + const newScopeObject = + newAuthorization.requiredScopes[scope as InternalScopeString]; + if (!newScopeObject) { + removedRequiredScopes[scope as InternalScopeString] = prevScopeObject; + } + }, + ); + + const removedOptionalScopes: InternalScopesObject = {}; + Object.entries(previousAuthorization.optionalScopes).forEach( + ([scope, prevScopeObject]) => { + const newScopeObject = + newAuthorization.optionalScopes[scope as InternalScopeString]; + if (!newScopeObject) { + removedOptionalScopes[scope as InternalScopeString] = prevScopeObject; + } + }, + ); + + if ( + Object.keys(removedRequiredScopes).length > 0 || + Object.keys(removedOptionalScopes).length > 0 + ) { + removedAuthorizations.set(origin, { + requiredScopes: removedRequiredScopes, + optionalScopes: removedOptionalScopes, + }); + } + } + + return removedAuthorizations; +}; diff --git a/app/scripts/controllers/permissions/enums.ts b/app/scripts/controllers/permissions/enums.ts index 03228d3594e2..53237cab9b07 100644 --- a/app/scripts/controllers/permissions/enums.ts +++ b/app/scripts/controllers/permissions/enums.ts @@ -1,4 +1,5 @@ export enum NOTIFICATION_NAMES { accountsChanged = 'metamask_accountsChanged', chainChanged = 'metamask_chainChanged', + sessionChanged = 'wallet_sessionChanged', } diff --git a/app/scripts/controllers/permissions/index.js b/app/scripts/controllers/permissions/index.js index 76a460487dfe..a463423646c3 100644 --- a/app/scripts/controllers/permissions/index.js +++ b/app/scripts/controllers/permissions/index.js @@ -1,4 +1,5 @@ export * from './background-api'; +export * from './differs'; export * from './enums'; export * from './specifications'; export * from './selectors'; diff --git a/app/scripts/controllers/permissions/selectors.js b/app/scripts/controllers/permissions/selectors.js index 97464885b7a6..d66101d80800 100644 --- a/app/scripts/controllers/permissions/selectors.js +++ b/app/scripts/controllers/permissions/selectors.js @@ -1,10 +1,10 @@ -import { createSelector } from 'reselect'; import { Caip25CaveatType, Caip25EndowmentPermissionName, getEthAccounts, getPermittedEthChainIds, } from '@metamask/multichain'; +import { createDeepEqualSelector } from '../../../../shared/modules/selectors/util'; /** * This file contains selectors for PermissionController selector event @@ -26,7 +26,7 @@ const getSubjects = (state) => state.subjects; * * @returns {Map} The current origin:accounts[] map. */ -export const getPermittedAccountsByOrigin = createSelector( +export const getPermittedAccountsByOrigin = createDeepEqualSelector( getSubjects, (subjects) => { return Object.values(subjects).reduce((originToAccountsMap, subject) => { @@ -44,6 +44,33 @@ export const getPermittedAccountsByOrigin = createSelector( }, ); +/** + * 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 = createDeepEqualSelector( + 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 @@ -51,7 +78,7 @@ export const getPermittedAccountsByOrigin = createSelector( * * @returns {Map} The current origin:chainIds[] map. */ -export const getPermittedChainsByOrigin = createSelector( +export const getPermittedChainsByOrigin = createDeepEqualSelector( getSubjects, (subjects) => { return Object.values(subjects).reduce((originToChainsMap, subject) => { @@ -68,47 +95,3 @@ export const getPermittedChainsByOrigin = createSelector( }, new Map()); }, ); - -/** - * Returns a map containing key/value pairs for those that have been - * added, changed, or removed between two string:string[] maps - * - * @param {Map} currentMap - The new string:string[] map. - * @param {Map} previousMap - The previous string:string[] map. - * @returns {Map} The string:string[] map of changed key/values. - */ -export const diffMap = (currentMap, previousMap) => { - if (previousMap === undefined) { - return currentMap; - } - - const changedMap = new Map(); - if (currentMap === previousMap) { - return changedMap; - } - - const newKeys = new Set([...currentMap.keys()]); - - for (const key of previousMap.keys()) { - const currentValue = currentMap.get(key) ?? []; - const previousValue = previousMap.get(key); - - // 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 array initialized in the previous - // call to this function. `currentMap` will never contain any empty - // arrays. - if (currentValue !== previousValue) { - changedMap.set(key, currentValue); - } - - newKeys.delete(key); - } - - // By now, newKeys is either empty or contains some number of previously - // unencountered origins, and all of their origins have "changed". - for (const origin of newKeys.keys()) { - changedMap.set(origin, currentMap.get(origin)); - } - return changedMap; -}; diff --git a/app/scripts/controllers/permissions/selectors.test.js b/app/scripts/controllers/permissions/selectors.test.js index 9a6cc10a9a07..c06c9e88e7cf 100644 --- a/app/scripts/controllers/permissions/selectors.test.js +++ b/app/scripts/controllers/permissions/selectors.test.js @@ -4,50 +4,11 @@ import { Caip25EndowmentPermissionName, } from '@metamask/multichain'; import { - diffMap, getPermittedAccountsByOrigin, getPermittedChainsByOrigin, } from './selectors'; describe('PermissionController selectors', () => { - describe('diffMap', () => { - it('returns the new value if the previous value is undefined', () => { - const newAccounts = new Map([['foo.bar', ['0x1']]]); - expect(diffMap(newAccounts)).toBe(newAccounts); - }); - - it('returns an empty map if the new and previous values are the same', () => { - const newAccounts = new Map([['foo.bar', ['0x1']]]); - expect(diffMap(newAccounts, newAccounts)).toStrictEqual(new Map()); - }); - - it('returns a new map of the changed key/value pairs if the new and previous maps differ', () => { - // We set this on the new and previous value under the key 'foo.bar' to - // check that identical values are excluded. - const identicalValue = ['0x1']; - - const previousAccounts = new Map([ - ['bar.baz', ['0x1']], // included: different accounts - ['fizz.buzz', ['0x1']], // included: removed in new value - ]); - previousAccounts.set('foo.bar', identicalValue); - - const newAccounts = new Map([ - ['bar.baz', ['0x1', '0x2']], // included: different accounts - ['baz.fizz', ['0x3']], // included: brand new - ]); - newAccounts.set('foo.bar', identicalValue); - - expect(diffMap(newAccounts, previousAccounts)).toStrictEqual( - new Map([ - ['bar.baz', ['0x1', '0x2']], - ['fizz.buzz', []], - ['baz.fizz', ['0x3']], - ]), - ); - }); - }); - describe('getPermittedAccountsByOrigin', () => { it('memoizes and gets permitted accounts by origin', () => { const state1 = { diff --git a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js index 9be36a3dbced..e3325612d7aa 100644 --- a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js +++ b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.js @@ -28,6 +28,10 @@ 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. * @@ -35,7 +39,7 @@ export const createEthAccountsMethodMiddleware = makeMethodMiddlewareMaker([ * handler implementations. * @returns The method middleware factory function. */ -function makeMethodMiddlewareMaker(handlers) { +export function makeMethodMiddlewareMaker(handlers) { const handlerMap = handlers.reduce((map, handler) => { for (const methodName of handler.methodNames) { map[methodName] = handler; diff --git a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js index 4a3b9f958a16..b919b195db5c 100644 --- a/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js +++ b/app/scripts/lib/rpc-method-middleware/createMethodMiddleware.test.js @@ -6,6 +6,7 @@ import { import { createEip1193MethodMiddleware, createEthAccountsMethodMiddleware, + createMultichainMethodMiddleware, } from '.'; const getHandler = () => ({ @@ -60,6 +61,7 @@ jest.mock('./handlers', () => ({ describe.each([ ['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..341eb1b98706 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(new Set()); const nextMock = jest.fn(); const endMock = jest.fn(); @@ -22,8 +22,8 @@ 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 default unsupported rpc methods when no list is provided: %s', (method: string) => { const middleware = createUnsupportedMethodMiddleware(); const nextMock = jest.fn(); @@ -37,4 +37,23 @@ describe('createUnsupportedMethodMiddleware', () => { expect(endMock).toHaveBeenCalledTimes(1); }, ); + + const unsupportedMethods = new Set(['foo', 'bar']); + + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([...unsupportedMethods])( + 'ends requests for methods that are in the provided list of unsupported methods: %s', + (method: string) => { + const middleware = createUnsupportedMethodMiddleware(unsupportedMethods); + const nextMock = jest.fn(); + const endMock = jest.fn(); + + const response = getMockResponse(); + middleware(getMockRequest(method), response, nextMock, endMock); + + expect('result' in response).toBe(false); + expect(nextMock).not.toHaveBeenCalled(); + expect(endMock).toHaveBeenCalledTimes(1); + }, + ); }); diff --git a/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts b/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts index c96201041d36..a6a537e06bc3 100644 --- a/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts +++ b/app/scripts/lib/rpc-method-middleware/createUnsupportedMethodMiddleware.ts @@ -6,13 +6,14 @@ import { UNSUPPORTED_RPC_METHODS } from '../../../../shared/constants/network'; /** * Creates a middleware that rejects explicitly unsupported RPC methods with the * appropriate error. + * + * @param methods - The list of unsupported RPC methods. */ -export function createUnsupportedMethodMiddleware(): JsonRpcMiddleware< - JsonRpcParams, - null -> { +export function createUnsupportedMethodMiddleware( + methods: Set = UNSUPPORTED_RPC_METHODS, +): JsonRpcMiddleware { return async function unsupportedMethodMiddleware(req, _res, next, end) { - if ((UNSUPPORTED_RPC_METHODS as Set).has(req.method)) { + if (methods.has(req.method)) { return end(rpcErrors.methodNotSupported()); } return next(); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/handler.test.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/handler.test.ts new file mode 100644 index 000000000000..e60df06c1444 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/handler.test.ts @@ -0,0 +1,553 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + Caip25Authorization, + NormalizedScopesObject, +} from '@metamask/multichain'; +import * as Multichain from '@metamask/multichain'; +import { Json, JsonRpcRequest, JsonRpcSuccess } from '@metamask/utils'; +import * as Util from '../../../util'; +import { walletCreateSession } from './handler'; + +jest.mock('../../../util', () => ({ + ...jest.requireActual('../../../util'), + shouldEmitDappViewedEvent: jest.fn(), +})); +const MockUtil = jest.mocked(Util); + +jest.mock('@metamask/multichain', () => ({ + ...jest.requireActual('@metamask/multichain'), + validateAndNormalizeScopes: jest.fn(), + bucketScopes: jest.fn(), + getSessionScopes: jest.fn(), + getSupportedScopeObjects: jest.fn(), +})); +const MockMultichain = jest.mocked(Multichain); + +const baseRequest = { + jsonrpc: '2.0' as const, + id: 0, + method: 'wallet_createSession', + 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({ + permissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + 'wallet:eip155:0x4', + ], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + }, + }, + }); + const grantPermissions = jest.fn().mockResolvedValue(undefined); + const findNetworkClientIdByChainId = jest.fn().mockReturnValue('mainnet'); + const sendMetrics = jest.fn(); + const metamaskState = { + permissionHistory: {}, + metaMetricsId: 'metaMetricsId', + accounts: { + '0x1': {}, + '0x2': {}, + '0x3': {}, + }, + }; + const listAccounts = jest.fn().mockReturnValue([]); + const response = { + jsonrpc: '2.0' as const, + id: 0, + } as unknown as JsonRpcSuccess<{ + sessionScopes: NormalizedScopesObject; + sessionProperties?: Record; + }>; + const handler = ( + request: JsonRpcRequest & { origin: string }, + ) => + walletCreateSession.implementation(request, response, next, end, { + findNetworkClientIdByChainId, + requestPermissionApprovalForOrigin, + grantPermissions, + metamaskState, + sendMetrics, + listAccounts, + }); + + return { + response, + next, + end, + findNetworkClientIdByChainId, + requestPermissionApprovalForOrigin, + grantPermissions, + metamaskState, + sendMetrics, + listAccounts, + handler, + }; +}; + +describe('wallet_createSession', () => { + beforeEach(() => { + MockMultichain.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: {}, + }); + MockMultichain.bucketScopes.mockReturnValue({ + supportedScopes: {}, + supportableScopes: {}, + unsupportableScopes: {}, + }); + MockMultichain.getSessionScopes.mockReturnValue({}); + MockMultichain.getSupportedScopeObjects.mockImplementation( + (scopesObject) => scopesObject, + ); + }); + + 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 JsonRpcError(5302, 'Invalid sessionProperties requested'), + ); + }); + + it('processes the scopes', async () => { + const { handler } = createMockedHandler(); + await handler({ + ...baseRequest, + params: { + ...baseRequest.params, + optionalScopes: { + foo: { + methods: [], + notifications: [], + }, + }, + }, + }); + + expect(MockMultichain.validateAndNormalizeScopes).toHaveBeenCalledWith( + baseRequest.params.requiredScopes, + { + foo: { + methods: [], + notifications: [], + }, + }, + ); + }); + + it('throws an error when processing scopes fails', async () => { + const { handler, end } = createMockedHandler(); + MockMultichain.validateAndNormalizeScopes.mockImplementation(() => { + throw new Error('failed to process scopes'); + }); + await handler(baseRequest); + expect(end).toHaveBeenCalledWith(new Error('failed to process scopes')); + }); + + it('filters the required scopesObjects', async () => { + const { handler } = createMockedHandler(); + MockMultichain.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + normalizedOptionalScopes: {}, + }); + await handler(baseRequest); + + expect(MockMultichain.getSupportedScopeObjects).toHaveBeenNthCalledWith(1, { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }); + }); + + it('filters the optional scopesObjects', async () => { + const { handler } = createMockedHandler(); + MockMultichain.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + }); + await handler(baseRequest); + + expect(MockMultichain.getSupportedScopeObjects).toHaveBeenNthCalledWith(2, { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }); + }); + + it('buckets the required scopes', async () => { + const { handler } = createMockedHandler(); + MockMultichain.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + normalizedOptionalScopes: {}, + }); + await handler(baseRequest); + + expect(MockMultichain.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 = + MockMultichain.bucketScopes.mock.calls[0][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + it('buckets the optional scopes', async () => { + const { handler } = createMockedHandler(); + MockMultichain.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + 'eip155:100': { + methods: ['eth_chainId'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:100:0x4'], + }, + }, + }); + await handler(baseRequest); + + expect(MockMultichain.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 = + MockMultichain.bucketScopes.mock.calls[1][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + 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' }, + ]); + MockMultichain.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({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337:0x3'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x3'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + }, + }); + }); + + 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('grants the CAIP-25 permission for the supported scopes and accounts that were approved', async () => { + const { handler, grantPermissions, requestPermissionApprovalForOrigin } = + createMockedHandler(); + MockMultichain.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({ + permissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x2'], + }, + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337:0x2'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + }, + }, + }); + await handler(baseRequest); + + expect(grantPermissions).toHaveBeenCalledWith({ + subject: { origin: 'http://test.com' }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:5': { + accounts: ['eip155:5:0x1', 'eip155:5:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + accounts: ['eip155:100:0x1', 'eip155:100:0x2'], + }, + 'eip155:1337': { + accounts: ['eip155:1337:0x1', 'eip155:1337: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 () => { + MockUtil.shouldEmitDappViewedEvent.mockReturnValue(true); + const { handler, sendMetrics } = createMockedHandler(); + MockMultichain.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 session scopes', async () => { + const { handler, response } = createMockedHandler(); + MockMultichain.getSessionScopes.mockReturnValue({ + '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'], + }, + }); + 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'], + }, + }, + }); + }); +}); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/handler.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/handler.ts new file mode 100644 index 000000000000..c778648626c3 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/handler.ts @@ -0,0 +1,250 @@ +import { JsonRpcError, rpcErrors } from '@metamask/rpc-errors'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, + getEthAccounts, + setEthAccounts, + bucketScopes, + validateAndNormalizeScopes, + Caip25Authorization, + getInternalScopesObject, + getSessionScopes, + NormalizedScopesObject, + getSupportedScopeObjects, + Caip25CaveatValue, +} from '@metamask/multichain'; +import { + Caveat, + CaveatSpecificationConstraint, + invalidParams, + PermissionController, + PermissionSpecificationConstraint, + RequestedPermissions, + ValidPermission, +} from '@metamask/permission-controller'; +import { + Hex, + isPlainObject, + Json, + JsonRpcRequest, + JsonRpcSuccess, +} from '@metamask/utils'; +import { NetworkController } from '@metamask/network-controller'; +import { + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from '@metamask/json-rpc-engine'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsEventOptions, + MetaMetricsEventPayload, +} from '../../../../../../shared/constants/metametrics'; +import { shouldEmitDappViewedEvent } from '../../../util'; +import { MESSAGE_TYPE } from '../../../../../../shared/constants/app'; + +type AbstractPermissionController = PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint +>; + +/** + * Handler for the `wallet_createSession` RPC method which is responsible + * for prompting for approval and granting a CAIP-25 permission. + * + * This implementation primarily deviates from the CAIP-25 handler + * specification by treating all scopes as optional regardless of + * if they were specified in `requiredScopes` or `optionalScopes`. + * Additionally, provided scopes, methods, notifications, and + * account values that are invalid/malformed are ignored rather than + * causing an error to be returned. + * + * @param req - The request object. + * @param res - The response object. + * @param _next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.listAccounts - The hook that returns an array of the wallet's evm accounts. + * @param hooks.findNetworkClientIdByChainId - The hook that returns the networkClientId for a chainId. + * @param hooks.requestPermissionApprovalForOrigin - The hook that prompts the user approval for requested permissions. + * @param hooks.sendMetrics - The hook that tracks an analytics event. + * @param hooks.metamaskState - The wallet state. + * @param hooks.metamaskState.metaMetricsId - The analytics id. + * @param hooks.metamaskState.permissionHistory - The permission history object keyed by origin. + * @param hooks.metamaskState.accounts - The accounts object keyed by address. + * @param hooks.grantPermissions - The hook that grants permission for the origin. + */ +async function walletCreateSessionHandler( + req: JsonRpcRequest & { origin: string }, + res: JsonRpcSuccess<{ + sessionScopes: NormalizedScopesObject; + sessionProperties?: Record; + }>, + _next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + hooks: { + listAccounts: () => { address: string }[]; + findNetworkClientIdByChainId: NetworkController['findNetworkClientIdByChainId']; + requestPermissionApprovalForOrigin: ( + requestedPermissions: RequestedPermissions, + ) => Promise<{ permissions: RequestedPermissions }>; + sendMetrics: ( + payload: MetaMetricsEventPayload, + options?: MetaMetricsEventOptions, + ) => void; + metamaskState: { + metaMetricsId: string; + permissionHistory: Record; + accounts: Record; + }; + grantPermissions: ( + ...args: Parameters + ) => Record>>; + }, +) { + const { origin } = req; + if (!isPlainObject(req.params)) { + return end(invalidParams({ data: { request: req } })); + } + const { requiredScopes, optionalScopes, sessionProperties } = req.params; + + if (sessionProperties && Object.keys(sessionProperties).length === 0) { + return end(new JsonRpcError(5302, 'Invalid sessionProperties requested')); + } + + try { + const { normalizedRequiredScopes, normalizedOptionalScopes } = + validateAndNormalizeScopes(requiredScopes || {}, optionalScopes || {}); + + const supportedRequiredScopesObjects = getSupportedScopeObjects( + normalizedRequiredScopes, + ); + const supportedOptionalScopesObjects = getSupportedScopeObjects( + normalizedOptionalScopes, + ); + + const existsNetworkClientForChainId = (chainId: Hex) => { + try { + hooks.findNetworkClientIdByChainId(chainId); + return true; + } catch (err) { + return false; + } + }; + + const { supportedScopes: supportedRequiredScopes } = bucketScopes( + supportedRequiredScopesObjects, + { + isChainIdSupported: existsNetworkClientForChainId, + isChainIdSupportable: () => false, // intended for future usage with eip3085 scopedProperties + }, + ); + + const { supportedScopes: supportedOptionalScopes } = bucketScopes( + supportedOptionalScopesObjects, + { + isChainIdSupported: existsNetworkClientForChainId, + isChainIdSupportable: () => false, // intended for future usage with eip3085 scopedProperties + }, + ); + + // Fetch EVM accounts from native wallet keyring + // These addresses are lowercased already + const existingEvmAddresses = hooks + .listAccounts() + .map((account) => account.address); + const supportedEthAccounts = getEthAccounts({ + requiredScopes: supportedRequiredScopes, + optionalScopes: supportedOptionalScopes, + }) + .map((address) => address.toLowerCase() as Hex) + .filter((address) => existingEvmAddresses.includes(address)); + + const requestedCaip25CaveatValue = { + requiredScopes: getInternalScopesObject(supportedRequiredScopes), + optionalScopes: getInternalScopesObject(supportedOptionalScopes), + isMultichainOrigin: true, + }; + + const requestedCaip25CaveatValueWithSupportedEthAccounts = setEthAccounts( + requestedCaip25CaveatValue, + supportedEthAccounts, + ); + + const { permissions: approvedPermissions } = + await hooks.requestPermissionApprovalForOrigin({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: requestedCaip25CaveatValueWithSupportedEthAccounts, + }, + ], + }, + }); + + const approvedCaip25Permission = + approvedPermissions[Caip25EndowmentPermissionName]; + const approvedCaip25CaveatValue = approvedCaip25Permission?.caveats?.find( + (caveat) => caveat.type === Caip25CaveatType, + )?.value as Caip25CaveatValue; + if (!approvedCaip25CaveatValue) { + throw rpcErrors.internal(); + } + + const sessionScopes = getSessionScopes(approvedCaip25CaveatValue); + + hooks.grantPermissions({ + subject: { + origin, + }, + approvedPermissions, + }); + + // 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); + + const approvedEthAccounts = getEthAccounts(approvedCaip25CaveatValue); + + 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: approvedEthAccounts.length, + }, + }); + } + + res.result = { + sessionScopes, + sessionProperties, + }; + return end(); + } catch (err) { + return end(err); + } +} + +export const walletCreateSession = { + methodNames: [MESSAGE_TYPE.WALLET_CREATE_SESSION], + implementation: walletCreateSessionHandler, + hookNames: { + findNetworkClientIdByChainId: true, + listAccounts: true, + requestPermissionApprovalForOrigin: true, + grantPermissions: true, + sendMetrics: true, + metamaskState: true, + }, +}; diff --git a/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/index.ts b/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/index.ts new file mode 100644 index 000000000000..68ae53f6c3d8 --- /dev/null +++ b/app/scripts/lib/rpc-method-middleware/handlers/wallet-createSession/index.ts @@ -0,0 +1 @@ +export * from './handler'; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 7596e11cb93a..3c01b64dc745 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -26,7 +26,7 @@ import { } from '@metamask/keyring-controller'; import createFilterMiddleware from '@metamask/eth-json-rpc-filters'; import createSubscriptionManager from '@metamask/eth-json-rpc-filters/subscriptionManager'; -import { JsonRpcError, providerErrors } from '@metamask/rpc-errors'; +import { JsonRpcError, providerErrors, rpcErrors } from '@metamask/rpc-errors'; import { Mutex } from 'await-semaphore'; import log from 'loglevel'; @@ -134,7 +134,6 @@ import { isSnapId } from '@metamask/snaps-utils'; import { Interface } from '@ethersproject/abi'; import { abiERC1155, abiERC721 } from '@metamask/metamask-eth-abis'; import { isEvmAccountType } from '@metamask/keyring-api'; -import { hexToBigInt, toCaipChainId } from '@metamask/utils'; import { normalize } from '@metamask/eth-sig-util'; import { AuthenticationController, @@ -149,10 +148,18 @@ import { Caip25CaveatType, Caip25EndowmentPermissionName, getEthAccounts, + getSessionScopes, setPermittedEthChainIds, setEthAccounts, addPermittedEthChainId, + multichainMethodCallValidatorMiddleware, + MultichainSubscriptionManager, + MultichainMiddlewareManager, + walletGetSession, + walletRevokeSession, + walletInvokeMethod, } from '@metamask/multichain'; +import { hexToBigInt, toCaipChainId } from '@metamask/utils'; import { isProduction } from '../../shared/modules/environment'; import { methodsRequiringNetworkSwitch, @@ -176,6 +183,7 @@ import { NETWORK_TYPES, NetworkStatus, MAINNET_DISPLAY_NAME, + UNSUPPORTED_RPC_METHODS, } from '../../shared/constants/network'; import { getAllowedSmartTransactionsChainIds } from '../../shared/constants/smartTransactions'; @@ -196,6 +204,7 @@ import { MILLISECOND, MINUTE, SECOND } from '../../shared/constants/time'; import { ORIGIN_METAMASK, POLLING_TOKEN_ENVIRONMENT_TYPES, + MESSAGE_TYPE, } from '../../shared/constants/app'; import { MetaMetricsEventCategory, @@ -268,6 +277,8 @@ import { createEthAccountsMethodMiddleware, createEip1193MethodMiddleware, createUnsupportedMethodMiddleware, + createMultichainMethodMiddleware, + makeMethodMiddlewareMaker, } from './lib/rpc-method-middleware'; import createOriginMiddleware from './lib/createOriginMiddleware'; import createMainFrameOriginMiddleware from './lib/createMainFrameOriginMiddleware'; @@ -306,6 +317,9 @@ import { NOTIFICATION_NAMES, unrestrictedMethods, PermissionNames, + getRemovedAuthorizations, + getChangedAuthorizations, + getAuthorizedScopesByOrigin, validateCaveatAccounts, validateCaveatNetworks, } from './controllers/permissions'; @@ -332,6 +346,7 @@ 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 { decodeTransactionData } from './lib/transaction/decode/util'; import BridgeController from './controllers/bridge/bridge-controller'; import { BRIDGE_CONTROLLER_NAME } from './controllers/bridge/constants'; @@ -343,6 +358,7 @@ import createTracingMiddleware from './lib/createTracingMiddleware'; import createOriginThrottlingMiddleware from './lib/createOriginThrottlingMiddleware'; import { PatchStore } from './lib/PatchStore'; import { sanitizeUIState } from './lib/state-utils'; +import { walletCreateSession } from './lib/rpc-method-middleware/handlers/wallet-createSession'; import BridgeStatusController from './controllers/bridge-status/bridge-status-controller'; import { BRIDGE_STATUS_CONTROLLER_NAME } from './controllers/bridge-status/constants'; import { rejectAllApprovals } from './lib/approval/utils'; @@ -390,6 +406,12 @@ export const METAMASK_CONTROLLER_EVENTS = { 'NotificationServicesController:markNotificationsAsRead', }; +// Types of APIs +const API_TYPE = { + EIP1193: 'eip-1193', + CAIP_MULTICHAIN: 'caip-multichain', +}; + // stream channels const PHISHING_SAFELIST = 'metamask-phishing-safelist'; @@ -611,6 +633,19 @@ export default class MetamaskController extends EventEmitter { infuraProjectId: opts.infuraProjectId, }); this.networkController.initializeProvider(); + + if (process.env.MULTICHAIN_API) { + 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 = @@ -2719,6 +2754,80 @@ export default class MetamaskController extends EventEmitter { getPermittedAccountsByOrigin, ); + // This handles CAIP-25 authorization changes every time relevant permission state + // changes, for any reason. + if (process.env.MULTICHAIN_API) { + 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 sessionScopes = getSessionScopes(authorization); + // if the eth_subscription notification is in the scope and eth_subscribe is in the methods + // then remove middleware and unsubscribe + Object.entries(sessionScopes).forEach(([scope, scopeObject]) => { + if ( + scopeObject.notifications.includes('eth_subscription') && + scopeObject.methods.includes('eth_subscribe') + ) { + this.removeMultichainApiEthSubscriptionMiddleware({ + scope, + origin, + }); + } + }); + } + + // add new notification subscriptions for added/changed authorizations + for (const [ + origin, + authorization, + ] of changedAuthorizations.entries()) { + const sessionScopes = getSessionScopes(authorization); + + // 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(sessionScopes).forEach(([scope, scopeObject]) => { + if ( + scopeObject.notifications.includes('eth_subscription') && + scopeObject.methods.includes('eth_subscribe') + ) { + // for each tabId + Object.values(this.connections[origin]).forEach(({ tabId }) => { + this.addMultichainApiEthSubscriptionMiddleware({ + scope, + origin, + tabId, + }); + }); + } else { + this.removeMultichainApiEthSubscriptionMiddleware({ + scope, + origin, + }); + } + }); + + this._notifyAuthorizationChange(origin, authorization); + } + }, + getAuthorizedScopesByOrigin, + ); + } + this.controllerMessenger.subscribe( `${this.permissionController.name}:stateChange`, async (currentValue, previousValue) => { @@ -2760,7 +2869,11 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger.subscribe( 'NetworkController:networkRemoved', ({ chainId }) => { - this.removeAllChainIdPermissions(chainId); + const scopeString = toCaipChainId( + 'eip155', + hexToBigInt(chainId).toString(10), + ); + this.removeAllScopePermissions(scopeString); }, ); @@ -2952,6 +3065,51 @@ export default class MetamaskController extends EventEmitter { ); } + /** + * If it does not already exist, creates and inserts middleware to handle eth + * subscriptions for a particular evm scope on a specific Multichain API + * JSON-RPC pipeline by origin and tabId. + * + * @param {object} options - The options object. + * @param {string} options.scope - The evm scope to handle eth susbcriptions for. + * @param {string} options.origin - The origin to handle eth subscriptions for. + * @param {string} options.tabId - The tabId to handle eth subscriptions for. + */ + addMultichainApiEthSubscriptionMiddleware({ scope, origin, tabId }) { + const subscriptionManager = this.multichainSubscriptionManager.subscribe({ + scope, + origin, + tabId, + }); + this.multichainMiddlewareManager.addMiddleware({ + scope, + origin, + tabId, + middleware: subscriptionManager.middleware, + }); + } + + /** + * If it does exist, removes all middleware that were handling eth + * subscriptions for a particular evm scope for all Multichain API + * JSON-RPC pipelines for an origin. + * + * @param {object} options - The options object. + * @param {string} options.scope - The evm scope to handle eth susbcriptions for. + * @param {string} options.origin - The origin to handle eth subscriptions for. + */ + + removeMultichainApiEthSubscriptionMiddleware({ scope, origin }) { + this.multichainMiddlewareManager.removeMiddlewareByScopeAndOrigin( + scope, + origin, + ); + this.multichainSubscriptionManager.unsubscribeByScopeAndOrigin( + scope, + origin, + ); + } + /** * TODO:LegacyProvider: Delete * Constructor helper: initialize a public config store. @@ -2991,11 +3149,15 @@ export default class MetamaskController extends EventEmitter { * Gets relevant state for the provider of an external origin. * * @param {string} origin - The origin to get the provider state for. - * @returns {Promise<{ isUnlocked: boolean, networkVersion: string, chainId: string, accounts: string[] }>} An object with relevant state properties. + * @returns {Promise<{ isUnlocked: boolean, networkVersion: string, chainId: string, accounts: string[], extensionId: string | undefined }>} An object with relevant state properties. */ async getProviderState(origin) { const providerNetworkState = await this.getProviderNetworkState(origin); - + const metadata = {}; + if (process.env.MULTICHAIN_API && isManifestV3) { + const { chrome } = globalThis; + metadata.extensionId = chrome?.runtime?.id; + } return { /** * We default `isUnlocked` to `true` because even though we no longer emit events depending on this, @@ -3003,6 +3165,7 @@ export default class MetamaskController extends EventEmitter { */ isUnlocked: true, accounts: this.getPermittedAccounts(origin), + ...metadata, ...providerNetworkState, }; } @@ -4917,13 +5080,9 @@ export default class MetamaskController extends EventEmitter { if (err instanceof PermissionDoesNotExistError) { // suppress expected error in case that the origin // does not have the target permission yet - } else { - throw err; + return []; } - } - - if (!caveat) { - return []; + throw err; } if (!this.isUnlocked() && !ignoreLock) { @@ -4935,18 +5094,18 @@ export default class MetamaskController extends EventEmitter { } /** - * Stops exposing the specified chain ID to all third parties. + * Stops exposing the specified scope to all third parties. * - * @param {string} targetChainId - The chain ID to stop exposing + * @param {string} scopeString - The scope to stop exposing * to third parties. */ - removeAllChainIdPermissions(targetChainId) { + removeAllScopePermissions(scopeString) { this.permissionController.updatePermissionsByCaveat( Caip25CaveatType, (existingScopes) => Caip25CaveatMutators[Caip25CaveatType].removeScope( existingScopes, - toCaipChainId('eip155', hexToBigInt(targetChainId).toString(10)), + scopeString, ), ); } @@ -4999,6 +5158,30 @@ 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. + * @param [options] - Optional. Additional properties to define on the requestData object + */ + async requestPermissionApproval(origin, permissions, options = {}) { + const id = nanoid(); + return this.approvalController.addAndShowApprovalRequest({ + id, + origin, + requestData: { + metadata: { + id, + origin, + }, + permissions, + ...options, + }, + type: MethodNames.RequestPermissions, + }); + } + /** * Prompts the user with permittedChains approval for given chainId. * @@ -5015,29 +5198,22 @@ export default class MetamaskController extends EventEmitter { [chainId], ); - const id = nanoid(); - await this.approvalController.addAndShowApprovalRequest({ - id, + await this.requestPermissionApproval( origin, - requestData: { - metadata: { - id, - origin, - }, - permissions: { - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: caveatValueWithChains, - }, - ], - }, + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithChains, + }, + ], }, + }, + { isLegacySwitchEthereumChain: true, }, - type: MethodNames.RequestPermissions, - }); + ); } /** @@ -5212,29 +5388,16 @@ export default class MetamaskController extends EventEmitter { requestedAccounts, ); - const id = nanoid(); - const { permissions: approvedPermissions } = - await this.approvalController.addAndShowApprovalRequest({ - id, - origin, - requestData: { - metadata: { - id, - origin, - }, - permissions: { - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: caveatValueWithAccountsAndChains, - }, - ], + await this.requestPermissionApproval(origin, { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccountsAndChains, }, - }, + ], }, - type: MethodNames.RequestPermissions, }); return approvedPermissions; @@ -5478,8 +5641,11 @@ export default class MetamaskController extends EventEmitter { * @param {MessageSender | SnapSender} options.sender - The sender of the messages on this stream. * @param {string} [options.subjectType] - The type of the sender, i.e. subject. */ - setupUntrustedCommunicationCaip({ connectionStream, sender, subjectType }) { + if (!process.env.MULTICHAIN_API) { + return; + } + let inputSubjectType; if (subjectType) { inputSubjectType = subjectType; @@ -5742,7 +5908,11 @@ export default class MetamaskController extends EventEmitter { // setup connection const providerStream = createEngineStream({ engine }); - const connectionId = this.addConnection(origin, { engine }); + const connectionId = this.addConnection(origin, { + tabId, + apiType: API_TYPE.EIP1193, + engine, + }); pipeline( outStream, @@ -5774,6 +5944,10 @@ export default class MetamaskController extends EventEmitter { * @param {SubjectType} subjectType - The type of the sender, i.e. subject. */ setupProviderConnectionCaip(outStream, sender, subjectType) { + if (!process.env.MULTICHAIN_API) { + return; + } + let origin; if (subjectType === SubjectType.Internal) { origin = ORIGIN_METAMASK; @@ -5798,6 +5972,8 @@ export default class MetamaskController extends EventEmitter { const engine = this.setupProviderEngineCaip({ origin, + sender, + subjectType, tabId, }); @@ -5806,7 +5982,11 @@ export default class MetamaskController extends EventEmitter { // setup connection const providerStream = createEngineStream({ engine }); - const connectionId = this.addConnection(origin, { engine }); + const connectionId = this.addConnection(origin, { + tabId, + apiType: API_TYPE.CAIP_MULTICHAIN, + engine, + }); pipeline( outStream, @@ -5815,22 +5995,14 @@ export default class MetamaskController extends EventEmitter { outStream, (err) => { // handle any middleware cleanup - engine._middleware.forEach((mid) => { - if (mid.destroy && typeof mid.destroy === 'function') { - mid.destroy(); - } - }); + engine.destroy(); connectionId && this.removeConnection(origin, connectionId); - if (err) { + // For context and todos related to the error message match, see https://github.com/MetaMask/metamask-extension/issues/26337 + if (err && !err.message?.match('Premature close')) { log.error(err); } }, ); - - // Used to show wallet liveliness to the provider - if (subjectType !== SubjectType.Internal) { - this._notifyChainChangeForConnection({ engine }, origin); - } } /** @@ -5951,7 +6123,7 @@ export default class MetamaskController extends EventEmitter { engine.push(createUnsupportedMethodMiddleware()); - // 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( createEthAccountsMethodMiddleware({ @@ -6296,18 +6468,272 @@ 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 CAIP Multichain provider that is safely restricted for the requesting subject. * * @param {object} options - Provider engine options * @param {string} options.origin - The origin of the sender + * @param {MessageSender | SnapSender} options.sender - The sender object. + * @param {string} options.subjectType - The type of the sender subject. * @param {tabId} [options.tabId] - The tab ID of the sender - if the sender is within a tab */ - setupProviderEngineCaip({ origin, tabId }) { + setupProviderEngineCaip({ origin, sender, subjectType, tabId }) { + if (!process.env.MULTICHAIN_API) { + return null; + } + 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(rpcErrors.methodNotFound({ data: { method: req.method } })); + } + return next(); + }); + + engine.push(multichainMethodCallValidatorMiddleware); + const middlewareMaker = makeMethodMiddlewareMaker([ + walletRevokeSession, + walletGetSession, + walletInvokeMethod, + walletCreateSession, + ]); + + engine.push( + middlewareMaker({ + grantPermissions: this.permissionController.grantPermissions.bind( + this.permissionController, + ), + findNetworkClientIdByChainId: + this.networkController.findNetworkClientIdByChainId.bind( + this.networkController, + ), + listAccounts: this.accountsController.listAccounts.bind( + this.accountsController, + ), + requestPermissionApprovalForOrigin: this.requestPermissionApproval.bind( + this, + origin, + ), + sendMetrics: this.metaMetricsController.trackEvent.bind( + this.metaMetricsController, + ), + metamaskState: this.getState(), + getCaveatForOrigin: this.permissionController.getCaveat.bind( + this.permissionController, + origin, + ), + getSelectedNetworkClientId: () => + this.networkController.state.selectedNetworkClientId, + revokePermissionForOrigin: + this.permissionController.revokePermission.bind( + this.permissionController, + origin, + ), + }), + ); + + // Add a middleware that will switch chain on each request (as needed) + const requestQueueMiddleware = createQueuedRequestMiddleware({ + enqueueRequest: this.queuedRequestController.enqueueRequest.bind( + this.queuedRequestController, + ), + // This will be removed once we can actually remove useRequestQueue state + // i.e. unrevert https://github.com/MetaMask/core/pull/5065 + useRequestQueue: () => true, + shouldEnqueueRequest: (request) => { + return methodsRequiringNetworkSwitch.includes(request.method); + }, + }); + engine.push(requestQueueMiddleware); + + engine.push( + createUnsupportedMethodMiddleware( + new Set([ + ...UNSUPPORTED_RPC_METHODS, + 'eth_requestAccounts', + 'eth_accounts', + ]), + ), + ); + + if (subjectType === SubjectType.Website) { + engine.push( + createOnboardingMiddleware({ + location: sender.url, + registerOnboarding: this.onboardingController.registerOnboarding, + }), + ); + } + + engine.push( + createMultichainMethodMiddleware({ + subjectType, + + // 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, + ), + 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, + ), + 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, + ), + + requestPermittedChainsPermissionForOrigin: (options) => + this.requestPermittedChainsPermission({ + ...options, + origin, + }), + requestPermittedChainsPermissionIncrementalForOrigin: (options) => + this.requestPermittedChainsPermissionIncremental({ + ...options, + origin, + }), + }), + ); + + engine.push(this.metamaskMiddleware); + + try { + const caip25Caveat = this.permissionController.getCaveat( + origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + + // add new notification subscriptions for changed authorizations + const sessionScopes = getSessionScopes(caip25Caveat.value); + + // 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(sessionScopes).forEach(([scope, scopeObject]) => { + if ( + scopeObject.notifications.includes('eth_subscription') && + scopeObject.methods.includes('eth_subscribe') + ) { + this.addMultichainApiEthSubscriptionMiddleware({ + scope, + origin, + tabId, + }); + } + }); + } 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) { + return end(err); + } + // copy provider response onto original response + Object.assign(res, providerRes); + return end(); + }); }); return engine; @@ -6344,9 +6770,11 @@ 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 {number} options.tabId - The tabId for the connection + * @param {API_TYPE} options.apiType - The API type for the connection * @returns {string} The connection's id (so that it can be deleted later) */ - addConnection(origin, { engine }) { + addConnection(origin, { tabId, apiType, engine }) { if (origin === ORIGIN_METAMASK) { return null; } @@ -6357,6 +6785,8 @@ export default class MetamaskController extends EventEmitter { const id = nanoid(); this.connections[origin][id] = { + tabId, + apiType, engine, }; @@ -6412,12 +6842,16 @@ export default class MetamaskController extends EventEmitter { * * @param {string} origin - The connection's origin string. * @param {unknown} payload - The event payload. + * @param apiType */ - notifyConnections(origin, payload) { + notifyConnections(origin, payload, apiType) { const connections = this.connections[origin]; if (connections) { Object.values(connections).forEach((conn) => { + if (apiType && conn.apiType !== apiType) { + return; + } if (conn.engine) { conn.engine.emit('notification', payload); } @@ -6437,8 +6871,9 @@ export default class MetamaskController extends EventEmitter { * are sent. * * @param {unknown} payload - The event payload, or payload getter function. + * @param apiType */ - notifyAllConnections(payload) { + notifyAllConnections(payload, apiType) { const getPayload = typeof payload === 'function' ? (origin) => payload(origin) @@ -6446,6 +6881,9 @@ export default class MetamaskController extends EventEmitter { Object.keys(this.connections).forEach((origin) => { Object.values(this.connections[origin]).forEach(async (conn) => { + if (apiType && conn.apiType !== apiType) { + return; + } try { this.notifyConnection(conn, await getPayload(origin)); } catch (err) { @@ -7045,28 +7483,48 @@ export default class MetamaskController extends EventEmitter { } _notifyAccountsChange(origin, newAccounts) { - this.notifyConnections(origin, { - method: NOTIFICATION_NAMES.accountsChanged, - // This should be the same as the return value of `eth_accounts`, - // namely an array of the current / most recently selected Ethereum - // account. - params: - newAccounts.length < 2 - ? // If the length is 1 or 0, the accounts are sorted by definition. - newAccounts - : // If the length is 2 or greater, we have to execute - // `eth_accounts` vi this method. - this.getPermittedAccounts(origin), - }); + this.notifyConnections( + origin, + { + method: NOTIFICATION_NAMES.accountsChanged, + // This should be the same as the return value of `eth_accounts`, + // namely an array of the current / most recently selected Ethereum + // account. + params: + newAccounts.length < 2 + ? // If the length is 1 or 0, the accounts are sorted by definition. + newAccounts + : // If the length is 2 or greater, we have to execute + // `eth_accounts` vi this method. + this.getPermittedAccounts(origin), + }, + API_TYPE.EIP1193, + ); this.permissionLogController.updateAccountsHistory(origin, newAccounts); } + async _notifyAuthorizationChange(origin, newAuthorization) { + this.notifyConnections( + origin, + { + method: NOTIFICATION_NAMES.sessionChanged, + params: { + sessionScopes: getSessionScopes(newAuthorization), + }, + }, + API_TYPE.CAIP_MULTICHAIN, + ); + } + async _notifyChainChange() { - this.notifyAllConnections(async (origin) => ({ - method: NOTIFICATION_NAMES.chainChanged, - params: await this.getProviderNetworkState(origin), - })); + this.notifyAllConnections( + async (origin) => ({ + method: NOTIFICATION_NAMES.chainChanged, + params: await this.getProviderNetworkState(origin), + }), + API_TYPE.EIP1193, + ); } async _notifyChainChangeForConnection(connection, origin) { diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 932acec5a519..5cfbc3bf8593 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -126,7 +126,8 @@ jest.mock('./controllers/permissions/specifications', () => ({ jest.mock('./lib/createLoggerMiddleware', () => createLoggerMiddlewareMock); -const rpcMethodMiddlewareMock = { +jest.mock('./lib/rpc-method-middleware', () => ({ + ...jest.requireActual('./lib/rpc-method-middleware'), createEip1193MethodMiddleware: () => (_req, _res, next, _end) => { next(); }, @@ -139,8 +140,7 @@ const rpcMethodMiddlewareMock = { createUnsupportedMethodMiddleware: () => (_req, _res, next, _end) => { next(); }, -}; -jest.mock('./lib/rpc-method-middleware', () => rpcMethodMiddlewareMock); +})); const KNOWN_PUBLIC_KEY = '02065bc80d3d12b3688e4ad5ab1e9eda6adf24aec2518bfc21b87c99d4c5077ab0'; @@ -968,6 +968,63 @@ describe('MetaMaskController', () => { }); }); + describe('#requestPermissionApproval', () => { + it('requests permissions for the origin from the ApprovalController', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue(); + + await metamaskController.requestPermissionApproval('test.com', { + eth_accounts: {}, + }); + + expect( + metamaskController.approvalController.addAndShowApprovalRequest, + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + requestData: { + metadata: { + id: expect.stringMatching(/.{21}/u), + origin: 'test.com', + }, + permissions: { + eth_accounts: {}, + }, + }, + type: 'wallet_requestPermissions', + }), + ); + + const [params] = + metamaskController.approvalController.addAndShowApprovalRequest.mock + .calls[0]; + expect(params.id).toStrictEqual(params.requestData.metadata.id); + }); + + it('returns the result from the ApprovalController', async () => { + jest + .spyOn( + metamaskController.approvalController, + 'addAndShowApprovalRequest', + ) + .mockResolvedValue('approvalResult'); + + const result = await metamaskController.requestPermissionApproval( + 'test.com', + { + eth_accounts: {}, + }, + ); + + expect(result).toStrictEqual('approvalResult'); + }); + }); + describe('#requestCaip25Approval', () => { describe('valid requests', () => { it('requests approval with well formed id and origin', async () => { @@ -1711,12 +1768,9 @@ describe('MetaMaskController', () => { }); describe('requestApprovalPermittedChainsPermission', () => { - it('requests approval with well formed id and origin', async () => { + it('requests approval', async () => { jest - .spyOn( - metamaskController.approvalController, - 'addAndShowApprovalRequest', - ) + .spyOn(metamaskController, 'requestPermissionApproval') .mockResolvedValue(); await metamaskController.requestApprovalPermittedChainsPermission( @@ -1725,51 +1779,36 @@ describe('MetaMaskController', () => { ); expect( - metamaskController.approvalController.addAndShowApprovalRequest, + metamaskController.requestPermissionApproval, ).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.stringMatching(/.{21}/u), - origin: 'test.com', - requestData: expect.objectContaining({ - metadata: { - id: expect.stringMatching(/.{21}/u), - origin: 'test.com', - }, - permissions: { - [Caip25EndowmentPermissionName]: { - caveats: [ - { - type: Caip25CaveatType, - value: { - requiredScopes: {}, - optionalScopes: { - 'eip155:1': { - accounts: [], - }, - }, - isMultichainOrigin: false, + 'test.com', + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], }, }, - ], + isMultichainOrigin: false, + }, }, - }, - }), - type: 'wallet_requestPermissions', - }), + ], + }, + }, + { + isLegacySwitchEthereumChain: true, + }, ); - - const [params] = - metamaskController.approvalController.addAndShowApprovalRequest.mock - .calls[0]; - expect(params.id).toStrictEqual(params.requestData.metadata.id); }); it('throws if the approval is rejected', async () => { jest - .spyOn( - metamaskController.approvalController, - 'addAndShowApprovalRequest', - ) + .spyOn(metamaskController, 'requestPermissionApproval') .mockRejectedValue(new Error('approval rejected')); await expect(() => @@ -2330,19 +2369,19 @@ describe('MetaMaskController', () => { describe('NetworkConfiguration is removed', () => { it('should remove the permitted chain from all existing permissions', () => { jest - .spyOn(metamaskController, 'removeAllChainIdPermissions') + .spyOn(metamaskController, 'removeAllScopePermissions') .mockReturnValue(); metamaskController.controllerMessenger.publish( 'NetworkController:networkRemoved', { - chainId: '0xdeadbeef', + chainId: '0xa', }, ); expect( - metamaskController.removeAllChainIdPermissions, - ).toHaveBeenCalledWith('0xdeadbeef'); + metamaskController.removeAllScopePermissions, + ).toHaveBeenCalledWith('eip155:10'); }); }); @@ -2874,8 +2913,6 @@ describe('MetaMaskController', () => { }); describe('#setupUntrustedCommunicationEip1193', () => { - const mockTxParams = { from: TEST_ADDRESS }; - beforeEach(() => { initializeMockMiddlewareLog(); metamaskController.preferencesController.setSecurityAlertsEnabled( @@ -2959,6 +2996,7 @@ describe('MetaMaskController', () => { expect.anything(), 'test.metamask-phishing.io', ); + streamTest.end(); }); it('adds a tabId, origin and networkClient to requests', async () => { @@ -2982,8 +3020,7 @@ describe('MetaMaskController', () => { const message = { id: 1999133338649204, jsonrpc: '2.0', - params: [{ ...mockTxParams }], - method: 'eth_sendTransaction', + method: 'eth_chainId', }; await new Promise((resolve) => { streamTest.write( @@ -3011,6 +3048,7 @@ describe('MetaMaskController', () => { }, ); }); + streamTest.end(); }); it('should add only origin to request if tabId not provided', async () => { @@ -3031,10 +3069,8 @@ describe('MetaMaskController', () => { }); const message = { - id: 1999133338649204, jsonrpc: '2.0', - params: [{ ...mockTxParams }], - method: 'eth_sendTransaction', + method: 'eth_chainId', }; await new Promise((resolve) => { streamTest.write( @@ -3057,15 +3093,268 @@ describe('MetaMaskController', () => { }, ); }); + streamTest.end(); }); - it.todo( - 'should only process `metamask-provider` multiplex formatted messages', - ); + it('should only process `metamask-provider` multiplex formatted messages', async () => { + const messageSender = { + url: 'http://mycrypto.com', + tab: { id: 456 }, + }; + const streamTest = createThroughStream((chunk, _, cb) => { + if (chunk.data && chunk.data.method) { + cb(null, chunk); + return; + } + cb(); + }); + + metamaskController.setupUntrustedCommunicationEip1193({ + connectionStream: streamTest, + sender: messageSender, + }); + + const message = { + jsonrpc: '2.0', + method: 'eth_chainId', + }; + await new Promise((resolve) => { + streamTest.write( + { + type: 'caip-x', + data: { + method: 'wallet_invokeMethod', + params: { + scope: 'eip155:1', + request: message, + }, + }, + }, + null, + () => { + setTimeout(() => { + expect(loggerMiddlewareMock.requests).toHaveLength(0); + resolve(); + }); + }, + ); + }); + await new Promise((resolve) => { + streamTest.write( + { + name: 'metamask-provider', + data: message, + }, + null, + () => { + setTimeout(() => { + expect(loggerMiddlewareMock.requests).toHaveLength(1); + resolve(); + }); + }, + ); + }); + streamTest.end(); + }); }); describe('#setupUntrustedCommunicationCaip', () => { - it.todo('should only process `caip-x` CAIP formatted messages'); + let localMetamaskController; + beforeEach(() => { + process.env.MULTICHAIN_API = true; + localMetamaskController = new MetaMaskController({ + showUserConfirmation: noop, + encryptor: mockEncryptor, + initState: { + ...cloneDeep(firstTimeState), + PreferencesController: { + useExternalServices: false, + }, + }, + initLangCode: 'en_US', + platform: { + showTransactionNotification: () => undefined, + getVersion: () => 'foo', + }, + browser: browserPolyfillMock, + infuraProjectId: 'foo', + isFirstMetaMaskControllerSetup: true, + }); + initializeMockMiddlewareLog(); + jest + .spyOn(localMetamaskController.onboardingController, 'state', 'get') + .mockReturnValue({ completedOnboarding: true }); + }); + + afterAll(() => { + process.env.MULTICHAIN_API = false; + tearDownMockMiddlewareLog(); + }); + + it('adds a tabId and origin to requests', async () => { + const messageSender = { + url: 'http://mycrypto.com', + tab: { id: 456 }, + }; + const streamTest = createThroughStream((chunk, _, cb) => { + if (chunk.data && chunk.data.method) { + cb(null, chunk); + return; + } + cb(); + }); + + localMetamaskController.setupUntrustedCommunicationCaip({ + connectionStream: streamTest, + sender: messageSender, + }); + + const message = { + jsonrpc: '2.0', + method: 'eth_chainId', + }; + await new Promise((resolve) => { + streamTest.write( + { + type: 'caip-x', + data: { + method: 'wallet_invokeMethod', + params: { + scope: 'eip155:1', + request: message, + }, + }, + }, + null, + () => { + setTimeout(() => { + expect(loggerMiddlewareMock.requests[0]).toHaveProperty( + 'origin', + 'http://mycrypto.com', + ); + expect(loggerMiddlewareMock.requests[0]).toHaveProperty( + 'tabId', + 456, + ); + resolve(); + }); + }, + ); + }); + streamTest.end(); + }); + + it('should add only origin to request if tabId not provided', async () => { + const messageSender = { + url: 'http://mycrypto.com', + }; + const streamTest = createThroughStream((chunk, _, cb) => { + if (chunk.data && chunk.data.method) { + cb(null, chunk); + return; + } + cb(); + }); + + localMetamaskController.setupUntrustedCommunicationCaip({ + connectionStream: streamTest, + sender: messageSender, + }); + + const message = { + jsonrpc: '2.0', + method: 'eth_chainId', + }; + await new Promise((resolve) => { + streamTest.write( + { + type: 'caip-x', + data: { + method: 'wallet_invokeMethod', + params: { + scope: 'eip155:1', + request: message, + }, + }, + }, + null, + () => { + setTimeout(() => { + expect(loggerMiddlewareMock.requests[0]).not.toHaveProperty( + 'tabId', + ); + expect(loggerMiddlewareMock.requests[0]).toHaveProperty( + 'origin', + 'http://mycrypto.com', + ); + resolve(); + }); + }, + ); + }); + streamTest.end(); + }); + + it('should only process `caip-x` CAIP formatted messages', async () => { + const messageSender = { + url: 'http://mycrypto.com', + tab: { id: 456 }, + }; + const streamTest = createThroughStream((chunk, _, cb) => { + if (chunk.data && chunk.data.method) { + cb(null, chunk); + return; + } + cb(); + }); + + localMetamaskController.setupUntrustedCommunicationCaip({ + connectionStream: streamTest, + sender: messageSender, + }); + + const message = { + jsonrpc: '2.0', + method: 'eth_chainId', + }; + await new Promise((resolve) => { + streamTest.write( + { + name: 'metamask-provider', + data: message, + }, + null, + () => { + setTimeout(() => { + expect(loggerMiddlewareMock.requests).toHaveLength(0); + resolve(); + }); + }, + ); + }); + await new Promise((resolve) => { + streamTest.write( + { + type: 'caip-x', + data: { + method: 'wallet_invokeMethod', + params: { + scope: 'eip155:1', + request: message, + }, + }, + }, + null, + () => { + setTimeout(() => { + expect(loggerMiddlewareMock.requests).toHaveLength(1); + resolve(); + }); + }, + ); + }); + streamTest.end(); + }); }); describe('#setupTrustedCommunication', () => { diff --git a/builds.yml b/builds.yml index eea8601d2754..4126a9f2ac6c 100644 --- a/builds.yml +++ b/builds.yml @@ -79,6 +79,7 @@ buildTypes: - SEGMENT_WRITE_KEY_REF: SEGMENT_FLASK_WRITE_KEY - ACCOUNT_SNAPS_DIRECTORY_URL: https://metamask.github.io/snaps-directory-staging/main/account-management - EIP_4337_ENTRYPOINT: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789' + - MULTICHAIN_API: true isPrerelease: true manifestOverrides: ./app/build-types/flask/manifest/ buildNameOverride: MetaMask Flask @@ -274,8 +275,14 @@ env: - NODE_DEBUG: '' # Used by react-devtools-core - EDITOR_URL: '' - # Determines if Barad Dur features should be used - - BARAD_DUR: '' + # Determines if Multichain API features should be used + # NOTE: The manifest.json for the build that sets this feature flag + # must also include an "externally_connectable" entry as follows: + # "externally_connectable": { + # "matches": ["http://*/*", "https://*/*"], + # "ids": ["*"] + # } + - MULTICHAIN_API: false # Determines if feature flagged Chain permissions - CHAIN_PERMISSIONS: '' # Determines if Portfolio View UI should be shown diff --git a/development/build/manifest.js b/development/build/manifest.js index bc5325b372eb..551907062557 100644 --- a/development/build/manifest.js +++ b/development/build/manifest.js @@ -7,9 +7,6 @@ const { isManifestV3 } = require('../../shared/modules/mv3.utils'); const baseManifest = isManifestV3 ? require('../../app/manifest/v3/_base.json') : require('../../app/manifest/v2/_base.json'); -const baradDurManifest = isManifestV3 - ? require('../../app/manifest/v3/_barad_dur.json') - : require('../../app/manifest/v2/_barad_dur.json'); const { loadBuildTypesConfig } = require('../lib/build-type'); const { TASKS, ENVIRONMENT } = require('./constants'); @@ -42,7 +39,6 @@ function createManifestTasks({ ); const result = mergeWith( cloneDeep(baseManifest), - process.env.BARAD_DUR ? cloneDeep(baradDurManifest) : {}, platformModifications, browserVersionMap[platform], await getBuildModifications(buildType, platform), diff --git a/development/create-static-server.js b/development/create-static-server.js index 8e55fa54ca13..e500cf8ef5a2 100755 --- a/development/create-static-server.js +++ b/development/create-static-server.js @@ -7,8 +7,9 @@ const serveHandler = require('serve-handler'); /** * Creates an HTTP server that serves static files from a directory using serve-handler. * If a request URL starts with `/node_modules/`, it rewrites the URL and serves files from the `node_modules` directory. + * If a request URL starts with `/test-dapp-multichain/`, it serves files from the root directory without the prefix. * - * @param { NonNullable[2]> } options - Configuration options for serve-handler. Documentation can be found here: https://github.com/vercel/serve-handler + * @param { NonNullable[2]> } options - Configuration options for serve-handler * @returns {http.Server} An instance of an HTTP server configured with the specified options. */ const createStaticServer = (options) => { @@ -20,6 +21,12 @@ const createStaticServer = (options) => { public: path.resolve('./node_modules'), }); } + + // Handle test-dapp-multichain URLs by removing the prefix + if (request.url.startsWith('/test-dapp-multichain/')) { + request.url = request.url.slice('/test-dapp-multichain'.length); + } + return serveHandler(request, response, { directoryListing: false, ...options, diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 907bd244f3ec..251cb2d11b77 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -612,7 +612,7 @@ "process": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { "packages": { "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, @@ -1513,7 +1513,7 @@ "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "@metamask/multichain>@metamask/utils": true, - "@metamask/multichain>@open-rpc/schema-utils-js": true, + "@open-rpc/schema-utils-js": true, "@metamask/multichain>jsonschema": true, "lodash": true } @@ -2706,10 +2706,10 @@ "crypto": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js": { + "@open-rpc/schema-utils-js": { "packages": { - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/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/meta-schema": true, "eslint>ajv": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 7c180c8360db..286f3ff672d7 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -612,7 +612,7 @@ "process": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { "packages": { "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, @@ -1529,7 +1529,7 @@ "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "@metamask/multichain>@metamask/utils": true, - "@metamask/multichain>@open-rpc/schema-utils-js": true, + "@open-rpc/schema-utils-js": true, "@metamask/multichain>jsonschema": true, "lodash": true } @@ -2749,10 +2749,10 @@ "crypto": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js": { + "@open-rpc/schema-utils-js": { "packages": { - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/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/meta-schema": true, "eslint>ajv": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 907bd244f3ec..251cb2d11b77 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -612,7 +612,7 @@ "process": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { "packages": { "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, @@ -1513,7 +1513,7 @@ "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "@metamask/multichain>@metamask/utils": true, - "@metamask/multichain>@open-rpc/schema-utils-js": true, + "@open-rpc/schema-utils-js": true, "@metamask/multichain>jsonschema": true, "lodash": true } @@ -2706,10 +2706,10 @@ "crypto": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js": { + "@open-rpc/schema-utils-js": { "packages": { - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/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/meta-schema": true, "eslint>ajv": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 6372097263b5..b4e033e5ba80 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -612,7 +612,7 @@ "process": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { + "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": { "packages": { "@open-rpc/schema-utils-js>@json-schema-tools/reference-resolver": true, "@open-rpc/schema-utils-js>@json-schema-tools/dereferencer>@json-schema-tools/traverse": true, @@ -1605,7 +1605,7 @@ "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "@metamask/multichain>@metamask/utils": true, - "@metamask/multichain>@open-rpc/schema-utils-js": true, + "@open-rpc/schema-utils-js": true, "@metamask/multichain>jsonschema": true, "lodash": true } @@ -2798,10 +2798,10 @@ "crypto": true } }, - "@metamask/multichain>@open-rpc/schema-utils-js": { + "@open-rpc/schema-utils-js": { "packages": { - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/dereferencer": true, - "@metamask/multichain>@open-rpc/schema-utils-js>@json-schema-tools/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/meta-schema": true, "eslint>ajv": true, diff --git a/package.json b/package.json index 29c0ecf61c27..542494a23349 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "build:test:webpack": "BLOCKAID_FILE_CDN=static.cx.metamask.io/api/v1/confirmations/ppom yarn env:e2e webpack --test --browser=chrome --no-cache --lockdown --sentry --snow", "test": "yarn lint && yarn test:unit", "dapp": "node development/static-server.js node_modules/@metamask/test-dapp/dist --port 8080", + "dapp-multichain": "node development/static-server.js node_modules/@metamask/test-dapp-multichain/build --port 8080", "dapp-chain": "GANACHE_ARGS='-b 2' concurrently -k -n ganache,dapp -p '[{time}][{name}]' 'yarn ganache:start' 'sleep 5 && yarn dapp'", "forwarder": "node ./development/static-server.js ./node_modules/@metamask/forwarder/dist/ --port 9010", "dapp-forwarder": "concurrently -k -n forwarder,dapp -p '[{time}][{name}]' 'yarn forwarder' 'yarn dapp'", @@ -58,6 +59,7 @@ "test:e2e:chrome:flask": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js --build-type flask", "test:e2e:chrome:webpack": "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:swap": "yarn playwright test --project=swap", "test:e2e:global": "yarn playwright test --project=global", "test:e2e:pw:report": "yarn playwright show-report public/playwright/playwright-reports/html", @@ -143,7 +145,7 @@ "glob-parent": "^6.0.2", "netmask": "^2.0.1", "js-sha3": "^0.9.2", - "json-schema": "^0.4.0", + "jsonschema": "^1.4.1", "ast-types": "^0.14.2", "x-default-browser": "^0.5.2", "acorn@^7.0.0": "patch:acorn@npm:7.4.1#.yarn/patches/acorn-npm-7.4.1-f450b4646c.patch", @@ -463,7 +465,7 @@ "@lavamoat/lavadome-core": "0.0.20", "@lavamoat/lavapack": "^7.0.5", "@lydell/node-pty": "^1.0.1", - "@metamask/api-specs": "^0.10.15", + "@metamask/api-specs": "^0.10.16", "@metamask/auto-changelog": "^2.1.0", "@metamask/build-utils": "^3.0.0", "@metamask/eslint-config": "^9.0.0", @@ -478,11 +480,12 @@ "@metamask/preferences-controller": "^15.0.2", "@metamask/test-bundler": "^1.0.0", "@metamask/test-dapp": "9.0.0", + "@metamask/test-dapp-multichain": "^0.6.0", "@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", @@ -772,6 +775,7 @@ "level>classic-level": false, "jest-preview": false, "@metamask/solana-wallet-snap>@solana/web3.js>bigint-buffer": false, + "@metamask/test-dapp-multichain>react-scripts>react-app-polyfill>core-js": false, "@lavamoat/allow-scripts>@lavamoat/preinstall-always-fail": false } }, diff --git a/privacy-snapshot.json b/privacy-snapshot.json index 81c2e6e2d665..1a1b082894fc 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -31,6 +31,7 @@ "etherscan.io", "execution.metamask.io", "fonts.gstatic.com", + "foo.io", "gas.api.cx.metamask.io", "github.com", "goerli.infura.io", 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/modules/caip-stream.test.ts b/shared/modules/caip-stream.test.ts index d97a18bda992..37fed9d693d5 100644 --- a/shared/modules/caip-stream.test.ts +++ b/shared/modules/caip-stream.test.ts @@ -77,5 +77,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 } = createDeferredPromise(); + 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 43046d8b0943..f90a20859989 100644 --- a/test/e2e/api-specs/ConfirmationRejectionRule.ts +++ b/test/e2e/api-specs/ConfirmationRejectionRule.ts @@ -14,6 +14,7 @@ import { addToQueue } from './helpers'; type ConfirmationsRejectRuleOptions = { driver: Driver; only: string[]; + requiresEthAccountsPermission: string[]; }; // this rule makes sure that all confirmation requests are rejected. // it also validates that the JSON-RPC response is an error with @@ -29,11 +30,7 @@ export class ConfirmationsRejectRule implements Rule { this.driver = options.driver; this.only = options.only; - this.requiresEthAccountsPermission = [ - 'personal_sign', - 'eth_signTypedData_v4', - 'eth_getEncryptionPublicKey', - ]; + this.requiresEthAccountsPermission = options.requiresEthAccountsPermission; } getTitle() { @@ -99,8 +96,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..deefbbbc1728 --- /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) { + method.examples.forEach((e) => { + const ex = e as ExamplePairingObject; + + if (!ex.result) { + return; + } + 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..7febfdafaa05 100644 --- a/test/e2e/api-specs/helpers.ts +++ b/test/e2e/api-specs/helpers.ts @@ -1,5 +1,7 @@ import { v4 as uuid } from 'uuid'; import { ErrorObject } from '@open-rpc/meta-schema'; +import { Json, JsonRpcFailure, JsonRpcResponse } from '@metamask/utils'; +import { InternalScopeString } from '@metamask/multichain'; import { Driver } from '../webdriver/driver'; // eslint-disable-next-line @typescript-eslint/no-shadow, @typescript-eslint/no-explicit-any @@ -47,7 +49,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 +59,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 +76,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, + InternalScopeString, + 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 +273,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 fa615bb6d9b8..639d7087a152 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -522,6 +522,97 @@ class FixtureBuilder { }); } + withPermissionControllerConnectedToMultichainTestDapp({ + account = '', + useLocalhostHostname = false, + } = {}) { + const selectedAccount = account || DEFAULT_FIXTURE_ACCOUNT; + const subjects = { + [useLocalhostHostname ? DAPP_URL_LOCALHOST : DAPP_URL]: { + origin: useLocalhostHostname ? DAPP_URL_LOCALHOST : DAPP_URL, + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1337': { + accounts: [ + `eip155:1337:${selectedAccount.toLowerCase()}`, + ], + }, + 'wallet:eip155': { + accounts: [ + `wallet:eip155:${selectedAccount.toLowerCase()}`, + ], + }, + wallet: { + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + id: 'ZaqPEWxyhNCJYACFw93jE', + date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', + }, + }, + }, + }; + return this.withPermissionController({ + subjects, + }); + } + + withPermissionControllerConnectedToMultichainTestDappWithTwoAccounts({ + scopes = ['eip155:1337'], + }) { + const optionalScopes = scopes + .map((scope) => ({ + [scope]: { + accounts: [ + `${scope}:0x5cfe73b6021e818b776b421b1c4db2474086a7e1`, + `${scope}:0x09781764c08de8ca82e156bbf156a3ca217c7950`, + ], + }, + })) + .reduce((acc, curr) => { + return { ...acc, ...curr }; + }, {}); + + const subjects = { + [DAPP_URL]: { + origin: DAPP_URL, + permissions: { + 'endowment:caip25': { + caveats: [ + { + type: 'authorizedScopes', + value: { + requiredScopes: {}, + optionalScopes, + isMultichainOrigin: true, + }, + }, + ], + id: 'ZaqPEWxyhNCJYACFw93jE', + date: 1664388714636, + invoker: DAPP_URL, + parentCapability: 'endowment:caip25', + }, + }, + }, + }; + return this.withPermissionController({ + subjects, + }); + } + withPermissionControllerConnectedToTestDappWithTwoAccounts() { const subjects = { [DAPP_URL]: { @@ -1537,6 +1628,69 @@ class FixtureBuilder { }); } + withPopularNetworks() { + return this.withNetworkController({ + networkConfigurations: { + 'op-mainnet': { + chainId: CHAIN_IDS.OPTIMISM, + nickname: 'OP Mainnet', + rpcPrefs: {}, + rpcUrl: 'https://mainnet.optimism.io', + ticker: 'ETH', + id: 'op-mainnet', + }, + 'polygon-mainnet': { + chainId: CHAIN_IDS.POLYGON, + nickname: 'Polygon Mainnet', + rpcPrefs: {}, + rpcUrl: 'https://polygon-rpc.com', + ticker: 'MATIC', + id: 'polygon-mainnet', + }, + 'arbitrum-one': { + chainId: CHAIN_IDS.ARBITRUM, + nickname: 'Arbitrum One', + rpcPrefs: {}, + rpcUrl: 'https://arb1.arbitrum.io/rpc', + ticker: 'ETH', + id: 'arbitrum-one', + }, + 'avalanche-mainnet': { + chainId: CHAIN_IDS.AVALANCHE, + nickname: 'Avalanche Network C-Chain', + rpcPrefs: {}, + rpcUrl: 'https://api.avax.network/ext/bc/C/rpc', + ticker: 'AVAX', + id: 'avalanche-mainnet', + }, + 'bnb-mainnet': { + chainId: CHAIN_IDS.BSC, + nickname: 'BNB Chain', + rpcPrefs: {}, + rpcUrl: 'https://bsc-dataseed.binance.org', + ticker: 'BNB', + id: 'bnb-mainnet', + }, + 'base-mainnet': { + chainId: CHAIN_IDS.BASE, + nickname: 'Base', + rpcPrefs: {}, + rpcUrl: 'https://mainnet.base.org', + ticker: 'ETH', + id: 'base-mainnet', + }, + 'zksync-mainnet': { + chainId: CHAIN_IDS.ZKSYNC_ERA, + nickname: 'zkSync Era', + rpcPrefs: {}, + rpcUrl: 'https://mainnet.era.zksync.io', + ticker: 'ETH', + id: 'zksync-mainnet', + }, + }, + }); + } + build() { this.fixture.meta = { version: FIXTURE_STATE_METADATA_VERSION, diff --git a/test/e2e/flask/multichain-api/testHelpers.ts b/test/e2e/flask/multichain-api/testHelpers.ts new file mode 100644 index 000000000000..5f1a454c47f7 --- /dev/null +++ b/test/e2e/flask/multichain-api/testHelpers.ts @@ -0,0 +1,139 @@ +import * as path from 'path'; +import { By } from 'selenium-webdriver'; +import { KnownRpcMethods, KnownNotifications } from '@metamask/multichain'; +import { + multipleGanacheOptions, + regularDelayMs, + WINDOW_TITLES, +} from '../../helpers'; +import { Driver } from '../../webdriver/driver'; + +export type FixtureCallbackArgs = { driver: Driver; extensionId: string }; + +/** + * Default options for setting up Multichain E2E test environment + */ +export const DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS = { + dapp: true, + dappPaths: [ + path.join( + '..', + '..', + 'node_modules', + '@metamask', + 'test-dapp-multichain', + 'build', + ), + ], + localNodeOptions: { + ...multipleGanacheOptions, + concurrent: [ + { + port: 8546, + chainId: 1338, + ganacheOptions2: multipleGanacheOptions, + }, + { + port: 7777, + chainId: 1000, + ganacheOptions2: multipleGanacheOptions, + }, + ], + }, +}; + +/** + * Retrieves the expected session scope for a given set of addresses. + * + * @param scope - The session scope. + * @param accounts - The addresses to get session scope for. + * @returns the expected session scope. + */ +export const getExpectedSessionScope = (scope: string, accounts: string[]) => ({ + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: accounts.map((acc) => `${scope}:${acc.toLowerCase()}`), +}); + +export const addAccountInWalletAndAuthorize = async ( + driver: Driver, +): Promise => { + const editButtons = await driver.findElements('[data-testid="edit"]'); + await editButtons[0].click(); + await driver.clickElement({ text: 'New account', tag: 'button' }); + await driver.clickElement({ text: 'Add account', tag: 'button' }); + await driver.delay(regularDelayMs); + + /** + * this needs to be called again, as previous element is stale and will not be found in current frame + */ + const freshEditButtons = await driver.findElements('[data-testid="edit"]'); + await freshEditButtons[0].click(); + await driver.delay(regularDelayMs); + + const checkboxes = await driver.findElements('input[type="checkbox" i]'); + await checkboxes[0].click(); // select all checkbox + await driver.delay(regularDelayMs); + + await driver.clickElement({ text: 'Update', tag: 'button' }); +}; + +/** + * Update Multichain network edit form so that only matching networks are selected. + * + * @param driver - E2E test driver {@link Driver}, wrapping the Selenium WebDriver. + * @param selectedNetworkNames + */ +export const updateNetworkCheckboxes = async ( + driver: Driver, + selectedNetworkNames: string[], +): Promise => { + const editButtons = await driver.findElements('[data-testid="edit"]'); + await editButtons[1].click(); + await driver.delay(regularDelayMs); + + const networkListItems = await driver.findElements( + '.multichain-network-list-item', + ); + + for (const item of networkListItems) { + const networkName = await item.getText(); + const checkbox = await item.findElement(By.css('input[type="checkbox"]')); + const isChecked = await checkbox.isSelected(); + + const isSelectedNetwork = selectedNetworkNames.some((selectedNetworkName) => + networkName.includes(selectedNetworkName), + ); + + const shouldNotBeChecked = isChecked && !isSelectedNetwork; + const shouldBeChecked = !isChecked && isSelectedNetwork; + + if (shouldNotBeChecked || shouldBeChecked) { + await checkbox.click(); + await driver.delay(regularDelayMs); + } + } + await driver.clickElement({ text: 'Update', tag: 'button' }); +}; + +/** + * Password locks user's metamask extension. + * + * @param driver - E2E test driver {@link Driver}, wrapping the Selenium WebDriver. + */ +export const passwordLockMetamaskExtension = async ( + driver: Driver, +): Promise => { + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + await driver.clickElementSafe('[data-testid="account-options-menu-button"]'); + await driver.clickElementSafe('[data-testid="global-menu-lock"]'); +}; + +/** + * Sometimes we need to escape colon character when using {@link Driver.findElement}, otherwise selenium will treat this as an invalid selector. + * + * @param selector - string to manipulate. + * @returns string with escaped colon char. + */ +export const escapeColon = (selector: string): string => + selector.replace(':', '\\:'); diff --git a/test/e2e/flask/multichain-api/wallet_createSession.spec.ts b/test/e2e/flask/multichain-api/wallet_createSession.spec.ts new file mode 100644 index 000000000000..c28f9cd44290 --- /dev/null +++ b/test/e2e/flask/multichain-api/wallet_createSession.spec.ts @@ -0,0 +1,429 @@ +import { strict as assert } from 'assert'; +import { By } from 'selenium-webdriver'; +import { isObject } from 'lodash'; +import { + largeDelayMs, + WINDOW_TITLES, + withFixtures, + ACCOUNT_1, + ACCOUNT_2, + unlockWallet, +} from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import TestDappMultichain from '../../page-objects/pages/test-dapp-multichain'; +import { + DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + getExpectedSessionScope, + addAccountInWalletAndAuthorize, + updateNetworkCheckboxes, + type FixtureCallbackArgs, +} from './testHelpers'; + +describe('Multichain API', function () { + describe('Connect wallet to the multichain dapp via `externally_connectable`, call `wallet_createSession` with requested EVM scope that does NOT match one of the user’s enabled networks', function () { + it("the specified EVM scopes that do not match the user's configured networks should be treated as if they were not requested", async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerOnMainnet() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + const scopesToIgnore = ['eip155:1338', 'eip155:1000']; + + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes([ + 'eip155:1337', + ...scopesToIgnore, + ]); + + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + const getSessionResult = await testDapp.getSession(); + + for (const scope of scopesToIgnore) { + assert.strictEqual( + getSessionResult.sessionScopes[scope], + undefined, + ); + } + }, + ); + }); + }); + + describe('Call `wallet_createSession` with EVM scopes that match the user’s enabled networks, and eip155 scoped accounts', function () { + it('should ignore requested accounts that do not match accounts in the wallet and and pre-select matching requested accounts in the permission confirmation screen', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withTrezorAccount() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + const REQUEST_SCOPE = 'eip155:1337'; + /** + * check {@link FixtureBuilder.withTrezorAccount} for second injected account address. + */ + const SECOND_ACCOUNT_IN_WALLET = + '0xf68464152d7289d7ea9a2bec2e0035c45188223c'; + const ACCOUNT_NOT_IN_WALLET = + '0x9999999999999999999999999999999999999999'; + + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes( + [REQUEST_SCOPE], + [SECOND_ACCOUNT_IN_WALLET, ACCOUNT_NOT_IN_WALLET], + ); + + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + const getSessionResult = await testDapp.getSession(); + /** + * Accounts in scope should not include invalid account {@link ACCOUNT_NOT_IN_WALLET}, only the valid accounts. + */ + const expectedSessionScope = getExpectedSessionScope(REQUEST_SCOPE, [ + SECOND_ACCOUNT_IN_WALLET, + ]); + const result = getSessionResult.sessionScopes[REQUEST_SCOPE].accounts; + + assert.deepEqual( + expectedSessionScope.accounts, + result, + `${expectedSessionScope.accounts} does not match accounts in scope ${result}`, + ); + }, + ); + }); + }); + + it('should only select the specified EVM scopes requested by the user', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder().withPopularNetworks().build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + const requestScopesToNetworkMap = { + 'eip155:1': 'Ethereum Mainnet', + 'eip155:59141': 'Linea Sepolia', + }; + + const requestScopes = Object.keys(requestScopesToNetworkMap); + const networksToRequest = Object.values(requestScopesToNetworkMap); + + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(requestScopes); + + // navigate to network selection screen + const editButtons = await driver.findElements('[data-testid="edit"]'); + await editButtons[1].click(); + await driver.delay(largeDelayMs); + + const networkListItems = await driver.findElements( + '.multichain-network-list-item', + ); + + for (const item of networkListItems) { + const network = await item.getText(); + const checkbox = await item.findElement( + By.css('input[type="checkbox"]'), + ); + const isChecked = await checkbox.isSelected(); + + if (networksToRequest.includes(network)) { + assert.strictEqual( + isChecked, + true, + `Expected ${network} to be selected.`, + ); + } else { + assert.strictEqual( + isChecked, + false, + `Expected ${network} to NOT be selected.`, + ); + } + } + }, + ); + }); + + describe('Call `wallet_createSession`', function () { + describe('With requested EVM scope that match the user’s enabled networks, edit selection in wallet UI', function () { + it('should change result according to changed network & accounts', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerAdditionalAccountIdentities() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes( + ['eip155:1337', 'eip155:1338'], + [ACCOUNT_1], + ); + + await addAccountInWalletAndAuthorize(driver); + await updateNetworkCheckboxes(driver, ['Localhost 8545']); + + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + const getSessionResult = await testDapp.getSession(); + + assert.strictEqual( + getSessionResult.sessionScopes['eip155:1338'], + undefined, + ); + + assert.ok(getSessionResult.sessionScopes['eip155:1337']); + + assert.deepEqual( + getSessionResult.sessionScopes['eip155:1337'].accounts, + getExpectedSessionScope('eip155:1337', [ACCOUNT_1, ACCOUNT_2]) + .accounts, + `Should add account ${ACCOUNT_2} to scope`, + ); + }, + ); + }); + }); + }); + + describe('Connect wallet to the multichain dapp via `externally_connectable`, call `wallet_createSession` without any accounts requested', function () { + it('should automatically select the current active account', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder().build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(['eip155:1337']); + + const editButtons = await driver.findElements('[data-testid="edit"]'); + await editButtons[0].click(); + + const checkboxes = await driver.findElements( + 'input[type="checkbox" i]', + ); + const accountCheckbox = checkboxes[1]; + const isChecked = await accountCheckbox.isSelected(); + + assert.strictEqual( + isChecked, + true, + 'current active account in the wallet should be automatically selected', + ); + }, + ); + }); + }); + + describe('Connect wallet to the multichain dapp via `externally_connectable`, call `wallet_createSession`, choose to edit accounts and', function () { + describe('add a new one', function () { + it('dApp should receive a response that includes permissions for the accounts that were selected for sharing', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPreferencesControllerAdditionalAccountIdentities() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(['eip155:1']); + + await addAccountInWalletAndAuthorize(driver); + + await driver.clickElement({ text: 'Connect', tag: 'button' }); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.MultichainTestDApp, + ); + + const getSessionResult = await testDapp.getSession(); + + assert.deepEqual( + getSessionResult.sessionScopes['eip155:1'].accounts, + getExpectedSessionScope('eip155:1', [ACCOUNT_1, ACCOUNT_2]) + .accounts, + 'The dapp should receive a response that includes permissions for the accounts that were selected for sharing', + ); + }, + ); + }); + }); + + describe('deselect all', function () { + it('should not be able to approve the create session request without at least one account selected', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder().build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(['eip155:1337']); + + const editButtons = await driver.findElements( + '[data-testid="edit"]', + ); + await editButtons[0].click(); + + const checkboxes = await driver.findElements( + 'input[type="checkbox" i]', + ); + const selectAllCheckbox = checkboxes[0]; + + await selectAllCheckbox.click(); + await driver.clickElement({ text: 'Disconnect', tag: 'button' }); + + const confirmButton = await driver.findElement( + '[data-testid="confirm-btn"]', + ); + const isEnabled = await confirmButton.isEnabled(); + + assert.strictEqual( + isEnabled, + false, + 'should not able to approve the create session request without at least one account should be selected', + ); + }, + ); + }); + }); + }); + + describe('Dapp has existing session with 3 scopes and 2 accounts and then calls `wallet_createSession` with different scopes and accounts', function () { + const OLD_SCOPES = ['eip155:1337', 'eip155:1', 'eip155:42161']; + const NEW_SCOPES = ['eip155:1338', 'eip155:1000']; + const TREZOR_ACCOUNT = '0xf68464152d7289d7ea9a2bec2e0035c45188223c'; + + it('should entirely overwrite old session permissions by those requested in the new `wallet_createSession` request', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .withPermissionControllerConnectedToMultichainTestDappWithTwoAccounts( + { + scopes: OLD_SCOPES, + }, + ) + .withTrezorAccount() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + + /** + * We first make sure sessions exist + */ + const existinggetSessionResult = await testDapp.getSession(); + OLD_SCOPES.forEach((scope) => + assert.strictEqual( + isObject(existinggetSessionResult.sessionScopes[scope]), + true, + `scope ${scope} should exist`, + ), + ); + + /** + * Then we make sure to deselect the existing session scopes, and create session with new scopes + */ + OLD_SCOPES.forEach( + async (scope) => + await driver.clickElement(`input[name="${scope}"]`), + ); + await testDapp.initCreateSessionScopes(NEW_SCOPES, [TREZOR_ACCOUNT]); + await driver.clickElement({ text: 'Connect', tag: 'button' }); + await driver.delay(largeDelayMs); + + const newgetSessionResult = await testDapp.getSession(); + + /** + * Assert old sessions don't exist anymore, as they are overwritten by new session scopes + */ + OLD_SCOPES.forEach((scope) => + assert.strictEqual( + newgetSessionResult.sessionScopes[scope], + undefined, + `scope ${scope} should not exist anymore`, + ), + ); + + const expectedNewSessionScopes = NEW_SCOPES.map((scope) => ({ + [scope]: getExpectedSessionScope(scope, [TREZOR_ACCOUNT]), + })); + + for (const expectedSessionScope of expectedNewSessionScopes) { + const [scopeName] = Object.keys(expectedSessionScope); + const expectedScopeObject = expectedSessionScope[scopeName]; + const resultSessionScope = + newgetSessionResult.sessionScopes[scopeName]; + + assert.deepEqual( + expectedScopeObject, + resultSessionScope, + `${scopeName} does not match expected scope`, + ); + + const resultAccounts = resultSessionScope.accounts; + assert.deepEqual( + expectedScopeObject.accounts, + resultAccounts, + `${expectedScopeObject.accounts} do not match accounts in scope ${scopeName}`, + ); + } + }, + ); + }); + }); +}); diff --git a/test/e2e/flask/multichain-api/wallet_getSession.spec.ts b/test/e2e/flask/multichain-api/wallet_getSession.spec.ts new file mode 100644 index 000000000000..d063e7cf70f1 --- /dev/null +++ b/test/e2e/flask/multichain-api/wallet_getSession.spec.ts @@ -0,0 +1,77 @@ +import { strict as assert } from 'assert'; +import { unlockWallet, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { DEFAULT_FIXTURE_ACCOUNT } from '../../constants'; +import TestDappMultichain from '../../page-objects/pages/test-dapp-multichain'; +import { + DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + getExpectedSessionScope, + type FixtureCallbackArgs, +} from './testHelpers'; + +describe('Multichain API', function () { + describe('Connect wallet to the multichain dapp via `externally_connectable`, call `wallet_getSession` when there is no existing session', function () { + it('should successfully receive empty session scopes', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder().withPopularNetworks().build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + const parsedResult = await testDapp.getSession(); + + assert.deepStrictEqual( + parsedResult.sessionScopes, + {}, + 'Should receive empty session scopes', + ); + }, + ); + }); + }); + + describe('Connect wallet to the multichain dapp via `externally_connectable`, call `wallet_getSession` when there is an existing session', function () { + it('should successfully receive result that specifies its permitted session scopes for selected chains', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withPopularNetworks() + .withPermissionControllerConnectedToTestDapp() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + /** + * check {@link FixtureBuilder.withPermissionControllerConnectedToTestDapp} for default scopes returned + */ + const DEFAULT_SCOPE = 'eip155:1337'; + + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + const parsedResult = await testDapp.getSession(); + + const sessionScope = parsedResult.sessionScopes[DEFAULT_SCOPE]; + const expectedSessionScope = getExpectedSessionScope(DEFAULT_SCOPE, [ + DEFAULT_FIXTURE_ACCOUNT, + ]); + + assert.deepStrictEqual( + sessionScope, + expectedSessionScope, + `Should receive result that specifies expected session scopes for ${DEFAULT_SCOPE}`, + ); + }, + ); + }); + }); +}); diff --git a/test/e2e/flask/multichain-api/wallet_invokeMethod.spec.ts b/test/e2e/flask/multichain-api/wallet_invokeMethod.spec.ts new file mode 100644 index 000000000000..9d1abf5384c5 --- /dev/null +++ b/test/e2e/flask/multichain-api/wallet_invokeMethod.spec.ts @@ -0,0 +1,246 @@ +import { strict as assert } from 'assert'; +import { + ACCOUNT_1, + ACCOUNT_2, + convertETHToHexGwei, + largeDelayMs, + unlockWallet, + WINDOW_TITLES, + withFixtures, +} from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { DEFAULT_GANACHE_ETH_BALANCE_DEC } from '../../constants'; +import TestDappMultichain from '../../page-objects/pages/test-dapp-multichain'; +import { + DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + addAccountInWalletAndAuthorize, + escapeColon, + type FixtureCallbackArgs, +} from './testHelpers'; + +describe('Multichain API', function () { + const GANACHE_SCOPES = ['eip155:1337', 'eip155:1338', 'eip155:1000']; + const ACCOUNTS = [ACCOUNT_1, ACCOUNT_2]; + const DEFAULT_INITIAL_BALANCE_HEX = convertETHToHexGwei( + DEFAULT_GANACHE_ETH_BALANCE_DEC, + ); + + describe('Calling `wallet_invokeMethod` on the same dapp across three different connected chains', function () { + describe('Read operations: calling different methods on each connected scope', function () { + it('Should match selected method to the expected output', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(GANACHE_SCOPES, ACCOUNTS); + await addAccountInWalletAndAuthorize(driver); + await driver.clickElement({ text: 'Connect', tag: 'button' }); + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.MultichainTestDApp, + ); + + const TEST_METHODS = { + [GANACHE_SCOPES[0]]: 'eth_chainId', + [GANACHE_SCOPES[1]]: 'eth_getBalance', + [GANACHE_SCOPES[2]]: 'eth_gasPrice', + }; + const EXPECTED_RESULTS = { + [GANACHE_SCOPES[0]]: '0x539', + [GANACHE_SCOPES[1]]: DEFAULT_INITIAL_BALANCE_HEX, + [GANACHE_SCOPES[2]]: '0x77359400', + }; + + for (const scope of GANACHE_SCOPES) { + const invokeMethod = TEST_METHODS[scope]; + await driver.clickElementSafe( + `[data-testid="${scope}-${invokeMethod}-option"]`, + ); + + await driver.clickElementSafe( + `[data-testid="invoke-method-${scope}-btn"]`, + ); + + const resultElement = await driver.findElement( + `#invoke-method-${escapeColon(scope)}-${invokeMethod}-result-0`, + ); + + const result = await resultElement.getText(); + + assert.strictEqual( + result, + `"${EXPECTED_RESULTS[scope]}"`, + `${scope} method ${invokeMethod} expected "${EXPECTED_RESULTS[scope]}", got ${result} instead`, + ); + } + }, + ); + }); + }); + + describe('Write operations: calling `eth_sendTransaction` on each connected scope', function () { + const INDEX_FOR_ALTERNATE_ACCOUNT = 1; + + it('should match chosen addresses in each chain to the selected address per scope in extension window', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(GANACHE_SCOPES, ACCOUNTS); + await addAccountInWalletAndAuthorize(driver); + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.MultichainTestDApp, + ); + + for (const [i, scope] of GANACHE_SCOPES.entries()) { + await driver.clickElementSafe( + `[data-testid="${scope}-eth_sendTransaction-option"]`, + ); + + i === INDEX_FOR_ALTERNATE_ACCOUNT && + (await driver.clickElementSafe( + `[data-testid="${scope}:${ACCOUNT_2}-option"]`, + )); + } + + await driver.clickElement({ + text: 'Invoke All Selected Methods', + tag: 'button', + }); + + for (const i of GANACHE_SCOPES.keys()) { + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + const accountWebElement = await driver.findElement( + '[data-testid="sender-address"]', + ); + const accountText = await accountWebElement.getText(); + const expectedAccount = + i === INDEX_FOR_ALTERNATE_ACCOUNT ? 'Account 2' : 'Account 1'; + + assert.strictEqual( + accountText, + expectedAccount, + `Should have ${expectedAccount} selected, got ${accountText}`, + ); + + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + } + }, + ); + }); + + it('should have less balance due to gas after transaction is sent', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(GANACHE_SCOPES, ACCOUNTS); + await addAccountInWalletAndAuthorize(driver); + await driver.clickElement({ text: 'Connect', tag: 'button' }); + + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.MultichainTestDApp, + ); + + for (const [i, scope] of GANACHE_SCOPES.entries()) { + await driver.clickElementSafe( + `[data-testid="${scope}-eth_sendTransaction-option"]`, + ); + + i === INDEX_FOR_ALTERNATE_ACCOUNT && + (await driver.clickElementSafe( + `[data-testid="${scope}:${ACCOUNT_2}-option"]`, + )); + } + + await driver.clickElement({ + text: 'Invoke All Selected Methods', + tag: 'button', + }); + + const totalNumberOfScopes = GANACHE_SCOPES.length; + for (let i = 0; i < totalNumberOfScopes; i++) { + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + } + + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.MultichainTestDApp, + ); + + await driver.clickElementSafe({ + text: 'Clear Results', + tag: 'button', + }); + + for (const scope of GANACHE_SCOPES) { + await driver.clickElementSafe( + `[data-testid="${scope}-eth_getBalance-option"]`, + ); + + await driver.delay(largeDelayMs); + await driver.clickElementSafe( + `[data-testid="invoke-method-${scope}-btn"]`, + ); + + const resultWebElement = await driver.findElement( + `#invoke-method-${escapeColon(scope)}-eth_getBalance-result-0`, + ); + const currentBalance = await resultWebElement.getText(); + + assert.notStrictEqual( + currentBalance, + `"${DEFAULT_INITIAL_BALANCE_HEX}"`, + `${scope} scope balance should be different after eth_sendTransaction due to gas`, + ); + } + }, + ); + }); + }); + }); +}); diff --git a/test/e2e/flask/multichain-api/wallet_notify.spec.ts b/test/e2e/flask/multichain-api/wallet_notify.spec.ts new file mode 100644 index 000000000000..e6229a0b36c9 --- /dev/null +++ b/test/e2e/flask/multichain-api/wallet_notify.spec.ts @@ -0,0 +1,60 @@ +import { strict as assert } from 'assert'; +import { unlockWallet, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import TestDappMultichain from '../../page-objects/pages/test-dapp-multichain'; +import { + DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + type FixtureCallbackArgs, +} from './testHelpers'; + +describe('Calling `eth_subscribe` on a particular network event', function () { + it('Should receive a notification through the Multichain API for the event app subscribed to', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToMultichainTestDapp() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + const SCOPE = 'eip155:1337'; + + await driver.clickElementSafe( + `[data-testid="${SCOPE}-eth_subscribe-option"]`, + ); + await driver.clickElementSafe( + `[data-testid="invoke-method-${SCOPE}-btn"]`, + ); + + const walletNotifyNotificationWebElement = await driver.findElement( + '#wallet-notify-result-0', + ); + const resultSummaries = await driver.findElements('.result-summary'); + + /** + * Currently we don't have `data-testid` setup for the desired result, so we click on all available results + * to make the complete text available and later evaluate if scopes match. + */ + resultSummaries.forEach(async (element) => await element.click()); + + const parsedNotificationResult = JSON.parse( + await walletNotifyNotificationWebElement.getText(), + ); + + const resultScope = parsedNotificationResult.params.scope; + + assert.strictEqual( + parsedNotificationResult.params.scope, + SCOPE, + `received notification should come from the subscribed event and scope. Expected scope: ${SCOPE}, Actual scope: ${resultScope}`, + ); + }, + ); + }); +}); diff --git a/test/e2e/flask/multichain-api/wallet_revokeSession.spec.ts b/test/e2e/flask/multichain-api/wallet_revokeSession.spec.ts new file mode 100644 index 000000000000..3c9791a1b720 --- /dev/null +++ b/test/e2e/flask/multichain-api/wallet_revokeSession.spec.ts @@ -0,0 +1,159 @@ +import { strict as assert } from 'assert'; +import { pick } from 'lodash'; +import { + ACCOUNT_1, + ACCOUNT_2, + largeDelayMs, + unlockWallet, + WINDOW_TITLES, + withFixtures, +} from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import TestDappMultichain from '../../page-objects/pages/test-dapp-multichain'; +import { + DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + addAccountInWalletAndAuthorize, + type FixtureCallbackArgs, +} from './testHelpers'; + +describe('Initializing a session w/ several scopes and accounts, then calling `wallet_revokeSession`', function () { + const GANACHE_SCOPES = ['eip155:1337', 'eip155:1338', 'eip155:1000']; + const ACCOUNTS = [ACCOUNT_1, ACCOUNT_2]; + it('Should return empty object from `wallet_getSession` call', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(GANACHE_SCOPES, ACCOUNTS); + await addAccountInWalletAndAuthorize(driver); + await driver.clickElement({ text: 'Connect', tag: 'button' }); + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.MultichainTestDApp); + + /** + * We verify that scopes are not empty before calling `wallet_revokeSession` + */ + const { sessionScopes } = await testDapp.getSession(); + assert.ok( + Object.keys(sessionScopes).length > 0, + 'Should have non-empty session scopes value before calling `wallet_revokeSession`', + ); + + await driver.clickElement({ + text: 'wallet_revokeSession', + tag: 'span', + }); + + const parsedResult = await testDapp.getSession(); + const resultSessionScopes = parsedResult.sessionScopes; + assert.deepStrictEqual( + resultSessionScopes, + {}, + 'Should receive an empty session scopes value after calling `wallet_revokeSession`', + ); + }, + ); + }); + + it('Should throw an error if `wallet_invokeMethod` is called afterwards', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ driver, extensionId }: FixtureCallbackArgs) => { + const expectedError = { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + }; + + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + + await testDapp.initCreateSessionScopes(GANACHE_SCOPES, ACCOUNTS); + await addAccountInWalletAndAuthorize(driver); + await driver.clickElement({ text: 'Connect', tag: 'button' }); + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.MultichainTestDApp); + + await driver.clickElement({ + text: 'wallet_revokeSession', + tag: 'span', + }); + + for (const scope of GANACHE_SCOPES) { + const id = 1999133338649204; + const data = JSON.stringify({ + id, + jsonrpc: '2.0', + method: 'wallet_invokeMethod', + params: { + scope, + request: { + method: 'eth_getBalance', + params: [ACCOUNT_1, 'latest'], + }, + }, + }); + + const script = ` + const port = chrome.runtime.connect('${extensionId}'); + const data = ${data}; + const result = new Promise((resolve) => { + port.onMessage.addListener((msg) => { + if (msg.type !== 'caip-x') { + return; + } + if (msg.data?.id !== ${id}) { + return; + } + + if (msg.data.id || msg.data.error) { + resolve(msg) + } + }) + }) + port.postMessage({ type: 'caip-x', data }); + return result;`; + + /** + * We call `executeScript` to attempt JSON rpc call directly through the injected provider object since when session is revoked, + * webapp does not provide UI to make call. + */ + const actualError = await driver + .executeScript(script) + .then((res) => res.data?.error); + + /** + * We make sure it's the expected error by comparing expected error code and message (we ignore `stack` property) + */ + assert.deepEqual( + expectedError, + pick( + actualError, + ['code', 'message'], + `calling wallet_invokeMethod should throw an error for scope ${scope}`, + ), + ); + } + }, + ); + }); +}); diff --git a/test/e2e/flask/multichain-api/wallet_sessionChanged.spec.ts b/test/e2e/flask/multichain-api/wallet_sessionChanged.spec.ts new file mode 100644 index 000000000000..7cc68cf9dae8 --- /dev/null +++ b/test/e2e/flask/multichain-api/wallet_sessionChanged.spec.ts @@ -0,0 +1,121 @@ +import { strict as assert } from 'assert'; +import { + ACCOUNT_1, + ACCOUNT_2, + largeDelayMs, + unlockWallet, + WINDOW_TITLES, + withFixtures, +} from '../../helpers'; +import { Driver } from '../../webdriver/driver'; +import FixtureBuilder from '../../fixture-builder'; +import TestDappMultichain from '../../page-objects/pages/test-dapp-multichain'; +import { + addAccountInWalletAndAuthorize, + DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + getExpectedSessionScope, + updateNetworkCheckboxes, +} from './testHelpers'; + +describe('Call `wallet_createSession`, then update the accounts and/or scopes in the permissions page of the wallet for that dapp', function () { + const INITIAL_SCOPES = ['eip155:1337', 'eip155:1338']; + const REMOVED_SCOPE = INITIAL_SCOPES[0]; + const UPDATED_SCOPE = INITIAL_SCOPES[1]; + + const ACCOUNTS = [ACCOUNT_1, ACCOUNT_2]; + const UPDATED_ACCOUNT = ACCOUNTS[1]; + it('should receive a `wallet_sessionChanged` event with the full new session scopes', async function () { + await withFixtures( + { + title: this.test?.fullTitle(), + fixtures: new FixtureBuilder() + .withNetworkControllerTripleGanache() + .build(), + ...DEFAULT_MULTICHAIN_TEST_DAPP_FIXTURE_OPTIONS, + }, + async ({ + driver, + extensionId, + }: { + driver: Driver; + extensionId: string; + }) => { + await unlockWallet(driver); + + const testDapp = new TestDappMultichain(driver); + await testDapp.openTestDappPage(); + await testDapp.connectExternallyConnectable(extensionId); + await testDapp.initCreateSessionScopes(INITIAL_SCOPES, ACCOUNTS); + await addAccountInWalletAndAuthorize(driver); + await driver.clickElement({ text: 'Connect', tag: 'button' }); + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + + /** + * We make sure to update selected accounts via wallet extension UI + */ + await driver.clickElementSafe( + '[data-testid="account-options-menu-button"]', + ); + await driver.clickElementSafe( + '[data-testid="global-menu-connected-sites"]', + ); + await driver.clickElementSafe('[data-testid="connection-list-item"]'); + + const editButtons = await driver.findElements('[data-testid="edit"]'); + await editButtons[0].click(); + const checkboxes = await driver.findElements( + 'input[type="checkbox" i]', + ); + const firstAccountCheckbox = checkboxes[1]; + await firstAccountCheckbox.click(); + await driver.clickElementSafe({ text: 'Update', tag: 'button' }); + + /** + * And also update selected scope to {@link UPDATED_SCOPE} + */ + await updateNetworkCheckboxes(driver, ['Localhost 8546']); + await driver.switchToWindowWithTitle(WINDOW_TITLES.MultichainTestDApp); + + const walletSessionChangedNotificationWebElement = + await driver.findElement('#wallet-session-changed-result-0'); + + const resultSummaries = await driver.findElements('.result-summary'); + await resultSummaries[1].click(); + + const expectedScope = getExpectedSessionScope(UPDATED_SCOPE, [ + UPDATED_ACCOUNT, + ]); + + const parsedNotificationResult = JSON.parse( + await walletSessionChangedNotificationWebElement.getText(), + ); + const sessionChangedScope = + parsedNotificationResult.params.sessionScopes; + + const currentScope = sessionChangedScope[UPDATED_SCOPE]; + const scopedAccounts = currentScope.accounts; + + assert.deepEqual( + currentScope, + expectedScope, + `scope ${UPDATED_SCOPE} should be present in 'wallet_sessionChanged' event data`, + ); + + assert.deepEqual( + scopedAccounts, + expectedScope.accounts, + `${expectedScope.accounts} does not match accounts in scope ${currentScope}`, + ); + + assert.deepEqual( + sessionChangedScope[REMOVED_SCOPE], + undefined, + `scope ${REMOVED_SCOPE} should NOT be present in 'wallet_sessionChanged' event data`, + ); + }, + ); + }); +}); diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index e4e39db927f0..4ed2a4f7f00b 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -49,6 +49,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(localNodeOptions); @@ -189,7 +192,9 @@ async function withFixtures(options, testSuite) { await 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') { @@ -227,6 +232,7 @@ async function withFixtures(options, testSuite) { mockedEndpoint, bundlerServer, mockServer, + extensionId, }); const errorsAndExceptions = driver.summarizeErrorsAndExceptions(); @@ -360,6 +366,7 @@ const WINDOW_TITLES = Object.freeze({ ServiceWorkerSettings: 'Inspect with Chrome Developer Tools', SnapSimpleKeyringDapp: 'SSK - Simple Snap Keyring', TestDApp: 'E2E Test Dapp', + MultichainTestDApp: 'Multichain Test Dapp', TestSnaps: 'Test Snaps', ERC4337Snap: 'Account Abstraction Snap', }); diff --git a/test/e2e/page-objects/pages/test-dapp-multichain.ts b/test/e2e/page-objects/pages/test-dapp-multichain.ts new file mode 100644 index 000000000000..ce02bd63aa91 --- /dev/null +++ b/test/e2e/page-objects/pages/test-dapp-multichain.ts @@ -0,0 +1,156 @@ +import { NormalizedScopeObject } from '@metamask/multichain'; +import { largeDelayMs, WINDOW_TITLES } from '../../helpers'; +import { Driver } from '../../webdriver/driver'; + +const DAPP_HOST_ADDRESS = '127.0.0.1:8080'; +const DAPP_URL = `http://${DAPP_HOST_ADDRESS}`; + +class TestDappMultichain { + private readonly driver: Driver; + + private readonly connectExternallyConnectableButton = { + text: 'Connect', + tag: 'button', + }; + + private readonly extensionIdInput = '[placeholder="Enter extension ID"]'; + + private readonly firstSessionMethodResult = '#session-method-result-0'; + + private readonly walletCreateSessionButton = { + text: 'wallet_createSession', + tag: 'span', + }; + + private readonly walletGetSessionButton = { + text: 'wallet_getSession', + tag: 'span', + }; + + private readonly resultSummary = '.result-summary'; + + constructor(driver: Driver) { + this.driver = driver; + } + + addCustomAccountAddressInput(i: number) { + return `#add-custom-address-button-${i}`; + } + + addCustomScopeButton(i: number) { + return `#add-custom-scope-button-${i}`; + } + + customAccountAddressInput(i: number) { + return `#custom-Address-input-${i}`; + } + + customScopeInput(i: number) { + return `#custom-Scope-input-${i}`; + } + + async clickConnectExternallyConnectableButton() { + await this.driver.clickElement(this.connectExternallyConnectableButton); + } + + async clickFirstResultSummary() { + const resultSummaries = await this.driver.findElements(this.resultSummary); + const firstResultSummary = resultSummaries[0]; + await firstResultSummary.click(); + } + + async clickWalletCreateSessionButton() { + await this.driver.clickElement(this.walletCreateSessionButton); + } + + async clickWalletGetSessionButton() { + await this.driver.clickElement(this.walletGetSessionButton); + } + + async fillExtensionIdInput(extensionId: string) { + await this.driver.fill(this.extensionIdInput, extensionId); + } + + /** + * Open the multichain test dapp page. + * + * @param options - The options for opening the test dapp page. + * @param options.url - The URL of the dapp. Defaults to DAPP_URL. + * @returns A promise that resolves when the new page is opened. + */ + async openTestDappPage({ + url = DAPP_URL, + }: { + url?: string; + } = {}): Promise { + await this.driver.openNewPage(url); + } + + /** + * Connect to multichain test dapp to the Multichain API via externally_connectable. + * + * @param extensionId - Extension identifier for web dapp to interact with wallet extension. + */ + async connectExternallyConnectable(extensionId: string) { + console.log('Connect multichain test dapp to Multichain API'); + await this.fillExtensionIdInput(extensionId); + await this.clickConnectExternallyConnectableButton(); + await this.driver.delay(largeDelayMs); + } + + /** + * Initiates a request to wallet extension to create session for the passed scopes. + * + * @param scopes - scopes to create session for. + * @param accounts - The account addresses to create session for. + */ + async initCreateSessionScopes( + scopes: string[], + accounts: string[] = [], + ): Promise { + await this.driver.switchToWindowWithTitle(WINDOW_TITLES.MultichainTestDApp); + for (const [i, scope] of scopes.entries()) { + const scopeInput = await this.driver.waitForSelector( + this.customScopeInput(i), + ); + + // @ts-expect-error Driver.findNestedElement injects `fill` method onto returned element, but typescript compiler will not let us access this method without a complaint, so we override it. + scopeInput.fill(scope); + await this.driver.clickElement(this.addCustomScopeButton(i)); + } + + for (const [i, account] of accounts.entries()) { + const accountInput = await this.driver.waitForSelector( + this.customAccountAddressInput(i), + ); + + // @ts-expect-error Driver.findNestedElement injects `fill` method onto returned element, but typescript compiler will not let us access this method without a complaint, so we override it. + accountInput.fill(account); + await this.driver.clickElement(this.addCustomAccountAddressInput(i)); + } + + await this.clickWalletCreateSessionButton(); + await this.driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await this.driver.delay(largeDelayMs); + } + + /** + * Retrieves permitted session object. + * + * @returns the session object. + */ + async getSession(): Promise<{ + sessionScopes: Record; + }> { + await this.driver.switchToWindowWithTitle(WINDOW_TITLES.MultichainTestDApp); + await this.clickWalletGetSessionButton(); + await this.clickFirstResultSummary(); + + const getSessionRawResult = await this.driver.findElement( + this.firstSessionMethodResult, + ); + return JSON.parse(await getSessionRawResult.getText()); + } +} + +export default TestDappMultichain; diff --git a/test/e2e/run-api-specs-multichain.ts b/test/e2e/run-api-specs-multichain.ts new file mode 100644 index 000000000000..01448153a743 --- /dev/null +++ b/test/e2e/run-api-specs-multichain.ts @@ -0,0 +1,254 @@ +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 { Call, IOptions } from '@open-rpc/test-coverage/build/coverage'; +import { InternalScopeString } from '@metamask/multichain'; +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() { + let testCoverageResults: Call[] = []; + const port = 8545; + const chainId = 1337; + + const doc = await parseOpenRPCDocument( + MultiChainOpenRPCDocument as OpenrpcDocument, + ); + + 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 [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 }, + ); + + // Multichain API excluding `wallet_invokeMethod` + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder().build(), + disableGanache: true, + title: 'api-specs-multichain 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 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 results = await testCoverage({ + openrpcDocument: doc, + transport: createMultichainDriverTransport(driver, extensionId), + reporters: ['console-streaming'], + skip: ['wallet_invokeMethod'], + rules: [ + new ExamplesRule({ + skip: [], + only: ['wallet_getSession', 'wallet_revokeSession'], + }), + new MultichainAuthorizationConfirmation({ + driver, + }), + new MultichainAuthorizationConfirmationErrors({ + driver, + }), + ], + }); + + testCoverageResults = testCoverageResults.concat(results); + }, + ); + + // requests made via wallet_invokeMethod + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToMultichainTestDapp() + .build(), + disableGanache: true, + title: 'api-specs-multichain coverage (wallet_invokeMethod)', + }, + 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 results = 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, + requiresEthAccountsPermission: [], + }), + ], + }); + + testCoverageResults = testCoverageResults.concat(results); + }, + ); + + // fix ids for html reporter + testCoverageResults.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, testCoverageResults); + + // if any of the tests failed, exit with a non-zero code + if (testCoverageResults.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 60e52e3a4eab..ccf9cdc6e78a 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,374 +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 getProof = openrpcDocument.methods.find( - (m) => (m as MethodObject).name === 'eth_getProof', - ); - (getProof as MethodObject).examples = [ - { - name: 'getProofExample', - description: 'Example of a getProof request', - params: [ - { - name: 'address', - value: ACCOUNT_1, - }, - { - name: 'keys', - value: [ - '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', - ], - }, - { - name: 'tag', - value: 'latest', - }, - ], - result: { - name: 'getProofResult', - value: { - address: ACCOUNT_1, - balance: '0x15af1d78b58c40000', - codeHash: - '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470', - nonce: '0x0', - storageHash: - '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', - accountProof: [ - '0xf9017180a0ab8cdb808c8303bb61fb48e276217be9770fa83ecf3f90f2234d558885f5abf18080a0de26cb1b4fd99c4d3ed75d4a67931e3c252605c7d68e0148d5327f341bfd5283a0de86ea5531307567132648d5c7956cb6082d6803f3dbc9e16b2dd20b320ca93aa0c2c799b60a0cd6acd42c1015512872e86c186bcf196e85061e76842f3b7cf86080a04fa8b5b81f5814f27b3a3e2b6273792dc150c94bea8f90c4b4d3fb4f52cd80dea0c326f61dd1e74e037d4db73aede5642260bf92869081753bbace550a73989aeda06301b39b2ea8a44df8b0356120db64b788e71f52e1d7a6309d0d2e5b86fee7cb80a029087b3ba8c5129e161e2cb956640f4d8e31a35f3f133c19a1044993def98b61a0a5ac64bb99d260ef6b13a4f2040ed48a4936664ec13d400238b5004841a4d888a0a9e6cc0d5192cb036c2454c7cf19ff53abf1861b50043a7b3713bc003a5a7d88a0144540d36e30b250d25bd5c34d819538742dc54c2017c4eb1fabb8e45f72759180', - '0xf85180a0563305036bc8702a52ae6338bfbeca18e8f42fd5ee640e72e18f31455d3be5f880808080808080808080a02fb46956347985b9870156b5747712899d213b1636ad4fe553c63e33521d567a80808080', - '0xf872a020bf0de4df4861e4184def33fbb5c7e634b9c33718934bf717ec7b695ea08cb5b84ff84d8089015af1d78b58c40000a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470', - ], - storageProof: [ - { - key: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', - proof: [], - 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', @@ -442,6 +84,11 @@ async function main() { new ConfirmationsRejectRule({ driver, only: methodsWithConfirmations, + requiresEthAccountsPermission: [ + 'personal_sign', + 'eth_signTypedData_v4', + 'eth_getEncryptionPublicKey', + ], }), ], }); diff --git a/test/e2e/webdriver/chrome.js b/test/e2e/webdriver/chrome.js index 32a55bfc6dee..a86fde913d00 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/permission-page-container/permission-page-container.component.js b/ui/components/app/permission-page-container/permission-page-container.component.js index 776967ed9578..96d80c056563 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 @@ -4,10 +4,7 @@ import { SnapCaveatType, WALLET_SNAP_PERMISSION_KEY, } from '@metamask/snaps-rpc-methods'; -import { - Caip25EndowmentPermissionName, - getPermittedEthChainIds, -} from '@metamask/multichain'; +import { Caip25EndowmentPermissionName } from '@metamask/multichain'; import { SubjectType } from '@metamask/permission-controller'; import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; import { PageContainerFooter } from '../../ui/page-container'; @@ -24,7 +21,7 @@ import { } from '../../../helpers/constants/design-system'; import { Box } from '../../component-library'; import { - getRequestedSessionScopes, + getRequestedCaip25CaveatValue, getCaip25PermissionsResponse, } from '../../../pages/permissions-connect/connect-page/utils'; import { containsEthPermissionsAndNonEvmAccount } from '../../../helpers/utils/permissions'; @@ -145,22 +142,26 @@ export default class PermissionPageContainer extends Component { approvePermissionsRequest, rejectPermissionsRequest, selectedAccounts, + requestedChainIds, } = this.props; const approvedAccounts = selectedAccounts.map( (selectedAccount) => selectedAccount.address, ); - const requestedSessionsScopes = getRequestedSessionScopes( - _request.permission, + const requestedCaip25CaveatValue = getRequestedCaip25CaveatValue( + _request.permissions, ); - const approvedChainIds = getPermittedEthChainIds(requestedSessionsScopes); const request = { ..._request, permissions: { ..._request.permissions, - ...getCaip25PermissionsResponse(approvedAccounts, approvedChainIds), + ...getCaip25PermissionsResponse( + requestedCaip25CaveatValue, + approvedAccounts, + requestedChainIds, + ), }, }; diff --git a/ui/components/ui/icon/preloader/preloader-icon.component.js b/ui/components/ui/icon/preloader/preloader-icon.component.js index b478ee7dc7cd..0e3133ed08eb 100644 --- a/ui/components/ui/icon/preloader/preloader-icon.component.js +++ b/ui/components/ui/icon/preloader/preloader-icon.component.js @@ -19,7 +19,7 @@ const Preloader = ({ className, size }) => ( /> renders component for contract interaction requ renders component when the prop override is passed renders component when the state property is true 1 renders component 1`] = ` = ({ }) => { const t = useI18nContext(); - const requestedSessionsScopes = getRequestedSessionScopes( + const requestedCaip25CaveatValue = getRequestedCaip25CaveatValue( request.permissions, ); - const requestedAccounts = getEthAccounts(requestedSessionsScopes); - const requestedChainIds = getPermittedEthChainIds(requestedSessionsScopes); + const requestedAccounts = getEthAccounts(requestedCaip25CaveatValue); + const requestedChainIds = getPermittedEthChainIds(requestedCaip25CaveatValue); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); const [nonTestNetworks, testNetworks] = useMemo( @@ -144,6 +144,7 @@ export const ConnectPage: React.FC = ({ permissions: { ...request.permissions, ...getCaip25PermissionsResponse( + requestedCaip25CaveatValue, selectedAccountAddresses as Hex[], selectedChainIds, ), diff --git a/ui/pages/permissions-connect/connect-page/utils.test.ts b/ui/pages/permissions-connect/connect-page/utils.test.ts index fc494eb576cb..142f7bbe51ac 100644 --- a/ui/pages/permissions-connect/connect-page/utils.test.ts +++ b/ui/pages/permissions-connect/connect-page/utils.test.ts @@ -6,10 +6,24 @@ import { Hex } from '@metamask/utils'; import { CHAIN_IDS } from '../../../../shared/constants/network'; import { getCaip25PermissionsResponse } from './utils'; +const baseCaip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + isMultichainOrigin: false, +}; + describe('getCaip25PermissionsResponse', () => { describe('No accountAddresses or chainIds requested', () => { it(`should construct a valid ${Caip25EndowmentPermissionName} empty permission`, () => { - const result = getCaip25PermissionsResponse([], []); + const result = getCaip25PermissionsResponse( + baseCaip25CaveatValue, + [], + [], + ); expect(result).toEqual({ [Caip25EndowmentPermissionName]: { @@ -35,7 +49,11 @@ describe('getCaip25PermissionsResponse', () => { describe('Request approval for chainIds', () => { it(`should construct a valid ${Caip25EndowmentPermissionName} permission from the passed chainIds`, () => { const hexChainIds: Hex[] = [CHAIN_IDS.ARBITRUM]; - const result = getCaip25PermissionsResponse([], hexChainIds); + const result = getCaip25PermissionsResponse( + baseCaip25CaveatValue, + [], + hexChainIds, + ); expect(result).toEqual({ [Caip25EndowmentPermissionName]: { @@ -60,11 +78,16 @@ describe('getCaip25PermissionsResponse', () => { }); }); }); + describe('Request approval for accountAddresses', () => { it(`should construct a valid ${Caip25EndowmentPermissionName} permission from the passed accountAddresses`, () => { const addresses: Hex[] = ['0x4c286da233db3d63d44dc2ec8adc8b6dfb595cb4']; - const result = getCaip25PermissionsResponse(addresses, []); + const result = getCaip25PermissionsResponse( + baseCaip25CaveatValue, + addresses, + [], + ); expect(result).toEqual({ [Caip25EndowmentPermissionName]: { @@ -88,12 +111,17 @@ describe('getCaip25PermissionsResponse', () => { }); }); }); + describe('Request approval for accountAddresses and chainIds', () => { it(`should construct a valid ${Caip25EndowmentPermissionName} permission from the passed accountAddresses and chainIds`, () => { const addresses: Hex[] = ['0x4c286da233db3d63d44dc2ec8adc8b6dfb595cb4']; const hexChainIds: Hex[] = [CHAIN_IDS.ARBITRUM, CHAIN_IDS.LINEA_MAINNET]; - const result = getCaip25PermissionsResponse(addresses, hexChainIds); + const result = getCaip25PermissionsResponse( + baseCaip25CaveatValue, + addresses, + hexChainIds, + ); expect(result).toEqual({ [Caip25EndowmentPermissionName]: { @@ -127,4 +155,54 @@ describe('getCaip25PermissionsResponse', () => { }); }); }); + + describe('Request approval including non-evm scopes', () => { + it('only modifies evm related scopes', () => { + const addresses: Hex[] = ['0x4c286da233db3d63d44dc2ec8adc8b6dfb595cb4']; + const hexChainIds: Hex[] = ['0x1']; + + const result = getCaip25PermissionsResponse( + { + ...baseCaip25CaveatValue, + requiredScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + }, + addresses, + hexChainIds, + ); + + expect(result).toEqual({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + accounts: [], + }, + }, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x4c286da233db3d63d44dc2ec8adc8b6dfb595cb4', + ], + }, + 'eip155:1': { + accounts: [ + 'eip155:1:0x4c286da233db3d63d44dc2ec8adc8b6dfb595cb4', + ], + }, + }, + isMultichainOrigin: false, + }, + }, + ], + }, + }); + }); + }); }); diff --git a/ui/pages/permissions-connect/connect-page/utils.ts b/ui/pages/permissions-connect/connect-page/utils.ts index 75b66d790f63..4a9afb28e18a 100644 --- a/ui/pages/permissions-connect/connect-page/utils.ts +++ b/ui/pages/permissions-connect/connect-page/utils.ts @@ -13,57 +13,50 @@ export type PermissionsRequest = Record< >; /** - * Takes in an incoming {@link PermissionsRequest} and attempts to return the {@link Caip25CaveatValue} with the Ethereum accounts set. + * Takes in an incoming {@link PermissionsRequest} and attempts to return the {@link Caip25CaveatValue}. * * @param permissions - The {@link PermissionsRequest} with the target name of the {@link Caip25EndowmentPermissionName}. - * @returns The {@link Caip25CaveatValue} with the Ethereum accounts set. + * @returns The {@link Caip25CaveatValue}. */ -export function getRequestedSessionScopes( +export function getRequestedCaip25CaveatValue( permissions?: PermissionsRequest, -): Pick { +): Caip25CaveatValue { return ( permissions?.[Caip25EndowmentPermissionName]?.caveats?.find( (caveat) => caveat.type === Caip25CaveatType, )?.value ?? { optionalScopes: {}, requiredScopes: {}, + isMultichainOrigin: false, } ); } /** - * Parses the CAIP-25 authorized permissions object after UI confirmation. + * Modifies the requested CAIP-25 permissions object after UI confirmation. * - * @param addresses - The list of permitted addresses. - * @param hexChainIds - The list of permitted chains. + * @param caip25CaveatValue - The requested CAIP-25 caveat value to modify. + * @param ethAccountAddresses - The list of permitted eth addresses. + * @param ethChainIds - The list of permitted eth chainIds. * @returns The granted permissions with the target name of the {@link Caip25EndowmentPermissionName}. */ export function getCaip25PermissionsResponse( - addresses: Hex[], - hexChainIds: Hex[], + caip25CaveatValue: Caip25CaveatValue, + ethAccountAddresses: Hex[], + ethChainIds: Hex[], ): { [Caip25EndowmentPermissionName]: { caveats: [{ type: string; value: Caip25CaveatValue }]; }; } { - const caveatValue: Caip25CaveatValue = { - requiredScopes: {}, - optionalScopes: { - 'wallet:eip155': { - accounts: [], - }, - }, - isMultichainOrigin: false, - }; - const caveatValueWithChains = setPermittedEthChainIds( - caveatValue, - hexChainIds, + caip25CaveatValue, + ethChainIds, ); const caveatValueWithAccounts = setEthAccounts( caveatValueWithChains, - addresses, + ethAccountAddresses, ); return { diff --git a/ui/pages/permissions-connect/permissions-connect.component.js b/ui/pages/permissions-connect/permissions-connect.component.js index 302346846fb4..66c0bf09311b 100644 --- a/ui/pages/permissions-connect/permissions-connect.component.js +++ b/ui/pages/permissions-connect/permissions-connect.component.js @@ -21,13 +21,13 @@ import SnapInstall from './snaps/snap-install'; import SnapUpdate from './snaps/snap-update'; import SnapResult from './snaps/snap-result'; import { ConnectPage } from './connect-page/connect-page'; -import { getRequestedSessionScopes } from './connect-page/utils'; +import { getRequestedCaip25CaveatValue } from './connect-page/utils'; const APPROVE_TIMEOUT = MILLISECOND * 1200; function getDefaultSelectedAccounts(currentAddress, permissions) { - const requestedSessionsScopes = getRequestedSessionScopes(permissions); - const requestedAccounts = getEthAccounts(requestedSessionsScopes); + const requestedCaip25CaveatValue = getRequestedCaip25CaveatValue(permissions); + const requestedAccounts = getEthAccounts(requestedCaip25CaveatValue); if (requestedAccounts.length > 0) { return new Set( @@ -43,8 +43,8 @@ function getDefaultSelectedAccounts(currentAddress, permissions) { } function getRequestedChainIds(permissions) { - const requestedSessionsScopes = getRequestedSessionScopes(permissions); - return getPermittedEthChainIds(requestedSessionsScopes); + const requestedCaip25CaveatValue = getRequestedCaip25CaveatValue(permissions); + return getPermittedEthChainIds(requestedCaip25CaveatValue); } export default class PermissionConnect extends Component { diff --git a/yarn.lock b/yarn.lock index c8148932a092..f4534b7dfa99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4867,10 +4867,10 @@ __metadata: languageName: node linkType: hard -"@metamask/api-specs@npm:^0.10.12, @metamask/api-specs@npm:^0.10.15": - version: 0.10.15 - resolution: "@metamask/api-specs@npm:0.10.15" - checksum: 10/1d68914e43dd14a8bafa77d93965e08cb3ee4b036dc161501dd1d565a21c703d03abefd9e91f23019065c316f74719103b44c871409219f6d4d2cd5503224ac2 +"@metamask/api-specs@npm:^0.10.12, @metamask/api-specs@npm:^0.10.16": + version: 0.10.16 + resolution: "@metamask/api-specs@npm:0.10.16" + checksum: 10/14577b826064c3d95e5c0c9e8b4eb28dfc0c4e330e56b50632a488073c9d2ddf17e669ff72bb3e0fec1284c18b38cef3ccdf8835fcfedfa249334b901afe4f0e languageName: node linkType: hard @@ -6416,6 +6416,13 @@ __metadata: languageName: node linkType: hard +"@metamask/test-dapp-multichain@npm:^0.6.0": + version: 0.6.0 + resolution: "@metamask/test-dapp-multichain@npm:0.6.0" + checksum: 10/23bb60422fa3986a648e487562697e7ca57dc97ac9ff693eeac391e673e5ebd838ad3a54160af8dbb195ab3eba497bf2a3767d76693bbbf6044ab6cdbd59b254 + languageName: node + linkType: hard + "@metamask/test-dapp@npm:9.0.0": version: 9.0.0 resolution: "@metamask/test-dapp@npm:9.0.0" @@ -7040,11 +7047,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" @@ -7056,7 +7064,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 @@ -24875,13 +24883,6 @@ __metadata: languageName: node linkType: hard -"jsonschema@npm:1.2.2": - version: 1.2.2 - resolution: "jsonschema@npm:1.2.2" - checksum: 10/aa778e23f1ff879345dabee968c2d7b36d39fe60bb0aa0d251e60d18aed7038499fb203be6c06f4185b0a301b5b187295a46a0a139f19be17b50b6c04b48193d - languageName: node - linkType: hard - "jsonschema@npm:^1.4.1": version: 1.4.1 resolution: "jsonschema@npm:1.4.1" @@ -26654,7 +26655,7 @@ __metadata: "@metamask/accounts-controller": "npm:^23.0.1" "@metamask/address-book-controller": "npm:^6.0.3" "@metamask/announcement-controller": "npm:^7.0.3" - "@metamask/api-specs": "npm:^0.10.15" + "@metamask/api-specs": "npm:^0.10.16" "@metamask/approval-controller": "npm:^7.0.0" "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A49.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-49.0.0-e9c0266958.patch" "@metamask/auto-changelog": "npm:^2.1.0" @@ -26731,6 +26732,7 @@ __metadata: "@metamask/solana-wallet-snap": "npm:^1.2.0" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:9.0.0" + "@metamask/test-dapp-multichain": "npm:^0.6.0" "@metamask/transaction-controller": "npm:^45.0.0" "@metamask/user-operation-controller": "npm:^24.0.1" "@metamask/utils": "npm:^10.0.1" @@ -26739,8 +26741,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"