diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index 170fb0803a8..bc3ebe9e56e 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/7715-permission-types` from `^0.7.1` to `^1.0.0` ([#9164](https://github.com/MetaMask/core/pull/9164)) + - Use permission decoders from `@metamask/7715-permission-types` + - **BREAKING:** Drop support for `erc20-token-revocation` permission type + ## [4.2.3] ### Changed diff --git a/packages/gator-permissions-controller/README.md b/packages/gator-permissions-controller/README.md index 973d049fde1..364b5481baf 100644 --- a/packages/gator-permissions-controller/README.md +++ b/packages/gator-permissions-controller/README.md @@ -26,7 +26,6 @@ const gatorPermissionsController = new GatorPermissionsController({ 'native-token-periodic', 'erc20-token-stream', 'erc20-token-periodic', - 'erc20-token-revocation', ], // Optional: override the default gator permissions provider Snap id // gatorPermissionsProviderSnapId: 'npm:@metamask/gator-permissions-snap', diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index 7e103f4a601..82c5c89410a 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -55,7 +55,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/7715-permission-types": "^0.7.1", + "@metamask/7715-permission-types": "^1.0.0", "@metamask/abi-utils": "^2.0.3", "@metamask/base-controller": "^9.1.0", "@metamask/delegation-core": "^2.2.1", diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index 22c08aab8fb..17edee29166 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -2,6 +2,11 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import { createTimestampTerms, createNativeTokenStreamingTerms, + createNativeTokenPeriodTransferTerms, + createERC20StreamingTerms, + createERC20TokenPeriodTransferTerms, + createApprovalRevocationTerms, + createValueLteTerms, encodeDelegations, ROOT_AUTHORITY, } from '@metamask/delegation-core'; @@ -92,7 +97,6 @@ const DEFAULT_TEST_CONFIG = { 'native-token-periodic', 'erc20-token-stream', 'erc20-token-periodic', - 'erc20-token-revocation', ] as SupportedPermissionType[], }; @@ -422,49 +426,6 @@ describe('GatorPermissionsController', () => { expect(controller.state.grantedPermissions[0].status).toBe('Active'); }); - it('categorizes erc20-token-revocation permissions into its own bucket', async () => { - const chainId = '0x1' as Hex; - // Create a minimal revocation permission entry and cast to satisfy types - const revocationEntry = { - permissionResponse: { - chainId, - from: '0x0000000000000000000000000000000000000001', - to: '0x0000000000000000000000000000000000000002', - permission: { - type: 'erc20-token-revocation', - isAdjustmentAllowed: false, - // Data shape is enforced by external types; not relevant for categorization - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: {} as any, - }, - context: '0xdeadbeef', - dependencies: [], - delegationManager: '0x0000000000000000000000000000000000000003', - }, - siteOrigin: 'https://example.org', - } as unknown; - const rootMessenger = getRootMessenger({ - snapControllerHandleRequestActionHandler: async () => - [revocationEntry] as unknown, - }); - const controller = new GatorPermissionsController({ - messenger: getGatorPermissionsControllerMessenger(rootMessenger), - config: DEFAULT_TEST_CONFIG, - }); - - await rootMessenger.call( - 'GatorPermissionsController:fetchAndUpdateGatorPermissions', - ); - - const { grantedPermissions } = controller.state; - expect(grantedPermissions).toHaveLength(1); - expect(grantedPermissions[0].permissionResponse.permission.type).toBe( - 'erc20-token-revocation', - ); - expect(grantedPermissions[0].permissionResponse.chainId).toBe(chainId); - expect(PERMISSION_STATUSES).toContain(grantedPermissions[0].status); - }); - it('handles null permissions data', async () => { const rootMessenger = getRootMessenger({ snapControllerHandleRequestActionHandler: async () => null, @@ -824,82 +785,6 @@ describe('GatorPermissionsController', () => { ).toThrow('Contracts not found for chainId: 999999'); }); - it('decodes a native-token-stream permission successfully', () => { - const { - TimestampEnforcer, - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - } = contracts; - - const delegator = delegatorAddressA; - const delegate = delegateAddressB; - - const beforeThreshold = 1720000; - const expiryTerms = createTimestampTerms( - { afterThreshold: 0, beforeThreshold }, - { out: 'hex' }, - ); - - const initialAmount = 123456n; - const maxAmount = 999999n; - const amountPerSecond = 1n; - const startTime = 1715664; - const streamTerms = createNativeTokenStreamingTerms( - { initialAmount, maxAmount, amountPerSecond, startTime }, - { out: 'hex' }, - ); - - const caveats = [ - { - enforcer: TimestampEnforcer, - terms: expiryTerms, - args: '0x', - } as const, - { - enforcer: NativeTokenStreamingEnforcer, - terms: streamTerms, - args: '0x', - } as const, - { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, - { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, - ]; - - const delegation = { - delegate, - delegator, - authority: ROOT_AUTHORITY as Hex, - caveats, - }; - - const result = rootMessenger.call( - 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', - { - origin: controller.gatorPermissionsProviderSnapId, - chainId, - delegation, - metadata: buildMetadata('Test justification'), - }, - ); - - expect(result.chainId).toBe(numberToHex(chainId)); - expect(result.from).toBe(delegator); - expect(result.to).toStrictEqual(delegate); - expect(result.permission.type).toBe('native-token-stream'); - expect(result.expiry).toBe(beforeThreshold); - // amounts are hex-encoded in decoded data; startTime is numeric - expect(result.permission.data.startTime).toBe(startTime); - // BigInt fields are encoded as hex; compare after decoding - expect(hexToBigInt(result.permission.data.initialAmount)).toBe( - initialAmount, - ); - expect(hexToBigInt(result.permission.data.maxAmount)).toBe(maxAmount); - expect(hexToBigInt(result.permission.data.amountPerSecond)).toBe( - amountPerSecond, - ); - expect(result.permission.justification).toBe('Test justification'); - }); - it('throws when origin does not match permissions provider', () => { expect(() => rootMessenger.call( @@ -1064,6 +949,531 @@ describe('GatorPermissionsController', () => { ), ).toThrow('Failed to decode permission'); }); + + describe('specific permission types', () => { + const UINT256_MAX = 2n ** 256n - 1n; + + it('decodes a native-token-stream permission successfully', () => { + const { + TimestampEnforcer, + NativeTokenStreamingEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const initialAmount = 123456n; + const maxAmount = 999999n; + const amountPerSecond = 1n; + const startTime = 1715664; + const streamTerms = createNativeTokenStreamingTerms( + { initialAmount, maxAmount, amountPerSecond, startTime }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenStreamingEnforcer, + terms: streamTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const delegation = { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.chainId).toBe(numberToHex(chainId)); + expect(result.from).toBe(delegator); + expect(result.to).toStrictEqual(delegate); + expect(result.permission.type).toBe('native-token-stream'); + expect(result.expiry).toBe(beforeThreshold); + // amounts are hex-encoded in decoded data; startTime is numeric + expect(result.permission.data.startTime).toBe(startTime); + + // BigInt fields are encoded as hex; compare after decoding + expect(hexToBigInt(result.permission.data.initialAmount)).toBe( + initialAmount, + ); + expect(hexToBigInt(result.permission.data.maxAmount)).toBe(maxAmount); + expect(hexToBigInt(result.permission.data.amountPerSecond)).toBe( + amountPerSecond, + ); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes a native-token-periodic permission successfully', () => { + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const periodAmount = 123456n; + const periodDuration = 86400; + const startDate = 1715664; + const periodicTerms = createNativeTokenPeriodTransferTerms( + { periodAmount, periodDuration, startDate }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: periodicTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('native-token-periodic'); + expect(result.expiry).toBe(beforeThreshold); + expect(hexToBigInt(result.permission.data.periodAmount)).toBe( + periodAmount, + ); + expect(result.permission.data.periodDuration).toBe(periodDuration); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes an erc20-token-stream permission successfully', () => { + const { + TimestampEnforcer, + ERC20StreamingEnforcer, + ValueLteEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const tokenAddress = + '0x3333333333333333333333333333333333333333' as Hex; + const initialAmount = 123456n; + const maxAmount = 999999n; + const amountPerSecond = 1n; + const startTime = 1715664; + const streamTerms = createERC20StreamingTerms( + { + tokenAddress, + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }, + { out: 'hex' }, + ); + const valueLteTerms = createValueLteTerms( + { maxValue: 0n }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ERC20StreamingEnforcer, + terms: streamTerms, + args: '0x', + } as const, + { + enforcer: ValueLteEnforcer, + terms: valueLteTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('erc20-token-stream'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.tokenAddress.toLowerCase()).toBe( + tokenAddress.toLowerCase(), + ); + expect(hexToBigInt(result.permission.data.initialAmount)).toBe( + initialAmount, + ); + expect(hexToBigInt(result.permission.data.maxAmount)).toBe(maxAmount); + expect(hexToBigInt(result.permission.data.amountPerSecond)).toBe( + amountPerSecond, + ); + expect(result.permission.data.startTime).toBe(startTime); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes an erc20-token-periodic permission successfully', () => { + const { + TimestampEnforcer, + ERC20PeriodTransferEnforcer, + ValueLteEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const tokenAddress = + '0x3333333333333333333333333333333333333333' as Hex; + const periodAmount = 123456n; + const periodDuration = 86400; + const startDate = 1715664; + const periodicTerms = createERC20TokenPeriodTransferTerms( + { tokenAddress, periodAmount, periodDuration, startDate }, + { out: 'hex' }, + ); + const valueLteTerms = createValueLteTerms( + { maxValue: 0n }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: periodicTerms, + args: '0x', + } as const, + { + enforcer: ValueLteEnforcer, + terms: valueLteTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('erc20-token-periodic'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.tokenAddress.toLowerCase()).toBe( + tokenAddress.toLowerCase(), + ); + expect(hexToBigInt(result.permission.data.periodAmount)).toBe( + periodAmount, + ); + expect(result.permission.data.periodDuration).toBe(periodDuration); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes a native-token-allowance permission successfully', () => { + const { + TimestampEnforcer, + NativeTokenPeriodTransferEnforcer, + ExactCalldataEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const allowanceAmount = 123456n; + const startDate = 1715664; + const allowanceTerms = createNativeTokenPeriodTransferTerms( + { + periodAmount: allowanceAmount, + periodDuration: UINT256_MAX, + startDate, + }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: NativeTokenPeriodTransferEnforcer, + terms: allowanceTerms, + args: '0x', + } as const, + { enforcer: ExactCalldataEnforcer, terms: '0x', args: '0x' } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('native-token-allowance'); + expect(result.expiry).toBe(beforeThreshold); + expect(hexToBigInt(result.permission.data.allowanceAmount)).toBe( + allowanceAmount, + ); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes an erc20-token-allowance permission successfully', () => { + const { + TimestampEnforcer, + ERC20PeriodTransferEnforcer, + ValueLteEnforcer, + NonceEnforcer, + } = contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const tokenAddress = + '0x3333333333333333333333333333333333333333' as Hex; + const allowanceAmount = 123456n; + const startDate = 1715664; + const allowanceTerms = createERC20TokenPeriodTransferTerms( + { + tokenAddress, + periodAmount: allowanceAmount, + periodDuration: UINT256_MAX, + startDate, + }, + { out: 'hex' }, + ); + const valueLteTerms = createValueLteTerms( + { maxValue: 0n }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ERC20PeriodTransferEnforcer, + terms: allowanceTerms, + args: '0x', + } as const, + { + enforcer: ValueLteEnforcer, + terms: valueLteTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('erc20-token-allowance'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.tokenAddress.toLowerCase()).toBe( + tokenAddress.toLowerCase(), + ); + expect(hexToBigInt(result.permission.data.allowanceAmount)).toBe( + allowanceAmount, + ); + expect(result.permission.data.startTime).toBe(startDate); + expect(result.permission.justification).toBe('Test justification'); + }); + + it('decodes a token-approval-revocation permission successfully', () => { + const { TimestampEnforcer, ApprovalRevocationEnforcer, NonceEnforcer } = + contracts; + + const delegator = delegatorAddressA; + const delegate = delegateAddressB; + + const beforeThreshold = 1720000; + const expiryTerms = createTimestampTerms( + { afterThreshold: 0, beforeThreshold }, + { out: 'hex' }, + ); + + const approvalRevocationTerms = createApprovalRevocationTerms( + { + erc20Approve: true, + erc721Approve: false, + erc721SetApprovalForAll: true, + permit2Approve: false, + permit2Lockdown: true, + permit2InvalidateNonces: false, + }, + { out: 'hex' }, + ); + + const caveats = [ + { + enforcer: TimestampEnforcer, + terms: expiryTerms, + args: '0x', + } as const, + { + enforcer: ApprovalRevocationEnforcer, + terms: approvalRevocationTerms, + args: '0x', + } as const, + { enforcer: NonceEnforcer, terms: '0x', args: '0x' } as const, + ]; + + const result = rootMessenger.call( + 'GatorPermissionsController:decodePermissionFromPermissionContextForOrigin', + { + origin: controller.gatorPermissionsProviderSnapId, + chainId, + delegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY as Hex, + caveats, + }, + metadata: buildMetadata('Test justification'), + }, + ); + + expect(result.permission.type).toBe('token-approval-revocation'); + expect(result.expiry).toBe(beforeThreshold); + expect(result.permission.data.erc20Approve).toBe(true); + expect(result.permission.data.erc721Approve).toBe(false); + expect(result.permission.data.erc721SetApprovalForAll).toBe(true); + expect(result.permission.data.permit2Approve).toBe(false); + expect(result.permission.data.permit2Lockdown).toBe(true); + expect(result.permission.data.permit2InvalidateNonces).toBe(false); + expect(result.permission.justification).toBe('Test justification'); + }); + }); }); describe('submitRevocation', () => { diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index 652513bdf97..aaab180f9be 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -4,7 +4,6 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerFindNetworkClientIdByChainIdAction, @@ -27,14 +26,17 @@ import type { } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { DELEGATION_FRAMEWORK_VERSION } from './constants'; import type { DecodedPermission } from './decodePermission'; import { - createPermissionDecodersForContracts, findDecodersWithMatchingCaveatAddresses, reconstructDecodedPermission, selectUniqueDecoderAndDecodedPermission, } from './decodePermission'; +import { createPermissionDecodersForContracts } from './decodePermission/decoders'; +import { + delegationContractsByChainId, + toEnforcerAddressesByName, +} from './decodePermission/enforcerAddresses'; import { GatorPermissionsFetchError, GatorPermissionsProviderError, @@ -84,8 +86,6 @@ const DEFAULT_MAX_SYNC_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in mil */ const PENDING_REVOCATION_TIMEOUT = 2 * 60 * 60 * 1000; -const contractsByChainId = DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]; - // === CONFIG === /** @@ -403,7 +403,6 @@ export class GatorPermissionsController extends BaseController< ): Promise { return updateGrantedPermissionsStatus(grantedPermissions, { getProviderForChainId: (chainId) => this.#getProviderForChainId(chainId), - contractsByChainId, }); } @@ -584,12 +583,14 @@ export class GatorPermissionsController extends BaseController< throw new OriginNotAllowedError({ origin }); } - const contracts = contractsByChainId[chainId]; + const deploymentContracts = delegationContractsByChainId[chainId]; - if (!contracts) { + if (!deploymentContracts) { throw new Error(`Contracts not found for chainId: ${chainId}`); } + const contracts = toEnforcerAddressesByName(deploymentContracts); + try { const enforcers = caveats.map((caveat) => caveat.enforcer); const permissionDecoders = diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index 40762254890..c0a1eecb54e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts @@ -1,716 +1,147 @@ -import { ROOT_AUTHORITY } from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; -import { numberToHex } from '@metamask/utils'; +import type { Caveat, Hex } from '@metamask/delegation-core'; import { findDecodersWithMatchingCaveatAddresses, reconstructDecodedPermission, selectUniqueDecoderAndDecodedPermission, } from './decodePermission'; -import { createPermissionDecodersForContracts } from './decoders'; -import type { - DecodedPermission, - DeployedContractsByName, - PermissionDecoder, -} from './types'; -import { getChecksumEnforcersByChainId } from './utils'; - -// These tests use the live deployments table for version 1.3.0 to -// construct deterministic caveat address sets for a known chain. +import type { PermissionDecoder, PermissionType } from './types'; describe('decodePermission', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - - const { - ExactCalldataEnforcer, - TimestampEnforcer, - ValueLteEnforcer, - AllowedCalldataEnforcer, - ERC20StreamingEnforcer, - ERC20PeriodTransferEnforcer, - NativeTokenStreamingEnforcer, - NativeTokenPeriodTransferEnforcer, - NonceEnforcer, - RedeemerEnforcer, - } = contracts; - const { approvalRevocationEnforcer } = - getChecksumEnforcersByChainId(contracts); - - describe('findDecodersWithMatchingCaveatAddresses()', () => { - const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; - - it('returns all matching rules from findDecodersWithMatchingCaveatAddresses', () => { - const enforcers = [ExactCalldataEnforcer, NonceEnforcer, zeroAddress]; - const contractsWithDuplicates = { - ...contracts, - NativeTokenStreamingEnforcer: zeroAddress, - NativeTokenPeriodTransferEnforcer: zeroAddress, - } as unknown as DeployedContractsByName; - + describe('findDecodersWithMatchingCaveatAddresses', () => { + it('returns all decoders that match the given enforcers', () => { + const matchingDecoder1 = { + permissionType: 'matching-permission-1', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const matchingDecoder2 = { + permissionType: 'matching-permission-2', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const nonMatchingDecoder = { + permissionType: 'non-matching-permission', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const decoders = [ + matchingDecoder1, + matchingDecoder2, + nonMatchingDecoder, + ] as unknown as PermissionDecoder[]; const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts( - contractsWithDuplicates, - ), - }); - - expect(rules).toHaveLength(3); - expect( - rules.map((matchingRule) => matchingRule.permissionType).sort(), - ).toStrictEqual( - [ - 'native-token-periodic', - 'native-token-stream', - 'native-token-allowance', - ].sort(), - ); - }); - - describe('native-token-stream', () => { - const expectedPermissionType = 'native-token-stream'; - - it('matches with required caveats', () => { - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('allows TimestampEnforcer as extra', () => { - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - TimestampEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('allows RedeemerEnforcer as extra', () => { - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - RedeemerEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('allows TimestampEnforcer and RedeemerEnforcer as extras', () => { - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - TimestampEnforcer, - RedeemerEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('rejects forbidden extra caveat', () => { - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - // Not allowed for native-token-stream - ValueLteEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('rejects when required caveats are missing', () => { - const enforcers = [ExactCalldataEnforcer]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('accepts lowercased addresses', () => { - const enforcers: Hex[] = [ - NativeTokenStreamingEnforcer.toLowerCase() as unknown as Hex, - ExactCalldataEnforcer.toLowerCase() as unknown as Hex, - NonceEnforcer.toLowerCase() as unknown as Hex, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual(['native-token-stream']); - }); - - it('throws if a contract is not found', () => { - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - ]; - const contractsWithoutTimestampEnforcer = { - ...contracts, - TimestampEnforcer: undefined, - } as unknown as DeployedContractsByName; - - expect(() => - findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts( - contractsWithoutTimestampEnforcer, - ), - }), - ).toThrow('Contract not found: TimestampEnforcer'); - }); - }); - - describe('native-token-periodic', () => { - const expectedPermissionType = 'native-token-periodic'; - it('matches with required caveats alongside native-token-allowance', () => { - const enforcers = [ - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType).sort(), - ).toStrictEqual( - [expectedPermissionType, 'native-token-allowance'].sort(), - ); - }); - - it('allows TimestampEnforcer as extra', () => { - const enforcers = [ - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - TimestampEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType).sort(), - ).toStrictEqual( - [expectedPermissionType, 'native-token-allowance'].sort(), - ); - }); - - it('rejects forbidden extra caveat', () => { - const enforcers = [ - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - // Not allowed for native-token-periodic - ValueLteEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('rejects when required caveats are missing', () => { - const enforcers = [ExactCalldataEnforcer]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('accepts lowercased addresses', () => { - const enforcers: Hex[] = [ - NativeTokenPeriodTransferEnforcer.toLowerCase() as unknown as Hex, - ExactCalldataEnforcer.toLowerCase() as unknown as Hex, - NonceEnforcer.toLowerCase() as unknown as Hex, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType).sort(), - ).toStrictEqual( - [expectedPermissionType, 'native-token-allowance'].sort(), - ); + enforcers: [], + permissionDecoders: decoders, }); - it('throws if a contract is not found', () => { - const enforcers = [ - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - ]; - const contractsWithoutTimestampEnforcer = { - ...contracts, - TimestampEnforcer: undefined, - } as unknown as DeployedContractsByName; - - expect(() => - findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts( - contractsWithoutTimestampEnforcer, - ), - }), - ).toThrow('Contract not found: TimestampEnforcer'); - }); + expect(rules).toStrictEqual([matchingDecoder1, matchingDecoder2]); }); - describe('erc20-token-stream', () => { - const expectedPermissionType = 'erc20-token-stream'; - it('matches with required caveats', () => { - const enforcers = [ - ERC20StreamingEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('allows TimestampEnforcer as extra', () => { - const enforcers = [ - ERC20StreamingEnforcer, - ValueLteEnforcer, - NonceEnforcer, - TimestampEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('rejects forbidden extra caveat', () => { - const enforcers = [ - ERC20StreamingEnforcer, - ValueLteEnforcer, - NonceEnforcer, - // Not allowed for erc20-token-stream - ExactCalldataEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('rejects when required caveats are missing', () => { - const enforcers = [ERC20StreamingEnforcer]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('accepts lowercased addresses', () => { - const enforcers: Hex[] = [ - ERC20StreamingEnforcer.toLowerCase() as unknown as Hex, - ValueLteEnforcer.toLowerCase() as unknown as Hex, - NonceEnforcer.toLowerCase() as unknown as Hex, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('throws if a contract is not found', () => { - const enforcers = [ - ERC20StreamingEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const contractsWithoutTimestampEnforcer = { - ...contracts, - TimestampEnforcer: undefined, - } as unknown as DeployedContractsByName; - - expect(() => - findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts( - contractsWithoutTimestampEnforcer, - ), - }), - ).toThrow('Contract not found: TimestampEnforcer'); - }); - }); - - describe('erc20-token-periodic', () => { - const expectedPermissionType = 'erc20-token-periodic'; - it('matches with required caveats alongside erc20-token-allowance', () => { - const enforcers = [ - ERC20PeriodTransferEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType).sort(), - ).toStrictEqual( - [expectedPermissionType, 'erc20-token-allowance'].sort(), - ); - }); - - it('allows TimestampEnforcer as extra', () => { - const enforcers = [ - ERC20PeriodTransferEnforcer, - ValueLteEnforcer, - NonceEnforcer, - TimestampEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType).sort(), - ).toStrictEqual( - [expectedPermissionType, 'erc20-token-allowance'].sort(), - ); - }); - - it('rejects forbidden extra caveat', () => { - const enforcers = [ - ERC20PeriodTransferEnforcer, - ValueLteEnforcer, - NonceEnforcer, - // Not allowed for erc20-token-periodic - ExactCalldataEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('rejects when required caveats are missing', () => { - const enforcers = [ERC20PeriodTransferEnforcer]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('accepts lowercased addresses', () => { - const enforcers: Hex[] = [ - ERC20PeriodTransferEnforcer.toLowerCase() as unknown as Hex, - ValueLteEnforcer.toLowerCase() as unknown as Hex, - NonceEnforcer.toLowerCase() as unknown as Hex, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType).sort(), - ).toStrictEqual( - [expectedPermissionType, 'erc20-token-allowance'].sort(), - ); + it('returns an empty array if no decoders match the given enforcers', () => { + const nonMatchingDecoder1 = { + permissionType: 'non-matching-permission-1', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const nonMatchingDecoder2 = { + permissionType: 'non-matching-permission-2', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const nonMatchingDecoder3 = { + permissionType: 'non-matching-permission-3', + caveatAddressesMatch: jest.fn().mockReturnValue(false), + }; + const decoders = [ + nonMatchingDecoder1, + nonMatchingDecoder2, + nonMatchingDecoder3, + ] as unknown as PermissionDecoder[]; + const rules = findDecodersWithMatchingCaveatAddresses({ + enforcers: [], + permissionDecoders: decoders, }); - it('throws if a contract is not found', () => { - const enforcers = [ - ERC20PeriodTransferEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const contractsWithoutTimestampEnforcer = { - ...contracts, - TimestampEnforcer: undefined, - } as unknown as DeployedContractsByName; - - expect(() => - findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts( - contractsWithoutTimestampEnforcer, - ), - }), - ).toThrow('Contract not found: TimestampEnforcer'); - }); + expect(rules).toStrictEqual([]); }); - describe('erc20-token-revocation', () => { - const expectedPermissionType = 'erc20-token-revocation'; - - it('matches with two AllowedCalldataEnforcer and ValueLteEnforcer and NonceEnforcer', () => { - const enforcers = [ - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('allows TimestampEnforcer as extra', () => { - const enforcers = [ - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - TimestampEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('rejects when only one AllowedCalldataEnforcer is provided', () => { - const enforcers = [ - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('rejects when three AllowedCalldataEnforcer are provided', () => { - const enforcers = [ - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('rejects when ValueLteEnforcer is missing', () => { - const enforcers = [ - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('rejects forbidden extra caveat', () => { - const enforcers = [ - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - // Not allowed for erc20-token-revocation - ExactCalldataEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); - - it('accepts lowercased addresses', () => { - const enforcers: Hex[] = [ - AllowedCalldataEnforcer.toLowerCase() as unknown as Hex, - AllowedCalldataEnforcer.toLowerCase() as unknown as Hex, - ValueLteEnforcer.toLowerCase() as unknown as Hex, - NonceEnforcer.toLowerCase() as unknown as Hex, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('throws if a contract is not found', () => { - const enforcers = [ - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const contractsWithoutAllowedCalldataEnforcer = { - ...contracts, - AllowedCalldataEnforcer: undefined, - } as unknown as DeployedContractsByName; - - expect(() => - findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts( - contractsWithoutAllowedCalldataEnforcer, - ), - }), - ).toThrow('Contract not found: AllowedCalldataEnforcer'); + it('returns an empty array if no decoders are provided', () => { + const rules = findDecodersWithMatchingCaveatAddresses({ + enforcers: [], + permissionDecoders: [], }); + expect(rules).toStrictEqual([]); }); - describe('token-approval-revocation', () => { - const expectedPermissionType = 'token-approval-revocation'; - const findMatchingDecoders = (enforcers: Hex[]): PermissionDecoder[] => - findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - - it('matches with ApprovalRevocationEnforcer and NonceEnforcer', () => { - const enforcers = [approvalRevocationEnforcer, NonceEnforcer]; - const rules = findMatchingDecoders(enforcers); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('allows TimestampEnforcer as extra', () => { - const enforcers = [ - approvalRevocationEnforcer, - NonceEnforcer, - TimestampEnforcer, - ]; - const rules = findMatchingDecoders(enforcers); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); - - it('rejects when NonceEnforcer is missing', () => { - const enforcers = [approvalRevocationEnforcer]; - const rules = findMatchingDecoders(enforcers); - expect(rules).toStrictEqual([]); - }); - - it('rejects forbidden extra caveat', () => { - const enforcers = [ - approvalRevocationEnforcer, - NonceEnforcer, - ValueLteEnforcer, - ]; - const rules = findMatchingDecoders(enforcers); - expect(rules).toStrictEqual([]); + it('calls caveatAddressesMatch with the given enforcers', () => { + const matchingDecoder1 = { + permissionType: 'matching-permission-1', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const matchingDecoder2 = { + permissionType: 'matching-permission-2', + caveatAddressesMatch: jest.fn().mockReturnValue(true), + }; + const enforcers: Hex[] = ['0x0000000000000000000000000000000000000000']; + + findDecodersWithMatchingCaveatAddresses({ + enforcers, + permissionDecoders: [ + matchingDecoder1, + matchingDecoder2, + ] as unknown as PermissionDecoder[], }); - it('accepts lowercased addresses', () => { - const enforcers: Hex[] = [ - approvalRevocationEnforcer.toLowerCase() as unknown as Hex, - NonceEnforcer.toLowerCase() as unknown as Hex, - ]; - const rules = findMatchingDecoders(enforcers); - expect( - rules.map((matchingRule) => matchingRule.permissionType), - ).toStrictEqual([expectedPermissionType]); - }); + expect(matchingDecoder1.caveatAddressesMatch).toHaveBeenCalledWith( + enforcers, + ); + expect(matchingDecoder2.caveatAddressesMatch).toHaveBeenCalledWith( + enforcers, + ); }); }); describe('reconstructDecodedPermission', () => { - const delegator = '0x1111111111111111111111111111111111111111' as Hex; - const delegate = '0x2222222222222222222222222222222222222222' as Hex; + const chainId = 1; + const delegator = '0x1111111111111111111111111111111111111111' as const; + const delegate = '0x2222222222222222222222222222222222222222' as const; const specifiedOrigin = 'https://dapp.example'; const justification = 'Test justification'; + const permissionType = 'selected-permission-type' as PermissionType; + const data = { + value: 1, + }; + const authory = + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as const; + + it('throws if the authority is not ROOT_AUTHORITY', () => { + const invalidAuthority = + '0x0000000000000000000000000000000000000000' as const; + expect(() => + reconstructDecodedPermission({ + chainId, + permissionType, + delegator, + delegate, + authority: invalidAuthority, + expiry: null, + data, + justification, + specifiedOrigin, + }), + ).toThrow('Invalid authority'); + }); - it('constructs DecodedPermission with expiry', () => { - const permissionType = 'native-token-stream' as const; - const data: DecodedPermission['permission']['data'] = { - initialAmount: '0x01', - maxAmount: '0x02', - amountPerSecond: '0x03', - startTime: 1715664, - } as const; - const expiry = 1720000; - + it('constructs a DecodedPermission with the specified values', () => { const result = reconstructDecodedPermission({ chainId, permissionType, delegator, delegate, - authority: ROOT_AUTHORITY, - expiry, + authority: authory, + expiry: null, data, justification, specifiedOrigin, }); - expect(result.chainId).toBe(numberToHex(chainId)); + expect(result.chainId).toBe('0x1'); expect(result.from).toBe(delegator); expect(result.to).toStrictEqual(delegate); expect(result.permission).toStrictEqual({ @@ -718,23 +149,17 @@ describe('decodePermission', () => { data, justification, }); - expect(result.expiry).toBe(expiry); expect(result.origin).toBe(specifiedOrigin); + + expect(result.rules).toBeUndefined(); }); - it('includes rules when provided', () => { - const permissionType = 'native-token-stream' as const; - const data: DecodedPermission['permission']['data'] = { - initialAmount: '0x01', - maxAmount: '0x02', - amountPerSecond: '0x03', - startTime: 1715664, - } as const; + it('constructs a DecodedPermission with specified rules', () => { const rules = [ { - type: 'redeemer' as const, + type: 'mock-rule', data: { - addresses: ['0x1111111111111111111111111111111111111111' as Hex], + value: 1, }, }, ]; @@ -744,7 +169,7 @@ describe('decodePermission', () => { permissionType, delegator, delegate, - authority: ROOT_AUTHORITY, + authority: authory, expiry: null, data, justification, @@ -754,331 +179,298 @@ describe('decodePermission', () => { expect(result.rules).toStrictEqual(rules); }); + }); - it('constructs DecodedPermission with null expiry', () => { - const permissionType = 'erc20-token-periodic' as const; - const data: DecodedPermission['permission']['data'] = { - tokenAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - periodAmount: '0x2a', - periodDuration: 3600, - startTime: 1715666, - } as const; + describe('selectUniqueDecoderAndDecodedPermission', () => { + const caveats = [ + { + enforcer: '0x0000000000000000000000000000000000000001', + terms: '0x0000000000000000000000000000000000000000', + args: '0x', + }, + ] as Caveat[]; + + const data = { + value: 1, + }; + + it('returns the successful decoder and decoded permission when exactly one decoder matches', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; + + const mismatchingDecoder = { + permissionType: 'mismatching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + }), + }; - const result = reconstructDecodedPermission({ - chainId, - permissionType, - delegator, - delegate, - authority: ROOT_AUTHORITY, - expiry: null, - data, - justification, - specifiedOrigin, + const result = selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, mismatchingDecoder], + caveats, }); - expect(result.chainId).toBe(numberToHex(chainId)); + expect(result.decoder).toBe(matchingDecoder); + expect(result.rules).toBeUndefined(); + expect(result.data).toBe(data); expect(result.expiry).toBeNull(); - expect(result.permission.type).toBe(permissionType); - expect(result.permission.data).toStrictEqual(data); }); - it('throws on invalid authority', () => { - const permissionType = 'native-token-stream' as const; - const data: DecodedPermission['permission']['data'] = { - initialAmount: '0x01', - maxAmount: '0x02', - amountPerSecond: '0x03', - startTime: 1715664, - } as const; - - expect(() => - reconstructDecodedPermission({ - chainId, - permissionType, - delegator, - delegate, - authority: '0x0000000000000000000000000000000000000000' as Hex, - expiry: 1720000, - data, - justification, - specifiedOrigin, + it('throws an error if no decoder matches', () => { + const mismatchingDecoder = { + permissionType: 'mismatching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('Failed to validate and decode permission'), }), - ).toThrow('Invalid authority'); - }); - }); + }; - describe('selectUniqueDecoderAndDecodedPermission', () => { - const emptyCaveats: Parameters< - PermissionDecoder['validateAndDecodePermission'] - >[0] = []; - - const dummyDecoderFields = { - requiredEnforcers: new Map(), - optionalEnforcers: new Set(), - caveatAddressesMatch: () => true, - } as const; + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [mismatchingDecoder], + caveats, + }); + }).toThrow('Failed to validate and decode permission'); + }); - it('returns the unique rule when exactly one candidate validates', () => { - const data = { - initialAmount: '0x1', - maxAmount: '0x2', - amountPerSecond: '0x3', - startTime: 1, - } as DecodedPermission['permission']['data']; + it('throws an error if the decoder throws an error', () => { + const throwingDecoder = { + permissionType: 'throwing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockImplementation(() => { + throw new Error('Failed to validate and decode permission'); + }), + }; - const decoders: PermissionDecoder[] = [ - { - ...dummyDecoderFields, - permissionType: 'native-token-stream', - validateAndDecodePermission: () => ({ - isValid: true, - expiry: 9, - data, - }), - }, - { - ...dummyDecoderFields, - permissionType: 'native-token-periodic', - validateAndDecodePermission: () => ({ - isValid: false, - error: new Error('bad terms for periodic'), - }), - }, - ]; + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [throwingDecoder], + caveats, + }); + }).toThrow('Failed to validate and decode permission'); + }); - const result = selectUniqueDecoderAndDecodedPermission({ - candidateDecoders: decoders, - caveats: emptyCaveats, - }); + it('throws an error if multiple decoders match', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; - expect(result.decoder.permissionType).toBe('native-token-stream'); - expect(result.expiry).toBe(9); - expect(result.data).toStrictEqual(data); + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, matchingDecoder], + caveats, + }); + }).toThrow( + 'Multiple permission types validate the same delegation caveats: matching-permission-type, matching-permission-type', + ); }); - it('throws when no candidate rules are provided', () => { - expect(() => + it('throws an error when candidate decoders are empty', () => { + expect(() => { selectUniqueDecoderAndDecodedPermission({ candidateDecoders: [], - caveats: emptyCaveats, - }), - ).toThrow('Unable to identify permission type'); + caveats, + }); + }).toThrow('Unable to identify permission type'); }); - it('rethrows the validation error when only one candidate exists and it fails', () => { - const originalError = new Error('stream validation failed'); - const decoders: PermissionDecoder[] = [ - { - ...dummyDecoderFields, - permissionType: 'native-token-stream', - validateAndDecodePermission: () => ({ - isValid: false, - error: originalError, - }), - }, - ]; + it('throws an aggregated error when multiple decoders fail validation', () => { + const firstFailingDecoder = { + permissionType: 'first-failing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('First decoder failed'), + }), + }; + const secondFailingDecoder = { + permissionType: 'second-failing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('Second decoder failed'), + }), + }; - expect(() => + expect(() => { selectUniqueDecoderAndDecodedPermission({ - candidateDecoders: decoders, - caveats: emptyCaveats, - }), - ).toThrow(originalError); + candidateDecoders: [firstFailingDecoder, secondFailingDecoder], + caveats, + }); + }).toThrow( + 'No permission type could validate the delegation caveats. Attempts: first-failing-permission-type: First decoder failed; second-failing-permission-type: Second decoder failed', + ); }); - it('throws when more than one candidate validates', () => { - const data = { - initialAmount: '0x1', - maxAmount: '0x2', - amountPerSecond: '0x3', - startTime: 1, - } as DecodedPermission['permission']['data']; + it('passes caveats to validateAndDecodePermission for each candidate decoder', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; + const mismatchingDecoder = { + permissionType: 'mismatching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: false, + error: new Error('Failed to validate and decode permission'), + }), + }; - const decoders: PermissionDecoder[] = [ - { - ...dummyDecoderFields, - permissionType: 'native-token-stream', - validateAndDecodePermission: () => ({ - isValid: true, - expiry: 1, - data, - }), - }, - { - ...dummyDecoderFields, - permissionType: 'native-token-periodic', - validateAndDecodePermission: () => ({ - isValid: true, - expiry: 1, - data, - }), - }, - ]; + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, mismatchingDecoder], + caveats, + }); - expect(() => - selectUniqueDecoderAndDecodedPermission({ - candidateDecoders: decoders, - caveats: emptyCaveats, - }), - ).toThrow( - 'Multiple permission types validate the same delegation caveats: native-token-stream, native-token-periodic', + expect(matchingDecoder.validateAndDecodePermission).toHaveBeenCalledWith( + caveats, ); + expect( + mismatchingDecoder.validateAndDecodePermission, + ).toHaveBeenCalledWith(caveats); }); - it('throws with attempt details when no candidate validates', () => { - const decoders: PermissionDecoder[] = [ - { - ...dummyDecoderFields, - permissionType: 'native-token-stream', - validateAndDecodePermission: () => ({ - isValid: false, - error: new Error('stream failed'), - }), - }, + it('returns rules when the selected decoder includes decoded rules', () => { + const rules = [ { - ...dummyDecoderFields, - permissionType: 'native-token-periodic', - validateAndDecodePermission: () => ({ - isValid: false, - error: new Error('periodic failed'), - }), + type: 'mock-rule', + data: { value: 1 }, }, ]; - - expect(() => - selectUniqueDecoderAndDecodedPermission({ - candidateDecoders: decoders, - caveats: emptyCaveats, + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + rules, }), - ).toThrow( - 'No permission type could validate the delegation caveats. Attempts: native-token-stream: stream failed; native-token-periodic: periodic failed', - ); - }); - }); - - describe('adversarial: attempts to violate decoder expectations', () => { - describe('getPermissionDecoderMatchingCaveatTypes()', () => { - it('rejects empty enforcer list', () => { - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers: [], - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); + }; - it('rejects enforcer list with only unknown/forbidden addresses', () => { - const unknown = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers: [unknown], - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); + const result = selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder], + caveats, }); - it('rejects when required enforcer count is exceeded (e.g. duplicate NonceEnforcer)', () => { - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); + expect(result.rules).toStrictEqual(rules); + }); - it('rejects mix of valid known enforcers and valid but unknown enforcer address', () => { - const unknownEnforcer = - '0xbadbadbadbadbadbadbadbadbadbadbadbadbadb' as Hex; - const enforcers = [ - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - NonceEnforcer, - unknownEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); + it('returns a non-null expiry when provided by the selected decoder', () => { + const expiry = 1735689600; + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry, + data, + }), + }; - it('rejects exactly one AllowedCalldataEnforcer for erc20-token-revocation (wrong multiplicity)', () => { - const enforcers = [ - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); + const result = selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder], + caveats, }); - it('rejects three AllowedCalldataEnforcer for erc20-token-revocation (excess multiplicity)', () => { - const enforcers = [ - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - NonceEnforcer, - ]; - const rules = findDecodersWithMatchingCaveatAddresses({ - enforcers, - permissionDecoders: createPermissionDecodersForContracts(contracts), - }); - expect(rules).toStrictEqual([]); - }); + expect(result.expiry).toBe(expiry); }); - describe('reconstructDecodedPermission()', () => { - const delegator = '0x1111111111111111111111111111111111111111' as Hex; - const delegate = '0x2222222222222222222222222222222222222222' as Hex; - const data: DecodedPermission['permission']['data'] = { - initialAmount: '0x01', - maxAmount: '0x02', - amountPerSecond: '0x03', - startTime: 1715664, - } as const; - - it('rejects authority that is not ROOT_AUTHORITY (one byte different)', () => { - const wrongAuthority = - '0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe' as Hex; - expect(() => - reconstructDecodedPermission({ - chainId, - permissionType: 'native-token-stream', - delegator, - delegate, - authority: wrongAuthority, - expiry: 1720000, - data, - justification: 'test', - specifiedOrigin: 'https://example.com', - }), - ).toThrow('Invalid authority'); - }); + it('throws if any candidate decoder throws, even when another candidate validates', () => { + const matchingDecoder = { + permissionType: 'matching-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockReturnValue({ + isValid: true, + expiry: null, + data, + }), + }; + const throwingDecoder = { + permissionType: 'throwing-permission-type' as PermissionType, + requiredEnforcers: new Map([[caveats[0].enforcer, 1]]), + optionalEnforcers: new Set([ + '0x0000000000000000000000000000000000000000' as Hex, + ]), + caveatAddressesMatch: jest.fn(), + validateAndDecodePermission: jest.fn().mockImplementation(() => { + throw new Error('Failed to validate and decode permission'); + }), + }; - it('rejects authority that looks like ROOT_AUTHORITY but with wrong length', () => { - const wrongAuthority = - '0xffffffffffffffffffffffffffffffffffffffff' as Hex; - expect(() => - reconstructDecodedPermission({ - chainId, - permissionType: 'native-token-stream', - delegator, - delegate, - authority: wrongAuthority, - expiry: 1720000, - data, - justification: 'test', - specifiedOrigin: 'https://example.com', - }), - ).toThrow('Invalid authority'); - }); + expect(() => { + selectUniqueDecoderAndDecodedPermission({ + candidateDecoders: [matchingDecoder, throwingDecoder], + caveats, + }); + }).toThrow('Failed to validate and decode permission'); }); }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20PayeeRule.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20PayeeRule.test.ts deleted file mode 100644 index 9d0acec9120..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20PayeeRule.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createAllowedCalldataTerms } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; -import { getChecksumAddress } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; - -import type { ChecksumCaveat } from '../types'; -import { getChecksumEnforcersByChainId } from '../utils'; -import { erc20PayeeRule } from './erc20PayeeRule'; - -describe('erc20PayeeRule', () => { - const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; - const contractAddresses = getChecksumEnforcersByChainId(contracts); - const { allowedCalldataEnforcer, nonceEnforcer } = contractAddresses; - const requiredEnforcers = new Map([[nonceEnforcer, 1]]); - - const PAYEE_ADDRESS: Hex = '0x3333333333333333333333333333333333333333'; - const CHECKSUM_PAYEE_INPUT: Hex = - '0x8617e340b3d01fa5f11f306f4090fd50e238070d'; - const paddedPayee: Hex = `0x${PAYEE_ADDRESS.slice(2).padStart(64, '0')}`; - - const validPayeeCaveat: ChecksumCaveat = { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: paddedPayee, - }), - args: '0x' as Hex, - }; - - it('returns null when no AllowedCalldataEnforcer caveat is present', () => { - const caveats: ChecksumCaveat[] = [ - { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, - ]; - - expect( - erc20PayeeRule({ contractAddresses, caveats, requiredEnforcers }), - ).toBeNull(); - }); - - it('returns a payee rule with the decoded checksummed address', () => { - expect( - erc20PayeeRule({ - contractAddresses, - caveats: [validPayeeCaveat], - requiredEnforcers, - }), - ).toStrictEqual({ - type: 'payee', - data: { addresses: [getChecksumAddress(PAYEE_ADDRESS)] }, - }); - }); - - it('returns a checksummed payee address when encoded address is lowercase', () => { - const paddedLowercasePayee: Hex = `0x${CHECKSUM_PAYEE_INPUT.slice(2).padStart(64, '0')}`; - const caveat: ChecksumCaveat = { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: paddedLowercasePayee, - }), - args: '0x' as Hex, - }; - - expect( - erc20PayeeRule({ - contractAddresses, - caveats: [caveat], - requiredEnforcers, - }), - ).toStrictEqual({ - type: 'payee', - data: { addresses: [getChecksumAddress(CHECKSUM_PAYEE_INPUT)] }, - }); - }); - - it('throws when allowedCalldataEnforcer is configured as required', () => { - const requiredWithPayee = new Map([ - [nonceEnforcer, 1], - [allowedCalldataEnforcer, 1], - ]); - - expect(() => - erc20PayeeRule({ - contractAddresses, - caveats: [validPayeeCaveat], - requiredEnforcers: requiredWithPayee, - }), - ).toThrow( - 'Invalid payee caveats: payee enforcer may not be a required caveat', - ); - }); - - it('throws when more than one AllowedCalldataEnforcer caveat is present', () => { - expect(() => - erc20PayeeRule({ - contractAddresses, - caveats: [validPayeeCaveat, validPayeeCaveat], - requiredEnforcers, - }), - ).toThrow( - 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', - ); - }); - - it('throws when startIndex is not 4', () => { - const caveat: ChecksumCaveat = { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 0, - value: paddedPayee, - }), - args: '0x' as Hex, - }; - - expect(() => - erc20PayeeRule({ - contractAddresses, - caveats: [caveat], - requiredEnforcers, - }), - ).toThrow( - 'Invalid payee caveat: AllowedCalldataEnforcer startIndex must be 4', - ); - }); - - it('throws when the encoded value is not 32 bytes long', () => { - const shortValue: Hex = `0x${PAYEE_ADDRESS.slice(2)}`; - const caveat: ChecksumCaveat = { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: shortValue, - }), - args: '0x' as Hex, - }; - - expect(() => - erc20PayeeRule({ - contractAddresses, - caveats: [caveat], - requiredEnforcers, - }), - ).toThrow( - 'Invalid payee caveat: AllowedCalldataEnforcer value must be 32 bytes long', - ); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20PayeeRule.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20PayeeRule.ts deleted file mode 100644 index 6e1f24a6226..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20PayeeRule.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { decodeAllowedCalldataTerms } from '@metamask/delegation-core'; -import { getChecksumAddress } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; - -import { EXECUTION_PERMISSION_PAYEE_RULE_TYPE } from '../../constants'; -import type { RuleDecoder } from '../types'; -import { getByteLength } from '../utils'; - -const ERC20_TRANSFER_PAYEE_START_INDEX = 4; -const ERC20_PAYEE_VALUE_BYTE_LENGTH = 32; - -/** - * Rule decoder for ERC-20 style payees, where a single payee address is - * encoded inside an AllowedCalldataEnforcer caveat constraining the recipient - * argument of an ERC-20 `transfer` call. - * - * Use this decoder for ERC-20 token permissions. For native-token permissions, - * use {@link nativePayeeRule} instead. - * - * @param args - The arguments to this function. - * @param args.contractAddresses - Checksummed enforcer addresses for the chain. - * @param args.caveats - Checksummed caveats from the delegation. - * @param args.requiredEnforcers - Required enforcer counts for the permission. - * @returns A `Rule` result containing the payee address when an - * AllowedCalldataEnforcer caveat exists, otherwise `null`. - * @throws If the AllowedCalldataEnforcer is also a required enforcer (the - * payee enforcer must not be configured as required), if multiple matching - * caveats are present, or if the encoded calldata constraint does not match - * the expected ERC-20 transfer payee shape. - */ -export const erc20PayeeRule: RuleDecoder = ({ - contractAddresses, - caveats, - requiredEnforcers, -}) => { - const { allowedCalldataEnforcer } = contractAddresses; - - if (requiredEnforcers.has(allowedCalldataEnforcer)) { - throw new Error( - 'Invalid payee caveats: payee enforcer may not be a required caveat', - ); - } - - const matchingCaveats = caveats.filter( - (caveat) => caveat.enforcer === allowedCalldataEnforcer, - ); - - if (matchingCaveats.length === 0) { - return null; - } - - if (matchingCaveats.length > 1) { - throw new Error( - 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', - ); - } - - const [caveat] = matchingCaveats; - const decoded = decodeAllowedCalldataTerms(caveat.terms); - - if (decoded.startIndex !== ERC20_TRANSFER_PAYEE_START_INDEX) { - throw new Error( - `Invalid payee caveat: AllowedCalldataEnforcer startIndex must be ${ERC20_TRANSFER_PAYEE_START_INDEX}`, - ); - } - - if (getByteLength(decoded.value) !== ERC20_PAYEE_VALUE_BYTE_LENGTH) { - throw new Error( - `Invalid payee caveat: AllowedCalldataEnforcer value must be ${ERC20_PAYEE_VALUE_BYTE_LENGTH} bytes long`, - ); - } - - const address: Hex = `0x${decoded.value.slice(-40)}`; - - return { - type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, - data: { addresses: [getChecksumAddress(address)] }, - }; -}; diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenAllowance.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenAllowance.test.ts deleted file mode 100644 index c09e8caa4f3..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenAllowance.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '.'; -import { ZERO_32_BYTES } from '../utils'; - -describe('erc20-token-allowance decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = - contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-allowance', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const valueLteCaveat = { - enforcer: ValueLteEnforcer, - terms: ZERO_32_BYTES, - args: '0x' as const, - }; - - const TOKEN_ADDRESS_HEX = 'aa'.repeat(20); - const TOKEN_ADDRESS: Hex = `0x${TOKEN_ADDRESS_HEX}`; - const ALLOWANCE_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); - const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); - const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); - const ALLOWANCE_TERMS = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; - - it('rejects duplicate ERC20PeriodTransferEnforcer caveats', () => { - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('rejects truncated terms', () => { - const truncatedTerms: Hex = `0x${'00'.repeat(40)}`; // 40 bytes, need 116 - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: truncatedTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: expected 116 bytes', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: expected 116 bytes', - ); - }); - - it('rejects when ValueLteEnforcer terms are not zero', () => { - const nonZeroValueLte: Hex = `0x${'0'.repeat(62)}01` as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ValueLteEnforcer, - terms: nonZeroValueLte, - args: '0x' as const, - }, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - `Invalid value-lte terms: must be ${ZERO_32_BYTES}`, - ); - }); - - it('successfully decodes valid erc20-token-allowance caveats', () => { - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({ - tokenAddress: TOKEN_ADDRESS, - allowanceAmount: `0x${ALLOWANCE_AMOUNT_HEX}`, - startTime: 1715664, - }); - }); - - it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { - const uppercaseMax = 'F'.repeat(64); - const terms = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - }); - - it('rejects when periodDuration is not UINT256_MAX', () => { - const nonMaxDuration = (86400).toString(16).padStart(64, '0'); - const terms = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', - ); - }); - - it('rejects when startTime is 0', () => { - const startTimeZero = '0'.repeat(64); - const terms = - `0x${TOKEN_ADDRESS_HEX}${ALLOWANCE_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: startTime must be a positive number', - ); - }); - - it('rejects when allowanceAmount is 0', () => { - const allowanceZero = '0'.repeat(64); - const terms = - `0x${TOKEN_ADDRESS_HEX}${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', - ); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenAllowance.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenAllowance.ts deleted file mode 100644 index 26e342cb676..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenAllowance.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { hexToNumber } from '@metamask/utils'; - -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { - getByteLength, - getTermsByEnforcer, - splitHex, - UINT256_MAX, - ZERO_32_BYTES, -} from '../utils'; -import { erc20PayeeRule } from './erc20PayeeRule'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; -import { redeemerRule } from './redeemerRule'; - -/** - * Builds the configuration for the erc20-token-allowance permission decoder. - * - * This permission shares the same enforcer set as `erc20-token-periodic` but - * is distinguished by a `periodDuration` of `UINT256_MAX`, which effectively - * disables the periodic reset and turns the caveat into a one-off allowance. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The erc20-token-allowance permission decoder configuration. - */ -export function makeErc20TokenAllowanceDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - erc20PeriodicEnforcer, - valueLteEnforcer, - nonceEnforcer, - allowedCalldataEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'erc20-token-allowance', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - allowedCalldataEnforcer, // payee rule - ], - requiredEnforcers: { - [erc20PeriodicEnforcer]: 1, - [valueLteEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRule, erc20PayeeRule], - validateAndDecodeData, - }; -} - -/** - * Decodes erc20-token-allowance permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Decoded allowance terms. - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; - - const valueLteTerms = getTermsByEnforcer({ - caveats, - enforcer: valueLteEnforcer, - }); - if (valueLteTerms !== ZERO_32_BYTES) { - throw new Error(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); - } - - const terms = getTermsByEnforcer({ - caveats, - enforcer: erc20PeriodicEnforcer, - }); - - const EXPECTED_TERMS_BYTELENGTH = 116; // 20 + 32 + 32 + 32 - - if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { - throw new Error('Invalid erc20-token-allowance terms: expected 116 bytes'); - } - - const [tokenAddress, allowanceAmount, periodDurationRaw, startTimeRaw] = - splitHex(terms, [20, 32, 32, 32]); - - if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { - throw new Error( - 'Invalid erc20-token-allowance terms: periodDuration must be UINT256_MAX', - ); - } - - const startTime = hexToNumber(startTimeRaw); - - if (startTime === 0) { - throw new Error( - 'Invalid erc20-token-allowance terms: startTime must be a positive number', - ); - } - - if (allowanceAmount === ZERO_32_BYTES) { - throw new Error( - 'Invalid erc20-token-allowance terms: allowanceAmount must be a positive number', - ); - } - - return { tokenAddress, allowanceAmount, startTime }; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenPeriodic.test.ts deleted file mode 100644 index e77c5cd4543..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenPeriodic.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { - createERC20TokenPeriodTransferTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '.'; -import { MAX_PERIOD_DURATION, ZERO_32_BYTES } from '../utils'; - -describe('erc20-token-periodic decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20PeriodTransferEnforcer, ValueLteEnforcer } = - contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-periodic', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const valueLteCaveat = { - enforcer: ValueLteEnforcer, - terms: ZERO_32_BYTES, - args: '0x' as const, - }; - - it('rejects duplicate ERC20PeriodTransferEnforcer caveats', () => { - const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const terms = createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 100n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ); - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('rejects truncated terms', () => { - const truncatedTerms: Hex = `0x${'a'.repeat(100)}`; // 50 bytes, need 116 - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: truncatedTerms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: expected 116 bytes', - ); - }); - - it('rejects when ValueLteEnforcer terms are not zero (native token value must be zero)', () => { - const nonZeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; - const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ValueLteEnforcer, - terms: nonZeroValueLteTerms, - args: '0x' as const, - }, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 200n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid value-lte terms'); - expect(result.error.message).toContain('must be'); - }); - - it('successfully decodes valid erc20-token-periodic caveats', () => { - const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 200n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data.tokenAddress).toBe(tokenAddress); - expect(result.data.periodAmount).toBeDefined(); - expect(result.data.periodDuration).toBe(86400); - expect(result.data.startTime).toBe(1715664); - }); - - it('decodes mixed-case token address', () => { - const mixedCaseAddress = contracts.ERC20PeriodTransferEnforcer; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress: mixedCaseAddress, - periodAmount: 200n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data.tokenAddress.toLowerCase()).toBe( - mixedCaseAddress.toLowerCase(), - ); - }); - - it('rejects when periodDuration is 0', () => { - const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const periodAmountHex = 100n.toString(16).padStart(64, '0'); - const periodDurationZero = '0'.repeat(64); - const startDateHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${tokenAddress.slice(2)}${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; - const validTerms = createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 100n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ); - const termsWithTrailing = `${validTerms}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: expected 116 bytes', - ); - }); - - it('rejects when startTime is 0', () => { - const tokenAddress = '0xdddddddddddddddddddddddddddddddddddddddd' as Hex; - const periodAmountHex = 100n.toString(16).padStart(64, '0'); - const periodDurationHex = (86400).toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = - `0x${tokenAddress.slice(2)}${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: startTime must be a positive number', - ); - }); - - it('rejects when periodAmount is 0', () => { - const tokenAddress = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex; - const periodAmountZero = '0'.repeat(64); - const periodDurationHex = (86400).toString(16).padStart(64, '0'); - const startDateHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${tokenAddress.slice(2)}${periodAmountZero}${periodDurationHex}${startDateHex}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', - ); - }); - - it('rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { - const tokenAddress = '0xffffffffffffffffffffffffffffffffffffffff' as Hex; - const terms = createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 100n, - periodDuration: MAX_PERIOD_DURATION + 1, - startDate: 1715664, - }, - { out: 'hex' }, - ); - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', - ); - }); - - it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { - const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms: createERC20TokenPeriodTransferTerms( - { - tokenAddress, - periodAmount: 200n, - periodDuration: MAX_PERIOD_DURATION, - startDate: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.data.periodDuration).toBe(MAX_PERIOD_DURATION); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenPeriodic.ts deleted file mode 100644 index 52614cae437..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenPeriodic.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; - -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { - getByteLength, - getTermsByEnforcer, - MAX_PERIOD_DURATION, - splitHex, - ZERO_32_BYTES, -} from '../utils'; -import { erc20PayeeRule } from './erc20PayeeRule'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; -import { redeemerRule } from './redeemerRule'; - -/** - * Builds the configuration for the erc20-token-periodic permission decoder. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The erc20-token-periodic permission decoder configuration. - */ -export function makeErc20TokenPeriodicDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - erc20PeriodicEnforcer, - valueLteEnforcer, - nonceEnforcer, - allowedCalldataEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'erc20-token-periodic', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - allowedCalldataEnforcer, // payee rule - ], - requiredEnforcers: { - [erc20PeriodicEnforcer]: 1, - [valueLteEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRule, erc20PayeeRule], - validateAndDecodeData, - }; -} - -/** - * Decodes erc20-token-periodic permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Decoded periodic terms. - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { erc20PeriodicEnforcer, valueLteEnforcer } = contractAddresses; - - const valueLteTerms = getTermsByEnforcer({ - caveats, - enforcer: valueLteEnforcer, - }); - if (valueLteTerms !== ZERO_32_BYTES) { - throw new Error(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); - } - - const terms = getTermsByEnforcer({ - caveats, - enforcer: erc20PeriodicEnforcer, - }); - - const EXPECTED_TERMS_BYTELENGTH = 116; // 20 + 32 + 32 + 32 - - if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { - throw new Error('Invalid erc20-token-periodic terms: expected 116 bytes'); - } - - const [tokenAddress, periodAmount, periodDurationRaw, startTimeRaw] = - splitHex(terms, [20, 32, 32, 32]); - - const periodDuration = hexToNumber(periodDurationRaw); - const periodAmountBigInt = hexToBigInt(periodAmount); - const startTime = hexToNumber(startTimeRaw); - - if (periodAmountBigInt === 0n) { - throw new Error( - 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', - ); - } - - if (periodDuration === 0) { - throw new Error( - 'Invalid erc20-token-periodic terms: periodDuration must be a positive number', - ); - } - - if (periodDuration > MAX_PERIOD_DURATION) { - throw new Error( - 'Invalid erc20-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', - ); - } - - if (startTime === 0) { - throw new Error( - 'Invalid erc20-token-periodic terms: startTime must be a positive number', - ); - } - - return { tokenAddress, periodAmount, periodDuration, startTime }; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenRevocation.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenRevocation.test.ts deleted file mode 100644 index d23bb16a76e..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenRevocation.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; -import { getChecksumAddress } from '@metamask/utils'; - -import { createPermissionDecodersForContracts } from '.'; - -describe('erc20-token-revocation decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { - TimestampEnforcer, - AllowedCalldataEnforcer, - ValueLteEnforcer, - RedeemerEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-revocation', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - it('rejects with only approve selector (missing zero-amount constraint)', () => { - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: approveSelectorTerms, - args: '0x' as const, - }, - { - enforcer: AllowedCalldataEnforcer, - terms: approveSelectorTerms, - args: '0x' as const, - }, - { - enforcer: ValueLteEnforcer, - terms: zeroValueLteTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', - ); - }); - - it('rejects with only zero-amount constraint (missing approve selector)', () => { - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x' as const, - }, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x' as const, - }, - { - enforcer: ValueLteEnforcer, - terms: zeroValueLteTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', - ); - }); - - it('rejects when ValueLteEnforcer terms are non-zero', () => { - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - const nonZeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000001' as const; - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: approveSelectorTerms, - args: '0x' as const, - }, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x' as const, - }, - { - enforcer: ValueLteEnforcer, - terms: nonZeroValueLteTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid ValueLteEnforcer terms: maxValue must be 0', - ); - }); - - it('rejects duplicate ValueLteEnforcer caveats', () => { - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: approveSelectorTerms, - args: '0x' as const, - }, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x' as const, - }, - { - enforcer: ValueLteEnforcer, - terms: zeroValueLteTerms, - args: '0x' as const, - }, - { - enforcer: ValueLteEnforcer, - terms: zeroValueLteTerms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('successfully decodes valid erc20-token-revocation caveats', () => { - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: approveSelectorTerms, - args: '0x' as const, - }, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x' as const, - }, - { - enforcer: ValueLteEnforcer, - terms: zeroValueLteTerms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({}); - expect(result.rules).toStrictEqual([ - { - type: 'expiry', - data: { timestamp: 1720000 }, - }, - ]); - }); - - it('includes redeemer rule but not payee when RedeemerEnforcer caveat is present', () => { - const packedAddr = '1111111111111111111111111111111111111111' as const; - const approveSelectorTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; - const zeroAmountTerms = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - const zeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; - const caveats = [ - expiryCaveat, - { - enforcer: AllowedCalldataEnforcer, - terms: approveSelectorTerms, - args: '0x' as const, - }, - { - enforcer: AllowedCalldataEnforcer, - terms: zeroAmountTerms, - args: '0x' as const, - }, - { - enforcer: ValueLteEnforcer, - terms: zeroValueLteTerms, - args: '0x' as const, - }, - { - enforcer: RedeemerEnforcer, - terms: `0x${packedAddr}` as const, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toStrictEqual([ - { - type: 'expiry', - data: { timestamp: 1720000 }, - }, - { - type: 'redeemer', - data: { - addresses: [ - getChecksumAddress( - '0x1111111111111111111111111111111111111111' as const, - ), - ], - }, - }, - ]); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenRevocation.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenRevocation.ts deleted file mode 100644 index 1021fa6f0e2..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenRevocation.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { - ERC20_APPROVE_SELECTOR_TERMS, - ERC20_APPROVE_ZERO_AMOUNT_TERMS, - getTermsByEnforcer, - ZERO_32_BYTES, -} from '../utils'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; -import { redeemerRule } from './redeemerRule'; - -/** - * Builds the configuration for the erc20-token-revocation permission decoder. - * - * Revocation permissions intentionally do not support a payee rule: the - * AllowedCalldataEnforcer is required (with count=2) to encode both the - * `approve` selector and the zero-amount constraint, so it cannot also be - * used to extract a payee address. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The erc20-token-revocation permission decoder configuration. - */ -export function makeErc20TokenRevocationDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - allowedCalldataEnforcer, - valueLteEnforcer, - nonceEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'erc20-token-revocation', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - ], - requiredEnforcers: { - [allowedCalldataEnforcer]: 2, - [valueLteEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRule], - validateAndDecodeData, - }; -} - -/** - * Decodes erc20-token-revocation permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Empty object (revocation has no decoded data payload). - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { allowedCalldataEnforcer, valueLteEnforcer } = contractAddresses; - - const allowedCalldataCaveats = caveats.filter( - (caveat) => caveat.enforcer === allowedCalldataEnforcer, - ); - const allowedCalldataTerms = allowedCalldataCaveats.map((caveat) => - caveat.terms.toLowerCase(), - ); - - const hasApproveSelector = allowedCalldataTerms.includes( - ERC20_APPROVE_SELECTOR_TERMS, - ); - - const hasZeroAmount = allowedCalldataTerms.includes( - ERC20_APPROVE_ZERO_AMOUNT_TERMS, - ); - - if (!hasApproveSelector || !hasZeroAmount) { - throw new Error( - 'Invalid erc20-token-revocation terms: expected approve selector and zero amount constraints', - ); - } - - const valueLteTerms = getTermsByEnforcer({ - caveats, - enforcer: valueLteEnforcer, - }); - - if (valueLteTerms !== ZERO_32_BYTES) { - throw new Error('Invalid ValueLteEnforcer terms: maxValue must be 0'); - } - - return {}; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenStream.test.ts deleted file mode 100644 index 19304a8c440..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenStream.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { - createERC20StreamingTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '.'; -import { ZERO_32_BYTES } from '../utils'; - -describe('erc20-token-stream decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { TimestampEnforcer, ERC20StreamingEnforcer, ValueLteEnforcer } = - contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'erc20-token-stream', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const valueLteCaveat = { - enforcer: ValueLteEnforcer, - terms: ZERO_32_BYTES, - args: '0x' as const, - }; - - it('rejects duplicate ERC20StreamingEnforcer caveats', () => { - const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const terms = createERC20StreamingTerms( - { - tokenAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ); - const caveats = [ - expiryCaveat, - valueLteCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('rejects truncated terms', () => { - const truncatedTerms: Hex = `0x${'a'.repeat(100)}`; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: truncatedTerms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-stream terms: expected 148 bytes', - ); - }); - - it('rejects when ValueLteEnforcer terms are not zero (native token value must be zero)', () => { - const nonZeroValueLteTerms = - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; - const tokenAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; - const caveats = [ - expiryCaveat, - { - enforcer: ValueLteEnforcer, - terms: nonZeroValueLteTerms, - args: '0x' as const, - }, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid value-lte terms'); - expect(result.error.message).toContain('must be'); - }); - - it('decodes mixed-case token address', () => { - const mixedCaseAddress = contracts.ERC20StreamingEnforcer; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress: mixedCaseAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data?.tokenAddress.toLowerCase()).toBe( - mixedCaseAddress.toLowerCase(), - ); - }); - - it('decodes zero token address', () => { - const zeroAddress = '0x0000000000000000000000000000000000000000' as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: createERC20StreamingTerms( - { - tokenAddress: zeroAddress, - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data?.tokenAddress).toBe(zeroAddress); - }); - - it('rejects when initialAmount exceeds maxAmount', () => { - const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; - const initialAmountHex = 1000n.toString(16).padStart(64, '0'); - const maxAmountHex = 100n.toString(16).padStart(64, '0'); - const amountPerSecondHex = 1n.toString(16).padStart(64, '0'); - const startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'maxAmount must be greater than initialAmount', - ); - }); - - it('rejects when maxAmount equals initialAmount', () => { - const tokenAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; - const initialAmountAndMaxAmount = 100n.toString(16).padStart(64, '0'); - const amountPerSecondHex = 1n.toString(16).padStart(64, '0'); - const startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${tokenAddress.slice(2)}${initialAmountAndMaxAmount}${initialAmountAndMaxAmount}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'maxAmount must be greater than initialAmount', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const tokenAddress = '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; - const validTerms = createERC20StreamingTerms( - { - tokenAddress, - initialAmount: 42n, - maxAmount: 100n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ); - const termsWithTrailing = `${validTerms}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20StreamingEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-stream terms: expected 148 bytes', - ); - }); - - it('rejects when amountPerSecond is 0', () => { - const tokenAddress = '0xdddddddddddddddddddddddddddddddddddddddd' as Hex; - const initialAmountHex = 1n.toString(16).padStart(64, '0'); - const maxAmountHex = 2n.toString(16).padStart(64, '0'); - const amountPerSecondZero = '0'.repeat(64); - const startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondZero}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-stream terms: amountPerSecond must be a positive number', - ); - }); - - it('rejects when startTime is 0', () => { - const tokenAddress = '0xdddddddddddddddddddddddddddddddddddddddd' as Hex; - const initialAmountHex = 1n.toString(16).padStart(64, '0'); - const maxAmountHex = 2n.toString(16).padStart(64, '0'); - const amountPerSecondHex = 1n.toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = - `0x${tokenAddress.slice(2)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - valueLteCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid erc20-token-stream terms: startTime must be a positive number', - ); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenStream.ts deleted file mode 100644 index 75478391393..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/erc20TokenStream.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; - -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { - getByteLength, - getTermsByEnforcer, - splitHex, - ZERO_32_BYTES, -} from '../utils'; -import { erc20PayeeRule } from './erc20PayeeRule'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; -import { redeemerRule } from './redeemerRule'; - -/** - * Builds the configuration for the erc20-token-stream permission decoder. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The erc20-token-stream permission decoder configuration. - */ -export function makeErc20TokenStreamDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - erc20StreamingEnforcer, - valueLteEnforcer, - nonceEnforcer, - allowedCalldataEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'erc20-token-stream', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - allowedCalldataEnforcer, // payee rule - ], - requiredEnforcers: { - [erc20StreamingEnforcer]: 1, - [valueLteEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRule, erc20PayeeRule], - validateAndDecodeData, - }; -} - -/** - * Decodes erc20-token-stream permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Decoded stream terms. - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { erc20StreamingEnforcer, valueLteEnforcer } = contractAddresses; - const valueLteTerms = getTermsByEnforcer({ - caveats, - enforcer: valueLteEnforcer, - }); - - if (valueLteTerms !== ZERO_32_BYTES) { - throw new Error(`Invalid value-lte terms: must be ${ZERO_32_BYTES}`); - } - - const terms = getTermsByEnforcer({ - caveats, - enforcer: erc20StreamingEnforcer, - }); - - const EXPECTED_TERMS_BYTELENGTH = 148; - - if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { - throw new Error('Invalid erc20-token-stream terms: expected 148 bytes'); - } - - const [ - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTimeRaw, - ] = splitHex(terms, [20, 32, 32, 32, 32]); - - const startTime = hexToNumber(startTimeRaw); - const initialAmountBigInt = hexToBigInt(initialAmount); - const maxAmountBigInt = hexToBigInt(maxAmount); - const amountPerSecondBigInt = hexToBigInt(amountPerSecond); - - if (maxAmountBigInt <= initialAmountBigInt) { - throw new Error( - 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', - ); - } - - if (amountPerSecondBigInt === 0n) { - throw new Error( - 'Invalid erc20-token-stream terms: amountPerSecond must be a positive number', - ); - } - - if (startTime === 0) { - throw new Error( - 'Invalid erc20-token-stream terms: startTime must be a positive number', - ); - } - - return { - tokenAddress, - initialAmount, - maxAmount, - amountPerSecond, - startTime, - }; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/expiryRule.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/expiryRule.test.ts deleted file mode 100644 index 6966a6694a1..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/expiryRule.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; -import type { Hex } from '@metamask/utils'; - -import type { ChecksumCaveat } from '../types'; -import { getChecksumEnforcersByChainId } from '../utils'; -import { expiryRule } from './expiryRule'; - -describe('expiryRule', () => { - const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; - const contractAddresses = getChecksumEnforcersByChainId(contracts); - const { timestampEnforcer, nonceEnforcer } = contractAddresses; - const requiredEnforcers = new Map([[nonceEnforcer, 1]]); - - it('returns null when no TimestampEnforcer caveat is present', () => { - const caveats: ChecksumCaveat[] = [ - { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, - ]; - - expect( - expiryRule({ contractAddresses, caveats, requiredEnforcers }), - ).toBeNull(); - }); - - it('returns an expiry rule with the decoded timestamp when TimestampEnforcer is present', () => { - const beforeThreshold = 1_750_000_000; - const caveats: ChecksumCaveat[] = [ - { - enforcer: timestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold, - }), - args: '0x' as Hex, - }, - ]; - - expect( - expiryRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'expiry', - data: { timestamp: beforeThreshold }, - }); - }); - - it('ignores caveats from unrelated enforcers', () => { - const beforeThreshold = 1_700_000_000; - const caveats: ChecksumCaveat[] = [ - { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, - { - enforcer: timestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold, - }), - args: '0x' as Hex, - }, - ]; - - expect( - expiryRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'expiry', - data: { timestamp: beforeThreshold }, - }); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/expiryRule.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/expiryRule.ts deleted file mode 100644 index 14b7127966f..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/expiryRule.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EXECUTION_PERMISSION_EXPIRY_RULE_TYPE } from '../../constants'; -import type { RuleDecoder } from '../types'; -import { extractExpiryFromCaveatTerms, getTermsByEnforcer } from '../utils'; - -/** - * Rule decoder that extracts the expiry timestamp from a TimestampEnforcer - * caveat, when present. Returns a standard {@link Rule} so that - * `makePermissionDecoder` can append it to the decoded permission's `rules` - * array. The decoder body additionally hoists the timestamp onto the - * top-level `expiry` field of the response. - * - * @param args - The arguments to this function. - * @param args.contractAddresses - Checksummed enforcer addresses for the chain. - * @param args.caveats - Checksummed caveats from the delegation. - * @returns A `{ type: 'expiry', data: { timestamp } }` rule when a - * TimestampEnforcer caveat exists, otherwise `null`. - */ -export const expiryRule: RuleDecoder = ({ contractAddresses, caveats }) => { - const { timestampEnforcer } = contractAddresses; - - const expiryTerms = getTermsByEnforcer({ - caveats, - enforcer: timestampEnforcer, - throwIfNotFound: false, - }); - - if (!expiryTerms) { - return null; - } - - return { - type: EXECUTION_PERMISSION_EXPIRY_RULE_TYPE, - data: { timestamp: extractExpiryFromCaveatTerms(expiryTerms) }, - }; -}; diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/index.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/index.ts index 58d818b8a80..a9d7582cfbe 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decoders/index.ts @@ -1,14 +1,8 @@ -import type { DeployedContractsByName, PermissionDecoder } from '../types'; -import { getChecksumEnforcersByChainId } from '../utils'; -import { makeErc20TokenAllowanceDecoderConfig } from './erc20TokenAllowance'; -import { makeErc20TokenPeriodicDecoderConfig } from './erc20TokenPeriodic'; -import { makeErc20TokenRevocationDecoderConfig } from './erc20TokenRevocation'; -import { makeErc20TokenStreamDecoderConfig } from './erc20TokenStream'; +import { makePermissionDecoderConfigs } from '@metamask/7715-permission-types'; +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; + +import type { PermissionDecoder } from '../types'; import { makePermissionDecoder } from './makePermissionDecoder'; -import { makeNativeTokenAllowanceDecoderConfig } from './nativeTokenAllowance'; -import { makeNativeTokenPeriodicDecoderConfig } from './nativeTokenPeriodic'; -import { makeNativeTokenStreamDecoderConfig } from './nativeTokenStream'; -import { makeTokenApprovalRevocationDecoderConfig } from './tokenApprovalRevocation'; /** * Builds the canonical set of permission decoders for a chain. @@ -17,22 +11,12 @@ import { makeTokenApprovalRevocationDecoderConfig } from './tokenApprovalRevocat * and provides `caveatAddressesMatch` and `validateAndDecodePermission` so the * entire decode flow can be driven by the decoders. * - * @param contracts - The deployed contracts for the chain. + * @param contracts - The deployed enforcer addresses for the chain. * @returns A list of permission decoders used to identify and decode permission types. * @throws Propagates any errors from resolving enforcer addresses. */ export const createPermissionDecodersForContracts = ( - contracts: DeployedContractsByName, + contracts: EnforcerAddressesByName, ): PermissionDecoder[] => { - const contractAddresses = getChecksumEnforcersByChainId(contracts); - return [ - makeNativeTokenStreamDecoderConfig(contractAddresses), - makeNativeTokenPeriodicDecoderConfig(contractAddresses), - makeNativeTokenAllowanceDecoderConfig(contractAddresses), - makeErc20TokenStreamDecoderConfig(contractAddresses), - makeErc20TokenPeriodicDecoderConfig(contractAddresses), - makeErc20TokenAllowanceDecoderConfig(contractAddresses), - makeErc20TokenRevocationDecoderConfig(contractAddresses), - makeTokenApprovalRevocationDecoderConfig(contractAddresses), - ].map(makePermissionDecoder); + return makePermissionDecoderConfigs(contracts).map(makePermissionDecoder); }; diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.test.ts index 817b2cf7ef0..1667b24dd97 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.test.ts @@ -1,827 +1,633 @@ -import { - createAllowedCalldataTerms, - createAllowedTargetsTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; +import type { PermissionDecoderConfig } from '@metamask/7715-permission-types'; +import { Rule } from '@metamask/7715-permission-types'; +import type { Caveat } from '@metamask/delegation-core'; import { CHAIN_ID, DELEGATOR_CONTRACTS, } from '@metamask/delegation-deployments'; import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { randomBytes } from 'crypto'; -import { getChecksumEnforcersByChainId } from '../utils'; -import { erc20PayeeRule } from './erc20PayeeRule'; -import { expiryRule } from './expiryRule'; +import type { DelegationDeploymentsEnforcerAddressesByName } from '../../types'; +import { toEnforcerAddressesByName } from '../enforcerAddresses'; +import { PermissionType } from '../types'; import { makePermissionDecoder } from './makePermissionDecoder'; -import { nativePayeeRule } from './nativePayeeRule'; -import { redeemerRule } from './redeemerRule'; + +const randomAddress = () => `0x${randomBytes(20).toString('hex')}` as const; + +type RuleDecoder = PermissionDecoderConfig['rules'][number]; describe('makePermissionDecoder', () => { - const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; - const contractAddresses = getChecksumEnforcersByChainId(contracts); - const { - timestampEnforcer, - nonceEnforcer: requiredEnforcer, - redeemerEnforcer, - allowedCalldataEnforcer, - allowedTargetsEnforcer, - } = contractAddresses; - - it('calls validate callback when decoding succeeds and extracts expiry', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [timestampEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [expiryRule], - validateAndDecodeData, + const permissionType = 'specified-permission-type' as PermissionType; + + const contracts = DELEGATOR_CONTRACTS['1.3.0'][ + CHAIN_ID.sepolia + ] as DelegationDeploymentsEnforcerAddressesByName; + const contractAddresses = toEnforcerAddressesByName(contracts); + + describe('factory function', () => { + it('returns the specified permission type', () => { + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + expect(decoder.permissionType).toStrictEqual(permissionType); + }); + + it('returns a set of checksummed optional enforcers', () => { + const optionalEnforcers: Hex[] = [ + randomAddress(), + randomAddress(), + randomAddress(), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + expect(decoder.optionalEnforcers).toStrictEqual( + new Set(optionalEnforcers.map(getChecksumAddress)), + ); }); - const caveats = [ - { - enforcer: timestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as Hex, - }, - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({}); - expect(validateAndDecodeData).toHaveBeenCalled(); + it('returns a Map of checksummed required enforcers to their required count', () => { + const requiredEnforcers = { + [randomAddress()]: 1, + [randomAddress()]: 2, + [randomAddress()]: 3, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const requiredEnforcersMap = new Map( + Object.entries(requiredEnforcers).map(([enforcer, count]) => [ + getChecksumAddress(enforcer as Hex), + count, + ]), + ); + + expect(decoder.requiredEnforcers).toStrictEqual(requiredEnforcersMap); + }); }); - it('rejects when any caveat terms are not valid hex (invalid characters)', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + describe('caveatAddressesMatch', () => { + it('returns true when the specified addresses match the required enforcers', () => { + const enforcer1 = randomAddress(); + const enforcer2 = randomAddress(); + const enforcer3 = randomAddress(); + + const requiredEnforcers = { + [enforcer1]: 1, + [enforcer2]: 1, + [enforcer3]: 1, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [enforcer1, enforcer2, enforcer3]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [timestampEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [expiryRule], - validateAndDecodeData, + it('returns true when the specified addresses include required addresses with the correct multiplicity', () => { + const enforcer1 = randomAddress(); + const enforcer2 = randomAddress(); + const enforcer3 = randomAddress(); + + const requiredEnforcers = { + [enforcer1]: 1, + [enforcer2]: 2, + [enforcer3]: 3, + }; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + enforcer1, + enforcer2, + enforcer2, + enforcer3, + enforcer3, + enforcer3, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); }); - const caveats = [ - { - enforcer: timestampEnforcer, - terms: '0xgg' as Hex, - args: '0x' as Hex, - }, - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } - expect(result.error.message).toBe('Invalid terms: must be a hex string'); - expect(validateAndDecodeData).not.toHaveBeenCalled(); - }); + it('returns true when the specified addresses include optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer1 = randomAddress(); + const optionalEnforcer2 = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [ + getChecksumAddress(optionalEnforcer1), + getChecksumAddress(optionalEnforcer2), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + requiredEnforcer, + optionalEnforcer1, + optionalEnforcer2, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); - it('rejects when any caveat terms contain non-hex characters after 0x prefix', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + it('returns true when the specified addresses include only a subset of declared optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer1 = randomAddress(); + const optionalEnforcer2 = randomAddress(); + const optionalEnforcer3 = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [ + getChecksumAddress(optionalEnforcer1), + getChecksumAddress(optionalEnforcer2), + getChecksumAddress(optionalEnforcer3), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [requiredEnforcer, optionalEnforcer2]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [timestampEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [expiryRule], - validateAndDecodeData, + it('returns false when the specified addresses include addresses that are neither required or optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer = randomAddress(); + const unknownEnforcer = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [getChecksumAddress(optionalEnforcer)]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + requiredEnforcer, + optionalEnforcer, + unknownEnforcer, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); }); - const caveats = [ - { - enforcer: timestampEnforcer, - terms: - '0x000000000000000000000000000000000000000000000000000000000000000z' as Hex, - args: '0x' as Hex, - }, - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } - expect(result.error.message).toBe('Invalid terms: must be a hex string'); - expect(validateAndDecodeData).not.toHaveBeenCalled(); - }); + it('returns false when the specified addresses do not include all required enforcers', () => { + const requiredEnforcer1 = randomAddress(); + const requiredEnforcer2 = randomAddress(); + const optionalEnforcer = randomAddress(); - it('rejects when required enforcer terms are not valid hex', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer1)]: 1, + [getChecksumAddress(requiredEnforcer2)]: 1, + }; - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [timestampEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [expiryRule], - validateAndDecodeData, - }); + const optionalEnforcers = [getChecksumAddress(optionalEnforcer)]; - const caveats = [ - { - enforcer: timestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as Hex, - }, - { - enforcer: requiredEnforcer, - terms: '0xNOTHEX' as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } - expect(result.error.message).toBe('Invalid terms: must be a hex string'); - expect(validateAndDecodeData).not.toHaveBeenCalled(); - }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); - it('accepts caveat terms with mixed-case hex', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + const specifiedCaveats = [requiredEnforcer1, optionalEnforcer]; - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [timestampEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [expiryRule], - validateAndDecodeData, + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); }); - const caveats = [ - { - enforcer: timestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as Hex, - }, - { - enforcer: requiredEnforcer, - terms: - '0x000000000000000000000000000000000000000000000000000000000000abAB' as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.expiry).toBe(1720000); - expect(validateAndDecodeData).toHaveBeenCalled(); - }); + it('returns false when the specified addresses include required addresses with the incorrect multiplicity (less than required)', () => { + const enforcer1 = randomAddress(); - it('accepts caveat terms with empty hex', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + const requiredEnforcers = { + [getChecksumAddress(enforcer1)]: 2, + }; - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [], - validateAndDecodeData, - }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(validateAndDecodeData).toHaveBeenCalled(); - }); + const specifiedCaveats = [enforcer1]; - it('includes redeemer rule when RedeemerEnforcer caveat is present', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - // Raw packed 20-byte address (40 hex chars), not ABI-padded 32-byte words. - const packedAddr = '1111111111111111111111111111111111111111' as const; - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [redeemerEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [redeemerRule], - validateAndDecodeData, + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); }); - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: redeemerEnforcer, - terms: `0x${packedAddr}` as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toStrictEqual([ - { - type: 'redeemer', - data: { - addresses: [ - getChecksumAddress( - '0x1111111111111111111111111111111111111111' as Hex, - ), - ], - }, - }, - ]); - }); + it('returns false when the specified addresses include required addresses with the incorrect multiplicity (more than required)', () => { + const enforcer1 = randomAddress(); - it('includes payee rule when AllowedTargetsEnforcer caveat is present (native)', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress = '0x2222222222222222222222222222222222222222' as Hex; - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [allowedTargetsEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [nativePayeeRule], - validateAndDecodeData, - }); + const requiredEnforcers = { + [getChecksumAddress(enforcer1)]: 1, + }; - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [payeeAddress] }), - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toStrictEqual([ - { - type: 'payee', - data: { - addresses: [getChecksumAddress(payeeAddress)], - }, - }, - ]); - }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); - it('includes payee rule when AllowedCalldataEnforcer caveat is present (erc20)', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress = '0x3333333333333333333333333333333333333333' as Hex; - const paddedAddress = `0x${payeeAddress.slice(2).padStart(64, '0')}`; - - const decoder = makePermissionDecoder({ - permissionType: 'erc20-token-stream', - contractAddresses, - optionalEnforcers: [allowedCalldataEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [erc20PayeeRule], - validateAndDecodeData, + const specifiedCaveats = [enforcer1, enforcer1]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(false); }); - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: paddedAddress, - }), - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toStrictEqual([ - { - type: 'payee', - data: { - addresses: [getChecksumAddress(payeeAddress)], + // todo: we could consider tightening this up to require optional enforcers to be singular + it('returns true when the specified addresses include duplicates of optional enforcers', () => { + const requiredEnforcer = randomAddress(); + const optionalEnforcer = randomAddress(); + + const requiredEnforcers = { + [getChecksumAddress(requiredEnforcer)]: 1, + }; + + const optionalEnforcers = [getChecksumAddress(optionalEnforcer)]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers, + requiredEnforcers, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const specifiedCaveats = [ + requiredEnforcer, + optionalEnforcer, + optionalEnforcer, + ]; + + expect(decoder.caveatAddressesMatch(specifiedCaveats)).toBe(true); + }); + + it('matches when decoder config address casing mismatches specified caveat addresses', () => { + const toUpperCaseHex = (address: Hex) => + `0x${address.slice(2).toUpperCase()}` as const; + const requiredEnforcer = randomAddress().toLowerCase() as Hex; + const optionalEnforcer = randomAddress().toLowerCase() as Hex; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [optionalEnforcer], + requiredEnforcers: { + [requiredEnforcer]: 1, }, - }, - ]); + rules: [], + validateAndDecodeData: jest.fn(), + }); + + expect( + decoder.caveatAddressesMatch([ + toUpperCaseHex(requiredEnforcer), + toUpperCaseHex(optionalEnforcer), + ]), + ).toBe(true); + }); }); - it('does not include payee rule when no matching payee caveat is present (erc20 decoder, AllowedTargets caveat)', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress = '0x2222222222222222222222222222222222222222' as Hex; - - const decoder = makePermissionDecoder({ - permissionType: 'erc20-token-stream', - contractAddresses, - optionalEnforcers: [allowedTargetsEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [erc20PayeeRule], - validateAndDecodeData, + describe('validateAndDecodePermission', () => { + it('returns a valid result when the specified validation and decoding succeeds', () => { + const data = { result: 'success' }; + const validateAndDecodeData = jest.fn().mockReturnValue(data); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(validateAndDecodeData).toHaveBeenCalled(); + expect(result.isValid).toBe(true); + expect((result as { data: Record }).data).toStrictEqual( + data, + ); }); - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [payeeAddress] }), - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toBeUndefined(); - }); + it('calls the validation and decoding function with the correct arguments', () => { + const validateAndDecodeData = jest.fn(); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const caveats: Caveat[] = [ + { + enforcer: randomAddress(), + terms: '0x123456', + args: '0x', + }, + { + enforcer: randomAddress(), + terms: '0x987654', + args: '0x', + }, + ]; - it('rejects multiple AllowedCalldataEnforcer caveats for erc20 payee decoding', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress1 = '0x2222222222222222222222222222222222222222' as Hex; - const payeeAddress2 = '0x3333333333333333333333333333333333333333' as Hex; - const padded1 = `0x${payeeAddress1.slice(2).padStart(64, '0')}`; - const padded2 = `0x${payeeAddress2.slice(2).padStart(64, '0')}`; - - const decoder = makePermissionDecoder({ - permissionType: 'erc20-token-stream', - contractAddresses, - optionalEnforcers: [allowedCalldataEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [erc20PayeeRule], - validateAndDecodeData, - }); + const checksumCaveats = caveats.map((caveat) => ({ + ...caveat, + enforcer: getChecksumAddress(caveat.enforcer), + })); - const result = decoder.validateAndDecodePermission([ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: padded1, - }), - args: '0x' as Hex, - }, - { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: padded2, - }), - args: '0x' as Hex, - }, - ]); - - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } - expect(result.error.message).toBe( - 'Invalid payee caveats: multiple AllowedCalldataEnforcer caveats', - ); - }); + decoder.validateAndDecodePermission(caveats); - it('includes payee rule with multiple addresses via AllowedTargetsEnforcer (native)', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress1 = '0x4444444444444444444444444444444444444444' as Hex; - const payeeAddress2 = '0x5555555555555555555555555555555555555555' as Hex; - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [allowedTargetsEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [nativePayeeRule], - validateAndDecodeData, + expect(validateAndDecodeData).toHaveBeenCalledWith( + checksumCaveats, + contractAddresses, + ); }); - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ - targets: [payeeAddress1, payeeAddress2], - }), - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toStrictEqual([ - { - type: 'payee', - data: { - addresses: [ - getChecksumAddress(payeeAddress1), - getChecksumAddress(payeeAddress2), - ], - }, - }, - ]); - }); - - it('does not include payee rule when no matching payee caveat is present (native decoder, AllowedCalldata caveat)', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress = '0x3333333333333333333333333333333333333333' as Hex; - const paddedAddress = `0x${payeeAddress.slice(2).padStart(64, '0')}`; - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [allowedCalldataEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [nativePayeeRule], - validateAndDecodeData, + it('returns an invalid result, with thrown error when the specified validation and decoding throws', () => { + const validationError = new Error('test error'); + const validateAndDecodeData = jest.fn().mockImplementation(() => { + throw validationError; + }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(validateAndDecodeData).toHaveBeenCalled(); + expect(result.isValid).toBe(false); + expect((result as { error: Error }).error).toBe(validationError); }); - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: paddedAddress, - }), - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toBeUndefined(); - }); + it('returns an invalid result, with appropriate error if any of the terms is not valid hex', () => { + const data = { result: 'success' }; + const validateAndDecodeData = jest.fn().mockReturnValue(data); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData, + }); + + const result = decoder.validateAndDecodePermission([ + { + enforcer: randomAddress(), + terms: '0xNOTHEX', + args: '0x', + }, + ]); - it('includes both redeemer and payee rules when both caveats present', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const redeemerAddr = '1111111111111111111111111111111111111111' as const; - const payeeAddress = '0x2222222222222222222222222222222222222222' as Hex; - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [redeemerEnforcer, allowedTargetsEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [redeemerRule, nativePayeeRule], - validateAndDecodeData, + expect(validateAndDecodeData).not.toHaveBeenCalled(); + expect(result.isValid).toBe(false); + expect((result as { error: Error }).error.message).toBe( + 'Invalid terms: must be a hex string', + ); }); - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: redeemerEnforcer, - terms: `0x${redeemerAddr}` as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [payeeAddress] }), - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toHaveLength(2); - expect(result.rules).toStrictEqual([ - { - type: 'redeemer', - data: { - addresses: [getChecksumAddress(`0x${redeemerAddr}` as Hex)], + it('calls decode on each of the specified rules', () => { + const rules: RuleDecoder[] = [ + jest.fn().mockReturnValue(null), + jest.fn().mockReturnValue(null), + jest.fn().mockReturnValue(null), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules, + validateAndDecodeData: jest.fn(), + }); + + const caveats: Caveat[] = [ + { + enforcer: randomAddress(), + terms: '0x123456', + args: '0x', }, - }, - { - type: 'payee', - data: { - addresses: [getChecksumAddress(payeeAddress)], + { + enforcer: randomAddress(), + terms: '0x987654', + args: '0x', }, - }, - ]); - }); + ]; - it('does not include payee rule when no payee caveat is present', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + const checksumCaveats = caveats.map((caveat) => ({ + ...caveat, + enforcer: getChecksumAddress(caveat.enforcer), + })); - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [allowedTargetsEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [nativePayeeRule], - validateAndDecodeData, - }); - - const caveats = [ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - - expect(result.isValid).toBe(true); - if (!result.isValid) { - throw new Error('Expected valid result'); - } - expect(result.rules).toBeUndefined(); - }); + decoder.validateAndDecodePermission(caveats); - it('returns true from caveatAddressesMatch when enforcers match', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + const ruleDecoderExpectedArgs = { + contractAddresses, + caveats: checksumCaveats, + requiredEnforcers: new Map(), + }; - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [timestampEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [expiryRule], - validateAndDecodeData, + expect(rules[0]).toHaveBeenCalledWith(ruleDecoderExpectedArgs); + expect(rules[1]).toHaveBeenCalledWith(ruleDecoderExpectedArgs); + expect(rules[2]).toHaveBeenCalledWith(ruleDecoderExpectedArgs); }); - expect( - decoder.caveatAddressesMatch([requiredEnforcer, timestampEnforcer]), - ).toBe(true); - expect(decoder.caveatAddressesMatch([requiredEnforcer])).toBe(true); - expect(decoder.caveatAddressesMatch([])).toBe(false); - }); - - it('rejects when payee enforcer is configured as a required enforcer', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress = '0x2222222222222222222222222222222222222222' as Hex; - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [], - requiredEnforcers: { - [requiredEnforcer]: 1, - [allowedTargetsEnforcer]: 1, - }, - rules: [nativePayeeRule], - validateAndDecodeData, + it('returns an invalid result, with thrown error when a rule decoder throws', () => { + const ruleDecoderError = new Error('test error'); + const ruleDecoder = jest.fn().mockImplementation(() => { + throw ruleDecoderError; + }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [ruleDecoder], + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(ruleDecoder).toHaveBeenCalled(); + expect(result.isValid).toBe(false); + expect((result as { error: Error }).error).toBe(ruleDecoderError); }); - const result = decoder.validateAndDecodePermission([ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [payeeAddress] }), - args: '0x' as Hex, - }, - ]); - - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } - expect(result.error.message).toBe( - 'Invalid payee caveats: payee enforcer may not be a required caveat', - ); - }); + it('returns an undefined rules when no rules are decoded', () => { + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); - it('rejects an ERC20 payee caveat with the wrong calldata start index', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress = '0x3333333333333333333333333333333333333333' as const; - const paddedAddress = - `0x${payeeAddress.slice(2).padStart(64, '0')}` as const; - - const decoder = makePermissionDecoder({ - permissionType: 'erc20-token-stream', - contractAddresses, - optionalEnforcers: [allowedCalldataEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [erc20PayeeRule], - validateAndDecodeData, + expect(result.isValid).toBe(true); + expect((result as { rules: Rule[] }).rules).toBeUndefined(); }); - const result = decoder.validateAndDecodePermission([ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 36, - value: paddedAddress, - }), - args: '0x' as Hex, - }, - ]); - - expect(result.isValid).toBe(false); - }); + it('returns a null expiry when no expiry rule is decoded', () => { + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules: [], + validateAndDecodeData: jest.fn(), + }); - it('rejects an ERC20 payee caveat when the calldata value is not one address', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + const result = decoder.validateAndDecodePermission([]); - const decoder = makePermissionDecoder({ - permissionType: 'erc20-token-stream', - contractAddresses, - optionalEnforcers: [allowedCalldataEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [erc20PayeeRule], - validateAndDecodeData, + expect(result.isValid).toBe(true); + expect((result as { expiry: number | null }).expiry).toBeNull(); }); - const result = decoder.validateAndDecodePermission([ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedCalldataEnforcer, - terms: createAllowedCalldataTerms({ - startIndex: 4, - value: '0x1234', - }), - args: '0x' as Hex, - }, - ]); - - expect(result.isValid).toBe(false); - }); + it('applies decoded rules to the result', () => { + const mockRule1 = { + type: 'mock-rule', + data: {}, + }; + const mockRule2 = { + type: 'mock-rule-2', + data: { + value: 1, + }, + }; + + const rules: RuleDecoder[] = [ + jest.fn().mockReturnValue(mockRule1), + jest.fn().mockReturnValue(null), + jest.fn().mockReturnValue(mockRule2), + ]; + + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules, + validateAndDecodeData: jest.fn(), + }); + + const result = decoder.validateAndDecodePermission([]); + + expect(result.isValid).toBe(true); + expect((result as { rules: Rule[] }).rules).toStrictEqual([ + mockRule1, + mockRule2, + ]); + }); - it('rejects a native payee caveat with no targets', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); + it('hoists expiry rule to the top-level expiry field, as well as including it in the rules array', () => { + const timestamp = 1720000; + const expiryRule = { + type: 'expiry', + data: { + timestamp, + }, + }; - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [allowedTargetsEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [nativePayeeRule], - validateAndDecodeData, - }); + const rules: RuleDecoder[] = [jest.fn().mockReturnValue(expiryRule)]; - const result = decoder.validateAndDecodePermission([ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - ]); - - expect(result.isValid).toBe(false); - }); + const decoder = makePermissionDecoder({ + permissionType, + contractAddresses, + optionalEnforcers: [], + requiredEnforcers: {}, + rules, + validateAndDecodeData: jest.fn(), + }); - it('rejects multiple AllowedTargetsEnforcer caveats for native payee decoding', () => { - const validateAndDecodeData = jest.fn().mockReturnValue({}); - const payeeAddress1 = '0x2222222222222222222222222222222222222222' as Hex; - const payeeAddress2 = '0x3333333333333333333333333333333333333333' as Hex; - - const decoder = makePermissionDecoder({ - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [allowedTargetsEnforcer], - requiredEnforcers: { [requiredEnforcer]: 1 }, - rules: [nativePayeeRule], - validateAndDecodeData, - }); + const result = decoder.validateAndDecodePermission([]); - const result = decoder.validateAndDecodePermission([ - { - enforcer: requiredEnforcer, - terms: '0x' as Hex, - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [payeeAddress1] }), - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [payeeAddress2] }), - args: '0x' as Hex, - }, - ]); - - expect(result.isValid).toBe(false); - if (result.isValid) { - throw new Error('Expected invalid result'); - } - expect(result.error.message).toBe( - 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', - ); + expect(result.isValid).toBe(true); + expect((result as { expiry: number }).expiry).toStrictEqual(timestamp); + expect((result as { rules: Rule[] }).rules).toStrictEqual([expiryRule]); + }); }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.ts index bb15e243f11..9306fbeea18 100644 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.ts +++ b/packages/gator-permissions-controller/src/decodePermission/decoders/makePermissionDecoder.ts @@ -1,45 +1,22 @@ -import type { Rule } from '@metamask/7715-permission-types'; +import type { + PermissionDecoderConfig, + Rule, +} from '@metamask/7715-permission-types'; import type { Caveat } from '@metamask/delegation-core'; import { getChecksumAddress, isStrictHexString } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { EXECUTION_PERMISSION_EXPIRY_RULE_TYPE } from '../../constants'; -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, - PermissionDecoder, - PermissionType, - RuleDecoder, - ValidateAndDecodeResult, -} from '../types'; +import type { PermissionDecoder, ValidateAndDecodeResult } from '../types'; import { buildEnforcerCountsAndSet, enforcersMatchRule } from '../utils'; -/** - * Configuration object describing how to decode a single permission type. - * - * Returned by each `makeDecoderConfig` factory and consumed by - * {@link makePermissionDecoder} to produce a {@link PermissionDecoder}. - */ -export type MakePermissionDecoderConfig = { - permissionType: PermissionType; - contractAddresses: ChecksumEnforcersByChainId; - optionalEnforcers: Hex[]; - requiredEnforcers: Record; - rules: RuleDecoder[]; - validateAndDecodeData: ( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, - ) => DecodedPermission['permission']['data']; -}; - /** * Creates a single {@link PermissionDecoder} with the given type, enforcer * sets, rule decoders, and decode/validate callback. * * @param config - The configuration describing the permission type's * enforcers, rule decoders, and data decoder. See - * {@link MakePermissionDecoderConfig} for field documentation. + * {@link PermissionDecoderConfig} for field documentation. * @param config.permissionType - The type of permission to decode. * @param config.contractAddresses - Checksummed enforcer addresses for the chain. * @param config.optionalEnforcers - Optional enforcers for the permission. @@ -56,11 +33,16 @@ export function makePermissionDecoder({ requiredEnforcers, rules, validateAndDecodeData, -}: MakePermissionDecoderConfig): PermissionDecoder { - const optionalEnforcersSet = new Set(optionalEnforcers); +}: PermissionDecoderConfig): PermissionDecoder { + const optionalEnforcersSet = new Set( + optionalEnforcers.map(getChecksumAddress), + ); const requiredEnforcersMap = new Map( - Object.entries(requiredEnforcers), - ) as Map; + Object.entries(requiredEnforcers).map(([enforcer, count]) => [ + getChecksumAddress(enforcer as Hex), + count, + ]), + ); const caveatAddressesMatch = (caveatAddresses: Hex[]): boolean => { const { counts, enforcersSet } = buildEnforcerCountsAndSet(caveatAddresses); @@ -76,7 +58,7 @@ export function makePermissionDecoder({ const validateAndDecodePermission = ( caveats: Caveat[], ): ValidateAndDecodeResult => { - const checksumCaveats: ChecksumCaveat[] = caveats.map((caveat) => ({ + const checksumCaveats: Caveat[] = caveats.map((caveat) => ({ ...caveat, enforcer: getChecksumAddress(caveat.enforcer), })); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativePayeeRule.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativePayeeRule.test.ts deleted file mode 100644 index d7e29cef562..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativePayeeRule.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { createAllowedTargetsTerms } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; -import { getChecksumAddress } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; - -import type { ChecksumCaveat } from '../types'; -import { getChecksumEnforcersByChainId } from '../utils'; -import { nativePayeeRule } from './nativePayeeRule'; - -describe('nativePayeeRule', () => { - const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; - const contractAddresses = getChecksumEnforcersByChainId(contracts); - const { allowedTargetsEnforcer, nonceEnforcer } = contractAddresses; - const requiredEnforcers = new Map([[nonceEnforcer, 1]]); - - const PAYEE_A: Hex = '0x4444444444444444444444444444444444444444'; - const PAYEE_B: Hex = '0x5555555555555555555555555555555555555555'; - const CHECKSUM_PAYEE_INPUT: Hex = - '0xde709f2102306220921060314715629080e2fb77'; - - it('returns null when no AllowedTargetsEnforcer caveat is present', () => { - const caveats: ChecksumCaveat[] = [ - { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, - ]; - - expect( - nativePayeeRule({ contractAddresses, caveats, requiredEnforcers }), - ).toBeNull(); - }); - - it('returns a payee rule with a single decoded checksummed address', () => { - const caveats: ChecksumCaveat[] = [ - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [PAYEE_A] }), - args: '0x' as Hex, - }, - ]; - - expect( - nativePayeeRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'payee', - data: { addresses: [getChecksumAddress(PAYEE_A)] }, - }); - }); - - it('returns a payee rule with multiple decoded checksummed addresses', () => { - const caveats: ChecksumCaveat[] = [ - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [PAYEE_A, PAYEE_B] }), - args: '0x' as Hex, - }, - ]; - - expect( - nativePayeeRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'payee', - data: { - addresses: [getChecksumAddress(PAYEE_A), getChecksumAddress(PAYEE_B)], - }, - }); - }); - - it('returns checksummed payee addresses', () => { - const caveats: ChecksumCaveat[] = [ - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [CHECKSUM_PAYEE_INPUT] }), - args: '0x' as Hex, - }, - ]; - - expect( - nativePayeeRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'payee', - data: { addresses: [getChecksumAddress(CHECKSUM_PAYEE_INPUT)] }, - }); - }); - - it('throws when allowedTargetsEnforcer is configured as required', () => { - const requiredWithPayee = new Map([ - [nonceEnforcer, 1], - [allowedTargetsEnforcer, 1], - ]); - const caveats: ChecksumCaveat[] = [ - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [PAYEE_A] }), - args: '0x' as Hex, - }, - ]; - - expect(() => - nativePayeeRule({ - contractAddresses, - caveats, - requiredEnforcers: requiredWithPayee, - }), - ).toThrow( - 'Invalid payee caveats: payee enforcer may not be a required caveat', - ); - }); - - it('throws when more than one AllowedTargetsEnforcer caveat is present', () => { - const caveats: ChecksumCaveat[] = [ - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [PAYEE_A] }), - args: '0x' as Hex, - }, - { - enforcer: allowedTargetsEnforcer, - terms: createAllowedTargetsTerms({ targets: [PAYEE_B] }), - args: '0x' as Hex, - }, - ]; - - expect(() => - nativePayeeRule({ contractAddresses, caveats, requiredEnforcers }), - ).toThrow('Invalid payee caveats: multiple AllowedTargetsEnforcer caveats'); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativePayeeRule.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativePayeeRule.ts deleted file mode 100644 index c30279deee3..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativePayeeRule.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { decodeAllowedTargetsTerms } from '@metamask/delegation-core'; -import { getChecksumAddress } from '@metamask/utils'; - -import { EXECUTION_PERMISSION_PAYEE_RULE_TYPE } from '../../constants'; -import type { RuleDecoder } from '../types'; - -/** - * Rule decoder for native-token style payees, where the payee address(es) are - * encoded as the targets in an AllowedTargetsEnforcer caveat. - * - * Use this decoder for native-token permissions. For ERC-20 token permissions, - * use {@link erc20PayeeRule} instead. - * - * @param args - The arguments to this function. - * @param args.contractAddresses - Checksummed enforcer addresses for the chain. - * @param args.caveats - Checksummed caveats from the delegation. - * @param args.requiredEnforcers - Required enforcer counts for the permission. - * @returns A `Rule` result containing the payee addresses when an - * AllowedTargetsEnforcer caveat exists, otherwise `null`. - * @throws If the AllowedTargetsEnforcer is also a required enforcer (the - * payee enforcer must not be configured as required), or if multiple matching - * caveats are present. - */ -export const nativePayeeRule: RuleDecoder = ({ - contractAddresses, - caveats, - requiredEnforcers, -}) => { - const { allowedTargetsEnforcer } = contractAddresses; - - if (requiredEnforcers.has(allowedTargetsEnforcer)) { - throw new Error( - 'Invalid payee caveats: payee enforcer may not be a required caveat', - ); - } - - const matchingCaveats = caveats.filter( - (caveat) => caveat.enforcer === allowedTargetsEnforcer, - ); - - if (matchingCaveats.length === 0) { - return null; - } - - if (matchingCaveats.length > 1) { - throw new Error( - 'Invalid payee caveats: multiple AllowedTargetsEnforcer caveats', - ); - } - - const [caveat] = matchingCaveats; - const decoded = decodeAllowedTargetsTerms(caveat.terms); - - return { - type: EXECUTION_PERMISSION_PAYEE_RULE_TYPE, - data: { addresses: decoded.targets.map(getChecksumAddress) }, - }; -}; diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenAllowance.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenAllowance.test.ts deleted file mode 100644 index 383428beedd..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenAllowance.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '.'; - -describe('native-token-allowance decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { - TimestampEnforcer, - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'native-token-allowance', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const exactCalldataCaveat = { - enforcer: ExactCalldataEnforcer, - terms: '0x' as Hex, - args: '0x' as const, - }; - - const PERIOD_AMOUNT_HEX = 100n.toString(16).padStart(64, '0'); - const PERIOD_DURATION_MAX_HEX = 'f'.repeat(64); - const START_DATE_HEX = (1715664).toString(16).padStart(64, '0'); - const ALLOWANCE_TERMS = - `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; - - it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('rejects truncated terms', () => { - const truncatedTerms: Hex = `0x${'00'.repeat(40)}`; // 40 bytes, need 96 - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: truncatedTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: expected 96 bytes', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const termsWithTrailing = `${ALLOWANCE_TERMS}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: expected 96 bytes', - ); - }); - - it('rejects when ExactCalldataEnforcer terms are not 0x', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ExactCalldataEnforcer, - terms: '0x00' as Hex, - args: '0x' as const, - }, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid exact-calldata terms: must be 0x', - ); - }); - - it('successfully decodes valid native-token-allowance caveats', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: ALLOWANCE_TERMS, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({ - allowanceAmount: `0x${PERIOD_AMOUNT_HEX}`, - startTime: 1715664, - }); - }); - - it('successfully decodes when periodDuration uses uppercase UINT256_MAX', () => { - const uppercaseMax = 'F'.repeat(64); - const terms = - `0x${PERIOD_AMOUNT_HEX}${uppercaseMax}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - }); - - it('rejects when periodDuration is not UINT256_MAX', () => { - const nonMaxDuration = (86400).toString(16).padStart(64, '0'); - const terms = - `0x${PERIOD_AMOUNT_HEX}${nonMaxDuration}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', - ); - }); - - it('rejects when startTime is 0', () => { - const startTimeZero = '0'.repeat(64); - const terms = - `0x${PERIOD_AMOUNT_HEX}${PERIOD_DURATION_MAX_HEX}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: startTime must be a positive number', - ); - }); - - it('rejects when allowanceAmount is 0', () => { - const allowanceZero = '0'.repeat(64); - const terms = - `0x${allowanceZero}${PERIOD_DURATION_MAX_HEX}${START_DATE_HEX}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', - ); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenAllowance.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenAllowance.ts deleted file mode 100644 index 5683910071e..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenAllowance.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { hexToNumber } from '@metamask/utils'; - -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { - getByteLength, - getTermsByEnforcer, - splitHex, - UINT256_MAX, - ZERO_32_BYTES, -} from '../utils'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; -import { nativePayeeRule } from './nativePayeeRule'; -import { redeemerRule } from './redeemerRule'; - -/** - * Builds the configuration for the native-token-allowance permission decoder. - * - * This permission shares the same enforcer set as `native-token-periodic` but - * is distinguished by a `periodDuration` of `UINT256_MAX`, which effectively - * disables the periodic reset and turns the caveat into a one-off allowance. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The native-token-allowance permission decoder configuration. - */ -export function makeNativeTokenAllowanceDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - nativeTokenPeriodicEnforcer, - exactCalldataEnforcer, - nonceEnforcer, - allowedTargetsEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'native-token-allowance', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - allowedTargetsEnforcer, // payee rule - ], - requiredEnforcers: { - [nativeTokenPeriodicEnforcer]: 1, - [exactCalldataEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRule, nativePayeeRule], - validateAndDecodeData, - }; -} - -/** - * Decodes native-token-allowance permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Decoded allowance terms. - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = - contractAddresses; - - const exactCalldataTerms = getTermsByEnforcer({ - caveats, - enforcer: exactCalldataEnforcer, - }); - - if (exactCalldataTerms !== '0x') { - throw new Error('Invalid exact-calldata terms: must be 0x'); - } - - const terms = getTermsByEnforcer({ - caveats, - enforcer: nativeTokenPeriodicEnforcer, - }); - - const EXPECTED_TERMS_BYTELENGTH = 96; // 32 + 32 + 32 - - if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { - throw new Error('Invalid native-token-allowance terms: expected 96 bytes'); - } - - const [allowanceAmount, periodDurationRaw, startTimeRaw] = splitHex( - terms, - [32, 32, 32], - ); - - if (periodDurationRaw.toLowerCase() !== UINT256_MAX) { - throw new Error( - 'Invalid native-token-allowance terms: periodDuration must be UINT256_MAX', - ); - } - - const startTime = hexToNumber(startTimeRaw); - - if (startTime === 0) { - throw new Error( - 'Invalid native-token-allowance terms: startTime must be a positive number', - ); - } - - if (allowanceAmount === ZERO_32_BYTES) { - throw new Error( - 'Invalid native-token-allowance terms: allowanceAmount must be a positive number', - ); - } - - return { allowanceAmount, startTime }; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenPeriodic.test.ts deleted file mode 100644 index 677f0fdcc99..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenPeriodic.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { - createNativeTokenPeriodTransferTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '.'; -import { MAX_PERIOD_DURATION } from '../utils'; - -describe('native-token-periodic decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { - TimestampEnforcer, - NativeTokenPeriodTransferEnforcer, - ExactCalldataEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'native-token-periodic', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const exactCalldataCaveat = { - enforcer: ExactCalldataEnforcer, - terms: '0x' as Hex, - args: '0x' as const, - }; - - it('rejects duplicate NativeTokenPeriodTransferEnforcer caveats', () => { - const terms = createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ); - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('rejects truncated terms', () => { - const truncatedTerms: Hex = `0x${'00'.repeat(40)}`; // 40 bytes, need 96 - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: truncatedTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: expected 96 bytes', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const validTerms = createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ); - const termsWithTrailing = `${validTerms}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: expected 96 bytes', - ); - }); - - it('rejects when ExactCalldataEnforcer terms are not 0x', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ExactCalldataEnforcer, - terms: '0x00' as Hex, - args: '0x' as const, - }, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid exact-calldata terms: must be 0x', - ); - }); - - it('successfully decodes valid native-token-periodic caveats', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: 86400, - startDate: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data.periodAmount).toBeDefined(); - expect(result.data.periodDuration).toBe(86400); - expect(result.data.startTime).toBe(1715664); - }); - - it('rejects when periodDuration is 0', () => { - const periodAmountHex = 100n.toString(16).padStart(64, '0'); - const periodDurationZero = '0'.repeat(64); - const startDateHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${periodAmountHex}${periodDurationZero}${startDateHex}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: periodDuration must be a positive number', - ); - }); - - it('rejects when startTime is 0', () => { - const periodAmountHex = 100n.toString(16).padStart(64, '0'); - const periodDurationHex = (86400).toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = - `0x${periodAmountHex}${periodDurationHex}${startTimeZero}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: startTime must be a positive number', - ); - }); - - it('rejects when periodAmount is 0', () => { - const periodAmountZero = '0'.repeat(64); - const periodDurationHex = (86400).toString(16).padStart(64, '0'); - const startDateHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${periodAmountZero}${periodDurationHex}${startDateHex}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: periodAmount must be a positive number', - ); - }); - - it('rejects when periodDuration exceeds MAX_PERIOD_DURATION', () => { - const terms = createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: MAX_PERIOD_DURATION + 1, - startDate: 1715664, - }, - { out: 'hex' }, - ); - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', - ); - }); - - it('accepts when periodDuration equals MAX_PERIOD_DURATION', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenPeriodTransferEnforcer, - terms: createNativeTokenPeriodTransferTerms( - { - periodAmount: 100n, - periodDuration: MAX_PERIOD_DURATION, - startDate: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.data.periodDuration).toBe(MAX_PERIOD_DURATION); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenPeriodic.ts deleted file mode 100644 index 23d2d098adf..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenPeriodic.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; - -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { - getByteLength, - getTermsByEnforcer, - MAX_PERIOD_DURATION, - splitHex, -} from '../utils'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; -import { nativePayeeRule } from './nativePayeeRule'; -import { redeemerRule } from './redeemerRule'; - -/** - * Builds the configuration for the native-token-periodic permission decoder. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The native-token-periodic permission decoder configuration. - */ -export function makeNativeTokenPeriodicDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - nativeTokenPeriodicEnforcer, - exactCalldataEnforcer, - nonceEnforcer, - allowedTargetsEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'native-token-periodic', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - allowedTargetsEnforcer, // payee rule - ], - requiredEnforcers: { - [nativeTokenPeriodicEnforcer]: 1, - [exactCalldataEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRule, nativePayeeRule], - validateAndDecodeData, - }; -} - -/** - * Decodes native-token-periodic permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Decoded periodic terms. - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { nativeTokenPeriodicEnforcer, exactCalldataEnforcer } = - contractAddresses; - - const exactCalldataTerms = getTermsByEnforcer({ - caveats, - enforcer: exactCalldataEnforcer, - }); - - if (exactCalldataTerms !== '0x') { - throw new Error('Invalid exact-calldata terms: must be 0x'); - } - - const terms = getTermsByEnforcer({ - caveats, - enforcer: nativeTokenPeriodicEnforcer, - }); - - const EXPECTED_TERMS_BYTELENGTH = 96; // 32 + 32 + 32 - - if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { - throw new Error('Invalid native-token-periodic terms: expected 96 bytes'); - } - - const [periodAmount, periodDurationRaw, startTimeRaw] = splitHex( - terms, - [32, 32, 32], - ); - - const periodDuration = hexToNumber(periodDurationRaw); - const startTime = hexToNumber(startTimeRaw); - const periodAmountBigInt = hexToBigInt(periodAmount); - - if (periodAmountBigInt === 0n) { - throw new Error( - 'Invalid native-token-periodic terms: periodAmount must be a positive number', - ); - } - - if (periodDuration === 0) { - throw new Error( - 'Invalid native-token-periodic terms: periodDuration must be a positive number', - ); - } - - if (periodDuration > MAX_PERIOD_DURATION) { - throw new Error( - 'Invalid native-token-periodic terms: periodDuration must be less than or equal to MAX_PERIOD_DURATION', - ); - } - - if (startTime === 0) { - throw new Error( - 'Invalid native-token-periodic terms: startTime must be a positive number', - ); - } - - return { periodAmount, periodDuration, startTime }; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenStream.test.ts deleted file mode 100644 index 28edc4be5bc..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenStream.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { - createNativeTokenStreamingTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; -import type { Hex } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '.'; - -describe('native-token-stream decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { - TimestampEnforcer, - NativeTokenStreamingEnforcer, - ExactCalldataEnforcer, - } = contracts; - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'native-token-stream', - ); - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - const exactCalldataCaveat = { - enforcer: ExactCalldataEnforcer, - terms: '0x' as Hex, - args: '0x' as const, - }; - - it('rejects duplicate caveats for same enforcer (e.g. two TimestampEnforcer)', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 9999, - }), - args: '0x' as const, - }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain('Invalid caveats'); - }); - - it('rejects TimestampEnforcer terms with non-hex characters', () => { - const invalidTerms = - '0x00000000000000000000000000000000zz000000000000000000000000001a3b80' as Hex; - const caveats = [ - exactCalldataCaveat, - { enforcer: TimestampEnforcer, terms: invalidTerms, args: '0x' as const }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error).toBeDefined(); - }); - - it('rejects native-token-stream terms shorter than expected', () => { - const truncatedTerms: Hex = `0x${'00'.repeat(50)}`; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms: truncatedTerms, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-stream terms: expected 128 bytes', - ); - }); - - it('rejects when terms have trailing bytes', () => { - const validTerms = createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ); - const termsWithTrailing = `${validTerms}deadbeef` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms: termsWithTrailing, - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-stream terms: expected 128 bytes', - ); - }); - - it('rejects when ExactCalldataEnforcer terms are not 0x', () => { - const caveats = [ - expiryCaveat, - { - enforcer: ExactCalldataEnforcer, - terms: '0x00' as Hex, - args: '0x' as const, - }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 10n, - maxAmount: 100n, - amountPerSecond: 5n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid exact-calldata terms: must be 0x', - ); - }); - - it('successfully decodes valid native-token-stream caveats', () => { - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 10n, - maxAmount: 100n, - amountPerSecond: 5n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - // this is here as a type guard - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data.initialAmount).toBeDefined(); - expect(result.data.maxAmount).toBeDefined(); - expect(result.data.amountPerSecond).toBeDefined(); - expect(result.data.startTime).toBe(1715664); - }); - - it('rejects TimestampEnforcer terms with wrong length (66 required)', () => { - const badLengthTerms: Hex = `0x${'0'.repeat(65)}`; - const caveats = [ - exactCalldataCaveat, - { - enforcer: TimestampEnforcer, - terms: badLengthTerms, - args: '0x' as const, - }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid TimestampEnforcer terms length', - ); - }); - - it('rejects expiry timestampBeforeThreshold zero', () => { - const caveats = [ - exactCalldataCaveat, - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 0, - }), - args: '0x' as const, - }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid expiry: timestampBeforeThreshold must be greater than 0', - ); - }); - - it('rejects expiry timestampAfterThreshold non-zero', () => { - const caveats = [ - exactCalldataCaveat, - { - enforcer: TimestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 1, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }, - { - enforcer: NativeTokenStreamingEnforcer, - terms: createNativeTokenStreamingTerms( - { - initialAmount: 1n, - maxAmount: 2n, - amountPerSecond: 1n, - startTime: 1715664, - }, - { out: 'hex' }, - ), - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid expiry: timestampAfterThreshold must be 0', - ); - }); - - it('rejects when initialAmount exceeds maxAmount', () => { - const initialAmountHex = 100n.toString(16).padStart(64, '0'); - const maxAmountHex = 50n.toString(16).padStart(64, '0'); - const amountPerSecondHex = 1n.toString(16).padStart(64, '0'); - const startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', - ); - }); - - it('rejects when maxAmount equals initialAmount', () => { - const initialAmountAndMaxAmount = 100n.toString(16).padStart(64, '0'); - const amountPerSecondHex = 1n.toString(16).padStart(64, '0'); - const startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${initialAmountAndMaxAmount}${initialAmountAndMaxAmount}${amountPerSecondHex}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', - ); - }); - - it('rejects native-token-stream with all-zero amounts (validates amounts are positive)', () => { - const ZERO_32 = '0'.repeat(64); - const startTimeHex = '1a2b50'.padStart(64, '0'); - const terms = `0x${ZERO_32}${ZERO_32}${ZERO_32}${startTimeHex}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', - ); - }); - - it('rejects native-token-stream when amountPerSecond is zero', () => { - const initialAmountHex = 1n.toString(16).padStart(64, '0'); - const maxAmountHex = 2n.toString(16).padStart(64, '0'); - const amountPerSecondZero = '0'.repeat(64); - const startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${initialAmountHex}${maxAmountHex}${amountPerSecondZero}${startTimeHex}` as Hex; - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, - ]; - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-stream terms: amountPerSecond must be a positive number', - ); - }); - - it('rejects native-token-stream with startTime 0 (validates startTime is positive)', () => { - const oneHex = 1n.toString(16).padStart(64, '0'); - const twoHex = 2n.toString(16).padStart(64, '0'); - const startTimeZero = '0'.repeat(64); - const terms = `0x${oneHex}${twoHex}${oneHex}${startTimeZero}` as Hex; - - const caveats = [ - expiryCaveat, - exactCalldataCaveat, - { enforcer: NativeTokenStreamingEnforcer, terms, args: '0x' as const }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - // this is here as a type guard - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid native-token-stream terms: startTime must be a positive number', - ); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenStream.ts deleted file mode 100644 index b2dbffa8d45..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/nativeTokenStream.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { hexToBigInt, hexToNumber } from '@metamask/utils'; - -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { getByteLength, getTermsByEnforcer, splitHex } from '../utils'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; -import { nativePayeeRule } from './nativePayeeRule'; -import { redeemerRule } from './redeemerRule'; - -/** - * Builds the configuration for the native-token-stream permission decoder. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The native-token-stream permission decoder configuration. - */ -export function makeNativeTokenStreamDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { - timestampEnforcer, - nativeTokenStreamingEnforcer, - exactCalldataEnforcer, - nonceEnforcer, - allowedTargetsEnforcer, - redeemerEnforcer, - } = contractAddresses; - - return { - permissionType: 'native-token-stream', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - redeemerEnforcer, // redeemer rule - allowedTargetsEnforcer, // payee rule - ], - requiredEnforcers: { - [nativeTokenStreamingEnforcer]: 1, - [exactCalldataEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule, redeemerRule, nativePayeeRule], - validateAndDecodeData, - }; -} - -/** - * Decodes native-token-stream permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Decoded stream terms. - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { nativeTokenStreamingEnforcer, exactCalldataEnforcer } = - contractAddresses; - - const exactCalldataTerms = getTermsByEnforcer({ - caveats, - enforcer: exactCalldataEnforcer, - }); - - if (exactCalldataTerms !== '0x') { - throw new Error('Invalid exact-calldata terms: must be 0x'); - } - - const terms = getTermsByEnforcer({ - caveats, - enforcer: nativeTokenStreamingEnforcer, - }); - - const EXPECTED_TERMS_BYTELENGTH = 128; // 32 + 32 + 32 + 32 - - if (getByteLength(terms) !== EXPECTED_TERMS_BYTELENGTH) { - throw new Error('Invalid native-token-stream terms: expected 128 bytes'); - } - - const [initialAmount, maxAmount, amountPerSecond, startTimeRaw] = splitHex( - terms, - [32, 32, 32, 32], - ); - const initialAmountBigInt = hexToBigInt(initialAmount); - const maxAmountBigInt = hexToBigInt(maxAmount); - const amountPerSecondBigInt = hexToBigInt(amountPerSecond); - const startTime = hexToNumber(startTimeRaw); - - if (maxAmountBigInt <= initialAmountBigInt) { - throw new Error( - 'Invalid native-token-stream terms: maxAmount must be greater than initialAmount', - ); - } - - if (amountPerSecondBigInt === 0n) { - throw new Error( - 'Invalid native-token-stream terms: amountPerSecond must be a positive number', - ); - } - - if (startTime === 0) { - throw new Error( - 'Invalid native-token-stream terms: startTime must be a positive number', - ); - } - - return { initialAmount, maxAmount, amountPerSecond, startTime }; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/redeemerRule.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/redeemerRule.test.ts deleted file mode 100644 index c397fccf608..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/redeemerRule.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createRedeemerTerms } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; -import { getChecksumAddress } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; - -import type { ChecksumCaveat } from '../types'; -import { getChecksumEnforcersByChainId } from '../utils'; -import { redeemerRule } from './redeemerRule'; - -describe('redeemerRule', () => { - const contracts = DELEGATOR_CONTRACTS['1.3.0'][CHAIN_ID.sepolia]; - const contractAddresses = getChecksumEnforcersByChainId(contracts); - const { redeemerEnforcer, nonceEnforcer } = contractAddresses; - const requiredEnforcers = new Map([[nonceEnforcer, 1]]); - - const ADDRESS_A: Hex = '0x1111111111111111111111111111111111111111'; - const ADDRESS_B: Hex = '0x2222222222222222222222222222222222222222'; - const CHECKSUM_REDEEMER_INPUT: Hex = - '0x52908400098527886e0f7030069857d2e4169ee7'; - - it('returns null when no RedeemerEnforcer caveat is present', () => { - const caveats: ChecksumCaveat[] = [ - { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, - ]; - - expect( - redeemerRule({ contractAddresses, caveats, requiredEnforcers }), - ).toBeNull(); - }); - - it('returns a redeemer rule with a single decoded address', () => { - const caveats: ChecksumCaveat[] = [ - { - enforcer: redeemerEnforcer, - terms: createRedeemerTerms({ redeemers: [ADDRESS_A] }), - args: '0x' as Hex, - }, - ]; - - expect( - redeemerRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'redeemer', - data: { addresses: [ADDRESS_A] }, - }); - }); - - it('returns a redeemer rule with multiple decoded addresses', () => { - const caveats: ChecksumCaveat[] = [ - { - enforcer: redeemerEnforcer, - terms: createRedeemerTerms({ redeemers: [ADDRESS_A, ADDRESS_B] }), - args: '0x' as Hex, - }, - ]; - - expect( - redeemerRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'redeemer', - data: { addresses: [ADDRESS_A, ADDRESS_B] }, - }); - }); - - it('returns checksummed redeemer addresses', () => { - const caveats: ChecksumCaveat[] = [ - { - enforcer: redeemerEnforcer, - terms: createRedeemerTerms({ redeemers: [CHECKSUM_REDEEMER_INPUT] }), - args: '0x' as Hex, - }, - ]; - - expect( - redeemerRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'redeemer', - data: { addresses: [getChecksumAddress(CHECKSUM_REDEEMER_INPUT)] }, - }); - }); - - it('ignores caveats from unrelated enforcers', () => { - const caveats: ChecksumCaveat[] = [ - { enforcer: nonceEnforcer, terms: '0x' as Hex, args: '0x' as Hex }, - { - enforcer: redeemerEnforcer, - terms: createRedeemerTerms({ redeemers: [ADDRESS_A] }), - args: '0x' as Hex, - }, - ]; - - expect( - redeemerRule({ contractAddresses, caveats, requiredEnforcers }), - ).toStrictEqual({ - type: 'redeemer', - data: { addresses: [getChecksumAddress(ADDRESS_A)] }, - }); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/redeemerRule.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/redeemerRule.ts deleted file mode 100644 index f7f283a8958..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/redeemerRule.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { decodeRedeemerTerms } from '@metamask/delegation-core'; -import { getChecksumAddress } from '@metamask/utils'; - -import { EXECUTION_PERMISSION_REDEEMER_RULE_TYPE } from '../../constants'; -import type { RuleDecoder } from '../types'; -import { getTermsByEnforcer } from '../utils'; - -/** - * Rule decoder that extracts a redeemer allowlist from a RedeemerEnforcer - * caveat, when present. - * - * @param args - The arguments to this function. - * @param args.contractAddresses - Checksummed enforcer addresses for the chain. - * @param args.caveats - Checksummed caveats from the delegation. - * @returns A `Rule` containing the redeemer addresses when a - * RedeemerEnforcer caveat exists, otherwise `null`. - */ -export const redeemerRule: RuleDecoder = ({ contractAddresses, caveats }) => { - const { redeemerEnforcer } = contractAddresses; - - const redeemerTerms = getTermsByEnforcer({ - caveats, - enforcer: redeemerEnforcer, - throwIfNotFound: false, - }); - - if (!redeemerTerms) { - return null; - } - - return { - type: EXECUTION_PERMISSION_REDEEMER_RULE_TYPE, - data: { - addresses: - decodeRedeemerTerms(redeemerTerms).redeemers.map(getChecksumAddress), - }, - }; -}; diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.test.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.test.ts deleted file mode 100644 index 0d8b48a99a0..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { createTimestampTerms } from '@metamask/delegation-core'; -import { - CHAIN_ID, - DELEGATOR_CONTRACTS, -} from '@metamask/delegation-deployments'; - -import { createPermissionDecodersForContracts } from '.'; -import { getChecksumEnforcersByChainId } from '../utils'; - -describe('token-approval-revocation decoder', () => { - const chainId = CHAIN_ID.sepolia; - const contracts = DELEGATOR_CONTRACTS['1.3.0'][chainId]; - const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } = - getChecksumEnforcersByChainId(contracts); - const permissionDecoders = createPermissionDecodersForContracts(contracts); - const decoder = permissionDecoders.find( - (candidate) => candidate.permissionType === 'token-approval-revocation', - ); - - if (!decoder) { - throw new Error('Decoder not found'); - } - - const expiryCaveat = { - enforcer: timestampEnforcer, - terms: createTimestampTerms({ - afterThreshold: 0, - beforeThreshold: 1720000, - }), - args: '0x' as const, - }; - - it('rejects empty terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: must be greater than 0', - ); - }); - - it('rejects 0x00 terms', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x00' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: must be greater than 0', - ); - }); - - it('rejects terms whose mask exceeds the supported max', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x40' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(false); - - if (result.isValid) { - throw new Error('Expected invalid result'); - } - - expect(result.error.message).toContain( - 'Invalid ApprovalRevocation terms: must be less than or equal to 63', - ); - }); - - it('successfully decodes valid token-approval-revocation caveats', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x01' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.expiry).toBe(1720000); - expect(result.data).toStrictEqual({ - erc20Approve: true, - erc721Approve: false, - erc721SetApprovalForAll: false, - permit2Approve: false, - permit2Lockdown: false, - permit2InvalidateNonces: false, - }); - expect(result.rules).toStrictEqual([ - { - type: 'expiry', - data: { timestamp: 1720000 }, - }, - ]); - }); - - it('decodes all supported flags from the terms bitmask', () => { - const caveats = [ - expiryCaveat, - { - enforcer: approvalRevocationEnforcer, - terms: '0x3f' as const, - args: '0x' as const, - }, - { - enforcer: nonceEnforcer, - terms: '0x' as const, - args: '0x' as const, - }, - ]; - - const result = decoder.validateAndDecodePermission(caveats); - expect(result.isValid).toBe(true); - - if (!result.isValid) { - throw new Error('Expected valid result'); - } - - expect(result.data).toStrictEqual({ - erc20Approve: true, - erc721Approve: true, - erc721SetApprovalForAll: true, - permit2Approve: true, - permit2Lockdown: true, - permit2InvalidateNonces: true, - }); - }); -}); diff --git a/packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts b/packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts deleted file mode 100644 index 0b4758148d0..00000000000 --- a/packages/gator-permissions-controller/src/decodePermission/decoders/tokenApprovalRevocation.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable no-bitwise */ -import { hexToNumber } from '@metamask/utils'; - -import type { - ChecksumCaveat, - ChecksumEnforcersByChainId, - DecodedPermission, -} from '../types'; -import { getTermsByEnforcer } from '../utils'; -import { expiryRule } from './expiryRule'; -import type { MakePermissionDecoderConfig } from './makePermissionDecoder'; - -enum ApprovalRevocationFlag { - Erc20Approve = 0x01, - Erc721Approve = 0x02, - Erc721SetApprovalForAll = 0x04, - Permit2Approve = 0x08, - Permit2Lockdown = 0x10, - Permit2InvalidateNonces = 0x20, -} - -const MAX_APPROVAL_REVOCATION_MASK = - ApprovalRevocationFlag.Permit2InvalidateNonces | - ApprovalRevocationFlag.Permit2Lockdown | - ApprovalRevocationFlag.Permit2Approve | - ApprovalRevocationFlag.Erc721SetApprovalForAll | - ApprovalRevocationFlag.Erc721Approve | - ApprovalRevocationFlag.Erc20Approve; - -/** - * Builds the configuration for the token-approval-revocation permission decoder. - * - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns The token-approval-revocation permission decoder configuration. - */ -export function makeTokenApprovalRevocationDecoderConfig( - contractAddresses: ChecksumEnforcersByChainId, -): MakePermissionDecoderConfig { - const { timestampEnforcer, approvalRevocationEnforcer, nonceEnforcer } = - contractAddresses; - - return { - permissionType: 'token-approval-revocation', - contractAddresses, - optionalEnforcers: [ - timestampEnforcer, // expiry rule - ], - requiredEnforcers: { - [approvalRevocationEnforcer]: 1, - [nonceEnforcer]: 1, - }, - rules: [expiryRule], - validateAndDecodeData, - }; -} - -/** - * Decodes token-approval-revocation permission data from caveats; throws on invalid. - * - * @param caveats - Caveats from the permission context (checksummed). - * @param contractAddresses - Checksummed enforcer addresses for the chain. - * @returns Decoded approval-revocation capability flags. - */ -function validateAndDecodeData( - caveats: ChecksumCaveat[], - contractAddresses: ChecksumEnforcersByChainId, -): DecodedPermission['permission']['data'] { - const { approvalRevocationEnforcer } = contractAddresses; - - const terms = getTermsByEnforcer({ - caveats, - enforcer: approvalRevocationEnforcer, - }); - - if (terms === '0x') { - throw new Error('Invalid ApprovalRevocation terms: must be greater than 0'); - } - - const mask = hexToNumber(terms); - - if (mask > MAX_APPROVAL_REVOCATION_MASK) { - throw new Error( - `Invalid ApprovalRevocation terms: must be less than or equal to ${MAX_APPROVAL_REVOCATION_MASK}`, - ); - } - - if (mask === 0) { - throw new Error('Invalid ApprovalRevocation terms: must be greater than 0'); - } - - return { - erc20Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc20Approve), - erc721Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Erc721Approve), - erc721SetApprovalForAll: isFlagEnabled( - mask, - ApprovalRevocationFlag.Erc721SetApprovalForAll, - ), - permit2Approve: isFlagEnabled(mask, ApprovalRevocationFlag.Permit2Approve), - permit2Lockdown: isFlagEnabled( - mask, - ApprovalRevocationFlag.Permit2Lockdown, - ), - permit2InvalidateNonces: isFlagEnabled( - mask, - ApprovalRevocationFlag.Permit2InvalidateNonces, - ), - }; -} - -function isFlagEnabled(mask: number, flag: number): boolean { - return (mask & flag) === flag; -} diff --git a/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.test.ts b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.test.ts new file mode 100644 index 00000000000..733b63ad872 --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.test.ts @@ -0,0 +1,155 @@ +import { makePermissionDecoderConfigs } from '@metamask/7715-permission-types'; +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; +import { getChecksumAddress, isStrictHexString } from '@metamask/utils'; + +import { buildMockDelegationEnforcerContracts } from '../../tests/mocks'; +import type { DelegationDeploymentsEnforcerAddressesByName } from '../types'; +import { + toEnforcerAddressesByName, + delegationContractsByChainId, +} from './enforcerAddresses'; + +const ENFORCER_ADDRESS_KEYS = [ + 'allowedCalldataEnforcer', + 'allowedTargetsEnforcer', + 'redeemerEnforcer', + 'erc20StreamingEnforcer', + 'erc20PeriodTransferEnforcer', + 'nativeTokenStreamingEnforcer', + 'nativeTokenPeriodTransferEnforcer', + 'approvalRevocationEnforcer', + 'exactCalldataEnforcer', + 'valueLteEnforcer', + 'timestampEnforcer', + 'nonceEnforcer', +] as const satisfies readonly (keyof EnforcerAddressesByName)[]; + +const DEPLOYMENT_TO_ENFORCER_KEY = { + AllowedCalldataEnforcer: 'allowedCalldataEnforcer', + AllowedTargetsEnforcer: 'allowedTargetsEnforcer', + RedeemerEnforcer: 'redeemerEnforcer', + ERC20StreamingEnforcer: 'erc20StreamingEnforcer', + ERC20PeriodTransferEnforcer: 'erc20PeriodTransferEnforcer', + NativeTokenStreamingEnforcer: 'nativeTokenStreamingEnforcer', + NativeTokenPeriodTransferEnforcer: 'nativeTokenPeriodTransferEnforcer', + ApprovalRevocationEnforcer: 'approvalRevocationEnforcer', + ExactCalldataEnforcer: 'exactCalldataEnforcer', + ValueLteEnforcer: 'valueLteEnforcer', + TimestampEnforcer: 'timestampEnforcer', + NonceEnforcer: 'nonceEnforcer', +} as const satisfies Record< + keyof DelegationDeploymentsEnforcerAddressesByName, + keyof EnforcerAddressesByName +>; + +const DEPLOYMENT_CONTRACT_NAMES = Object.keys( + DEPLOYMENT_TO_ENFORCER_KEY, +) as (keyof DelegationDeploymentsEnforcerAddressesByName)[]; + +const expectValidEnforcerAddresses = ( + contracts: DelegationDeploymentsEnforcerAddressesByName, + result: EnforcerAddressesByName, +): void => { + expect(Object.keys(result).sort()).toStrictEqual( + [...ENFORCER_ADDRESS_KEYS].sort(), + ); + + for (const deploymentName of DEPLOYMENT_CONTRACT_NAMES) { + const enforcerKey = DEPLOYMENT_TO_ENFORCER_KEY[deploymentName]; + const sourceAddress = contracts[deploymentName]; + const resolvedAddress = result[enforcerKey]; + + expect(resolvedAddress).toBe(getChecksumAddress(sourceAddress)); + expect(isStrictHexString(resolvedAddress)).toBe(true); + expect(resolvedAddress).toBe(getChecksumAddress(resolvedAddress)); + } +}; + +describe('toEnforcerAddressesByName', () => { + describe('unit tests', () => { + it('maps deployment contract names to EnforcerAddressesByName keys with checksummed values', () => { + const contracts = buildMockDelegationEnforcerContracts(); + const result = toEnforcerAddressesByName(contracts); + + expect(result).toBeDefined(); + expectValidEnforcerAddresses(contracts, result); + }); + + it('checksums mixed-case deployment addresses', () => { + const contracts = buildMockDelegationEnforcerContracts(); + const mixedCaseContracts: DelegationDeploymentsEnforcerAddressesByName = { + ...contracts, + TimestampEnforcer: '0xAbCdEf0123456789AbCdEf0123456789AbCdEf01', + ERC20StreamingEnforcer: '0x0123456789abcdef0123456789abcdef01234567', + }; + + const result = toEnforcerAddressesByName(mixedCaseContracts); + + expect(result.timestampEnforcer).toBe( + getChecksumAddress(mixedCaseContracts.TimestampEnforcer), + ); + expect(result.erc20StreamingEnforcer).toBe( + getChecksumAddress(mixedCaseContracts.ERC20StreamingEnforcer), + ); + expect(result.timestampEnforcer).not.toBe( + mixedCaseContracts.TimestampEnforcer, + ); + }); + + it.each(DEPLOYMENT_CONTRACT_NAMES)( + 'throws when %s is missing', + (missingContractName) => { + const contracts = buildMockDelegationEnforcerContracts(); + const { [missingContractName]: _removed, ...incompleteContracts } = + contracts; + + expect(() => + toEnforcerAddressesByName( + incompleteContracts as DelegationDeploymentsEnforcerAddressesByName, + ), + ).toThrow(`Contract not found: ${missingContractName}`); + }, + ); + }); + + describe('integration with @metamask/delegation-deployments', () => { + it('resolves every deployed chain to a valid EnforcerAddressesByName', () => { + for (const [chainId, contracts] of Object.entries( + delegationContractsByChainId, + )) { + const result = toEnforcerAddressesByName(contracts); + + expectValidEnforcerAddresses(contracts, result); + expect(Number(chainId)).toBeGreaterThan(0); + } + }); + + it('produces addresses accepted by makePermissionDecoderConfigs for every deployed chain', () => { + for (const contracts of Object.values(delegationContractsByChainId)) { + const enforcerAddresses = toEnforcerAddressesByName(contracts); + const decoderConfigs = makePermissionDecoderConfigs(enforcerAddresses); + + expect(decoderConfigs.length).toBeGreaterThan(0); + for (const config of decoderConfigs) { + expect(config.contractAddresses).toStrictEqual(enforcerAddresses); + } + } + }); + + it('resolves sepolia deployment addresses to the expected canonical enforcers', () => { + const sepoliaChainId = 11155111; + const contracts = delegationContractsByChainId[sepoliaChainId]; + const result = toEnforcerAddressesByName(contracts); + + expect(result.timestampEnforcer).toBe( + getChecksumAddress(contracts.TimestampEnforcer), + ); + expect(result.nativeTokenStreamingEnforcer).toBe( + getChecksumAddress(contracts.NativeTokenStreamingEnforcer), + ); + expect(result.redeemerEnforcer).toBe( + getChecksumAddress(contracts.RedeemerEnforcer), + ); + }); + }); +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.ts b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.ts new file mode 100644 index 00000000000..3a15fbc27fe --- /dev/null +++ b/packages/gator-permissions-controller/src/decodePermission/enforcerAddresses.ts @@ -0,0 +1,74 @@ +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { getChecksumAddress } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { DELEGATION_FRAMEWORK_VERSION } from '../constants'; +import type { DelegationDeploymentsEnforcerAddressesByName } from '../types'; + +// @metamask/delegation-deployments exports a very loosely typed object. We assert a more narrow typing here. +export const delegationContractsByChainId = DELEGATOR_CONTRACTS[ + DELEGATION_FRAMEWORK_VERSION +] as Record; + +const getChecksumContractAddress = ( + contracts: DelegationDeploymentsEnforcerAddressesByName, + contractName: keyof DelegationDeploymentsEnforcerAddressesByName, +): Hex => { + const address = contracts[contractName]; + + if (!address) { + throw new Error(`Contract not found: ${contractName}`); + } + + return getChecksumAddress(address); +}; + +/** + * Converts delegation-deployments enforcer addresses to the canonical shape + * expected by `@metamask/7715-permission-types`, checksumming each address. + * + * @param contracts - Enforcer addresses keyed by delegation-deployments names. + * @returns Checksummed enforcer addresses keyed by permission-types names. + * @throws If an expected enforcer contract is not found. + */ +export const toEnforcerAddressesByName = ( + contracts: DelegationDeploymentsEnforcerAddressesByName, +): EnforcerAddressesByName => ({ + allowedCalldataEnforcer: getChecksumContractAddress( + contracts, + 'AllowedCalldataEnforcer', + ), + allowedTargetsEnforcer: getChecksumContractAddress( + contracts, + 'AllowedTargetsEnforcer', + ), + redeemerEnforcer: getChecksumContractAddress(contracts, 'RedeemerEnforcer'), + erc20StreamingEnforcer: getChecksumContractAddress( + contracts, + 'ERC20StreamingEnforcer', + ), + erc20PeriodTransferEnforcer: getChecksumContractAddress( + contracts, + 'ERC20PeriodTransferEnforcer', + ), + nativeTokenStreamingEnforcer: getChecksumContractAddress( + contracts, + 'NativeTokenStreamingEnforcer', + ), + nativeTokenPeriodTransferEnforcer: getChecksumContractAddress( + contracts, + 'NativeTokenPeriodTransferEnforcer', + ), + approvalRevocationEnforcer: getChecksumContractAddress( + contracts, + 'ApprovalRevocationEnforcer', + ), + exactCalldataEnforcer: getChecksumContractAddress( + contracts, + 'ExactCalldataEnforcer', + ), + valueLteEnforcer: getChecksumContractAddress(contracts, 'ValueLteEnforcer'), + timestampEnforcer: getChecksumContractAddress(contracts, 'TimestampEnforcer'), + nonceEnforcer: getChecksumContractAddress(contracts, 'NonceEnforcer'), +}); diff --git a/packages/gator-permissions-controller/src/decodePermission/index.ts b/packages/gator-permissions-controller/src/decodePermission/index.ts index 696f123f69d..03ef6f911c6 100644 --- a/packages/gator-permissions-controller/src/decodePermission/index.ts +++ b/packages/gator-permissions-controller/src/decodePermission/index.ts @@ -3,7 +3,6 @@ export { reconstructDecodedPermission, selectUniqueDecoderAndDecodedPermission, } from './decodePermission'; -export { createPermissionDecodersForContracts } from './decoders'; export type { DecodedPermission, diff --git a/packages/gator-permissions-controller/src/decodePermission/types.ts b/packages/gator-permissions-controller/src/decodePermission/types.ts index 8b3b3925826..8b1a298a57a 100644 --- a/packages/gator-permissions-controller/src/decodePermission/types.ts +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -1,74 +1,11 @@ import type { - BasePermission, - MetaMaskBasePermissionData, PermissionRequest, PermissionTypes, Rule, } from '@metamask/7715-permission-types'; -import type { - ApprovalRevocationTerms, - Caveat, -} from '@metamask/delegation-core'; -import type { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import type { Caveat } from '@metamask/delegation-core'; import type { Hex } from '@metamask/utils'; -export type DeployedContractsByName = - (typeof DELEGATOR_CONTRACTS)[number][number]; - -/** - * Permission type for an unbounded ERC-20 token allowance. - * - * Encoded on-chain as an ERC20PeriodTransferEnforcer caveat with - * `periodDuration` set to `UINT256_MAX` so that the allowance never resets - * within any realistic time horizon. - * - * Not yet defined in `@metamask/7715-permission-types`, so declared locally. - */ -type Erc20TokenAllowancePermission = BasePermission & { - type: 'erc20-token-allowance'; - data: MetaMaskBasePermissionData & { - allowanceAmount: Hex; - startTime?: number | null; - tokenAddress: Hex; - }; -}; - -/** - * Permission type for an unbounded native token allowance. - * - * Encoded on-chain as a NativeTokenPeriodTransferEnforcer caveat with - * `periodDuration` set to `UINT256_MAX`. - * - * Not yet defined in `@metamask/7715-permission-types`, so declared locally. - */ -type NativeTokenAllowancePermission = BasePermission & { - type: 'native-token-allowance'; - data: MetaMaskBasePermissionData & { - allowanceAmount: Hex; - startTime?: number | null; - }; -}; - -/** - * Permission type for token approval revocation. - * - * Not yet defined in `@metamask/7715-permission-types`, so declared locally. - */ -type TokenApprovalRevocationPermission = BasePermission & { - type: 'token-approval-revocation'; - data: MetaMaskBasePermissionData & ApprovalRevocationTerms; -}; - -/** - * Extended permission union, including types not yet published in - * `@metamask/7715-permission-types` but supported by this package's decoder. - */ -type ExtendedPermissionTypes = - | PermissionTypes - | Erc20TokenAllowancePermission - | NativeTokenAllowancePermission - | TokenApprovalRevocationPermission; - // This is a somewhat convoluted type - it includes all of the fields that are decoded from the permission context. /** * A partially reconstructed permission object decoded from a permission context. @@ -86,8 +23,8 @@ export type DecodedPermission = Pick< PermissionRequest['permission'], 'isAdjustmentAllowed' | 'type' | 'data' > & { - type: ExtendedPermissionTypes['type']; - data: ExtendedPermissionTypes['data']; + type: PermissionTypes['type']; + data: PermissionTypes['data']; // PermissionRequest type does not work well without the specific permission type, so we amend it here justification?: string; }; @@ -105,27 +42,6 @@ export type DecodedPermission = Pick< */ export type PermissionType = DecodedPermission['permission']['type']; -/** - * Checksummed enforcer contract addresses for a chain (from getChecksumEnforcersByChainId). - */ -export type ChecksumEnforcersByChainId = { - erc20StreamingEnforcer: Hex; - erc20PeriodicEnforcer: Hex; - nativeTokenStreamingEnforcer: Hex; - nativeTokenPeriodicEnforcer: Hex; - approvalRevocationEnforcer: Hex; - exactCalldataEnforcer: Hex; - valueLteEnforcer: Hex; - timestampEnforcer: Hex; - nonceEnforcer: Hex; - allowedCalldataEnforcer: Hex; - allowedTargetsEnforcer: Hex; - redeemerEnforcer: Hex; -}; - -/** Caveat with checksummed enforcer address; used by rule decode functions. */ -export type ChecksumCaveat = Caveat; - /** * Result of validating and decoding permission terms from caveats. * When valid, includes expiry and decoded data; when invalid, includes the error. @@ -162,17 +78,3 @@ export type PermissionDecoder = { caveats: Caveat[], ) => ValidateAndDecodeResult; }; - -/** - * A function that inspects checksummed caveats and optionally produces a - * {@link Rule} (e.g. redeemer, payee, expiry). Each rule decoder is - * responsible for a single rule type and is composed by - * `makePermissionDecoder` to populate the permission's `rules` array. The - * `expiry` rule, in addition to being appended to `rules`, has its value - * hoisted onto the top-level `expiry` field of the decoded permission. - */ -export type RuleDecoder = (args: { - contractAddresses: ChecksumEnforcersByChainId; - caveats: ChecksumCaveat[]; - requiredEnforcers: Map; -}) => Rule | null; diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts index d28bc61de4b..72b127dbf02 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -1,86 +1,24 @@ import type { Caveat } from '@metamask/delegation-core'; -import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { buildMockDelegationEnforcerContracts } from '../../tests/mocks'; import { createPermissionDecodersForContracts } from './decoders'; -import type { DeployedContractsByName } from './types'; +import { toEnforcerAddressesByName } from './enforcerAddresses'; import { - getChecksumEnforcersByChainId, + extractExpiryFromCaveatTerms, getTermsByEnforcer, splitHex, } from './utils'; -// Helper to build a contracts map with lowercase addresses -const buildContracts = (): DeployedContractsByName => ({ - ERC20PeriodTransferEnforcer: '0x1111111111111111111111111111111111111111', - ERC20StreamingEnforcer: '0x2222222222222222222222222222222222222222', - ApprovalRevocationEnforcer: '0x1212121212121212121212121212121212121212', - ExactCalldataEnforcer: '0x3333333333333333333333333333333333333333', - NativeTokenPeriodTransferEnforcer: - '0x4444444444444444444444444444444444444444', - NativeTokenStreamingEnforcer: '0x5555555555555555555555555555555555555555', - TimestampEnforcer: '0x6666666666666666666666666666666666666666', - ValueLteEnforcer: '0x7777777777777777777777777777777777777777', - NonceEnforcer: '0x8888888888888888888888888888888888888888', - AllowedCalldataEnforcer: '0x9999999999999999999999999999999999999999', - AllowedTargetsEnforcer: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - RedeemerEnforcer: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', -}); - -describe('getChecksumEnforcersByChainId', () => { - it('returns checksummed addresses for all known enforcers', () => { - const contracts = buildContracts(); - const result = getChecksumEnforcersByChainId(contracts); - - expect(result).toStrictEqual({ - erc20StreamingEnforcer: getChecksumAddress( - contracts.ERC20StreamingEnforcer, - ), - erc20PeriodicEnforcer: getChecksumAddress( - contracts.ERC20PeriodTransferEnforcer, - ), - nativeTokenStreamingEnforcer: getChecksumAddress( - contracts.NativeTokenStreamingEnforcer, - ), - nativeTokenPeriodicEnforcer: getChecksumAddress( - contracts.NativeTokenPeriodTransferEnforcer, - ), - approvalRevocationEnforcer: getChecksumAddress( - contracts.ApprovalRevocationEnforcer, - ), - exactCalldataEnforcer: getChecksumAddress( - contracts.ExactCalldataEnforcer, - ), - valueLteEnforcer: getChecksumAddress(contracts.ValueLteEnforcer), - timestampEnforcer: getChecksumAddress(contracts.TimestampEnforcer), - nonceEnforcer: getChecksumAddress(contracts.NonceEnforcer), - allowedCalldataEnforcer: getChecksumAddress( - contracts.AllowedCalldataEnforcer, - ), - allowedTargetsEnforcer: getChecksumAddress( - contracts.AllowedTargetsEnforcer, - ), - redeemerEnforcer: getChecksumAddress(contracts.RedeemerEnforcer), - }); - }); - - it('throws if a required contract is missing', () => { - const contracts = buildContracts(); - delete contracts.ValueLteEnforcer; - expect(() => getChecksumEnforcersByChainId(contracts)).toThrow( - 'Contract not found: ValueLteEnforcer', - ); - }); -}); - describe('createPermissionDecodersForContracts', () => { it('builds canonical decoders with correct required and allowed enforcers', () => { - const contracts = buildContracts(); + const contracts = buildMockDelegationEnforcerContracts(); + const enforcerAddresses = toEnforcerAddressesByName(contracts); const { erc20StreamingEnforcer, - erc20PeriodicEnforcer, + erc20PeriodTransferEnforcer, nativeTokenStreamingEnforcer, - nativeTokenPeriodicEnforcer, + nativeTokenPeriodTransferEnforcer, approvalRevocationEnforcer, exactCalldataEnforcer, valueLteEnforcer, @@ -89,7 +27,7 @@ describe('createPermissionDecodersForContracts', () => { allowedCalldataEnforcer, allowedTargetsEnforcer, redeemerEnforcer, - } = getChecksumEnforcersByChainId(contracts); + } = enforcerAddresses; // erc20-token-stream // erc20-token-periodic @@ -97,10 +35,9 @@ describe('createPermissionDecodersForContracts', () => { // native-token-stream // native-token-periodic // native-token-allowance - // erc20-token-revocation // token-approval-revocation - const permissionTypeCount = 8; - const decoders = createPermissionDecodersForContracts(contracts); + const permissionTypeCount = 7; + const decoders = createPermissionDecodersForContracts(enforcerAddresses); expect(decoders).toHaveLength(permissionTypeCount); const byType = Object.fromEntries( @@ -157,7 +94,7 @@ describe('createPermissionDecodersForContracts', () => { Array.from(byType['native-token-periodic'].requiredEnforcers.entries()), ).toStrictEqual( expect.arrayContaining([ - [nativeTokenPeriodicEnforcer, 1], + [nativeTokenPeriodTransferEnforcer, 1], [exactCalldataEnforcer, 1], [nonceEnforcer, 1], ]), @@ -213,7 +150,7 @@ describe('createPermissionDecodersForContracts', () => { Array.from(byType['erc20-token-periodic'].requiredEnforcers.entries()), ).toStrictEqual( expect.arrayContaining([ - [erc20PeriodicEnforcer, 1], + [erc20PeriodTransferEnforcer, 1], [valueLteEnforcer, 1], [nonceEnforcer, 1], ]), @@ -241,7 +178,7 @@ describe('createPermissionDecodersForContracts', () => { Array.from(byType['native-token-allowance'].requiredEnforcers.entries()), ).toStrictEqual( expect.arrayContaining([ - [nativeTokenPeriodicEnforcer, 1], + [nativeTokenPeriodTransferEnforcer, 1], [exactCalldataEnforcer, 1], [nonceEnforcer, 1], ]), @@ -269,30 +206,7 @@ describe('createPermissionDecodersForContracts', () => { Array.from(byType['erc20-token-allowance'].requiredEnforcers.entries()), ).toStrictEqual( expect.arrayContaining([ - [erc20PeriodicEnforcer, 1], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - ); - - // erc20-token-revocation - expect(byType['erc20-token-revocation']).toBeDefined(); - expect(byType['erc20-token-revocation'].permissionType).toBe( - 'erc20-token-revocation', - ); - expect(byType['erc20-token-revocation'].optionalEnforcers.size).toBe(2); - expect( - byType['erc20-token-revocation'].optionalEnforcers.has(timestampEnforcer), - ).toBe(true); - expect( - byType['erc20-token-revocation'].optionalEnforcers.has(redeemerEnforcer), - ).toBe(true); - expect(byType['erc20-token-revocation'].requiredEnforcers.size).toBe(3); - expect( - Array.from(byType['erc20-token-revocation'].requiredEnforcers.entries()), - ).toStrictEqual( - expect.arrayContaining([ - [allowedCalldataEnforcer, 2], + [erc20PeriodTransferEnforcer, 1], [valueLteEnforcer, 1], [nonceEnforcer, 1], ]), @@ -323,14 +237,15 @@ describe('createPermissionDecodersForContracts', () => { }); it('each decoder has caveatAddressesMatch and validateAndDecodePermission', () => { - const contracts = buildContracts(); - const decoders = createPermissionDecodersForContracts(contracts); + const contracts = buildMockDelegationEnforcerContracts(); + const enforcerAddresses = toEnforcerAddressesByName(contracts); + const decoders = createPermissionDecodersForContracts(enforcerAddresses); const { nativeTokenStreamingEnforcer, exactCalldataEnforcer, nonceEnforcer, timestampEnforcer, - } = getChecksumEnforcersByChainId(contracts); + } = enforcerAddresses; for (const decoder of decoders) { expect(typeof decoder.caveatAddressesMatch).toBe('function'); @@ -431,6 +346,40 @@ describe('getTermsByEnforcer', () => { }); }); +describe('extractExpiryFromCaveatTerms', () => { + it('returns expiry from valid TimestampEnforcer terms', () => { + const expiry = 1735689600n; + const terms = + `0x${'0'.repeat(32)}${expiry.toString(16).padStart(32, '0')}` as Hex; + + expect(extractExpiryFromCaveatTerms(terms)).toBe(Number(expiry)); + }); + + it('throws if terms length is not 66 characters', () => { + const invalidTerms = '0x1234' as Hex; + expect(() => extractExpiryFromCaveatTerms(invalidTerms)).toThrow( + 'Invalid TimestampEnforcer terms length: expected 66 characters (0x + 64 hex), got 6', + ); + }); + + it('throws if timestampAfterThreshold is non-zero', () => { + const terms = + '0x0000000000000000000000000000000100000000000000000000000000000001' as Hex; + + expect(() => extractExpiryFromCaveatTerms(terms)).toThrow( + 'Invalid expiry: timestampAfterThreshold must be 0', + ); + }); + + it('throws if timestampBeforeThreshold is zero', () => { + const terms = `0x${'0'.repeat(64)}`; + + expect(() => extractExpiryFromCaveatTerms(terms)).toThrow( + 'Invalid expiry: timestampBeforeThreshold must be greater than 0', + ); + }); +}); + describe('splitHex', () => { it('splits per byte lengths and preserves leading zeros', () => { const value = '0x00a0b0' as Hex; // 3 bytes diff --git a/packages/gator-permissions-controller/src/decodePermission/utils.ts b/packages/gator-permissions-controller/src/decodePermission/utils.ts index a1dac594723..23d6d5ecc14 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -2,142 +2,6 @@ import type { Caveat } from '@metamask/delegation-core'; import { getChecksumAddress, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { - ChecksumEnforcersByChainId, - DeployedContractsByName, -} from './types'; - -/** - * The names of the enforcer contracts for each permission type. - */ -const ENFORCER_CONTRACT_NAMES = { - ERC20PeriodTransferEnforcer: 'ERC20PeriodTransferEnforcer', - ERC20StreamingEnforcer: 'ERC20StreamingEnforcer', - ApprovalRevocationEnforcer: 'ApprovalRevocationEnforcer', - ExactCalldataEnforcer: 'ExactCalldataEnforcer', - NativeTokenPeriodTransferEnforcer: 'NativeTokenPeriodTransferEnforcer', - NativeTokenStreamingEnforcer: 'NativeTokenStreamingEnforcer', - TimestampEnforcer: 'TimestampEnforcer', - ValueLteEnforcer: 'ValueLteEnforcer', - NonceEnforcer: 'NonceEnforcer', - AllowedCalldataEnforcer: 'AllowedCalldataEnforcer', - AllowedTargetsEnforcer: 'AllowedTargetsEnforcer', - RedeemerEnforcer: 'RedeemerEnforcer', -}; - -/** - * 32 bytes of zero (0x + 64 hex chars). - */ -export const ZERO_32_BYTES = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const; - -/** - * Maximum unsigned 256-bit integer encoded as 32 bytes (0x + 64 hex chars). - */ -export const UINT256_MAX = - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as const; - -/** AllowedCalldataEnforcer terms for ERC20 approve selector. */ -export const ERC20_APPROVE_SELECTOR_TERMS = - '0x0000000000000000000000000000000000000000000000000000000000000000095ea7b3' as const; - -/** AllowedCalldataEnforcer terms for ERC20 approve zero amount. */ -export const ERC20_APPROVE_ZERO_AMOUNT_TERMS = - '0x00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000' as const; - -/** Maximum period duration in seconds. */ -export const MAX_PERIOD_DURATION = 10 * 365 * 24 * 60 * 60; // 10 years in seconds - -/** - * Get the byte length of a hex string. - * - * @param hexString - The hex string to get the byte length of. - * @returns The byte length of the hex string. - */ -export const getByteLength = (hexString: Hex): number => { - return (hexString.length - 2) / 2; -}; - -/** - * Resolves and returns checksummed addresses of all known enforcer contracts - * for a given `chainId` under the current delegation framework version. - * - * @param contracts - The deployed contracts for the chain. - * @returns An object mapping enforcer names to checksummed contract addresses. - * @throws If the chain or an expected enforcer contract is not found. - */ -export const getChecksumEnforcersByChainId = ( - contracts: DeployedContractsByName, -): ChecksumEnforcersByChainId => { - const getChecksumContractAddress = (contractName: string): Hex => { - const address = contracts[contractName]; - - if (!address) { - throw new Error(`Contract not found: ${contractName}`); - } - - return getChecksumAddress(address); - }; - - // permission type specific enforcers - const erc20StreamingEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.ERC20StreamingEnforcer, - ); - const erc20PeriodicEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.ERC20PeriodTransferEnforcer, - ); - const nativeTokenStreamingEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.NativeTokenStreamingEnforcer, - ); - const nativeTokenPeriodicEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.NativeTokenPeriodTransferEnforcer, - ); - const approvalRevocationEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.ApprovalRevocationEnforcer, - ); - - // general enforcers - const exactCalldataEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.ExactCalldataEnforcer, - ); - const valueLteEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.ValueLteEnforcer, - ); - const timestampEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.TimestampEnforcer, - ); - const nonceEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.NonceEnforcer, - ); - - const allowedCalldataEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.AllowedCalldataEnforcer, - ); - - const allowedTargetsEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.AllowedTargetsEnforcer, - ); - - const redeemerEnforcer = getChecksumContractAddress( - ENFORCER_CONTRACT_NAMES.RedeemerEnforcer, - ); - - return { - erc20StreamingEnforcer, - erc20PeriodicEnforcer, - nativeTokenStreamingEnforcer, - nativeTokenPeriodicEnforcer, - approvalRevocationEnforcer, - exactCalldataEnforcer, - valueLteEnforcer, - timestampEnforcer, - nonceEnforcer, - allowedCalldataEnforcer, - allowedTargetsEnforcer, - redeemerEnforcer, - }; -}; - /** * Extracts the expiry timestamp from TimestampEnforcer caveat terms. * Terms are 32 bytes: first 16 bytes timestampAfterThreshold (must be 0), diff --git a/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts b/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts index 2b7eb658394..659e5049388 100644 --- a/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts +++ b/packages/gator-permissions-controller/src/permissionOnChainStatus.test.ts @@ -13,6 +13,7 @@ import { hexToBigInt, numberToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { DELEGATION_FRAMEWORK_VERSION } from './constants'; +import { toEnforcerAddressesByName } from './decodePermission/enforcerAddresses'; import { encodeDisabledDelegationsCalldata, getExpiryFromDelegation, @@ -22,11 +23,17 @@ import { updateGrantedPermissionsStatus, } from './permissionOnChainStatus'; import type { PermissionInfoWithMetadata } from './types'; +import type { DelegationDeploymentsEnforcerAddressesByName } from './types'; -const contracts = +const sepoliaContracts = DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][CHAIN_ID.sepolia]; -const { TimestampEnforcer, NativeTokenStreamingEnforcer } = contracts; +const delegationContracts = + sepoliaContracts as DelegationDeploymentsEnforcerAddressesByName; + +const enforcerAddresses = toEnforcerAddressesByName(delegationContracts); + +const { TimestampEnforcer, NativeTokenStreamingEnforcer } = delegationContracts; describe('permissionOnChainStatus', () => { describe('encodeDisabledDelegationsCalldata', () => { @@ -70,7 +77,7 @@ describe('permissionOnChainStatus', () => { salt: 0n, signature: '0x' as const, }; - const result = getExpiryFromDelegation(delegation, contracts); + const result = getExpiryFromDelegation(delegation, enforcerAddresses); expect(result).toBe(expirySeconds); }); @@ -94,7 +101,7 @@ describe('permissionOnChainStatus', () => { salt: 0n, signature: '0x' as const, }; - expect(getExpiryFromDelegation(delegation, contracts)).toBeNull(); + expect(getExpiryFromDelegation(delegation, enforcerAddresses)).toBeNull(); }); it('returns null when TimestampEnforcer terms fail to decode', () => { @@ -113,7 +120,7 @@ describe('permissionOnChainStatus', () => { salt: 0n, signature: '0x' as const, }; - expect(getExpiryFromDelegation(delegation, contracts)).toBeNull(); + expect(getExpiryFromDelegation(delegation, enforcerAddresses)).toBeNull(); }); }); @@ -129,7 +136,7 @@ describe('permissionOnChainStatus', () => { expect( await readDelegationDisabledOnChain({ provider, - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, delegationHash: '0x1111111111111111111111111111111111111111111111111111111111111111', }), @@ -159,9 +166,6 @@ describe('permissionOnChainStatus', () => { }); describe('resolveGrantedPermissionOnChainStatus', () => { - const contractsByChainId = - DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]; - it('returns Revoked when revocationMetadata is present without calling the network', async () => { const getProviderForChainId = jest.fn(); const entry: PermissionInfoWithMetadata = { @@ -180,7 +184,7 @@ describe('permissionOnChainStatus', () => { }, }, context: '0x00000000', - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', status: 'Active', @@ -189,7 +193,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId, }); expect(result.status).toBe('Revoked'); @@ -214,7 +217,7 @@ describe('permissionOnChainStatus', () => { }, }, context: '0x00000000', - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', status: 'Expired', @@ -222,7 +225,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId, }); expect(result.status).toBe('Expired'); @@ -247,7 +249,7 @@ describe('permissionOnChainStatus', () => { }, }, context: '0x00000000', - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', // status is missing, so we must add type assertion @@ -255,7 +257,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId, }); expect(result.status).toBe('Active'); @@ -299,7 +300,7 @@ describe('permissionOnChainStatus', () => { }, }, context, - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', status: 'Expired', @@ -319,7 +320,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId, }); expect(result.status).toBe('Active'); @@ -373,7 +373,7 @@ describe('permissionOnChainStatus', () => { }, }, context, - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', status: 'Active', @@ -393,7 +393,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId, }); expect(result.status).toBe('Expired'); @@ -436,7 +435,7 @@ describe('permissionOnChainStatus', () => { }, }, context, - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', status: 'Active', @@ -453,7 +452,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId, }); expect(result.status).toBe('Revoked'); @@ -482,7 +480,7 @@ describe('permissionOnChainStatus', () => { const context = encodeDelegations([delegation]); const entry: PermissionInfoWithMetadata = { permissionResponse: { - chainId: numberToHex(CHAIN_ID.sepolia), + chainId: numberToHex(999999), from: delegation.delegator, permission: { type: 'native-token-stream', @@ -496,7 +494,7 @@ describe('permissionOnChainStatus', () => { }, }, context, - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', status: 'Expired', @@ -513,7 +511,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId: {}, }); expect(result.status).toBe('Expired'); @@ -567,7 +564,7 @@ describe('permissionOnChainStatus', () => { }, }, context, - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://example.org', status: 'Expired', @@ -587,7 +584,6 @@ describe('permissionOnChainStatus', () => { const result = await resolveGrantedPermissionOnChainStatus(entry, { getProviderForChainId, - contractsByChainId, }); expect(result.status).toBe('Active'); @@ -597,9 +593,6 @@ describe('permissionOnChainStatus', () => { describe('updateGrantedPermissionsStatus', () => { it('resolves each permission entry', async () => { const getProviderForChainId = jest.fn(); - const contractsByChainId = - DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]; - const entry: PermissionInfoWithMetadata = { permissionResponse: { chainId: numberToHex(CHAIN_ID.sepolia), @@ -616,7 +609,7 @@ describe('permissionOnChainStatus', () => { }, }, context: '0x00000000', - delegationManager: contracts.DelegationManager, + delegationManager: sepoliaContracts.DelegationManager, }, siteOrigin: 'https://a.example', status: 'Active', @@ -625,7 +618,7 @@ describe('permissionOnChainStatus', () => { const results = await updateGrantedPermissionsStatus( [entry, { ...entry, siteOrigin: 'https://b.example' }], - { getProviderForChainId, contractsByChainId }, + { getProviderForChainId }, ); expect(results).toHaveLength(2); diff --git a/packages/gator-permissions-controller/src/permissionOnChainStatus.ts b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts index 6ac35cfde3a..45409185361 100644 --- a/packages/gator-permissions-controller/src/permissionOnChainStatus.ts +++ b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts @@ -1,14 +1,15 @@ +import type { EnforcerAddressesByName } from '@metamask/7715-permission-types'; import { encodeSingle, decodeSingle } from '@metamask/abi-utils'; import { decodeDelegations, hashDelegation } from '@metamask/delegation-core'; import type { Delegation } from '@metamask/delegation-core'; import { bytesToHex, getChecksumAddress, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { DeployedContractsByName } from './decodePermission/types'; import { - extractExpiryFromCaveatTerms, - getChecksumEnforcersByChainId, -} from './decodePermission/utils'; + delegationContractsByChainId, + toEnforcerAddressesByName, +} from './decodePermission/enforcerAddresses'; +import { extractExpiryFromCaveatTerms } from './decodePermission/utils'; import { controllerLog } from './logger'; import type { PermissionInfoWithMetadata, @@ -34,7 +35,6 @@ export type GetProviderForChainId = ( export type PermissionOnChainStatusOptions = { getProviderForChainId: GetProviderForChainId; - contractsByChainId: Record; }; /** @@ -97,15 +97,16 @@ export async function readLatestBlockTimestampSeconds( * Reads TimestampEnforcer expiry (unix seconds) from the leaf delegation's caveats. * * @param leaf - Leaf delegation (index 0 when decoded from permission context). - * @param contracts - Deployed enforcer addresses for the chain. + * @param contracts - enforcer addresses for the chain. * @returns Expiry timestamp in seconds, or `null` if no valid timestamp caveat. */ export function getExpiryFromDelegation( leaf: Delegation, - contracts: DeployedContractsByName, + contracts: EnforcerAddressesByName, ): number | null { - const { timestampEnforcer } = getChecksumEnforcersByChainId(contracts); - const targetEnforcer = getChecksumAddress(timestampEnforcer).toLowerCase(); + const targetEnforcer = getChecksumAddress( + contracts.timestampEnforcer, + ).toLowerCase(); const timestampCaveat = leaf.caveats.find( (caveat) => getChecksumAddress(caveat.enforcer).toLowerCase() === targetEnforcer, @@ -163,10 +164,11 @@ export async function resolveGrantedPermissionOnChainStatus( } const chainId = hexToNumber(entry.permissionResponse.chainId); - const contracts = options.contractsByChainId[chainId]; - if (!contracts) { + const deploymentContracts = delegationContractsByChainId[chainId]; + if (!deploymentContracts) { return { ...entry, status: originalStatus }; } + const contracts = toEnforcerAddressesByName(deploymentContracts); const expiry = getExpiryFromDelegation(delegation, contracts); if (expiry === null) { return { ...entry, status: 'Active' }; diff --git a/packages/gator-permissions-controller/src/types.ts b/packages/gator-permissions-controller/src/types.ts index 9f65b2ce0c9..4327656aa5b 100644 --- a/packages/gator-permissions-controller/src/types.ts +++ b/packages/gator-permissions-controller/src/types.ts @@ -185,3 +185,22 @@ export type PendingRevocationParams = { * Permission type identifier: the `type` field of standard ERC-7715 permissions. */ export type SupportedPermissionType = PermissionTypes['type']; + +/** + * Narrower typing for the delegator contract address mapping exported from @metamask/delegation-deployments. + */ +export type DelegationDeploymentsEnforcerAddressesByName = Record< + | 'ERC20StreamingEnforcer' + | 'ERC20PeriodTransferEnforcer' + | 'NativeTokenStreamingEnforcer' + | 'NativeTokenPeriodTransferEnforcer' + | 'ApprovalRevocationEnforcer' + | 'ExactCalldataEnforcer' + | 'ValueLteEnforcer' + | 'TimestampEnforcer' + | 'NonceEnforcer' + | 'AllowedCalldataEnforcer' + | 'AllowedTargetsEnforcer' + | 'RedeemerEnforcer', + Hex +>; diff --git a/packages/gator-permissions-controller/tests/mocks.ts b/packages/gator-permissions-controller/tests/mocks.ts index 7d9f13c4e6d..b42fa49821b 100644 --- a/packages/gator-permissions-controller/tests/mocks.ts +++ b/packages/gator-permissions-controller/tests/mocks.ts @@ -6,7 +6,32 @@ import type { } from '@metamask/7715-permission-types'; import type { Hex } from '@metamask/utils'; -import type { StoredGatorPermission } from '../src/types'; +import type { + DelegationDeploymentsEnforcerAddressesByName, + StoredGatorPermission, +} from '../src/types'; + +/** + * Builds a mock delegation-deployments enforcer address map for unit tests. + * + * @returns Mock enforcer deployment addresses keyed by contract name. + */ +export const buildMockDelegationEnforcerContracts = + (): DelegationDeploymentsEnforcerAddressesByName => ({ + ERC20PeriodTransferEnforcer: '0x1111111111111111111111111111111111111111', + ERC20StreamingEnforcer: '0x2222222222222222222222222222222222222222', + ApprovalRevocationEnforcer: '0x1212121212121212121212121212121212121212', + ExactCalldataEnforcer: '0x3333333333333333333333333333333333333333', + NativeTokenPeriodTransferEnforcer: + '0x4444444444444444444444444444444444444444', + NativeTokenStreamingEnforcer: '0x5555555555555555555555555555555555555555', + TimestampEnforcer: '0x6666666666666666666666666666666666666666', + ValueLteEnforcer: '0x7777777777777777777777777777777777777777', + NonceEnforcer: '0x8888888888888888888888888888888888888888', + AllowedCalldataEnforcer: '0x9999999999999999999999999999999999999999', + AllowedTargetsEnforcer: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + RedeemerEnforcer: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); /** * Mock stored gator permission: native-token-stream (as returned by the Snap). diff --git a/yarn.lock b/yarn.lock index 91265a904d4..c0934bab577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5381,10 +5381,13 @@ __metadata: languageName: node linkType: hard -"@metamask/7715-permission-types@npm:^0.7.1": - version: 0.7.1 - resolution: "@metamask/7715-permission-types@npm:0.7.1" - checksum: 10/2cbe7b8d09ae9b11171d2f9c36bf94f1acea17f9541b399bf99caa14e500c0e91d79e69a4846db2c80b18aa0df0215b36be1ea661cc99f4d6b5bc8d9eea5a4dd +"@metamask/7715-permission-types@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/7715-permission-types@npm:1.0.0" + dependencies: + "@metamask/delegation-core": "npm:^2.2.1" + "@metamask/utils": "npm:^11.4.0" + checksum: 10/df565c43c05daa4ef35b2e57bfdf9062e3e928d8fa43194c27c7149f4e2fdb7bda05b2397267b2f9fed3e42a8c26e956483262b2e8d39e63c2045c069cb53e66 languageName: node linkType: hard @@ -7080,7 +7083,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/7715-permission-types": "npm:^0.7.1" + "@metamask/7715-permission-types": "npm:^1.0.0" "@metamask/abi-utils": "npm:^2.0.3" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0"