From 1e7d70e1ae9b930213c4b9e9322e91e0edeb88db Mon Sep 17 00:00:00 2001 From: jiexi Date: Thu, 14 Nov 2024 04:48:59 +0700 Subject: [PATCH] feat: Add CAIP-25 permission and adapters to `@metamask/multichain` (#4784) ## Explanation This PR updates `@metamask/multichain` to provide types, CAIP-25 permission, and helpers/adapters for the new permission, which can be shared across the extension & mobile clients. These tools and utilities will be used in both clients (mobile + extension)'s multichain API implementations. ### File Overview * `packages/multichain/src/adapters/`: Helpers that get and set legacy permission values from and to the new CAIP-25 permission * `packages/multichain/src/caip25Permission.ts`: Constants, types, mutators, and a specification builder for a CAIP-25 permission * `packages/multichain/src/index.ts`: Barrel export * `packages/multichain/src/scope/`: Types for [CAIP-217 ](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-217.md) and our internal normalized/flattened version of them. Additionally contains helpers for validating shape, normalizing/merging, and checking support (i.e. if the wallet is able to serve the chain with it's requested methods and notifications) ## References Upstream: https://github.com/MetaMask/core/pull/4812 Downstream: https://github.com/MetaMask/core/pull/4813 Key Multichain API Standards implemented here: - [CAIP-217](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-217.md) - `scope` defintion Open PR that uses this new package for migrating the legacy permissions to CAIP-25 permission in the extension: https://github.com/MetaMask/metamask-extension/pull/27847 ## Changelog ### `@metamask/multichain` - **ADDED**: TODO ## Checklist - [X] I've updated the test suite for new or updated code as appropriate - [X] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [X] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Alex Co-authored-by: Mark Stacey Co-authored-by: Elliot Winkler --- README.md | 3 + packages/multichain/package.json | 14 + ...ip-permission-adapter-eth-accounts.test.ts | 263 +++++ .../caip-permission-adapter-eth-accounts.ts | 138 +++ ...permission-adapter-permittedChains.test.ts | 340 +++++++ ...caip-permission-adapter-permittedChains.ts | 133 +++ .../multichain/src/caip25Permission.test.ts | 950 ++++++++++++++++++ packages/multichain/src/caip25Permission.ts | 306 ++++++ packages/multichain/src/index.test.ts | 34 +- packages/multichain/src/index.ts | 54 +- packages/multichain/src/scope/assert.test.ts | 483 +++++++++ packages/multichain/src/scope/assert.ts | 190 ++++ .../src/scope/authorization.test.ts | 91 ++ .../multichain/src/scope/authorization.ts | 53 + .../multichain/src/scope/constants.test.ts | 59 ++ packages/multichain/src/scope/constants.ts | 56 ++ packages/multichain/src/scope/errors.test.ts | 40 + packages/multichain/src/scope/errors.ts | 48 + .../multichain/src/scope/supported.test.ts | 264 +++++ packages/multichain/src/scope/supported.ts | 140 +++ .../multichain/src/scope/transform.test.ts | 380 +++++++ packages/multichain/src/scope/transform.ts | 152 +++ packages/multichain/src/scope/types.test.ts | 23 + packages/multichain/src/scope/types.ts | 97 ++ .../multichain/src/scope/validation.test.ts | 179 ++++ packages/multichain/src/scope/validation.ts | 129 +++ packages/multichain/tsconfig.build.json | 12 +- packages/multichain/tsconfig.json | 9 +- yarn.lock | 46 +- 29 files changed, 4666 insertions(+), 20 deletions(-) create mode 100644 packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.test.ts create mode 100644 packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.ts create mode 100644 packages/multichain/src/adapters/caip-permission-adapter-permittedChains.test.ts create mode 100644 packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts create mode 100644 packages/multichain/src/caip25Permission.test.ts create mode 100644 packages/multichain/src/caip25Permission.ts create mode 100644 packages/multichain/src/scope/assert.test.ts create mode 100644 packages/multichain/src/scope/assert.ts create mode 100644 packages/multichain/src/scope/authorization.test.ts create mode 100644 packages/multichain/src/scope/authorization.ts create mode 100644 packages/multichain/src/scope/constants.test.ts create mode 100644 packages/multichain/src/scope/constants.ts create mode 100644 packages/multichain/src/scope/errors.test.ts create mode 100644 packages/multichain/src/scope/errors.ts create mode 100644 packages/multichain/src/scope/supported.test.ts create mode 100644 packages/multichain/src/scope/supported.ts create mode 100644 packages/multichain/src/scope/transform.test.ts create mode 100644 packages/multichain/src/scope/transform.ts create mode 100644 packages/multichain/src/scope/types.test.ts create mode 100644 packages/multichain/src/scope/types.ts create mode 100644 packages/multichain/src/scope/validation.test.ts create mode 100644 packages/multichain/src/scope/validation.ts diff --git a/README.md b/README.md index 91009bca92..d01734fa5b 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,9 @@ linkStyle default opacity:0.5 logging_controller --> controller_utils; message_manager --> base_controller; message_manager --> controller_utils; + multichain --> controller_utils; + multichain --> network_controller; + multichain --> permission_controller; name_controller --> base_controller; name_controller --> controller_utils; network_controller --> base_controller; diff --git a/packages/multichain/package.json b/packages/multichain/package.json index 4fa4f7ccfc..a65ad36a54 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.4.3", + "@metamask/eth-json-rpc-filters": "^7.0.0", + "@metamask/rpc-errors": "^7.0.1", + "@metamask/utils": "^10.0.0", + "lodash": "^4.17.21" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/network-controller": "^22.0.2", + "@metamask/permission-controller": "^11.0.3", "@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": "^22.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..d58a8ad4b2 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.test.ts @@ -0,0 +1,263 @@ +import type { Caip25CaveatValue } from '../caip25Permission'; +import { + getEthAccounts, + setEthAccounts, +} from './caip-permission-adapter-eth-accounts'; + +describe('CAIP-25 eth_accounts adapters', () => { + describe('getEthAccounts', () => { + it('returns an empty array if the required scopes are empty', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: {}, + optionalScopes: {}, + }); + expect(ethAccounts).toStrictEqual([]); + }); + it('returns an empty array if the scope objects have no accounts', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: { + 'eip155:1': { methods: [], notifications: [], accounts: [] }, + 'eip155:2': { methods: [], notifications: [], accounts: [] }, + }, + optionalScopes: {}, + }); + expect(ethAccounts).toStrictEqual([]); + }); + it('returns an empty array if the scope objects have no eth accounts', () => { + const ethAccounts = getEthAccounts({ + requiredScopes: { + 'bip122:000000000019d6689c085ae165831e93': { + methods: [], + notifications: [], + accounts: [ + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ], + }, + }, + optionalScopes: {}, + }); + expect(ethAccounts).toStrictEqual([]); + }); + + 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'], + }, + }, + }); + + 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: [], + }, + wallet: { + 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', + ], + }, + wallet: { + methods: [], + notifications: [], + accounts: [ + 'wallet:eip155:0x1', + 'wallet:eip155:0x2', + 'wallet:eip155:0x3', + ], + }, + }, + isMultichainOrigin: false, + }); + }); + + it('returns a CAIP-25 caveat value with missing "wallet:eip155" optional scope filled in, forming CAIP-10 account addresses 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..6e96aac786 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-eth-accounts.ts @@ -0,0 +1,138 @@ +import { + assertIsStrictHexString, + type CaipAccountId, + type Hex, + KnownCaipNamespace, + parseCaipAccountId, +} from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission'; +import { KnownWalletScopeString } from '../scope/constants'; +import { getUniqueArrayItems, mergeScopes } from '../scope/transform'; +import type { InternalScopesObject, InternalScopeString } from '../scope/types'; +import { parseScopeString } from '../scope/types'; + +/** + * Checks if a scope string is either an EIP155 or wallet namespaced scope string. + * @param scopeString - The scope string to check. + * @returns True if the scope string is an EIP155 or wallet namespaced scope string, false otherwise. + */ +const isEip155ScopeString = (scopeString: InternalScopeString) => { + const { namespace } = parseScopeString(scopeString); + + return ( + namespace === KnownCaipNamespace.Eip155 || + scopeString === KnownWalletScopeString.Eip155 + ); +}; + +/** + * Gets the Ethereum (EIP155 namespaced) accounts from the required and optional scopes. + * @param caip25CaveatValue - The CAIP-25 caveat value to get the Ethereum accounts from. + * @returns An array of Ethereum accounts. + */ +export const getEthAccounts = ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' + >, +): Hex[] => { + const ethAccounts: Hex[] = []; + const sessionScopes = mergeScopes( + caip25CaveatValue.requiredScopes, + caip25CaveatValue.optionalScopes, + ); + + Object.entries(sessionScopes).forEach(([_, { accounts }]) => { + accounts?.forEach((account) => { + const { address, chainId } = parseCaipAccountId(account); + + if (isEip155ScopeString(chainId)) { + // This address should always be a valid Hex string because + // it's an EIP155/Ethereum account + assertIsStrictHexString(address); + ethAccounts.push(address); + } + }); + }); + + return getUniqueArrayItems(ethAccounts); +}; + +/** + * Sets the Ethereum (EIP155 namespaced) accounts for the given scopes object. + * @param scopesObject - The scopes object to set the Ethereum accounts for. + * @param accounts - The Ethereum accounts to set. + * @returns The updated scopes object with the Ethereum accounts set. + */ +const setEthAccountsForScopesObject = ( + scopesObject: InternalScopesObject, + accounts: Hex[], +) => { + const updatedScopesObject: InternalScopesObject = {}; + Object.entries(scopesObject).forEach(([key, scopeObject]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = key as keyof typeof scopesObject; + const isWalletNamespace = scopeString === KnownCaipNamespace.Wallet; + const { namespace, reference } = parseScopeString(scopeString); + if (!isEip155ScopeString(scopeString) && !isWalletNamespace) { + updatedScopesObject[scopeString] = scopeObject; + return; + } + + let caipAccounts: CaipAccountId[] = []; + if (isWalletNamespace) { + caipAccounts = accounts.map( + (account) => `${KnownWalletScopeString.Eip155}:${account}`, + ); + } else if (namespace && reference) { + caipAccounts = accounts.map( + (account) => `${namespace}:${reference}:${account}`, + ); + } + + updatedScopesObject[scopeString] = { + ...scopeObject, + accounts: caipAccounts, + }; + }); + + return updatedScopesObject; +}; + +/** + * Sets the Ethereum (EIP155 namespaced) accounts for the given CAIP-25 caveat value. + * We set the same accounts for all the scopes that are EIP155 or Wallet namespaced because + * we do not provide UI/UX flows for selecting different accounts across different chains. + * + * Additionally, this function adds a `wallet:eip155` scope with empty methods, notifications, and accounts + * to ensure that the `wallet:eip155` scope is always present in the caveat value. + * This is required for Snaps currently can have account permissions without chain permissions. + * This added `wallet:eip155` scope should be removed once Snaps are able to have/use chain permissions. + * @param caip25CaveatValue - The CAIP-25 caveat value to set the Ethereum accounts for. + * @param accounts - The Ethereum accounts to set. + * @returns The updated CAIP-25 caveat value with the Ethereum accounts set. + */ +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-permittedChains.test.ts b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.test.ts new file mode 100644 index 0000000000..c1504bd96f --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.test.ts @@ -0,0 +1,340 @@ +import type { Caip25CaveatValue } from '../caip25Permission'; +import { KnownNotifications, KnownRpcMethods } from '../scope/constants'; +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'], + }, + }, + }); + + expect(ethChainIds).toStrictEqual(['0x1', '0x5', '0xa', '0x64']); + }); + }); + + describe('addPermittedEthChainId', () => { + it('returns a version of the caveat value with a new 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('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: { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + '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: { + wallet: { + methods: [], + notifications: [], + accounts: [], + }, + '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: [], + }, + }, + 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..dbf1975840 --- /dev/null +++ b/packages/multichain/src/adapters/caip-permission-adapter-permittedChains.ts @@ -0,0 +1,133 @@ +import { toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; +import { KnownCaipNamespace } from '@metamask/utils'; + +import type { Caip25CaveatValue } from '../caip25Permission'; +import { KnownNotifications, KnownRpcMethods } from '../scope/constants'; +import { getUniqueArrayItems, mergeScopes } from '../scope/transform'; +import type { InternalScopesObject } from '../scope/types'; +import { parseScopeString } from '../scope/types'; + +/** + * Gets the Ethereum (EIP155 namespaced) chainIDs from the required and optional scopes. + * @param caip25CaveatValue - The CAIP-25 caveat value from which to get the Ethereum chainIDs. + * @returns An array of Ethereum chainIDs. + */ +export const getPermittedEthChainIds = ( + caip25CaveatValue: Pick< + Caip25CaveatValue, + 'requiredScopes' | 'optionalScopes' + >, +) => { + 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); +}; + +/** + * Adds an Ethereum (EIP155 namespaced) chainID to the optional scopes if it is not already present + * in either the pre-existing required or optional scopes. + * @param caip25CaveatValue - The CAIP-25 caveat value to add the Ethereum chainID to. + * @param chainId - The Ethereum chainID to add. + * @returns The updated CAIP-25 caveat value with the added Ethereum chainID. + */ +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: { + ...caip25CaveatValue.optionalScopes, + [scopeString]: { + methods: KnownRpcMethods.eip155, + notifications: KnownNotifications.eip155, + accounts: [], + }, + }, + }; +}; + +/** + * Filters the scopes object to only include: + * - Scopes without references (e.g. "wallet:") + * - EIP155 scopes for the given chainIDs + * - Non EIP155 scopes (e.g. "bip122:" or any other non ethereum namespaces) + * @param scopesObject - The scopes object to filter. + * @param chainIds - The chainIDs to filter EIP155 scopes by. + * @returns The filtered scopes object. + */ +const filterEthScopesObjectByChainId = ( + scopesObject: InternalScopesObject, + chainIds: Hex[], +) => { + const updatedScopesObject: InternalScopesObject = {}; + + Object.entries(scopesObject).forEach(([key, scopeObject]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = key as keyof typeof scopesObject; + const { namespace, reference } = parseScopeString(scopeString); + if (!reference) { + updatedScopesObject[scopeString] = scopeObject; + return; + } + if (namespace === KnownCaipNamespace.Eip155) { + const chainId = toHex(reference); + if (chainIds.includes(chainId)) { + updatedScopesObject[scopeString] = scopeObject; + } + } else { + updatedScopesObject[scopeString] = scopeObject; + } + }); + + return updatedScopesObject; +}; + +/** + * Sets the permitted Ethereum (EIP155 namespaced) chainIDs for the required and optional scopes. + * @param caip25CaveatValue - The CAIP-25 caveat value to set the permitted Ethereum chainIDs for. + * @param chainIds - The Ethereum chainIDs to set as permitted. + * @returns The updated CAIP-25 caveat value with the permitted Ethereum chainIDs. + */ +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..b124f34f9b --- /dev/null +++ b/packages/multichain/src/caip25Permission.test.ts @@ -0,0 +1,950 @@ +import { + CaveatMutatorOperation, + PermissionType, +} from '@metamask/permission-controller'; + +import type { Caip25CaveatValue } from './caip25Permission'; +import { + Caip25CaveatType, + caip25EndowmentBuilder, + Caip25EndowmentPermissionName, + Caip25CaveatMutators, + createCaip25Caveat, +} 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', () => ({ + ...jest.requireActual('./scope/assert'), + assertScopesSupported: jest.fn(), +})); + +const MockScopeAssert = jest.mocked(ScopeAssert); + +const { removeAccount, removeScope } = Caip25CaveatMutators[Caip25CaveatType]; + +describe('caip25EndowmentBuilder', () => { + beforeEach(() => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: {}, + normalizedOptionalScopes: {}, + }); + }); + + describe('specificationBuilder', () => { + 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('createCaip25Caveat', () => { + it('builds the caveat', () => { + expect( + createCaip25Caveat({ + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }), + ).toStrictEqual({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + }); + }); + + describe('Caip25CaveatMutators.authorizedScopes', () => { + describe('removeScope', () => { + it('returns a version of the caveat with the given scope removed from requiredScopes if it is present', () => { + 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(ethereumGoerliCaveat, 'eip155:1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + accounts: [], + }, + }, + }, + }); + }); + + it('returns a version of the caveat with the given scope removed from optionalScopes if it is present', () => { + 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(ethereumGoerliCaveat, 'eip155:5'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: [], + }, + }, + optionalScopes: {}, + }, + }); + }); + + it('returns a version of the caveat with the given scope removed from requiredScopes and optionalScopes if it is present', () => { + const ethereumGoerliCaveat = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: [], + }, + 'eip155:5': { + methods: [], + notifications: ['chainChanged'], + accounts: [], + }, + }, + optionalScopes: { + 'eip155:5': { + methods: ['eth_call'], + notifications: ['accountsChanged'], + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: true, + }; + const result = removeScope(ethereumGoerliCaveat, 'eip155:5'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: [], + }, + }, + optionalScopes: {}, + }, + }); + }); + + it('returns the caveat unchanged when the given scope is not found in either requiredScopes or optionalScopes', () => { + 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(ethereumGoerliCaveat, 'eip155:2'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.Noop, + }); + }); + }); + + describe('removeAccount', () => { + it('returns a version of the caveat with the given account removed from requiredScopes if it is present', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }; + const result = removeAccount(ethereumGoerliCaveat, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x2'], + }, + }, + optionalScopes: {}, + isMultichainOrigin: true, + }, + }); + }); + + it('returns a version of the caveat with the given account removed from optionalScopes if it is present', () => { + const ethereumGoerliCaveat: Caip25CaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1', 'eip155:1:0x2'], + }, + }, + isMultichainOrigin: true, + }; + const result = removeAccount(ethereumGoerliCaveat, '0x1'); + expect(result).toStrictEqual({ + operation: CaveatMutatorOperation.UpdateValue, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + methods: ['eth_call'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x2'], + }, + }, + isMultichainOrigin: true, + }, + }); + }); + + it('returns a version of the caveat with the given account removed from requiredScopes and optionalScopes if it is present', () => { + 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(ethereumGoerliCaveat, '0x1'); + 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('returns the caveat unchanged when the given account is not found in either requiredScopes or optionalScopes', () => { + 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(ethereumGoerliCaveat, '0x3'); + 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({ + // @ts-expect-error Intentionally invalid input + caveats: [], + 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), + }), + ); + + MockScopeAssert.assertScopesSupported.mock.calls[0][1].isChainIdSupported( + '0x1', + ); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + 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), + }), + ); + MockScopeAssert.assertScopesSupported.mock.calls[1][1].isChainIdSupported( + '0x1', + ); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + it('does not throw if unable to find a network client for the chainId', () => { + MockScopeAuthorization.validateAndNormalizeScopes.mockReturnValue({ + normalizedRequiredScopes: { + 'eip155:1': { + methods: ['normalized_required'], + notifications: [], + accounts: [], + }, + }, + normalizedOptionalScopes: { + 'eip155:5': { + methods: ['normalized_optional'], + notifications: [], + accounts: [], + }, + }, + }); + findNetworkClientIdByChainId.mockImplementation(() => { + throw new Error('unable to find network client'); + }); + 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.mock.calls[0][1].isChainIdSupported( + '0x1', + ), + ).toBe(false); + expect(findNetworkClientIdByChainId).toHaveBeenCalledWith('0x1'); + }); + + 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( + new Error( + `${Caip25EndowmentPermissionName} error: Received non-normalized value for caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + 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( + new Error( + `${Caip25EndowmentPermissionName} error: Received non-normalized value for caveat of type "${Caip25CaveatType}".`, + ), + ); + }); + + it('does not throw if the input requiredScopes and optionalScopes InternalScopesObject 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..b7c1679491 --- /dev/null +++ b/packages/multichain/src/caip25Permission.ts @@ -0,0 +1,306 @@ +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 { + hasProperty, + parseCaipAccountId, + type Hex, + type NonEmptyArray, +} from '@metamask/utils'; +import { cloneDeep, isEqual } from 'lodash'; + +import { getEthAccounts } from './adapters/caip-permission-adapter-eth-accounts'; +import { + assertScopesSupported, + assertIsExternalScopesObject, +} from './scope/assert'; +import { validateAndNormalizeScopes } from './scope/authorization'; +import type { + ExternalScopeString, + InternalScopeObject, + InternalScopesObject, +} from './scope/types'; + +/** + * The CAIP-25 permission caveat value. + * This permission contains the required and optional scopes and session properties from the [CAIP-25](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md) request that initiated the permission session. + * It also contains a boolean (isMultichainOrigin) indicating if the permission session is multichain, which may be needed to determine implicit permissioning. + */ +export type Caip25CaveatValue = { + requiredScopes: InternalScopesObject; + optionalScopes: InternalScopesObject; + sessionProperties?: Record; + isMultichainOrigin: boolean; +}; + +/** + * The name of the CAIP-25 permission caveat. + */ +export const Caip25CaveatType = 'authorizedScopes'; + +/** + * Creates a CAIP-25 permission caveat. + * @param value - The CAIP-25 permission caveat value. + * @returns The CAIP-25 permission caveat (now including the type). + */ +export const createCaip25Caveat = (value: Caip25CaveatValue) => { + return { + type: Caip25CaveatType, + value, + }; +}; + +/** + * The target name of the CAIP-25 endowment permission. + */ +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}".`, + ); + } + + if ( + !caip25Caveat.value || + !hasProperty(caip25Caveat.value, 'requiredScopes') || + !hasProperty(caip25Caveat.value, 'optionalScopes') || + !hasProperty(caip25Caveat.value, 'isMultichainOrigin') || + typeof caip25Caveat.value.isMultichainOrigin !== 'boolean' + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received invalid value for caveat of type "${Caip25CaveatType}".`, + ); + } + const { requiredScopes, optionalScopes } = caip25Caveat.value; + + assertIsExternalScopesObject(requiredScopes); + assertIsExternalScopesObject(optionalScopes); + + 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, + }).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.`, + ); + } + + if ( + !isEqual(requiredScopes, normalizedRequiredScopes) || + !isEqual(optionalScopes, normalizedOptionalScopes) + ) { + throw new Error( + `${Caip25EndowmentPermissionName} error: Received non-normalized value for caveat of type "${Caip25CaveatType}".`, + ); + } + }, + }; +}; + +/** + * The `caip25` endowment specification builder. Passed to the + * `PermissionController` for constructing and validating the + * `endowment:caip25` permission. + */ +export const caip25EndowmentBuilder = Object.freeze({ + targetName: Caip25EndowmentPermissionName, + specificationBuilder, +} as const); + +/** + * Factories that construct caveat mutator functions that are passed to + * PermissionController.updatePermissionsByCaveat. + */ +export const Caip25CaveatMutators = { + [Caip25CaveatType]: { + removeScope, + removeAccount, + }, +}; + +/** + * 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 scopeObject - The scope object to remove the account from. + * @param targetAddress - The address to remove from the scope object. + */ +function removeAccountFromScopeObject( + scopeObject: InternalScopeObject, + targetAddress: string, +) { + if (scopeObject.accounts) { + scopeObject.accounts = scopeObject.accounts.filter( + removeAccountFilterFn(targetAddress), + ); + } +} + +/** + * Removes the target account from the scope object. + * + * @param caip25CaveatValue - The CAIP-25 permission caveat value from which to remove the account (across all chain scopes). + * @param targetAddress - The address to remove from the scope object. Not a CAIP-10 formatted address because it will be removed across each chain scope. + * @returns The updated scope object. + */ +function removeAccount( + caip25CaveatValue: Caip25CaveatValue, + targetAddress: Hex, +) { + const copyOfCaveatValue = cloneDeep(caip25CaveatValue); + + [copyOfCaveatValue.requiredScopes, copyOfCaveatValue.optionalScopes].forEach( + (scopes) => { + Object.entries(scopes).forEach(([, scopeObject]) => { + removeAccountFromScopeObject(scopeObject, targetAddress); + }); + }, + ); + + const noChange = isEqual(copyOfCaveatValue, caip25CaveatValue); + + if (noChange) { + return { + operation: CaveatMutatorOperation.Noop, + }; + } + + return { + operation: CaveatMutatorOperation.UpdateValue, + value: copyOfCaveatValue, + }; +} + +/** + * Removes the target account from the value arrays of the given + * `endowment:caip25` caveat. No-ops if the target scopeString is not in + * the existing scopes. + * + * @param caip25CaveatValue - The CAIP-25 permission caveat value to remove the scope from. + * @param targetScopeString - The scope that is being removed. + * @returns The updated CAIP-25 permission caveat value. + */ +function removeScope( + caip25CaveatValue: Caip25CaveatValue, + targetScopeString: ExternalScopeString, +) { + 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: Object.fromEntries(newRequiredScopes), + optionalScopes: Object.fromEntries(newOptionalScopes), + }, + }; + } + + return { + operation: CaveatMutatorOperation.Noop, + }; +} diff --git a/packages/multichain/src/index.test.ts b/packages/multichain/src/index.test.ts index bc062d3694..61f0fdcc42 100644 --- a/packages/multichain/src/index.test.ts +++ b/packages/multichain/src/index.test.ts @@ -1,9 +1,31 @@ -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", + "getPermittedEthChainIds", + "addPermittedEthChainId", + "setPermittedEthChainIds", + "validateAndNormalizeScopes", + "KnownWalletRpcMethods", + "KnownRpcMethods", + "KnownWalletNamespaceRpcMethods", + "KnownNotifications", + "KnownWalletScopeString", + "parseScopeString", + "normalizeScope", + "mergeScopeObject", + "mergeScopes", + "normalizeAndMergeScopes", + "Caip25CaveatType", + "createCaip25Caveat", + "Caip25EndowmentPermissionName", + "caip25EndowmentBuilder", + "Caip25CaveatMutators", + ] + `); }); }); diff --git a/packages/multichain/src/index.ts b/packages/multichain/src/index.ts index 6972c11729..51864f461c 100644 --- a/packages/multichain/src/index.ts +++ b/packages/multichain/src/index.ts @@ -1,9 +1,45 @@ -/** - * 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 { + getPermittedEthChainIds, + addPermittedEthChainId, + setPermittedEthChainIds, +} from './adapters/caip-permission-adapter-permittedChains'; + +export type { Caip25Authorization } from './scope/authorization'; +export { validateAndNormalizeScopes } from './scope/authorization'; +export { + KnownWalletRpcMethods, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownNotifications, + KnownWalletScopeString, +} from './scope/constants'; +export type { + ExternalScopeString, + ExternalScopeObject, + ExternalScopesObject, + InternalScopeString, + InternalScopeObject, + InternalScopesObject, + ScopedProperties, + NonWalletKnownCaipNamespace, +} from './scope/types'; +export { parseScopeString } from './scope/types'; +export { + normalizeScope, + mergeScopeObject, + mergeScopes, + normalizeAndMergeScopes, +} from './scope/transform'; + +export type { Caip25CaveatValue } from './caip25Permission'; +export { + Caip25CaveatType, + createCaip25Caveat, + Caip25EndowmentPermissionName, + caip25EndowmentBuilder, + Caip25CaveatMutators, +} 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..362078cd22 --- /dev/null +++ b/packages/multichain/src/scope/assert.test.ts @@ -0,0 +1,483 @@ +import * as Utils from '@metamask/utils'; + +import { + assertScopeSupported, + assertScopesSupported, + assertIsExternalScopesObject, +} from './assert'; +import { Caip25Errors } from './errors'; +import * as Supported from './supported'; +import type { InternalScopeObject } from './types'; + +jest.mock('./supported', () => ({ + isSupportedScopeString: jest.fn(), + isSupportedNotification: jest.fn(), + isSupportedMethod: jest.fn(), +})); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + isCaipReference: jest.fn(), + isCaipAccountId: jest.fn(), +})); + +const MockSupported = jest.mocked(Supported); +const MockUtils = jest.mocked(Utils); + +const validScopeObject: InternalScopeObject = { + methods: [], + notifications: [], + accounts: [], +}; + +describe('Scope Assert', () => { + beforeEach(() => { + MockUtils.isCaipReference.mockImplementation(() => true); + MockUtils.isCaipAccountId.mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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(Caip25Errors.requestedChainsNotSupportedError()); + }); + }); + + 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(Caip25Errors.requestedMethodsNotSupportedError()); + }); + + 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(Caip25Errors.requestedNotificationsNotSupportedError()); + }); + + 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(Caip25Errors.requestedChainsNotSupportedError()); + }); + + 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(); + }); + }); + + describe('assertIsExternalScopesObject', () => { + it('does not throw if passed obj is a valid ExternalScopesObject with all valid properties', () => { + const obj = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => assertIsExternalScopesObject(obj)).not.toThrow(); + }); + + it('does not throw if passed obj is a valid ExternalScopesObject with some optional properties missing', () => { + const obj = { + accounts: ['eip155:1:0x1234'], + methods: ['method1'], + }; + expect(() => assertIsExternalScopesObject(obj)).not.toThrow(); + }); + + it('throws an error if passed obj is not an object', () => { + expect(() => assertIsExternalScopesObject(null)).toThrow( + 'ExternalScopesObject must be an object', + ); + expect(() => assertIsExternalScopesObject(123)).toThrow( + 'ExternalScopesObject must be an object', + ); + expect(() => assertIsExternalScopesObject('string')).toThrow( + 'ExternalScopesObject must be an object', + ); + }); + + it('throws and error if passed an object with an ExternalScopeObject value that is not an object', () => { + expect(() => assertIsExternalScopesObject({ 'eip155:1': 123 })).toThrow( + 'ExternalScopeObject must be an object', + ); + }); + + it('throws an error if passed an object with a key that is not a valid ExternalScopeString', () => { + jest.spyOn(Utils, 'isCaipReference').mockImplementation(() => false); + + expect(() => + assertIsExternalScopesObject({ 'invalid-scope-string': {} }), + ).toThrow('scopeString is not a valid ExternalScopeString'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a references property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: 'not-an-array', + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.references must be an array of CaipReference', + ); + }); + + it('throws an error if references contains invalid CaipReference', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['invalidRef'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + jest + .spyOn(Utils, 'isCaipReference') + .mockImplementation((ref) => ref !== 'invalidRef'); + + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.references must be an array of CaipReference', + ); + jest.restoreAllMocks(); + }); + + it('throws an error if passed an object with an ExternalScopeObject with an accounts property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: 'not-an-array', + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.accounts must be an array of CaipAccountId', + ); + }); + + it('throws an error if accounts contains invalid CaipAccountId', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234', 'invalidAccount'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + MockUtils.isCaipAccountId.mockImplementation( + (id) => id !== 'invalidAccount', + ); + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.accounts must be an array of CaipAccountId', + ); + jest.restoreAllMocks(); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a methods property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: 'not-an-array', + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.methods must be an array of strings'); + }); + + it('throws an error if methods contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 123], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.methods must be an array of strings'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a notifications property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: 'not-an-array', + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.notifications must be an array of strings', + ); + }); + + it('throws an error if notifications contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1', false], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow( + 'ExternalScopeObject.notifications must be an array of strings', + ); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a rpcDocuments property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: 'not-an-array', + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcDocuments must be an array of strings'); + }); + + it('throws an error if rpcDocuments contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1', 456], + rpcEndpoints: ['endpoint1'], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcDocuments must be an array of strings'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a rpcEndpoints property that is not an array', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: 'not-an-array', + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcEndpoints must be an array of strings'); + }); + + it('throws an error if passed an object with an ExternalScopeObject with a rpcEndpoints property that contains non-string elements', () => { + const invalidExternalScopeObject = { + 'eip155:1': { + references: ['reference1', 'reference2'], + accounts: ['eip155:1:0x1234'], + methods: ['method1', 'method2'], + notifications: ['notification1'], + rpcDocuments: ['doc1'], + rpcEndpoints: ['endpoint1', null], + }, + }; + expect(() => + assertIsExternalScopesObject(invalidExternalScopeObject), + ).toThrow('ExternalScopeObject.rpcEndpoints must be an array of strings'); + }); + }); +}); diff --git a/packages/multichain/src/scope/assert.ts b/packages/multichain/src/scope/assert.ts new file mode 100644 index 0000000000..522ed2f1d8 --- /dev/null +++ b/packages/multichain/src/scope/assert.ts @@ -0,0 +1,190 @@ +import { + hasProperty, + isCaipAccountId, + isCaipChainId, + isCaipNamespace, + isCaipReference, + type Hex, +} from '@metamask/utils'; + +import { Caip25Errors } from './errors'; +import { + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, +} from './supported'; +import type { + ExternalScopeObject, + ExternalScopesObject, + ExternalScopeString, + InternalScopeObject, + InternalScopesObject, +} from './types'; + +/** + * Asserts that a scope string and its associated scope object are supported. + * @param scopeString - The scope string against which to assert support. + * @param scopeObject - The scope object against which to assert support. + * @param options - An object containing the following properties: + * @param options.isChainIdSupported - A predicate that determines if a chainID is supported. + */ +export const assertScopeSupported = ( + scopeString: string, + scopeObject: InternalScopeObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + const { methods, notifications } = scopeObject; + if (!isSupportedScopeString(scopeString, isChainIdSupported)) { + throw Caip25Errors.requestedChainsNotSupportedError(); + } + + const allMethodsSupported = methods.every((method) => + isSupportedMethod(scopeString, method), + ); + + if (!allMethodsSupported) { + throw Caip25Errors.requestedMethodsNotSupportedError(); + } + + if ( + notifications && + !notifications.every((notification) => + isSupportedNotification(scopeString, notification), + ) + ) { + throw Caip25Errors.requestedNotificationsNotSupportedError(); + } +}; + +/** + * Asserts that all scope strings and their associated scope objects are supported. + * @param scopes - The scopes object against which to assert support. + * @param options - An object containing the following properties: + * @param options.isChainIdSupported - A predicate that determines if a chainID is supported. + */ +export const assertScopesSupported = ( + scopes: InternalScopesObject, + { + isChainIdSupported, + }: { + isChainIdSupported: (chainId: Hex) => boolean; + }, +) => { + for (const [scopeString, scopeObject] of Object.entries(scopes)) { + assertScopeSupported(scopeString, scopeObject, { + isChainIdSupported, + }); + } +}; +/** + * Asserts that an object is a valid ExternalScopeObject. + * @param obj - The object to assert. + */ +function assertIsExternalScopeObject( + obj: unknown, +): asserts obj is ExternalScopeObject { + if (typeof obj !== 'object' || obj === null) { + throw new Error('ExternalScopeObject must be an object'); + } + + if (hasProperty(obj, 'references')) { + if ( + !Array.isArray(obj.references) || + !obj.references.every(isCaipReference) + ) { + throw new Error( + 'ExternalScopeObject.references must be an array of CaipReference', + ); + } + } + + if (hasProperty(obj, 'accounts')) { + if (!Array.isArray(obj.accounts) || !obj.accounts.every(isCaipAccountId)) { + throw new Error( + 'ExternalScopeObject.accounts must be an array of CaipAccountId', + ); + } + } + + if (hasProperty(obj, 'methods')) { + if ( + !Array.isArray(obj.methods) || + !obj.methods.every((method) => typeof method === 'string') + ) { + throw new Error( + 'ExternalScopeObject.methods must be an array of strings', + ); + } + } + + if (hasProperty(obj, 'notifications')) { + if ( + !Array.isArray(obj.notifications) || + !obj.notifications.every( + (notification) => typeof notification === 'string', + ) + ) { + throw new Error( + 'ExternalScopeObject.notifications must be an array of strings', + ); + } + } + + if (hasProperty(obj, 'rpcDocuments')) { + if ( + !Array.isArray(obj.rpcDocuments) || + !obj.rpcDocuments.every((doc) => typeof doc === 'string') + ) { + throw new Error( + 'ExternalScopeObject.rpcDocuments must be an array of strings', + ); + } + } + + if (hasProperty(obj, 'rpcEndpoints')) { + if ( + !Array.isArray(obj.rpcEndpoints) || + !obj.rpcEndpoints.every((endpoint) => typeof endpoint === 'string') + ) { + throw new Error( + 'ExternalScopeObject.rpcEndpoints must be an array of strings', + ); + } + } +} + +/** + * Asserts that a scope string is a valid ExternalScopeString. + * @param scopeString - The scope string to assert. + */ +function assertIsExternalScopeString( + scopeString: unknown, +): asserts scopeString is ExternalScopeString { + if ( + typeof scopeString !== 'string' || + (!isCaipNamespace(scopeString) && !isCaipChainId(scopeString)) + ) { + throw new Error('scopeString is not a valid ExternalScopeString'); + } +} + +/** + * Asserts that an object is a valid ExternalScopesObject. + * @param obj - The object to assert. + */ +export function assertIsExternalScopesObject( + obj: unknown, +): asserts obj is ExternalScopesObject { + if (typeof obj !== 'object' || obj === null) { + throw new Error('ExternalScopesObject must be an object'); + } + + for (const [scopeString, scopeObject] of Object.entries(obj)) { + assertIsExternalScopeString(scopeString); + assertIsExternalScopeObject(scopeObject); + } +} diff --git a/packages/multichain/src/scope/authorization.test.ts b/packages/multichain/src/scope/authorization.test.ts new file mode 100644 index 0000000000..4759b40edd --- /dev/null +++ b/packages/multichain/src/scope/authorization.test.ts @@ -0,0 +1,91 @@ +import { validateAndNormalizeScopes } from './authorization'; +import * as Transform from './transform'; +import type { ExternalScopeObject } from './types'; +import * as Validation from './validation'; + +jest.mock('./validation', () => ({ + getValidScopes: 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', () => { + describe('validateAndNormalizeScopes', () => { + it('validates the scopes', () => { + MockValidation.getValidScopes.mockReturnValue({ + validRequiredScopes: {}, + validOptionalScopes: {}, + }); + validateAndNormalizeScopes( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + expect(MockValidation.getValidScopes).toHaveBeenCalledWith( + { + 'eip155:1': validScopeObject, + }, + { + 'eip155:5': validScopeObject, + }, + ); + }); + + it('normalizes and merges the validated scopes', () => { + MockValidation.getValidScopes.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.getValidScopes.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..6974d65295 --- /dev/null +++ b/packages/multichain/src/scope/authorization.ts @@ -0,0 +1,53 @@ +import type { Json } from '@metamask/utils'; + +import { normalizeAndMergeScopes } from './transform'; +import type { + ExternalScopesObject, + ExternalScopeString, + InternalScopesObject, +} from './types'; +import { getValidScopes } from './validation'; + +/** + * Represents the parameters of a [CAIP-25](https://chainagnostic.org/CAIPs/caip-25) request. + */ +export type Caip25Authorization = ( + | { + requiredScopes: ExternalScopesObject; + optionalScopes?: ExternalScopesObject; + } + | { + requiredScopes?: ExternalScopesObject; + optionalScopes: ExternalScopesObject; + } +) & { + sessionProperties?: Record; + scopedProperties?: Record; +}; + +/** + * Validates and normalizes a set of scopes according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + * @param requiredScopes - The required scopes to validate and normalize. + * @param optionalScopes - The optional scopes to validate and normalize. + * @returns An object containing the normalized required scopes and normalized optional scopes. + */ +export const validateAndNormalizeScopes = ( + requiredScopes: ExternalScopesObject, + optionalScopes: ExternalScopesObject, +): { + normalizedRequiredScopes: InternalScopesObject; + normalizedOptionalScopes: InternalScopesObject; +} => { + const { validRequiredScopes, validOptionalScopes } = getValidScopes( + requiredScopes, + optionalScopes, + ); + + const normalizedRequiredScopes = normalizeAndMergeScopes(validRequiredScopes); + const normalizedOptionalScopes = normalizeAndMergeScopes(validOptionalScopes); + + return { + normalizedRequiredScopes, + normalizedOptionalScopes, + }; +}; diff --git a/packages/multichain/src/scope/constants.test.ts b/packages/multichain/src/scope/constants.test.ts new file mode 100644 index 0000000000..8369ec721a --- /dev/null +++ b/packages/multichain/src/scope/constants.test.ts @@ -0,0 +1,59 @@ +import { KnownRpcMethods } from './constants'; + +describe('KnownRpcMethods', () => { + it('should match the snapshot', () => { + expect(KnownRpcMethods).toMatchInlineSnapshot(` + Object { + "bip122": Array [], + "eip155": Array [ + "wallet_switchEthereumChain", + "wallet_getPermissions", + "wallet_requestPermissions", + "wallet_revokePermissions", + "personal_sign", + "eth_signTypedData_v4", + "wallet_watchAsset", + "eth_requestAccounts", + "eth_accounts", + "eth_sendTransaction", + "eth_decrypt", + "eth_getEncryptionPublicKey", + "web3_clientVersion", + "eth_subscribe", + "eth_unsubscribe", + "eth_blockNumber", + "eth_call", + "eth_chainId", + "eth_coinbase", + "eth_estimateGas", + "eth_feeHistory", + "eth_gasPrice", + "eth_getBalance", + "eth_getBlockByHash", + "eth_getBlockByNumber", + "eth_getBlockTransactionCountByHash", + "eth_getBlockTransactionCountByNumber", + "eth_getCode", + "eth_getFilterChanges", + "eth_getFilterLogs", + "eth_getLogs", + "eth_getProof", + "eth_getStorageAt", + "eth_getTransactionByBlockHashAndIndex", + "eth_getTransactionByBlockNumberAndIndex", + "eth_getTransactionByHash", + "eth_getTransactionCount", + "eth_getTransactionReceipt", + "eth_getUncleCountByBlockHash", + "eth_getUncleCountByBlockNumber", + "eth_newBlockFilter", + "eth_newFilter", + "eth_newPendingTransactionFilter", + "eth_sendRawTransaction", + "eth_syncing", + "eth_uninstallFilter", + ], + } + `); + }); +}); diff --git a/packages/multichain/src/scope/constants.ts b/packages/multichain/src/scope/constants.ts new file mode 100644 index 0000000000..8610638b5d --- /dev/null +++ b/packages/multichain/src/scope/constants.ts @@ -0,0 +1,56 @@ +import MetaMaskOpenRPCDocument from '@metamask/api-specs'; + +import type { NonWalletKnownCaipNamespace } from './types'; + +/** + * ScopeStrings for offchain methods that are not specific to a chainId but are specific to a CAIP namespace. + */ +export enum KnownWalletScopeString { + Eip155 = 'wallet:eip155', +} + +/** + * Methods that do not belong exclusively to any CAIP namespace. + */ +export const KnownWalletRpcMethods: string[] = [ + 'wallet_registerOnboarding', + 'wallet_scanQRCode', +]; + +const WalletEip155Methods = ['wallet_addEthereumChain']; + +/** + * All MetaMask methods, except for ones we have specified in the constants above. + */ +const Eip155Methods = MetaMaskOpenRPCDocument.methods + .map(({ name }: { name: string }) => name) + .filter((method: string) => !WalletEip155Methods.includes(method)) + .filter((method: string) => !KnownWalletRpcMethods.includes(method)); + +/** + * Methods by ecosystem that are chain specific. + */ +export const KnownRpcMethods: Record = { + eip155: Eip155Methods, + bip122: [], +}; + +/** + * Methods for CAIP namespaces that aren't chain specific. + */ +export const KnownWalletNamespaceRpcMethods: Record< + NonWalletKnownCaipNamespace, + string[] +> = { + eip155: WalletEip155Methods, + bip122: [], +}; + +/** + * Notifications for known CAIP namespaces. + */ +export const KnownNotifications: Record = + { + eip155: ['eth_subscription'], + bip122: [], + }; diff --git a/packages/multichain/src/scope/errors.test.ts b/packages/multichain/src/scope/errors.test.ts new file mode 100644 index 0000000000..f176cd36d8 --- /dev/null +++ b/packages/multichain/src/scope/errors.test.ts @@ -0,0 +1,40 @@ +import { Caip25Errors } from './errors'; + +describe('Caip25Errors', () => { + it('requestedChainsNotSupportedError', () => { + expect(Caip25Errors.requestedChainsNotSupportedError().message).toBe( + 'Requested chains are not supported', + ); + expect(Caip25Errors.requestedChainsNotSupportedError().code).toBe(5100); + }); + + it('requestedMethodsNotSupportedError', () => { + expect(Caip25Errors.requestedMethodsNotSupportedError().message).toBe( + 'Requested methods are not supported', + ); + expect(Caip25Errors.requestedMethodsNotSupportedError().code).toBe(5101); + }); + + it('requestedNotificationsNotSupportedError', () => { + expect(Caip25Errors.requestedNotificationsNotSupportedError().message).toBe( + 'Requested notifications are not supported', + ); + expect(Caip25Errors.requestedNotificationsNotSupportedError().code).toBe( + 5102, + ); + }); + + it('unknownMethodsRequestedError', () => { + expect(Caip25Errors.unknownMethodsRequestedError().message).toBe( + 'Unknown method(s) requested', + ); + expect(Caip25Errors.unknownMethodsRequestedError().code).toBe(5201); + }); + + it('unknownNotificationsRequestedError', () => { + expect(Caip25Errors.unknownNotificationsRequestedError().message).toBe( + 'Unknown notification(s) requested', + ); + expect(Caip25Errors.unknownNotificationsRequestedError().code).toBe(5202); + }); +}); diff --git a/packages/multichain/src/scope/errors.ts b/packages/multichain/src/scope/errors.ts new file mode 100644 index 0000000000..97ff9c9872 --- /dev/null +++ b/packages/multichain/src/scope/errors.ts @@ -0,0 +1,48 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; + +/** + * CAIP25 Errors. + */ +export const Caip25Errors = { + /** + * Thrown when chains requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * @returns A new JsonRpcError instance. + */ + requestedChainsNotSupportedError: () => + new JsonRpcError(5100, 'Requested chains are not supported'), + + /** + * Thrown when methods requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * TODO: consider throwing the more generic version of this error (UNKNOWN_METHODS_REQUESTED_ERROR) unless in a DevMode build of the wallet + * @returns A new JsonRpcError instance. + */ + requestedMethodsNotSupportedError: () => + new JsonRpcError(5101, 'Requested methods are not supported'), + + /** + * Thrown when notifications requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * TODO: consider throwing the more generic version of this error (UNKNOWN_NOTIFICATIONS_REQUESTED_ERROR) unless in a DevMode build of the wallet + * @returns A new JsonRpcError instance. + */ + requestedNotificationsNotSupportedError: () => + new JsonRpcError(5102, 'Requested notifications are not supported'), + + /** + * Thrown when methods requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * @returns A new JsonRpcError instance. + */ + unknownMethodsRequestedError: () => + new JsonRpcError(5201, 'Unknown method(s) requested'), + + /** + * Thrown when notifications requested in a CAIP-25 `wallet_createSession` call are not supported by the wallet. + * Defined in [CAIP-25 error codes section](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-25.md#trusted-failure-codes). + * @returns A new JsonRpcError instance. + */ + unknownNotificationsRequestedError: () => + new JsonRpcError(5202, 'Unknown notification(s) requested'), +}; diff --git a/packages/multichain/src/scope/supported.test.ts b/packages/multichain/src/scope/supported.test.ts new file mode 100644 index 0000000000..fd488cfd71 --- /dev/null +++ b/packages/multichain/src/scope/supported.test.ts @@ -0,0 +1,264 @@ +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from './constants'; +import { + isSupportedAccount, + isSupportedMethod, + isSupportedNotification, + isSupportedScopeString, +} from './supported'; + +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); + }); + + it('returns false for unknown namespaces', () => { + expect(isSupportedNotification('unknown', 'anything else')).toBe(false); + }); + + it('returns false for wallet namespace', () => { + expect(isSupportedNotification('wallet', 'anything else')).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('wallet:unknown', '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 false for unknown namespaces', () => { + expect(isSupportedScopeString('unknown', jest.fn())).toBe(false); + }); + + it('returns true for the wallet namespace with eip155 reference', () => { + expect(isSupportedScopeString('wallet:eip155', jest.fn())).toBe(true); + }); + + it('returns false for the wallet namespace with eip155 reference', () => { + expect(isSupportedScopeString('wallet: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, + ); + }); + }); + + describe('isSupportedAccount', () => { + it('returns true if eoa account matching eip155 namespaced address exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xdeadbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns true if eoa account matching eip155 namespaced address with different casing exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xDEADbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns true if erc4337 account matching eip155 namespaced address exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xdeadbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns true if erc4337 account matching eip155 namespaced address with different casing exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xDEADbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns false if neither eoa or erc4337 account matching eip155 namespaced address exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'other', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('eip155:1:0xdeadbeef', getInternalAccounts), + ).toBe(false); + }); + + it('returns true if eoa account matching wallet:eip155 address exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xdeadbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns true if eoa account matching wallet:eip155 address with different casing exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xDEADbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns true if erc4337 account matching wallet:eip155 address exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xdeadbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns true if erc4337 account matching wallet:eip155 address with different casing exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:erc4337', + address: '0xdeadBEEF', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xDEADbeef', getInternalAccounts), + ).toBe(true); + }); + + it('returns false if neither eoa or erc4337 account matching wallet:eip155 address exists', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'other', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('wallet:eip155:0xdeadbeef', getInternalAccounts), + ).toBe(false); + }); + + it('returns false if wallet namespace with unknown reference', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadbeef', + }, + { + type: 'eip155:erc4337', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('wallet:foobar:0xdeadbeef', getInternalAccounts), + ).toBe(false); + }); + + it('returns false if unknown namespace', () => { + const getInternalAccounts = jest.fn().mockReturnValue([ + { + type: 'eip155:eoa', + address: '0xdeadbeef', + }, + { + type: 'eip155:erc4337', + address: '0xdeadbeef', + }, + ]); + expect( + isSupportedAccount('foo:bar:0xdeadbeef', getInternalAccounts), + ).toBe(false); + }); + }); +}); diff --git a/packages/multichain/src/scope/supported.ts b/packages/multichain/src/scope/supported.ts new file mode 100644 index 0000000000..7bb21e3652 --- /dev/null +++ b/packages/multichain/src/scope/supported.ts @@ -0,0 +1,140 @@ +import { toHex, isEqualCaseInsensitive } from '@metamask/controller-utils'; +import type { CaipAccountId, Hex } from '@metamask/utils'; +import { KnownCaipNamespace, parseCaipAccountId } from '@metamask/utils'; + +import { + KnownNotifications, + KnownRpcMethods, + KnownWalletNamespaceRpcMethods, + KnownWalletRpcMethods, +} from './constants'; +import type { ExternalScopeString } from './types'; +import { parseScopeString } from './types'; + +/** + * Determines if a scope string is supported. + * @param scopeString - The scope string to check. + * @param isChainIdSupported - A predicate that determines if a chainID is supported. + * @returns A boolean indicating if the scope string is supported. + */ +export const isSupportedScopeString = ( + scopeString: string, + isChainIdSupported: (chainId: Hex) => boolean, +) => { + const { namespace, reference } = parseScopeString(scopeString); + + switch (namespace) { + case KnownCaipNamespace.Wallet: + return !reference || reference === KnownCaipNamespace.Eip155; + case KnownCaipNamespace.Eip155: + return !reference || isChainIdSupported(toHex(reference)); + default: + return false; + } +}; + +/** + * Determines if an account is supported by the wallet (i.e. on a keyring known to the wallet). + * @param account - The CAIP account ID to check. + * @param getInternalAccounts - A function that returns the internal accounts. + * @returns A boolean indicating if the account is supported by the wallet. + */ +export const isSupportedAccount = ( + account: CaipAccountId, + getInternalAccounts: () => { type: string; address: string }[], +) => { + const { + address, + chain: { namespace, reference }, + } = parseCaipAccountId(account); + + const isSupportedEip155Account = () => + getInternalAccounts().some( + (internalAccount) => + ['eip155:eoa', 'eip155:erc4337'].includes(internalAccount.type) && + isEqualCaseInsensitive(address, internalAccount.address), + ); + + switch (namespace) { + case KnownCaipNamespace.Wallet: + return reference === KnownCaipNamespace.Eip155 + ? isSupportedEip155Account() + : false; + case KnownCaipNamespace.Eip155: + return isSupportedEip155Account(); + default: + return false; + } +}; + +/** + * Determines if a method is supported by the wallet. + * @param scopeString - The scope string to check. + * @param method - The method to check. + * @returns A boolean indicating if the method is supported by the wallet. + */ +export const isSupportedMethod = ( + scopeString: ExternalScopeString, + method: string, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + if (!namespace || !isKnownCaipNamespace(namespace)) { + return false; + } + + if (namespace === KnownCaipNamespace.Wallet) { + if (reference) { + if ( + !isKnownCaipNamespace(reference) || + reference === KnownCaipNamespace.Wallet + ) { + return false; + } + return KnownWalletNamespaceRpcMethods[reference].includes(method); + } + + return KnownWalletRpcMethods.includes(method); + } + + return KnownRpcMethods[namespace].includes(method); +}; + +/** + * Determines if a notification is supported by the wallet. + * @param scopeString - The scope string to check. + * @param notification - The notification to check. + * @returns A boolean indicating if the notification is supported by the wallet. + */ +export const isSupportedNotification = ( + scopeString: ExternalScopeString, + notification: string, +): boolean => { + const { namespace } = parseScopeString(scopeString); + + if ( + !namespace || + !isKnownCaipNamespace(namespace) || + namespace === KnownCaipNamespace.Wallet + ) { + return false; + } + + return KnownNotifications[namespace].includes(notification); +}; + +/** + * Checks whether the given namespace is a known CAIP namespace. + * + * @param namespace - The namespace to check + * @returns Whether the given namespace is a known CAIP namespace. + */ +function isKnownCaipNamespace( + namespace: string, +): namespace is KnownCaipNamespace { + const knownNamespaces = Object.keys(KnownCaipNamespace).map((key) => + key.toLowerCase(), + ); + + return knownNamespaces.includes(namespace); +} diff --git a/packages/multichain/src/scope/transform.test.ts b/packages/multichain/src/scope/transform.test.ts new file mode 100644 index 0000000000..5eede12203 --- /dev/null +++ b/packages/multichain/src/scope/transform.test.ts @@ -0,0 +1,380 @@ +import { + normalizeScope, + mergeScopes, + mergeScopeObject, + normalizeAndMergeScopes, +} from './transform'; +import type { ExternalScopeObject, InternalScopeObject } from './types'; + +const externalScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +const validScopeObject: InternalScopeObject = { + methods: [], + notifications: [], + accounts: [], +}; + +describe('Scope Transform', () => { + describe('normalizeScope', () => { + describe('scopeString is chain scoped', () => { + it('returns the scope with empty accounts array when accounts are not defined', () => { + expect(normalizeScope('eip155:1', externalScopeObject)).toStrictEqual({ + 'eip155:1': { + ...externalScopeObject, + accounts: [], + }, + }); + }); + + it('returns the scope unchanged when accounts are defined', () => { + expect( + normalizeScope('eip155:1', { ...externalScopeObject, accounts: [] }), + ).toStrictEqual({ + 'eip155:1': { + ...externalScopeObject, + accounts: [], + }, + }); + }); + }); + + 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 normalizedScopes = normalizeScope('eip155', { + ...validScopeObject, + references: ['1', '5'], + }); + + expect(normalizedScopes['eip155:1']).not.toBe( + normalizedScopes['eip155:5'], + ); + expect(normalizedScopes['eip155:1'].methods).not.toBe( + normalizedScopes['eip155:5'].methods, + ); + }); + + it('returns the scope as is when `references` is an empty array', () => { + expect( + normalizeScope('eip155', { ...validScopeObject, references: [] }), + ).toStrictEqual({ + eip155: validScopeObject, + }); + }); + }); + }); + + 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'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + }, + { + ...validScopeObject, + rpcDocuments: ['a', 'b', 'c'], + }, + ), + ).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'], + }); + + expect( + mergeScopeObject( + { + ...validScopeObject, + }, + { + ...validScopeObject, + rpcEndpoints: ['a', 'b', 'c'], + }, + ), + ).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: [], + }, + }); + }); + it('returns an empty object when no scopes are provided', () => { + expect(mergeScopes({}, {})).toStrictEqual({}); + }); + + it('returns an unchanged scope when two identical scopeObjects are provided', () => { + expect( + mergeScopes( + { 'eip155:1': validScopeObject }, + { 'eip155:1': validScopeObject }, + ), + ).toStrictEqual({ 'eip155:1': validScopeObject }); + }); + }); + + 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'], + }, + }); + }); + it('returns an empty object when no scopes are provided', () => { + expect(normalizeAndMergeScopes({})).toStrictEqual({}); + }); + it('return an unchanged scope when scopeObjects are already normalized (i.e. none contain references to flatten)', () => { + expect( + normalizeAndMergeScopes({ + 'eip155:1': validScopeObject, + 'eip155:2': validScopeObject, + 'eip155:3': validScopeObject, + }), + ).toStrictEqual({ + 'eip155:1': validScopeObject, + 'eip155:2': validScopeObject, + 'eip155:3': validScopeObject, + }); + }); + }); +}); diff --git a/packages/multichain/src/scope/transform.ts b/packages/multichain/src/scope/transform.ts new file mode 100644 index 0000000000..561b64b002 --- /dev/null +++ b/packages/multichain/src/scope/transform.ts @@ -0,0 +1,152 @@ +import type { CaipReference } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import type { + ExternalScopeObject, + ExternalScopesObject, + InternalScopeObject, + InternalScopesObject, +} 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 + * InternalScopeString and InternalScopeObject 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, +): InternalScopesObject => { + const { references, ...scopeObject } = externalScopeObject; + const { namespace, reference } = parseScopeString(scopeString); + + const normalizedScopeObject: InternalScopeObject = { + accounts: [], + ...scopeObject, + }; + + const shouldFlatten = + namespace && + !reference && + references !== undefined && + references.length > 0; + + if (shouldFlatten) { + return Object.fromEntries( + references.map((ref: CaipReference) => [ + `${namespace}:${ref}`, + cloneDeep(normalizedScopeObject), + ]), + ); + } + return { [scopeString]: normalizedScopeObject }; +}; + +/** + * Merges two InternalScopeObjects + * @param scopeObjectA - The first scope object to merge. + * @param scopeObjectB - The second scope object to merge. + * @returns The merged scope object. + */ +export const mergeScopeObject = ( + scopeObjectA: InternalScopeObject, + scopeObjectB: InternalScopeObject, +) => { + const mergedScopeObject: InternalScopeObject = { + 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; +}; + +/** + * Merges two InternalScopeObjects + * @param scopeA - The first scope object to merge. + * @param scopeB - The second scope object to merge. + * @returns The merged scope object. + */ +export const mergeScopes = ( + scopeA: InternalScopesObject, + scopeB: InternalScopesObject, +): InternalScopesObject => { + const scope: InternalScopesObject = {}; + + Object.entries(scopeA).forEach(([_scopeString, scopeObjectA]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = _scopeString as keyof typeof scopeA; + const scopeObjectB = scopeB[scopeString]; + + scope[scopeString] = scopeObjectB + ? mergeScopeObject(scopeObjectA, scopeObjectB) + : scopeObjectA; + }); + + Object.entries(scopeB).forEach(([_scopeString, scopeObjectB]) => { + // Cast needed because index type is returned as `string` by `Object.entries` + const scopeString = _scopeString as keyof typeof scopeB; + const scopeObjectA = scopeA[scopeString]; + + if (!scopeObjectA) { + scope[scopeString] = scopeObjectB; + } + }); + + return scope; +}; + +/** + * Normalizes and merges a set of ExternalScopesObjects into a InternalScopesObject (i.e. a set of InternalScopeObjects where references are flattened). + * @param scopes - The external scopes to normalize and merge. + * @returns The normalized and merged scopes. + */ +export const normalizeAndMergeScopes = ( + scopes: ExternalScopesObject, +): InternalScopesObject => { + let mergedScopes: InternalScopesObject = {}; + 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..77b1669fd5 --- /dev/null +++ b/packages/multichain/src/scope/types.ts @@ -0,0 +1,97 @@ +import { + isCaipNamespace, + isCaipChainId, + parseCaipChainId, +} from '@metamask/utils'; +import type { + CaipChainId, + CaipReference, + CaipAccountId, + KnownCaipNamespace, + CaipNamespace, + Json, +} from '@metamask/utils'; + +/** + * Represents a `scopeString` as defined in [CAIP-217](https://chainagnostic.org/CAIPs/caip-217). + */ +export type ExternalScopeString = CaipChainId | CaipNamespace; +/** + * Represents a `scopeObject` as defined in [CAIP-217](https://chainagnostic.org/CAIPs/caip-217). + */ +export type ExternalScopeObject = Omit & { + references?: CaipReference[]; + accounts?: CaipAccountId[]; +}; +/** + * Represents a `scope` as defined in [CAIP-217](https://chainagnostic.org/CAIPs/caip-217). + * TODO update the language in CAIP-217 to use "scope" instead of "scopeObject" for this full record type. + */ +export type ExternalScopesObject = Record< + ExternalScopeString, + ExternalScopeObject +>; + +/** + * Represents a `scopeString` as defined in + * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that + * CAIP namespaces without a reference (aside from "wallet") are disallowed for our internal representations of CAIP-25 session scopes + */ +export type InternalScopeString = CaipChainId | KnownCaipNamespace.Wallet; +/** + * Represents a `scopeObject` as defined in + * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that + * the `references` property is disallowed for our internal representations of CAIP-25 session scopes. + * e.g. We flatten each reference into its own scopeObject before storing them in a `endowment:caip25` permission. + */ +export type InternalScopeObject = { + methods: string[]; + notifications: string[]; + accounts: CaipAccountId[]; + rpcDocuments?: string[]; + rpcEndpoints?: string[]; +}; +/** + * Represents a keyed `scopeObject` as defined in + * [CAIP-217](https://chainagnostic.org/CAIPs/caip-217), with the exception that + * `scopeObject`s do not contain `references` in our internal representations of CAIP-25 session scopes. + * e.g. We flatten each reference into its own scopeObject before storing them in a `endowment:caip25` permission. + */ +export type InternalScopesObject = Record & { + [KnownCaipNamespace.Wallet]?: InternalScopeObject; +}; + +export type ScopedProperties = Record> & { + [KnownCaipNamespace.Wallet]?: Record; +}; + +/** + * Parses a scope string into a namespace and reference. + * @param scopeString - The scope string to parse. + * @returns An object containing the namespace and reference. + */ +export const parseScopeString = ( + scopeString: string, +): { + namespace?: string; + reference?: string; +} => { + if (isCaipNamespace(scopeString)) { + return { + namespace: scopeString, + }; + } + if (isCaipChainId(scopeString)) { + return parseCaipChainId(scopeString); + } + + return {}; +}; + +/** + * CAIP namespaces excluding "wallet" currently supported by/known to the wallet. + */ +export type NonWalletKnownCaipNamespace = Exclude< + KnownCaipNamespace, + KnownCaipNamespace.Wallet +>; diff --git a/packages/multichain/src/scope/validation.test.ts b/packages/multichain/src/scope/validation.test.ts new file mode 100644 index 0000000000..6871b01069 --- /dev/null +++ b/packages/multichain/src/scope/validation.test.ts @@ -0,0 +1,179 @@ +import type { ExternalScopeObject } from './types'; +import { isValidScope, getValidScopes } from './validation'; + +const validScopeString = 'eip155:1'; +const validScopeObject: ExternalScopeObject = { + methods: [], + notifications: [], +}; + +describe('Scope Validation', () => { + describe('isValidScope', () => { + it('returns false when the scopeString is neither a CAIP namespace or CAIP chainId', () => { + expect( + isValidScope('not a namespace or a caip chain id', validScopeObject), + ).toBe(false); + }); + + it('returns true when the scopeString is "wallet" and the scopeObject does not contain references', () => { + expect(isValidScope('wallet', validScopeObject)).toBe(true); + }); + + it('returns true when the scopeString is a valid CAIP chainId and the scopeObject is valid', () => { + expect(isValidScope('eip155:1', validScopeObject)).toBe(true); + }); + + it('returns false when the scopeString is a valid CAIP namespace but references are invalid CAIP references', () => { + expect( + isValidScope('eip155', { + ...validScopeObject, + references: ['@'], + }), + ).toBe(false); + }); + + it('returns false when the scopeString is a CAIP chainId but references is defined', () => { + expect( + isValidScope('eip155:1', { + ...validScopeObject, + references: [], + }), + ).toBe(false); + }); + + it('returns false when the scopeString is a valid CAIP namespace (other than "wallet") but references is an empty array', () => { + expect( + isValidScope('eip155', { ...validScopeObject, references: [] }), + ).toBe(false); + }); + + it('returns false when the scopeString is a valid CAIP namespace (other than "wallet") but references is undefined', () => { + expect(isValidScope('eip155', validScopeObject)).toBe(false); + }); + + it('returns false when methods contains empty string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + methods: [''], + }), + ).toBe(false); + }); + + it('returns false when methods contains non-string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + // @ts-expect-error Intentionally invalid input + methods: [{ foo: 'bar' }], + }), + ).toBe(false); + }); + + it('returns true when methods contains only strings', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + methods: ['method1', 'method2'], + }), + ).toBe(true); + }); + + it('returns false when notifications contains empty string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + notifications: [''], + }), + ).toBe(false); + }); + + it('returns false when notifications contains non-string', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + // @ts-expect-error Intentionally invalid input + notifications: [{ foo: 'bar' }], + }), + ).toBe(false); + }); + + it('returns false when unexpected properties are defined', () => { + expect( + isValidScope(validScopeString, { + ...validScopeObject, + // @ts-expect-error Intentionally invalid input + unexpectedParam: 'foobar', + }), + ).toBe(false); + }); + + it('returns true when only expected properties are defined', () => { + expect( + isValidScope(validScopeString, { + methods: [], + notifications: [], + accounts: [], + rpcDocuments: [], + rpcEndpoints: [], + }), + ).toBe(true); + + expect( + isValidScope('eip155', { + ...validScopeObject, + references: ['1'], + }), + ).toBe(true); + }); + }); + + describe('getValidScopes', () => { + const validScopeObjectWithAccounts = { + ...validScopeObject, + accounts: [], + }; + + it('does not throw an error if required scopes are defined but none are valid', () => { + expect( + getValidScopes( + // @ts-expect-error Intentionally invalid input + { 'eip155:1': {} }, + undefined, + ), + ).toStrictEqual({ validRequiredScopes: {}, validOptionalScopes: {} }); + }); + + it('does not throw an error if optional scopes are defined but none are valid', () => { + expect( + getValidScopes(undefined, { + // @ts-expect-error Intentionally invalid input + 'eip155:1': {}, + }), + ).toStrictEqual({ validRequiredScopes: {}, validOptionalScopes: {} }); + }); + + it('returns the valid required and optional scopes', () => { + expect( + getValidScopes( + { + 'eip155:1': validScopeObjectWithAccounts, + // @ts-expect-error Intentionally invalid input + 'eip155:64': {}, + }, + { + 'eip155:2': {}, + '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..26e96fdc65 --- /dev/null +++ b/packages/multichain/src/scope/validation.ts @@ -0,0 +1,129 @@ +import { isCaipReference } from '@metamask/utils'; + +import type { + ExternalScopeString, + ExternalScopeObject, + ExternalScopesObject, +} from './types'; +import { parseScopeString } from './types'; + +/** + * Validates a scope object according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + * @param scopeString - The scope string to validate. + * @param scopeObject - The scope object to validate. + * @returns A boolean indicating if the scope object is valid according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + */ +export const isValidScope = ( + scopeString: ExternalScopeString, + scopeObject: ExternalScopeObject, +): boolean => { + const { namespace, reference } = parseScopeString(scopeString); + + // Namespace is required + if (!namespace) { + return false; + } + + const { + references, + methods, + notifications, + accounts, + rpcDocuments, + rpcEndpoints, + ...extraProperties + } = scopeObject; + + // Methods and notifications are required + if (!methods || !notifications) { + return false; + } + + // For namespaces other than 'wallet', either reference or non-empty references array must be present + if ( + namespace !== 'wallet' && + !reference && + (!references || references.length === 0) + ) { + return false; + } + + // If references are present, reference must be absent and all references must be valid + if (references) { + if (reference) { + return false; + } + + const areReferencesValid = references.every((nestedReference) => + isCaipReference(nestedReference), + ); + + if (!areReferencesValid) { + return false; + } + } + + const areMethodsValid = methods.every( + (method) => typeof method === 'string' && method.trim() !== '', + ); + + if (!areMethodsValid) { + return false; + } + + const areNotificationsValid = notifications.every( + (notification) => + typeof notification === 'string' && notification.trim() !== '', + ); + + if (!areNotificationsValid) { + return false; + } + + // Ensure no unexpected properties are present in the scope object + if (Object.keys(extraProperties).length > 0) { + return false; + } + + return true; +}; + +/** + * Filters out invalid scopes and returns valid sets of required and optional scopes according to the [CAIP-217](https://chainagnostic.org/CAIPs/caip-217) spec. + * @param requiredScopes - The required scopes to validate. + * @param optionalScopes - The optional scopes to validate. + * @returns An object containing valid required scopes and optional scopes. + */ +export const getValidScopes = ( + 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/yarn.lock b/yarn.lock index 9473694c14..4c119b3c04 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.1, @metamask/approval-controller@workspace:packages/approval-controller": version: 0.0.0-use.local resolution: "@metamask/approval-controller@workspace:packages/approval-controller" @@ -2530,6 +2537,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:^10.0.0": version: 10.0.0 resolution: "@metamask/eth-json-rpc-infura@npm:10.0.0" @@ -2869,6 +2889,17 @@ __metadata: languageName: unknown linkType: soft +"@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.1, @metamask/json-rpc-engine@npm:^9.0.2": version: 9.0.3 resolution: "@metamask/json-rpc-engine@npm:9.0.3" @@ -3019,14 +3050,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.4.3" + "@metamask/eth-json-rpc-filters": "npm:^7.0.0" + "@metamask/network-controller": "npm:^22.0.2" + "@metamask/permission-controller": "npm:^11.0.3" + "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/utils": "npm:^10.0.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": ^22.0.0 + "@metamask/permission-controller": ^11.0.0 languageName: unknown linkType: soft @@ -3415,7 +3457,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/rpc-errors@npm:^6.3.1": +"@metamask/rpc-errors@npm:^6.2.1, @metamask/rpc-errors@npm:^6.3.1": version: 6.3.1 resolution: "@metamask/rpc-errors@npm:6.3.1" dependencies: @@ -3786,7 +3828,7 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^8.2.0": +"@metamask/utils@npm:^8.2.0, @metamask/utils@npm:^8.3.0": version: 8.5.0 resolution: "@metamask/utils@npm:8.5.0" dependencies: