diff --git a/packages/gator-permissions-controller/README.md b/packages/gator-permissions-controller/README.md index 973d049fde..364b5481ba 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/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index 22c08aab8f..17edee2916 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 652513bdf9..d1775a7fee 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -30,11 +30,11 @@ 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 { GatorPermissionsFetchError, GatorPermissionsProviderError, diff --git a/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts b/packages/gator-permissions-controller/src/decodePermission/decodePermission.test.ts index 4076225489..c0a1eecb54 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 9d0acec912..0000000000 --- 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 6e1f24a622..0000000000 --- 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 c09e8caa4f..0000000000 --- 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 26e342cb67..0000000000 --- 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 e77c5cd454..0000000000 --- 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 52614cae43..0000000000 --- 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 d23bb16a76..0000000000 --- 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 1021fa6f0e..0000000000 --- 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 19304a8c44..0000000000 --- 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 7547839139..0000000000 --- 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 6966a6694a..0000000000 --- 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 14b7127966..0000000000 --- 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 58d818b8a8..4f3743353f 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 { DeployedContractsByName } 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. @@ -24,15 +18,5 @@ import { makeTokenApprovalRevocationDecoderConfig } from './tokenApprovalRevocat export const createPermissionDecodersForContracts = ( contracts: DeployedContractsByName, ): 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 817b2cf7ef..27872f95c5 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,627 @@ -import { - createAllowedCalldataTerms, - createAllowedTargetsTerms, - createTimestampTerms, -} from '@metamask/delegation-core'; +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 { PermissionType, RuleDecoder } from '../types'; import { getChecksumEnforcersByChainId } from '../utils'; -import { erc20PayeeRule } from './erc20PayeeRule'; -import { expiryRule } from './expiryRule'; import { makePermissionDecoder } from './makePermissionDecoder'; -import { nativePayeeRule } from './nativePayeeRule'; -import { redeemerRule } from './redeemerRule'; + +const randomAddress = () => `0x${randomBytes(20).toString('hex')}` as const; describe('makePermissionDecoder', () => { + const permissionType = 'specified-permission-type' as PermissionType; + 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, + + 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); }); - 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 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)), + ); + }); + + 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 bb15e243f1..9306fbeea1 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 d7e29cef56..0000000000 --- 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 c30279deee..0000000000 --- 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 383428beed..0000000000 --- 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 5683910071..0000000000 --- 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 677f0fdcc9..0000000000 --- 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 23d2d098ad..0000000000 --- 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 28edc4be5b..0000000000 --- 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 b2dbffa8d4..0000000000 --- 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 c397fccf60..0000000000 --- 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 f7f283a895..0000000000 --- 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 0d8b48a99a..0000000000 --- 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 0b4758148d..0000000000 --- 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/index.ts b/packages/gator-permissions-controller/src/decodePermission/index.ts index 696f123f69..03ef6f911c 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 8b3b392582..ad03853bdb 100644 --- a/packages/gator-permissions-controller/src/decodePermission/types.ts +++ b/packages/gator-permissions-controller/src/decodePermission/types.ts @@ -9,12 +9,8 @@ import type { ApprovalRevocationTerms, Caveat, } from '@metamask/delegation-core'; -import type { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { Hex } from '@metamask/utils'; -export type DeployedContractsByName = - (typeof DELEGATOR_CONTRACTS)[number][number]; - /** * Permission type for an unbounded ERC-20 token allowance. * @@ -123,9 +119,6 @@ export type ChecksumEnforcersByChainId = { 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. @@ -173,6 +166,6 @@ export type PermissionDecoder = { */ export type RuleDecoder = (args: { contractAddresses: ChecksumEnforcersByChainId; - caveats: ChecksumCaveat[]; + caveats: Caveat[]; 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 d28bc61de4..eb08db8b21 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.test.ts @@ -1,10 +1,11 @@ +import type { DeployedContractsByName } from '@metamask/7715-permission-types'; import type { Caveat } from '@metamask/delegation-core'; import { getChecksumAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { createPermissionDecodersForContracts } from './decoders'; -import type { DeployedContractsByName } from './types'; import { + extractExpiryFromCaveatTerms, getChecksumEnforcersByChainId, getTermsByEnforcer, splitHex, @@ -97,9 +98,8 @@ describe('createPermissionDecodersForContracts', () => { // native-token-stream // native-token-periodic // native-token-allowance - // erc20-token-revocation // token-approval-revocation - const permissionTypeCount = 8; + const permissionTypeCount = 7; const decoders = createPermissionDecodersForContracts(contracts); expect(decoders).toHaveLength(permissionTypeCount); @@ -275,29 +275,6 @@ describe('createPermissionDecodersForContracts', () => { ]), ); - // 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], - [valueLteEnforcer, 1], - [nonceEnforcer, 1], - ]), - ); - // token-approval-revocation expect(byType['token-approval-revocation']).toBeDefined(); expect(byType['token-approval-revocation'].permissionType).toBe( @@ -431,6 +408,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 a1dac59472..978e339b04 100644 --- a/packages/gator-permissions-controller/src/decodePermission/utils.ts +++ b/packages/gator-permissions-controller/src/decodePermission/utils.ts @@ -1,11 +1,9 @@ import type { Caveat } from '@metamask/delegation-core'; +import type { DeployedContractsByName } from '@metamask/7715-permission-types'; import { getChecksumAddress, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { - ChecksumEnforcersByChainId, - DeployedContractsByName, -} from './types'; +import type { ChecksumEnforcersByChainId } from './types'; /** * The names of the enforcer contracts for each permission type. @@ -25,39 +23,6 @@ const ENFORCER_CONTRACT_NAMES = { 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. diff --git a/packages/gator-permissions-controller/src/permissionOnChainStatus.ts b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts index 6ac35cfde3..6e718cfcda 100644 --- a/packages/gator-permissions-controller/src/permissionOnChainStatus.ts +++ b/packages/gator-permissions-controller/src/permissionOnChainStatus.ts @@ -1,10 +1,10 @@ +import type { DeployedContractsByName } 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,