From b2107ce2cf45860115f65fafc02e741d5579316f Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:18:30 +1300 Subject: [PATCH 1/3] Allow mixed case hex when validating erc20 token addresses --- .../rules/erc20TokenPeriodic.test.ts | 33 ++++++++++++++++++ .../rules/erc20TokenPeriodic.ts | 4 +-- .../rules/erc20TokenStream.test.ts | 34 +++++++++++++++++++ .../rules/erc20TokenStream.ts | 4 +-- 4 files changed, 71 insertions(+), 4 deletions(-) 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..3708f7841fc 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 as Hex; + 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'); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.ts index 7c6131ed95c..225d34671f7 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, isHexChecksumAddress } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -89,7 +89,7 @@ function validateAndDecodeData( const periodAmountBigInt = hexToBigInt(periodAmount); const startTime = hexToNumber(startTimeRaw); - if (!isHexAddress(tokenAddress)) { + if (!isHexChecksumAddress(tokenAddress)) { throw new Error( 'Invalid erc20-token-periodic terms: tokenAddress must be a valid hex string', ); 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..b9f37bf7619 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 as Hex; + 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 = [ diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.ts index fb167f71caf..813de216412 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, isHexChecksumAddress } from '@metamask/utils'; import { makePermissionRule } from './makePermissionRule'; import type { @@ -96,7 +96,7 @@ function validateAndDecodeData( const maxAmountBigInt = hexToBigInt(maxAmount); const amountPerSecondBigInt = hexToBigInt(amountPerSecond); - if (!isHexAddress(tokenAddress)) { + if (!isHexChecksumAddress(tokenAddress)) { throw new Error( 'Invalid erc20-token-stream terms: tokenAddress must be a valid hex string', ); From 2383c3ad99bd0716366c9c9401f84aea628f79ac Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:37:49 +1300 Subject: [PATCH 2/3] Actually, we don't need to validate that the address is Hex, we just validate that the terms are Hex --- .../rules/erc20TokenPeriodic.test.ts | 33 +--- .../rules/erc20TokenPeriodic.ts | 8 +- .../rules/erc20TokenStream.test.ts | 30 +--- .../rules/erc20TokenStream.ts | 8 +- .../rules/makePermissionRule.test.ts | 144 ++++++++++++++++++ .../rules/makePermissionRule.ts | 10 +- 6 files changed, 157 insertions(+), 76 deletions(-) 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 3708f7841fc..a71d18d21af 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenPeriodic.test.ts @@ -171,7 +171,7 @@ describe('erc20-token-periodic rule', () => { }); it('decodes mixed-case token address', () => { - const mixedCaseAddress = contracts.ERC20PeriodTransferEnforcer as Hex; + const mixedCaseAddress = contracts.ERC20PeriodTransferEnforcer; const caveats = [ expiryCaveat, valueLteCaveat, @@ -327,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 225d34671f7..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, isHexChecksumAddress } 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 (!isHexChecksumAddress(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 b9f37bf7619..a6f22378266 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/erc20TokenStream.test.ts @@ -131,7 +131,7 @@ describe('erc20-token-stream rule', () => { }); it('decodes mixed-case token address', () => { - const mixedCaseAddress = contracts.ERC20StreamingEnforcer as Hex; + const mixedCaseAddress = contracts.ERC20StreamingEnforcer; const caveats = [ expiryCaveat, valueLteCaveat, @@ -333,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 813de216412..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, isHexChecksumAddress } 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 (!isHexChecksumAddress(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..5abf62c1781 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,148 @@ 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(); + }); }); diff --git a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts index fb7c48485ed..519b2150d27 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,14 @@ export function makePermissionRule({ enforcer: getChecksumAddress(caveat.enforcer), })); try { + const invalidTerms = checksumCaveats.filter( + ({ terms }) => !isStrictHexString(terms), + ); + + if (invalidTerms.length > 0) { + throw new Error('Invalid terms: must be a hex string'); + } + let expiry: number | null = null; const expiryTerms = getTermsByEnforcer({ From 8e8f42086516075aa5c951eff363debcf591e67a Mon Sep 17 00:00:00 2001 From: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:00:04 +1300 Subject: [PATCH 3/3] Allow empty hex string as terms - add test coverage of the above --- .../rules/makePermissionRule.test.ts | 28 +++++++++++++++++++ .../rules/makePermissionRule.ts | 3 +- 2 files changed, 30 insertions(+), 1 deletion(-) 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 5abf62c1781..be7128a161e 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.test.ts @@ -193,4 +193,32 @@ describe('makePermissionRule', () => { 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 519b2150d27..d7cdf8b8694 100644 --- a/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts +++ b/packages/gator-permissions-controller/src/decodePermission/rules/makePermissionRule.ts @@ -72,7 +72,8 @@ export function makePermissionRule({ })); try { const invalidTerms = checksumCaveats.filter( - ({ terms }) => !isStrictHexString(terms), + // isStrictHexString rejects '0x' which is a valid terms value + ({ terms }) => terms !== '0x' && !isStrictHexString(terms), ); if (invalidTerms.length > 0) {