diff --git a/packages/chain-agnostic-permission/CHANGELOG.md b/packages/chain-agnostic-permission/CHANGELOG.md index 3003c146fc2..5a6eff2b675 100644 --- a/packages/chain-agnostic-permission/CHANGELOG.md +++ b/packages/chain-agnostic-permission/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `getCaip25PermissionFromLegacyPermissions` and `requestPermittedChainsPermissionIncremental` misc functions. ([#6225](https://github.com/MetaMask/core/pull/6225)) + ### Changed - Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) diff --git a/packages/chain-agnostic-permission/src/caip25Permission.test.ts b/packages/chain-agnostic-permission/src/caip25Permission.test.ts index 9823811eb15..67bb525064e 100644 --- a/packages/chain-agnostic-permission/src/caip25Permission.test.ts +++ b/packages/chain-agnostic-permission/src/caip25Permission.test.ts @@ -1,7 +1,12 @@ import { CaveatMutatorOperation, PermissionType, + type SubjectPermissions, + type ExtractPermission, + type PermissionSpecificationConstraint, + type CaveatSpecificationConstraint, } from '@metamask/permission-controller'; +import { pick } from 'lodash'; import type { Caip25CaveatValue } from './caip25Permission'; import { @@ -14,7 +19,10 @@ import { diffScopesForCaip25CaveatValue, generateCaip25Caveat, getCaip25CaveatFromPermission, + getCaip25PermissionFromLegacyPermissions, + requestPermittedChainsPermissionIncremental, } from './caip25Permission'; +import { CaveatTypes, PermissionKeys } from './constants'; import { KnownSessionProperties } from './scope/constants'; import * as ScopeSupported from './scope/supported'; @@ -27,6 +35,9 @@ const MockScopeSupported = jest.mocked(ScopeSupported); const { removeAccount, removeScope } = Caip25CaveatMutators[Caip25CaveatType]; +const mockRequestPermissionsIncremental = jest.fn(); +const mockGrantPermissionsIncremental = jest.fn(); + describe('caip25EndowmentBuilder', () => { describe('specificationBuilder', () => { it('builds the expected permission specification', () => { @@ -1759,3 +1770,589 @@ describe('generateCaip25Caveat', () => { }); }); }); + +describe('requestPermittedChainsPermissionIncremental', () => { + it('requests permittedChains approval if autoApprove: false', async () => { + const subjectPermissions: Partial< + SubjectPermissions< + ExtractPermission< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + > + > + > = { + [Caip25EndowmentPermissionName]: { + id: 'id', + date: 1, + invoker: 'origin', + parentCapability: PermissionKeys.permittedChains, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { 'eip155:1': { accounts: [] } }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }; + + const expectedCaip25Permission = { + [Caip25EndowmentPermissionName]: pick( + subjectPermissions[Caip25EndowmentPermissionName], + 'caveats', + ), + }; + + mockRequestPermissionsIncremental.mockResolvedValue([ + subjectPermissions, + { id: 'id', origin: 'origin' }, + ]); + + await requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }); + + expect(mockRequestPermissionsIncremental).toHaveBeenCalledWith( + { origin: 'test.com' }, + expectedCaip25Permission, + undefined, // undefined metadata + ); + }); + + it('throws if permittedChains approval is rejected', async () => { + mockRequestPermissionsIncremental.mockRejectedValue( + new Error('approval rejected'), + ); + + await expect(() => + requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }), + ).rejects.toThrow(new Error('approval rejected')); + }); + + it('grants permittedChains approval if autoApprove: true', async () => { + const subjectPermissions: Partial< + SubjectPermissions< + ExtractPermission< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + > + > + > = { + [Caip25EndowmentPermissionName]: { + id: 'id', + date: 1, + invoker: 'origin', + parentCapability: PermissionKeys.permittedChains, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { 'eip155:1': { accounts: [] } }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }; + + const expectedCaip25Permission = { + [Caip25EndowmentPermissionName]: pick( + subjectPermissions[Caip25EndowmentPermissionName], + 'caveats', + ), + }; + + mockGrantPermissionsIncremental.mockReturnValue(subjectPermissions); + + await requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: true, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }); + + expect(mockGrantPermissionsIncremental).toHaveBeenCalledWith({ + subject: { origin: 'test.com' }, + approvedPermissions: expectedCaip25Permission, + }); + }); + + it('throws if autoApprove: true and granting permittedChains throws', async () => { + mockGrantPermissionsIncremental.mockImplementation(() => { + throw new Error('Invalid merged permissions for subject "test.com"'); + }); + + await expect(() => + requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: true, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }), + ).rejects.toThrow( + new Error('Invalid merged permissions for subject "test.com"'), + ); + }); + + it('passes metadata to requestPermissionsIncremental when metadata is provided', async () => { + const subjectPermissions: Partial< + SubjectPermissions< + ExtractPermission< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + > + > + > = { + [Caip25EndowmentPermissionName]: { + id: 'id', + date: 1, + invoker: 'origin', + parentCapability: PermissionKeys.permittedChains, + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { 'eip155:1': { accounts: [] } }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }; + + const expectedCaip25Permission = { + [Caip25EndowmentPermissionName]: pick( + subjectPermissions[Caip25EndowmentPermissionName], + 'caveats', + ), + }; + + const metadata = { options: { someOption: 'testValue' } }; + + mockRequestPermissionsIncremental.mockResolvedValue([ + subjectPermissions, + { id: 'id', origin: 'origin' }, + ]); + + await requestPermittedChainsPermissionIncremental({ + origin: 'test.com', + chainId: '0x1', + autoApprove: false, + metadata, + hooks: { + requestPermissionsIncremental: mockRequestPermissionsIncremental, + grantPermissionsIncremental: mockGrantPermissionsIncremental, + }, + }); + + expect(mockRequestPermissionsIncremental).toHaveBeenCalledWith( + { origin: 'test.com' }, + expectedCaip25Permission, + { metadata }, + ); + }); +}); + +describe('getCaip25PermissionFromLegacyPermissions', () => { + it('returns valid CAIP-25 permissions', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({}); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when only eth_accounts is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when only permittedChains is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + 'eip155:100': { + accounts: [], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when both are specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + 'eip155:100': { + accounts: [ + 'eip155:100:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for only eth_accounts when only eth_accounts is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for only eth_accounts when only permittedChains is specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:100': { + accounts: [], + }, + 'wallet:eip155': { + accounts: [], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns approval from the PermissionsController for eth_accounts and permittedChains when both eth_accounts and permittedChains are specified in params', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: CaveatTypes.restrictReturnedAccounts, + value: ['0x0000000000000000000000000000000000000001'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x64'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:100': { + accounts: [ + 'eip155:100:0x0000000000000000000000000000000000000001', + ], + }, + 'wallet:eip155': { + accounts: [ + 'wallet:eip155:0x0000000000000000000000000000000000000001', + ], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns CAIP-25 approval with accounts and chainIds specified from `eth_accounts` and `endowment:permittedChains` permissions caveats', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: 'restrictNetworkSwitching', + value: ['0x1', '0x5'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); + + it('returns CAIP-25 approval with approved accounts for the `wallet:eip155` scope', async () => { + const permissions = getCaip25PermissionFromLegacyPermissions({ + [PermissionKeys.eth_accounts]: { + caveats: [ + { + type: 'restrictReturnedAccounts', + value: ['0xdeadbeef'], + }, + ], + }, + [PermissionKeys.permittedChains]: { + caveats: [ + { + type: 'restrictNetworkSwitching', + value: ['0x1', '0x5'], + }, + ], + }, + }); + + expect(permissions).toStrictEqual( + expect.objectContaining({ + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: ['eip155:1:0xdeadbeef'], + }, + 'eip155:5': { + accounts: ['eip155:5:0xdeadbeef'], + }, + 'wallet:eip155': { + accounts: ['wallet:eip155:0xdeadbeef'], + }, + }, + isMultichainOrigin: false, + sessionProperties: {}, + }, + }, + ], + }, + }), + ); + }); +}); diff --git a/packages/chain-agnostic-permission/src/caip25Permission.ts b/packages/chain-agnostic-permission/src/caip25Permission.ts index cc4b923a9b0..ad20fcc6c5b 100644 --- a/packages/chain-agnostic-permission/src/caip25Permission.ts +++ b/packages/chain-agnostic-permission/src/caip25Permission.ts @@ -20,10 +20,17 @@ import { type Hex, type NonEmptyArray, } from '@metamask/utils'; -import { cloneDeep, isEqual } from 'lodash'; +import { cloneDeep, isEqual, pick } from 'lodash'; -import { setNonSCACaipAccountIdsInCaip25CaveatValue } from './operators/caip-permission-operator-accounts'; -import { setChainIdsInCaip25CaveatValue } from './operators/caip-permission-operator-permittedChains'; +import { CaveatTypes, PermissionKeys } from './constants'; +import { + setEthAccounts, + setNonSCACaipAccountIdsInCaip25CaveatValue, +} from './operators/caip-permission-operator-accounts'; +import { + setChainIdsInCaip25CaveatValue, + setPermittedEthChainIds, +} from './operators/caip-permission-operator-permittedChains'; import { assertIsInternalScopesObject } from './scope/assert'; import { isSupportedAccount, @@ -559,3 +566,187 @@ export function getCaip25CaveatFromPermission(caip25Permission?: { } | undefined; } + +/** + * Requests user approval for the CAIP-25 permission + * and returns a granted permissions object. + * + * @param requestedPermissions - The legacy permissions to request approval for. + * @param requestedPermissions.caveats - The legacy caveats processed by the function. + * - `restrictReturnedAccounts`: Restricts which Ethereum accounts can be accessed + * - `restrictNetworkSwitching`: Restricts which blockchain networks can be used + * @returns The converted CAIP-25 permission object. + */ +export const getCaip25PermissionFromLegacyPermissions = + (requestedPermissions?: { + [PermissionKeys.eth_accounts]?: { + caveats?: { + type: keyof typeof CaveatTypes; + value: Hex[]; + }[]; + }; + [PermissionKeys.permittedChains]?: { + caveats?: { + type: keyof typeof CaveatTypes; + value: Hex[]; + }[]; + }; + }) => { + const permissions = pick(requestedPermissions, [ + PermissionKeys.eth_accounts, + PermissionKeys.permittedChains, + ]); + + if (!permissions[PermissionKeys.eth_accounts]) { + permissions[PermissionKeys.eth_accounts] = {}; + } + + if (!permissions[PermissionKeys.permittedChains]) { + permissions[PermissionKeys.permittedChains] = {}; + } + + const requestedAccounts = + permissions[PermissionKeys.eth_accounts]?.caveats?.find( + (caveat) => caveat.type === CaveatTypes.restrictReturnedAccounts, + )?.value ?? []; + + const requestedChains = + permissions[PermissionKeys.permittedChains]?.caveats?.find( + (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, + )?.value ?? []; + + const newCaveatValue = { + requiredScopes: {}, + optionalScopes: { + 'wallet:eip155': { + accounts: [], + }, + }, + sessionProperties: {}, + isMultichainOrigin: false, + }; + + const caveatValueWithChains = setPermittedEthChainIds( + newCaveatValue, + requestedChains, + ); + + const caveatValueWithAccountsAndChains = setEthAccounts( + caveatValueWithChains, + requestedAccounts, + ); + + return { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithAccountsAndChains, + }, + ], + }, + }; + }; + +/** + * Requests incremental permittedChains permission for the specified origin. + * and updates the existing CAIP-25 permission. + * Allows for granting without prompting for user approval which + * would be used as part of flows like `wallet_addEthereumChain` + * requests where the addition of the network and the permitting + * of the chain are combined into one approval. + * + * @param options - The options object + * @param options.origin - The origin to request approval for. + * @param options.chainId - The chainId to add to the existing permittedChains. + * @param options.autoApprove - If the chain should be granted without prompting for user approval. + * @param options.metadata - Request data for the approval. + * @param options.metadata.options - Additional metadata about the permission request. + * @param options.hooks - Permission controller hooks for incremental operations. + * @param options.hooks.requestPermissionsIncremental - Initiates an incremental permission request that prompts for user approval. + * Incremental permission requests allow the caller to replace existing and/or add brand new permissions and caveats for the specified subject. + * @param options.hooks.grantPermissionsIncremental - Incrementally grants approved permissions to the specified subject without prompting for user approval. + * Every permission and caveat is stringently validated and an error is thrown if validation fails. + */ +export const requestPermittedChainsPermissionIncremental = async ({ + origin, + chainId, + autoApprove, + hooks, + metadata, +}: { + origin: string; + chainId: Hex; + autoApprove: boolean; + hooks: { + requestPermissionsIncremental: ( + subject: { origin: string }, + requestedPermissions: Record< + string, + { caveats: { type: string; value: unknown }[] } + >, + options?: { metadata?: Record }, + ) => Promise< + | [ + Partial>, + { data?: Record; id: string; origin: string }, + ] + | [] + >; + grantPermissionsIncremental: (params: { + subject: { origin: string }; + approvedPermissions: Record< + string, + { caveats: { type: string; value: unknown }[] } + >; + requestData?: Record; + }) => Partial>; + }; + metadata?: { options: Record }; +}) => { + const caveatValueWithChains = setPermittedEthChainIds( + { + requiredScopes: {}, + optionalScopes: {}, + sessionProperties: {}, + isMultichainOrigin: false, + }, + [chainId], + ); + + if (!autoApprove) { + let options; + if (metadata) { + options = { metadata }; + } + await hooks.requestPermissionsIncremental( + { origin }, + { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithChains, + }, + ], + }, + }, + options, + ); + return; + } + + hooks.grantPermissionsIncremental({ + subject: { origin }, + approvedPermissions: { + [Caip25EndowmentPermissionName]: { + caveats: [ + { + type: Caip25CaveatType, + value: caveatValueWithChains, + }, + ], + }, + }, + }); +}; diff --git a/packages/chain-agnostic-permission/src/constants.ts b/packages/chain-agnostic-permission/src/constants.ts new file mode 100644 index 00000000000..382db59186f --- /dev/null +++ b/packages/chain-agnostic-permission/src/constants.ts @@ -0,0 +1,13 @@ +export const CaveatTypes = Object.freeze({ + restrictReturnedAccounts: 'restrictReturnedAccounts', + restrictNetworkSwitching: 'restrictNetworkSwitching', +}); + +/** + * The "keys" of permissions recognized by the PermissionController. + * Permission keys and names have distinct meanings in the permission system. + */ +export const PermissionKeys = Object.freeze({ + eth_accounts: 'eth_accounts', + permittedChains: 'endowment:permitted-chains', +}); diff --git a/packages/chain-agnostic-permission/src/index.test.ts b/packages/chain-agnostic-permission/src/index.test.ts index e6569b97d1d..5e4cf167c7d 100644 --- a/packages/chain-agnostic-permission/src/index.test.ts +++ b/packages/chain-agnostic-permission/src/index.test.ts @@ -49,6 +49,8 @@ describe('@metamask/chain-agnostic-permission', () => { "Caip25CaveatMutators", "generateCaip25Caveat", "getCaip25CaveatFromPermission", + "getCaip25PermissionFromLegacyPermissions", + "requestPermittedChainsPermissionIncremental", "KnownSessionProperties", "Caip25Errors", ] diff --git a/packages/chain-agnostic-permission/src/index.ts b/packages/chain-agnostic-permission/src/index.ts index 59abb97496e..1a9d7f2714a 100644 --- a/packages/chain-agnostic-permission/src/index.ts +++ b/packages/chain-agnostic-permission/src/index.ts @@ -71,6 +71,8 @@ export { Caip25CaveatMutators, generateCaip25Caveat, getCaip25CaveatFromPermission, + getCaip25PermissionFromLegacyPermissions, + requestPermittedChainsPermissionIncremental, } from './caip25Permission'; export { KnownSessionProperties } from './scope/constants'; export { Caip25Errors } from './scope/errors';