diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts index 5b608be4d73..a71d18d21af 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -170,6 +170,39 @@ describe('erc20-token-periodic rule', () => { 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 = rule.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'); @@ -294,35 +327,4 @@ describe('erc20-token-periodic rule', () => { 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', ); }); - - it('rejects when tokenAddress is not valid hex (invalid characters)', () => { - const invalidTokenAddress = 'gg'; - const periodAmountHex = 100n.toString(16).padStart(64, '0'); - const periodDurationHex = (86400).toString(16).padStart(64, '0'); - const startDateHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${invalidTokenAddress}${'0'.repeat(38)}${periodAmountHex}${periodDurationHex}${startDateHex}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { - enforcer: ERC20PeriodTransferEnforcer, - terms, - args: '0x' as const, - }, - ]; - - const result = rule.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: tokenAddress must be a valid hex string', - ); - }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index 7c6131ed95c..3f8ccf6b49f 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts @@ -1,4 +1,4 @@ -import { hexToBigInt, hexToNumber, isHexAddress } from '@metamask/utils'; +import { hexToBigInt, hexToNumber } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -89,12 +89,6 @@ function validateAndDecodeData( const periodAmountBigInt = hexToBigInt(periodAmount); const startTime = hexToNumber(startTimeRaw); - if (!isHexAddress(tokenAddress)) { - throw new Error( - 'Invalid erc20-token-periodic terms: tokenAddress must be a valid hex string', - ); - } - if (periodAmountBigInt === 0n) { throw new Error( 'Invalid erc20-token-periodic terms: periodAmount must be a positive number', diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts index 1ce4dabd9fa..a6f22378266 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -130,6 +130,40 @@ describe('erc20-token-stream rule', () => { 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 = rule.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 = [ @@ -299,32 +333,4 @@ describe('erc20-token-stream rule', () => { 'Invalid erc20-token-stream terms: startTime must be a positive number', ); }); - - it('rejects when tokenAddress is not valid hex (invalid characters)', () => { - const invalidTokenAddress = 'gg'; - 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 startTimeHex = (1715664).toString(16).padStart(64, '0'); - const terms = - `0x${invalidTokenAddress}${'0'.repeat(38)}${initialAmountHex}${maxAmountHex}${amountPerSecondHex}${startTimeHex}` as Hex; - - const caveats = [ - expiryCaveat, - valueLteCaveat, - { enforcer: ERC20StreamingEnforcer, terms, args: '0x' as const }, - ]; - - const result = rule.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: tokenAddress must be a valid hex string', - ); - }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index fb167f71caf..344328fb57b 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts @@ -1,4 +1,4 @@ -import { hexToBigInt, hexToNumber, isHexAddress } from '@metamask/utils'; +import { hexToBigInt, hexToNumber } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -96,12 +96,6 @@ function validateAndDecodeData( const maxAmountBigInt = hexToBigInt(maxAmount); const amountPerSecondBigInt = hexToBigInt(amountPerSecond); - if (!isHexAddress(tokenAddress)) { - throw new Error( - 'Invalid erc20-token-stream terms: tokenAddress must be a valid hex string', - ); - } - if (maxAmountBigInt <= initialAmountBigInt) { throw new Error( 'Invalid erc20-token-stream terms: maxAmount must be greater than initialAmount', diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts index 22fc901bc01..be7128a161e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts @@ -49,4 +49,176 @@ describe('makePermissionRule', () => { expect(result.data).toStrictEqual({}); expect(validateAndDecodeData).toHaveBeenCalled(); }); + + it('rejects when any caveat terms are not valid hex (invalid characters)', () => { + const validateAndDecodeData = jest.fn().mockReturnValue({}); + + const rule = makePermissionRule({ + permissionType: 'native-token-stream', + timestampEnforcer, + optionalEnforcers: [], + requiredEnforcers: { [requiredEnforcer]: 1 }, + validateAndDecodeData, + }); + + const caveats = [ + { + enforcer: timestampEnforcer, + terms: '0xgg' as Hex, + args: '0x' as Hex, + }, + { + enforcer: requiredEnforcer, + terms: '0x' as Hex, + args: '0x' as Hex, + }, + ]; + + const result = rule.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('rejects when any caveat terms contain non-hex characters after 0x prefix', () => { + const validateAndDecodeData = jest.fn().mockReturnValue({}); + + const rule = makePermissionRule({ + permissionType: 'native-token-stream', + timestampEnforcer, + optionalEnforcers: [], + requiredEnforcers: { [requiredEnforcer]: 1 }, + validateAndDecodeData, + }); + + const caveats = [ + { + enforcer: timestampEnforcer, + terms: + '0x000000000000000000000000000000000000000000000000000000000000000z' as Hex, + args: '0x' as Hex, + }, + { + enforcer: requiredEnforcer, + terms: '0x' as Hex, + args: '0x' as Hex, + }, + ]; + + const result = rule.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('rejects when required enforcer terms are not valid hex', () => { + const validateAndDecodeData = jest.fn().mockReturnValue({}); + + const rule = makePermissionRule({ + permissionType: 'native-token-stream', + timestampEnforcer, + optionalEnforcers: [], + requiredEnforcers: { [requiredEnforcer]: 1 }, + validateAndDecodeData, + }); + + const caveats = [ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as Hex, + }, + { + enforcer: requiredEnforcer, + terms: '0xNOTHEX' as Hex, + args: '0x' as Hex, + }, + ]; + + const result = rule.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('accepts caveat terms with mixed-case hex', () => { + const validateAndDecodeData = jest.fn().mockReturnValue({}); + + const rule = makePermissionRule({ + permissionType: 'native-token-stream', + timestampEnforcer, + optionalEnforcers: [], + requiredEnforcers: { [requiredEnforcer]: 1 }, + validateAndDecodeData, + }); + + const caveats = [ + { + enforcer: timestampEnforcer, + terms: createTimestampTerms({ + timestampAfterThreshold: 0, + timestampBeforeThreshold: 1720000, + }), + args: '0x' as Hex, + }, + { + enforcer: requiredEnforcer, + terms: + '0x000000000000000000000000000000000000000000000000000000000000abAB' as Hex, + args: '0x' as Hex, + }, + ]; + + const result = rule.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('accepts caveat terms with empty hex', () => { + const validateAndDecodeData = jest.fn().mockReturnValue({}); + + const rule = makePermissionRule({ + permissionType: 'native-token-stream', + timestampEnforcer, + optionalEnforcers: [], + requiredEnforcers: { [requiredEnforcer]: 1 }, + validateAndDecodeData, + }); + + const caveats = [ + { + enforcer: requiredEnforcer, + terms: '0x' as Hex, + args: '0x' as Hex, + }, + ]; + + const result = rule.validateAndDecodePermission(caveats); + + expect(result.isValid).toBe(true); + if (!result.isValid) { + throw new Error('Expected valid result'); + } + expect(validateAndDecodeData).toHaveBeenCalled(); + }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts index fb7c48485ed..d7cdf8b8694 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts @@ -1,5 +1,5 @@ import type { Caveat } from '@metamask/delegation-core'; -import { getChecksumAddress } from '@metamask/utils'; +import { getChecksumAddress, isStrictHexString } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { @@ -71,6 +71,15 @@ export function makePermissionRule({ enforcer: getChecksumAddress(caveat.enforcer), })); try { + const invalidTerms = checksumCaveats.filter( + // isStrictHexString rejects '0x' which is a valid terms value + ({ terms }) => terms !== '0x' && !isStrictHexString(terms), + ); + + if (invalidTerms.length > 0) { + throw new Error('Invalid terms: must be a hex string'); + } + let expiry: number | null = null; const expiryTerms = getTermsByEnforcer({