diff --git a/packages/multichain/jest.config.js b/packages/multichain/jest.config.js index ca08413339..1cadcfe8b2 100644 --- a/packages/multichain/jest.config.js +++ b/packages/multichain/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 82.38, + functions: 87.37, + lines: 86.65, + statements: 87.09, }, }, }); diff --git a/packages/multichain/package.json b/packages/multichain/package.json index 4fa4f7ccfc..313c94a52f 100644 --- a/packages/multichain/package.json +++ b/packages/multichain/package.json @@ -46,8 +46,18 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/api-specs": "^0.10.12", + "@metamask/controller-utils": "^11.3.0", + "@metamask/eth-json-rpc-filters": "^7.0.0", + "@metamask/rpc-errors": "^7.0.0", + "@metamask/utils": "^9.1.0", + "lodash": "^4.17.21" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/network-controller": "^21.0.1", + "@metamask/permission-controller": "^11.0.2", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -56,6 +66,10 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.2.2" }, + "peerDependencies": { + "@metamask/network-controller": "^21.0.0", + "@metamask/permission-controller": "^11.0.0" + }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.test.ts b/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.test.ts new file mode 100644 index 0000000000..eb55966678 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.test.ts @@ -0,0 +1,217 @@ +import type { Caip25CaveatValue } from '../caip25Permission'; +import { + getEthAccounts, + setEthAccounts, +} from './caip-permission-adapter-eth-accounts'; + +describe('CAIP-25 eth_accounts adapters', () => { + describe('getEthAccounts', () => { + it('returns the unique set of EIP155 accounts from the CAIP-25 caveat value', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: ['wallet:eip155:0x5'], + }, + }, + isMultichainOrigin: false, + }); + + expect(ethAccounts).toStrictEqual([ + '0x1', + '0x2', + '0x4', + '0x3', + '0x100', + '0x5', + ]); + }); + }); + + describe('setEthAccounts', () => { + it('returns a CAIP-25 caveat value with all EIP-155 scopeObject.accounts set to CAIP-10 account addresses formed from the accounts param', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2', 'eip155:1:0x3'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x1', 'eip155:5:0x2', 'eip155:5:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2', 'eip155:1:0x3'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: ['eip155:10:0x1', 'eip155:10:0x2', 'eip155:10:0x3'], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x1', 'eip155:100:0x2', 'eip155:100:0x3'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('returns a CAIP-25 caveat value with upserted "wallet:eip155" optional scope with CAIP-10 account addresses formed from the accounts param', () => { + const input: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(result).toStrictEqual({ + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object in place', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = setEthAccounts(input, ['0x1', '0x2', '0x3']); + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + }); +}); diff --git a/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.ts b/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.ts new file mode 100644 index 0000000000..6c72f1d07b --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.ts @@ -0,0 +1,97 @@ +import { + type CaipAccountId, + type Hex, + KnownCaipNamespace, + parseCaipAccountId, +} from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission'; +import { getUniqueArrayItems, mergeScopes } from '../scope/transform'; +import type { ScopesObject, ScopeString } from '../scope/types'; +import { KnownWalletScopeString, parseScopeString } from '../scope/types'; + +const isEip155ScopeString = (scopeString: ScopeString) => { + const { namespace } = parseScopeString(scopeString); + + return ( + namespace === KnownCaipNamespace.Eip155 || + scopeString === KnownWalletScopeString.Eip155 + ); +}; + +export const getEthAccounts = (caip25CaveatValue: Caip25CaveatValue) => { + const ethAccounts: string[] = []; + const sessionScopes = mergeScopes( + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ); + + Object.entries(sessionScopes).forEach(([_, { accounts }]) => { + accounts?.forEach((account) => { + const { address, chainId } = parseCaipAccountId(account); + + if (isEip155ScopeString(chainId)) { + ethAccounts.push(address); + } + }); + }); + + return getUniqueArrayItems(ethAccounts); +}; + +const setEthAccountsForScopesObject = ( + scopesObject: ScopesObject, + accounts: Hex[], +) => { + const updatedScopesObject: ScopesObject = {}; + + Object.entries(scopesObject).forEach(([scopeString, scopeObject]) => { + const isWalletNamespace = scopeString === KnownCaipNamespace.Wallet; + + if ( + !isEip155ScopeString(scopeString as ScopeString) && + !isWalletNamespace + ) { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + return; + } + + const caipAccounts = accounts.map( + (account) => + (isWalletNamespace + ? `${KnownWalletScopeString.Eip155}:${account}` + : `${scopeString}:${account}`) as CaipAccountId, + ); + + updatedScopesObject[scopeString as ScopeString] = { + ...scopeObject, + accounts: caipAccounts, + }; + }); + + return updatedScopesObject; +}; + +export const setEthAccounts = ( + caip25CaveatValue: Caip25CaveatValue, + accounts: Hex[], +) => { + return { + ...caip25CaveatValue, + requiredScopes: setEthAccountsForScopesObject( + caip25CaveatValue.requiredScopes, + accounts, + ), + optionalScopes: setEthAccountsForScopesObject( + { + [KnownWalletScopeString.Eip155]: { + methods: [], + notifications: [], + accounts: [], + }, + ...caip25CaveatValue.optionalScopes, + }, + accounts, + ), + }; +}; diff --git a/packages/multichain/src/adapters/caip-permission-adapter-middleware.test.ts b/packages/multichain/src/adapters/caip-permission-adapter-middleware.test.ts new file mode 100644 index 0000000000..c044c73b0f --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-middleware.test.ts @@ -0,0 +1,148 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +import { caipPermissionAdapterMiddleware } from './caip-permission-adapter-middleware'; + +const baseRequest = { + id: 1, + jsonrpc: '2.0' as const, + origin: 'http://test.com', + networkClientId: 'mainnet', + method: 'eth_call', + params: { + foo: 'bar', + }, +}; + +const createMockedHandler = () => { + const next = jest.fn(); + const end = jest.fn(); + const getCaveat = jest.fn().mockReturnValue({ + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: [], + accounts: [], + }, + 'eip155:5': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['net_version'], + notifications: [], + accounts: [], + }, + wallet: { + methods: ['wallet_watchAsset'], + notifications: [], + accounts: [], + }, + unhandled: { + methods: ['foobar'], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: true, + }, + }); + const getNetworkConfigurationByNetworkClientId = jest + .fn() + .mockImplementation((networkClientId: string) => { + const chainId = + { + mainnet: '0x1', + goerli: '0x5', + }[networkClientId] || '0x999'; + return { + chainId, + }; + }); + const handler = ( + request: JsonRpcRequest & { + networkClientId: string; + origin: string; + }, + ) => + caipPermissionAdapterMiddleware(request, {}, next, end, { + getCaveat, + getNetworkConfigurationByNetworkClientId, + }); + + return { + next, + end, + getCaveat, + getNetworkConfigurationByNetworkClientId, + handler, + }; +}; + +describe('CaipPermissionAdapterMiddleware', () => { + it('gets the authorized scopes from the CAIP-25 endowment permission', async () => { + const { handler, getCaveat } = createMockedHandler(); + await handler(baseRequest); + expect(getCaveat).toHaveBeenCalledWith( + 'http://test.com', + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + }); + + it('allows the request when there is no CAIP-25 endowment permission', async () => { + const { handler, getCaveat, next } = createMockedHandler(); + getCaveat.mockImplementation(() => { + throw new Error('permission not found'); + }); + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + + it('allows the request when the CAIP-25 endowment permission was not granted from the multichain API', async () => { + const { handler, getCaveat, next } = createMockedHandler(); + getCaveat.mockReturnValue({ + value: { + isMultichainOrigin: false, + }, + }); + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + + it('gets the chainId for the request networkClientId', async () => { + const { handler, getNetworkConfigurationByNetworkClientId } = + createMockedHandler(); + await handler(baseRequest); + expect(getNetworkConfigurationByNetworkClientId).toHaveBeenCalledWith( + 'mainnet', + ); + }); + + describe('when the CAIP-25 endowment permission was granted over the multichain API', () => { + it('throws an error if the requested method is not authorized for the scope specified in the request', async () => { + const { handler, end } = createMockedHandler(); + + await handler({ + ...baseRequest, + method: 'unauthorized_method', + }); + expect(end).toHaveBeenCalledWith(providerErrors.unauthorized()); + }); + + it('allows the request if the requested scope method is authorized in the current scope', async () => { + const { handler, next } = createMockedHandler(); + + await handler(baseRequest); + expect(next).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/multichain/src/adapters/caip-permission-adapter-middleware.ts b/packages/multichain/src/adapters/caip-permission-adapter-middleware.ts new file mode 100644 index 0000000000..865a4cccfc --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-middleware.ts @@ -0,0 +1,80 @@ +import type { + NetworkConfiguration, + NetworkClientId, +} from '@metamask/network-controller'; +import type { Caveat } from '@metamask/permission-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission'; +import { + Caip25CaveatType, + Caip25EndowmentPermissionName, +} from '../caip25Permission'; +import { mergeScopes } from '../scope/transform'; +import { KnownWalletScopeString, type ScopeString } from '../scope/types'; + +/** + * Middleware to handle CAIP-25 permission requests. + * + * @param request - The request object. + * @param _response - The response object. + * @param next - The next middleware function. + * @param end - The end function. + * @param hooks - The hooks object. + * @param hooks.getCaveat - Function to retrieve a caveat. + * @param hooks.getNetworkConfigurationByNetworkClientId - Function to retrieve a network configuration. + */ +export async function caipPermissionAdapterMiddleware( + request: JsonRpcRequest & { + networkClientId: NetworkClientId; + origin: string; + }, + _response: unknown, + next: () => Promise, + end: (error?: Error) => void, + hooks: { + getCaveat: ( + ...args: unknown[] + ) => Caveat; + getNetworkConfigurationByNetworkClientId: ( + networkClientId: NetworkClientId, + ) => NetworkConfiguration; + }, +) { + const { networkClientId, method } = request; + + let caveat; + try { + caveat = hooks.getCaveat( + request.origin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (err) { + // noop + } + if (!caveat?.value?.isMultichainOrigin) { + return next(); + } + + const { chainId } = + hooks.getNetworkConfigurationByNetworkClientId(networkClientId); + + const scope: ScopeString = `eip155:${parseInt(chainId, 16)}`; + + const scopesObject = mergeScopes( + caveat.value.requiredScopes, + caveat.value.optionalScopes, + ); + + if ( + !scopesObject[scope]?.methods?.includes(method) && + !scopesObject[KnownWalletScopeString.Eip155]?.methods?.includes(method) && + !scopesObject.wallet?.methods?.includes(method) + ) { + return end(providerErrors.unauthorized()); + } + + return next(); +} diff --git a/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.test.ts b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.test.ts new file mode 100644 index 0000000000..4020c2442b --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.test.ts @@ -0,0 +1,387 @@ +import type { Caip25CaveatValue } from '../caip25Permission'; +import { KnownNotifications, KnownRpcMethods } from '../scope/types'; +import { + addPermittedEthChainId, + getPermittedEthChainIds, + setPermittedEthChainIds, +} from './caip-permission-adapter-permittedChains'; + +describe('CAIP-25 permittedChains adapters', () => { + describe('getPermittedEthChainIds', () => { + it('returns the unique set of EIP155 chainIds in hexadecimal format from the CAIP-25 caveat value', () => { + const ethChainIds = getPermittedEthChainIds({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0x2', 'eip155:1:0x3'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x4'], + }, + 'eip155:10': { + methods: [], + notifications: [], + accounts: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }); + + expect(ethChainIds).toStrictEqual(['0x1', '0x5', '0xa', '0x64']); + }); + }); + + describe('addPermittedEthChainId', () => { + it('adds an optional scope for the chainId if it does not already exist in required or optional scopes', () => { + const result = addPermittedEthChainId( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: false, + }, + '0x65', + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('adds an optional scope for "wallet:eip155" if it does not already exist in the optional scopes', () => { + const result = addPermittedEthChainId( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }, + '0x65', + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = addPermittedEthChainId(input, '0x65'); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + + it('does not add an optional scope for the chainId if already exists in the required scopes', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }; + const result = addPermittedEthChainId(input, '0x1'); + + expect(result).toStrictEqual(input); + }); + + it('does not add an optional scope for the chainId if already exists in the optional scopes', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }; + const result = addPermittedEthChainId(input, '0x64'); // 0x64 === 100 + + expect(result).toStrictEqual(input); + }); + }); + + describe('setPermittedEthChainIds', () => { + it('returns a CAIP-25 caveat value with EIP-155 scopes missing from the chainIds array removed', () => { + const result = setPermittedEthChainIds( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }, + ['0x1'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('returns a CAIP-25 caveat value with optional scopes added for missing chainIds', () => { + const result = setPermittedEthChainIds( + { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + }, + isMultichainOrigin: false, + }, + ['0x1', '0x64', '0x65'], + ); + + expect(result).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: [], + }, + 'eip155:100': { + methods: [], + notifications: [], + accounts: ['eip155:100:0x100'], + }, + 'eip155:101': { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + 'wallet:eip155': { + methods: [], + notifications: [], + accounts: [], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('does not modify the input CAIP-25 caveat value object', () => { + const input: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }; + + const result = setPermittedEthChainIds(input, ['0x1', '0x2', '0x3']); + + expect(input).toStrictEqual({ + requiredScopes: { + 'eip155:1': { + methods: [], + notifications: [], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: false, + }); + expect(input).not.toStrictEqual(result); + }); + }); +}); diff --git a/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts new file mode 100644 index 0000000000..6a59efa916 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts @@ -0,0 +1,110 @@ +import { toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; +import { KnownCaipNamespace } from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission'; +import { getUniqueArrayItems, mergeScopes } from '../scope/transform'; +import type { ScopesObject, ScopeString } from '../scope/types'; +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletScopeString, + parseScopeString, +} from '../scope/types'; + +export const getPermittedEthChainIds = ( + caip25CaveatValue: Caip25CaveatValue, +) => { + const ethChainIds: Hex[] = []; + const sessionScopes = mergeScopes( + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ); + + Object.keys(sessionScopes).forEach((scopeString) => { + const { namespace, reference } = parseScopeString(scopeString); + if (namespace === KnownCaipNamespace.Eip155 && reference) { + ethChainIds.push(toHex(reference)); + } + }); + + return getUniqueArrayItems(ethChainIds); +}; + +export const addPermittedEthChainId = ( + caip25CaveatValue: Caip25CaveatValue, + chainId: Hex, +) => { + const scopeString = `eip155:${parseInt(chainId, 16)}`; + if ( + Object.keys(caip25CaveatValue.requiredScopes).includes(scopeString) || + Object.keys(caip25CaveatValue.optionalScopes).includes(scopeString) + ) { + return caip25CaveatValue; + } + + return { + ...caip25CaveatValue, + optionalScopes: { + [KnownWalletScopeString.Eip155]: { + methods: [], + notifications: [], + accounts: [], + }, + ...caip25CaveatValue.optionalScopes, + [scopeString]: { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + }, + }; +}; + +const filterEthScopesObjectByChainId = ( + scopesObject: ScopesObject, + chainIds: Hex[], +) => { + const updatedScopesObject: ScopesObject = {}; + + Object.entries(scopesObject).forEach(([scopeString, scopeObject]) => { + const { namespace, reference } = parseScopeString(scopeString); + if (!reference) { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + return; + } + if (namespace === KnownCaipNamespace.Eip155) { + const chainId = toHex(reference); + if (chainIds.includes(chainId)) { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + } + } else { + updatedScopesObject[scopeString as ScopeString] = scopeObject; + } + }); + + return updatedScopesObject; +}; + +export const setPermittedEthChainIds = ( + caip25CaveatValue: Caip25CaveatValue, + chainIds: Hex[], +) => { + let updatedCaveatValue: Caip25CaveatValue = { + ...caip25CaveatValue, + requiredScopes: filterEthScopesObjectByChainId( + caip25CaveatValue.requiredScopes, + chainIds, + ), + optionalScopes: filterEthScopesObjectByChainId( + caip25CaveatValue.optionalScopes, + chainIds, + ), + }; + + chainIds.forEach((chainId) => { + updatedCaveatValue = addPermittedEthChainId(updatedCaveatValue, chainId); + }); + + return updatedCaveatValue; +}; diff --git a/packages/multichain/src/caip25Permission.test.ts b/packages/multichain/src/caip25Permission.test.ts new file mode 100644 index 0000000000..900b89c907 --- /dev/null +++ b/packages/multichain/src/caip25Permission.test.ts @@ -0,0 +1,754 @@ +import type { NonEmptyArray } from '@metamask/controller-utils'; +import type { CaveatConstraint } from '@metamask/permission-controller'; +import { + CaveatMutatorOperation, + PermissionType, +} from '@metamask/permission-controller'; + +import type { Caip25CaveatValue } from './caip25Permission'; +import { + Caip25CaveatType, + caip25EndowmentBuilder, + Caip25EndowmentPermissionName, + Caip25CaveatMutatorFactories, + removeScope, +} from './caip25Permission'; +import * as ScopeAssert from './scope/assert'; +import * as ScopeAuthorization from './scope/authorization'; + +jest.mock('./scope/authorization', () => ({ + validateAndNormalizeScopes: jest.fn(), +})); +const MockScopeAuthorization = jest.mocked(ScopeAuthorization); + +jest.mock('./scope/assert', () => ({ + assertScopesSupported: jest.fn(), +})); +const MockScopeAssert = jest.mocked(ScopeAssert); + +const { removeAccount } = Caip25CaveatMutatorFactories[Caip25CaveatType]; + +describe('endowment:caip25', () => { + beforeEach(() => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: {}, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('builds the expected permission specification', () => { + const specification = caip25EndowmentBuilder.specificationBuilder({ + methodHooks: { + findNetworkClientIdByChainId: jest.fn(), + listAccounts: jest.fn(), + }, + }); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetName: Caip25EndowmentPermissionName, + endowmentGetter: expect.any(Function), + allowedCaveats: [Caip25CaveatType], + validator: expect.any(Function), + }); + + expect(specification.endowmentGetter()).toBeNull(); + }); + + describe('caveat mutator removeScope', () => { + it('can remove a caveat', () => { + const ethereumGoerliCaveat = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope('eip155:5', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: [], + }, + }, + optionalScopes: {}, + }, + }); + }); + + it('can noop when nothing is removed', () => { + const ethereumGoerliCaveat = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope('eip155:2', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.Noop, + }); + }); + }); + + describe('caveat mutator removeAccount', () => { + it('can remove an account', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }; + const result = removeAccount('0x1', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('can remove an account in multiple scopes in optional and required', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + 'eip155:2': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:2:0x1', 'eip155:2:0x2'], + }, + }, + optionalScopes: { + 'eip155:3': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:3:0x1', 'eip155:3:0x2'], + }, + }, + isMultichainOrigin: true, + }; + const result = removeAccount('0x1', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x2'], + }, + 'eip155:2': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:2:0x2'], + }, + }, + optionalScopes: { + 'eip155:3': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:3:0x2'], + }, + }, + isMultichainOrigin: true, + }, + }); + }); + + it('can noop when nothing is removed', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + accounts: [], + }, + }, + isMultichainOrigin: true, + }; + const result = removeAccount('0x3', ethereumGoerliCaveat); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.Noop, + }); + }); + }); + + describe('permission validator', () => { + const findNetworkClientIdByChainId = jest.fn(); + const listAccounts = jest.fn(); + const { validator } = caip25EndowmentBuilder.specificationBuilder({ + methodHooks: { + findNetworkClientIdByChainId, + listAccounts, + }, + }); + + it('throws an error if there is not exactly one caveat', () => { + expect(() => { + validator({ + caveats: [ + { + type: 'caveatType', + value: {}, + }, + { + type: 'caveatType', + value: {}, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + caveats: [] as unknown as NonEmptyArray, + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('throws an error if there is no CAIP-25 caveat', () => { + expect(() => { + validator({ + caveats: [ + { + type: 'NotCaip25Caveat', + value: {}, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('throws an error if the CAIP-25 caveat is malformed', () => { + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + missingRequiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + missingOptionalScopes: {}, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: 'NotABoolean', + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('validates and normalizes the ScopesObjects', () => { + try { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + } catch (err) { + // noop + } + expect( + MockScopeAuthorization.validateAndNormalizeScopes, + ).toHaveBeenCalledWith( + { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + ); + }); + + it('asserts the validated and normalized required scopes are supported', () => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['normalized_required'], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: { + 'eip155:1': { + methods: ['normalized_optional'], + notifications: [], + accounts: [], + }, + }, + }); + try { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + } catch (err) { + // noop + } + expect(MockScopeAssert.assertScopesSupported).toHaveBeenCalledWith( + { + 'eip155:1': { + methods: ['normalized_required'], + notifications: [], + accounts: [], + }, + }, + expect.objectContaining({ + isChainIdSupported: expect.any(Function), + }), + ); + const isChainIdSupportedBody = + MockScopeAssert.assertScopesSupported.mock.calls[0][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + it('asserts the validated and normalized optional scopes are supported', () => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['normalized_required'], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: { + 'eip155:5': { + methods: ['normalized_optional'], + notifications: [], + accounts: [], + }, + }, + }); + try { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + } catch (err) { + // noop + } + expect(MockScopeAssert.assertScopesSupported).toHaveBeenCalledWith( + { + 'eip155:5': { + methods: ['normalized_optional'], + notifications: [], + accounts: [], + }, + }, + expect.objectContaining({ + isChainIdSupported: expect.any(Function), + }), + ); + const isChainIdSupportedBody = + MockScopeAssert.assertScopesSupported.mock.calls[1][1].isChainIdSupported.toString(); + expect(isChainIdSupportedBody).toContain('findNetworkClientIdByChainId'); + }); + + it('throws if the eth accounts specified in the normalized scopeObjects are not found in the wallet keyring', () => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + normalizedOptionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + }); + listAccounts.mockReturnValue([{ address: '0xdead' }]); // missing '0xbeef' + + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow( + new Error( + `${Caip25EndowmentPermissionName} error: Received eip155 account value(s) for caveat of type "${Caip25CaveatType}" that were not found in the wallet keyring.`, + ), + ); + }); + + it('throws if the input requiredScopes does not match the output of validateAndNormalizeScopes', () => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + }); + listAccounts.mockReturnValue([{ address: '0xbeef' }]); + + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow(/Expected values to be strictly deep-equal/u); + }); + + it('throws if the input optionalScopes does not match the output of validateAndNormalizeScopes', () => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + normalizedOptionalScopes: {}, + }); + listAccounts.mockReturnValue([{ address: '0xdead' }]); + + expect(() => { + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }); + }).toThrow(/Expected values to be strictly deep-equal/u); + }); + + it('does not throw if the input requiredScopes and optionalScopes ScopesObject are already validated and normalized', () => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + normalizedOptionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + }); + listAccounts.mockReturnValue([ + { address: '0xdead' }, + { address: '0xbeef' }, + ]); + + expect( + validator({ + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_chainId'], + notifications: [], + accounts: ['eip155:1:0xdead'], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: [], + notifications: [], + accounts: ['eip155:5:0xbeef'], + }, + }, + isMultichainOrigin: true, + }, + }, + ], + date: 1234, + id: '1', + invoker: 'test.com', + parentCapability: Caip25EndowmentPermissionName, + }), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/multichain/src/caip25Permission.ts b/packages/multichain/src/caip25Permission.ts new file mode 100644 index 0000000000..e58355c1f4 --- /dev/null +++ b/packages/multichain/src/caip25Permission.ts @@ -0,0 +1,284 @@ +import type { NetworkClientId } from '@metamask/network-controller'; +import type { + PermissionSpecificationBuilder, + EndowmentGetterParams, + ValidPermissionSpecification, + PermissionValidatorConstraint, + PermissionConstraint, +} from '@metamask/permission-controller'; +import { + CaveatMutatorOperation, + PermissionType, +} from '@metamask/permission-controller'; +import type { CaipAccountId, Json } from '@metamask/utils'; +import { + parseCaipAccountId, + type Hex, + type NonEmptyArray, +} from '@metamask/utils'; +import { strict as assert } from 'assert'; +import { cloneDeep, isEqual } from 'lodash'; + +import { getEthAccounts } from './adapters/caip-permission-adapter-eth-accounts'; +import { assertScopesSupported } from './scope/assert'; +import { validateAndNormalizeScopes } from './scope/authorization'; +import type { + ExternalScopeString, + ScopeObject, + ScopesObject, +} from './scope/types'; + +export type Caip25CaveatValue = { + requiredScopes: ScopesObject; + optionalScopes: ScopesObject; + sessionProperties?: Record; + isMultichainOrigin: boolean; +}; + +export const Caip25CaveatType = 'authorizedScopes'; + +export const Caip25CaveatFactoryFn = (value: Caip25CaveatValue) => { + return { + type: Caip25CaveatType, + value, + }; +}; + +export const Caip25EndowmentPermissionName = 'endowment:caip25'; + +type Caip25EndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetName: typeof Caip25EndowmentPermissionName; + endowmentGetter: (_options?: EndowmentGetterParams) => null; + validator: PermissionValidatorConstraint; + allowedCaveats: Readonly> | null; +}>; + +type Caip25EndowmentSpecificationBuilderOptions = { + methodHooks: { + findNetworkClientIdByChainId: (chainId: Hex) => NetworkClientId; + listAccounts: () => { address: Hex }[]; + }; +}; + +/** + * Helper that returns a `endowment:caip25` specification that + * can be passed into the PermissionController constructor. + * + * @param builderOptions - The specification builder options. + * @param builderOptions.methodHooks - The RPC method hooks needed by the method implementation. + * @returns The specification for the `caip25` endowment. + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + Caip25EndowmentSpecificationBuilderOptions, + Caip25EndowmentSpecification +> = ({ methodHooks }: Caip25EndowmentSpecificationBuilderOptions) => { + return { + permissionType: PermissionType.Endowment, + targetName: Caip25EndowmentPermissionName, + allowedCaveats: [Caip25CaveatType], + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => null, + validator: (permission: PermissionConstraint) => { + const caip25Caveat = permission.caveats?.[0]; + if ( + permission.caveats?.length !== 1 || + caip25Caveat?.type !== Caip25CaveatType + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Invalid caveats. There must be a single caveat of type "${Caip25CaveatType}".`, + ); + } + + const { requiredScopes, optionalScopes, isMultichainOrigin } = + caip25Caveat.value as Caip25CaveatValue; + + if ( + !requiredScopes || + !optionalScopes || + typeof isMultichainOrigin !== 'boolean' + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ); + } + + const { normalizedRequiredScopes, normalizedOptionalScopes } = + validateAndNormalizeScopes(requiredScopes, optionalScopes); + + const isChainIdSupported = (chainId: Hex) => { + try { + methodHooks.findNetworkClientIdByChainId(chainId); + return true; + } catch (err) { + return false; + } + }; + + assertScopesSupported(normalizedRequiredScopes, { + isChainIdSupported, + }); + assertScopesSupported(normalizedOptionalScopes, { + isChainIdSupported, + }); + + // Fetch EVM accounts from native wallet keyring + // These addresses are lowercased already + const existingEvmAddresses = methodHooks + .listAccounts() + .map((account) => account.address); + const ethAccounts = getEthAccounts({ + requiredScopes: normalizedRequiredScopes, + optionalScopes: normalizedOptionalScopes, + isMultichainOrigin, + }).map((address) => address.toLowerCase() as Hex); + + const allEthAccountsSupported = ethAccounts.every((address) => + existingEvmAddresses.includes(address), + ); + if (!allEthAccountsSupported) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received eip155 account value(s) for caveat of type "${Caip25CaveatType}" that were not found in the wallet keyring.`, + ); + } + + assert.deepEqual(requiredScopes, normalizedRequiredScopes); + assert.deepEqual(optionalScopes, normalizedOptionalScopes); + }, + }; +}; + +export const caip25EndowmentBuilder = Object.freeze({ + targetName: Caip25EndowmentPermissionName, + specificationBuilder, +} as const); + +/** + * Factories that construct caveat mutator functions that are passed to + * PermissionController.updatePermissionsByCaveat. + */ +export const Caip25CaveatMutatorFactories = { + [Caip25CaveatType]: { + removeScope, + removeAccount, + }, +}; + +const reduceKeysHelper = ( + acc: Record, + [key, value]: [Key, Value], +) => { + return { + ...acc, + [key]: value, + }; +}; + +/** + * Removes the account from the scope object. + * + * @param targetAddress - The address to remove from the scope object. + * @returns A function that removes the account from the scope object. + */ +function removeAccountFilterFn(targetAddress: string) { + return (account: CaipAccountId) => { + const parsed = parseCaipAccountId(account); + return parsed.address !== targetAddress; + }; +} + +/** + * Removes the account from the scope object. + * + * @param targetAddress - The address to remove from the scope object. + * @param scopeObject - The scope object to remove the account from. + */ +function removeAccountOnScope(targetAddress: string, scopeObject: ScopeObject) { + if (scopeObject.accounts) { + scopeObject.accounts = scopeObject.accounts.filter( + removeAccountFilterFn(targetAddress), + ); + } +} + +/** + * Removes the target account from the scope object. + * + * @param targetAddress - The address to remove from the scope object. + * @param existingScopes - The scope object to remove the account from. + * @returns The updated scope object. + */ +function removeAccount( + targetAddress: string, // non caip-10 formatted address + existingScopes: Caip25CaveatValue, +) { + // copy existing scopes + const copyOfExistingScopes = cloneDeep(existingScopes); + + [ + copyOfExistingScopes.requiredScopes, + copyOfExistingScopes.optionalScopes, + ].forEach((scopes) => { + Object.entries(scopes).forEach(([, scopeObject]) => { + removeAccountOnScope(targetAddress, scopeObject); + }); + }); + + // deep equal check for changes + const noChange = isEqual(copyOfExistingScopes, existingScopes); + + if (noChange) { + return { + operation: CaveatMutatorOperation.Noop, + }; + } + + return { + operation: CaveatMutatorOperation.UpdateValue, + value: copyOfExistingScopes, + }; +} + +/** + * Removes the target account from the value arrays of all + * `endowment:caip25` caveats. No-ops if the target scopeString is not in + * the existing scopes,. + * + * @param targetScopeString - The scope that is being removed. + * @param caip25CaveatValue - The CAIP-25 permission caveat value to remove the scope from. + * @returns The updated CAIP-25 permission caveat value. + */ +export function removeScope( + targetScopeString: ExternalScopeString, + caip25CaveatValue: Caip25CaveatValue, +) { + const newRequiredScopes = Object.entries( + caip25CaveatValue.requiredScopes, + ).filter(([scope]) => scope !== targetScopeString); + const newOptionalScopes = Object.entries( + caip25CaveatValue.optionalScopes, + ).filter(([scope]) => { + return scope !== targetScopeString; + }); + + const requiredScopesRemoved = + newRequiredScopes.length !== + Object.keys(caip25CaveatValue.requiredScopes).length; + const optionalScopesRemoved = + newOptionalScopes.length !== + Object.keys(caip25CaveatValue.optionalScopes).length; + + if (requiredScopesRemoved || optionalScopesRemoved) { + return { + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: newRequiredScopes.reduce(reduceKeysHelper, {}), + optionalScopes: newOptionalScopes.reduce(reduceKeysHelper, {}), + }, + }; + } + + return { + operation: CaveatMutatorOperation.Noop, + }; +} diff --git a/packages/multichain/src/index.test.ts b/packages/multichain/src/index.test.ts index bc062d3694..7cf4f39321 100644 --- a/packages/multichain/src/index.test.ts +++ b/packages/multichain/src/index.test.ts @@ -1,9 +1,39 @@ -import greeter from '.'; +import * as allExports from '.'; -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); +describe('@metamask/multichain', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + Array [ + "getEthAccounts", + "setEthAccounts", + "caipPermissionAdapterMiddleware", + "getPermittedEthChainIds", + "addPermittedEthChainId", + "setPermittedEthChainIds", + "validateAndNormalizeScopes", + "isSupportedScopeString", + "isSupportedAccount", + "isSupportedMethod", + "isSupportedNotification", + "normalizeScope", + "mergeScopeObject", + "mergeScopes", + "normalizeAndMergeScopes", + "isValidScope", + "validateScopes", + "Caip25CaveatType", + "Caip25CaveatFactoryFn", + "Caip25EndowmentPermissionName", + "caip25EndowmentBuilder", + "Caip25CaveatMutatorFactories", + "removeScope", + "KnownWalletScopeString", + "KnownWalletRpcMethods", + "KnownRpcMethods", + "KnownWalletNamespaceRpcMethods", + "KnownNotifications", + "parseScopeString", + ] + `); }); }); diff --git a/packages/multichain/src/index.ts b/packages/multichain/src/index.ts index 6972c11729..03bbb44262 100644 --- a/packages/multichain/src/index.ts +++ b/packages/multichain/src/index.ts @@ -1,9 +1,37 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { + getEthAccounts, + setEthAccounts, +} from './adapters/caip-permission-adapter-eth-accounts'; +export { caipPermissionAdapterMiddleware } from './adapters/caip-permission-adapter-middleware'; +export { + getPermittedEthChainIds, + addPermittedEthChainId, + setPermittedEthChainIds, +} from './adapters/caip-permission-adapter-permittedChains'; + +export type { Caip25Authorization } from './scope/authorization'; +export { validateAndNormalizeScopes } from './scope/authorization'; +export * from './scope/types'; +export { + isSupportedScopeString, + isSupportedAccount, + isSupportedMethod, + isSupportedNotification, +} from './scope/supported'; +export { + normalizeScope, + mergeScopeObject, + mergeScopes, + normalizeAndMergeScopes, +} from './scope/transform'; +export { isValidScope, validateScopes } from './scope/validation'; + +export type { Caip25CaveatValue } from './caip25Permission'; +export { + Caip25CaveatType, + Caip25CaveatFactoryFn, + Caip25EndowmentPermissionName, + caip25EndowmentBuilder, + Caip25CaveatMutatorFactories, + removeScope, +} from './caip25Permission'; diff --git a/packages/multichain/src/scope/assert.test.ts b/packages/multichain/src/scope/assert.test.ts new file mode 100644 index 0000000000..9b09aa6437 --- /dev/null +++ b/packages/multichain/src/scope/assert.test.ts @@ -0,0 +1,208 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; + +import { assertScopeSupported, assertScopesSupported } from './assert'; +import * as Supported from './supported'; +import type { ScopeObject } from './types'; + +jest.mock('./supported', () => ({ + isSupportedScopeString: jest.fn(), + isSupportedNotification: jest.fn(), + isSupportedMethod: jest.fn(), +})); +const MockSupported = jest.mocked(Supported); + +const validScopeObject: ScopeObject = { + methods: [], + notifications: [], + accounts: [], +}; + +describe('Scope Assert', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('assertScopeSupported', () => { + const isChainIdSupported = jest.fn(); + + describe('scopeString', () => { + it('checks if the scopeString is supported', () => { + try { + assertScopeSupported('scopeString', validScopeObject, { + isChainIdSupported, + }); + } catch (err) { + // noop + } + expect(MockSupported.isSupportedScopeString).toHaveBeenCalledWith( + 'scopeString', + isChainIdSupported, + ); + }); + + it('throws an error if the scopeString is not supported', () => { + MockSupported.isSupportedScopeString.mockReturnValue(false); + expect(() => { + assertScopeSupported('scopeString', validScopeObject, { + isChainIdSupported, + }); + }).toThrow( + new JsonRpcError(5100, 'Requested chains are not supported'), + ); + }); + }); + + describe('scopeObject', () => { + beforeEach(() => { + MockSupported.isSupportedScopeString.mockReturnValue(true); + }); + + it('checks if the methods are supported', () => { + try { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + }, + { + isChainIdSupported, + }, + ); + } catch (err) { + // noop + } + + expect(MockSupported.isSupportedMethod).toHaveBeenCalledWith( + 'scopeString', + 'eth_chainId', + ); + }); + + it('throws an error if there are unsupported methods', () => { + MockSupported.isSupportedMethod.mockReturnValue(false); + expect(() => { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + }, + { + isChainIdSupported, + }, + ); + }).toThrow( + new JsonRpcError(5101, 'Requested methods are not supported'), + ); + }); + + it('checks if the notifications are supported', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + try { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + notifications: ['chainChanged'], + }, + { + isChainIdSupported, + }, + ); + } catch (err) { + // noop + } + + expect(MockSupported.isSupportedNotification).toHaveBeenCalledWith( + 'scopeString', + 'chainChanged', + ); + }); + + it('throws an error if there are unsupported notifications', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + MockSupported.isSupportedNotification.mockReturnValue(false); + expect(() => { + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + notifications: ['chainChanged'], + }, + { + isChainIdSupported, + }, + ); + }).toThrow( + new JsonRpcError(5102, 'Requested notifications are not supported'), + ); + }); + + it('does not throw if the scopeObject is valid', () => { + MockSupported.isSupportedMethod.mockReturnValue(true); + MockSupported.isSupportedNotification.mockReturnValue(true); + expect( + assertScopeSupported( + 'scopeString', + { + ...validScopeObject, + methods: ['eth_chainId'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0xdeadbeef'], + }, + { + isChainIdSupported, + }, + ), + ).toBeUndefined(); + }); + }); + }); + + describe('assertScopesSupported', () => { + const isChainIdSupported = jest.fn(); + + it('does not throw an error if no scopes are defined', () => { + expect( + assertScopesSupported( + {}, + { + isChainIdSupported, + }, + ), + ).toBeUndefined(); + }); + + it('throws an error if any scope is invalid', () => { + MockSupported.isSupportedScopeString.mockReturnValue(false); + + expect(() => { + assertScopesSupported( + { + 'eip155:1': validScopeObject, + }, + { + isChainIdSupported, + }, + ); + }).toThrow(new JsonRpcError(5100, 'Requested chains are not supported')); + }); + + it('does not throw an error if all scopes are valid', () => { + MockSupported.isSupportedScopeString.mockReturnValue(true); + + expect( + assertScopesSupported( + { + 'eip155:1': validScopeObject, + 'eip155:2': validScopeObject, + }, + { + isChainIdSupported, + }, + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/multichain/src/scope/assert.ts b/packages/multichain/src/scope/assert.ts new file mode 100644 index 0000000000..77a9dd6205 --- /dev/null +++ b/packages/multichain/src/scope/assert.ts @@ -0,0 +1,71 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; +import type { Hex } from '@metamask/utils'; + +import { + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, +} from './supported'; +import type { ScopeObject, ScopesObject } from './types'; + +export const assertScopeSupported = ( + scopeString: string, + scopeObject: ScopeObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const { methods, notifications } = scopeObject; + if (!isSupportedScopeString(scopeString, isChainIdSupported)) { + throw new JsonRpcError(5100, 'Requested chains are not supported'); + } + + const allMethodsSupported = methods.every((method) => + isSupportedMethod(scopeString, method), + ); + + if (!allMethodsSupported) { + // not sure which one of these to use + // When provider evaluates requested methods to not be supported + // code = 5101 + // message = "Requested methods are not supported" + // When provider does not recognize one or more requested method(s) + // code = 5201 + // message = "Unknown method(s) requested" + + throw new JsonRpcError(5101, 'Requested methods are not supported'); + } + + if ( + notifications && + !notifications.every((notification) => + isSupportedNotification(scopeString, notification), + ) + ) { + // not sure which one of these to use + // When provider evaluates requested notifications to not be supported + // code = 5102 + // message = "Requested notifications are not supported" + // When provider does not recognize one or more requested notification(s) + // code = 5202 + // message = "Unknown notification(s) requested" + throw new JsonRpcError(5102, 'Requested notifications are not supported'); + } +}; + +export const assertScopesSupported = ( + scopes: ScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertScopeSupported(scopeString, scopeObject, { + isChainIdSupported, + }); + } +}; diff --git a/packages/multichain/src/scope/authorization.test.ts b/packages/multichain/src/scope/authorization.test.ts new file mode 100644 index 0000000000..55a184b364 --- /dev/null +++ b/packages/multichain/src/scope/authorization.test.ts @@ -0,0 +1,95 @@ +import { validateAndNormalizeScopes } from './authorization'; +import * as Transform from './transform'; +import type { ExternalScopeObject } from './types'; +import * as Validation from './validation'; + +jest.mock('./validation', () => ({ + validateScopes: jest.fn(), +})); +const MockValidation = jest.mocked(Validation); + +jest.mock('./transform', () => ({ + normalizeAndMergeScopes: jest.fn(), +})); +const MockTransform = jest.mocked(Transform); + +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Authorization', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('validateAndNormalizeScopes', () => { + it('validates the scopes', () => { + try { + validateAndNormalizeScopes( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + } catch (err) { + // noop + } + expect(MockValidation.validateScopes).toHaveBeenCalledWith( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + }); + + it('normalized and merges the validated scopes', () => { + MockValidation.validateScopes.mockReturnValue({ + validRequiredScopes: { + 'eip155:1': validScopeObject, + }, + validOptionalScopes: { + 'eip155:5': validScopeObject, + }, + }); + + validateAndNormalizeScopes({}, {}); + expect(MockTransform.normalizeAndMergeScopes).toHaveBeenCalledWith({ + 'eip155:1': validScopeObject, + }); + expect(MockTransform.normalizeAndMergeScopes).toHaveBeenCalledWith({ + 'eip155:5': validScopeObject, + }); + }); + + it('returns the normalized and merged scopes', () => { + MockValidation.validateScopes.mockReturnValue({ + validRequiredScopes: { + 'eip155:1': validScopeObject, + }, + validOptionalScopes: { + 'eip155:5': validScopeObject, + }, + }); + MockTransform.normalizeAndMergeScopes.mockImplementation((value) => ({ + ...value, + transformed: true, + })); + + expect(validateAndNormalizeScopes({}, {})).toStrictEqual({ + normalizedRequiredScopes: { + 'eip155:1': validScopeObject, + transformed: true, + }, + normalizedOptionalScopes: { + 'eip155:5': validScopeObject, + transformed: true, + }, + }); + }); + }); +}); diff --git a/packages/multichain/src/scope/authorization.ts b/packages/multichain/src/scope/authorization.ts new file mode 100644 index 0000000000..c7b98a8357 --- /dev/null +++ b/packages/multichain/src/scope/authorization.ts @@ -0,0 +1,37 @@ +import { normalizeAndMergeScopes } from './transform'; +import type { ExternalScopesObject, ScopesObject } from './types'; +import { validateScopes } from './validation'; + +export type Caip25Authorization = + | { + requiredScopes: ExternalScopesObject; + optionalScopes?: ExternalScopesObject; + sessionProperties?: Record; + } + | ({ + requiredScopes?: ExternalScopesObject; + optionalScopes: ExternalScopesObject; + } & { + sessionProperties?: Record; + }); + +export const validateAndNormalizeScopes = ( + requiredScopes: ExternalScopesObject, + optionalScopes: ExternalScopesObject, +): { + normalizedRequiredScopes: ScopesObject; + normalizedOptionalScopes: ScopesObject; +} => { + const { validRequiredScopes, validOptionalScopes } = validateScopes( + requiredScopes, + optionalScopes, + ); + + const normalizedRequiredScopes = normalizeAndMergeScopes(validRequiredScopes); + const normalizedOptionalScopes = normalizeAndMergeScopes(validOptionalScopes); + + return { + normalizedRequiredScopes, + normalizedOptionalScopes, + }; +}; diff --git a/packages/multichain/src/scope/supported.test.ts b/packages/multichain/src/scope/supported.test.ts new file mode 100644 index 0000000000..72faf0e50d --- /dev/null +++ b/packages/multichain/src/scope/supported.test.ts @@ -0,0 +1,88 @@ +import { + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, +} from './supported'; +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from './types'; + +describe('Scope Support', () => { + describe('isSupportedNotification', () => { + it.each(Object.entries(KnownNotifications))( + 'returns true for each %s scope method', + (scopeString: string, notifications: string[]) => { + notifications.forEach((notification) => { + expect(isSupportedNotification(scopeString, notification)).toBe(true); + }); + }, + ); + + it('returns false otherwise', () => { + expect(isSupportedNotification('eip155', 'anything else')).toBe(false); + expect(isSupportedNotification('', '')).toBe(false); + }); + }); + + describe('isSupportedMethod', () => { + it.each(Object.entries(KnownRpcMethods))( + 'returns true for each %s scoped method', + (scopeString: string, methods: string[]) => { + methods.forEach((method) => { + expect(isSupportedMethod(scopeString, method)).toBe(true); + }); + }, + ); + + it('returns true for each wallet scoped method', () => { + KnownWalletRpcMethods.forEach((method) => { + expect(isSupportedMethod('wallet', method)).toBe(true); + }); + }); + + it.each(Object.entries(KnownWalletNamespaceRpcMethods))( + 'returns true for each wallet:%s scoped method', + (scopeString: string, methods: string[]) => { + methods.forEach((method) => { + expect(isSupportedMethod(`wallet:${scopeString}`, method)).toBe(true); + }); + }, + ); + + it('returns false otherwise', () => { + expect(isSupportedMethod('eip155', 'anything else')).toBe(false); + expect(isSupportedMethod('', '')).toBe(false); + }); + }); + + describe('isSupportedScopeString', () => { + it('returns true for the wallet namespace', () => { + expect(isSupportedScopeString('wallet', jest.fn())).toBe(true); + }); + + it('returns false for the wallet namespace when a reference is included', () => { + expect(isSupportedScopeString('wallet:someref', jest.fn())).toBe(false); + }); + + it('returns true for the ethereum namespace', () => { + expect(isSupportedScopeString('eip155', jest.fn())).toBe(true); + }); + + it('returns true for the ethereum namespace when a network client exists for the reference', () => { + const isChainIdSupportedMock = jest.fn().mockReturnValue(true); + expect(isSupportedScopeString('eip155:1', isChainIdSupportedMock)).toBe( + true, + ); + }); + + it('returns false for the ethereum namespace when a network client does not exist for the reference', () => { + const isChainIdSupportedMock = jest.fn().mockReturnValue(false); + expect(isSupportedScopeString('eip155:1', isChainIdSupportedMock)).toBe( + false, + ); + }); + }); +}); diff --git a/packages/multichain/src/scope/supported.ts b/packages/multichain/src/scope/supported.ts new file mode 100644 index 0000000000..52f351ea6c --- /dev/null +++ b/packages/multichain/src/scope/supported.ts @@ -0,0 +1,113 @@ +import { toHex, isEqualCaseInsensitive } from '@metamask/controller-utils'; +import type { CaipAccountId, Hex } from '@metamask/utils'; +import { + isCaipChainId, + isCaipNamespace, + KnownCaipNamespace, + parseCaipAccountId, + parseCaipChainId, +} from '@metamask/utils'; + +import type { NonWalletKnownCaipNamespace, ExternalScopeString } from './types'; +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, + parseScopeString, +} from './types'; + +export const isSupportedScopeString = ( + scopeString: string, + isChainIdSupported: (chainId: Hex) => boolean, +) => { + const isNamespaceScoped = isCaipNamespace(scopeString); + const isChainScoped = isCaipChainId(scopeString); + + if (isNamespaceScoped) { + switch (scopeString) { + case KnownCaipNamespace.Wallet: + return true; + case KnownCaipNamespace.Eip155: + return true; + default: + return false; + } + } + + if (isChainScoped) { + const { namespace, reference } = parseCaipChainId(scopeString); + switch (namespace) { + case KnownCaipNamespace.Wallet: + if (reference === KnownCaipNamespace.Eip155) { + return true; + } + return false; + case KnownCaipNamespace.Eip155: + return isChainIdSupported(toHex(reference)); + default: + return false; + } + } + + return false; +}; + +export const isSupportedAccount = ( + account: CaipAccountId, + getInternalAccounts: () => { type: string; address: string }[], +) => { + const { + address, + chain: { namespace }, + } = parseCaipAccountId(account); + switch (namespace) { + case KnownCaipNamespace.Eip155: + try { + return getInternalAccounts().some( + (internalAccount) => + ['eip155:eoa', 'eip155:erc4337'].includes(internalAccount.type) && + isEqualCaseInsensitive(address, internalAccount.address), + ); + } catch (err) { + console.log('failed to check if account is supported by wallet', err); + } + return false; + default: + return false; + } +}; + +export const isSupportedMethod = ( + scopeString: ExternalScopeString, + method: string, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + if (namespace === KnownCaipNamespace.Wallet) { + if (reference) { + return ( + KnownWalletNamespaceRpcMethods[ + reference as NonWalletKnownCaipNamespace + ] || [] + ).includes(method); + } + + return KnownWalletRpcMethods.includes(method); + } + + return ( + KnownRpcMethods[namespace as NonWalletKnownCaipNamespace] || [] + ).includes(method); +}; + +export const isSupportedNotification = ( + scopeString: ExternalScopeString, + notification: string, +): boolean => { + const { namespace } = parseScopeString(scopeString); + + return ( + KnownNotifications[namespace as NonWalletKnownCaipNamespace] || [] + ).includes(notification); +}; diff --git a/packages/multichain/src/scope/transform.test.ts b/packages/multichain/src/scope/transform.test.ts new file mode 100644 index 0000000000..afdd3ae2a3 --- /dev/null +++ b/packages/multichain/src/scope/transform.test.ts @@ -0,0 +1,304 @@ +import { + normalizeScope, + mergeScopes, + mergeScopeObject, + normalizeAndMergeScopes, +} from './transform'; +import type { ExternalScopeObject, ScopeObject } from './types'; + +const externalScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +const validScopeObject: ScopeObject = { + methods: [], + notifications: [], + accounts: [], +}; + +describe('Scope Transform', () => { + describe('normalizeScope', () => { + it('returns the scope with empty accounts array when the scopeString is chain scoped when accounts are not defined', () => { + expect(normalizeScope('eip155:1', externalScopeObject)).toStrictEqual({ + 'eip155:1': validScopeObject, + }); + }); + + it('returns the scope as is when the scopeString is chain scoped and accounts are defined', () => { + expect(normalizeScope('eip155:1', validScopeObject)).toStrictEqual({ + 'eip155:1': validScopeObject, + }); + }); + + describe('scopeString is namespace scoped', () => { + it('returns the scope as is when `references` is not defined', () => { + expect(normalizeScope('eip155', validScopeObject)).toStrictEqual({ + eip155: validScopeObject, + }); + }); + + it('returns one scope per `references` element with `references` excluded from the scopeObject', () => { + expect( + normalizeScope('eip155', { + ...validScopeObject, + references: ['1', '5', '64'], + }), + ).toStrictEqual({ + 'eip155:1': validScopeObject, + 'eip155:5': validScopeObject, + 'eip155:64': validScopeObject, + }); + }); + + it('returns one deep cloned scope per `references` element', () => { + const noramlizedScopes = normalizeScope('eip155', { + ...validScopeObject, + references: ['1', '5'], + }); + + expect(noramlizedScopes['eip155:1']).not.toBe( + noramlizedScopes['eip155:5'], + ); + expect(noramlizedScopes['eip155:1'].methods).not.toBe( + noramlizedScopes['eip155:5'].methods, + ); + }); + }); + }); + + describe('mergeScopeObject', () => { + it('returns an object with the unique set of methods', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + methods: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + methods: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + methods: ['a', 'b', 'c', 'd'], + }); + }); + + it('returns an object with the unique set of notifications', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + notifications: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + notifications: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + notifications: ['a', 'b', 'c', 'd'], + }); + }); + + it('returns an object with the unique set of accounts', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }, + { + ...validScopeObject, + accounts: ['eip155:1:b', 'eip155:1:c', 'eip155:1:d'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c', 'eip155:1:d'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + accounts: ['eip155:1:a', 'eip155:1:b', 'eip155:1:c'], + }); + }); + + it('returns an object with the unique set of rpcDocuments', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + rpcDocuments: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c', 'd'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }); + }); + + it('returns an object with the unique set of rpcEndpoints', () => { + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + rpcEndpoints: ['b', 'c', 'd'], + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c', 'd'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + { + ...validScopeObject, + }, + ), + ).toStrictEqual({ + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }); + }); + }); + + describe('mergeScopes', () => { + it('merges the scopeObjects with matching scopeString', () => { + expect( + mergeScopes( + { + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + accounts: [], + }, + }, + { + 'eip155:1': { + methods: ['c', 'd'], + notifications: ['bar'], + accounts: [], + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': { + methods: ['a', 'b', 'c', 'd'], + notifications: ['foo', 'bar'], + accounts: [], + }, + }); + }); + + it('preserves the scopeObjects with no matching scopeString', () => { + expect( + mergeScopes( + { + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + accounts: [], + }, + }, + { + 'eip155:2': { + methods: ['c', 'd'], + notifications: ['bar'], + accounts: [], + }, + 'eip155:3': { + methods: [], + notifications: [], + accounts: [], + }, + }, + ), + ).toStrictEqual({ + 'eip155:1': { + methods: ['a', 'b', 'c'], + notifications: ['foo'], + accounts: [], + }, + 'eip155:2': { + methods: ['c', 'd'], + notifications: ['bar'], + accounts: [], + }, + 'eip155:3': { + methods: [], + notifications: [], + accounts: [], + }, + }); + }); + }); + + describe('normalizeAndMergeScopes', () => { + it('normalizes scopes and merges any overlapping scopeStrings', () => { + expect( + normalizeAndMergeScopes({ + eip155: { + ...validScopeObject, + methods: ['a', 'b'], + references: ['1', '5'], + }, + 'eip155:1': { + ...validScopeObject, + methods: ['b', 'c', 'd'], + }, + }), + ).toStrictEqual({ + 'eip155:1': { + ...validScopeObject, + methods: ['a', 'b', 'c', 'd'], + }, + 'eip155:5': { + ...validScopeObject, + methods: ['a', 'b'], + }, + }); + }); + }); +}); diff --git a/packages/multichain/src/scope/transform.ts b/packages/multichain/src/scope/transform.ts new file mode 100644 index 0000000000..94f69d060d --- /dev/null +++ b/packages/multichain/src/scope/transform.ts @@ -0,0 +1,131 @@ +import type { CaipReference } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import type { + ExternalScopeObject, + ExternalScopesObject, + ScopeString, + ScopeObject, + ScopesObject, +} from './types'; +import { parseScopeString } from './types'; + +/** + * Returns a list of unique items + * + * @param list - The list of items to filter + * @returns A list of unique items + */ +export const getUniqueArrayItems = (list: Value[]): Value[] => { + return Array.from(new Set(list)); +}; + +/** + * Normalizes a ScopeString and ExternalScopeObject into a separate + * ScopeString and ScopeObject for each reference in the `references` + * value if defined and adds an empty `accounts` array if not defined. + * + * @param scopeString - The string representing the scope + * @param externalScopeObject - The object that defines the scope + * @returns a map of caipChainId to ScopeObjects + */ +export const normalizeScope = ( + scopeString: string, + externalScopeObject: ExternalScopeObject, +): ScopesObject => { + const { references, ...scopeObject } = externalScopeObject; + const { namespace, reference } = parseScopeString(scopeString); + + const normalizedScopeObject = { + accounts: [], + ...scopeObject, + }; + + // Scope is already a CAIP-2 ID and has no references to flatten + if (!namespace || reference || !references) { + return { [scopeString]: normalizedScopeObject }; + } + + const scopeMap: ScopesObject = {}; + references.forEach((nestedReference: CaipReference) => { + scopeMap[`${namespace}:${nestedReference}`] = cloneDeep( + normalizedScopeObject, + ); + }); + return scopeMap; +}; + +export const mergeScopeObject = ( + scopeObjectA: ScopeObject, + scopeObjectB: ScopeObject, +) => { + const mergedScopeObject: ScopeObject = { + methods: getUniqueArrayItems([ + ...scopeObjectA.methods, + ...scopeObjectB.methods, + ]), + notifications: getUniqueArrayItems([ + ...scopeObjectA.notifications, + ...scopeObjectB.notifications, + ]), + accounts: getUniqueArrayItems([ + ...scopeObjectA.accounts, + ...scopeObjectB.accounts, + ]), + }; + + if (scopeObjectA.rpcDocuments || scopeObjectB.rpcDocuments) { + mergedScopeObject.rpcDocuments = getUniqueArrayItems([ + ...(scopeObjectA.rpcDocuments ?? []), + ...(scopeObjectB.rpcDocuments ?? []), + ]); + } + + if (scopeObjectA.rpcEndpoints || scopeObjectB.rpcEndpoints) { + mergedScopeObject.rpcEndpoints = getUniqueArrayItems([ + ...(scopeObjectA.rpcEndpoints ?? []), + ...(scopeObjectB.rpcEndpoints ?? []), + ]); + } + + return mergedScopeObject; +}; + +export const mergeScopes = ( + scopeA: ScopesObject, + scopeB: ScopesObject, +): ScopesObject => { + const scope: ScopesObject = {}; + + Object.entries(scopeA).forEach(([_scopeString, scopeObjectA]) => { + const scopeString = _scopeString as ScopeString; + const scopeObjectB = scopeB[scopeString]; + + scope[scopeString] = scopeObjectB + ? mergeScopeObject(scopeObjectA, scopeObjectB) + : scopeObjectA; + }); + + Object.entries(scopeB).forEach(([_scopeString, scopeObjectB]) => { + const scopeString = _scopeString as ScopeString; + const scopeObjectA = scopeA[scopeString]; + + if (!scopeObjectA) { + scope[scopeString] = scopeObjectB; + } + }); + + return scope; +}; + +export const normalizeAndMergeScopes = ( + scopes: ExternalScopesObject, +): ScopesObject => { + let mergedScopes: ScopesObject = {}; + Object.keys(scopes).forEach((scopeString) => { + const normalizedScopes = normalizeScope(scopeString, scopes[scopeString]); + mergedScopes = mergeScopes(mergedScopes, normalizedScopes); + }); + + return mergedScopes; +}; diff --git a/packages/multichain/src/scope/types.test.ts b/packages/multichain/src/scope/types.test.ts new file mode 100644 index 0000000000..1b6149b3f2 --- /dev/null +++ b/packages/multichain/src/scope/types.test.ts @@ -0,0 +1,23 @@ +import { parseScopeString } from './types'; + +describe('Scope', () => { + describe('parseScopeString', () => { + it('returns only the namespace if scopeString is namespace', () => { + expect(parseScopeString('abc')).toStrictEqual({ namespace: 'abc' }); + }); + + it('returns the namespace and reference if scopeString is a CAIP chain ID', () => { + expect(parseScopeString('abc:foo')).toStrictEqual({ + namespace: 'abc', + reference: 'foo', + }); + }); + + it('returns empty object if scopeString is invalid', () => { + expect(parseScopeString('')).toStrictEqual({}); + expect(parseScopeString('a:')).toStrictEqual({}); + expect(parseScopeString(':b')).toStrictEqual({}); + expect(parseScopeString('a:b:c')).toStrictEqual({}); + }); + }); +}); diff --git a/packages/multichain/src/scope/types.ts b/packages/multichain/src/scope/types.ts new file mode 100644 index 0000000000..0d5c87fbb1 --- /dev/null +++ b/packages/multichain/src/scope/types.ts @@ -0,0 +1,103 @@ +import MetaMaskOpenRPCDocument from '@metamask/api-specs'; +import type { + CaipChainId, + CaipReference, + CaipAccountId, + KnownCaipNamespace, + CaipNamespace, +} from '@metamask/utils'; +import { + isCaipNamespace, + isCaipChainId, + parseCaipChainId, +} from '@metamask/utils'; + +export enum KnownWalletScopeString { + Eip155 = 'wallet:eip155', +} + +export type NonWalletKnownCaipNamespace = Extract< + KnownCaipNamespace, + KnownCaipNamespace.Eip155 +>; + +export const KnownWalletRpcMethods: string[] = [ + 'wallet_registerOnboarding', + 'wallet_scanQRCode', +]; +const WalletEip155Methods = ['wallet_addEthereumChain']; + +const Eip155Methods = MetaMaskOpenRPCDocument.methods + .map(({ name }: { name: string }) => name) + .filter((method: string) => !WalletEip155Methods.includes(method)) + .filter((method: string) => !KnownWalletRpcMethods.includes(method)); + +export const KnownRpcMethods: Record = { + eip155: Eip155Methods, +}; + +export const KnownWalletNamespaceRpcMethods: Record< + NonWalletKnownCaipNamespace, + string[] +> = { + eip155: WalletEip155Methods, +}; + +export const KnownNotifications: Record = + { + eip155: ['accountsChanged', 'chainChanged', 'eth_subscription'], + }; + +// These External prefixed types represent the CAIP-217 +// Scope and ScopeObject as defined in the spec. +export type ExternalScopeString = CaipChainId | CaipNamespace; +export type ExternalScopeObject = Omit & { + references?: CaipReference[]; + accounts?: CaipAccountId[]; +}; +export type ExternalScopesObject = Record< + ExternalScopeString, + ExternalScopeObject +>; + +// These non-prefixed types represent CAIP-217 Scope and +// ScopeObject as defined by the spec but without +// namespace-only Scopes (except for "wallet") and without +// the `references` array of CAIP References on the ScopeObject. +// These deviations from the spec are necessary as MetaMask +// does not support wildcarded Scopes, i.e. Scopes that only +// specify a namespace but no specific reference. +export type ScopeString = CaipChainId | KnownCaipNamespace.Wallet; +export type ScopeObject = { + methods: string[]; + notifications: string[]; + accounts: CaipAccountId[]; + rpcDocuments?: string[]; + rpcEndpoints?: string[]; +}; +export type ScopesObject = Record & { + [KnownCaipNamespace.Wallet]?: ScopeObject; +}; + +export const parseScopeString = ( + scopeString: string, +): { + namespace?: string; + reference?: string; +} => { + if (isCaipNamespace(scopeString)) { + return { + namespace: scopeString, + }; + } + if (isCaipChainId(scopeString)) { + return parseCaipChainId(scopeString); + } + + return {}; +}; + +export type ScopedProperties = Record< + ExternalScopeString, + Record +>; diff --git a/packages/multichain/src/scope/validation.test.ts b/packages/multichain/src/scope/validation.test.ts new file mode 100644 index 0000000000..e8cfb96280 --- /dev/null +++ b/packages/multichain/src/scope/validation.test.ts @@ -0,0 +1,180 @@ +import type { ExternalScopeObject } from './types'; +import { isValidScope, validateScopes } from './validation'; + +const validScopeString = 'eip155:1'; +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Validation', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isValidScope', () => { + it.each([ + [ + false, + 'the scopeString is neither a CAIP namespace or CAIP chainId', + 'not a namespace or a caip chain id', + validScopeObject, + ], + [ + true, + 'the scopeString is a valid CAIP namespace and the scopeObject is valid', + 'eip155', + validScopeObject, + ], + [ + true, + 'the scopeString is a valid CAIP chainId and the scopeObject is valid', + 'eip155:1', + validScopeObject, + ], + [ + false, + 'the scopeString is a CAIP chainId but references is nonempty', + 'eip155:1', + { + ...validScopeObject, + references: ['5'], + }, + ], + [ + false, + 'methods contains empty string', + validScopeString, + { + ...validScopeObject, + methods: [''], + }, + ], + [ + false, + 'methods contains non-string', + validScopeString, + { + ...validScopeObject, + methods: [{ foo: 'bar' }], + }, + ], + [ + true, + 'methods contains only strings', + validScopeString, + { + ...validScopeObject, + methods: ['method1', 'method2'], + }, + ], + [ + false, + 'notifications contains empty string', + validScopeString, + { + ...validScopeObject, + notifications: [''], + }, + ], + [ + false, + 'notifications contains non-string', + validScopeString, + { + ...validScopeObject, + notifications: [{ foo: 'bar' }], + }, + ], + [ + false, + 'notifications contains non-string', + 'eip155:1', + { + ...validScopeObject, + notifications: [{ foo: 'bar' }], + }, + ], + [ + false, + 'unexpected properties are defined', + validScopeString, + { + ...validScopeObject, + unexpectedParam: 'foobar', + }, + ], + [ + true, + 'only expected properties are defined', + validScopeString, + { + references: [], + methods: [], + notifications: [], + accounts: [], + rpcDocuments: [], + rpcEndpoints: [], + }, + ], + ])( + 'returns %s when %s', + ( + expected: boolean, + _scenario: string, + scopeString: string, + scopeObject: unknown, + ) => { + expect( + isValidScope(scopeString, scopeObject as ExternalScopeObject), + ).toStrictEqual(expected); + }, + ); + }); + + describe('validateScopes', () => { + const validScopeObjectWithAccounts = { + ...validScopeObject, + accounts: [], + }; + + it('does not throw an error if required scopes are defined but none are valid', () => { + expect( + validateScopes( + { 'eip155:1': {} as unknown as ExternalScopeObject }, + undefined, + ), + ).toStrictEqual({ validRequiredScopes: {}, validOptionalScopes: {} }); + }); + + it('does not throw an error if optional scopes are defined but none are valid', () => { + expect( + validateScopes(undefined, { + 'eip155:1': {} as unknown as ExternalScopeObject, + }), + ).toStrictEqual({ validRequiredScopes: {}, validOptionalScopes: {} }); + }); + + it('returns the valid required and optional scopes', () => { + expect( + validateScopes( + { + 'eip155:1': validScopeObjectWithAccounts, + 'eip155:64': {} as unknown as ExternalScopeObject, + }, + { + 'eip155:2': {} as unknown as ExternalScopeObject, + 'eip155:5': validScopeObjectWithAccounts, + }, + ), + ).toStrictEqual({ + validRequiredScopes: { + 'eip155:1': validScopeObjectWithAccounts, + }, + validOptionalScopes: { + 'eip155:5': validScopeObjectWithAccounts, + }, + }); + }); + }); +}); diff --git a/packages/multichain/src/scope/validation.ts b/packages/multichain/src/scope/validation.ts new file mode 100644 index 0000000000..3dbb7cfa25 --- /dev/null +++ b/packages/multichain/src/scope/validation.ts @@ -0,0 +1,102 @@ +import { isCaipReference } from '@metamask/utils'; + +import type { + ExternalScopeString, + ExternalScopeObject, + ExternalScopesObject, +} from './types'; +import { parseScopeString } from './types'; + +export const isValidScope = ( + scopeString: ExternalScopeString, + scopeObject: ExternalScopeObject, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + if (!namespace && !reference) { + return false; + } + + const { + references, + methods, + notifications, + accounts, + rpcDocuments, + rpcEndpoints, + ...restScopeObject + } = scopeObject; + + if (!methods || !notifications) { + return false; + } + + // These assume that the namespace has a notion of chainIds + if (reference && references && references.length > 0) { + return false; + } + if (namespace && references) { + const areReferencesValid = references.every((nestedReference) => { + return isCaipReference(nestedReference); + }); + + if (!areReferencesValid) { + return false; + } + } + + const areMethodsValid = methods.every( + (method) => typeof method === 'string' && method !== '', + ); + if (!areMethodsValid) { + return false; + } + + const areNotificationsValid = notifications.every( + (notification) => typeof notification === 'string' && notification !== '', + ); + if (!areNotificationsValid) { + return false; + } + + // unexpected properties found on scopeObject + if (Object.keys(restScopeObject).length !== 0) { + return false; + } + + return true; +}; + +export const validateScopes = ( + requiredScopes?: ExternalScopesObject, + optionalScopes?: ExternalScopesObject, +) => { + const validRequiredScopes: ExternalScopesObject = {}; + for (const [scopeString, scopeObject] of Object.entries( + requiredScopes || {}, + )) { + if (isValidScope(scopeString, scopeObject)) { + validRequiredScopes[scopeString] = { + accounts: [], + ...scopeObject, + }; + } + } + + const validOptionalScopes: ExternalScopesObject = {}; + for (const [scopeString, scopeObject] of Object.entries( + optionalScopes || {}, + )) { + if (isValidScope(scopeString, scopeObject)) { + validOptionalScopes[scopeString] = { + accounts: [], + ...scopeObject, + }; + } + } + + return { + validRequiredScopes, + validOptionalScopes, + }; +}; diff --git a/packages/multichain/tsconfig.build.json b/packages/multichain/tsconfig.build.json index 02a0eea03f..f2108df276 100644 --- a/packages/multichain/tsconfig.build.json +++ b/packages/multichain/tsconfig.build.json @@ -3,8 +3,16 @@ "compilerOptions": { "baseUrl": "./", "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "resolveJsonModule": true }, - "references": [], + "references": [ + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../permission-controller/tsconfig.build.json" + } + ], "include": ["../../types", "./src"] } diff --git a/packages/multichain/tsconfig.json b/packages/multichain/tsconfig.json index 025ba2ef7f..34e1d4a721 100644 --- a/packages/multichain/tsconfig.json +++ b/packages/multichain/tsconfig.json @@ -3,6 +3,13 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { + "path": "../network-controller" + }, + { + "path": "../permission-controller" + } + ], "include": ["../../types", "./src"] } diff --git a/types/@metamask/eth-json-rpc-filters.d.ts b/types/@metamask/eth-json-rpc-filters.d.ts new file mode 100644 index 0000000000..5a51785b82 --- /dev/null +++ b/types/@metamask/eth-json-rpc-filters.d.ts @@ -0,0 +1 @@ +declare module '@metamask/eth-json-rpc-filters/subscriptionManager'; diff --git a/yarn.lock b/yarn.lock index 10990344a0..b16e5d8f3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2103,6 +2103,13 @@ __metadata: languageName: unknown linkType: soft +"@metamask/api-specs@npm:^0.10.12": + version: 0.10.12 + resolution: "@metamask/api-specs@npm:0.10.12" + checksum: 10/e592f27f350994688d3d54a8a8db16de033011ef665efe3283a77431914d8d69d1c3312fad33e4245b4984e1223b04c98da3d0a68c7f9577cf8290ba441c52ee + languageName: node + linkType: hard + "@metamask/approval-controller@npm:^7.0.2, @metamask/approval-controller@npm:^7.1.0, @metamask/approval-controller@workspace:packages/approval-controller": version: 0.0.0-use.local resolution: "@metamask/approval-controller@workspace:packages/approval-controller" @@ -2527,6 +2534,19 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-json-rpc-filters@npm:^7.0.0": + version: 7.0.1 + resolution: "@metamask/eth-json-rpc-filters@npm:7.0.1" + dependencies: + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/json-rpc-engine": "npm:^8.0.2" + "@metamask/safe-event-emitter": "npm:^3.0.0" + async-mutex: "npm:^0.5.0" + pify: "npm:^5.0.0" + checksum: 10/5200f75cee48dfd79deba5e4f1b16ff6827e606da617891f5cb7b59c43ae4ac8420cb9a6a9ca31705c47d2c3d32a3754e052b30f61fd293cc37f009c4fe20c12 + languageName: node + linkType: hard + "@metamask/eth-json-rpc-infura@npm:^9.1.0": version: 9.1.0 resolution: "@metamask/eth-json-rpc-infura@npm:9.1.0" @@ -2875,6 +2895,17 @@ __metadata: languageName: node linkType: hard +"@metamask/json-rpc-engine@npm:^8.0.2": + version: 8.0.2 + resolution: "@metamask/json-rpc-engine@npm:8.0.2" + dependencies: + "@metamask/rpc-errors": "npm:^6.2.1" + "@metamask/safe-event-emitter": "npm:^3.0.0" + "@metamask/utils": "npm:^8.3.0" + checksum: 10/f088f4b648b9b55875b56e8237853e7282f13302a9db6a1f9bba06314dfd6cd0a23b3d27f8fde05a157b97ebb03b67bc2699ba455c99553dfb2ecccd73ab3474 + languageName: node + linkType: hard + "@metamask/json-rpc-engine@npm:^9.0.0, @metamask/json-rpc-engine@npm:^9.0.1, @metamask/json-rpc-engine@npm:^9.0.2, @metamask/json-rpc-engine@npm:^9.0.3": version: 9.0.3 resolution: "@metamask/json-rpc-engine@npm:9.0.3" @@ -3025,14 +3056,25 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain@workspace:packages/multichain" dependencies: + "@metamask/api-specs": "npm:^0.10.12" "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/controller-utils": "npm:^11.3.0" + "@metamask/eth-json-rpc-filters": "npm:^7.0.0" + "@metamask/network-controller": "npm:^21.0.1" + "@metamask/permission-controller": "npm:^11.0.2" + "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/utils": "npm:^9.1.0" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" + lodash: "npm:^4.17.21" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.2.2" + peerDependencies: + "@metamask/network-controller": ^21.0.0 + "@metamask/permission-controller": ^11.0.0 languageName: unknown linkType: soft